From c40cc05f68dbcdd0a325379fc146017165b56341 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 21:44:19 +0200 Subject: [PATCH] feat(stammbaum): tree visual polish + parent-midpoint layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns the SVG tree with docs/specs/stammbaum-tree-spec.html: - Node outline: var(--c-primary) at stroke-width=1.5 (was the much paler --c-line at 1) and selected text uses var(--c-primary-fg) so it remains readable on the dark/light primary fill - Spouse line and parent-child line now share the same stroke style; spouse keeps the midpoint dot (radius bumped to 4.5 per spec) - When two parents are connected by SPOUSE_OF, draw a single shared parent-pair → child line from the spouse midpoint instead of two diverging lines - ViewBox: enforces a 1200×800 minimum and centers the content so a single node no longer scales up to fill the whole canvas in the top-left - Children are positioned at the average of their parents' x and packed left-to-right per row, keeping connectors close to vertical Adds component tests for the centring, the shared parent-pair link (verified vertical), and the fallback to two lines when parents are not spouses. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/StammbaumTree.svelte | 189 +++++++++++++--- .../components/StammbaumTree.svelte.test.ts | 203 ++++++++++++++++++ 2 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/components/StammbaumTree.svelte.test.ts diff --git a/frontend/src/lib/components/StammbaumTree.svelte b/frontend/src/lib/components/StammbaumTree.svelte index 73b625bc..991c1140 100644 --- a/frontend/src/lib/components/StammbaumTree.svelte +++ b/frontend/src/lib/components/StammbaumTree.svelte @@ -20,16 +20,29 @@ const NODE_W = 160; const NODE_H = 56; const COL_GAP = 40; const ROW_GAP = 80; +const VIEWBOX_PAD = 80; +// Minimum viewBox dimensions — keeps a single node from being scaled up +// to fill the entire canvas. Roughly matches a typical desktop content area. +const MIN_VIEWBOX_W = 1200; +const MIN_VIEWBOX_H = 800; type Layout = { positions: Map; generations: Map; - width: number; - height: number; + viewX: number; + viewY: number; + viewW: number; + viewH: number; }; const layout = $derived.by(() => buildLayout(nodes, edges)); -const viewBox = $derived(`0 0 ${layout.width / zoom} ${layout.height / zoom}`); +const viewBox = $derived.by(() => { + const w = layout.viewW / zoom; + const h = layout.viewH / zoom; + const cx = layout.viewX + layout.viewW / 2; + const cy = layout.viewY + layout.viewH / 2; + return `${cx - w / 2} ${cy - h / 2} ${w} ${h}`; +}); function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout { const parentToChildren = new Map(); @@ -95,21 +108,69 @@ function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): La }); } + // Position roots left-to-right; for every later generation, place each + // child below the midpoint of its parents and then pack the row left-to- + // right with a minimum gap. Keeps parent → child connectors close to + // vertical instead of fanning out diagonally. const positions = new Map(); - let maxRowWidth = 0; const sortedGens = [...generations.keys()].sort((a, b) => a - b); - for (const g of sortedGens) { + for (let gi = 0; gi < sortedGens.length; gi++) { + const g = sortedGens[gi]; const ids = generations.get(g)!; - ids.forEach((id, idx) => { - positions.set(id, { - x: idx * (NODE_W + COL_GAP), - y: g * (NODE_H + ROW_GAP) + const y = g * (NODE_H + ROW_GAP); + if (gi === 0) { + ids.forEach((id, idx) => { + positions.set(id, { x: idx * (NODE_W + COL_GAP), y }); }); - }); - maxRowWidth = Math.max(maxRowWidth, ids.length * (NODE_W + COL_GAP)); + continue; + } + const preferredX = new Map(); + for (const id of ids) { + const parentXs: number[] = []; + for (const parentId of childToParents.get(id) ?? []) { + const p = positions.get(parentId); + if (p) parentXs.push(p.x); + } + preferredX.set( + id, + parentXs.length > 0 ? parentXs.reduce((a, b) => a + b, 0) / parentXs.length : 0 + ); + } + const ordered = [...ids].sort((a, b) => (preferredX.get(a) ?? 0) - (preferredX.get(b) ?? 0)); + let cursorX = -Infinity; + for (const id of ordered) { + const x = Math.max(preferredX.get(id) ?? 0, cursorX); + positions.set(id, { x, y }); + cursorX = x + NODE_W + COL_GAP; + } } - const height = (sortedGens.length || 1) * (NODE_H + ROW_GAP); - return { positions, generations, width: maxRowWidth + 80, height: height + 80 }; + + // Bounding box around the actual content, then expanded to MIN dimensions + // (so a single node doesn't get scaled up to fill the canvas). The viewBox + // is centered on the content. + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of positions.values()) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x + NODE_W); + maxY = Math.max(maxY, p.y + NODE_H); + } + if (positions.size === 0) { + minX = 0; + minY = 0; + maxX = 0; + maxY = 0; + } + const contentW = maxX - minX; + const contentH = maxY - minY; + const viewW = Math.max(contentW + 2 * VIEWBOX_PAD, MIN_VIEWBOX_W); + const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H); + const viewX = minX + contentW / 2 - viewW / 2; + const viewY = minY + contentH / 2 - viewH / 2; + return { positions, generations, viewX, viewY, viewW, viewH }; } function mapPush(map: Map, key: K, value: V) { @@ -133,6 +194,58 @@ function handleNodeKey(event: KeyboardEvent, id: string) { const parentEdges = $derived(edges.filter((e) => e.relationType === 'PARENT_OF')); const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF')); + +function pairKey(a: string, b: string): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + +type ParentLinks = { + shared: { key: string; parentA: string; parentB: string; childId: string }[]; + single: { key: string; parentId: string; childId: string }[]; +}; + +const parentLinks = $derived.by(() => { + const spousePairs = new Set(); + for (const e of spouseEdges) { + spousePairs.add(pairKey(e.personId, e.relatedPersonId)); + } + + const childToParents = new Map(); + for (const e of parentEdges) { + const list = childToParents.get(e.relatedPersonId) ?? []; + list.push(e.personId); + childToParents.set(e.relatedPersonId, list); + } + + const shared: ParentLinks['shared'] = []; + const single: ParentLinks['single'] = []; + for (const [childId, parents] of childToParents) { + const consumed = new Set(); + for (let i = 0; i < parents.length; i++) { + if (consumed.has(parents[i])) continue; + for (let j = i + 1; j < parents.length; j++) { + if (consumed.has(parents[j])) continue; + if (spousePairs.has(pairKey(parents[i], parents[j]))) { + shared.push({ + key: `${pairKey(parents[i], parents[j])}->${childId}`, + parentA: parents[i], + parentB: parents[j], + childId + }); + consumed.add(parents[i]); + consumed.add(parents[j]); + break; + } + } + } + for (const parentId of parents) { + if (consumed.has(parentId)) continue; + single.push({ key: `${parentId}->${childId}`, parentId, childId }); + } + } + + return { shared, single }; +}); e.relationType === 'SPOUSE_OF') aria-label="Stammbaum" class="block h-full w-full" > - - {#each parentEdges as e (e.id)} - {@const parentCenter = nodeCenter(e.personId)} - {@const childCenter = nodeCenter(e.relatedPersonId)} + + {#each parentLinks.shared as link (link.key)} + {@const aCenter = nodeCenter(link.parentA)} + {@const bCenter = nodeCenter(link.parentB)} + {@const childCenter = nodeCenter(link.childId)} + {#if aCenter && bCenter && childCenter} + + {/if} + {/each} + + + {#each parentLinks.single as link (link.key)} + {@const parentCenter = nodeCenter(link.parentId)} + {@const childCenter = nodeCenter(link.childId)} {#if parentCenter && childCenter} {/if} @@ -168,15 +298,15 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF') y1={aCenter.y} x2={bCenter.x} y2={bCenter.y} - stroke="var(--c-accent, #00c7b1)" - stroke-width="2" + stroke="var(--c-primary)" + stroke-width="1.5" stroke-dasharray={e.toYear ? '4 4' : undefined} /> {/if} {/each} @@ -202,12 +332,12 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF') width={NODE_W} height={NODE_H} rx="4" - fill={isSelected ? 'var(--c-primary, #002850)' : 'var(--c-surface, white)'} - stroke="var(--c-line, #d4d4d4)" - stroke-width="1" + fill={isSelected ? 'var(--c-primary)' : 'var(--c-surface)'} + stroke="var(--c-primary)" + stroke-width="1.5" /> {#if isSelected} - + {/if} e.relationType === 'SPOUSE_OF') text-anchor="middle" font-family="serif" font-size="14" - fill={isSelected ? 'white' : 'var(--c-ink, #002850)'} + fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink)'} > {node.displayName} @@ -226,9 +356,8 @@ const spouseEdges = $derived(edges.filter((e) => e.relationType === 'SPOUSE_OF') text-anchor="middle" font-family="sans-serif" font-size="10" - fill={isSelected - ? 'rgba(255,255,255,0.7)' - : 'var(--c-ink-3, #6b7280)'} + fill={isSelected ? 'var(--c-primary-fg)' : 'var(--c-ink-3)'} + opacity={isSelected ? 0.75 : 1} > {node.birthYear ?? '?'}–{node.deathYear ?? ''} diff --git a/frontend/src/lib/components/StammbaumTree.svelte.test.ts b/frontend/src/lib/components/StammbaumTree.svelte.test.ts new file mode 100644 index 00000000..955914d1 --- /dev/null +++ b/frontend/src/lib/components/StammbaumTree.svelte.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumTree from './StammbaumTree.svelte'; + +const ID_A = '00000000-0000-0000-0000-000000000001'; +const ID_B = '00000000-0000-0000-0000-000000000002'; + +function parseViewBox(svg: SVGElement): [number, number, number, number] { + const parts = svg.getAttribute('viewBox')!.split(/\s+/).map(Number); + return [parts[0], parts[1], parts[2], parts[3]]; +} + +describe('StammbaumTree viewBox', () => { + 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, + onSelect: () => {} + }); + + const svg = document.querySelector('svg')!; + const [x, y, w, h] = parseViewBox(svg); + + // Single 160x56 node fits inside the 1200x800 minimum viewBox. + expect(w).toBe(1200); + expect(h).toBe(800); + + // Node sits at content (0,0)–(160,56). Its center should be the + // viewBox center → x + w/2 ≈ 80, y + h/2 ≈ 28. + expect(x + w / 2).toBeCloseTo(80, 5); + expect(y + h / 2).toBeCloseTo(28, 5); + }); + + it('draws one shared line from spouse midpoint when both parents share a child', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD = '00000000-0000-0000-0000-00000000000c'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'sp', + personId: PARENT_A, + relatedPersonId: PARENT_B, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1', + personId: PARENT_A, + relatedPersonId: CHILD, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2', + personId: PARENT_B, + relatedPersonId: CHILD, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const lines = Array.from(document.querySelectorAll('svg line')); + // Parent-child lines: count anything that's not the spouse-pair link. + const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); + expect(parentLines).toHaveLength(1); + }); + + it('positions a single child at the midpoint of its two parents', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD = '00000000-0000-0000-0000-00000000000c'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'sp', + personId: PARENT_A, + relatedPersonId: PARENT_B, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1', + personId: PARENT_A, + relatedPersonId: CHILD, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2', + personId: PARENT_B, + relatedPersonId: CHILD, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + // The shared parent-child line goes from the spouse midpoint to the + // top of the child node. With centring, x1 must equal x2 → vertical. + const lines = Array.from(document.querySelectorAll('svg line')); + const slopedLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); + expect(slopedLines).toHaveLength(1); + const link = slopedLines[0]; + expect(link.getAttribute('x1')).toEqual(link.getAttribute('x2')); + }); + + it('falls back to two separate lines when both parents are not spouses', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD = '00000000-0000-0000-0000-00000000000c'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'p1', + personId: PARENT_A, + relatedPersonId: CHILD, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2', + personId: PARENT_B, + relatedPersonId: CHILD, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const lines = Array.from(document.querySelectorAll('svg line')); + const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); + expect(parentLines).toHaveLength(2); + }); + + it('centers two spouse nodes within the minimum viewBox', async () => { + render(StammbaumTree, { + nodes: [ + { id: ID_A, displayName: 'Anna', familyMember: true }, + { id: ID_B, displayName: 'Bertha', familyMember: true } + ], + edges: [ + { + id: 'e1', + personId: ID_A, + relatedPersonId: ID_B, + personDisplayName: 'Anna', + relatedPersonDisplayName: 'Bertha', + relationType: 'SPOUSE_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const svg = document.querySelector('svg')!; + const [x, y, w, h] = parseViewBox(svg); + + expect(w).toBe(1200); + expect(h).toBe(800); + + // Two nodes side by side: positions (0,0) and (200,0). Right edge + // at 200+160 = 360. Content center x = 180, y = 28. + expect(x + w / 2).toBeCloseTo(180, 5); + expect(y + h / 2).toBeCloseTo(28, 5); + }); +});