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:
@@ -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}`;
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user