feat(stammbaum): bloodline-contiguous tidy-tree layout (replace per-generation packer) #724

Closed
opened 2026-06-04 12:07:35 +02:00 by marcel · 1 comment
Owner

Problem

The Stammbaum spreads bloodlines across the entire canvas. Extreme case: Martin Cram (a descendant) sits at the far right while his great-great-grandfather Albert de Gruyter (G0) sits at the far left — the same bloodline is smeared across the full width instead of reading as one cluster.

Root cause (current algorithm)

frontend/src/lib/person/genealogy/layout/buildLayout.ts is a top-down, per-generation packer:

  • Vertical: assignRanks() assigns a generation rank → y = rank · (NODE_H + ROW_GAP).
  • Horizontal: for each generation independently (buildLayout.ts:70-278), nodes are grouped into sibling blocks, each block is centered under its already-placed parents' midpoint, blocks are sorted by that center and packed left-to-right.

Two failure modes follow:

  1. Collisions only ever shove right (buildLayout.ts:269), never rebalance. When a deep branch can't fit at its ideal center, everything downstream drifts rightward and the ancestor above is stranded at the left edge of its descendants.
  2. A parent is placed before its descendants exist, so it can never be re-centered over the span they end up occupying.

Result: ancestors lean left of their own descendants, and childless married-in couples wedge between bloodlines, fragmenting them.

Goal

Replace the per-generation packer with a bottom-up "tidy tree" (Reingold–Tilford / Walker style) that:

  • keeps each bloodline as one contiguous, compact horizontal band, and
  • centers every ancestor over the horizontal span of its descendants (fixes the Albert/Martin symptom).

Scope

Only horizontal x is rewritten. Everything else is reused unchanged:

  • assignRanks() still drives ygeneration seeding (#689) and spouse pull-down untouched.
  • computeViewBox() reused as-is (MIN dimensions, centering).
  • buildLayout's public contract is unchanged: it still returns { positions, generations, viewX/Y, viewW/H } (plus an additive crossLinks field). The generations map is consumed by StammbaumTree.svelte and kept with the same rank-keyed semantics.
  • Connector components (StammbaumConnectors.svelte) read only positions + edges and re-derive pairing themselves.

The block-packing section is the only code replaced. The forest node is a Unit.

Resolved Decisions

(See ADR-030 for the as-built record, including two refinements confirmed with the maintainer during implementation: the intra-family hybrid, and the width metric — see Acceptance criteria.)

  1. Null-birthYear sibling placement → NULLS LAST. Siblings/branches sort birthYear ASC, NULLS LAST, then displayName ASC, then id.
  2. Width acceptance → per-bloodline span. (Revised during implementation, confirmed with maintainer.) OLD_WIDTH = 4860px recorded as the dated golden constant for the OLD full-canvas smear. Centering a ~24-root forest is inherently wider overall (~7960px), so total maxX − minX < OLD_WIDTH was the wrong metric. Instead assert per-bloodline span stays far under the old full-canvas smear — directly measuring the un-smearing.
  3. Cross-link fallback → minimal owner+place, distinct line. Structural ownership to the lower-birthYear parent (then id); the other parent→child connector renders at a distinct cadence (2 6) at reduced opacity (0.7), never the 4 4 ended-marriage dash. Geometry lands on the correct child top (WCAG 1.4.1).
  4. Contour algorithm → hand-roll, zero dependencies. Plain contour-with-subtree-shift in an isolated, unit-tested module. No new package.json dependency (the d3-flextree escape hatch was not needed).

Design

See ADR-030 and the PR for the as-built module split (tidyTree.ts domain-agnostic packer, familyForest.ts domain forest + pickStructuralOwner, buildLayout.ts orchestrator). x from structure, y from rank.

Acceptance criteria

  • Horizontal placement is bottom-up tidy-tree in isolated familyForest.ts (+ pickStructuralOwner) and a domain-agnostic tidyTree.ts; assignRanks/computeViewBox/generations reused unchanged.
  • A named test great_great_grandparent_is_not_stranded_left_of_descendants asserts the NEW layout centers the apex within its descendant span (regresses if re-stranded). Note: the OLD packer was removed in the same change, so a live red-proof against old behaviour is not retained; the old smear is captured as the dated OLD_WIDTH = 4860px baseline and in ADR-030.
  • Every ancestor's x is centered within its descendants' horizontal span (fixture-wide loop test green, canonical + synthetic).
  • Each bloodline renders as one contiguous band (no foreign node interleaved).
  • Sibling/branch order is birthYear ASC NULLS LAST → displayName → id; spouse order (#361) preserved; determinism test green (seeded permutation → identical positions).
  • Intra-family marriages place the couple exactly adjacent in the owner's run. Hybrid (confirmed with maintainer): same-level bonds keep both connectors solid; genuinely cross-level bonds (e.g. canonical Clara⚭Herbert) use minimal owner+place with a distinct 2 6 cross-link dash, never the 4 4 ended-marriage dash.
  • No-overlap, contiguity, termination (+once-only), and unknown-id guard (PARENT_OF + SPOUSE_OF) invariants tested and green; empty-graph + single-node covered.
  • Per-bloodline span regression (replaces the total-width criterion — see Resolved Decision 2): every contiguous bloodline stays far under the old full-canvas smear (dated OLD_WIDTH = 4860px). Albert de Gruyter's bloodline: ~960px, down from the full ~4860px smear.

Open verification (do during implementation, not a blocker)

  • Connector legibility after re-centering at 320px. Still outstanding — recommend a manual /stammbaum pass; spin a follow-up connector-clarity issue only if drops/cross-links tangle. Cross-link opacity set to 0.7 to clear the WCAG 1.4.11 3:1 floor.

Out of scope

  • Vertical/rank changes, connector restyling beyond the cross-link cadence, pan/zoom (#692).
  • Polished cross-link visual routing + relationship tooltip — minimal distinct-dash fallback only; richer treatment is a follow-up.
## Problem The Stammbaum spreads bloodlines across the entire canvas. Extreme case: **Martin Cram** (a descendant) sits at the far right while his great-great-grandfather **Albert de Gruyter** (G0) sits at the far left — the same bloodline is smeared across the full width instead of reading as one cluster. ### Root cause (current algorithm) `frontend/src/lib/person/genealogy/layout/buildLayout.ts` is a **top-down, per-generation packer**: - Vertical: `assignRanks()` assigns a generation rank → `y = rank · (NODE_H + ROW_GAP)`. - Horizontal: for each generation **independently** (`buildLayout.ts:70-278`), nodes are grouped into sibling blocks, each block is centered under its already-placed parents' midpoint, blocks are sorted by that center and packed left-to-right. Two failure modes follow: 1. **Collisions only ever shove right** (`buildLayout.ts:269`), never rebalance. When a deep branch can't fit at its ideal center, everything downstream drifts rightward and the ancestor above is stranded at the **left edge** of its descendants. 2. **A parent is placed before its descendants exist**, so it can never be re-centered over the span they end up occupying. Result: ancestors lean left of their own descendants, and childless married-in couples wedge between bloodlines, fragmenting them. ## Goal Replace the per-generation packer with a **bottom-up "tidy tree"** (Reingold–Tilford / Walker style) that: - keeps each bloodline as one **contiguous, compact horizontal band**, and - centers every ancestor over the horizontal span of its descendants (fixes the Albert/Martin symptom). ## Scope **Only horizontal `x` is rewritten.** Everything else is reused unchanged: - `assignRanks()` still drives `y` → **generation seeding (#689) and spouse pull-down untouched**. - `computeViewBox()` reused as-is (MIN dimensions, centering). - **`buildLayout`'s public contract is unchanged**: it still returns `{ positions, generations, viewX/Y, viewW/H }` (plus an additive `crossLinks` field). The `generations` map is consumed by `StammbaumTree.svelte` and **kept with the same rank-keyed semantics**. - Connector components (`StammbaumConnectors.svelte`) read only `positions` + `edges` and re-derive pairing themselves. The block-packing section is the only code replaced. The forest node is a **`Unit`**. ## Resolved Decisions _(See ADR-030 for the as-built record, including two refinements confirmed with the maintainer during implementation: the intra-family hybrid, and the width metric — see Acceptance criteria.)_ 1. **Null-`birthYear` sibling placement → NULLS LAST.** Siblings/branches sort `birthYear ASC, NULLS LAST, then displayName ASC, then id`. 2. **Width acceptance → per-bloodline span.** _(Revised during implementation, confirmed with maintainer.)_ `OLD_WIDTH = 4860px` recorded as the dated golden constant for the OLD full-canvas smear. Centering a ~24-root forest is inherently **wider** overall (~7960px), so total `maxX − minX < OLD_WIDTH` was the wrong metric. Instead assert **per-bloodline span** stays far under the old full-canvas smear — directly measuring the un-smearing. 3. **Cross-link fallback → minimal owner+place, distinct line.** Structural ownership to the lower-`birthYear` parent (then `id`); the other parent→child connector renders at a distinct cadence (`2 6`) at reduced opacity (`0.7`), never the `4 4` ended-marriage dash. Geometry lands on the correct child top (WCAG 1.4.1). 4. **Contour algorithm → hand-roll, zero dependencies.** Plain contour-with-subtree-shift in an isolated, unit-tested module. No new `package.json` dependency (the `d3-flextree` escape hatch was not needed). ## Design See ADR-030 and the PR for the as-built module split (`tidyTree.ts` domain-agnostic packer, `familyForest.ts` domain forest + `pickStructuralOwner`, `buildLayout.ts` orchestrator). `x` from structure, `y` from rank. ## Acceptance criteria - [x] Horizontal placement is bottom-up tidy-tree in isolated `familyForest.ts` (+ `pickStructuralOwner`) and a domain-agnostic `tidyTree.ts`; `assignRanks`/`computeViewBox`/`generations` reused unchanged. - [x] A named test `great_great_grandparent_is_not_stranded_left_of_descendants` asserts the **NEW** layout centers the apex within its descendant span (regresses if re-stranded). _Note: the OLD packer was removed in the same change, so a live red-proof against old behaviour is not retained; the old smear is captured as the dated `OLD_WIDTH = 4860px` baseline and in ADR-030._ - [x] Every ancestor's `x` is centered within its descendants' horizontal span (fixture-wide loop test green, canonical + synthetic). - [x] Each bloodline renders as one contiguous band (no foreign node interleaved). - [x] Sibling/branch order is `birthYear ASC NULLS LAST → displayName → id`; spouse order (#361) preserved; determinism test green (seeded permutation → identical positions). - [x] Intra-family marriages place the couple exactly adjacent in the owner's run. **Hybrid** _(confirmed with maintainer)_: same-level bonds keep both connectors solid; genuinely cross-level bonds (e.g. canonical Clara⚭Herbert) use minimal owner+place with a distinct `2 6` cross-link dash, never the `4 4` ended-marriage dash. - [x] No-overlap, contiguity, termination (+once-only), and unknown-id guard (PARENT_OF + SPOUSE_OF) invariants tested and green; empty-graph + single-node covered. - [x] **Per-bloodline span** regression _(replaces the total-width criterion — see Resolved Decision 2)_: every contiguous bloodline stays far under the old full-canvas smear (dated `OLD_WIDTH = 4860px`). Albert de Gruyter's bloodline: **~960px**, down from the full ~4860px smear. ## Open verification (do during implementation, not a blocker) - **Connector legibility after re-centering at 320px.** Still outstanding — recommend a manual `/stammbaum` pass; spin a follow-up connector-clarity issue only if drops/cross-links tangle. Cross-link opacity set to `0.7` to clear the WCAG 1.4.11 3:1 floor. ## Out of scope - Vertical/rank changes, connector restyling beyond the cross-link cadence, pan/zoom (#692). - Polished cross-link visual routing + relationship tooltip — minimal distinct-dash fallback only; richer treatment is a follow-up.
marcel added the P1-highfeaturepersonui labels 2026-06-04 12:07:40 +02:00
Author
Owner

Implemented — bloodline-contiguous tidy-tree layout

Branch feat/issue-724-tidy-tree-layout (21 atomic TDD commits). All 42 layout unit tests green; npm run check adds 0 new type errors (833 vs ~834 baseline); lint clean.

What changed

  • layout/tidyTree.ts (new) — domain-agnostic bottom-up Reingold–Tilford contour packer over abstract { id, width, children, level? } nodes, zero generated-API imports. Contour is indexed by absolute generation level (not tree depth) so unrelated roots at different generations share x-columns.
  • layout/familyForest.ts (new) — domain forest construction: unit model (primary + absorbed spouse run), pickStructuralOwner, loose-spouse absorption, multi-spouse runs (#361 preserved), sibling/branch ordering (birthYear ASC NULLS LAST → displayName → id), intra-family resolution + cross-link classification. Unknown-id guard covers PARENT_OF and SPOUSE_OF.
  • layout/buildLayout.ts — the ~210-line per-generation block packer is replaced by the forest → tidyTree orchestration; assignRanks, the generations map, and computeViewBox are reused unchanged; y still comes from rank, x from structure.
  • StammbaumConnectors.svelte — cross-level links render with a distinct 2 6 dash at reduced opacity, never the 4 4 ended-marriage cadence (geometry still lands on the child — WCAG 1.4.1).

Acceptance criteria

  • Bottom-up tidy-tree in isolated familyForest.ts/tidyTree.ts; assignRanks/computeViewBox/generations reused unchanged.
  • Named-bug guard: great_great_grandparent_is_not_stranded_left_of_descendants — apex centred over descendant span.
  • Every unit centred within its child-unit span (fixture-wide loop, canonical + synthetic).
  • Each bloodline is one contiguous band (no foreign node interleaved).
  • Sibling order birthYear ASC NULLS LAST → displayName → id; spouse order (#361) preserved; determinism test green (seeded permutation → identical positions).
  • Intra-family: couple always exactly adjacent in the run; cross-level links use the distinct dash, same-level render solid.
  • No-overlap, contiguity, termination (+once-only), unknown-id guards tested & green.

Two decisions taken during implementation (confirmed with the maintainer)

  1. Intra-family = hybrid. The canonical fixture's two intra-family marriages chain across three bloodlines (Walter⚭Eugenie → their child Clara⚭Herbert). Same-level bonds (e.g. Walter⚭Eugenie, both parents are roots) render as solid connectors; genuinely cross-level bonds (Clara⚭Herbert) keep the structural owner's hierarchy edge and draw the other parent→spouse edge as a distinct cross-link. Couples are always exactly adjacent via the run mechanism.
  2. Width acceptance criterion replaced. Centring every ancestor inherently makes a 24-root forest wider overall (measured 7960px vs the old 4860px) — total canvas width was the wrong metric. Replaced with a per-bloodline span regression: the widest bloodline (Albert de Gruyter's, which the old packer smeared across the full ~4860px canvas) is now ~960px, and every bloodline is asserted to stay well under the old full-canvas smear.

Follow-ups (out of scope, as the issue allows)

  • Connector legibility at 320px is the issue's "open verification" — recommend a manual pass on /stammbaum; spin a connector-clarity issue only if cross-links/parent drops tangle.
  • Polished cross-link routing + relationship tooltip remain a deliberate follow-up.

No new package.json dependency (the d3-flextree escape hatch was not needed).

## Implemented — bloodline-contiguous tidy-tree layout Branch `feat/issue-724-tidy-tree-layout` (21 atomic TDD commits). All 42 layout unit tests green; `npm run check` adds **0** new type errors (833 vs ~834 baseline); lint clean. ### What changed - **`layout/tidyTree.ts`** (new) — domain-agnostic bottom-up Reingold–Tilford contour packer over abstract `{ id, width, children, level? }` nodes, zero generated-API imports. Contour is indexed by **absolute generation level** (not tree depth) so unrelated roots at different generations share x-columns. - **`layout/familyForest.ts`** (new) — domain forest construction: unit model (primary + absorbed spouse run), `pickStructuralOwner`, loose-spouse absorption, multi-spouse runs (#361 preserved), sibling/branch ordering (birthYear ASC NULLS LAST → displayName → id), intra-family resolution + cross-link classification. Unknown-id guard covers PARENT_OF **and** SPOUSE_OF. - **`layout/buildLayout.ts`** — the ~210-line per-generation block packer is replaced by the forest → tidyTree orchestration; `assignRanks`, the `generations` map, and `computeViewBox` are reused unchanged; `y` still comes from rank, `x` from structure. - **`StammbaumConnectors.svelte`** — cross-level links render with a distinct `2 6` dash at reduced opacity, never the `4 4` ended-marriage cadence (geometry still lands on the child — WCAG 1.4.1). ### Acceptance criteria - [x] Bottom-up tidy-tree in isolated `familyForest.ts`/`tidyTree.ts`; `assignRanks`/`computeViewBox`/`generations` reused unchanged. - [x] Named-bug guard: `great_great_grandparent_is_not_stranded_left_of_descendants` — apex centred over descendant span. - [x] Every unit centred within its child-unit span (fixture-wide loop, canonical + synthetic). - [x] Each bloodline is one contiguous band (no foreign node interleaved). - [x] Sibling order birthYear ASC NULLS LAST → displayName → id; spouse order (#361) preserved; determinism test green (seeded permutation → identical positions). - [x] Intra-family: couple always exactly adjacent in the run; cross-level links use the distinct dash, same-level render solid. - [x] No-overlap, contiguity, termination (+once-only), unknown-id guards tested & green. ### Two decisions taken during implementation (confirmed with the maintainer) 1. **Intra-family = hybrid.** The canonical fixture's two intra-family marriages chain across three bloodlines (Walter⚭Eugenie → their child Clara⚭Herbert). Same-level bonds (e.g. Walter⚭Eugenie, both parents are roots) render as solid connectors; genuinely cross-level bonds (Clara⚭Herbert) keep the structural owner's hierarchy edge and draw the other parent→spouse edge as a distinct cross-link. Couples are always exactly adjacent via the run mechanism. 2. **Width acceptance criterion replaced.** Centring every ancestor inherently makes a 24-root forest *wider* overall (measured 7960px vs the old 4860px) — total canvas width was the wrong metric. Replaced with a **per-bloodline span** regression: the widest bloodline (Albert de Gruyter's, which the old packer smeared across the full ~4860px canvas) is now **~960px**, and every bloodline is asserted to stay well under the old full-canvas smear. ### Follow-ups (out of scope, as the issue allows) - Connector legibility at 320px is the issue's "open verification" — recommend a manual pass on `/stammbaum`; spin a connector-clarity issue only if cross-links/parent drops tangle. - Polished cross-link routing + relationship tooltip remain a deliberate follow-up. No new `package.json` dependency (the `d3-flextree` escape hatch was not needed).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#724