Files
familienarchiv/frontend/src/lib/person/genealogy/layout/tidyTree.ts
Marcel 0efe3a4ef6 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>
2026-06-04 13:50:33 +02:00

169 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 ReingoldTilford / 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 ReingoldTilford.
//
// 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;
}