The canonical graph is a forest of 24 roots spread across generations 0-4.
Packing every root at tree-depth 0 stacked all of them horizontally even when
they sit at different generations (different y), blowing the canvas out to
~9660px. Indexing the contour by absolute level (the rank buildLayout already
passes as level) lets unrelated roots at different generations share x-columns,
and keeps the no-overlap guarantee per-row. level falls back to tree depth when
omitted, so the abstract tidyTree tests are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
No node outside a root's structural subtree may intrude into that bloodline's
[minX, maxX] horizontal span — the contiguity guarantee that fixes the smeared
bloodline symptom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
O(n^2) sweep over canonical + synthetic: any two nodes sharing a y are at least
NODE_W + COL_GAP apart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixture-wide loop over the canonical forest and a synthetic tree: each unit's
run centre is within [min, max] of its child-unit centres — the ancestor
centring invariant, asserted on real data.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 5-generation single bloodline fanning out wide at the bottom: the apex
great-great-grandparent (and every ancestor in the chain) sits at the centre of
the descendant span, the exact symptom the old per-generation packer produced
in reverse (apex pinned to the left edge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When the two spouses' parents sit at different structural levels, the
structural owner keeps its hierarchy edge and the other parent->spouse edge is
recorded in layout.crossLinks (rendered with a distinct dash). The couple still
sits exactly adjacent in the owner's run and B keeps a real position.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the existing adjacency contract: the couple is exactly adjacent in the
run AND, because both parents are roots (same structural level), the displaced
parent edge stays solid — layout.crossLinks is empty for this case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
buildLayout now builds the family forest, packs it bottom-up via tidyTree, and
maps each unit's run x back to per-person positions (x from structure, y from
rank). assignRanks, the generations map, and computeViewBox are reused
unchanged. The unknown-id guard now covers PARENT_OF as well as SPOUSE_OF, and
displaced cross-level edges are exposed as crossLinks for distinct rendering.
The ~210-line block packer (and its block/merge helpers) is gone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Net-new ordering coverage: roots and every unit's children sort by birthYear
ASC (undated last), then displayName, then stable id — so horizontal x never
depends on Map iteration order.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assigns every person to one unit: a primary, or a spouse absorbed into the
primary's run (marriage-year order, #361 preserved). Wires the parent/child
hierarchy from each primary's structural-owner parent and records displaced
parent edges as cross-links (classified same-level vs cross-level for later
distinct rendering). Unknown-id guard covers PARENT_OF and SPOUSE_OF.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structural-owner rule for couples: earlier birth year wins, missing year sorts
last, ties break on stable id. The single definition reused by the cross-link,
cycle and intra-family paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New domain-agnostic bottom-up tidy-tree module (Reingold-Tilford contour pack)
operating on abstract { id, width, children } nodes — zero generated-API
imports. First rung of the TDD ladder: a single leaf lays out at x=0. The full
contour/centring machinery is in place; subsequent commits add tests that
exercise it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing node() factory never sets birthYear, but the new sibling/branch
comparator (birthYear ASC NULLS LAST) needs it. Add makeNode(id, name,
{birthYear, generation}) alongside it; unblocks every ordering test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump DIMMED_OPACITY 0.4 -> 0.45 so dimmed outlines/labels stay legible
against bg-surface in both themes (dark mode dims already-light mint, the
riskier case). Import the constant into StammbaumTree.svelte.test.ts so the
node-opacity assertions track it instead of a hard-coded '0.4'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure, DOM-free traversal over the family graph. Given the relationship
edges and a selected root, highlightLineage returns the active id set
(root + full pedigree upward + full descendant tree downward + every
spouse of those blood people, as active leaves) and a connector
predicate active only when both joined people are active.
The walk is guarded by the accumulating visited set, so cyclic PARENT_OF
data terminates (REQ-STAMMBAUM-04 / AC10). SIBLING_OF and social
relation types are ignored, so collaterals never enter the active set.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cycle-2 follow-up from Sara. The existing assertion
`Math.abs(posA2.x - posB2.x) === NODE_W + COL_GAP` proves adjacency in
the current integer-slot packer but would silently pass if a future
refactor moved to fractional offsets with a third node squatting at a
non-slot x between the spouses. The added loop closes that contract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Felix + @Markus on PR #693: viewBox computation is self-contained
(reads only positions + the MIN/PAD constants). Lift it out so buildLayout
ends with a readable two-line orchestration.
Pure refactor under green tests — no behaviour change, no test diff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Felix + @Markus on PR #693: buildLayout was a 367-line orchestrator
doing five sequential phases. assignRanks() is one of the two
self-contained phases that reads top-down on its own.
Pure refactor under green tests — no behaviour change, no test diff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Sara on PR #693: canonical_fixture_multi_spouse_falls_through_to_displayName
_when_no_fromYear asserts the *fallback* branch of the multi-spouse sort
(NULLS LAST, then displayName). It only exercises the name branch while
every SPOUSE_OF row in the fixture has fromYear=undefined. The day a year
gets backfilled in canonical import, the test would silently start
asserting year-order with no notice.
Add a precondition at the head of the test that fails fast with a clear
maintainer message ("update or split into year-branch / name-branch")
when any canonical SPOUSE_OF row gains a fromYear.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AC2 — intra-family marriage. When two parented persons at the same
imported generation are spouses but live in separate sibling blocks
(each under their own parent), the block-packer used to leave them
split, drawing a long spouse line that crossed through any intervening
siblings. The new step 3.5 detects that case, moves the focal members
to the join boundary (A's spouse rightmost in A's block, B's spouse
leftmost in B's), and concatenates B's members onto A's; the combined
block centres on the average of the two parents' midpoints.
Latent against today's data (no intra-family marriage in the canonical
fixture); covered by a synthetic two-family scenario in
buildLayout.test.ts. Packer growth stays comfortably under Markus's
80-LoC extraction threshold, so packBlocks.ts is not yet warranted.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the alternating-side insertOnRight rule with a sort-and-splice
that places every loose spouse to the right of the parented focal in
(fromYear ASC NULLS LAST, displayName ASC) order. Mirrored in step 3 for
the all-loose chained merge so Albert de Gruyter's four marriages land
in deterministic alphabetical order today (no fromYear populated in the
canonical dataset) and switch automatically to year-order as the
transcription pipeline backfills marriage years.
PersonNodeDTO carries only displayName, not parsed first/last names, so
the tiebreaker uses displayName rather than the (lastName, firstName)
key in the original UX brief. The canonical alphabetical order matches
in both schemes — the rule activates the moment a multi-spouse case has
mixed display-name patterns.
Retires the temporary commit-3 scaffold
`attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers`
which became position-arithmetic-equivalent under the new right-of-focal
rule; the two new sort tests are stronger discriminators for the same
behaviour.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Switches spousePairs from Map<string, string> to Map<string, Set<string>>
so multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages)
keep every partner instead of losing the earlier .set() values.
The behavioural discriminator (now exercised by
attaches_loose_multi_spouse_to_parented_partner_when_edge_order_clobbers)
is a loose person with both a parented and a loose spouse: the old map
clobbered to whichever edge landed last, so the loose-placement step could
miss the parented partner and merge the focal node into the wrong block.
Also closes the robustness gap NullX flagged: SPOUSE_OF edges referencing
IDs outside allNodes are dropped at ingestion instead of leaking into the
spouse-pulldown loop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
buildLayout switches to a two-stage assignment:
1. Seed — every node with node.generation != null is locked at that
rank. The fallback heuristic never moves a locked rank, and the
spouse-pulldown never pulls a locked rank.
2. Fallback — for unseeded nodes, rank = max(parent rank) + 1 reading
parents from the same unified rank map, so an unseeded child of a
seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties
unseeded spouses to their deeper partner exactly as before.
3. Normalise — if any rank is negative (future G −1 ancestor), shift
the whole map so min(rank) == 0. No-op for today's data.
Fixes the Herbert Cram pattern from #361's review: two parented
spouses with imported G 3 now render on the same y row. Existing
StammbaumTree tests still pass byte-for-byte because every test node
has node.generation undefined, so the heuristic runs unchanged.
Refs #689
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move the layout function out of StammbaumTree.svelte (lines 47-275) into a
new pure TypeScript module at frontend/src/lib/person/genealogy/layout/
buildLayout.ts so it can be exercised by direct unit tests. Drops the
eslint-disable svelte/prefer-svelte-reactivity blanket; switches the
remaining scope-local Maps/Sets in parentLinks to SvelteMap/SvelteSet to
satisfy the rule per-call-site. No behaviour change — existing
StammbaumTree tests must pass byte-for-byte.
Refs #689
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>