feat(stammbaum): bloodline-contiguous tidy-tree layout (replace per-generation packer) #724
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.tsis a top-down, per-generation packer:assignRanks()assigns a generation rank →y = rank · (NODE_H + ROW_GAP).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:
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.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:
Scope
Only horizontal
xis rewritten. Everything else is reused unchanged:assignRanks()still drivesy→ 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 additivecrossLinksfield). Thegenerationsmap is consumed byStammbaumTree.svelteand kept with the same rank-keyed semantics.StammbaumConnectors.svelte) read onlypositions+edgesand 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.)
birthYearsibling placement → NULLS LAST. Siblings/branches sortbirthYear ASC, NULLS LAST, then displayName ASC, then id.OLD_WIDTH = 4860pxrecorded as the dated golden constant for the OLD full-canvas smear. Centering a ~24-root forest is inherently wider overall (~7960px), so totalmaxX − minX < OLD_WIDTHwas the wrong metric. Instead assert per-bloodline span stays far under the old full-canvas smear — directly measuring the un-smearing.birthYearparent (thenid); the other parent→child connector renders at a distinct cadence (2 6) at reduced opacity (0.7), never the4 4ended-marriage dash. Geometry lands on the correct child top (WCAG 1.4.1).package.jsondependency (thed3-flextreeescape hatch was not needed).Design
See ADR-030 and the PR for the as-built module split (
tidyTree.tsdomain-agnostic packer,familyForest.tsdomain forest +pickStructuralOwner,buildLayout.tsorchestrator).xfrom structure,yfrom rank.Acceptance criteria
familyForest.ts(+pickStructuralOwner) and a domain-agnostictidyTree.ts;assignRanks/computeViewBox/generationsreused unchanged.great_great_grandparent_is_not_stranded_left_of_descendantsasserts 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 datedOLD_WIDTH = 4860pxbaseline and in ADR-030.xis centered within its descendants' horizontal span (fixture-wide loop test green, canonical + synthetic).birthYear ASC NULLS LAST → displayName → id; spouse order (#361) preserved; determinism test green (seeded permutation → identical positions).2 6cross-link dash, never the4 4ended-marriage dash.OLD_WIDTH = 4860px). Albert de Gruyter's bloodline: ~960px, down from the full ~4860px smear.Open verification (do during implementation, not a blocker)
/stammbaumpass; spin a follow-up connector-clarity issue only if drops/cross-links tangle. Cross-link opacity set to0.7to clear the WCAG 1.4.11 3:1 floor.Out of scope
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 checkadds 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, thegenerationsmap, andcomputeViewBoxare reused unchanged;ystill comes from rank,xfrom structure.StammbaumConnectors.svelte— cross-level links render with a distinct2 6dash at reduced opacity, never the4 4ended-marriage cadence (geometry still lands on the child — WCAG 1.4.1).Acceptance criteria
familyForest.ts/tidyTree.ts;assignRanks/computeViewBox/generationsreused unchanged.great_great_grandparent_is_not_stranded_left_of_descendants— apex centred over descendant span.Two decisions taken during implementation (confirmed with the maintainer)
Follow-ups (out of scope, as the issue allows)
/stammbaum; spin a connector-clarity issue only if cross-links/parent drops tangle.No new
package.jsondependency (thed3-flextreeescape hatch was not needed).