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:
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SVGSVGElement, PanZoomGesturesParams> = (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<SVGSVGElement, PanZoomGesturesParams> = (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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user