Web Dev Solutions

Catalin Mititiuc

From 263201d869956b94660d4efa8297e89dadbe36a8 Mon Sep 17 00:00:00 2001 From: Catalin Mititiuc Date: Fri, 12 Apr 2024 10:57:15 -0700 Subject: Initial commit --- .gitignore | 4 + README.md | 9 ++ dev-server.js | 11 ++ index.js | 2 + package-lock.json | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 7 + public/image.svg | 82 ++++++++++ public/index.html | 33 ++++ src/app.js | 18 +++ src/modules/pan.js | 77 ++++++++++ src/modules/zoom.js | 57 +++++++ 11 files changed, 731 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 dev-server.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/image.svg create mode 100644 public/index.html create mode 100644 src/app.js create mode 100644 src/modules/pan.js create mode 100644 src/modules/zoom.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44848b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.log +*.tgz +dist +node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ca567d --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +## Install dev server packages + + docker run --rm -w /app -v $PWD:/app -u $(id -u):$(id -u) node npm install + +## Start the dev server + + docker run --rm --init -it -w /app -v $PWD:/app -p 8080:8080 node node dev-server.js + +Visit `localhost:8080` to view. diff --git a/dev-server.js b/dev-server.js new file mode 100644 index 0000000..4330d0d --- /dev/null +++ b/dev-server.js @@ -0,0 +1,11 @@ +require('esbuild-server') + .createServer( + { + bundle: true, + entryPoints: ['src/app.js'], + }, + { + static: 'public', + } + ) + .start(); diff --git a/index.js b/index.js new file mode 100644 index 0000000..b619e1b --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +export { default as pan } from './src/modules/pan.js'; +export { default as zoom } from './src/modules/zoom.js'; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d47231c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,431 @@ +{ + "name": "app", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "esbuild": "^0.20.2", + "esbuild-server": "^0.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/esbuild-server": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/esbuild-server/-/esbuild-server-0.3.0.tgz", + "integrity": "sha512-8RuzIdM13gs7MyYwxn/c88nDdx086aREBvzWDk4G3cC7nudF8480OTrvAvanVmFZ9anDv9U4cRX/OKbladaRVA==", + "dev": true, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "esbuild": ">=0.17.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb86df3 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "svg-pan-zoom", + "devDependencies": { + "esbuild": "^0.20.2", + "esbuild-server": "^0.3.0" + } +} diff --git a/public/image.svg b/public/image.svg new file mode 100644 index 0000000..5a5c3d9 --- /dev/null +++ b/public/image.svg @@ -0,0 +1,82 @@ + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..8040421 --- /dev/null +++ b/public/index.html @@ -0,0 +1,33 @@ + + + + + + SVG Pan & Zoom Example + + + +

Pan & Zoom an SVG Image with 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 new file mode 100644 index 0000000..f5a4fa8 --- /dev/null +++ b/src/app.js @@ -0,0 +1,18 @@ +import zoom from './modules/zoom.js'; +import pan from './modules/pan.js'; + +window.addEventListener('load', function () { + const svg = document.querySelector('object').contentDocument.querySelector('svg'); + + svg.addEventListener('wheel', e => { + e.preventDefault(); + + svg.setAttributeNS(null, 'viewBox', zoom(svg, e)); + }, { passive: false }); + + svg.addEventListener('pointerdown', e => { + e.preventDefault(); + + pan(svg, e); + }, { passive: false }); +}); diff --git a/src/modules/pan.js b/src/modules/pan.js new file mode 100644 index 0000000..9faae0a --- /dev/null +++ b/src/modules/pan.js @@ -0,0 +1,77 @@ +const minDistanceThreshold = 5; + +function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) { + return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); +} + +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 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(); + + let isPanning = false; + + function pointerMove(e) { + setToCurrentPointerCoords(movePt, e); + + if (!isPanning && minDistanceThresholdIsMet(startPt, movePt)) { + isPanning = true; + e.target.setPointerCapture(e.pointerId); + setToCurrentPointerCoords(startPt, e); + stopEventPropagationToChildren(svg, 'click'); + } + + if (isPanning) { + setViewBoxPosition(svg, getPanCoords(svg, startPt, movePt, { x, y })); + } + } + + svg.addEventListener('pointermove', pointerMove); + + svg.addEventListener( + 'pointerup', + () => svg.removeEventListener('pointermove', pointerMove), + { once: true } + ); +} diff --git a/src/modules/zoom.js b/src/modules/zoom.js new file mode 100644 index 0000000..24ddb79 --- /dev/null +++ b/src/modules/zoom.js @@ -0,0 +1,57 @@ +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()); +} + +function zoomIn(deltaY) { + return deltaY < 0; +} + +function calcSizeChangeAmounts(width, height) { + return { + width: width * zoomStepRatio, + height: height * zoomStepRatio + }; +} + +function calcValChangeRatios(focusPoint, x, y, width, height) { + return { + 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 { + x: valChangeRatio.x * changeAmount.width, + y: valChangeRatio.y * changeAmount.height, + width: valChangeRatio.width * changeAmount.width, + height: valChangeRatio.height * changeAmount.height + }; +} + +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), + + 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) + }; + + return `${attr.x} ${attr.y} ${attr.width} ${attr.height}`; +} -- cgit v1.2.3