docs(stammbaum): ADR-030 tidy-tree layout, supersede ADR-026 packer, refresh glossary (#724)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m27s
CI / OCR Service Tests (pull_request) Successful in 22s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
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>
This commit is contained in:
@@ -111,16 +111,21 @@ _See also [PersonRelationship](#person-person)._
|
||||
|
||||
**seeded rank** (`Person.generation`) — the imported generation index on a `Person` (G 0 = founders, increasing downward), used as a strict row anchor in `buildLayout.ts`. The iterative fallback heuristic never overrides a seeded rank, and spouse-pulldown never pulls a seeded rank — only unseeded nodes (no `generation`) flow through the heuristic.
|
||||
|
||||
**sibling block** — a layout unit holding the children of a single parent-set at one generation, used inside `buildLayout.ts`. Each block has a center computed from the parents' midpoint; blocks are then packed left-to-right within a generation row. Two adjacent sibling blocks at the same rank can be merged if a `SPOUSE_OF` edge crosses them (intra-family marriage, AC2).
|
||||
**family forest** — the model the Stammbaum horizontal layout reasons over (ADR-030, `familyForest.ts`): a forest of **units** rather than per-generation rows. Replaces the old per-generation "sibling block" packer. The canonical fixture is ~24 root units over 62 nodes.
|
||||
|
||||
**loose spouse** — a person at a given generation who is a spouse of someone in a sibling block but is not themselves a parented child of anyone in the graph. Loose spouses are attached adjacent to their parented partner (right side per Leonie's UX rule) so the spouse line stays short.
|
||||
_Not to be confused with [parented](#parented-layout)_ — loose is the absence of parent edges into the graph.
|
||||
**unit** `[layout]` — one bloodline carrier (the **primary**) plus the spouse(s) absorbed into its run, rendered as one adjacent row of cards. `members[0]` is the primary; the rest are spouses in marriage-year order (#361). A lone person is a unit of one. A unit's children are the units anchored by the couple's offspring. The unit — not the individual — is the node the tidy-tree packs.
|
||||
|
||||
**parented** `[layout]` — a layout flag on a sibling-block member indicating that the person has at least one `PARENT_OF` edge incoming from a node already in the graph at the prior generation. Parented members are the layout anchors of their block (the block is centred so the average index of parented members sits under the parents' midpoint); non-parented members (loose spouses) ride along on the side.
|
||||
**tidy tree** — the bottom-up Reingold–Tilford contour packer (`tidyTree.ts`) that assigns each unit's horizontal `x`: lay out child subtrees first, pack them so their contours clear by `COL_GAP` at every level, then centre the unit over the span of its children. Contours are indexed by absolute generation level, so unrelated roots at different generations share x-columns. `x` comes from structure; `y` still comes from rank (`assignRanks`, #689).
|
||||
|
||||
**anchor index** — within a sibling block, the average position of `parented` member indices. The block is shifted horizontally so this index, multiplied by `NODE_W + COL_GAP`, lines up under the midpoint of the block's parents — keeping every parent-child connector orthogonal (90°).
|
||||
**structural owner** — for a couple, the spouse that keeps the bloodline (hierarchy) position: lower `birthYear`, then stable `id` (`pickStructuralOwner` in `familyForest.ts`). The other spouse is absorbed into the owner's run. Reused by the cross-link, cycle, and intra-family paths so the rule is defined once.
|
||||
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints are parented members of _different_ sibling blocks at the same rank (i.e. both have parents in the graph, but the parent sets differ). Layout merges the two blocks so the spouses sit adjacent at the join boundary; latent in current data (0 cases in the May-2026 canonical snapshot) but covered by a synthetic regression test in `buildLayout.test.ts`.
|
||||
**loose spouse** — a person who marries into the graph with no `PARENT_OF` edges of their own. They are absorbed into their partner's unit run (no ancestor subtree), but any children of theirs still anchor through the couple unit.
|
||||
|
||||
**bloodline** — the set of people reachable from a root unit via structural-owner `PARENT_OF` edges; renders as one contiguous horizontal band with no foreign node interleaved (the contiguity invariant that fixed the smeared-bloodline bug, #724).
|
||||
|
||||
**cross-link** `[layout]` — a `PARENT_OF` edge whose child is positioned in a spouse's run elsewhere (a cross-level intra-family marriage). The connector draws it with a distinct `2 6` dash at reduced opacity — never the `4 4` ended-marriage cadence — with geometry still landing on the child (WCAG 1.4.1).
|
||||
|
||||
**intra-family marriage** — a `SPOUSE_OF` edge where both endpoints have parents in the graph. The 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 stays solid (the adjacency case), otherwise it renders as a cross-link. The canonical fixture has two such marriages (Walter⚭Eugenie, Clara⚭Herbert), covered in `buildLayout.test.ts`.
|
||||
|
||||
**marriage dot** — the SVG circle drawn at the midpoint of a `SPOUSE_OF` connector in the Stammbaum tree (`StammbaumTree.svelte`). Radius is `r=6` (12 px diameter) so the marker meets WCAG 1.4.11 (3:1 non-text contrast) when it stacks to disambiguate multiple marriages on the same focal person.
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred
|
||||
|
||||
**Date:** 2026-05-28
|
||||
**Status:** Accepted
|
||||
**Status:** Accepted — superseded in part by [ADR-030](./030-stammbaum-bloodline-tidy-tree-layout.md)
|
||||
**Issue:** #361
|
||||
**Supersedes:** _none_
|
||||
**Supersedes-on-trigger:** A future ADR-027 if any acceptance criterion below stops converging in-house.
|
||||
**Superseded-by:** ADR-030 (#724) replaces the **per-generation block packer** below with
|
||||
a bottom-up tidy-tree after its position-within-rank model stranded ancestors and smeared
|
||||
bloodlines across the canvas — the UX stop-trigger named in this ADR. The **in-house /
|
||||
no-dagre** decision and the seeded-rank invariant (#689) are retained.
|
||||
**Supersedes-on-trigger:** _(triggered)_ The UX stop-trigger fired; see ADR-030.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,6 +121,7 @@ threshold, so `packBlocks.ts` is **not** yet warranted.
|
||||
is the source-of-truth probe against live data; the function is the
|
||||
capture-time and fixture-time signal that the predicate's count crossed
|
||||
zero.
|
||||
|
||||
- **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under
|
||||
this ADR; reactivates only under ADR-027 (dagre adoption).
|
||||
- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()`
|
||||
|
||||
110
docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md
Normal file
110
docs/adr/030-stammbaum-bloodline-tidy-tree-layout.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user