From 6703347468947fe79623ee36b260b816df10d22f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 13:50:33 +0200 Subject: [PATCH] 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; }