diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 8ab4af76..3fa83409 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -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(() => buildLayout(nodes, edges)); @@ -79,12 +80,20 @@ const gutterRows = $derived.by(() => { 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}`; }); diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts index 57c2bd48..a0dacba6 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte.test.ts @@ -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 }); diff --git a/frontend/src/lib/person/genealogy/panZoom.ts b/frontend/src/lib/person/genealogy/panZoom.ts index 7777a615..9110bb10 100644 --- a/frontend/src/lib/person/genealogy/panZoom.ts +++ b/frontend/src/lib/person/genealogy/panZoom.ts @@ -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 diff --git a/frontend/src/routes/stammbaum/+page.svelte b/frontend/src/routes/stammbaum/+page.svelte index f2c774dc..2c3ee9db 100644 --- a/frontend/src/routes/stammbaum/+page.svelte +++ b/frontend/src/routes/stammbaum/+page.svelte @@ -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( const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null); -let zoom = $state(1); +let view = $state(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) }; } @@ -97,7 +103,7 @@ function zoomOut() { nodes={data.nodes} edges={data.edges} selectedId={selectedId} - zoom={zoom} + panZoom={view} onSelect={(id) => (selectedId = id)} />