Web Dev Solutions

Catalin Mititiuc

From 2d3fc1cd22ffcc61ec178eeaf97f3a4d7cba98bf Mon Sep 17 00:00:00 2001 From: Catalin Mititiuc Date: Sat, 20 Apr 2024 19:34:06 -0700 Subject: Use CSS transformations instead of manipulating the viewBox --- README.md | 16 ++- package.json | 9 +- public/assets/css/style.css | 40 ++++++++ public/assets/images/41156165560-4438592e93-o.webp | Bin 0 -> 585068 bytes public/assets/images/image.svg | 108 +++++++++++++++++++++ public/image.svg | 82 ---------------- public/index.html | 31 +++--- src/app.js | 33 +++++-- src/modules/pan.js | 62 +++++------- src/modules/utils.js | 8 ++ src/modules/zoom.js | 75 +++++++------- 11 files changed, 271 insertions(+), 193 deletions(-) create mode 100644 public/assets/css/style.css create mode 100644 public/assets/images/41156165560-4438592e93-o.webp create mode 100644 public/assets/images/image.svg delete mode 100644 public/image.svg create mode 100644 src/modules/utils.js diff --git a/README.md b/README.md index 0ca567d..4d96e8f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ -## Install dev server packages +# Pan-Zoom - docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install +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. -## Start the dev server +## To view the demo using Docker - docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js +1. Install the development server packages. -Visit `localhost:8080` to view. + docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install + +2. Start the server. + + docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js + +3. Visit `localhost:8080` to view. diff --git a/package.json b/package.json index fb86df3..ccfb11c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,12 @@ { - "name": "svg-pan-zoom", + "name": "pan-zoom", + "version": "0.1.0", + "browser": "index.js", "devDependencies": { "esbuild": "^0.20.2", "esbuild-server": "^0.3.0" - } + }, + "files": [ + "./src/modules" + ] } diff --git a/public/assets/css/style.css b/public/assets/css/style.css new file mode 100644 index 0000000..db47790 --- /dev/null +++ b/public/assets/css/style.css @@ -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; +} diff --git a/public/assets/images/41156165560-4438592e93-o.webp b/public/assets/images/41156165560-4438592e93-o.webp new file mode 100644 index 0000000..2ad3fa4 Binary files /dev/null and b/public/assets/images/41156165560-4438592e93-o.webp differ diff --git a/public/assets/images/image.svg b/public/assets/images/image.svg new file mode 100644 index 0000000..29f9306 --- /dev/null +++ b/public/assets/images/image.svg @@ -0,0 +1,108 @@ + + + + + + diff --git a/public/image.svg b/public/image.svg deleted file mode 100644 index 5a5c3d9..0000000 --- a/public/image.svg +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - diff --git a/public/index.html b/public/index.html index 8040421..35a3030 100644 --- a/public/index.html +++ b/public/index.html @@ -3,31 +3,28 @@ - SVG Pan & Zoom Example - + JavaScript/CSS Pan & Zoom Demo + -

Pan & Zoom an SVG Image with JavaScript

+

Pan & Zoom an Element with CSS/JavaScript

Click and drag on the image to pan. Use the mouse wheel to zoom in and out.

- +

+ +

+ +
+ + +
diff --git a/src/app.js b/src/app.js index f5a4fa8..3afc329 100644 --- a/src/app.js +++ b/src/app.js @@ -1,18 +1,31 @@ import zoom from './modules/zoom.js'; import pan from './modules/pan.js'; -window.addEventListener('load', function () { - const svg = document.querySelector('object').contentDocument.querySelector('svg'); +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')); +} - svg.addEventListener('wheel', e => { - e.preventDefault(); +// If embedding an SVG using an tag, it's necessary to wait until the +// page has loaded before querying its `contentDocument`, otherwise it will be +// `null`. - svg.setAttributeNS(null, 'viewBox', zoom(svg, e)); - }, { passive: false }); +window.addEventListener('load', function () { + const svg = object.contentDocument.querySelector('svg'), + pannableAndZoomableElements = [img, svg]; - svg.addEventListener('pointerdown', e => { - e.preventDefault(); + button.addEventListener('click', () => { + [button, container].forEach(el => el.classList.toggle('switch')); + reset(pannableAndZoomableElements); + }); - 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 }); + }); }); diff --git a/src/modules/pan.js b/src/modules/pan.js index 9faae0a..201c2f1 100644 --- a/src/modules/pan.js +++ b/src/modules/pan.js @@ -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(svg, type) { - svg.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true }); -} - -function setToCurrentPointerCoords(point, e) { - point.x = e.clientX; - point.y = e.clientY; - - return point; +function stopEventPropagationToChildren(el, type) { + el.addEventListener(type, e => e.stopPropagation(), { capture: true, once: true }); } -function getPanCoords(svg, startPt, movePt, initialPos) { - const posChange = getPositionChangeInLocalCoords(svg, startPt, movePt); +function getTranslateMatrix(startPt, movePt) { + const translateMatrix = new DOMMatrix(); - return { - x: initialPos.x + posChange.x, - y: initialPos.y + posChange.y - }; + return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y); } -function setViewBoxPosition(svg, { x, y }) { - const { width, height } = svg.viewBox.baseVal; - - svg.setAttributeNS(null, 'viewBox', `${x} ${y} ${width} ${height}`); -} +export default function (el, e) { + e.preventDefault(); -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 } ); } diff --git a/src/modules/utils.js b/src/modules/utils.js new file mode 100644 index 0000000..e7f5c55 --- /dev/null +++ b/src/modules/utils.js @@ -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); +} diff --git a/src/modules/zoom.js b/src/modules/zoom.js index 24ddb79..97a23e1 100644 --- a/src/modules/zoom.js +++ b/src/modules/zoom.js @@ -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) { - return { - width: width * zoomStepRatio, - height: height * zoomStepRatio - }; +function getScale(e, factor) { + return zoomIn(e.deltaY) ? 1 + factor : 1 - factor; } -function calcValChangeRatios(focusPoint, x, y, width, height) { +function getFocalPointBeforeTransform(el, e) { + const { x, y, width, height } = el.getBoundingClientRect(); + return { - x: (focusPoint.x - x) / width, - y: (focusPoint.y - y) / height, - width: (width + x - focusPoint.x) / width, - height: (height + y - focusPoint.y) / height + x: e.clientX, + y: e.clientY, + relativeToImageSize: { + x: (e.clientX - x) / width, + y: (e.clientY - y) / height + } }; } -function calcValChangeAmounts(focusPoint, x, y, width, height) { - const changeAmount = calcSizeChangeAmounts(width, height), - valChangeRatio = calcValChangeRatios(focusPoint, x, y, width, height); +function getFocalPointAfterTransform(el, fpBeforeTrans) { + const { x, y, width, height } = el.getBoundingClientRect(), + relativeFocalPoint = fpBeforeTrans.relativeToImageSize; return { - x: valChangeRatio.x * changeAmount.width, - y: valChangeRatio.y * changeAmount.height, - width: valChangeRatio.width * changeAmount.width, - height: valChangeRatio.height * changeAmount.height + x: x + width * relativeFocalPoint.x, + y: y + height * relativeFocalPoint.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), +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(); - 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); } -- cgit v1.2.3