index : pan-zoom | |
SVG pan/zoom library. |
aboutsummaryrefslogtreecommitdiff |
diff options
-rw-r--r-- | README.md | 16 | ||||
-rw-r--r-- | package.json | 9 | ||||
-rw-r--r-- | public/assets/css/style.css | 40 | ||||
-rw-r--r-- | public/assets/images/41156165560-4438592e93-o.webp | bin | 0 -> 585068 bytes | |||
-rw-r--r-- | public/assets/images/image.svg | 108 | ||||
-rw-r--r-- | public/image.svg | 82 | ||||
-rw-r--r-- | public/index.html | 31 | ||||
-rw-r--r-- | src/app.js | 33 | ||||
-rw-r--r-- | src/modules/pan.js | 62 | ||||
-rw-r--r-- | src/modules/utils.js | 8 | ||||
-rw-r--r-- | src/modules/zoom.js | 75 |
11 files changed, 271 insertions, 193 deletions
@@ -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 Binary files differnew file mode 100644 index 0000000..2ad3fa4 --- /dev/null +++ b/public/assets/images/41156165560-4438592e93-o.webp 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 @@ +<?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> 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 @@ -<?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> 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 @@ <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> - <title>SVG Pan & Zoom Example</title> - <style> - 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> + <title>JavaScript/CSS Pan & Zoom Demo</title> + <link rel="stylesheet" href="assets/css/style.css"> </head> <body> - <h1>Pan & Zoom an SVG Image with JavaScript</h1> + <h1>Pan & Zoom an Element with CSS/JavaScript</h1> <p> Click and drag on the image to pan. Use the mouse wheel to zoom in and out. </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> </body> </html> @@ -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 <object> 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); } |