import type { Action } from 'svelte/action'; import { clampPan, clampZoom, screenDeltaToSvg, zoomAtPoint, pinchZoom, stepInertia, FRAME_MS, INERTIA_DECAY, INERTIA_MIN_SPEED, 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; /** * 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 = (node, params) => { let p = params; let current = p.state; const pointers = new Map(); 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(stepInertia(current, velX, velY, FRAME_MS)); 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); emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, 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); } }; };