diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 66030333..ad9e8371 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -5,6 +5,8 @@ import { serializePanZoomParams, screenDeltaToSvg, zoomAtPoint, + pinchZoom, + stepInertia, recentreOn, clampPan, lerpView, @@ -133,6 +135,35 @@ describe('zoomAtPoint', () => { }); }); +describe('pinchZoom', () => { + it('scales zoom by the finger-distance ratio around the centroid', () => { + // Fingers spread 100→200 → 2× zoom; centroid at canvas centre → no pan. + expect(pinchZoom({ x: 0, y: 0, z: 1 }, 1, 100, 200, 0, 0)).toEqual({ x: 0, y: 0, z: 2 }); + }); + + it('zooms out when fingers pinch together', () => { + expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 200, 100, 0, 0).z).toBe(1); + }); + + it('clamps the scaled zoom into bounds', () => { + expect(pinchZoom({ x: 0, y: 0, z: 2 }, 2, 100, 1000, 0, 0).z).toBe(MAX_ZOOM); + }); + + it('treats a zero start distance as no zoom change', () => { + expect(pinchZoom({ x: 5, y: 5, z: 1.5 }, 1.5, 0, 200, 0, 0).z).toBe(1.5); + }); +}); + +describe('stepInertia', () => { + it('advances the pan by velocity × frame duration in the drag direction', () => { + expect(stepInertia({ x: 100, y: 50, z: 1 }, 0.5, 0.25, 16)).toEqual({ x: 92, y: 46, z: 1 }); + }); + + it('leaves zoom untouched', () => { + expect(stepInertia({ x: 0, y: 0, z: 2.5 }, 1, 1, 16).z).toBe(2.5); + }); +}); + describe('recentreOn', () => { const node = { x: 300, y: 200 }; const base = { x: 100, y: 100 }; diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 2811ecb5..69c4af40 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -130,6 +130,43 @@ export function zoomAtPoint( }; } +/** Assumed milliseconds per animation frame, used to scale inertia velocity. */ +export const FRAME_MS = 16; +/** Per-frame velocity decay for pan inertia (OQ-004). */ +export const INERTIA_DECAY = 0.92; +/** Inertia stops once the velocity (svg units per ms) drops below this. */ +export const INERTIA_MIN_SPEED = 0.02; + +/** + * Pinch zoom around the gesture centroid (US-PAN-002/003). The new zoom is the + * start zoom scaled by the finger-distance ratio (clamped); the anchor offset + * keeps the centroid fixed via {@link zoomAtPoint}. + */ +export function pinchZoom( + state: PanZoomState, + startZoom: number, + startDist: number, + currentDist: number, + anchorX: number, + anchorY: number +): PanZoomState { + const ratio = startDist > 0 ? currentDist / startDist : 1; + return zoomAtPoint(state, clampZoom(startZoom * ratio), anchorX, anchorY); +} + +/** + * Advance the pan by one inertia frame: continue the release velocity (svg units + * per ms) in the drag direction, scaled by the frame duration. Zoom is untouched. + */ +export function stepInertia( + state: PanZoomState, + velX: number, + velY: number, + frameMs: number = FRAME_MS +): PanZoomState { + return { x: state.x - velX * frameMs, y: state.y - velY * frameMs, z: state.z }; +} + /** Linearly interpolate between two view states (drives fit/recentre tweening). */ export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState { return { diff --git a/frontend/src/lib/person/genealogy/panZoomGestures.ts b/frontend/src/lib/person/genealogy/panZoomGestures.ts index d5955645..8173d93c 100644 --- a/frontend/src/lib/person/genealogy/panZoomGestures.ts +++ b/frontend/src/lib/person/genealogy/panZoomGestures.ts @@ -4,6 +4,11 @@ import { clampZoom, screenDeltaToSvg, zoomAtPoint, + pinchZoom, + stepInertia, + FRAME_MS, + INERTIA_DECAY, + INERTIA_MIN_SPEED, ZOOM_STEP_KB, type PanZoomState } from '$lib/person/genealogy/panZoom'; @@ -25,8 +30,6 @@ export interface PanZoomGesturesParams { /** 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: @@ -86,7 +89,7 @@ export const panZoomGestures: Action = (no 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 }); + emit(stepInertia(current, velX, velY, FRAME_MS)); velX *= INERTIA_DECAY; velY *= INERTIA_DECAY; const stalled = current.x === before.x && current.y === before.y; @@ -135,8 +138,7 @@ export const panZoomGestures: Action = (no 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)); + emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y)); moved = true; return; }