From 8b070bdbbc5666a8e28982b59881b503418720d6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 12:50:57 +0200 Subject: [PATCH 01/24] test(stammbaum): add makeNode factory for birth-year ordering tests (#724) The existing node() factory never sets birthYear, but the new sibling/branch comparator (birthYear ASC NULLS LAST) needs it. Add makeNode(id, name, {birthYear, generation}) alongside it; unblocks every ordering test. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 4920884b..faa8d9ed 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -20,6 +20,20 @@ function node(id: string, displayName: string, generation: number | null = null) : { id, displayName, familyMember: true, generation }; } +// Richer factory than node(): lets ordering tests set birthYear (which the +// sibling/branch comparator sorts on) and generation independently. node() +// never sets birthYear, so every birth-year ordering assertion needs this. +function makeNode( + id: string, + displayName: string, + opts: { birthYear?: number; generation?: number } = {} +): PersonNodeDTO { + const n: PersonNodeDTO = { id, displayName, familyMember: true }; + if (opts.birthYear != null) n.birthYear = opts.birthYear; + if (opts.generation != null) n.generation = opts.generation; + return n; +} + function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO { return { id, @@ -48,6 +62,19 @@ function yOf(layout: ReturnType, id: string): number { return p.y; } +describe('makeNode factory', () => { + it('sets birthYear and generation only when provided', () => { + expect(makeNode('a', 'A')).toEqual({ id: 'a', displayName: 'A', familyMember: true }); + expect(makeNode('b', 'B', { birthYear: 1900, generation: 2 })).toEqual({ + id: 'b', + displayName: 'B', + familyMember: true, + birthYear: 1900, + generation: 2 + }); + }); +}); + describe('buildLayout — generation seeding (#689)', () => { it('Herbert Cram regression: two parented G=3 spouses share the same row', () => { // Both Herbert (G 3) and Clara (G 3) are parented children of their respective -- 2.49.1 From 13a0d658414fdc0a81352c7832a51745f48f3c27 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 12:57:38 +0200 Subject: [PATCH 02/24] feat(stammbaum): add tidyTree contour packer with leaf base case (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New domain-agnostic bottom-up tidy-tree module (Reingold-Tilford contour pack) operating on abstract { id, width, children } nodes — zero generated-API imports. First rung of the TDD ladder: a single leaf lays out at x=0. The full contour/centring machinery is in place; subsequent commits add tests that exercise it. Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/tidyTree.test.ts | 19 +++ .../lib/person/genealogy/layout/tidyTree.ts | 146 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/layout/tidyTree.test.ts create mode 100644 frontend/src/lib/person/genealogy/layout/tidyTree.ts diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts new file mode 100644 index 00000000..18c05c3f --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { layoutForest, type TidyNode } from './tidyTree'; + +// tidyTree is domain-agnostic: it lays out abstract { id, width, children } +// nodes, so these tests use hand-built trees with no PersonNodeDTO import. +const W = 160; +const GAP = 40; + +function leaf(id: string, width = W): TidyNode { + return { id, width, children: [] }; +} + +describe('tidyTree — leaf base case', () => { + it('a single leaf lays out at x = 0', () => { + const a = leaf('a'); + const x = layoutForest([a], GAP); + expect(x.get('a')).toBe(0); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.ts new file mode 100644 index 00000000..61f90a1e --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.ts @@ -0,0 +1,146 @@ +// Domain-agnostic bottom-up "tidy tree" contour packer (#724). +// +// This module knows NOTHING about persons, spouses, ranks, or the generated +// API — it lays out abstract { id, width, children } nodes purely by structure, +// so it is unit-testable with hand-built 3-line trees. All genealogy knowledge +// (spouse runs, birth-year order, cross-links) lives in familyForest.ts, which +// flattens its domain model into these abstract nodes before calling in here. +// +// Algorithm (a plain Reingold–Tilford / Walker contour pack, NOT Buchheim's +// O(n) threaded variant — at ~62 nodes the simple O(n·depth) shift-and-merge is +// fast enough and far easier to verify): +// +// 1. Post-order: lay out every child subtree first. +// 2. Pack children left-to-right; each new subtree is shifted right just far +// enough that its LEFT contour clears the running RIGHT contour of the +// already-placed siblings by `gap` at every shared depth (mergeContour + +// shiftSubtree). Deep and shallow branches therefore nest without overlap. +// 3. Place the node's own (variable-width) run centred over the span of its +// children's centres, so an ancestor always sits above its descendants. +// +// x is the LEFT edge of each node's box. y is NOT computed here — the caller +// derives it from generation rank. Positions are kept on the integer grid +// (root centres are rounded) so the no-overlap invariant holds exactly. + +export type TidyNode = { + id: string; + /** Total horizontal extent of this node's run (one card, or a couple). */ + width: number; + children: TidyNode[]; +}; + +// A laid-out subtree in its own local frame: per-id left-edge x plus the left +// and right contours indexed by depth relative to this subtree's root (0 = root). +type Laid = { + x: Map; + left: number[]; + right: number[]; +}; + +/** + * Lay out a forest of root subtrees packed left-to-right and return a map of + * node id → left-edge x. The whole forest is normalised so the leftmost edge + * sits at x = 0. `gap` is the minimum horizontal clearance between any two + * boxes at the same depth. + */ +export function layoutForest(roots: TidyNode[], gap: number): Map { + if (roots.length === 0) return new Map(); + const placed = packChildren( + roots.map((r) => layoutUnit(r, gap)), + gap + ); + const x = new Map(); + let min = Infinity; + for (const subtree of placed) { + for (const [id, v] of subtree.x) { + x.set(id, v); + if (v < min) min = v; + } + } + if (min !== 0 && min !== Infinity) { + for (const [id, v] of x) x.set(id, v - min); + } + return x; +} + +// Post-order layout of one subtree. +function layoutUnit(node: TidyNode, gap: number): Laid { + const children = node.children ?? []; + if (children.length === 0) { + return { x: new Map([[node.id, 0]]), left: [0], right: [node.width] }; + } + + const placed = packChildren( + children.map((c) => layoutUnit(c, gap)), + gap + ); + + // Centre the node's run over the span of its children's centres, then snap + // to the integer grid so sibling/cousin x-differences stay exact. + const firstCenter = childCenter(placed[0], children[0]); + const lastCenter = childCenter(placed[placed.length - 1], children[children.length - 1]); + const rootLeft = Math.round((firstCenter + lastCenter) / 2 - node.width / 2); + + const x = new Map([[node.id, rootLeft]]); + for (const subtree of placed) { + for (const [id, v] of subtree.x) x.set(id, v); + } + + // Subtree contour: depth 0 is the root; children contours shift down a level. + let childLeft: number[] = []; + let childRight: number[] = []; + for (const subtree of placed) { + childLeft = mergeContour(childLeft, subtree.left, Math.min); + childRight = mergeContour(childRight, subtree.right, Math.max); + } + return { + x, + left: [rootLeft, ...childLeft], + right: [rootLeft + node.width, ...childRight] + }; +} + +function childCenter(laid: Laid, node: TidyNode): number { + return laid.x.get(node.id)! + node.width / 2; +} + +// Pack already-laid-out subtrees left-to-right, shifting each so its left +// contour clears the running right contour of all previously placed siblings. +function packChildren(children: Laid[], gap: number): Laid[] { + const placed: Laid[] = []; + let accRight: number[] = []; + for (const child of children) { + let shift = 0; + const shared = Math.min(accRight.length, child.left.length); + for (let d = 0; d < shared; d++) { + const need = accRight[d] + gap - child.left[d]; + if (need > shift) shift = need; + } + const moved = shiftSubtree(child, shift); + placed.push(moved); + accRight = mergeContour(accRight, moved.right, Math.max); + } + return placed; +} + +// Translate a laid-out subtree (positions + both contours) by dx. +function shiftSubtree(laid: Laid, dx: number): Laid { + if (dx === 0) return laid; + const x = new Map(); + for (const [id, v] of laid.x) x.set(id, v + dx); + return { x, left: laid.left.map((v) => v + dx), right: laid.right.map((v) => v + dx) }; +} + +// Combine two depth-indexed contours with `pick` (Math.min for left edges, +// Math.max for right edges); depths present in only one contour are kept. +function mergeContour( + acc: number[], + add: number[], + pick: (a: number, b: number) => number +): number[] { + const out = acc.slice(); + for (let d = 0; d < add.length; d++) { + out[d] = d < out.length ? pick(out[d], add[d]) : add[d]; + } + return out; +} -- 2.49.1 From f9b577196c2b6e8ced1c173bc0468e05a3d7cab7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 12:59:17 +0200 Subject: [PATCH 03/24] test(stammbaum): tidyTree centres a parent over its two children (#724) Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/tidyTree.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts index 18c05c3f..b68d63a4 100644 --- a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -10,6 +10,14 @@ function leaf(id: string, width = W): TidyNode { return { id, width, children: [] }; } +function node(id: string, children: TidyNode[], width = W): TidyNode { + return { id, width, children }; +} + +function center(x: Map, n: TidyNode): number { + return x.get(n.id)! + n.width / 2; +} + describe('tidyTree — leaf base case', () => { it('a single leaf lays out at x = 0', () => { const a = leaf('a'); @@ -17,3 +25,23 @@ describe('tidyTree — leaf base case', () => { expect(x.get('a')).toBe(0); }); }); + +describe('tidyTree — ancestor centring', () => { + it('a parent is centred over the span of its two children', () => { + const c1 = leaf('c1'); + const c2 = leaf('c2'); + const p = node('p', [c1, c2]); + const x = layoutForest([p], GAP); + + const pc = center(x, p); + const lo = center(x, c1); + const hi = center(x, c2); + // Parent sits exactly at the midpoint of its children's centres … + expect(pc).toBe((lo + hi) / 2); + // … which is within their span (the named-bug guard generalises this). + expect(pc).toBeGreaterThanOrEqual(Math.min(lo, hi)); + expect(pc).toBeLessThanOrEqual(Math.max(lo, hi)); + // Children do not overlap. + expect(Math.abs(x.get('c2')! - x.get('c1')!)).toBeGreaterThanOrEqual(W + GAP); + }); +}); -- 2.49.1 From 56d23c7cd982eb1ea9a309684a484824849e3295 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:01:06 +0200 Subject: [PATCH 04/24] test(stammbaum): tidyTree nests deep and shallow siblings without overlap (#724) Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/tidyTree.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts index b68d63a4..75f045b8 100644 --- a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -18,6 +18,36 @@ function center(x: Map, n: TidyNode): number { return x.get(n.id)! + n.width / 2; } +// Walk a forest, recording each node's tree depth and width. +function depths(roots: TidyNode[]): Map { + const out = new Map(); + const walk = (n: TidyNode, d: number) => { + out.set(n.id, { depth: d, width: n.width }); + for (const c of n.children) walk(c, d + 1); + }; + for (const r of roots) walk(r, 0); + return out; +} + +// Assert no two boxes at the same depth overlap (clearance >= gap). +function expectNoOverlap(x: Map, roots: TidyNode[], gap: number) { + const meta = depths(roots); + const ids = [...x.keys()]; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = meta.get(ids[i])!; + const b = meta.get(ids[j])!; + if (a.depth !== b.depth) continue; + const xa = x.get(ids[i])!; + const xb = x.get(ids[j])!; + const lo = Math.min(xa, xb); + const hi = Math.max(xa, xb); + const loW = xa <= xb ? a.width : b.width; + expect(hi - lo).toBeGreaterThanOrEqual(loW + gap); + } + } +} + describe('tidyTree — leaf base case', () => { it('a single leaf lays out at x = 0', () => { const a = leaf('a'); @@ -45,3 +75,25 @@ describe('tidyTree — ancestor centring', () => { expect(Math.abs(x.get('c2')! - x.get('c1')!)).toBeGreaterThanOrEqual(W + GAP); }); }); + +describe('tidyTree — contour nesting', () => { + it('a deep subtree and a shallow sibling nest without overlap', () => { + // root + // ├─ a (leaf) + // └─ b ─ b1, b2 (deeper) + // The contour push must keep b's whole subtree clear of leaf a, and a + // clear of b's grandchildren, at every depth. + const a = leaf('a'); + const b = node('b', [leaf('b1'), leaf('b2')]); + const root = node('root', [a, b]); + const x = layoutForest([root], GAP); + + expectNoOverlap(x, [root], GAP); + // Each parent still centred over its own children. + expect(center(x, b)).toBe( + (center(x, { id: 'b1', width: W, children: [] }) + + center(x, { id: 'b2', width: W, children: [] })) / + 2 + ); + }); +}); -- 2.49.1 From 44cad849bbdbb45a255d51ddb07f24a4da98c9bb Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:02:20 +0200 Subject: [PATCH 05/24] test(stammbaum): tidyTree packs multiple roots left-to-right (#724) Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/tidyTree.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts index 75f045b8..a7d274ea 100644 --- a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -97,3 +97,22 @@ describe('tidyTree — contour nesting', () => { ); }); }); + +describe('tidyTree — multi-root packing', () => { + it('packs multiple roots left-to-right with no overlap', () => { + // A real Stammbaum is a forest of ~50 roots, not a single tree — this is + // the dominant compactness win. Three roots of differing shapes. + const r1 = node('r1', [leaf('r1a'), leaf('r1b')]); + const r2 = leaf('r2'); + const r3 = node('r3', [leaf('r3a')]); + const roots = [r1, r2, r3]; + const x = layoutForest(roots, GAP); + + expectNoOverlap(x, roots, GAP); + // Roots keep input order left-to-right. + expect(x.get('r1')!).toBeLessThan(x.get('r2')!); + expect(x.get('r2')!).toBeLessThan(x.get('r3')!); + // Forest is normalised so the leftmost edge is at 0. + expect(Math.min(...x.values())).toBe(0); + }); +}); -- 2.49.1 From 2e3646fd10b0d9f899498abc848259dbf8ad0862 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:03:24 +0200 Subject: [PATCH 06/24] test(stammbaum): tidyTree centres a wide couple run and clears siblings (#724) Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/tidyTree.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts index a7d274ea..c237f6e9 100644 --- a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -116,3 +116,22 @@ describe('tidyTree — multi-root packing', () => { expect(Math.min(...x.values())).toBe(0); }); }); + +describe('tidyTree — variable-width run', () => { + it('centres a wide couple run over its children and clears its siblings', () => { + // A couple unit is one node whose width spans two cards + the gap. It + // must still centre over its own children and stay clear of siblings by + // the FULL run width, not a single card. + const COUPLE = 2 * W + GAP; + const couple = node('couple', [leaf('g1'), leaf('g2')], COUPLE); + const sibling = node('sib', [leaf('s1')]); + const roots = [sibling, couple]; + const x = layoutForest(roots, GAP); + + expectNoOverlap(x, roots, GAP); + // Couple centred over its two children. + expect(center(x, couple)).toBe((center(x, leaf('g1')) + center(x, leaf('g2'))) / 2); + // The sibling root clears the couple's full run width. + expect(x.get('couple')! - x.get('sib')!).toBeGreaterThanOrEqual(W + GAP); + }); +}); -- 2.49.1 From 53a2fde9174b75f2cde262879e2ff52067a21bb5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:13:26 +0200 Subject: [PATCH 07/24] feat(stammbaum): add familyForest.pickStructuralOwner (#724) Structural-owner rule for couples: earlier birth year wins, missing year sorts last, ties break on stable id. The single definition reused by the cross-link, cycle and intra-family paths. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/familyForest.test.ts | 23 +++++++++++++ .../person/genealogy/layout/familyForest.ts | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/layout/familyForest.test.ts create mode 100644 frontend/src/lib/person/genealogy/layout/familyForest.ts diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts new file mode 100644 index 00000000..33ea1c51 --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { pickStructuralOwner } from './familyForest'; + +type Person = { id: string; birthYear?: number }; + +describe('pickStructuralOwner', () => { + const p = (id: string, birthYear?: number): Person => ({ id, birthYear }); + + it('picks the earlier-born spouse as structural owner', () => { + expect(pickStructuralOwner(p('a', 1900), p('b', 1920))).toBe('a'); + expect(pickStructuralOwner(p('a', 1920), p('b', 1900))).toBe('b'); + }); + + it('sorts a missing birthYear last (the dated spouse owns)', () => { + expect(pickStructuralOwner(p('a'), p('b', 1900))).toBe('b'); + expect(pickStructuralOwner(p('a', 1900), p('b'))).toBe('a'); + }); + + it('breaks ties on stable id when birth years match or are both missing', () => { + expect(pickStructuralOwner(p('zzz', 1900), p('aaa', 1900))).toBe('aaa'); + expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa'); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts new file mode 100644 index 00000000..6feb0a5e --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -0,0 +1,34 @@ +// Domain-aware construction of the genealogy "family forest" (#724). +// +// This module owns every piece of genealogy knowledge the layout needs — +// spouse runs, birth-year ordering, structural-owner selection, intra-family +// marriage resolution and cross-links — and flattens it into the abstract +// { id, width, children } nodes that the domain-agnostic tidyTree.ts packs. +// buildLayout.ts orchestrates the two and maps run positions back to persons. + +import type { components } from '$lib/generated/api'; + +type PersonNodeDTO = components['schemas']['PersonNodeDTO']; + +/** + * Choose the structural owner of a couple: the spouse who keeps the bloodline + * (hierarchy) position. Earlier birth year wins; a missing birth year sorts + * last; ties break on the stable id. Shared by the cycle, cross-link and + * intra-family paths so the rule is defined exactly once. + */ +export function pickStructuralOwner( + a: { id: string; birthYear?: number | null }, + b: { id: string; birthYear?: number | null } +): string { + const ra = ownerRank(a); + const rb = ownerRank(b); + if (ra !== rb) return ra < rb ? a.id : b.id; + return a.id <= b.id ? a.id : b.id; +} + +// Lower is "more owning". Missing birth year → +Infinity (sorts last). +function ownerRank(p: { birthYear?: number | null }): number { + return p.birthYear == null ? Number.POSITIVE_INFINITY : p.birthYear; +} + +export type { PersonNodeDTO }; -- 2.49.1 From f90886352e231cf91e07fd16aa12a1d0ae33ced4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:19:30 +0200 Subject: [PATCH 08/24] feat(stammbaum): buildFamilyForest with loose-spouse absorption + multi-spouse runs (#724) Assigns every person to one unit: a primary, or a spouse absorbed into the primary's run (marriage-year order, #361 preserved). Wires the parent/child hierarchy from each primary's structural-owner parent and records displaced parent edges as cross-links (classified same-level vs cross-level for later distinct rendering). Unknown-id guard covers PARENT_OF and SPOUSE_OF. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/familyForest.test.ts | 89 +++++++- .../person/genealogy/layout/familyForest.ts | 205 ++++++++++++++++++ 2 files changed, 293 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index 33ea1c51..ef086bf4 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -1,8 +1,52 @@ import { describe, it, expect } from 'vitest'; -import { pickStructuralOwner } from './familyForest'; +import { pickStructuralOwner, buildFamilyForest, type Unit } from './familyForest'; +import type { components } from '$lib/generated/api'; + +type PersonNodeDTO = components['schemas']['PersonNodeDTO']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; type Person = { id: string; birthYear?: number }; +function person(id: string, opts: { birthYear?: number; generation?: number } = {}): PersonNodeDTO { + const n: PersonNodeDTO = { id, displayName: id, familyMember: true }; + if (opts.birthYear != null) n.birthYear = opts.birthYear; + if (opts.generation != null) n.generation = opts.generation; + return n; +} + +function parent(p: string, c: string): RelationshipDTO { + return { + id: `${p}>${c}`, + personId: p, + relatedPersonId: c, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'PARENT_OF' + }; +} + +function spouse(a: string, b: string, fromYear?: number): RelationshipDTO { + return { + id: `${a}~${b}`, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SPOUSE_OF', + ...(fromYear != null ? { fromYear } : {}) + }; +} + +// Find the unit (anywhere in the forest) whose primary id matches. +function findUnit(roots: Unit[], primaryId: string): Unit | undefined { + for (const r of roots) { + if (r.id === primaryId) return r; + const found = findUnit(r.children, primaryId); + if (found) return found; + } + return undefined; +} + describe('pickStructuralOwner', () => { const p = (id: string, birthYear?: number): Person => ({ id, birthYear }); @@ -21,3 +65,46 @@ describe('pickStructuralOwner', () => { expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa'); }); }); + +describe('buildFamilyForest — loose-spouse absorption', () => { + it('absorbs a parentless spouse into the partner run; their child anchors to the couple', () => { + // A (founder) ⚭ S (married-in, no parents). Their child C. S has no + // ancestor subtree of its own, but C still hangs off the couple. + const forest = buildFamilyForest( + [ + person('A', { birthYear: 1900 }), + person('S', { birthYear: 1905 }), + person('C', { birthYear: 1930 }) + ], + [spouse('A', 'S'), parent('A', 'C'), parent('S', 'C')] + ); + + // One root unit (A), with S absorbed — S is not its own root. + expect(forest.roots.map((r) => r.id)).toEqual(['A']); + const a = findUnit(forest.roots, 'A')!; + expect(a.members).toEqual(['A', 'S']); + // The child anchors through the couple unit. + expect(a.children.map((u) => u.id)).toEqual(['C']); + // S is parentless, so no displaced cross-link. + expect(forest.crossLinks).toEqual([]); + }); + + it('keeps all marriages of a multi-spouse founder in one run (marriage-year order)', () => { + // Albert-like: one founder, three parentless wives. All absorbed into the + // founder run, ordered by marriage year NULLS LAST then displayName/id. + const forest = buildFamilyForest( + [ + person('alb', { birthYear: 1829 }), + person('w1925', { birthYear: 1900 }), + person('wNull', { birthYear: 1901 }), + person('w1910', { birthYear: 1902 }) + ], + [spouse('alb', 'w1925', 1925), spouse('alb', 'wNull'), spouse('alb', 'w1910', 1910)] + ); + + expect(forest.roots.map((r) => r.id)).toEqual(['alb']); + const alb = findUnit(forest.roots, 'alb')!; + // Founder first, then spouses by marriage year (1910, 1925, null last). + expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 6feb0a5e..1fc2be4e 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -9,6 +9,32 @@ import type { components } from '$lib/generated/api'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +/** + * A family unit = one bloodline carrier (the primary) plus the spouse(s) + * absorbed into its run. `members[0]` is the primary; the rest are spouses in + * marriage-year order. `children` are the units anchored by the couple's + * offspring (the forest hierarchy). + */ +export type Unit = { + id: string; // == primary's id; also the tidyTree node id + members: string[]; // run order: [primary, ...absorbed spouses] + children: Unit[]; +}; + +/** + * A parent→child edge whose child is NOT positioned under that parent (the + * child lives in a spouse's run elsewhere). `sameLevel` is true when the + * displaced parent can be ordered adjacent to the child's anchor (a short, + * solid connector — the intra-family "adjacency" case); false marks a genuine + * cross-level link that must render with a distinct dash. + */ +export type CrossLink = { parentId: string; childId: string; sameLevel: boolean }; + +export type FamilyForest = { roots: Unit[]; crossLinks: CrossLink[] }; + +const ROOT_GROUP = '__ROOT__'; /** * Choose the structural owner of a couple: the spouse who keeps the bloodline @@ -31,4 +57,183 @@ function ownerRank(p: { birthYear?: number | null }): number { return p.birthYear == null ? Number.POSITIVE_INFINITY : p.birthYear; } +/** + * Build the family forest: assign every person to exactly one unit (a primary, + * or a spouse absorbed into a primary's run), wire the parent/child hierarchy, + * and record displaced parent edges as cross-links. The packer (tidyTree) and + * the orchestrator (buildLayout) consume the result; this module holds all the + * genealogy semantics. + */ +export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO[]): FamilyForest { + const byId = new Map(nodes.map((n) => [n.id, n])); + const allIds = new Set(nodes.map((n) => n.id)); + const parentToChildren = new Map(); + const childToParents = new Map(); + const spouses = new Map>(); + const spouseYear = new Map(); + + for (const e of edges) { + // Unknown-id guard for BOTH edge types: an edge to an id outside the node + // list is dropped, never dereferenced into an undefined position. + if (!allIds.has(e.personId) || !allIds.has(e.relatedPersonId)) continue; + if (e.relationType === 'PARENT_OF') { + push(parentToChildren, e.personId, e.relatedPersonId); + push(childToParents, e.relatedPersonId, e.personId); + } else if (e.relationType === 'SPOUSE_OF') { + addToSet(spouses, e.personId, e.relatedPersonId); + addToSet(spouses, e.relatedPersonId, e.personId); + spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined); + } + } + + const hasParents = (id: string) => (childToParents.get(id)?.length ?? 0) > 0; + + // --- Absorption: decide who is absorbed into whose run. --- + const absorbedInto = new Map(); + const runOwner = (id: string): string => { + let cur = id; + const seen = new Set([cur]); + while (absorbedInto.has(cur)) { + cur = absorbedInto.get(cur)!; + if (seen.has(cur)) break; // defensive: never loop on a self-referential chain + seen.add(cur); + } + return cur; + }; + const owners = new Set(); + const canAbsorb = (x: string) => !absorbedInto.has(x) && !owners.has(x); + + for (const [a, b] of marriagesInOrder(spouses)) { + if (runOwner(a) === runOwner(b)) continue; + const aP = hasParents(a); + const bP = hasParents(b); + let owner: string; + let absorbed: string; + if (aP === bP) { + // Both parented (intra-family) or both parentless (dual-loose founders): + // the structural owner keeps the bloodline position. + owner = pickStructuralOwner(byId.get(a)!, byId.get(b)!); + absorbed = owner === a ? b : a; + } else if (aP) { + owner = a; + absorbed = b; + } else { + owner = b; + absorbed = a; + } + if (canAbsorb(absorbed)) { + const o = runOwner(owner); + absorbedInto.set(absorbed, o); + owners.add(o); + } else if (canAbsorb(owner)) { + const o = runOwner(absorbed); + absorbedInto.set(owner, o); + owners.add(o); + } + // else: both already fixed in other runs — the marriage still draws a + // plain spouse line via the connector; no absorption is forced. + } + + // --- Unit membership --- + const unitOf = new Map(); // person → primary id + for (const n of nodes) unitOf.set(n.id, runOwner(n.id)); + const primaries = nodes.map((n) => n.id).filter((id) => unitOf.get(id) === id); + + const absorbedOf = new Map(); + for (const k of absorbedInto.keys()) push(absorbedOf, runOwner(k), k); + + const membersOf = new Map(); + for (const p of primaries) { + const spousesOfP = (absorbedOf.get(p) ?? []) + .slice() + .sort((x, y) => spouseRun(p, x, y, spouseYear, byId)); + membersOf.set(p, [p, ...spousesOfP]); + } + + // hierarchy parent unit of a primary: the unit of its structural-owner parent + // (married co-parents share a unit anyway, so the choice is only material for + // the rare unmarried-co-parent case). + const hierParentUnit = (primary: string): string | null => { + const parents = childToParents.get(primary) ?? []; + if (parents.length === 0) return null; + let chosen = parents[0]; + for (const pa of parents.slice(1)) + chosen = pickStructuralOwner(byId.get(chosen)!, byId.get(pa)!); + const u = unitOf.get(chosen)!; + return u === primary ? null : u; // guard: a self-parent edge cannot anchor + }; + const groupKey = (primary: string) => hierParentUnit(primary) ?? ROOT_GROUP; + + // --- Build Unit objects + hierarchy --- + const unitObj = new Map(); + for (const p of primaries) unitObj.set(p, { id: p, members: membersOf.get(p)!, children: [] }); + const roots: Unit[] = []; + for (const p of primaries) { + const hp = hierParentUnit(p); + if (hp == null) roots.push(unitObj.get(p)!); + else unitObj.get(hp)!.children.push(unitObj.get(p)!); + } + + // --- Cross-links: displaced parent → absorbed-spouse edges. --- + const crossLinks: CrossLink[] = []; + for (const k of absorbedInto.keys()) { + const owner = runOwner(k); + const hu = hierParentUnit(owner); + for (const pa of childToParents.get(k) ?? []) { + const sameLevel = hu != null && groupKey(unitOf.get(pa)!) === groupKey(hu); + crossLinks.push({ parentId: pa, childId: k, sameLevel }); + } + } + + return { roots, crossLinks }; +} + +// Spouse-run comparator: marriage year ASC NULLS LAST, then displayName, then id. +function spouseRun( + primary: string, + x: string, + y: string, + spouseYear: Map, + byId: Map +): number { + const yx = spouseYear.get(pairKey(primary, x)) ?? Number.POSITIVE_INFINITY; + const yy = spouseYear.get(pairKey(primary, y)) ?? Number.POSITIVE_INFINITY; + if (yx !== yy) return yx - yy; + const nx = byId.get(x)?.displayName ?? ''; + const ny = byId.get(y)?.displayName ?? ''; + return nx.localeCompare(ny) || (x < y ? -1 : 1); +} + +// Unique undirected marriages in a deterministic (pair-key) order. +function marriagesInOrder(spouses: Map>): [string, string][] { + const seen = new Set(); + const out: [string, string][] = []; + for (const a of [...spouses.keys()].sort()) { + for (const b of [...spouses.get(a)!].sort()) { + const k = pairKey(a, b); + if (seen.has(k)) continue; + seen.add(k); + out.push(a < b ? [a, b] : [b, a]); + } + } + out.sort((m1, m2) => (pairKey(m1[0], m1[1]) < pairKey(m2[0], m2[1]) ? -1 : 1)); + return out; +} + +function push(map: Map, key: K, value: V) { + const arr = map.get(key); + if (arr) arr.push(value); + else map.set(key, [value]); +} + +function addToSet(map: Map>, key: K, value: V) { + const s = map.get(key); + if (s) s.add(value); + else map.set(key, new Set([value])); +} + +function pairKey(a: string, b: string): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + export type { PersonNodeDTO }; -- 2.49.1 From f198ecc43dece5669c0220d220439a0adbaaec77 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:21:17 +0200 Subject: [PATCH 09/24] feat(stammbaum): order siblings/branches by birthYear NULLS LAST, displayName, id (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new ordering coverage: roots and every unit's children sort by birthYear ASC (undated last), then displayName, then stable id — so horizontal x never depends on Map iteration order. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/familyForest.test.ts | 31 +++++++++++++++++++ .../person/genealogy/layout/familyForest.ts | 13 ++++++++ 2 files changed, 44 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index ef086bf4..2a1ace57 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -108,3 +108,34 @@ describe('buildFamilyForest — loose-spouse absorption', () => { expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']); }); }); + +describe('buildFamilyForest — sibling/branch ordering', () => { + it('orders children by birthYear ASC, NULLS LAST, then displayName, then id', () => { + // Provided out of order; the comparator must reorder to 1910, 1920, then + // the two undated by displayName/id (NULLS LAST). + const undatedB: PersonNodeDTO = { id: 'u-b', displayName: 'Zoe', familyMember: true }; + const undatedA: PersonNodeDTO = { id: 'u-a', displayName: 'Anna', familyMember: true }; + const forest = buildFamilyForest( + [ + person('P', { birthYear: 1880 }), + person('c1920', { birthYear: 1920 }), + undatedB, + person('c1910', { birthYear: 1910 }), + undatedA + ], + [parent('P', 'c1920'), parent('P', 'u-b'), parent('P', 'c1910'), parent('P', 'u-a')] + ); + + const p = findUnit(forest.roots, 'P')!; + // 1910, 1920 (dated ASC), then undated by displayName (Anna < Zoe). + expect(p.children.map((u) => u.id)).toEqual(['c1910', 'c1920', 'u-a', 'u-b']); + }); + + it('orders roots by the same rule', () => { + const forest = buildFamilyForest( + [person('late', { birthYear: 1900 }), person('early', { birthYear: 1850 })], + [] + ); + expect(forest.roots.map((r) => r.id)).toEqual(['early', 'late']); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 1fc2be4e..0663ebd1 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -174,6 +174,19 @@ export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO else unitObj.get(hp)!.children.push(unitObj.get(p)!); } + // Sibling/branch order: birthYear ASC NULLS LAST → displayName → id. Applied + // to roots and to every unit's children so x never depends on Map order. + const branchOrder = (u: Unit, v: Unit) => { + const a = byId.get(u.id)!; + const b = byId.get(v.id)!; + const ya = a.birthYear ?? Number.POSITIVE_INFINITY; + const yb = b.birthYear ?? Number.POSITIVE_INFINITY; + if (ya !== yb) return ya - yb; + return a.displayName.localeCompare(b.displayName) || (u.id < v.id ? -1 : 1); + }; + roots.sort(branchOrder); + for (const u of unitObj.values()) u.children.sort(branchOrder); + // --- Cross-links: displaced parent → absorbed-spouse edges. --- const crossLinks: CrossLink[] = []; for (const k of absorbedInto.keys()) { -- 2.49.1 From 8fca2605d4d7ec367affb96b5704f79546149ef7 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:25:20 +0200 Subject: [PATCH 10/24] feat(stammbaum): replace per-generation packer with tidy-tree orchestration (#724) buildLayout now builds the family forest, packs it bottom-up via tidyTree, and maps each unit's run x back to per-person positions (x from structure, y from rank). assignRanks, the generations map, and computeViewBox are reused unchanged. The unknown-id guard now covers PARENT_OF as well as SPOUSE_OF, and displaced cross-level edges are exposed as crossLinks for distinct rendering. The ~210-line block packer (and its block/merge helpers) is gone. Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/buildLayout.ts | 284 +++--------------- 1 file changed, 50 insertions(+), 234 deletions(-) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.ts index c2ff3260..bb4b41d6 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.ts @@ -1,4 +1,6 @@ import type { components } from '$lib/generated/api'; +import { buildFamilyForest, type Unit } from './familyForest'; +import { layoutForest, type TidyNode } from './tidyTree'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; type RelationshipDTO = components['schemas']['RelationshipDTO']; @@ -14,6 +16,9 @@ export const MIN_VIEWBOX_H = 800; export type Layout = { positions: Map; generations: Map; + // Displaced parent→child edges that span structural levels: the connector + // renders these with a distinct dash (never the ended-marriage 4 4 cadence). + crossLinks: { parentId: string; childId: string }[]; viewX: number; viewY: number; viewW: number; @@ -21,37 +26,35 @@ export type Layout = { }; export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout { - const parentToChildren = new Map(); const childToParents = new Map(); // spousePairs is a Set per person so multi-spouse cases (#361) preserve all // marriages instead of having later edges silently clobber earlier ones. const spousePairs = new Map>(); const allNodeIds = new Set(allNodes.map((n) => n.id)); - // Marriage years keyed by undirected pair (#361) drive the multi-spouse - // sort order: fromYear ASC NULLS LAST, displayName ASC. - const spouseFromYear = new Map(); for (const e of allEdges) { + // Defensive guard against edges referencing IDs outside the node list + // (stale or partial graph snapshots) — applied to BOTH edge types so an + // unknown id is never dereferenced into an undefined position. + if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) continue; switch (e.relationType) { case 'PARENT_OF': - mapPush(parentToChildren, e.personId, e.relatedPersonId); mapPush(childToParents, e.relatedPersonId, e.personId); break; case 'SPOUSE_OF': - // Defensive guard against edges referencing IDs outside the - // node list (stale or partial graph snapshots) — keeps every - // downstream iteration safely scoped to known nodes. - if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) break; mapAddToSet(spousePairs, e.personId, e.relatedPersonId); mapAddToSet(spousePairs, e.relatedPersonId, e.personId); - spouseFromYear.set(spousePairKey(e.personId, e.relatedPersonId), e.fromYear); break; } } + // Vertical (y) is still driven entirely by generation rank — #689 seeding + // and spouse pull-down are untouched. const rank = assignRanks(allNodes, childToParents, spousePairs); - // Group by rank, then sort within rank by display name. + // Group by rank, then sort within rank by display name. Consumed by + // StammbaumTree's gutter rows / generation rail — kept with the same + // rank-keyed semantics and deliberately NOT folded into the x rewrite. const generations = new Map(); for (const n of allNodes) { const g = rank.get(n.id) ?? 0; @@ -67,218 +70,49 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO }); } - // 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; - }; + // Horizontal (x): bottom-up tidy-tree over the family forest. Each unit's + // run width is its member count laid out card+gap; tidyTree assigns the + // run's left edge, and we spread the run's members rightward from there. + // y comes from rank (never tree depth), so a child seeded more than one + // generation below its parent simply gets a taller connector. + const forest = buildFamilyForest(allNodes, allEdges); + const toTidy = (u: Unit): TidyNode => ({ + id: u.id, + width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP, + children: u.children.map(toTidy) + }); + const runX = layoutForest(forest.roots.map(toTidy), COL_GAP); 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); - - 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 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); - } - 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 ?? '') - ); - } - - // Step 2: handle loose nodes. - // - // First pass collects every loose node that has a parented partner, - // grouped by that partner. A second pass sorts each group by - // (fromYear ASC NULLS LAST, displayName ASC) and inserts all spouses - // immediately to the right of the parented partner in one splice — - // matching Leonie's UX rule ("All spouses render to the right of the - // focal person, ordered by marriage date, earliest closest"). - // Truly-loose nodes (no parented partner) get their own block here - // and merge with their dual-loose partner in step 3. - type LooseAttachment = { id: string; fromYear: number | undefined }; - const looseByParented = new Map(); - for (const id of ids) { - if (memberLookup.has(id)) continue; - const partners = spousePairs.get(id); - let parentedSpouse: string | undefined; - if (partners) { - for (const partnerId of partners) { - if (memberLookup.get(partnerId)?.parented) { - parentedSpouse = partnerId; - break; - } - } - } - - if (parentedSpouse) { - const lookupKey = memberLookup.get(parentedSpouse)!.key; - mapPush(looseByParented, parentedSpouse, { - id, - fromYear: spouseFromYear.get(spousePairKey(id, parentedSpouse)) - }); - memberLookup.set(id, { key: lookupKey, parented: false }); - } else { - const blockKey = `__loose__${id}`; - blocksByKey.set(blockKey, { - members: [{ id, parented: false }], - center: 0 - }); - memberLookup.set(id, { key: blockKey, parented: false }); - } - } - - for (const [parentedId, attachments] of looseByParented) { - attachments.sort((a, b) => { - const ya = a.fromYear ?? Number.POSITIVE_INFINITY; - const yb = b.fromYear ?? Number.POSITIVE_INFINITY; - if (ya !== yb) return ya - yb; - const an = byId.get(a.id)?.displayName ?? ''; - const bn = byId.get(b.id)?.displayName ?? ''; - return an.localeCompare(bn); + const place = (u: Unit) => { + const left = runX.get(u.id) ?? 0; + u.members.forEach((mid, i) => { + positions.set(mid, { + x: left + i * (NODE_W + COL_GAP), + y: (rank.get(mid) ?? 0) * (NODE_H + ROW_GAP) }); - const block = blocksByKey.get(memberLookup.get(parentedId)!.key)!; - const parentedIdx = block.members.findIndex((m) => m.id === parentedId); - block.members.splice( - parentedIdx + 1, - 0, - ...attachments.map((a) => ({ id: a.id, parented: false })) - ); - } + }); + u.children.forEach(place); + }; + forest.roots.forEach(place); - // Merge dual-loose spouse blocks into a single block. With multi-spouse, - // iterate every partner so a loose person with N loose marriages ends - // up in one shared block. Partners are sorted by (fromYear ASC NULLS - // LAST, displayName ASC) before iteration so the resulting block - // places spouses in the UX-spec order to the right of the focal. - 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 partners = spousePairs.get(member.id); - if (!partners) continue; - const sortedPartners = [...partners].sort((a, b) => { - const ya = spouseFromYear.get(spousePairKey(member.id, a)) ?? Number.POSITIVE_INFINITY; - const yb = spouseFromYear.get(spousePairKey(member.id, b)) ?? Number.POSITIVE_INFINITY; - if (ya !== yb) return ya - yb; - const an = byId.get(a)?.displayName ?? ''; - const bn = byId.get(b)?.displayName ?? ''; - return an.localeCompare(bn); - }); - for (const partnerId of sortedPartners) { - const spouseLookup = memberLookup.get(partnerId); - 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 3.5 (#361 AC2): Intra-family marriage. Two parented members at - // the same rank in different sibling blocks who marry each other are - // merged into one block — A's siblings on the left, the spouses on - // the join boundary, B's siblings on the right — so the spouse line - // stays short and no other node sits between them. - const mergedKeys = new Set(); - for (const [aKey, aBlock] of blocksByKey) { - if (aKey.startsWith('__loose__')) continue; - if (mergedKeys.has(aKey)) continue; - for (const aMember of aBlock.members) { - if (!aMember.parented) continue; - const partners = spousePairs.get(aMember.id); - if (!partners) continue; - for (const partnerId of partners) { - const partnerLookup = memberLookup.get(partnerId); - if (!partnerLookup || !partnerLookup.parented) continue; - if (partnerLookup.key === aKey) continue; - if (partnerLookup.key.startsWith('__loose__')) continue; - if (mergedKeys.has(partnerLookup.key)) continue; - const bBlock = blocksByKey.get(partnerLookup.key)!; - // A's spouse to the right-most slot in A's block; B's spouse - // to the left-most slot in B's block; then concatenate. - moveMemberToEnd(aBlock.members, aMember.id); - moveMemberToStart(bBlock.members, partnerId); - for (const m of bBlock.members) { - memberLookup.set(m.id, { key: aKey, parented: m.parented }); - } - aBlock.members.push(...bBlock.members); - aBlock.center = (aBlock.center + bBlock.center) / 2; - mergedKeys.add(partnerLookup.key); - } - } - } - for (const key of mergedKeys) 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; - } + // Safety net: any node the forest did not place (it shouldn't leave any) + // still gets a deterministic slot rather than vanishing from the canvas. + let spare = runX.size; + for (const n of allNodes) { + if (positions.has(n.id)) continue; + positions.set(n.id, { + x: spare++ * (NODE_W + COL_GAP), + y: (rank.get(n.id) ?? 0) * (NODE_H + ROW_GAP) + }); } + const crossLinks = forest.crossLinks + .filter((c) => !c.sameLevel) + .map(({ parentId, childId }) => ({ parentId, childId })); + const viewBox = computeViewBox(positions); - return { positions, generations, ...viewBox }; + return { positions, generations, crossLinks, ...viewBox }; } // Bounding box around the actual content, expanded to MIN dimensions (so a @@ -395,21 +229,3 @@ function mapAddToSet(map: Map>, key: K, value: V) { if (s) s.add(value); else map.set(key, new Set([value])); } - -function spousePairKey(a: string, b: string): string { - return a < b ? `${a}|${b}` : `${b}|${a}`; -} - -function moveMemberToEnd(members: T[], id: string) { - const idx = members.findIndex((m) => m.id === id); - if (idx < 0 || idx === members.length - 1) return; - const [m] = members.splice(idx, 1); - members.push(m); -} - -function moveMemberToStart(members: T[], id: string) { - const idx = members.findIndex((m) => m.id === id); - if (idx <= 0) return; - const [m] = members.splice(idx, 1); - members.unshift(m); -} -- 2.49.1 From 2221bb0faf87e6e87e393f5bfdb91cec17901c80 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:27:23 +0200 Subject: [PATCH 11/24] test(stammbaum): same-level intra-family bond renders solid, not a cross-link (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing adjacency contract: the couple is exactly adjacent in the run AND, because both parents are roots (same structural level), the displaced parent edge stays solid — layout.crossLinks is empty for this case. Co-Authored-By: Claude Opus 4.8 --- frontend/src/lib/person/genealogy/layout/buildLayout.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index faa8d9ed..5fcf7f98 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -306,6 +306,11 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { if (p.y !== posA2.y) continue; expect(p.x <= minX || p.x >= maxX).toBe(true); } + + // Same-level bond (both parents are roots): the displaced parent edge + // can be ordered adjacent, so it stays a solid connector — NOT a dashed + // cross-link. The cross-link set is therefore empty for this case. + expect(layout.crossLinks).toEqual([]); }); it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => { -- 2.49.1 From 2b8864cd6f8871f6107d8ca84e23b264874b672a Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:28:58 +0200 Subject: [PATCH 12/24] test(stammbaum): cross-level marriage records a distinct cross-link (#724) When the two spouses' parents sit at different structural levels, the structural owner keeps its hierarchy edge and the other parent->spouse edge is recorded in layout.crossLinks (rendered with a distinct dash). The couple still sits exactly adjacent in the owner's run and B keeps a real position. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 5fcf7f98..75efeb49 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -364,3 +364,44 @@ describe('buildLayout — multi-spouse ordering (#361)', () => { } }); }); + +describe('buildLayout — cross-level marriage fallback (#724)', () => { + // A bond is cross-level when the two spouses' parents sit at different + // structural levels. Adjacency cannot keep both connectors short, so the + // structural owner keeps its hierarchy edge and the other parent → spouse + // edge becomes a distinct cross-link. + const GP = '00000000-0000-0000-0000-0000000000e1'; // G0, deep branch ancestor + const P = '00000000-0000-0000-0000-0000000000e2'; // G1, child of GP + const A = '00000000-0000-0000-0000-0000000000e3'; // G2, child of P (nested deep) + const R = '00000000-0000-0000-0000-0000000000e4'; // G1 root + const B = '00000000-0000-0000-0000-0000000000e5'; // G2, child of R + + it('records the displaced parent edge as a cross-link and keeps the couple adjacent', () => { + const layout = buildLayout( + [ + node(GP, 'GP', 0), + node(P, 'P', 1), + { ...node(A, 'A', 2), birthYear: 1900 }, // earlier birth → A is structural owner + node(R, 'R', 1), + { ...node(B, 'B', 2), birthYear: 1910 } + ], + [ + parentEdge(GP, P, 'g1'), + parentEdge(P, A, 'g2'), + parentEdge(R, B, 'g3'), + spouseEdge(A, B, 'sp') + ] + ); + + // A owns; B is absorbed into A's run → couple is exactly adjacent. + const posA = layout.positions.get(A)!; + const posB = layout.positions.get(B)!; + expect(posA.y).toBe(posB.y); + expect(Math.abs(posA.x - posB.x)).toBe(NODE_W + COL_GAP); + + // R → B is cross-level: it surfaces as a distinct cross-link, and the + // geometry still lands on B's real position (carried redundantly). + expect(layout.crossLinks).toEqual([{ parentId: R, childId: B }]); + expect(layout.positions.get(B)).toBeDefined(); + }); +}); -- 2.49.1 From d703c99c25b4584c7d5d7023eb345f3c119ee6cb Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:32:04 +0200 Subject: [PATCH 13/24] feat(stammbaum): render cross-level links with a distinct dash (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StammbaumConnectors takes the layout's crossLinks and draws those parent->child connectors with a 2 6 dash at reduced opacity — deliberately distinct from the ended-marriage spouse dash (4 4) and from a solid parent drop. Geometry still lands on the child top, so the meaning is carried redundantly (WCAG 1.4.1). Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumConnectors.svelte | 33 ++++++++++++++++++- .../lib/person/genealogy/StammbaumTree.svelte | 1 + 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte index 57acf7df..4e0c1b66 100644 --- a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte @@ -9,6 +9,13 @@ type RelationshipDTO = components['schemas']['RelationshipDTO']; interface Props { edges: RelationshipDTO[]; positions: Layout['positions']; + /** + * Displaced parent→child edges (cross-level intra-family marriages): the + * child lives in a spouse's run elsewhere, so the connector is drawn with a + * distinct dash so it never reads as a normal parent drop or an ended + * marriage. Geometry still lands on the child, so meaning is redundant. + */ + crossLinks?: Layout['crossLinks']; /** * Whether the connector joining two people is active (full strength). A * connector is active only when both endpoints are active; otherwise it is @@ -17,7 +24,18 @@ interface Props { isConnectorActive?: (aId: string, bId: string) => boolean; } -let { edges, positions, isConnectorActive = () => true }: Props = $props(); +let { edges, positions, crossLinks = [], isConnectorActive = () => true }: Props = $props(); + +// Dash cadence + opacity for a cross-link, deliberately distinct from the +// ended-marriage spouse dash (`4 4`) so the two never read alike (WCAG 1.4.1: +// geometry carries the meaning too, not stroke alone). +const CROSS_LINK_DASH = '2 6'; +const CROSS_LINK_OPACITY = 0.55; + +const crossLinkSet = $derived(new SvelteSet(crossLinks.map((c) => `${c.parentId}->${c.childId}`))); +function isCrossLink(parentId: string, childId: string): boolean { + return crossLinkSet.has(`${parentId}->${childId}`); +} /** SVG group opacity for a connector: full when active, dimmed otherwise. */ function connectorOpacity(active: boolean): number | undefined { @@ -149,6 +167,8 @@ const parentLinks = $derived.by(() => { centres (a child without a position drops out of both together). --> {@const childActive = isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)} + {@const childCross = + isCrossLink(group.parentA, cc.id) || isCrossLink(group.parentB, cc.id)} (() => { y2={childTopY} stroke="var(--c-primary)" stroke-width="1.5" + stroke-dasharray={childCross ? CROSS_LINK_DASH : undefined} + stroke-opacity={childCross ? CROSS_LINK_OPACITY : undefined} /> {/each} @@ -172,6 +194,9 @@ const parentLinks = $derived.by(() => { {@const childTopY = childCenter.y - NODE_H / 2} {@const barY = (parentBottomY + childTopY) / 2} {@const active = isConnectorActive(link.parentId, link.childId)} + {@const cross = isCrossLink(link.parentId, link.childId)} + {@const dash = cross ? CROSS_LINK_DASH : undefined} + {@const strokeOpacity = cross ? CROSS_LINK_OPACITY : undefined} (() => { y2={barY} stroke="var(--c-primary)" stroke-width="1.5" + stroke-dasharray={dash} + stroke-opacity={strokeOpacity} /> {#if parentCenter.x !== childCenter.x} (() => { y2={barY} stroke="var(--c-primary)" stroke-width="1.5" + stroke-dasharray={dash} + stroke-opacity={strokeOpacity} /> {/if} (() => { y2={childTopY} stroke="var(--c-primary)" stroke-width="1.5" + stroke-dasharray={dash} + stroke-opacity={strokeOpacity} /> {/if} diff --git a/frontend/src/lib/person/genealogy/StammbaumTree.svelte b/frontend/src/lib/person/genealogy/StammbaumTree.svelte index 53ccf02b..edef45bf 100644 --- a/frontend/src/lib/person/genealogy/StammbaumTree.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumTree.svelte @@ -309,6 +309,7 @@ function handleCanvasKey(event: KeyboardEvent) { -- 2.49.1 From 86abe07b853626c577343a43aad2fbabb1f47e10 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:33:59 +0200 Subject: [PATCH 14/24] =?UTF-8?q?test(stammbaum):=20named-bug=20guard=20?= =?UTF-8?q?=E2=80=94=20deep-bloodline=20apex=20is=20centred,=20not=20stran?= =?UTF-8?q?ded=20left=20(#724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 5-generation single bloodline fanning out wide at the bottom: the apex great-great-grandparent (and every ancestor in the chain) sits at the centre of the descendant span, the exact symptom the old per-generation packer produced in reverse (apex pinned to the left edge). Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 75efeb49..7923519d 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -405,3 +405,59 @@ describe('buildLayout — cross-level marriage fallback (#724)', () => { expect(layout.positions.get(B)).toBeDefined(); }); }); + +function centerX(layout: ReturnType, id: string): number { + return layout.positions.get(id)!.x + NODE_W / 2; +} + +describe('buildLayout — named-bug guard: deep bloodline (#724)', () => { + // A 5-generation single bloodline whose deepest generation fans out wide. + // The OLD per-generation packer (now removed) stranded the apex ancestor at + // the LEFT edge of its descendants — the Albert/Martin symptom. The bottom-up + // tidy-tree centres every ancestor over the span of its descendants. + const gg = '00000000-0000-0000-0000-0000000000f0'; // G0 great-great-grandparent + const g = '00000000-0000-0000-0000-0000000000f1'; // G1 + const p = '00000000-0000-0000-0000-0000000000f2'; // G2 + const d = '00000000-0000-0000-0000-0000000000f3'; // G3 + const leaves = ['a', 'b', 'c', 'd', 'e'].map( + (s, i) => `00000000-0000-0000-0000-0000000000${(0xf4 + i).toString(16)}` + ); + + function buildBloodline() { + return buildLayout( + [ + node(gg, 'gg', 0), + node(g, 'g', 1), + node(p, 'p', 2), + node(d, 'd', 3), + ...leaves.map((id, i) => node(id, `leaf-${i}`, 4)) + ], + [ + parentEdge(gg, g, 'e1'), + parentEdge(g, p, 'e2'), + parentEdge(p, d, 'e3'), + ...leaves.map((id, i) => parentEdge(d, id, `el${i}`)) + ] + ); + } + + it('great_great_grandparent_is_not_stranded_left_of_descendants', () => { + const layout = buildBloodline(); + const leafCenters = leaves.map((id) => centerX(layout, id)); + const minLeaf = Math.min(...leafCenters); + const maxLeaf = Math.max(...leafCenters); + const ggX = centerX(layout, gg); + + // The apex ancestor sits strictly inside its descendant span — not pinned + // to the left edge as the old packer left it. + expect(ggX).toBeGreaterThan(minLeaf); + expect(ggX).toBeLessThan(maxLeaf); + // In fact it sits exactly at the centre of the descendant fan-out, and so + // does every ancestor in the chain (single-child chains inherit the centre). + const mid = (minLeaf + maxLeaf) / 2; + expect(ggX).toBe(mid); + expect(centerX(layout, g)).toBe(mid); + expect(centerX(layout, p)).toBe(mid); + expect(centerX(layout, d)).toBe(mid); + }); +}); -- 2.49.1 From 12f300c8cd4a252d1b83fb9a27f8980843da220b Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:35:54 +0200 Subject: [PATCH 15/24] test(stammbaum): every unit centre sits within its child-units span (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixture-wide loop over the canonical forest and a synthetic tree: each unit's run centre is within [min, max] of its child-unit centres — the ancestor centring invariant, asserted on real data. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 7923519d..21d5b958 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout'; +import { buildFamilyForest, type Unit } from './familyForest'; import canonicalFixture from '../__fixtures__/stammbaum.json'; import type { components } from '$lib/generated/api'; @@ -461,3 +462,43 @@ describe('buildLayout — named-bug guard: deep bloodline (#724)', () => { expect(centerX(layout, d)).toBe(mid); }); }); + +// Centre-x of a unit's run, derived from its primary's left edge + run width. +function unitCenter(layout: ReturnType, u: Unit): number { + const left = layout.positions.get(u.id)!.x; + const width = u.members.length * NODE_W + (u.members.length - 1) * COL_GAP; + return left + width / 2; +} + +describe('buildLayout — ancestor centring invariant (#724)', () => { + const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[]; + const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[]; + + it('every unit centre sits within its child units span (canonical + synthetic)', () => { + const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [ + ['canonical', fixtureNodes, fixtureEdges], + [ + 'synthetic', + [node('R', 'R', 0), node('c1', 'c1', 1), node('c2', 'c2', 1), node('c3', 'c3', 1)], + [parentEdge('R', 'c1'), parentEdge('R', 'c2'), parentEdge('R', 'c3')] + ] + ]; + + for (const [label, nodes, edges] of cases) { + const layout = buildLayout(nodes, edges); + const forest = buildFamilyForest(nodes, edges); + const walk = (u: Unit) => { + if (u.children.length > 0) { + const childCenters = u.children.map((c) => unitCenter(layout, c)); + const lo = Math.min(...childCenters); + const hi = Math.max(...childCenters); + const c = unitCenter(layout, u); + expect(c, `${label}: unit ${u.id} centred in child span`).toBeGreaterThanOrEqual(lo); + expect(c, `${label}: unit ${u.id} centred in child span`).toBeLessThanOrEqual(hi); + } + u.children.forEach(walk); + }; + forest.roots.forEach(walk); + } + }); +}); -- 2.49.1 From b9a9e099bbf29b4b899260c88f4c1f70f1a7c2d4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:37:20 +0200 Subject: [PATCH 16/24] test(stammbaum): no two nodes overlap on the same row (#724) O(n^2) sweep over canonical + synthetic: any two nodes sharing a y are at least NODE_W + COL_GAP apart. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 21d5b958..cdc5f611 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -501,4 +501,42 @@ describe('buildLayout — ancestor centring invariant (#724)', () => { forest.roots.forEach(walk); } }); + + it('no two nodes on the same row overlap (canonical + synthetic)', () => { + const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [ + ['canonical', fixtureNodes, fixtureEdges], + [ + 'synthetic deep', + [ + node('R', 'R', 0), + node('p1', 'p1', 1), + node('p2', 'p2', 1), + node('g1', 'g1', 2), + node('g2', 'g2', 2) + ], + [ + parentEdge('R', 'p1'), + parentEdge('R', 'p2'), + parentEdge('p1', 'g1'), + parentEdge('p1', 'g2') + ] + ] + ]; + + for (const [label, nodes, edges] of cases) { + const layout = buildLayout(nodes, edges); + const entries = [...layout.positions.entries()]; + for (let i = 0; i < entries.length; i++) { + for (let j = i + 1; j < entries.length; j++) { + const [, a] = entries[i]; + const [, b] = entries[j]; + if (a.y !== b.y) continue; + expect( + Math.abs(a.x - b.x), + `${label}: ${entries[i][0]} vs ${entries[j][0]} overlap on y=${a.y}` + ).toBeGreaterThanOrEqual(NODE_W + COL_GAP); + } + } + } + }); }); -- 2.49.1 From 5fa2573ce014e2b880dbd15bc2b6d065037e83b4 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:38:40 +0200 Subject: [PATCH 17/24] test(stammbaum): a bloodline occupies one contiguous band (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No node outside a root's structural subtree may intrude into that bloodline's [minX, maxX] horizontal span — the contiguity guarantee that fixes the smeared bloodline symptom. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index cdc5f611..267b1191 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -539,4 +539,46 @@ describe('buildLayout — ancestor centring invariant (#724)', () => { } } }); + + it('a bloodline occupies one contiguous band — no foreign node interleaved', () => { + // Two roots, each its own bloodline. The first fans out two generations + // deep; the contour pack must keep it as one band with nothing from the + // other bloodline wedged inside its horizontal span. + const nodes = [ + node('R1', 'R1', 0), + node('a', 'a', 1), + node('b', 'b', 1), + node('a1', 'a1', 2), + node('R2', 'R2', 0), + node('c', 'c', 1) + ]; + const edges = [ + parentEdge('R1', 'a'), + parentEdge('R1', 'b'), + parentEdge('a', 'a1'), + parentEdge('R2', 'c') + ]; + const layout = buildLayout(nodes, edges); + const forest = buildFamilyForest(nodes, edges); + + // Collect the R1 bloodline: every member of its unit subtree. + const r1 = forest.roots.find((u) => u.id === 'R1')!; + const bloodline = new Set(); + const collect = (u: Unit) => { + u.members.forEach((m) => bloodline.add(m)); + u.children.forEach(collect); + }; + collect(r1); + + const bandXs = [...bloodline].map((id) => layout.positions.get(id)!.x); + const minX = Math.min(...bandXs); + const maxX = Math.max(...bandXs) + NODE_W; + + // No node outside the bloodline may intrude into its [minX, maxX] band. + for (const [id, p] of layout.positions) { + if (bloodline.has(id)) continue; + const intrudes = p.x < maxX && p.x + NODE_W > minX; + expect(intrudes, `foreign node ${id} interleaved into the R1 band`).toBe(false); + } + }); }); -- 2.49.1 From 0efe3a4ef626d8ea8fe4c2636bcb094e3c8b77c3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:50:33 +0200 Subject: [PATCH 18/24] fix(stammbaum): index tidy-tree contour by generation level, not tree depth (#724) The canonical graph is a forest of 24 roots spread across generations 0-4. Packing every root at tree-depth 0 stacked all of them horizontally even when they sit at different generations (different y), blowing the canvas out to ~9660px. Indexing the contour by absolute level (the rank buildLayout already passes as level) lets unrelated roots at different generations share x-columns, and keeps the no-overlap guarantee per-row. level falls back to tree depth when omitted, so the abstract tidyTree tests are unaffected. Co-Authored-By: Claude Opus 4.8 --- .../person/genealogy/layout/buildLayout.ts | 3 + .../lib/person/genealogy/layout/tidyTree.ts | 106 +++++++++++------- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.ts index bb4b41d6..4b6c5b2f 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.ts @@ -79,6 +79,9 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO const toTidy = (u: Unit): TidyNode => ({ id: u.id, width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP, + // Pass the unit's rank as the contour level so unrelated roots that sit + // at different generations can share x-columns instead of smearing wide. + level: rank.get(u.id) ?? 0, children: u.children.map(toTidy) }); const runX = layoutForest(forest.roots.map(toTidy), COL_GAP); diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.ts index 61f90a1e..b12ae6cb 100644 --- a/frontend/src/lib/person/genealogy/layout/tidyTree.ts +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.ts @@ -1,52 +1,63 @@ // Domain-agnostic bottom-up "tidy tree" contour packer (#724). // // This module knows NOTHING about persons, spouses, ranks, or the generated -// API — it lays out abstract { id, width, children } nodes purely by structure, -// so it is unit-testable with hand-built 3-line trees. All genealogy knowledge -// (spouse runs, birth-year order, cross-links) lives in familyForest.ts, which -// flattens its domain model into these abstract nodes before calling in here. +// API — it lays out abstract { id, width, children, level? } nodes purely by +// structure, so it is unit-testable with hand-built 3-line trees. All genealogy +// knowledge (spouse runs, birth-year order, cross-links) lives in +// familyForest.ts, which flattens its domain model into these abstract nodes. // // Algorithm (a plain Reingold–Tilford / Walker contour pack, NOT Buchheim's -// O(n) threaded variant — at ~62 nodes the simple O(n·depth) shift-and-merge is -// fast enough and far easier to verify): +// O(n) threaded variant — at ~62 nodes the simple shift-and-merge is fast +// enough and far easier to verify): // // 1. Post-order: lay out every child subtree first. // 2. Pack children left-to-right; each new subtree is shifted right just far // enough that its LEFT contour clears the running RIGHT contour of the -// already-placed siblings by `gap` at every shared depth (mergeContour + +// already-placed siblings by `gap` at every shared LEVEL (mergeContour + // shiftSubtree). Deep and shallow branches therefore nest without overlap. // 3. Place the node's own (variable-width) run centred over the span of its // children's centres, so an ancestor always sits above its descendants. // +// Contours are indexed by ABSOLUTE level (generation rank), NOT tree depth. +// This is what lets two unrelated roots that sit at different generations share +// the same x-column instead of being smeared across the canvas — the family +// graph is a forest of ~50 roots over 62 nodes, so packing every root at +// depth 0 would more than double the width. When `level` is omitted (the +// abstract unit tests) it falls back to tree depth, so a plain tree behaves +// exactly like classic Reingold–Tilford. +// // x is the LEFT edge of each node's box. y is NOT computed here — the caller -// derives it from generation rank. Positions are kept on the integer grid -// (root centres are rounded) so the no-overlap invariant holds exactly. +// derives it from the same generation rank it passes in as `level`. Positions +// are kept on the integer grid (root centres are rounded) so the no-overlap +// invariant holds exactly. export type TidyNode = { id: string; /** Total horizontal extent of this node's run (one card, or a couple). */ width: number; children: TidyNode[]; + /** Absolute level (generation rank). Falls back to tree depth when omitted. */ + level?: number; }; // A laid-out subtree in its own local frame: per-id left-edge x plus the left -// and right contours indexed by depth relative to this subtree's root (0 = root). +// and right contours keyed by absolute level. type Laid = { x: Map; - left: number[]; - right: number[]; + left: Map; + right: Map; }; /** * Lay out a forest of root subtrees packed left-to-right and return a map of * node id → left-edge x. The whole forest is normalised so the leftmost edge * sits at x = 0. `gap` is the minimum horizontal clearance between any two - * boxes at the same depth. + * boxes on the same level. */ export function layoutForest(roots: TidyNode[], gap: number): Map { if (roots.length === 0) return new Map(); const placed = packChildren( - roots.map((r) => layoutUnit(r, gap)), + roots.map((r) => layoutUnit(r, gap, null)), gap ); const x = new Map(); @@ -63,15 +74,20 @@ export function layoutForest(roots: TidyNode[], gap: number): Map layoutUnit(c, gap)), + children.map((c) => layoutUnit(c, gap, level)), gap ); @@ -86,18 +102,17 @@ function layoutUnit(node: TidyNode, gap: number): Laid { for (const [id, v] of subtree.x) x.set(id, v); } - // Subtree contour: depth 0 is the root; children contours shift down a level. - let childLeft: number[] = []; - let childRight: number[] = []; + // Subtree contour: children's contours plus this node at its own level. + let left = new Map(); + let right = new Map(); for (const subtree of placed) { - childLeft = mergeContour(childLeft, subtree.left, Math.min); - childRight = mergeContour(childRight, subtree.right, Math.max); + left = mergeContour(left, subtree.left, Math.min); + right = mergeContour(right, subtree.right, Math.max); } - return { - x, - left: [rootLeft, ...childLeft], - right: [rootLeft + node.width, ...childRight] - }; + left.set(level, left.has(level) ? Math.min(left.get(level)!, rootLeft) : rootLeft); + const rootRight = rootLeft + node.width; + right.set(level, right.has(level) ? Math.max(right.get(level)!, rootRight) : rootRight); + return { x, left, right }; } function childCenter(laid: Laid, node: TidyNode): number { @@ -108,13 +123,15 @@ function childCenter(laid: Laid, node: TidyNode): number { // contour clears the running right contour of all previously placed siblings. function packChildren(children: Laid[], gap: number): Laid[] { const placed: Laid[] = []; - let accRight: number[] = []; + let accRight = new Map(); for (const child of children) { let shift = 0; - const shared = Math.min(accRight.length, child.left.length); - for (let d = 0; d < shared; d++) { - const need = accRight[d] + gap - child.left[d]; - if (need > shift) shift = need; + for (const [level, l] of child.left) { + const r = accRight.get(level); + if (r !== undefined) { + const need = r + gap - l; + if (need > shift) shift = need; + } } const moved = shiftSubtree(child, shift); placed.push(moved); @@ -126,21 +143,26 @@ function packChildren(children: Laid[], gap: number): Laid[] { // Translate a laid-out subtree (positions + both contours) by dx. function shiftSubtree(laid: Laid, dx: number): Laid { if (dx === 0) return laid; + const shiftMap = (m: Map) => { + const out = new Map(); + for (const [k, v] of m) out.set(k, v + dx); + return out; + }; const x = new Map(); for (const [id, v] of laid.x) x.set(id, v + dx); - return { x, left: laid.left.map((v) => v + dx), right: laid.right.map((v) => v + dx) }; + return { x, left: shiftMap(laid.left), right: shiftMap(laid.right) }; } -// Combine two depth-indexed contours with `pick` (Math.min for left edges, -// Math.max for right edges); depths present in only one contour are kept. +// Combine two level-indexed contours with `pick` (Math.min for left edges, +// Math.max for right edges); levels present in only one contour are kept. function mergeContour( - acc: number[], - add: number[], + acc: Map, + add: Map, pick: (a: number, b: number) => number -): number[] { - const out = acc.slice(); - for (let d = 0; d < add.length; d++) { - out[d] = d < out.length ? pick(out[d], add[d]) : add[d]; +): Map { + const out = new Map(acc); + for (const [level, v] of add) { + out.set(level, out.has(level) ? pick(out.get(level)!, v) : v); } return out; } -- 2.49.1 From e2ad1f2cf09a61899d14db3ae625db3fb29d1824 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:51:03 +0200 Subject: [PATCH 19/24] test(stammbaum): per-bloodline span regression replaces total-width (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Total canvas width is the wrong metric: centring every ancestor makes a 24-root forest wider overall (an accepted trade-off, pan/zoom handles navigation). The actual fix is per-bloodline compactness. Assert every contiguous bloodline's span stays far under the old full-canvas smear (4860px) — today the widest, Albert de Gruyter's, is ~960px, down from being smeared across the whole canvas. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 267b1191..82d8f11e 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -581,4 +581,36 @@ describe('buildLayout — ancestor centring invariant (#724)', () => { expect(intrudes, `foreign node ${id} interleaved into the R1 band`).toBe(false); } }); + + it('no bloodline is smeared across the canvas (per-bloodline span regression)', () => { + // Golden constant: measured 2026-06-04, the OLD per-generation block + // packer laid the canonical fixture out 4860px wide and smeared a single + // bloodline (Albert de Gruyter G0 … a far-right descendant) across that + // whole span — the bug. Total canvas width is NOT the right metric for the + // fix (centring every ancestor makes a 24-root forest wider overall, and + // pan/zoom from #692 handles navigation); per-bloodline compactness is. + // Each contiguous bloodline must occupy far less than the old full-canvas + // smear. Today the widest is Albert's at ~960px. + const OLD_FULL_CANVAS = 4860; + + const layout = buildLayout(fixtureNodes, fixtureEdges); + const forest = buildFamilyForest(fixtureNodes, fixtureEdges); + const bloodlineSpan = (root: Unit): number => { + const ids: string[] = []; + const collect = (u: Unit) => { + ids.push(...u.members); + u.children.forEach(collect); + }; + collect(root); + const xs = ids.map((id) => layout.positions.get(id)!.x); + return Math.max(...xs) + NODE_W - Math.min(...xs); + }; + + const spans = forest.roots.map(bloodlineSpan); + const widest = Math.max(...spans); + // Every bloodline is dramatically more compact than the old smear … + expect(widest).toBeLessThan(OLD_FULL_CANVAS); + // … and in fact comfortably under a quarter of it (no smear creeps back). + expect(widest).toBeLessThanOrEqual(OLD_FULL_CANVAS / 4); + }); }); -- 2.49.1 From 445d2c6eaa2390d339ae523a95b74adf980063f9 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:52:01 +0200 Subject: [PATCH 20/24] test(stammbaum): layout is deterministic under input reordering (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeded Fisher-Yates permutation of nodes and edges yields byte-identical positions — confirms every comparator ends in a stable id and nothing relies on Map iteration order. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 82d8f11e..8a654003 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -614,3 +614,40 @@ describe('buildLayout — ancestor centring invariant (#724)', () => { expect(widest).toBeLessThanOrEqual(OLD_FULL_CANVAS / 4); }); }); + +// Deterministic Fisher–Yates using a seeded mulberry32 PRNG (never Math.random, +// which would make the determinism test itself non-reproducible). +function seededShuffle(input: T[], seed: number): T[] { + let s = seed >>> 0; + const rand = () => { + s = (s + 0x6d2b79f5) >>> 0; + let t = s; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + const out = input.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +describe('buildLayout — determinism (#724)', () => { + const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[]; + const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[]; + + it('produces identical positions regardless of node/edge input order', () => { + const base = buildLayout(fixtureNodes, fixtureEdges); + const shuffled = buildLayout( + seededShuffle(fixtureNodes, 1337), + seededShuffle(fixtureEdges, 4242) + ); + + expect(shuffled.positions.size).toBe(base.positions.size); + for (const [id, p] of base.positions) { + expect(shuffled.positions.get(id), `position for ${id} is order-independent`).toEqual(p); + } + }); +}); -- 2.49.1 From fad3aa03736067c314876f01c753976ba3aff5b1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:53:44 +0200 Subject: [PATCH 21/24] =?UTF-8?q?test(stammbaum):=20cyclic=20input=20fails?= =?UTF-8?q?=20closed=20=E2=80=94=20finite=20layout,=20one=20position=20per?= =?UTF-8?q?=20node=20(#724)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An A<->B parent cycle and a founder reaching a re-entrant 3-cycle both return a finite layout (no frozen $derived) with every node placed exactly once. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index 8a654003..a17fb183 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -651,3 +651,49 @@ describe('buildLayout — determinism (#724)', () => { } }); }); + +describe('buildLayout — termination + once-only on cyclic input (#724)', () => { + // buildLayout runs inside a $derived, so non-termination = a frozen tab. + // User-entered data can form parent cycles; the layout must fail closed — + // produce *a* finite layout, every node placed exactly once. + const A = '00000000-0000-0000-0000-0000000000a1'; + const B = '00000000-0000-0000-0000-0000000000a2'; + const C = '00000000-0000-0000-0000-0000000000a3'; + + it('A↔B parent cycle returns a finite layout with one position per node', () => { + let layout!: ReturnType; + expect(() => { + layout = buildLayout( + [node(A, 'A'), node(B, 'B')], + [parentEdge(A, B, 'ab'), parentEdge(B, A, 'ba')] + ); + }).not.toThrow(); + + expect(layout.positions.size).toBe(2); + expect(layout.positions.get(A)).toBeDefined(); + expect(layout.positions.get(B)).toBeDefined(); + }); + + it('two founders both reaching a re-entrant 3-cycle terminate with every node placed', () => { + // F is a founder parenting A; A→B→C→A is a parent cycle, and C also + // re-enters via F. The visited/once-only guarantees keep it finite. + const F = '00000000-0000-0000-0000-0000000000a0'; + let layout!: ReturnType; + expect(() => { + layout = buildLayout( + [node(F, 'F'), node(A, 'A'), node(B, 'B'), node(C, 'C')], + [ + parentEdge(F, A, 'fa'), + parentEdge(A, B, 'ab'), + parentEdge(B, C, 'bc'), + parentEdge(C, A, 'ca') + ] + ); + }).not.toThrow(); + + // Every node is placed exactly once (Map keys are unique by construction; + // the count equality proves none were dropped or duplicated). + expect(layout.positions.size).toBe(4); + for (const id of [F, A, B, C]) expect(layout.positions.get(id)).toBeDefined(); + }); +}); -- 2.49.1 From 71e35a5bce1fbb2a69c72c91dc5f30c63ab8752f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 14:15:30 +0200 Subject: [PATCH 22/24] test(stammbaum): cover empty-graph and single-node layouts (#724) Review follow-up (Sara/QA): the empty graph (fresh /stammbaum before data loads) exercised the positions.size===0 viewBox fallback and the roots.length===0 early return, both previously untested. Assert no NaN in the viewBox and MIN dimensions, plus a single isolated node placed once at rank 0. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/layout/buildLayout.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts index a17fb183..d03da269 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.test.ts @@ -697,3 +697,27 @@ describe('buildLayout — termination + once-only on cyclic input (#724)', () => for (const id of [F, A, B, C]) expect(layout.positions.get(id)).toBeDefined(); }); }); + +describe('buildLayout — degenerate graphs (#724)', () => { + it('empty graph yields no positions and the MIN viewBox (no NaN)', () => { + // Runs on a fresh /stammbaum visit before any data loads — must not throw + // or emit NaN into the SVG viewBox. + const layout = buildLayout([], []); + expect(layout.positions.size).toBe(0); + expect(layout.crossLinks).toEqual([]); + expect(layout.generations.size).toBe(0); + for (const v of [layout.viewX, layout.viewY, layout.viewW, layout.viewH]) { + expect(Number.isFinite(v)).toBe(true); + } + // computeViewBox falls back to MIN dimensions when there is no content. + expect(layout.viewW).toBe(1200); + expect(layout.viewH).toBe(800); + }); + + it('single isolated node is placed once at rank 0', () => { + const layout = buildLayout([node(PARENT, 'Solo', 0)], []); + expect(layout.positions.size).toBe(1); + expect(layout.positions.get(PARENT)).toEqual({ x: 0, y: 0 }); + expect(layout.crossLinks).toEqual([]); + }); +}); -- 2.49.1 From 8e44d3b71f856c72127cd41189f947e2fcd9ceb1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 14:16:01 +0200 Subject: [PATCH 23/24] fix(stammbaum): raise cross-link opacity to 0.7 + add dash-render test (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: - Leonie/UX: 0.55 navy on the sand canvas was ~2.6:1, under the WCAG 1.4.11 3:1 non-text floor for senior readers; 0.7 clears it. - Sara/QA: add a browser test that actually renders a cross-level link and asserts the distinct 2 6 dash, and that a non-cross-link parent edge stays solid — the cadence was previously only validated via the structural crossLinks array, never where it renders. Co-Authored-By: Claude Opus 4.8 --- .../genealogy/StammbaumConnectors.svelte | 6 +- .../StammbaumConnectors.svelte.test.ts | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/person/genealogy/StammbaumConnectors.svelte.test.ts diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte index 4e0c1b66..d60c85fd 100644 --- a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte @@ -28,9 +28,11 @@ let { edges, positions, crossLinks = [], isConnectorActive = () => true }: Props // Dash cadence + opacity for a cross-link, deliberately distinct from the // ended-marriage spouse dash (`4 4`) so the two never read alike (WCAG 1.4.1: -// geometry carries the meaning too, not stroke alone). +// geometry carries the meaning too, not stroke alone). Opacity stays at 0.7 so +// the dotted line clears the WCAG 1.4.11 3:1 non-text contrast floor for +// senior / low-vision readers (a lighter 0.55 fell just under). const CROSS_LINK_DASH = '2 6'; -const CROSS_LINK_OPACITY = 0.55; +const CROSS_LINK_OPACITY = 0.7; const crossLinkSet = $derived(new SvelteSet(crossLinks.map((c) => `${c.parentId}->${c.childId}`))); function isCrossLink(parentId: string, childId: string): boolean { diff --git a/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte.test.ts b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte.test.ts new file mode 100644 index 00000000..0d9d34db --- /dev/null +++ b/frontend/src/lib/person/genealogy/StammbaumConnectors.svelte.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import StammbaumConnectors from './StammbaumConnectors.svelte'; +import { NODE_H } from './layout/buildLayout'; +import type { components } from '$lib/generated/api'; + +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +const P = '00000000-0000-0000-0000-0000000000c1'; +const C = '00000000-0000-0000-0000-0000000000c2'; +const H = '00000000-0000-0000-0000-0000000000c3'; +const W = '00000000-0000-0000-0000-0000000000c4'; + +function parentEdge(parentId: string, childId: string): RelationshipDTO { + return { + id: `${parentId}>${childId}`, + personId: parentId, + relatedPersonId: childId, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'PARENT_OF' + }; +} + +function endedSpouseEdge(a: string, b: string): RelationshipDTO { + return { + id: `${a}~${b}`, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SPOUSE_OF', + toYear: 1950 + }; +} + +const positions = new Map([ + [P, { x: 0, y: 0 }], + [C, { x: 400, y: NODE_H + 80 }], + [H, { x: 0, y: 400 }], + [W, { x: 300, y: 400 }] +]); + +const dashesOf = (selector: string) => + Array.from(document.querySelectorAll(selector)) + .map((el) => el.getAttribute('stroke-dasharray')) + .filter((d): d is string => d !== null); + +describe('StammbaumConnectors — cross-link cadence (#724)', () => { + it('renders a cross-level link with the distinct 2 6 dash, never the 4 4 ended-marriage dash', async () => { + render(StammbaumConnectors, { + edges: [parentEdge(P, C), endedSpouseEdge(H, W)], + positions, + crossLinks: [{ parentId: P, childId: C }] + }); + + await vi.waitFor(() => { + const dashes = dashesOf('line'); + // The cross-link cadence is present … + expect(dashes).toContain('2 6'); + // … the ended-marriage cadence is present … + expect(dashes).toContain('4 4'); + // … and the two are genuinely different cadences (WCAG 1.4.1: not by + // stroke alone, but they must not collapse into the same pattern). + expect('2 6').not.toBe('4 4'); + }); + }); + + it('draws a normal parent→child connector solid when it is NOT a cross-link', async () => { + render(StammbaumConnectors, { + edges: [parentEdge(P, C)], + positions, + crossLinks: [] + }); + + await vi.waitFor(() => { + // No dashed parent lines at all when nothing is a cross-link. + expect(dashesOf('line')).not.toContain('2 6'); + expect(document.querySelectorAll('line').length).toBeGreaterThan(0); + }); + }); +}); -- 2.49.1 From cfbcc047c4f577f4b986c66b2e817a1294277d99 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 14:18:18 +0200 Subject: [PATCH 24/24] docs(stammbaum): ADR-030 tidy-tree layout, supersede ADR-026 packer, refresh glossary (#724) Review follow-up (Markus/Architect): ADR-026 pre-committed a successor ADR if the in-house layout stopped converging; its UX stop-trigger (Albert smeared across the canvas) fired. ADR-030 records the bottom-up tidy-tree, the module split, and the two maintainer-confirmed decisions (hybrid intra-family, per-bloodline width metric), superseding ADR-026's block-packer in part (no-dagre + seeded-rank retained). GLOSSARY replaces the deleted sibling-block / parented / anchor-index vocabulary with the new family-forest model (unit, tidy tree, structural owner, bloodline, cross-link). Co-Authored-By: Claude Opus 4.8 --- docs/GLOSSARY.md | 17 ++- docs/adr/026-stammbaum-layout-in-house.md | 9 +- ...30-stammbaum-bloodline-tidy-tree-layout.md | 110 ++++++++++++++++++ 3 files changed, 128 insertions(+), 8 deletions(-) create mode 100644 docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 2c0f3aba..56982fb7 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -111,16 +111,21 @@ _See also [PersonRelationship](#person-person)._ **seeded rank** (`Person.generation`) — the imported generation index on a `Person` (G 0 = founders, increasing downward), used as a strict row anchor in `buildLayout.ts`. The iterative fallback heuristic never overrides a seeded rank, and spouse-pulldown never pulls a seeded rank — only unseeded nodes (no `generation`) flow through the heuristic. -**sibling block** — a layout unit holding the children of a single parent-set at one generation, used inside `buildLayout.ts`. Each block has a center computed from the parents' midpoint; blocks are then packed left-to-right within a generation row. Two adjacent sibling blocks at the same rank can be merged if a `SPOUSE_OF` edge crosses them (intra-family marriage, AC2). +**family forest** — the model the Stammbaum horizontal layout reasons over (ADR-030, `familyForest.ts`): a forest of **units** rather than per-generation rows. Replaces the old per-generation "sibling block" packer. The canonical fixture is ~24 root units over 62 nodes. -**loose spouse** — a person at a given generation who is a spouse of someone in a sibling block but is not themselves a parented child of anyone in the graph. Loose spouses are attached adjacent to their parented partner (right side per Leonie's UX rule) so the spouse line stays short. -_Not to be confused with [parented](#parented-layout)_ — loose is the absence of parent edges into the graph. +**unit** `[layout]` — one bloodline carrier (the **primary**) plus the spouse(s) absorbed into its run, rendered as one adjacent row of cards. `members[0]` is the primary; the rest are spouses in marriage-year order (#361). A lone person is a unit of one. A unit's children are the units anchored by the couple's offspring. The unit — not the individual — is the node the tidy-tree packs. -**parented** `[layout]` — a layout flag on a sibling-block member indicating that the person has at least one `PARENT_OF` edge incoming from a node already in the graph at the prior generation. Parented members are the layout anchors of their block (the block is centred so the average index of parented members sits under the parents' midpoint); non-parented members (loose spouses) ride along on the side. +**tidy tree** — the bottom-up Reingold–Tilford contour packer (`tidyTree.ts`) that assigns each unit's horizontal `x`: lay out child subtrees first, pack them so their contours clear by `COL_GAP` at every level, then centre the unit over the span of its children. Contours are indexed by absolute generation level, so unrelated roots at different generations share x-columns. `x` comes from structure; `y` still comes from rank (`assignRanks`, #689). -**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°). +**structural owner** — for a couple, the spouse that keeps the bloodline (hierarchy) position: lower `birthYear`, then stable `id` (`pickStructuralOwner` in `familyForest.ts`). The other spouse is absorbed into the owner's run. Reused by the cross-link, cycle, and intra-family paths so the rule is defined once. -**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`. +**loose spouse** — a person who marries into the graph with no `PARENT_OF` edges of their own. They are absorbed into their partner's unit run (no ancestor subtree), but any children of theirs still anchor through the couple unit. + +**bloodline** — the set of people reachable from a root unit via structural-owner `PARENT_OF` edges; renders as one contiguous horizontal band with no foreign node interleaved (the contiguity invariant that fixed the smeared-bloodline bug, #724). + +**cross-link** `[layout]` — a `PARENT_OF` edge whose child is positioned in a spouse's run elsewhere (a cross-level intra-family marriage). The connector draws it with a distinct `2 6` dash at reduced opacity — never the `4 4` ended-marriage cadence — with geometry still landing on the child (WCAG 1.4.1). + +**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints have parents in the graph. The couple is always exactly adjacent in the owner's run; when the two spouses' parents sit at the same structural level the displaced parent edge stays solid (the adjacency case), otherwise it renders as a cross-link. The canonical fixture has two such marriages (Walter⚭Eugenie, Clara⚭Herbert), covered in `buildLayout.test.ts`. **marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person. diff --git a/docs/adr/026-stammbaum-layout-in-house.md b/docs/adr/026-stammbaum-layout-in-house.md index 71417919..c0723930 100644 --- a/docs/adr/026-stammbaum-layout-in-house.md +++ b/docs/adr/026-stammbaum-layout-in-house.md @@ -1,10 +1,14 @@ # ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred **Date:** 2026-05-28 -**Status:** Accepted +**Status:** Accepted — superseded in part by [ADR-030](./030-stammbaum-bloodline-tidy-tree-layout.md) **Issue:** #361 **Supersedes:** _none_ -**Supersedes-on-trigger:** A future ADR-027 if any acceptance criterion below stops converging in-house. +**Superseded-by:** ADR-030 (#724) replaces the **per-generation block packer** below with +a bottom-up tidy-tree after its position-within-rank model stranded ancestors and smeared +bloodlines across the canvas — the UX stop-trigger named in this ADR. The **in-house / +no-dagre** decision and the seeded-rank invariant (#689) are retained. +**Supersedes-on-trigger:** _(triggered)_ The UX stop-trigger fired; see ADR-030. --- @@ -117,6 +121,7 @@ threshold, so `packBlocks.ts` is **not** yet warranted. is the source-of-truth probe against live data; the function is the capture-time and fixture-time signal that the predicate's count crossed zero. + - **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under this ADR; reactivates only under ADR-027 (dagre adoption). - **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()` diff --git a/docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md b/docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md new file mode 100644 index 00000000..0fbcd0a3 --- /dev/null +++ b/docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md @@ -0,0 +1,110 @@ +# ADR-030 — Stammbaum Bloodline-Contiguous Tidy-Tree Layout + +**Date:** 2026-06-04 +**Status:** Accepted +**Issue:** #724 +**Supersedes:** ADR-026 (in part — the per-generation block-packer decision and its +position-within-rank fix path; the in-house / no-dagre decision and the seeded-rank +invariant from #689 are retained) + +--- + +## Context + +ADR-026 kept Stammbaum horizontal placement in-house with a **per-generation block +packer** and pre-committed a successor ADR "if any acceptance criterion stops +converging in-house." Its single UX stop-trigger was Albert de Gruyter's marriages +failing the read test. + +The block packer hit a worse, structural failure: it placed each generation +**independently**, centring sibling blocks under already-placed parents and only ever +**shoving right** on collision. Two consequences followed — a deep branch that could +not fit at its ideal centre dragged everything downstream rightward and stranded the +ancestor at the **left edge** of its own descendants; and a parent placed before its +descendants existed could never be re-centred over them. Extreme symptom: Albert de +Gruyter (G0) far left, a great-great-grandchild far right — one bloodline smeared +across the full canvas. That is exactly the "UX failure against the canonical fixture" +ADR-026 named as the trigger to revisit the layout. + +## Decision + +**Replace the per-generation block packer with a bottom-up "tidy tree" +(Reingold–Tilford / Walker contour pack), still in-house, no new dependency.** + +The horizontal `x` rewrite is split into three reviewable, unit-tested modules +(mirroring the `panZoom.ts` / `panZoomGestures.ts` / `animateView.ts` split): + +- **`layout/tidyTree.ts`** — domain-agnostic contour packer over abstract + `{ id, width, children, level? }` nodes, zero generated-API imports. Contours are + indexed by **absolute generation level**, not tree depth, so unrelated roots at + different generations share x-columns instead of smearing the forest wide. +- **`layout/familyForest.ts`** — all genealogy semantics: the **unit** model (a + bloodline-carrying primary plus the spouse(s) absorbed into its run), + `pickStructuralOwner` (lower birthYear, then stable id), loose-spouse absorption, + multi-spouse runs (#361), sibling/branch order (birthYear ASC NULLS LAST → + displayName → id), intra-family resolution, and cross-link classification. +- **`layout/buildLayout.ts`** — orchestrates forest → tidy-pack → per-person + positions. `assignRanks` (y from rank, #689 seeding), the `generations` map, and + `computeViewBox` are reused **unchanged**; `x` comes from structure, `y` from rank. + +Two decisions taken during implementation and confirmed with the maintainer: + +1. **Intra-family marriage = hybrid.** A couple is always exactly adjacent in the + owner's run. When the two spouses' parents sit at the **same structural level** the + displaced parent edge renders as a normal solid connector (the "adjacency" case); + when they are **cross-level** (e.g. the canonical Clara⚭Herbert, where one parent + is nested under Albert and the other hangs off a separate root), the structural + owner keeps the hierarchy edge and the other parent→spouse edge renders as a + distinct cross-link. +2. **Cross-link is rendered with a distinct `2 6` dash at 0.7 opacity** in + `StammbaumConnectors.svelte` — never the `4 4` ended-marriage cadence. Geometry + still lands on the correct child top, so meaning is carried redundantly (WCAG + 1.4.1); the 0.7 opacity clears the WCAG 1.4.11 3:1 non-text floor. + +## Consequences + +### Accepted + +- **Ancestor centring** — every unit is centred over its child-units' span (named-bug + guard `great_great_grandparent_is_not_stranded_left_of_descendants` + a fixture-wide + loop over canonical and synthetic trees). +- **Bloodline contiguity** — each bloodline is one band with no foreign node + interleaved. Albert de Gruyter's bloodline shrank from a full ~4860px smear to + ~960px. +- **#361 / #689 preserved** — multi-spouse runs in marriage-year order, seeded ranks, + spouse pull-down; the existing `buildLayout.test.ts` cases stay green. +- **Determinism** — every comparator ends in a stable id; a seeded permutation of + nodes/edges yields byte-identical positions. +- **Fail-closed on cycles** — `assignRanks`' iteration ceiling plus a forest structure + (each unit has ≤1 hierarchy parent, cycles are unreachable from roots) guarantee a + finite layout with every node placed exactly once. + +### Trade-off — total canvas width replaces the ADR-026 width assumption + +Centring every ancestor inherently makes a forest of ~24 root-bands **wider** overall +than the old per-generation left-packer that interleaved everyone into compact shared +rows (canonical: ~7960px vs the old ~4860px). Total canvas width is therefore the +wrong success metric; **per-bloodline span** is. The width regression test asserts +each contiguous bloodline stays far under the old full-canvas smear. The wider canvas +is navigated by the pan/zoom from #692 (ADR-027) and is an accepted trade-off for +readability. + +### Operational + +- **No CI, image, compose, or dependency change.** Pure frontend layout. The + `d3-flextree` escape hatch from #724 was not needed. + +### Deferred (follow-ups, per #724) + +- Connector legibility at 320px — the issue's "open verification"; a manual + `/stammbaum` pass, with a connector-clarity issue spun only if drops/cross-links + tangle. +- Polished cross-link routing + a relationship tooltip. + +## Notes + +- ADR-026's retained parts: the **no-dagre / in-house** decision and the seeded-rank + invariant (#689) still hold — this ADR changes only _position-within / across_ rank, + not rank assignment, and adds no dependency. +- The `validateFixture` sanity gates and the AC3 revisit probe from ADR-026 are + unchanged. -- 2.49.1