Use CSS transformations instead of manipulating the viewBox

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

View File

@ -1,9 +1,15 @@
## Install dev server packages # Pan-Zoom
Pan/zoom library for web browsers. Hold and drag an element to pan. Use the mouse wheel to zoom. See `src/app.js` for a usage example.
## To view the demo using Docker
1. Install the development server packages.
docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install
## Start the dev server 2. Start the server.
docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js
Visit `localhost:8080` to view. 3. Visit `localhost:8080` to view.

View File

@ -1,7 +1,12 @@
{ {
"name": "svg-pan-zoom", "name": "pan-zoom",
"version": "0.1.0",
"browser": "index.js",
"devDependencies": { "devDependencies": {
"esbuild": "^0.20.2", "esbuild": "^0.20.2",
"esbuild-server": "^0.3.0" "esbuild-server": "^0.3.0"
} },
"files": [
"./src/modules"
]
} }

View File

@ -0,0 +1,40 @@
body {
text-align: center;
max-width: 100vw;
}
.container {
padding: 0;
max-width: 586.033px;
max-height: 586.033px;
margin: 0 auto;
overflow: hidden;
border: 1px solid steelblue;
background-color: gray;
}
img, object {
touch-action: none;
}
img {
max-width: 100%;
border: 1px solid silver;
transform: scale(0.9);
}
.container object, .container.switch img {
display: block;
}
.container img, .container.switch object {
display: none;
}
button .button-text.raster, button.switch .button-text.svg {
display: none;
}
button.switch .button-text.raster {
display: inline;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

View File

@ -0,0 +1,108 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg viewBox="-200 -150 400 300" version="1.1" xmlns="http://www.w3.org/2000/svg">
<style>
svg {
overflow: hidden;
border: 1px solid silver;
transform: scale(0.9);
}
circle, rect {
fill-opacity: 0.9;
filter: drop-shadow(5px 5px 2px rgba(0, 0, 0, .5));
}
</style>
<script type="text/javascript">//<![CDATA[
const svgns = 'http://www.w3.org/2000/svg',
svg = document.querySelector('svg'),
{ x: vbX, y: vbY, width: vbWidth, height: vbHeight } = svg.viewBox.baseVal,
shapeCount = 100,
circleRadius = { min: 5, max: 45 },
rectSideLength = { min: 5, max: 95 },
colorValRange = { min: 0, max: 255 },
shadeFactorRange = { min: 0.3, max: 0.7 };
// source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#getting_a_random_integer_between_two_values_inclusive
function getRandomIntInclusive(min, max) {
const minCeiled = Math.ceil(min),
maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled + 1) + minCeiled);
}
// source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#getting_a_random_number_between_two_values
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
function getRandomColorValue() {
return getRandomIntInclusive(colorValRange.min, colorValRange.max);
}
function getRandomShadeFactor() {
return getRandomArbitrary(shadeFactorRange.min, shadeFactorRange.max);
}
function getRandomOrigin() {
return {
x: getRandomIntInclusive(vbX, vbX + vbWidth),
y: getRandomIntInclusive(vbY, vbY + vbHeight)
}
}
function getRandomFillAndStrokeVals() {
const fill = ['r', 'g', 'b'].map(() => getRandomColorValue()),
stroke = fill.map(v => Math.floor(v * getRandomShadeFactor()));
return {
fill: fill,
stroke: stroke
};
}
function getRandomCircle(fill, stroke) {
const el = document.createElementNS(svgns, 'circle'),
r = getRandomIntInclusive(circleRadius.max, circleRadius.min),
origin = getRandomOrigin();
el.setAttributeNS(null, 'cx', origin.x);
el.setAttributeNS(null, 'cy', origin.y);
el.setAttributeNS(null, 'r', r);
el.setAttributeNS(null, 'fill', fill);
el.setAttributeNS(null, 'stroke', stroke);
return el;
}
function getRandomRect(fill, stroke) {
const el = document.createElementNS(svgns, 'rect'),
[width, height] = ['w', 'h'].map(() =>
getRandomIntInclusive(rectSideLength.max, rectSideLength.min)
),
origin = getRandomOrigin();
el.setAttributeNS(null, 'x', origin.x);
el.setAttributeNS(null, 'y', origin.y);
el.setAttributeNS(null, 'width', width);
el.setAttributeNS(null, 'height', height);
el.setAttributeNS(null, 'fill', fill);
el.setAttributeNS(null, 'stroke', stroke);
return el;
}
function getRandomShape({ fill: fillVals, stroke: strokeVals }) {
const shape = [getRandomCircle, getRandomRect][Math.round(Math.random())],
[fill, stroke] = [fillVals, strokeVals].map(v => `rgb(${v.join(', ')})`);
return shape(fill, stroke);
}
[...Array(shapeCount)]
.map(() => getRandomFillAndStrokeVals())
.forEach(fillAndStrokeVal => svg.appendChild(getRandomShape(fillAndStrokeVal)));
//]]></script>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -1,82 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg viewBox="0 0 400 300" version="1.1" xmlns="http://www.w3.org/2000/svg">
<style>
svg {
overflow: hidden;
}
circle, rect {
fill-opacity: 0.9;
}
</style>
<script type="text/javascript">//<![CDATA[
const svgns = 'http://www.w3.org/2000/svg',
svg = document.querySelector('svg'),
{ width: vbWidth, height: vbHeight } = svg.viewBox.baseVal,
shapeCount = 100,
maxColorValue = 256,
circleRadius = { max: 45, min: 5 },
rectSideLength = { max: 95, min: 5 };
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
function getRandomPositiveInt(max) {
return getRandomInt(max) + 1;
}
function getRandomFillAndStrokeVals() {
const shadeFactor = Math.random(),
fill = ['r', 'g', 'b'].map(() => getRandomInt(maxColorValue)),
stroke = fill.map(v => Math.floor(v * shadeFactor));
return {
fill: fill,
stroke: stroke
};
}
function getRandomCircle(fill, stroke) {
const el = document.createElementNS(svgns, 'circle'),
r = getRandomPositiveInt(circleRadius.max) + circleRadius.min;
el.setAttributeNS(null, 'cx', getRandomInt(vbWidth));
el.setAttributeNS(null, 'cy', getRandomInt(vbHeight));
el.setAttributeNS(null, 'r', r);
el.setAttributeNS(null, 'fill', fill);
el.setAttributeNS(null, 'stroke', stroke);
return el;
}
function getRandomRect(fill, stroke) {
const el = document.createElementNS(svgns, 'rect'),
[width, height] = ['w', 'h'].map(() =>
getRandomPositiveInt(rectSideLength.max) + rectSideLength.min
);
el.setAttributeNS(null, 'x', getRandomInt(vbWidth));
el.setAttributeNS(null, 'y', getRandomInt(vbHeight));
el.setAttributeNS(null, 'width', width);
el.setAttributeNS(null, 'height', height);
el.setAttributeNS(null, 'fill', fill);
el.setAttributeNS(null, 'stroke', stroke);
return el;
}
function getRandomShape({ fill: fillVals, stroke: strokeVals }) {
const shapes = [getRandomCircle, getRandomRect],
[fill, stroke] = [fillVals, strokeVals].map(v => `rgb(${v.join(', ')})`);
return shapes[getRandomInt(shapes.length)](fill, stroke);
}
[...Array(shapeCount)]
.map(() => getRandomFillAndStrokeVals())
.forEach(fillAndStrokeVal => svg.appendChild(getRandomShape(fillAndStrokeVal)));
//]]></script>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -3,31 +3,28 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>SVG Pan & Zoom Example</title> <title>JavaScript/CSS Pan & Zoom Demo</title>
<style> <link rel="stylesheet" href="assets/css/style.css">
body {
text-align: center;
max-width: 100vw;
}
object {
max-height: 400px;
max-width: 100%;
background-color: lightsteelblue;
border: 1px solid steelblue;
touch-action: none;
}
</style>
</head> </head>
<body> <body>
<h1>Pan & Zoom an SVG Image with JavaScript</h1> <h1>Pan & Zoom an Element with CSS/JavaScript</h1>
<p> <p>
Click and drag on the image to pan. Use the mouse wheel Click and drag on the image to pan. Use the mouse wheel
to zoom in and out. to zoom in and out.
</p> </p>
<object type="image/svg+xml" data="image.svg"></object> <p>
<button>
<span class="button-text svg">Raster image</span>
<span class="button-text raster">SVG image</span>
</button>
</p>
<div class="container">
<object type="image/svg+xml" data="assets/images/image.svg"></object>
<img src="assets/images/41156165560-4438592e93-o.webp"/>
</div>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -1,18 +1,31 @@
import zoom from './modules/zoom.js'; import zoom from './modules/zoom.js';
import pan from './modules/pan.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 () { 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 => { button.addEventListener('click', () => {
e.preventDefault(); [button, container].forEach(el => el.classList.toggle('switch'));
reset(pannableAndZoomableElements);
});
svg.setAttributeNS(null, 'viewBox', zoom(svg, e)); pannableAndZoomableElements.forEach(el => {
}, { passive: false }); el.addEventListener('wheel', e => zoom(el, e, optionalZoomFactor), { passive: false });
el.addEventListener('pointerdown', e => pan(el, e), { passive: false });
svg.addEventListener('pointerdown', e => { });
e.preventDefault();
pan(svg, e);
}, { passive: false });
}); });

View File

@ -1,5 +1,14 @@
import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5; 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 }) { function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
} }
@ -8,46 +17,21 @@ function minDistanceThresholdIsMet(startPt, endPt) {
return distanceBetween(startPt, endPt) >= minDistanceThreshold; return distanceBetween(startPt, endPt) >= minDistanceThreshold;
} }
function getPositionChangeInLocalCoords(svg, startPt, endPt) { function stopEventPropagationToChildren(el, type) {
const matrix = svg.getScreenCTM().inverse(), el.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true });
localStartPt = startPt.matrixTransform(matrix),
localEndPt = endPt.matrixTransform(matrix);
return {
x: localStartPt.x - localEndPt.x,
y: localStartPt.y - localEndPt.y
};
} }
function stopEventPropagationToChildren(svg, type) { function getTranslateMatrix(startPt, movePt) {
svg.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true }); const translateMatrix = new DOMMatrix();
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
} }
function setToCurrentPointerCoords(point, e) { export default function (el, e) {
point.x = e.clientX; e.preventDefault();
point.y = e.clientY;
return point; const mtx = getComputedTransformMatrix(el),
} startPt = new DOMPoint(e.clientX, e.clientY),
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),
movePt = new DOMPoint(); movePt = new DOMPoint();
let isPanning = false; let isPanning = false;
@ -59,19 +43,19 @@ export default function (svg, e) {
isPanning = true; isPanning = true;
e.target.setPointerCapture(e.pointerId); e.target.setPointerCapture(e.pointerId);
setToCurrentPointerCoords(startPt, e); setToCurrentPointerCoords(startPt, e);
stopEventPropagationToChildren(svg, 'click'); stopEventPropagationToChildren(el, 'click');
} }
if (isPanning) { 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', 'pointerup',
() => svg.removeEventListener('pointermove', pointerMove), () => el.removeEventListener('pointermove', pointerMove),
{ once: true } { 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, import getComputedTransformMatrix from './utils.js';
positive = 1,
negative = -1;
function toLocalCoords(svg, x, y) {
const clientP = new DOMPoint(x, y);
return clientP.matrixTransform(svg.getScreenCTM().inverse());
}
function zoomIn(deltaY) { function zoomIn(deltaY) {
return deltaY < 0; return deltaY < 0;
} }
function calcSizeChangeAmounts(width, height) { function getScale(e, factor) {
return { return zoomIn(e.deltaY) ? 1 + factor : 1 - factor;
width: width * zoomStepRatio,
height: height * zoomStepRatio
};
} }
function calcValChangeRatios(focusPoint, x, y, width, height) { function getFocalPointBeforeTransform(el, e) {
return { const { x, y, width, height } = el.getBoundingClientRect();
x: (focusPoint.x - x) / width,
y: (focusPoint.y - y) / height,
width: (width + x - focusPoint.x) / width,
height: (height + y - focusPoint.y) / height
};
}
function calcValChangeAmounts(focusPoint, x, y, width, height) {
const changeAmount = calcSizeChangeAmounts(width, height),
valChangeRatio = calcValChangeRatios(focusPoint, x, y, width, height);
return { return {
x: valChangeRatio.x * changeAmount.width, x: e.clientX,
y: valChangeRatio.y * changeAmount.height, y: e.clientY,
width: valChangeRatio.width * changeAmount.width, relativeToImageSize: {
height: valChangeRatio.height * changeAmount.height x: (e.clientX - x) / width,
y: (e.clientY - y) / height
}
}; };
} }
export default function (svg, e) { function getFocalPointAfterTransform(el, fpBeforeTrans) {
const pointerPosition = toLocalCoords(svg, e.clientX, e.clientY), const { x, y, width, height } = el.getBoundingClientRect(),
sign = zoomIn(e.deltaY) ? positive : negative, relativeFocalPoint = fpBeforeTrans.relativeToImageSize;
{ x, y, width, height } = svg.viewBox.baseVal,
changeAmount = calcValChangeAmounts(pointerPosition, x, y, width, height),
attr = { return {
x: x + sign * changeAmount.x, x: x + width * relativeFocalPoint.x,
y: y + sign * changeAmount.y, y: y + height * relativeFocalPoint.y
width: width + sign * (-changeAmount.x - changeAmount.width),
height: height + sign * (-changeAmount.y - changeAmount.height)
}; };
}
return `${attr.x} ${attr.y} ${attr.width} ${attr.height}`;
function getTranslateMatrix(el, e, scaleMatrix) {
const fpBeforeTrans = getFocalPointBeforeTransform(el, e);
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 (el, e, factor = 0.1) {
e.preventDefault();
const mtx = getComputedTransformMatrix(el),
scale = getScale(e, factor),
transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
el.style.transform = transMtx.multiply(mtx).scale(scale);
} }