fix(stammbaum): iterative generation + spouse-adjacent block layout

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 `<g transform>`.

Tracked the eventual move to dagre (multi-marriage / cross-cousin /
~50+ nodes) in #361.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-28 08:35:32 +02:00
committed by marcel
parent ccbcbca0e8
commit cd26057ea5
2 changed files with 447 additions and 138 deletions

View File

@@ -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<string, number>(); const generation = new Map<string, number>();
const queue: string[] = []; for (const n of allNodes) generation.set(n.id, 0);
for (const n of allNodes) { const maxIters = allNodes.length + 4;
if (!childToParents.has(n.id)) { for (let it = 0; it < maxIters; it++) {
generation.set(n.id, 0); let changed = false;
queue.push(n.id); for (const n of allNodes) {
} const parents = childToParents.get(n.id) ?? [];
} if (parents.length === 0) continue;
while (queue.length > 0) { let maxParentGen = -1;
const id = queue.shift()!; for (const pid of parents) {
const g = generation.get(id) ?? 0; maxParentGen = Math.max(maxParentGen, generation.get(pid) ?? 0);
for (const childId of parentToChildren.get(id) ?? []) { }
if (!generation.has(childId)) { const newGen = maxParentGen + 1;
generation.set(childId, g + 1); if ((generation.get(n.id) ?? 0) < newGen) {
queue.push(childId); generation.set(n.id, newGen);
changed = true;
} }
} }
} for (const [a, b] of spousePairs) {
// Anything not assigned (cycles or isolated nodes after a graph slice) → gen 0. const m = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0);
for (const n of allNodes) { if ((generation.get(a) ?? 0) < m) {
if (!generation.has(n.id)) generation.set(n.id, 0); generation.set(a, m);
} changed = true;
// Spouses share the deeper generation so they sit on the same row. }
for (const [a, b] of spousePairs) { if ((generation.get(b) ?? 0) < m) {
const g = Math.max(generation.get(a) ?? 0, generation.get(b) ?? 0); generation.set(b, m);
generation.set(a, g); changed = true;
generation.set(b, g); }
}
if (!changed) break;
} }
// Group by generation, then sort within generation by display name. // 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 // Per-generation layout:
// 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 // 1. Build sibling-groups (children of the same parent set) — these become
// vertical instead of fanning out diagonally. // 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<string, { x: number; y: number }>(); const positions = new Map<string, { x: number; y: number }>();
const sortedGens = [...generations.keys()].sort((a, b) => a - b); const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (let gi = 0; gi < sortedGens.length; gi++) { for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi]; const g = sortedGens[gi];
const ids = generations.get(g)!; const ids = generations.get(g)!;
const y = g * (NODE_H + ROW_GAP); const y = g * (NODE_H + ROW_GAP);
if (gi === 0) {
ids.forEach((id, idx) => { const blocksByKey = new Map<string, Block>();
positions.set(id, { x: idx * (NODE_W + COL_GAP), y }); const memberLookup = new Map<string, { key: string; parented: boolean }>();
});
continue; // Step 1: place every node with parents-in-graph into a sibling block.
}
const preferredX = new Map<string, number>();
for (const id of ids) { for (const id of ids) {
const parentXs: number[] = []; const parents = childToParents.get(id) ?? [];
for (const parentId of childToParents.get(id) ?? []) { if (parents.length === 0) continue;
const p = positions.get(parentId); const blockKey = [...parents].sort().join('|');
if (p) parentXs.push(p.x); 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( block.members.push({ id, parented: true });
id, memberLookup.set(id, { key: blockKey, parented: true });
parentXs.length > 0 ? parentXs.reduce((a, b) => a + b, 0) / parentXs.length : 0 }
// 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; // Step 2 + 3: handle loose nodes.
for (const id of ordered) { for (const id of ids) {
const x = Math.max(preferredX.get(id) ?? 0, cursorX); if (memberLookup.has(id)) continue;
positions.set(id, { x, y }); const spouse = spousePairs.get(id);
cursorX = x + NODE_W + COL_GAP; 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<string>();
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 = { 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 }[]; single: { key: string; parentId: string; childId: string }[];
}; };
@@ -217,7 +322,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
childToParents.set(e.relatedPersonId, list); childToParents.set(e.relatedPersonId, list);
} }
const shared: ParentLinks['shared'] = []; const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
const single: ParentLinks['single'] = []; const single: ParentLinks['single'] = [];
for (const [childId, parents] of childToParents) { for (const [childId, parents] of childToParents) {
const consumed = new Set<string>(); const consumed = new Set<string>();
@@ -226,12 +331,17 @@ const parentLinks = $derived.by<ParentLinks>(() => {
for (let j = i + 1; j < parents.length; j++) { for (let j = i + 1; j < parents.length; j++) {
if (consumed.has(parents[j])) continue; if (consumed.has(parents[j])) continue;
if (spousePairs.has(pairKey(parents[i], parents[j]))) { if (spousePairs.has(pairKey(parents[i], parents[j]))) {
shared.push({ const groupKey = pairKey(parents[i], parents[j]);
key: `${pairKey(parents[i], parents[j])}->${childId}`, const existing = sharedMap.get(groupKey);
parentA: parents[i], if (existing) {
parentB: parents[j], existing.childIds.push(childId);
childId } else {
}); sharedMap.set(groupKey, {
parentA: parents[i],
parentB: parents[j],
childIds: [childId]
});
}
consumed.add(parents[i]); consumed.add(parents[i]);
consumed.add(parents[j]); consumed.add(parents[j]);
break; break;
@@ -244,6 +354,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
} }
} }
const shared: ParentLinks['shared'] = [];
for (const [key, group] of sharedMap) shared.push({ key, ...group });
return { shared, single }; return { shared, single };
}); });
</script> </script>
@@ -255,33 +367,84 @@ const parentLinks = $derived.by<ParentLinks>(() => {
aria-label="Stammbaum" aria-label="Stammbaum"
class="block h-full w-full" class="block h-full w-full"
> >
<!-- Shared parent-pair → child connectors (drawn from spouse midpoint) --> <!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
{#each parentLinks.shared as link (link.key)} bar, then short verticals from the bar to each child top. -->
{@const aCenter = nodeCenter(link.parentA)} {#each parentLinks.shared as group (group.key)}
{@const bCenter = nodeCenter(link.parentB)} {@const aCenter = nodeCenter(group.parentA)}
{@const childCenter = nodeCenter(link.childId)} {@const bCenter = nodeCenter(group.parentB)}
{#if aCenter && bCenter && childCenter} {@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)}
<line <line
x1={(aCenter.x + bCenter.x) / 2} x1={midX}
y1={(aCenter.y + bCenter.y) / 2} y1={parentBottomY}
x2={childCenter.x} x2={midX}
y2={childCenter.y - NODE_H / 2} y2={barY}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
/> />
{#if minX !== maxX}
<line
x1={minX}
y1={barY}
x2={maxX}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
{#each childCenters as cc, i (group.childIds[i])}
<line
x1={cc.x}
y1={barY}
x2={cc.x}
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/each}
{/if} {/if}
{/each} {/each}
<!-- Single-parent → child connectors --> <!-- Single-parent → child connectors: parent bottom → bar → child top. -->
{#each parentLinks.single as link (link.key)} {#each parentLinks.single as link (link.key)}
{@const parentCenter = nodeCenter(link.parentId)} {@const parentCenter = nodeCenter(link.parentId)}
{@const childCenter = nodeCenter(link.childId)} {@const childCenter = nodeCenter(link.childId)}
{#if parentCenter && childCenter} {#if parentCenter && childCenter}
{@const parentBottomY = parentCenter.y + NODE_H / 2}
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
<line <line
x1={parentCenter.x} x1={parentCenter.x}
y1={parentCenter.y + NODE_H / 2} y1={parentBottomY}
x2={parentCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{#if parentCenter.x !== childCenter.x}
<line
x1={parentCenter.x}
y1={barY}
x2={childCenter.x}
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
/>
{/if}
<line
x1={childCenter.x}
y1={barY}
x2={childCenter.x} x2={childCenter.x}
y2={childCenter.y - NODE_H / 2} y2={childTopY}
stroke="var(--c-primary)" stroke="var(--c-primary)"
stroke-width="1.5" stroke-width="1.5"
/> />

View File

@@ -10,6 +10,31 @@ function parseViewBox(svg: SVGElement): [number, number, number, number] {
return [parts[0], parts[1], parts[2], parts[3]]; 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 <g transform="translate(...)">.
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', () => { describe('StammbaumTree viewBox', () => {
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, {
@@ -27,13 +52,92 @@ describe('StammbaumTree viewBox', () => {
expect(w).toBe(1200); expect(w).toBe(1200);
expect(h).toBe(800); expect(h).toBe(800);
// Node sits at content (0,0)(160,56). Its center should be the // Whatever absolute coordinates the layout uses, the viewBox must
// viewBox center → x + w/2 ≈ 80, y + h/2 ≈ 28. // centre on the rendered content.
expect(x + w / 2).toBeCloseTo(80, 5); const c = rectsCentroid(svg);
expect(y + h / 2).toBeCloseTo(28, 5); 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_A = '00000000-0000-0000-0000-00000000000a';
const PARENT_B = '00000000-0000-0000-0000-00000000000b'; const PARENT_B = '00000000-0000-0000-0000-00000000000b';
const CHILD = '00000000-0000-0000-0000-00000000000c'; const CHILD = '00000000-0000-0000-0000-00000000000c';
@@ -75,45 +179,101 @@ describe('StammbaumTree viewBox', () => {
}); });
const lines = Array.from(document.querySelectorAll('svg line')); const lines = Array.from(document.querySelectorAll('svg line'));
// Parent-child lines: count anything that's not the spouse-pair link. // No slanted segments. With one child, no horizontal sibling bar
const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); // is needed because midX == child.center.x.
expect(parentLines).toHaveLength(1); 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 () => { it('places a loose spouse adjacent to their partner and demotes their child a generation', async () => {
const PARENT_A = '00000000-0000-0000-0000-00000000000a'; // Walter ↔ Eugenie (gen 0); their children Hans + Clara (gen 1).
const PARENT_B = '00000000-0000-0000-0000-00000000000b'; // Hans ↔ Hilde (Hilde has no parents in graph). Hans + Hilde have
const CHILD = '00000000-0000-0000-0000-00000000000c'; // 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, { render(StammbaumTree, {
nodes: [ nodes: [
{ id: PARENT_A, displayName: 'Walter', familyMember: true }, { id: WALTER, displayName: 'Walter', familyMember: true },
{ id: PARENT_B, displayName: 'Eugenie', familyMember: true }, { id: EUGENIE, displayName: 'Eugenie', familyMember: true },
{ id: CHILD, displayName: 'Hans', 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: [ edges: [
{ {
id: 'sp', id: 's1',
personId: PARENT_A, personId: WALTER,
relatedPersonId: PARENT_B, relatedPersonId: EUGENIE,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Eugenie', relatedPersonDisplayName: 'Eugenie',
relationType: 'SPOUSE_OF' relationType: 'SPOUSE_OF'
}, },
{ {
id: 'p1', id: 'p1',
personId: PARENT_A, personId: WALTER,
relatedPersonId: CHILD, relatedPersonId: HANS,
personDisplayName: 'Walter', personDisplayName: 'Walter',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' relationType: 'PARENT_OF'
}, },
{ {
id: 'p2', id: 'p2',
personId: PARENT_B, personId: EUGENIE,
relatedPersonId: CHILD, relatedPersonId: HANS,
personDisplayName: 'Eugenie', personDisplayName: 'Eugenie',
relatedPersonDisplayName: 'Hans', relatedPersonDisplayName: 'Hans',
relationType: 'PARENT_OF' 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, selectedId: null,
@@ -121,51 +281,38 @@ describe('StammbaumTree viewBox', () => {
onSelect: () => {} onSelect: () => {}
}); });
// The shared parent-child line goes from the spouse midpoint to the const ys = new Map<string, number>();
// top of the child node. With centring, x1 must equal x2 → vertical. for (const g of Array.from(document.querySelectorAll('g[transform]'))) {
const lines = Array.from(document.querySelectorAll('svg line')); const aria = g.getAttribute('aria-label') ?? '';
const slopedLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); const transform = g.getAttribute('transform') ?? '';
expect(slopedLines).toHaveLength(1); const match = transform.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/);
const link = slopedLines[0]; if (!match) continue;
expect(link.getAttribute('x1')).toEqual(link.getAttribute('x2')); ys.set(aria.split(',')[0], parseFloat(match[2]));
}); }
it('falls back to two separate lines when both parents are not spouses', async () => { // Lili must be on a deeper row than Hans / Hilde.
const PARENT_A = '00000000-0000-0000-0000-00000000000a'; expect(ys.get('Lili')).toBeGreaterThan(ys.get('Hans')!);
const PARENT_B = '00000000-0000-0000-0000-00000000000b'; expect(ys.get('Hans')).toEqual(ys.get('Hilde'));
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: () => {}
});
// Hans and Hilde must be horizontally adjacent (|Δx| == NODE_W + COL_GAP).
const xs = new Map<string, number>();
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 lines = Array.from(document.querySelectorAll('svg line'));
const parentLines = lines.filter((l) => l.getAttribute('y1') !== l.getAttribute('y2')); const slanted = lines.filter(
expect(parentLines).toHaveLength(2); (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 () => { it('centers two spouse nodes within the minimum viewBox', async () => {
@@ -195,9 +342,8 @@ describe('StammbaumTree viewBox', () => {
expect(w).toBe(1200); expect(w).toBe(1200);
expect(h).toBe(800); expect(h).toBe(800);
// Two nodes side by side: positions (0,0) and (200,0). Right edge const c = rectsCentroid(svg);
// at 200+160 = 360. Content center x = 180, y = 28. expect(x + w / 2).toBeCloseTo(c.x, 1);
expect(x + w / 2).toBeCloseTo(180, 5); expect(y + h / 2).toBeCloseTo(c.y, 1);
expect(y + h / 2).toBeCloseTo(28, 5);
}); });
}); });