From 5d752fcc0f9c8bcfb9275149eb6bcfa802b9e143 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:28:41 +0200 Subject: [PATCH] feat(stammbaum): centroid-anchored zoom (zoomAtPoint) (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 29 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 25 ++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 42282a65..52782c3b 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -4,6 +4,7 @@ import { parsePanZoomParams, serializePanZoomParams, screenDeltaToSvg, + zoomAtPoint, DEFAULT_VIEW, DEFAULT_ZOOM, MIN_ZOOM, @@ -91,3 +92,31 @@ describe('screenDeltaToSvg', () => { expect(screenDeltaToSvg(10, 10, 1000, 800, 0, 0)).toEqual({ dx: 0, dy: 0 }); }); }); + +describe('zoomAtPoint', () => { + // The anchor is expressed as an offset (in SVG units) from the base viewBox + // centre. The fraction of the anchor across the viewBox must not change. + const anchorScreenFraction = (state: { x: number; z: number }, anchorOffsetX: number) => { + const baseW = 1000; + const w = baseW / state.z; + const centreOffset = anchorOffsetX - state.x; // anchor relative to viewBox centre + return centreOffset / w + 0.5; + }; + + it('keeps the canvas centre fixed when the anchor is the centre', () => { + const next = zoomAtPoint({ x: 0, y: 0, z: 1 }, 2, 0, 0); + expect(next).toEqual({ x: 0, y: 0, z: 2 }); + }); + + it('keeps an off-centre anchor at the same screen position across a zoom-in', () => { + const before = { x: 0, y: 0, z: 1 }; + const after = zoomAtPoint(before, 2, 100, 50); + expect(after.z).toBe(2); + expect(anchorScreenFraction(after, 100)).toBeCloseTo(anchorScreenFraction(before, 100), 10); + }); + + it('clamps the target zoom and makes no move when already at the bound', () => { + const next = zoomAtPoint({ x: 30, y: 10, z: MAX_ZOOM }, 99, 200, 200); + expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM }); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 07a934d7..5ef7ef4a 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -81,3 +81,28 @@ export function screenDeltaToSvg( dy: elPxH > 0 ? dyPx * (viewBoxH / elPxH) : 0 }; } + +/** + * Zoom to `newZoom` while keeping a given anchor point fixed on screen + * (pinch-centroid zoom — US-PAN-002 AC1 / US-PAN-003 AC1). + * + * `anchorX`/`anchorY` are the anchor point expressed as an offset, in SVG units, + * from the base viewBox centre. Because the viewBox width scales as `1/z`, the + * ratio of old-to-new width is exactly `z / newZoom` independent of the base + * size, so the new pan offset that preserves the anchor's screen fraction is + * `anchor - (anchor - pan) * (z / newZoom)`. + */ +export function zoomAtPoint( + state: PanZoomState, + newZoom: number, + anchorX: number, + anchorY: number +): PanZoomState { + const z = clampZoom(newZoom); + const ratio = state.z / z; + return { + x: anchorX - (anchorX - state.x) * ratio, + y: anchorY - (anchorY - state.y) * ratio, + z + }; +}