Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m34s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
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>
111 lines
5.7 KiB
Markdown
111 lines
5.7 KiB
Markdown
# 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.
|