feat(stammbaum): add tidyTree contour packer with leaf base case (#724)
New domain-agnostic bottom-up tidy-tree module (Reingold-Tilford contour pack)
operating on abstract { id, width, children } nodes — zero generated-API
imports. First rung of the TDD ladder: a single leaf lays out at x=0. The full
contour/centring machinery is in place; subsequent commits add tests that
exercise it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
19
frontend/src/lib/person/genealogy/layout/tidyTree.test.ts
Normal file
19
frontend/src/lib/person/genealogy/layout/tidyTree.test.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { layoutForest, type TidyNode } from './tidyTree';
|
||||||
|
|
||||||
|
// tidyTree is domain-agnostic: it lays out abstract { id, width, children }
|
||||||
|
// nodes, so these tests use hand-built trees with no PersonNodeDTO import.
|
||||||
|
const W = 160;
|
||||||
|
const GAP = 40;
|
||||||
|
|
||||||
|
function leaf(id: string, width = W): TidyNode {
|
||||||
|
return { id, width, children: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('tidyTree — leaf base case', () => {
|
||||||
|
it('a single leaf lays out at x = 0', () => {
|
||||||
|
const a = leaf('a');
|
||||||
|
const x = layoutForest([a], GAP);
|
||||||
|
expect(x.get('a')).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
146
frontend/src/lib/person/genealogy/layout/tidyTree.ts
Normal file
146
frontend/src/lib/person/genealogy/layout/tidyTree.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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):
|
||||||
|
//
|
||||||
|
// 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 +
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
export type TidyNode = {
|
||||||
|
id: string;
|
||||||
|
/** Total horizontal extent of this node's run (one card, or a couple). */
|
||||||
|
width: number;
|
||||||
|
children: TidyNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
type Laid = {
|
||||||
|
x: Map<string, number>;
|
||||||
|
left: number[];
|
||||||
|
right: 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.
|
||||||
|
*/
|
||||||
|
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)),
|
||||||
|
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.
|
||||||
|
function layoutUnit(node: TidyNode, gap: number): Laid {
|
||||||
|
const children = node.children ?? [];
|
||||||
|
if (children.length === 0) {
|
||||||
|
return { x: new Map([[node.id, 0]]), left: [0], right: [node.width] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placed = packChildren(
|
||||||
|
children.map((c) => layoutUnit(c, gap)),
|
||||||
|
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: depth 0 is the root; children contours shift down a level.
|
||||||
|
let childLeft: number[] = [];
|
||||||
|
let childRight: number[] = [];
|
||||||
|
for (const subtree of placed) {
|
||||||
|
childLeft = mergeContour(childLeft, subtree.left, Math.min);
|
||||||
|
childRight = mergeContour(childRight, subtree.right, Math.max);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
left: [rootLeft, ...childLeft],
|
||||||
|
right: [rootLeft + node.width, ...childRight]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 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;
|
||||||
|
}
|
||||||
|
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 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) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
function mergeContour(
|
||||||
|
acc: number[],
|
||||||
|
add: 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];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user