From 0a7b4fa265e72ac17dd8e5fef373a5caf082ad8a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 31 May 2026 16:37:38 +0200 Subject: [PATCH] feat(stammbaum): add recentreAbove pan helper for the mobile anchor (#703) recentreAbove recentres on a node and lifts it above the viewBox centre by a fraction of the zoomed viewBox height, measured against the auto-zoomed height. On a phone this lands the tapped anchor in the band above the bottom sheet instead of behind it (AC8). A zero bias is exactly a legible recentre. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/person/genealogy/panZoom.test.ts | 29 +++++++++++++++++++ frontend/src/lib/person/genealogy/panZoom.ts | 18 ++++++++++++ 2 files changed, 47 insertions(+) diff --git a/frontend/src/lib/person/genealogy/panZoom.test.ts b/frontend/src/lib/person/genealogy/panZoom.test.ts index 67af1fa3..811ffa93 100644 --- a/frontend/src/lib/person/genealogy/panZoom.test.ts +++ b/frontend/src/lib/person/genealogy/panZoom.test.ts @@ -8,6 +8,7 @@ import { pinchZoom, stepInertia, recentreOn, + recentreAbove, clampPan, cornerView, lerpView, @@ -188,6 +189,34 @@ describe('recentreOn', () => { }); }); +describe('recentreAbove', () => { + const node = { x: 300, y: 200 }; + const base = { x: 100, y: 100 }; + const baseH = 800; + + it('matches the auto-zooming recentre when the bias is zero', () => { + expect(recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0)).toEqual( + recentreOn(node, base, { x: 0, y: 0, z: 2 }, true) + ); + }); + + it('lifts the node up by a fraction of the zoomed viewBox height (clears the bottom sheet)', () => { + // recentreOn centres the node (pan.y = 100) at z=2; the bias adds + // 0.3 * (baseH / z) = 0.3 * 400 = 120, so the node sits ~20% from the top. + const next = recentreAbove(node, base, { x: 0, y: 0, z: 2 }, baseH, 0.3); + expect(next.x).toBe(200); + expect(next.z).toBe(2); + expect(next.y).toBeCloseTo(100 + 0.3 * (baseH / 2), 6); + }); + + it('measures the bias against the auto-zoomed height when starting zoomed out', () => { + // z=0.4 auto-zooms up to LEGIBLE_ZOOM (1); the bias then uses baseH / 1. + const next = recentreAbove(node, base, { x: 0, y: 0, z: 0.4 }, baseH, 0.3); + expect(next.z).toBe(LEGIBLE_ZOOM); + expect(next.y).toBeCloseTo(100 + 0.3 * baseH, 6); + }); +}); + describe('clampPan', () => { // Base frame is 1000 x 800. it('forbids panning when the whole tree fits (z <= 1)', () => { diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index a19dddef..1f4a99e2 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -236,3 +236,21 @@ export function recentreOn( z: autoZoom ? clampZoom(Math.max(state.z, LEGIBLE_ZOOM)) : state.z }; } + +/** + * Recentre on a node but lift it above the viewBox centre by `biasFraction` of + * the zoomed viewBox height, so on a phone the tapped anchor lands in the band + * above the bottom sheet rather than behind it (#703 AC8). The bias is measured + * against the auto-zoomed height ({@link recentreOn} may snap zoom up to + * {@link LEGIBLE_ZOOM}), and a zero bias is exactly a legible recentre. + */ +export function recentreAbove( + nodeCentre: { x: number; y: number }, + baseCentre: { x: number; y: number }, + state: PanZoomState, + baseH: number, + biasFraction: number +): PanZoomState { + const centred = recentreOn(nodeCentre, baseCentre, state, true); + return { ...centred, y: centred.y + biasFraction * (baseH / centred.z) }; +}