3 Commits

8 changed files with 126 additions and 57 deletions

View File

@@ -3,7 +3,7 @@
Pan/zoom library for SVG elements. Hold and drag to pan. Use the mouse wheel to Pan/zoom library for SVG elements. Hold and drag to pan. Use the mouse wheel to
zoom. See `src/app.js` for a usage example. zoom. See `src/app.js` for a usage example.
## To view the demo using Docker ## View demo
1. Install the development server packages. 1. Install the development server packages.

View File

@@ -1,2 +1,3 @@
export { default as pan } from './src/modules/pan.js'; export { default as pan, programmaticPan } from './src/modules/pan';
export { default as zoom } from './src/modules/zoom.js'; export { default as zoom } from './src/modules/zoom';
export * as withRestore from './src/modules/with-restore';

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "pan-zoom", "name": "pan-zoom",
"version": "0.2.0", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pan-zoom", "name": "pan-zoom",
"version": "0.2.0", "version": "0.3.0",
"devDependencies": { "devDependencies": {
"esbuild": "^0.20.2", "esbuild": "^0.20.2",
"esbuild-server": "^0.3.0" "esbuild-server": "^0.3.0"

View File

@@ -1,6 +1,6 @@
{ {
"name": "pan-zoom", "name": "pan-zoom",
"version": "0.2.0", "version": "0.3.0",
"browser": "index.js", "browser": "index.js",
"devDependencies": { "devDependencies": {
"esbuild": "^0.20.2", "esbuild": "^0.20.2",

View File

@@ -1,5 +1,5 @@
import zoom from './modules/zoom.js'; import zoom from './modules/zoom';
import pan from './modules/pan.js'; import pan from './modules/pan';
const optionalZoomFactor = 0.1, const optionalZoomFactor = 0.1,
object = document.querySelector('object'); object = document.querySelector('object');
@@ -14,8 +14,8 @@ window.addEventListener('load', function () {
pointer = svg.querySelector('#pointer'), pointer = svg.querySelector('#pointer'),
options = { passive: false }; options = { passive: false };
svg.addEventListener('wheel', e => zoom(targetEl, e, optionalZoomFactor), options); svg.addEventListener('wheel', zoom(targetEl, optionalZoomFactor), options);
svg.addEventListener('pointerdown', e => pan(svg, targetEl, e), options); svg.addEventListener('pointerdown', pan(targetEl), options);
svg.addEventListener('pointermove', e => { svg.addEventListener('pointermove', e => {
const pt = new DOMPoint(e.clientX, e.clientY), const pt = new DOMPoint(e.clientX, e.clientY),

View File

@@ -2,11 +2,15 @@ import getComputedTransformMatrix from './utils.js';
const minDistanceThreshold = 5; const minDistanceThreshold = 5;
function mainButtonPressed(e) {
return e.button === 0;
}
function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) { function distanceBetween({ x: x1, y: y1 }, { x: x2, y: y2 }) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
} }
function minDistanceThresholdIsMet(startPt, endPt) { function exceedsMinDistanceThreshhold(startPt, endPt) {
return distanceBetween(startPt, endPt) >= minDistanceThreshold; return distanceBetween(startPt, endPt) >= minDistanceThreshold;
} }
@@ -20,45 +24,68 @@ function getTranslateMatrix(startPt, movePt) {
return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y); return translateMatrix.translate(movePt.x - startPt.x, movePt.y - startPt.y);
} }
export default function (svg, el, e) { function getTransformMatrices(el) {
e.preventDefault(); return {
computed: getComputedTransformMatrix(el),
inverseScreen: el.getScreenCTM().inverse()
};
}
const mtx = getComputedTransformMatrix(el), function clientToSvgPt({ clientX, clientY }, { inverseScreen }, pt = new DOMPoint()) {
inverseScreenCTM = el.getScreenCTM().inverse(); pt.x = clientX;
pt.y = clientY;
return pt.matrixTransform(inverseScreen);
}
let startPt = new DOMPoint(e.clientX, e.clientY), function setPanTransform(el, { computed }, startPt, endPt) {
movePt = new DOMPoint(), el.style.transform = computed.multiply(getTranslateMatrix(startPt, endPt));
isPanning = false; }
export function programmaticPan(el, from, to) {
const matrices = getTransformMatrices(el);
const startPt = clientToSvgPt(from, matrices);
const endPt = clientToSvgPt(to, matrices);
el.style.transition = 'transform 0.5s';
setPanTransform(el, matrices, startPt, endPt);
el.addEventListener('transitionend', () => el.style.transition = '', { once: true });
}
export default function (el) {
let matrices, startPt, movePt, isPanning;
function pointerMove(e) { function pointerMove(e) {
movePt.x = e.clientX; movePt.x = e.clientX;
movePt.y = e.clientY; movePt.y = e.clientY;
if (!isPanning && minDistanceThresholdIsMet(startPt, movePt)) { if (!isPanning && exceedsMinDistanceThreshhold(startPt, movePt)) {
isPanning = true; isPanning = true;
e.target.setPointerCapture(e.pointerId); startPt = clientToSvgPt(e, matrices, startPt);
startPt.x = e.clientX;
startPt.y = e.clientY;
startPt = startPt.matrixTransform(inverseScreenCTM);
stopEventPropagationToChildren(el, 'click'); stopEventPropagationToChildren(el, 'click');
} }
if (isPanning) { if (isPanning) {
movePt.x = e.clientX; movePt = clientToSvgPt(e, matrices, movePt);
movePt.y = e.clientY; setPanTransform(el, matrices, startPt, movePt);
movePt = movePt.matrixTransform(inverseScreenCTM);
el.style.transform = mtx.multiply(getTranslateMatrix(startPt, movePt));
} }
} }
svg.addEventListener('pointermove', pointerMove); return function(e) {
if (!mainButtonPressed(e)) return;
e.preventDefault();
e.target.setPointerCapture(e.pointerId);
svg.addEventListener( isPanning = false;
matrices = getTransformMatrices(el);
startPt = new DOMPoint(e.clientX, e.clientY);
movePt = new DOMPoint();
this.addEventListener('pointermove', pointerMove);
this.addEventListener(
'pointerup', 'pointerup',
() => svg.removeEventListener('pointermove', pointerMove), () => this.removeEventListener('pointermove', pointerMove),
{ once: true } { once: true }
); );
}
} }

View File

@@ -0,0 +1,36 @@
import zoom from './zoom';
import pan from './pan';
const storageKey = "pan-zoom";
const zoomFactor = 0.25;
function restorePanZoomVal(el) {
const storedPanZoomVal = localStorage.getItem(storageKey);
if (storedPanZoomVal) el.style.transform = storedPanZoomVal;
}
function addEventListeners(svg, el) {
svg.addEventListener("wheel", zoom(el, zoomFactor), { passive: false });
svg.addEventListener("pointerdown", pan(el), { passive: false });
}
function storePanZoomVal(transformMatrix) {
localStorage.setItem(storageKey, transformMatrix);
}
function observePanZoomChanges(el) {
const observer = new MutationObserver(() =>
storePanZoomVal(el.style.transform),
);
observer.observe(el, { attributeFilter: ["style"] });
}
export function start(svg, selector) {
const targetEl = svg.querySelector(selector);
restorePanZoomVal(targetEl);
addEventListeners(svg, targetEl);
observePanZoomChanges(targetEl);
}

View File

@@ -5,14 +5,17 @@ function zoomIn(deltaY) {
} }
function getScale(e, factor) { function getScale(e, factor) {
return zoomIn(e.deltaY) ? 1 + factor : 1 - factor; const outMult = 1 - factor;
const inMult = 1 + factor / outMult
return zoomIn(e.deltaY) ? inMult : outMult;
} }
function getFocalPointBeforeTransform(el, e, inverseScreenCTM) { function getFocalPointBeforeTransform(el, e, inverseScreenCTM) {
const { x, y, width, height } = el.getBoundingClientRect(), const { x, y, width, height } = el.getBoundingClientRect();
pointer = (new DOMPoint(e.clientX, e.clientY)).matrixTransform(inverseScreenCTM), const pointer = (new DOMPoint(e.clientX, e.clientY)).matrixTransform(inverseScreenCTM);
origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM), const origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM);
terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM); const terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM);
return { return {
x: pointer.x, x: pointer.x,
@@ -25,10 +28,10 @@ function getFocalPointBeforeTransform(el, e, inverseScreenCTM) {
} }
function getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM) { function getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM) {
const { x, y, width, height } = el.getBoundingClientRect(), const { x, y, width, height } = el.getBoundingClientRect();
origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM), const origin = (new DOMPoint(x, y)).matrixTransform(inverseScreenCTM);
terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM), const terminus = (new DOMPoint(x + width, y + height)).matrixTransform(inverseScreenCTM);
relativeFocalPoint = fpBeforeTrans.relativeToImageSize; const relativeFocalPoint = fpBeforeTrans.relativeToImageSize;
return { return {
x: origin.x + (terminus.x - origin.x) * relativeFocalPoint.x, x: origin.x + (terminus.x - origin.x) * relativeFocalPoint.x,
@@ -37,13 +40,13 @@ function getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM) {
} }
function getTranslateMatrix(el, e, scaleMatrix) { function getTranslateMatrix(el, e, scaleMatrix) {
const inverseScreenCTM = el.getScreenCTM().inverse(), const inverseScreenCTM = el.getScreenCTM().inverse();
fpBeforeTrans = getFocalPointBeforeTransform(el, e, inverseScreenCTM); const fpBeforeTrans = getFocalPointBeforeTransform(el, e, inverseScreenCTM);
el.style.transform = scaleMatrix; el.style.transform = scaleMatrix;
const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM), const fpAfterTrans = getFocalPointAfterTransform(el, fpBeforeTrans, inverseScreenCTM);
translateMatrix = new DOMMatrix(); const translateMatrix = new DOMMatrix();
return translateMatrix.translate( return translateMatrix.translate(
fpBeforeTrans.x - fpAfterTrans.x, fpBeforeTrans.x - fpAfterTrans.x,
@@ -51,12 +54,14 @@ function getTranslateMatrix(el, e, scaleMatrix) {
); );
} }
export default function (el, e, factor = 0.1) { export default function (el, factor = 0.1) {
return e => {
e.preventDefault(); e.preventDefault();
const mtx = getComputedTransformMatrix(el), const mtx = getComputedTransformMatrix(el);
scale = getScale(e, factor), const scale = getScale(e, factor);
transMtx = getTranslateMatrix(el, e, mtx.scale(scale)); const transMtx = getTranslateMatrix(el, e, mtx.scale(scale));
el.style.transform = mtx.multiply(transMtx).scale(scale); el.style.transform = mtx.multiply(transMtx).scale(scale);
}
} }