feat(stammbaum): land a fresh visit on the tree's top-left corner (#692)
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m35s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m33s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s

At z=3 a pan of {0,0} centres on the tree midpoint; a fresh visit (no shared
?z) now anchors the viewBox to the tree's top-left corner via topLeftView
(the negative clamp limit), emitted on mount. Shared links still win.
Verified live: lands at cx<0, cy<0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 19:25:03 +02:00
parent 01b902e885
commit b1309db8db
4 changed files with 34 additions and 1 deletions

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { untrack, onMount } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { components } from '$lib/generated/api';
import {
@@ -13,6 +13,7 @@ import {
type PanZoomState,
clampZoom,
recentreOn,
topLeftView,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import { panZoomGestures } from '$lib/person/genealogy/panZoomGestures';
@@ -32,6 +33,8 @@ interface Props {
centreOnId?: string | null;
/** Fired on the first pointer interaction with the canvas (affordance dismiss). */
onActivity?: () => void;
/** When true, the initial view is anchored to the tree's top-left corner. */
anchorTopLeft?: boolean;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -50,6 +53,7 @@ let {
onPanZoom = () => {},
centreOnId = null,
onActivity,
anchorTopLeft = false,
onSelect,
showGutter
}: Props = $props();
@@ -139,6 +143,12 @@ 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.
onMount(() => {
if (anchorTopLeft) onPanZoom(topLeftView(baseDims.w, baseDims.h, panZoom.z));
});
const viewBox = $derived.by(() => {
const w = baseDims.w / panZoom.z;
const h = baseDims.h / panZoom.z;

View File

@@ -9,6 +9,7 @@ import {
stepInertia,
recentreOn,
clampPan,
topLeftView,
lerpView,
DEFAULT_VIEW,
DEFAULT_ZOOM,
@@ -207,6 +208,18 @@ describe('clampPan', () => {
});
});
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 });
});
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);
});
});
describe('lerpView', () => {
const from = { x: 0, y: 0, z: 1 };
const to = { x: 100, y: -40, z: 2 };

View File

@@ -195,6 +195,15 @@ export function clampPan(state: PanZoomState, baseW: number, baseH: number): Pan
return { x: clampAxis(state.x, baseW), y: clampAxis(state.y, baseH), z: state.z };
}
/**
* 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.
*/
export function topLeftView(baseW: number, baseH: number, z: number): PanZoomState {
return { x: (baseW / z - baseW) / 2, y: (baseH / z - baseH) / 2, 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:

View File

@@ -140,6 +140,7 @@ $effect(() => {
selectedId={selectedId}
panZoom={view}
centreOnId={centreOnId}
anchorTopLeft={!page.url.searchParams.has('z')}
onPanZoom={(v) => (view = v)}
onActivity={() => (canvasActivity = true)}
onSelect={(id) => (selectedId = id)}