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,
|
ROW_GAP,
|
||||||
type Layout
|
type Layout
|
||||||
} from '$lib/person/genealogy/layout/buildLayout';
|
} from '$lib/person/genealogy/layout/buildLayout';
|
||||||
|
import type { PanZoomState } from '$lib/person/genealogy/panZoom';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -16,7 +17,7 @@ interface Props {
|
|||||||
nodes: PersonNodeDTO[];
|
nodes: PersonNodeDTO[];
|
||||||
edges: RelationshipDTO[];
|
edges: RelationshipDTO[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
zoom: number;
|
panZoom: PanZoomState;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
/**
|
/**
|
||||||
* Force-show or force-hide the generation gutter. When undefined, falls
|
* Force-show or force-hide the generation gutter. When undefined, falls
|
||||||
@@ -27,7 +28,7 @@ interface Props {
|
|||||||
showGutter?: boolean;
|
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));
|
const layout = $derived.by<Layout>(() => buildLayout(nodes, edges));
|
||||||
|
|
||||||
@@ -79,12 +80,20 @@ const gutterRows = $derived.by<GutterRow[]>(() => {
|
|||||||
return rows;
|
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 viewBox = $derived.by(() => {
|
||||||
const totalW = layout.viewW + gutterWidth;
|
const w = baseDims.w / panZoom.z;
|
||||||
const w = totalW / zoom;
|
const h = baseDims.h / panZoom.z;
|
||||||
const h = layout.viewH / zoom;
|
const cx = baseCentre.x + panZoom.x;
|
||||||
const cx = layout.viewX - gutterWidth + totalW / 2;
|
const cy = baseCentre.y + panZoom.y;
|
||||||
const cy = layout.viewY + layout.viewH / 2;
|
|
||||||
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,35 @@ function rectsCentroid(svg: SVGElement): { x: number; y: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('StammbaumTree viewBox', () => {
|
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 () => {
|
it('uses the minimum size and centers a single node', async () => {
|
||||||
render(StammbaumTree, {
|
render(StammbaumTree, {
|
||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,7 +137,7 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +197,7 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,7 +300,7 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +358,7 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,7 +391,7 @@ describe('StammbaumTree viewBox', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -393,7 +416,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: ID_A,
|
selectedId: ID_A,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,7 +432,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -422,7 +445,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true, deathYear: 1950 }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -434,7 +457,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,7 +470,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect
|
onSelect
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -462,7 +485,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect
|
onSelect
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,7 +501,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect
|
onSelect
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,7 +516,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect
|
onSelect
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,7 +543,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -547,7 +570,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -575,7 +598,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -588,7 +611,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -607,7 +630,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
nodes: [{ id: ID_A, displayName: 'Anna', familyMember: true }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -636,7 +659,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,7 +676,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: ID_A,
|
selectedId: ID_A,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,7 +697,7 @@ describe('StammbaumTree node rendering branches', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: ID_A,
|
selectedId: ID_A,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {}
|
onSelect: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -696,7 +719,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
|
|||||||
],
|
],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
showGutter: true
|
showGutter: true
|
||||||
});
|
});
|
||||||
@@ -713,7 +736,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
showGutter: true
|
showGutter: true
|
||||||
});
|
});
|
||||||
@@ -730,7 +753,7 @@ describe('StammbaumTree generation gutter (#689)', () => {
|
|||||||
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
nodes: [{ id: ID_A, displayName: 'Herbert', familyMember: true, generation: 3 }],
|
||||||
edges: [],
|
edges: [],
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
zoom: 1,
|
panZoom: { x: 0, y: 0, z: 1 },
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
showGutter: false
|
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). */
|
/** Minimum zoom a recentre will snap up to so the focal node's text is legible (OQ-005). */
|
||||||
export const LEGIBLE_ZOOM = 1;
|
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
|
* 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
|
* (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 { page } from '$app/state';
|
||||||
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
import StammbaumTree from '$lib/person/genealogy/StammbaumTree.svelte';
|
||||||
import StammbaumSidePanel from '$lib/person/genealogy/StammbaumSidePanel.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';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
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);
|
const selectedNode = $derived(data.nodes.find((n) => n.id === selectedId) ?? null);
|
||||||
|
|
||||||
let zoom = $state(1);
|
let view = $state<PanZoomState>(DEFAULT_VIEW);
|
||||||
function zoomIn() {
|
function zoomIn() {
|
||||||
zoom = Math.min(2, zoom + 0.1);
|
view = { ...view, z: clampZoom(view.z + ZOOM_STEP_KB) };
|
||||||
}
|
}
|
||||||
function zoomOut() {
|
function zoomOut() {
|
||||||
zoom = Math.max(0.4, zoom - 0.1);
|
view = { ...view, z: clampZoom(view.z - ZOOM_STEP_KB) };
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -97,7 +103,7 @@ function zoomOut() {
|
|||||||
nodes={data.nodes}
|
nodes={data.nodes}
|
||||||
edges={data.edges}
|
edges={data.edges}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
zoom={zoom}
|
panZoom={view}
|
||||||
onSelect={(id) => (selectedId = id)}
|
onSelect={(id) => (selectedId = id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user