feat(stammbaum): drive viewBox from PanZoomState (pan + zoom) (#692)

Replace the scalar zoom prop with a {x,y,z} PanZoomState. The viewBox centre
is offset by the pan and width/height scaled by zoom; the default {0,0,1}
frames the whole tree (fit-to-screen). Page header buttons now step view.z
through clampZoom over the resolved 0.25–3.0 range.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-29 16:35:49 +02:00
parent 197b668f20
commit 0422af8980
4 changed files with 77 additions and 36 deletions

View File

@@ -8,6 +8,7 @@ import {
ROW_GAP,
type Layout
} from '$lib/person/genealogy/layout/buildLayout';
import type { PanZoomState } from '$lib/person/genealogy/panZoom';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -16,7 +17,7 @@ interface Props {
nodes: PersonNodeDTO[];
edges: RelationshipDTO[];
selectedId: string | null;
zoom: number;
panZoom: PanZoomState;
onSelect: (id: string) => void;
/**
* Force-show or force-hide the generation gutter. When undefined, falls
@@ -27,7 +28,7 @@ interface Props {
showGutter?: boolean;
}
let { nodes, edges, selectedId, zoom, onSelect, showGutter }: Props = $props();
let { nodes, edges, selectedId, panZoom, onSelect, showGutter }: Props = $props();
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
@@ -79,12 +80,20 @@ const gutterRows = $derived.by<GutterRow[]>(() => {
return rows;
});
// Base viewBox geometry at z=1, no pan — the whole tree framed (#692). Pan
// offsets shift the centre; zoom scales width/height inversely. The default
// {x:0,y:0,z:1} therefore fits the tree to the element (fit-to-screen).
const baseDims = $derived({ w: layout.viewW + gutterWidth, h: layout.viewH });
const baseCentre = $derived({
x: layout.viewX - gutterWidth + baseDims.w / 2,
y: layout.viewY + layout.viewH / 2
});
const viewBox = $derived.by(() => {
const totalW = layout.viewW + gutterWidth;
const w = totalW / zoom;
const h = layout.viewH / zoom;
const cx = layout.viewX - gutterWidth + totalW / 2;
const cy = layout.viewY + layout.viewH / 2;
const w = baseDims.w / panZoom.z;
const h = baseDims.h / panZoom.z;
const cx = baseCentre.x + panZoom.x;
const cy = baseCentre.y + panZoom.y;
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
});

View File

@@ -36,12 +36,35 @@ function rectsCentroid(svg: SVGElement): { x: number; y: number } {
}
describe('StammbaumTree viewBox', () => {
it('offsets the viewBox origin by the pan state (#692)', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
panZoom: { x: 100, y: 40, z: 1 },
showGutter: false,
onSelect: () => {}
});
const svg = document.querySelector('svg')!;
const [x, y, w, h] = parseViewBox(svg);
// Same dimensions as the unpanned default (z=1)…
expect(w).toBe(1200);
expect(h).toBe(800);
// …but the origin is shifted by the pan offset.
const unpannedX = -(1200 / 2 - 160 / 2); // single 160-wide node centred
expect(x).toBeCloseTo(unpannedX + 100, 6);
expect(y).toBeCloseTo(-(800 / 2 - 56 / 2) + 40, 6);
});
it('uses the minimum size and centers a single node', async () => {
render(StammbaumTree, {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -114,7 +137,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -174,7 +197,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -277,7 +300,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -335,7 +358,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -368,7 +391,7 @@ describe('StammbaumTree viewBox', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -393,7 +416,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -409,7 +432,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -422,7 +445,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -434,7 +457,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -447,7 +470,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -462,7 +485,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -478,7 +501,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -493,7 +516,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect
});
@@ -520,7 +543,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -547,7 +570,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -575,7 +598,7 @@ describe('StammbaumTree node rendering branches', () => {
}
],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -588,7 +611,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -607,7 +630,7 @@ describe('StammbaumTree node rendering branches', () => {
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -636,7 +659,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -653,7 +676,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -674,7 +697,7 @@ describe('StammbaumTree node rendering branches', () => {
],
edges: [],
selectedId: ID_A,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {}
});
@@ -696,7 +719,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
});
@@ -713,7 +736,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: true
});
@@ -730,7 +753,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
edges: [],
selectedId: null,
zoom: 1,
panZoom: { x: 0, y: 0, z: 1 },
onSelect: () => {},
showGutter: false
});

View File

@@ -17,6 +17,9 @@ 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;
/** Fixed zoom increment per keyboard `+`/`-` press and per control-button click (OQ-002). */
export const ZOOM_STEP_KB = 0.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

View File

@@ -3,6 +3,12 @@ import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.svelte';
import {
type PanZoomState,
DEFAULT_VIEW,
clampZoom,
ZOOM_STEP_KB
} from '$lib/person/genealogy/panZoom';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
@@ -23,12 +29,12 @@ let selectedId = $state<string | null>(
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
let zoom = $state(1);
let view = $state<PanZoomState>(DEFAULT_VIEW);
function zoomIn() {
zoom = Math.min(2, zoom + 0.1);
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
}
function zoomOut() {
zoom = Math.max(0.4, zoom - 0.1);
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
}
</script>
@@ -97,7 +103,7 @@ function zoomOut() {
nodes={data.nodes}
edges={data.edges}
selectedId={selectedId}
zoom={zoom}
panZoom={view}
onSelect={(id) => (selectedId = id)}
/>
</div>