Use CSS transformations instead of manipulating the viewBox

This commit is contained in:
2025-06-16 23:25:25 -07:00
parent 9f82c19ed4
commit e0fbba8fee
11 changed files with 273 additions and 195 deletions

View File

@@ -1,18 +1,31 @@
import zoom from './modules/zoom.js';
import pan from './modules/pan.js';
const optionalZoomFactor = 0.1,
container = document.querySelector('.container'),
object = document.querySelector('object'),
img = document.querySelector('img'),
button = document.querySelector('button');
function reset(elements) {
elements.forEach(el => el.removeAttribute('style'));
}
// If embedding an SVG using an <object> tag, it's necessary to wait until the
// page has loaded before querying its `contentDocument`, otherwise it will be
// `null`.
window.addEventListener('load', function () {
const svg = document.querySelector('object').contentDocument.querySelector('svg');
const svg = object.contentDocument.querySelector('svg'),
pannableAndZoomableElements = [img, svg];
svg.addEventListener('wheel', e => {
e.preventDefault();
button.addEventListener('click', () => {
[button, container].forEach(el => el.classList.toggle('switch'));
reset(pannableAndZoomableElements);
});
svg.setAttributeNS(null, 'viewBox', zoom(svg, e));
}, { passive: false });
svg.addEventListener('pointerdown', e => {
e.preventDefault();
pan(svg, e);
}, { passive: false });
pannableAndZoomableElements.forEach(el => {
el.addEventListener('wheel', e => zoom(el, e, optionalZoomFactor), { passive: false });
el.addEventListener('pointerdown', e => pan(el, e), { passive: false });
});
});

View File

@@ -1,5 +1,14 @@
import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5;
function setToCurrentPointerCoords(point, e) {
point.x = e.clientX;
point.y = e.clientY;
return point;
}
function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
@@ -8,46 +17,21 @@ function minDistanceThresholdIsMet(startPt, endPt) {
return distanceBetween(startPt, endPt) >= minDistanceThreshold;
}
function getPositionChangeInLocalCoords(svg, startPt, endPt) {
const matrix = svg.getScreenCTM().inverse(),
localStartPt = startPt.matrixTransform(matrix),
localEndPt = endPt.matrixTransform(matrix);
return {
x: localStartPt.x - localEndPt.x,
y: localStartPt.y - localEndPt.y
};
function stopEventPropagationToChildren(el, type) {
el.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true });
}
function stopEventPropagationToChildren(svg, type) {
svg.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true });
function getTranslateMatrix(startPt, movePt) {
const translateMatrix = new DOMMatrix();
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
}
function setToCurrentPointerCoords(point, e) {
point.x = e.clientX;
point.y = e.clientY;
export default function (el, e) {
e.preventDefault();
return point;
}
function getPanCoords(svg, startPt, movePt, initialPos) {
const posChange = getPositionChangeInLocalCoords(svg, startPt, movePt);
return {
x: initialPos.x + posChange.x,
y: initialPos.y + posChange.y
};
}
function setViewBoxPosition(svg, { x, y }) {
const { width, height } = svg.viewBox.baseVal;
svg.setAttributeNS(null, 'viewBox', `${x} ${y} ${width} ${height}`);
}
export default function (svg, e) {
const { x, y } = svg.viewBox.baseVal,
startPt = setToCurrentPointerCoords(new DOMPoint(), e),
const mtx = getComputedTransformMatrix(el),
startPt = new DOMPoint(e.clientX, e.clientY),
movePt = new DOMPoint();
let isPanning = false;
@@ -59,19 +43,19 @@ export default function (svg, e) {
isPanning = true;
e.target.setPointerCapture(e.pointerId);
setToCurrentPointerCoords(startPt, e);
stopEventPropagationToChildren(svg, 'click');
stopEventPropagationToChildren(el, 'click');
}
if (isPanning) {
setViewBoxPosition(svg, getPanCoords(svg, startPt, movePt, { x, y }));
el.style.transform = getTranslateMatrix(startPt, movePt).multiply(mtx);
}
}
svg.addEventListener('pointermove', pointerMove);
el.addEventListener('pointermove', pointerMove);
svg.addEventListener(
el.addEventListener(
'pointerup',
() => svg.removeEventListener('pointermove', pointerMove),
() => el.removeEventListener('pointermove', pointerMove),
{ once: true }
);
}

8
src/modules/utils.js Normal file
View File

@@ -0,0 +1,8 @@
const digits = /-?\d+\.?\d*/g;
export default function getComputedTransformMatrix(el) {
const matrixSequence = getComputedStyle(el).transform.match(digits),
identityMatrix = '';
return new DOMMatrix(matrixSequence || identityMatrix);
}

View File

@@ -1,57 +1,56 @@
const zoomStepRatio = 0.25,
positive = 1,
negative = -1;
function toLocalCoords(svg, x, y) {
const clientP = new DOMPoint(x, y);
return clientP.matrixTransform(svg.getScreenCTM().inverse());
}
import getComputedTransformMatrix from './utils.js';
function zoomIn(deltaY) {
return deltaY < 0;
}
function calcSizeChangeAmounts(width, height) {
function getScale(e, factor) {
return zoomIn(e.deltaY) ? 1 + factor : 1 - factor;
}
function getFocalPointBeforeTransform(el, e) {
const { x, y, width, height } = el.getBoundingClientRect();
return {
width: width * zoomStepRatio,
height: height * zoomStepRatio
x: e.clientX,
y: e.clientY,
relativeToImageSize: {
x: (e.clientX - x) / width,
y: (e.clientY - y) / height
}
};
}
function calcValChangeRatios(focusPoint, x, y, width, height) {
function getFocalPointAfterTransform(el, fpBeforeTrans) {
const { x, y, width, height } = el.getBoundingClientRect(),
relativeFocalPoint = fpBeforeTrans.relativeToImageSize;
return {
x: (focusPoint.x - x) / width,
y: (focusPoint.y - y) / height,
width: (width + x - focusPoint.x) / width,
height: (height + y - focusPoint.y) / height
x: x + width * relativeFocalPoint.x,
y: y + height * relativeFocalPoint.y
};
}
function calcValChangeAmounts(focusPoint, x, y, width, height) {
const changeAmount = calcSizeChangeAmounts(width, height),
valChangeRatio = calcValChangeRatios(focusPoint, x, y, width, height);
function getTranslateMatrix(el, e, scaleMatrix) {
const fpBeforeTrans = getFocalPointBeforeTransform(el, e);
return {
x: valChangeRatio.x * changeAmount.width,
y: valChangeRatio.y * changeAmount.height,
width: valChangeRatio.width * changeAmount.width,
height: valChangeRatio.height * changeAmount.height
};
el.style.transform = scaleMatrix;
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans),
translateMatrix = new DOMMatrix();
return translateMatrix.translate(
fpBeforeTrans.x - fpAfterTrans.x,
fpBeforeTrans.y - fpAfterTrans.y
);
}
export default function (svg, e) {
const pointerPosition = toLocalCoords(svg, e.clientX, e.clientY),
sign = zoomIn(e.deltaY) ? positive : negative,
{ x, y, width, height } = svg.viewBox.baseVal,
changeAmount = calcValChangeAmounts(pointerPosition, x, y, width, height),
export default function (el, e, factor = 0.1) {
e.preventDefault();
attr = {
x: x + sign * changeAmount.x,
y: y + sign * changeAmount.y,
width: width + sign * (-changeAmount.x - changeAmount.width),
height: height + sign * (-changeAmount.y - changeAmount.height)
};
const mtx = getComputedTransformMatrix(el),
scale = getScale(e, factor),
transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
return `${attr.x} ${attr.y} ${attr.width} ${attr.height}`;
el.style.transform = transMtx.multiply(mtx).scale(scale);
}