From 197b668f20e9d32e437e9b31326b1a7308ef59b4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Fri, 29 May 2026 16:29:55 +0200 Subject: [PATCH] feat(stammbaum): recentre-on-node with legible auto-zoom (#692) Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 25 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 23 +++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 52782c3b..bea6f2d9 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -5,8 +5,10 @@ import { serializePanZoomParams, screenDeltaToSvg, zoomAtPoint, + recentreOn, DEFAULT_VIEW, DEFAULT_ZOOM, + LEGIBLE_ZOOM, MIN_ZOOM, MAX_ZOOM } from './panZoom'; @@ -120,3 +122,26 @@ describe('zoomAtPoint', () => { expect(next).toEqual({ x: 30, y: 10, z: MAX_ZOOM }); }); }); + +describe('recentreOn', () => { + const node = { x: 300, y: 200 }; + const base = { x: 100, y: 100 }; + + it('pans so the node sits at the viewBox centre, keeping the current zoom', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 1 }, false)).toEqual({ x: 200, y: 100, z: 1 }); + }); + + it('auto-zooms up to LEGIBLE_ZOOM when zoomed out (OQ-005)', () => { + const next = recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, true); + expect(next.z).toBe(LEGIBLE_ZOOM); + expect({ x: next.x, y: next.y }).toEqual({ x: 200, y: 100 }); + }); + + it('does not reduce an already-legible zoom when auto-zooming', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 2 }, true).z).toBe(2); + }); + + it('leaves zoom untouched when auto-zoom is off', () => { + expect(recentreOn(node, base, { x: 0, y: 0, z: 0.4 }, false).z).toBe(0.4); + }); +}); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 5ef7ef4a..7777a615 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -14,6 +14,9 @@ export const MIN_ZOOM = 0.25; export const MAX_ZOOM = 3.0; export const DEFAULT_ZOOM = 1; +/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */ +export const LEGIBLE_ZOOM = 1; + /** * The canvas view state. `x`/`y` are pan offsets applied to the viewBox centre * (SVG user units); `z` is the zoom factor. The default `{0, 0, 1}` frames the @@ -106,3 +109,23 @@ export function zoomAtPoint( z }; } + +/** + * Pan so a node sits at the viewBox centre (US-PAN-005). Because the viewBox + * centre is `baseCentre + pan` independent of zoom, centring is a pure pan: + * `pan = nodeCentre - baseCentre`. When `autoZoom` is set, a zoomed-out view is + * snapped up to {@link LEGIBLE_ZOOM} so the focal node's text is readable + * (OQ-005); an already-legible zoom is preserved. + */ +export function recentreOn( + nodeCentre: { x: number; y: number }, + baseCentre: { x: number; y: number }, + state: PanZoomState, + autoZoom: boolean +): PanZoomState { + return { + x: nodeCentre.x - baseCentre.x, + y: nodeCentre.y - baseCentre.y, + z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z + }; +}