refactor(stammbaum): extract + unit-test pinch and inertia math (#692)

Move the pinch-zoom (pinchZoom) and inertia-step (stepInertia) geometry out of
the panZoomGestures DOM glue into pure, unit-tested helpers in panZoom.ts, with
named FRAME_MS/INERTIA_* constants. Addresses the QA blocker that the gesture
module's core math was untested. No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 18:47:29 +02:00
parent c1dd6d299f
commit f4b631e1bc
3 changed files with 75 additions and 5 deletions

View File

@@ -5,6 +5,8 @@ import {
serializePanZoomParams, serializePanZoomParams,
screenDeltaToSvg, screenDeltaToSvg,
zoomAtPoint, zoomAtPoint,
pinchZoom,
stepInertia,
recentreOn, recentreOn,
clampPan, clampPan,
lerpView, 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', () => { describe('recentreOn', () => {
const node = { x: 300, y: 200 }; const node = { x: 300, y: 200 };
const base = { x: 100, y: 100 }; const base = { x: 100, y: 100 };

View File

@@ -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). */ /** Linearly interpolate between two view states (drives fit/recentre tweening). */
export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState { export function lerpView(from: PanZoomState, to: PanZoomState, t: number): PanZoomState {
return { return {

View File

@@ -4,6 +4,11 @@ import {
clampZoom, clampZoom,
screenDeltaToSvg, screenDeltaToSvg,
zoomAtPoint, zoomAtPoint,
pinchZoom,
stepInertia,
FRAME_MS,
INERTIA_DECAY,
INERTIA_MIN_SPEED,
ZOOM_STEP_KB, ZOOM_STEP_KB,
type PanZoomState type PanZoomState
} from '$lib/person/genealogy/panZoom'; } 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. */ /** Pointer movement (px) below which a drag is treated as a tap, not a pan. */
const DRAG_THRESHOLD_PX = 4; 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: * Touch/mouse/wheel pan & zoom for the Stammbaum canvas (#692). Thin DOM glue:
@@ -86,7 +89,7 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return; if (Math.hypot(velX, velY) < INERTIA_MIN_SPEED) return;
const step = () => { const step = () => {
const before = current; 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; velX *= INERTIA_DECAY;
velY *= INERTIA_DECAY; velY *= INERTIA_DECAY;
const stalled = current.x === before.x && current.y === before.y; const stalled = current.x === before.x && current.y === before.y;
@@ -135,8 +138,7 @@ export const panZoomGestures: Action<SVGSVGElement, PanZoomGesturesParams> = (no
const dist = distance(a, b) || 1; const dist = distance(a, b) || 1;
const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; const centroid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
const anchor = anchorOffset(centroid.x, centroid.y); const anchor = anchorOffset(centroid.x, centroid.y);
const targetZoom = clampZoom(pinchStartZoom * (dist / pinchStartDist)); emit(pinchZoom(current, pinchStartZoom, pinchStartDist, dist, anchor.x, anchor.y));
emit(zoomAtPoint(current, targetZoom, anchor.x, anchor.y));
moved = true; moved = true;
return; return;
} }