Web Dev Solutions

Catalin Mititiuc

aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCatalin Mititiuc <webdevcat@proton.me>2024-04-12 10:57:15 -0700
committerCatalin Mititiuc <webdevcat@proton.me>2024-04-15 09:48:00 -0700
commit263201d869956b94660d4efa8297e89dadbe36a8 (patch)
tree8fa47922163548dda0739aaa5ff0612de96c9fd1
Initial commit
-rw-r--r--.gitignore4
-rw-r--r--README.md9
-rw-r--r--dev-server.js11
-rw-r--r--index.js2
-rw-r--r--package-lock.json431
-rw-r--r--package.json7
-rw-r--r--public/image.svg82
-rw-r--r--public/index.html33
-rw-r--r--src/app.js18
-rw-r--r--src/modules/pan.js77
-rw-r--r--src/modules/zoom.js57
11 files changed, 731 insertions, 0 deletions
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 @@
+<?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
new file mode 100644
index 0000000..8040421
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html>
+ <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>
+ </head>
+ <body>
+ <h1>Pan & Zoom an SVG Image with 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>
+ <script src="app.js"></script>
+ </body>
+</html>
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}`;
+}