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:
@@ -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 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<string, { x: number; y: number }>();
|
||||
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<string, number>();
|
||||
|
||||
const blocksByKey = new Map<string, Block>();
|
||||
const memberLookup = new Map<string, { key: string; parented: boolean }>();
|
||||
|
||||
// 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<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 = {
|
||||
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<ParentLinks>(() => {
|
||||
childToParents.set(e.relatedPersonId, list);
|
||||
}
|
||||
|
||||
const shared: ParentLinks['shared'] = [];
|
||||
const sharedMap = new Map<string, { parentA: string; parentB: string; childIds: string[] }>();
|
||||
const single: ParentLinks['single'] = [];
|
||||
for (const [childId, parents] of childToParents) {
|
||||
const consumed = new Set<string>();
|
||||
@@ -226,12 +331,17 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
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<ParentLinks>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
const shared: ParentLinks['shared'] = [];
|
||||
for (const [key, group] of sharedMap) shared.push({ key, ...group });
|
||||
return { shared, single };
|
||||
});
|
||||
</script>
|
||||
@@ -255,33 +367,84 @@ const parentLinks = $derived.by<ParentLinks>(() => {
|
||||
aria-label="Stammbaum"
|
||||
class="block h-full w-full"
|
||||
>
|
||||
<!-- Shared parent-pair → child connectors (drawn from spouse midpoint) -->
|
||||
{#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}
|
||||
<!-- Shared parent-pair → children: drop from spouse midpoint to a sibling
|
||||
bar, then short verticals from the bar to each child top. -->
|
||||
{#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)}
|
||||
<line
|
||||
x1={(aCenter.x + bCenter.x) / 2}
|
||||
y1={(aCenter.y + bCenter.y) / 2}
|
||||
x2={childCenter.x}
|
||||
y2={childCenter.y - NODE_H / 2}
|
||||
x1={midX}
|
||||
y1={parentBottomY}
|
||||
x2={midX}
|
||||
y2={barY}
|
||||
stroke="var(--c-primary)"
|
||||
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}
|
||||
{/each}
|
||||
|
||||
<!-- Single-parent → child connectors -->
|
||||
<!-- Single-parent → child connectors: parent bottom → bar → child top. -->
|
||||
{#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}
|
||||
<line
|
||||
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}
|
||||
y2={childCenter.y - NODE_H / 2}
|
||||
y2={childTopY}
|
||||
stroke="var(--c-primary)"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
|
||||
@@ -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 <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', () => {
|
||||
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<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;
|
||||
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<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 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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user