feat(stammbaum): touch/mouse/wheel pan & pinch zoom gestures (#692)
Add a panZoomGestures action: one-finger/left-button drag pans, two-finger pinch and Ctrl+wheel zoom around the centroid, plain wheel pans. Pan is edge-clamped via clampPan (no infinite scroll), a real drag suppresses the trailing node click, and inertia decays after release unless prefers-reduced- motion. Canvas container switches from native scroll to overflow-hidden. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
244
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal file
244
frontend/src/lib/person/genealogy/panZoomGestures.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Action } from 'svelte/action';
|
||||
import {
|
||||
clampPan,
|
||||
clampZoom,
|
||||
screenDeltaToSvg,
|
||||
zoomAtPoint,
|
||||
ZOOM_STEP_KB,
|
||||
type PanZoomState
|
||||
} from '$lib/person/genealogy/panZoom';
|
||||
|
||||
export interface PanZoomGesturesParams {
|
||||
/** The authoritative view state (re-synced at the start of each gesture). */
|
||||
state: PanZoomState;
|
||||
/** Base viewBox geometry at z=1 (includes the gutter) — see StammbaumTree. */
|
||||
baseW: number;
|
||||
baseH: number;
|
||||
baseCentreX: number;
|
||||
baseCentreY: number;
|
||||
/** When true, inertia is skipped and motion stops on release (REQ-PAN-005). */
|
||||
reducedMotion: boolean;
|
||||
onPanZoom: (state: PanZoomState) => void;
|
||||
/** Fired on the first pointer of a gesture (used to dismiss the affordance). */
|
||||
onGestureStart?: () => void;
|
||||
}
|
||||
|
||||
/** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
const INERTIA_DECAY = 0.92;
|
||||
const INERTIA_MIN_SPEED = 0.02; // svg units per ms
|
||||
|
||||
/**
|
||||
* Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
|
||||
* all geometry is delegated to the pure helpers in `panZoom.ts`. One-finger
|
||||
* drag and left-button drag pan; two-finger pinch and Ctrl+wheel zoom around the
|
||||
* gesture centroid; plain wheel pans. Pan is edge-clamped and a real drag
|
||||
* suppresses the trailing node click. Inertia decays after release unless the
|
||||
* user prefers reduced motion.
|
||||
*/
|
||||
export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (node, params) => {
|
||||
let p = params;
|
||||
let current = p.state;
|
||||
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let dragging = false;
|
||||
let moved = false;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let lastTime = 0;
|
||||
let velX = 0;
|
||||
let velY = 0;
|
||||
let pinchStartDist = 0;
|
||||
let pinchStartZoom = 1;
|
||||
let inertiaFrame = 0;
|
||||
let suppressClick = false;
|
||||
|
||||
const emit = (next: PanZoomState) => {
|
||||
current = clampPan(next, p.baseW, p.baseH);
|
||||
p.onPanZoom(current);
|
||||
};
|
||||
|
||||
const viewBoxW = () => p.baseW / current.z;
|
||||
const viewBoxH = () => p.baseH / current.z;
|
||||
|
||||
// Convert a client point to its anchor offset from the base viewBox centre.
|
||||
const anchorOffset = (clientX: number, clientY: number) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const w = viewBoxW();
|
||||
const h = viewBoxH();
|
||||
const fracX = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5;
|
||||
const fracY = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5;
|
||||
const svgX = p.baseCentreX + current.x - w / 2 + fracX * w;
|
||||
const svgY = p.baseCentreY + current.y - h / 2 + fracY * h;
|
||||
return { x: svgX - p.baseCentreX, y: svgY - p.baseCentreY };
|
||||
};
|
||||
|
||||
const distance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||
Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
const cancelInertia = () => {
|
||||
if (inertiaFrame) cancelAnimationFrame(inertiaFrame);
|
||||
inertiaFrame = 0;
|
||||
};
|
||||
|
||||
const runInertia = () => {
|
||||
if (p.reducedMotion) return;
|
||||
if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
|
||||
const step = () => {
|
||||
const before = current;
|
||||
emit({ ...current, x: current.x - velX * 16, y: current.y - velY * 16 });
|
||||
velX *= INERTIA_DECAY;
|
||||
velY *= INERTIA_DECAY;
|
||||
const stalled = current.x === before.x && current.y === before.y;
|
||||
if (!stalled && Math.hypot(velX, velY) >= INERTIA_MIN_SPEED) {
|
||||
inertiaFrame = requestAnimationFrame(step);
|
||||
} else {
|
||||
inertiaFrame = 0;
|
||||
}
|
||||
};
|
||||
inertiaFrame = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
cancelInertia();
|
||||
try {
|
||||
node.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* pointer not capturable (e.g. synthetic event) — drag still works */
|
||||
}
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
p.onGestureStart?.();
|
||||
|
||||
if (pointers.size === 1) {
|
||||
current = p.state; // re-sync from the authoritative state
|
||||
dragging = true;
|
||||
moved = false;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
lastTime = performance.now();
|
||||
velX = 0;
|
||||
velY = 0;
|
||||
} else if (pointers.size === 2) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
pinchStartDist = distance(a, b) || 1;
|
||||
pinchStartZoom = current.z;
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
|
||||
if (pointers.size >= 2) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
const dist = distance(a, b) || 1;
|
||||
const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
const anchor = anchorOffset(centroid.x, centroid.y);
|
||||
const targetZoom = clampZoom(pinchStartZoom * (dist / pinchStartDist));
|
||||
emit(zoomAtPoint(current, targetZoom, anchor.x, anchor.y));
|
||||
moved = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragging) return;
|
||||
const dxPx = e.clientX - lastX;
|
||||
const dyPx = e.clientY - lastY;
|
||||
if (!moved && Math.hypot(dxPx, dyPx) >= DRAG_THRESHOLD_PX) moved = true;
|
||||
|
||||
const { dx, dy } = screenDeltaToSvg(
|
||||
dxPx,
|
||||
dyPx,
|
||||
viewBoxW(),
|
||||
viewBoxH(),
|
||||
node.clientWidth,
|
||||
node.clientHeight
|
||||
);
|
||||
const now = performance.now();
|
||||
const dt = Math.max(1, now - lastTime);
|
||||
velX = dx / dt;
|
||||
velY = dy / dt;
|
||||
lastTime = now;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
emit({ ...current, x: current.x - dx, y: current.y - dy });
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
pointers.delete(e.pointerId);
|
||||
try {
|
||||
if (node.hasPointerCapture?.(e.pointerId)) node.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* nothing to release */
|
||||
}
|
||||
|
||||
if (pointers.size === 0) {
|
||||
if (dragging && moved) {
|
||||
suppressClick = true;
|
||||
runInertia();
|
||||
}
|
||||
dragging = false;
|
||||
} else if (pointers.size === 1) {
|
||||
// Dropped from pinch to a single pointer — resume a clean drag.
|
||||
const [only] = [...pointers.entries()];
|
||||
dragging = true;
|
||||
moved = true;
|
||||
lastX = only[1].x;
|
||||
lastY = only[1].y;
|
||||
lastTime = performance.now();
|
||||
}
|
||||
};
|
||||
|
||||
// A drag ends with a synthetic click on the node underneath; swallow it so a
|
||||
// pan does not also select a person (US-PAN-001).
|
||||
const onClickCapture = (e: MouseEvent) => {
|
||||
if (suppressClick) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
suppressClick = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.ctrlKey) {
|
||||
const factor = e.deltaY < 0 ? 1 + ZOOM_STEP_KB : 1 / (1 + ZOOM_STEP_KB);
|
||||
const anchor = anchorOffset(e.clientX, e.clientY);
|
||||
emit(zoomAtPoint(current, clampZoom(current.z * factor), anchor.x, anchor.y));
|
||||
return;
|
||||
}
|
||||
const { dx, dy } = screenDeltaToSvg(
|
||||
e.deltaX,
|
||||
e.deltaY,
|
||||
viewBoxW(),
|
||||
viewBoxH(),
|
||||
node.clientWidth,
|
||||
node.clientHeight
|
||||
);
|
||||
emit({ ...current, x: current.x + dx, y: current.y + dy });
|
||||
};
|
||||
|
||||
node.style.touchAction = 'none';
|
||||
node.addEventListener('pointerdown', onPointerDown);
|
||||
node.addEventListener('pointermove', onPointerMove);
|
||||
node.addEventListener('pointerup', onPointerUp);
|
||||
node.addEventListener('pointercancel', onPointerUp);
|
||||
node.addEventListener('click', onClickCapture, true);
|
||||
node.addEventListener('wheel', onWheel, { passive: false });
|
||||
|
||||
return {
|
||||
update(next: PanZoomGesturesParams) {
|
||||
p = next;
|
||||
if (!dragging && pointers.size === 0 && !inertiaFrame) current = next.state;
|
||||
},
|
||||
destroy() {
|
||||
cancelInertia();
|
||||
node.removeEventListener('pointerdown', onPointerDown);
|
||||
node.removeEventListener('pointermove', onPointerMove);
|
||||
node.removeEventListener('pointerup', onPointerUp);
|
||||
node.removeEventListener('pointercancel', onPointerUp);
|
||||
node.removeEventListener('click', onClickCapture, true);
|
||||
node.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user