// 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, 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 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 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 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 keyed by absolute level. type Laid = { x: Map; 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 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, null)), 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. `parentLevel` seeds the depth fallback. function layoutUnit(node: TidyNode, gap: number, parentLevel: number | null): Laid { const level = node.level ?? (parentLevel == null ? 0 : parentLevel + 1); const children = node.children ?? []; if (children.length === 0) { return { x: new Map([[node.id, 0]]), left: new Map([[level, 0]]), right: new Map([[level, node.width]]) }; } const placed = packChildren( children.map((c) => layoutUnit(c, gap, level)), 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: children's contours plus this node at its own level. let left = new Map(); let right = new Map(); for (const subtree of placed) { left = mergeContour(left, subtree.left, Math.min); right = mergeContour(right, subtree.right, Math.max); } 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 { 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 = new Map(); for (const child of children) { let shift = 0; 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); 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 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: shiftMap(laid.left), right: shiftMap(laid.right) }; } // 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: Map, add: Map, pick: (a: number, b: number) => number ): 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; }