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 <noreply@anthropic.com>
169 lines
6.3 KiB
TypeScript
169 lines
6.3 KiB
TypeScript
// 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<string, number>;
|
||
left: Map<number, number>;
|
||
right: Map<number, 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 on the same level.
|
||
*/
|
||
export function layoutForest(roots: TidyNode[], gap: number): Map<string, number> {
|
||
if (roots.length === 0) return new Map();
|
||
const placed = packChildren(
|
||
roots.map((r) => layoutUnit(r, gap, null)),
|
||
gap
|
||
);
|
||
const x = new Map<string, number>();
|
||
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<string, number>([[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<number, number>();
|
||
let right = new Map<number, number>();
|
||
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<number, number>();
|
||
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<number, number>) => {
|
||
const out = new Map<number, number>();
|
||
for (const [k, v] of m) out.set(k, v + dx);
|
||
return out;
|
||
};
|
||
const x = new Map<string, number>();
|
||
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<number, number>,
|
||
add: Map<number, number>,
|
||
pick: (a: number, b: number) => number
|
||
): Map<number, number> {
|
||
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;
|
||
}
|