Web Dev Solutions

Catalin Mititiuc

aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md16
-rw-r--r--package.json9
-rw-r--r--public/assets/css/style.css40
-rw-r--r--public/assets/images/41156165560-4438592e93-o.webpbin0 -> 585068 bytes
-rw-r--r--public/assets/images/image.svg108
-rw-r--r--public/image.svg82
-rw-r--r--public/index.html31
-rw-r--r--src/app.js33
-rw-r--r--src/modules/pan.js62
-rw-r--r--src/modules/utils.js8
-rw-r--r--src/modules/zoom.js75
11 files changed, 271 insertions, 193 deletions
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
--- /dev/null
+++ b/public/assets/images/41156165560-4438592e93-o.webp
Binary files 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 @@
+<?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>
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 <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);
}