diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 811b2c9f..711dc6f2 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -12,8 +12,9 @@ import { import { type PanZoomState, clampZoom, + clampPan, recentreOn, - topLeftView, + cornerView, ZOOM_STEP_KB } from '$lib/person/genealogy/panZoom'; import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures'; @@ -143,10 +144,30 @@ const railRows = $derived( .map((r) => ({ rank: r.rank, label: r.label, centerY: r.y + NODE_H / 2 })) ); -// A fresh visit (no shared URL state) lands on the tree's top-left corner rather -// than its centre (#692). Runs once after layout is available. +// A fresh visit (no shared URL state) lands on the tree's content top-left +// rather than its centre (#692). Anchors to the first row / leftmost node (not +// the padded frame corner, which would leave empty space above row 1), with a +// small margin. Runs once after layout is available. +const ANCHOR_MARGIN = 24; onMount(() => { - if (anchorTopLeft) onPanZoom(topLeftView(baseDims.w, baseDims.h, panZoom.z)); + if (!anchorTopLeft) return; + let minX = Infinity; + let minY = Infinity; + for (const pos of layout.positions.values()) { + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + } + if (!Number.isFinite(minX)) return; // no nodes + const target = cornerView( + minX - ANCHOR_MARGIN, + minY - ANCHOR_MARGIN, + baseCentre.x, + baseCentre.y, + baseDims.w, + baseDims.h, + panZoom.z + ); + onPanZoom(clampPan(target, baseDims.w, baseDims.h)); }); const viewBox = $derived.by(() => { @@ -309,7 +330,7 @@ const parentLinks = $derived.by(() => { { }); }); -describe('topLeftView', () => { - it('aligns the viewBox top-left with the tree corner at the given zoom', () => { - expect(topLeftView(1000, 800, 2)).toEqual({ x: -250, y: -200, z: 2 }); +describe('cornerView', () => { + // Frame 0..1000 × 0..800, centre (500, 400). + it('puts the viewBox top-left on the target SVG point', () => { + // Target = content top-left at (100, 80), z=2 → viewBox is 500×400. + expect(cornerView(100, 80, 500, 400, 1000, 800, 2)).toEqual({ x: -150, y: -120, z: 2 }); }); - it('sits exactly at the negative clamp limit (the corner is reachable)', () => { - const v = topLeftView(1000, 800, 3); - // clampPan must leave the corner view untouched (it is on the boundary). - expect(clampPan(v, 1000, 800)).toEqual(v); + it('reduces to the frame corner when the target is the frame top-left', () => { + // Target = frame top-left (0, 0) → most-negative (corner) pan. + const v = cornerView(0, 0, 500, 400, 1000, 800, 3); + expect(clampPan(v, 1000, 800)).toEqual(v); // on the clamp boundary }); }); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 00d480a9..a19dddef 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -196,12 +196,25 @@ export function clampPan(state: PanZoomState, baseW: number, baseH: number): Pan } /** - * The view whose viewBox top-left aligns with the tree's top-left corner at zoom - * `z` (the landing view for a fresh visit). This is the pan at the most-negative - * edge of the pannable range, i.e. `-clampPan` limit on each axis. + * The view whose viewBox top-left lands on the SVG point (`targetX`, `targetY`) + * at zoom `z` — used to anchor a fresh visit to the tree's content corner. + * Pass the content bounding-box top-left (not the padded frame corner) so the + * first row sits near the top with no empty slack above it. */ -export function topLeftView(baseW: number, baseH: number, z: number): PanZoomState { - return { x: (baseW / z - baseW) / 2, y: (baseH / z - baseH) / 2, z }; +export function cornerView( + targetX: number, + targetY: number, + baseCentreX: number, + baseCentreY: number, + baseW: number, + baseH: number, + z: number +): PanZoomState { + return { + x: targetX - baseCentreX + baseW / z / 2, + y: targetY - baseCentreY + baseH / z / 2, + z + }; } /**