# ADR-030 — Stammbaum Bloodline-Contiguous Tidy-Tree Layout **Date:** 2026-06-04 **Status:** Accepted **Issue:** #724 **Supersedes:** ADR-026 (in part — the per-generation block-packer decision and its position-within-rank fix path; the in-house / no-dagre decision and the seeded-rank invariant from #689 are retained) --- ## Context ADR-026 kept Stammbaum horizontal placement in-house with a **per-generation block packer** and pre-committed a successor ADR "if any acceptance criterion stops converging in-house." Its single UX stop-trigger was Albert de Gruyter's marriages failing the read test. The block packer hit a worse, structural failure: it placed each generation **independently**, centring sibling blocks under already-placed parents and only ever **shoving right** on collision. Two consequences followed — a deep branch that could not fit at its ideal centre dragged everything downstream rightward and stranded the ancestor at the **left edge** of its own descendants; and a parent placed before its descendants existed could never be re-centred over them. Extreme symptom: Albert de Gruyter (G0) far left, a great-great-grandchild far right — one bloodline smeared across the full canvas. That is exactly the "UX failure against the canonical fixture" ADR-026 named as the trigger to revisit the layout. ## Decision **Replace the per-generation block packer with a bottom-up "tidy tree" (Reingold–Tilford / Walker contour pack), still in-house, no new dependency.** The horizontal `x` rewrite is split into three reviewable, unit-tested modules (mirroring the `panZoom.ts` / `panZoomGestures.ts` / `animateView.ts` split): - **`layout/tidyTree.ts`** — domain-agnostic contour packer over abstract `{ id, width, children, level? }` nodes, zero generated-API imports. Contours are indexed by **absolute generation level**, not tree depth, so unrelated roots at different generations share x-columns instead of smearing the forest wide. - **`layout/familyForest.ts`** — all genealogy semantics: the **unit** model (a bloodline-carrying primary plus the spouse(s) absorbed into its run), `pickStructuralOwner` (lower birthYear, then stable id), loose-spouse absorption, multi-spouse runs (#361), sibling/branch order (birthYear ASC NULLS LAST → displayName → id), intra-family resolution, and cross-link classification. - **`layout/buildLayout.ts`** — orchestrates forest → tidy-pack → per-person positions. `assignRanks` (y from rank, #689 seeding), the `generations` map, and `computeViewBox` are reused **unchanged**; `x` comes from structure, `y` from rank. Two decisions taken during implementation and confirmed with the maintainer: 1. **Intra-family marriage = hybrid.** A couple is always exactly adjacent in the owner's run. When the two spouses' parents sit at the **same structural level** the displaced parent edge renders as a normal solid connector (the "adjacency" case); when they are **cross-level** (e.g. the canonical Clara⚭Herbert, where one parent is nested under Albert and the other hangs off a separate root), the structural owner keeps the hierarchy edge and the other parent→spouse edge renders as a distinct cross-link. 2. **Cross-link is rendered with a distinct `2 6` dash at 0.7 opacity** in `StammbaumConnectors.svelte` — never the `4 4` ended-marriage cadence. Geometry still lands on the correct child top, so meaning is carried redundantly (WCAG 1.4.1); the 0.7 opacity clears the WCAG 1.4.11 3:1 non-text floor. ## Consequences ### Accepted - **Ancestor centring** — every unit is centred over its child-units' span (named-bug guard `great_great_grandparent_is_not_stranded_left_of_descendants` + a fixture-wide loop over canonical and synthetic trees). - **Bloodline contiguity** — each bloodline is one band with no foreign node interleaved. Albert de Gruyter's bloodline shrank from a full ~4860px smear to ~960px. - **#361 / #689 preserved** — multi-spouse runs in marriage-year order, seeded ranks, spouse pull-down; the existing `buildLayout.test.ts` cases stay green. - **Determinism** — every comparator ends in a stable id; a seeded permutation of nodes/edges yields byte-identical positions. - **Fail-closed on cycles** — `assignRanks`' iteration ceiling plus a forest structure (each unit has ≤1 hierarchy parent, cycles are unreachable from roots) guarantee a finite layout with every node placed exactly once. ### Trade-off — total canvas width replaces the ADR-026 width assumption Centring every ancestor inherently makes a forest of ~24 root-bands **wider** overall than the old per-generation left-packer that interleaved everyone into compact shared rows (canonical: ~7960px vs the old ~4860px). Total canvas width is therefore the wrong success metric; **per-bloodline span** is. The width regression test asserts each contiguous bloodline stays far under the old full-canvas smear. The wider canvas is navigated by the pan/zoom from #692 (ADR-027) and is an accepted trade-off for readability. ### Operational - **No CI, image, compose, or dependency change.** Pure frontend layout. The `d3-flextree` escape hatch from #724 was not needed. ### Deferred (follow-ups, per #724) - Connector legibility at 320px — the issue's "open verification"; a manual `/stammbaum` pass, with a connector-clarity issue spun only if drops/cross-links tangle. - Polished cross-link routing + a relationship tooltip. ## Notes - ADR-026's retained parts: the **no-dagre / in-house** decision and the seeded-rank invariant (#689) still hold — this ADR changes only _position-within / across_ rank, not rank assignment, and adds no dependency. - The `validateFixture` sanity gates and the AC3 revisit probe from ADR-026 are unchanged.