From 485e13cfea7cf1ca4960a5a07e34b57c7b3c2362 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 12:57:38 +0200 Subject: [PATCH] feat(stammbaum): add tidyTree contour packer with leaf base case (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../person/genealogy/layout/tidyTree.test.ts | 19 +++ .../lib/person/genealogy/layout/tidyTree.ts | 146 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 frontend/src/lib/person/genealogy/layout/tidyTree.test.ts create mode 100644 frontend/src/lib/person/genealogy/layout/tidyTree.ts diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts new file mode 100644 index 00000000..18c05c3f --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.test.ts @@ -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); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/tidyTree.ts b/frontend/src/lib/person/genealogy/layout/tidyTree.ts new file mode 100644 index 00000000..61f90a1e --- /dev/null +++ b/frontend/src/lib/person/genealogy/layout/tidyTree.ts @@ -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; + 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 { + if (roots.length === 0) return new Map(); + const placed = packChildren( + roots.map((r) => layoutUnit(r, gap)), + 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. +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([[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(); + 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; +}