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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 13:50:33 +02:00
parent 5fa2573ce0
commit 0efe3a4ef6
2 changed files with 67 additions and 42 deletions

View File

@@ -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);

View File

@@ -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 ReingoldTilford / 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 ReingoldTilford.
//
// 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<string, number>;
left: number[];
right: 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 at the same depth.
* 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)),
roots.map((r) => layoutUnit(r, gap, null)),
gap
);
const x = new Map<string, number>();
@@ -63,15 +74,20 @@ export function layoutForest(roots: TidyNode[], gap: number): Map<string, number
return x;
}
// Post-order layout of one subtree.
function layoutUnit(node: TidyNode, gap: number): Laid {
// 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: [0], right: [node.width] };
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)),
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<number, number>();
let right = new Map<number, number>();
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<number, 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;
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<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: 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<number, number>,
add: Map<number, 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];
): 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;
}