Review follow-up (Markus/Architect): ADR-026 pre-committed a successor ADR if the in-house layout stopped converging; its UX stop-trigger (Albert smeared across the canvas) fired. ADR-030 records the bottom-up tidy-tree, the module split, and the two maintainer-confirmed decisions (hybrid intra-family, per-bloodline width metric), superseding ADR-026's block-packer in part (no-dagre + seeded-rank retained). GLOSSARY replaces the deleted sibling-block / parented / anchor-index vocabulary with the new family-forest model (unit, tidy tree, structural owner, bloodline, cross-link). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.7 KiB
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), thegenerationsmap, andcomputeViewBoxare reused unchanged;xcomes from structure,yfrom rank.
Two decisions taken during implementation and confirmed with the maintainer:
- 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.
- Cross-link is rendered with a distinct
2 6dash at 0.7 opacity inStammbaumConnectors.svelte— never the4 4ended-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.tscases 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-flextreeescape hatch from #724 was not needed.
Deferred (follow-ups, per #724)
- Connector legibility at 320px — the issue's "open verification"; a manual
/stammbaumpass, 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
validateFixturesanity gates and the AC3 revisit probe from ADR-026 are unchanged.