From 800bddd604d001048327a7577b7812a80641b9fa Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 28 Apr 2026 08:35:32 +0200 Subject: [PATCH] fix(stammbaum): iterative generation + spouse-adjacent block layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two distinct bugs surfaced once a 3-generation tree was loaded (Walter+Eugenie → Hans+Clara, Hans married to Hilde with child Lili): 1. Generation BFS was non-iterative. Hilde was visited as a "root" first, assigning Lili = gen 1, then Hilde was pulled to gen 1 to match her spouse Hans — but Lili's depth was never recomputed, leaving her on the same row as her parents. Replaced the BFS with an iterative longest-path assignment that re-runs (max parent gen + 1) and the spouse-shared-row rule together until stable. 2. No spouse adjacency. Hilde (no parents in the graph) ended up in her own block on the far left, with Hans + Clara to her right and the spouse line drawn straight across Clara's box. Replaced the per-parent-set grouping with a block model: - sibling-blocks group children of the same parent set - loose spouses attach on the outer edge of their partner's block - dual-loose spouse pairs merge into one 2-person block - each block is centred so its parented members' average sits exactly under the parent midpoint, keeping all connectors at 90° Adds a regression test for the full Walter/Eugenie/Hans/Clara/Hilde/ Lili scenario (Lili in a deeper row, Hans+Hilde adjacent, no slanted segments) and rewrites the viewBox tests to be position-agnostic via a rect-centroid helper that reads the per-node ``. Tracked the eventual move to dagre (multi-marriage / cross-cousin / ~50+ nodes) in #361. Co-Authored-By: Claude Opus 4.7 --- .../src/lib/components/StammbaumTree.svelte | 303 ++++++++++++++---- .../components/StammbaumTree.svelte.test.ts | 282 ++++++++++++---- 2 files changed, 447 insertions(+), 138 deletions(-) diff --git a/frontend/src/lib/components/StammbaumTree.svelte b/frontend/src/lib/components/StammbaumTree.svelte index 991c1140..2282de61 100644 --- a/frontend/src/lib/components/StammbaumTree.svelte +++ b/frontend/src/lib/components/StammbaumTree.svelte @@ -62,34 +62,43 @@ function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): La } } - // Generation assignment via BFS from roots (nodes with no parents in graph). + // Iterative longest-path generation assignment. + // + // Each node's generation = max(parent generations) + 1 (roots stay at 0). + // Then spouses are pulled to share the deeper generation. Pulling a spouse + // down can shift their own descendants, so we iterate until stable rather + // than running BFS once like the previous implementation (which left + // e.g. a child of a "later-pulled" spouse stranded one row too high). const generation = new Map(); - const queue: string[] = []; - for (const n of allNodes) { - if (!childToParents.has(n.id)) { - generation.set(n.id, 0); - queue.push(n.id); - } - } - while (queue.length > 0) { - const id = queue.shift()!; - const g = generation.get(id) ?? 0; - for (const childId of parentToChildren.get(id) ?? []) { - if (!generation.has(childId)) { - generation.set(childId, g + 1); - queue.push(childId); + for (const n of allNodes) generation.set(n.id, 0); + const maxIters = allNodes.length + 4; + for (let it = 0; it < maxIters; it++) { + let changed = false; + for (const n of allNodes) { + const parents = childToParents.get(n.id) ?? []; + if (parents.length === 0) continue; + let maxParentGen = -1; + for (const pid of parents) { + maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0); + } + const newGen = maxParentGen + 1; + if ((generation.get(n.id) ?? 0) < newGen) { + generation.set(n.id, newGen); + changed = true; } } - } - // Anything not assigned (cycles or isolated nodes after a graph slice) → gen 0. - for (const n of allNodes) { - if (!generation.has(n.id)) generation.set(n.id, 0); - } - // Spouses share the deeper generation so they sit on the same row. - for (const [a, b] of spousePairs) { - const g = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0); - generation.set(a, g); - generation.set(b, g); + for (const [a, b] of spousePairs) { + const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0); + if ((generation.get(a) ?? 0) < m) { + generation.set(a, m); + changed = true; + } + if ((generation.get(b) ?? 0) < m) { + generation.set(b, m); + changed = true; + } + } + if (!changed) break; } // Group by generation, then sort within generation by display name. @@ -108,40 +117,132 @@ 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. + // Per-generation layout: + // + // 1. Build sibling-groups (children of the same parent set) — these become + // the layout "blocks" that are centred under their parents' midpoint. + // 2. Attach loose spouses (people with no parents in the graph but a + // spouse who *is* in a sibling group) on the outside of their partner, + // so the spouse line stays short and adjacent. + // 3. Merge dual-loose spouse pairs into a single 2-person block. + // 4. Centre each block such that its *parented* members average sits + // exactly under the parent midpoint (keeping all connectors at 90°), + // then pack blocks left-to-right. + type Block = { + members: { id: string; parented: boolean }[]; + center: number; + }; + const positions = new Map(); const sortedGens = [...generations.keys()].sort((a, b) => a - b); + for (let gi = 0; gi < sortedGens.length; gi++) { const g = sortedGens[gi]; const ids = generations.get(g)!; const y = g * (NODE_H + ROW_GAP); - if (gi === 0) { - ids.forEach((id, idx) => { - positions.set(id, { x: idx * (NODE_W + COL_GAP), y }); - }); - continue; - } - const preferredX = new Map(); + + const blocksByKey = new Map(); + const memberLookup = new Map(); + + // Step 1: place every node with parents-in-graph into a sibling block. 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); + const parents = childToParents.get(id) ?? []; + if (parents.length === 0) continue; + const blockKey = [...parents].sort().join('|'); + let block = blocksByKey.get(blockKey); + if (!block) { + const parentCenters: number[] = []; + for (const pid of parents) { + const p = positions.get(pid); + if (p) parentCenters.push(p.x + NODE_W / 2); + } + const center = + parentCenters.length > 0 + ? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length + : 0; + block = { members: [], center }; + blocksByKey.set(blockKey, block); } - preferredX.set( - id, - parentXs.length > 0 ? parentXs.reduce((a, b) => a + b, 0) / parentXs.length : 0 + block.members.push({ id, parented: true }); + memberLookup.set(id, { key: blockKey, parented: true }); + } + + // Sort members within each sibling block alphabetically. + for (const block of blocksByKey.values()) { + block.members.sort((a, b) => + (byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '') ); } - 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; + + // Step 2 + 3: handle loose nodes. + for (const id of ids) { + if (memberLookup.has(id)) continue; + const spouse = spousePairs.get(id); + const spouseLookup = spouse ? memberLookup.get(spouse) : undefined; + + if (spouseLookup && spouseLookup.parented) { + // Spouse is parented — attach this loose node next to them on + // the outer edge of their sibling block so the marriage line + // is short and the sibling order is preserved. + const block = blocksByKey.get(spouseLookup.key)!; + const spouseIdx = block.members.findIndex((m) => m.id === spouse); + const insertOnRight = spouseIdx >= block.members.length / 2; + const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx; + block.members.splice(insertAt, 0, { id, parented: false }); + memberLookup.set(id, { key: spouseLookup.key, parented: false }); + } else { + // No usable parented spouse: put in its own loose block. We + // merge dual-loose spouse pairs in the next pass. + const blockKey = `__loose__${id}`; + blocksByKey.set(blockKey, { + members: [{ id, parented: false }], + center: 0 + }); + memberLookup.set(id, { key: blockKey, parented: false }); + } + } + + // Merge dual-loose spouse blocks into a single 2-person block. + const removed = new Set(); + for (const [key, block] of blocksByKey) { + if (!key.startsWith('__loose__')) continue; + if (removed.has(key)) continue; + const member = block.members[0]; + const spouse = spousePairs.get(member.id); + if (!spouse) continue; + const spouseLookup = memberLookup.get(spouse); + if (!spouseLookup || removed.has(spouseLookup.key)) continue; + if (spouseLookup.key === key) continue; + if (!spouseLookup.key.startsWith('__loose__')) continue; + const otherBlock = blocksByKey.get(spouseLookup.key)!; + block.members.push(...otherBlock.members); + removed.add(spouseLookup.key); + } + for (const key of removed) blocksByKey.delete(key); + + // Step 4: centre each block on its anchor (parented members) and pack. + const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center); + let cursorRight = -Infinity; + for (const block of ordered) { + const n = block.members.length; + const groupWidth = n * NODE_W + (n - 1) * COL_GAP; + const anchorIndices: number[] = []; + for (let i = 0; i < n; i++) { + if (block.members[i].parented) anchorIndices.push(i); + } + const avgAnchorIdx = + anchorIndices.length > 0 + ? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length + : (n - 1) / 2; + let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP); + if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP; + for (let i = 0; i < n; i++) { + positions.set(block.members[i].id, { + x: groupLeft + i * (NODE_W + COL_GAP), + y + }); + } + cursorRight = groupLeft + groupWidth; } } @@ -200,7 +301,11 @@ function pairKey(a: string, b: string): string { } type ParentLinks = { - shared: { key: string; parentA: string; parentB: string; childId: string }[]; + // One entry per spouse-pair-with-children: drives the drop + sibling-bar + // + per-child vertical pattern in the SVG. + shared: { key: string; parentA: string; parentB: string; childIds: string[] }[]; + // One entry per remaining parent → child edge (single parents, or the + // "second" parent edge when only one parent is in the spouse pair). single: { key: string; parentId: string; childId: string }[]; }; @@ -217,7 +322,7 @@ const parentLinks = $derived.by(() => { childToParents.set(e.relatedPersonId, list); } - const shared: ParentLinks['shared'] = []; + const sharedMap = new Map(); const single: ParentLinks['single'] = []; for (const [childId, parents] of childToParents) { const consumed = new Set(); @@ -226,12 +331,17 @@ const parentLinks = $derived.by(() => { 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 - }); + const groupKey = pairKey(parents[i], parents[j]); + const existing = sharedMap.get(groupKey); + if (existing) { + existing.childIds.push(childId); + } else { + sharedMap.set(groupKey, { + parentA: parents[i], + parentB: parents[j], + childIds: [childId] + }); + } consumed.add(parents[i]); consumed.add(parents[j]); break; @@ -244,6 +354,8 @@ const parentLinks = $derived.by(() => { } } + const shared: ParentLinks['shared'] = []; + for (const [key, group] of sharedMap) shared.push({ key, ...group }); return { shared, single }; }); @@ -255,33 +367,84 @@ const parentLinks = $derived.by(() => { aria-label="Stammbaum" class="block h-full w-full" > - - {#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} + + {#each parentLinks.shared as group (group.key)} + {@const aCenter = nodeCenter(group.parentA)} + {@const bCenter = nodeCenter(group.parentB)} + {@const childCenters = group.childIds + .map((id) => nodeCenter(id)) + .filter((c): c is { x: number; y: number } => c !== null)} + {#if aCenter && bCenter && childCenters.length > 0} + {@const midX = (aCenter.x + bCenter.x) / 2} + {@const parentBottomY = aCenter.y + NODE_H / 2} + {@const childTopY = childCenters[0].y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {@const xs = childCenters.map((c) => c.x)} + {@const minX = Math.min(midX, ...xs)} + {@const maxX = Math.max(midX, ...xs)} + {#if minX !== maxX} + + {/if} + {#each childCenters as cc, i (group.childIds[i])} + + {/each} {/if} {/each} - + {#each parentLinks.single as link (link.key)} {@const parentCenter = nodeCenter(link.parentId)} {@const childCenter = nodeCenter(link.childId)} {#if parentCenter && childCenter} + {@const parentBottomY = parentCenter.y + NODE_H / 2} + {@const childTopY = childCenter.y - NODE_H / 2} + {@const barY = (parentBottomY + childTopY) / 2} + {#if parentCenter.x !== childCenter.x} + + {/if} + diff --git a/frontend/src/lib/components/StammbaumTree.svelte.test.ts b/frontend/src/lib/components/StammbaumTree.svelte.test.ts index 955914d1..7bf0ef1b 100644 --- a/frontend/src/lib/components/StammbaumTree.svelte.test.ts +++ b/frontend/src/lib/components/StammbaumTree.svelte.test.ts @@ -10,6 +10,31 @@ function parseViewBox(svg: SVGElement): [number, number, number, number] { return [parts[0], parts[1], parts[2], parts[3]]; } +function rectsCentroid(svg: SVGElement): { x: number; y: number } { + const rects = Array.from(svg.querySelectorAll('rect')); + let sx = 0; + let sy = 0; + let n = 0; + for (const r of rects) { + const x = parseFloat(r.getAttribute('x') ?? '0'); + const y = parseFloat(r.getAttribute('y') ?? '0'); + const w = parseFloat(r.getAttribute('width') ?? '0'); + const h = parseFloat(r.getAttribute('height') ?? '0'); + // Skip the narrow accent stripe. + if (w < 10) continue; + // Each node rect lives inside . + const g = r.closest('g[transform]'); + const transform = g?.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + const tx = match ? parseFloat(match[1]) : 0; + const ty = match ? parseFloat(match[2]) : 0; + sx += tx + x + w / 2; + sy += ty + y + h / 2; + n++; + } + return { x: sx / n, y: sy / n }; +} + describe('StammbaumTree viewBox', () => { it('uses the minimum size and centers a single node', async () => { render(StammbaumTree, { @@ -27,13 +52,92 @@ describe('StammbaumTree 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); + // Whatever absolute coordinates the layout uses, the viewBox must + // centre on the rendered content. + const c = rectsCentroid(svg); + expect(x + w / 2).toBeCloseTo(c.x, 1); + expect(y + h / 2).toBeCloseTo(c.y, 1); }); - it('draws one shared line from spouse midpoint when both parents share a child', async () => { + it('renders only orthogonal segments when two parents share two children', async () => { + const PARENT_A = '00000000-0000-0000-0000-00000000000a'; + const PARENT_B = '00000000-0000-0000-0000-00000000000b'; + const CHILD_1 = '00000000-0000-0000-0000-00000000000c'; + const CHILD_2 = '00000000-0000-0000-0000-00000000000d'; + render(StammbaumTree, { + nodes: [ + { id: PARENT_A, displayName: 'Walter', familyMember: true }, + { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, + { id: CHILD_1, displayName: 'Clara', familyMember: true }, + { id: CHILD_2, displayName: 'Hans', familyMember: true } + ], + edges: [ + { + id: 'sp', + personId: PARENT_A, + relatedPersonId: PARENT_B, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Eugenie', + relationType: 'SPOUSE_OF' + }, + { + id: 'p1a', + personId: PARENT_A, + relatedPersonId: CHILD_1, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p1b', + personId: PARENT_B, + relatedPersonId: CHILD_1, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p2a', + personId: PARENT_A, + relatedPersonId: CHILD_2, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + }, + { + id: 'p2b', + personId: PARENT_B, + relatedPersonId: CHILD_2, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Hans', + relationType: 'PARENT_OF' + } + ], + selectedId: null, + zoom: 1, + onSelect: () => {} + }); + + const lines = Array.from(document.querySelectorAll('svg line')); + // Every parent-child segment must be either vertical (x1==x2) or + // horizontal (y1==y2) — no slanted segments allowed. + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); + // Sibling bar must exist and span the children: at least one + // horizontal line whose x1 != x2 and y1 == y2. + const horizontalBars = lines.filter( + (l) => + l.getAttribute('y1') === l.getAttribute('y2') && + l.getAttribute('x1') !== l.getAttribute('x2') + ); + expect(horizontalBars.length).toBeGreaterThanOrEqual(1); + }); + + it('positions a single child at the midpoint of its two parents (vertical drop)', async () => { const PARENT_A = '00000000-0000-0000-0000-00000000000a'; const PARENT_B = '00000000-0000-0000-0000-00000000000b'; const CHILD = '00000000-0000-0000-0000-00000000000c'; @@ -75,45 +179,101 @@ describe('StammbaumTree viewBox', () => { }); 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); + // No slanted segments. With one child, no horizontal sibling bar + // is needed because midX == child.center.x. + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); }); - 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'; + it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => { + // Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1). + // Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have + // child Lili. Hilde must sit next to Hans, and Lili must be on a + // row below Hans/Hilde — not on the same row. + const WALTER = '00000000-0000-0000-0000-000000000001'; + const EUGENIE = '00000000-0000-0000-0000-000000000002'; + const HANS = '00000000-0000-0000-0000-000000000003'; + const CLARA = '00000000-0000-0000-0000-000000000004'; + const HILDE = '00000000-0000-0000-0000-000000000005'; + const LILI = '00000000-0000-0000-0000-000000000006'; + render(StammbaumTree, { nodes: [ - { id: PARENT_A, displayName: 'Walter', familyMember: true }, - { id: PARENT_B, displayName: 'Eugenie', familyMember: true }, - { id: CHILD, displayName: 'Hans', familyMember: true } + { id: WALTER, displayName: 'Walter', familyMember: true }, + { id: EUGENIE, displayName: 'Eugenie', familyMember: true }, + { id: HANS, displayName: 'Hans', familyMember: true }, + { id: CLARA, displayName: 'Clara', familyMember: true }, + { id: HILDE, displayName: 'Hilde', familyMember: true }, + { id: LILI, displayName: 'Lili', familyMember: true } ], edges: [ { - id: 'sp', - personId: PARENT_A, - relatedPersonId: PARENT_B, + id: 's1', + personId: WALTER, + relatedPersonId: EUGENIE, personDisplayName: 'Walter', relatedPersonDisplayName: 'Eugenie', relationType: 'SPOUSE_OF' }, { id: 'p1', - personId: PARENT_A, - relatedPersonId: CHILD, + personId: WALTER, + relatedPersonId: HANS, personDisplayName: 'Walter', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' }, { id: 'p2', - personId: PARENT_B, - relatedPersonId: CHILD, + personId: EUGENIE, + relatedPersonId: HANS, personDisplayName: 'Eugenie', relatedPersonDisplayName: 'Hans', relationType: 'PARENT_OF' + }, + { + id: 'p3', + personId: WALTER, + relatedPersonId: CLARA, + personDisplayName: 'Walter', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 'p4', + personId: EUGENIE, + relatedPersonId: CLARA, + personDisplayName: 'Eugenie', + relatedPersonDisplayName: 'Clara', + relationType: 'PARENT_OF' + }, + { + id: 's2', + personId: HANS, + relatedPersonId: HILDE, + personDisplayName: 'Hans', + relatedPersonDisplayName: 'Hilde', + relationType: 'SPOUSE_OF' + }, + { + id: 'p5', + personId: HANS, + relatedPersonId: LILI, + personDisplayName: 'Hans', + relatedPersonDisplayName: 'Lili', + relationType: 'PARENT_OF' + }, + { + id: 'p6', + personId: HILDE, + relatedPersonId: LILI, + personDisplayName: 'Hilde', + relatedPersonDisplayName: 'Lili', + relationType: 'PARENT_OF' } ], selectedId: null, @@ -121,51 +281,38 @@ describe('StammbaumTree viewBox', () => { 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')); - }); + const ys = new Map(); + for (const g of Array.from(document.querySelectorAll('g[transform]'))) { + const aria = g.getAttribute('aria-label') ?? ''; + const transform = g.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + if (!match) continue; + ys.set(aria.split(',')[0], parseFloat(match[2])); + } - 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: () => {} - }); + // Lili must be on a deeper row than Hans / Hilde. + expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!); + expect(ys.get('Hans')).toEqual(ys.get('Hilde')); + // Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP). + const xs = new Map(); + for (const g of Array.from(document.querySelectorAll('g[transform]'))) { + const aria = g.getAttribute('aria-label') ?? ''; + const transform = g.getAttribute('transform') ?? ''; + const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/); + if (!match) continue; + xs.set(aria.split(',')[0], parseFloat(match[1])); + } + expect(Math.abs(xs.get('Hans')! - xs.get('Hilde')!)).toBe(160 + 40); + + // All parent-child segments must be orthogonal. const lines = Array.from(document.querySelectorAll('svg line')); - const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); - expect(parentLines).toHaveLength(2); + const slanted = lines.filter( + (l) => + l.getAttribute('x1') !== l.getAttribute('x2') && + l.getAttribute('y1') !== l.getAttribute('y2') + ); + expect(slanted).toHaveLength(0); }); it('centers two spouse nodes within the minimum viewBox', async () => { @@ -195,9 +342,8 @@ describe('StammbaumTree viewBox', () => { 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); + const c = rectsCentroid(svg); + expect(x + w / 2).toBeCloseTo(c.x, 1); + expect(y + h / 2).toBeCloseTo(c.y, 1); }); });