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

Merged
marcel merged 24 commits from feat/issue-724-tidy-tree-layout into main 2026-06-04 14:55:12 +02:00
12 changed files with 1385 additions and 243 deletions

View File

@@ -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 ReingoldTilford 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.

View File

@@ -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()`

View 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"
(ReingoldTilford / 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.

View File

@@ -9,6 +9,13 @@ type RelationshipDTO = components['schemas']['RelationshipDTO'];
interface Props {
edges: RelationshipDTO[];
positions: Layout['positions'];
/**
* Displaced parent→child edges (cross-level intra-family marriages): the
* child lives in a spouse's run elsewhere, so the connector is drawn with a
* distinct dash so it never reads as a normal parent drop or an ended
* marriage. Geometry still lands on the child, so meaning is redundant.
*/
crossLinks?: Layout['crossLinks'];
/**
* Whether the connector joining two people is active (full strength). A
* connector is active only when both endpoints are active; otherwise it is
@@ -17,7 +24,20 @@ interface Props {
isConnectorActive?: (aId: string, bId: string) => boolean;
}
let { edges, positions, isConnectorActive = () => true }: Props = $props();
let { edges, positions, crossLinks = [], isConnectorActive = () => true }: Props = $props();
// Dash cadence + opacity for a cross-link, deliberately distinct from the
// ended-marriage spouse dash (`4 4`) so the two never read alike (WCAG 1.4.1:
// geometry carries the meaning too, not stroke alone). Opacity stays at 0.7 so
// the dotted line clears the WCAG 1.4.11 3:1 non-text contrast floor for
// senior / low-vision readers (a lighter 0.55 fell just under).
const CROSS_LINK_DASH = '2 6';
const CROSS_LINK_OPACITY = 0.7;
const crossLinkSet = $derived(new SvelteSet(crossLinks.map((c) => `${c.parentId}->${c.childId}`)));
function isCrossLink(parentId: string, childId: string): boolean {
return crossLinkSet.has(`${parentId}->${childId}`);
}
/** SVG group opacity for a connector: full when active, dimmed otherwise. */
function connectorOpacity(active: boolean): number | undefined {
@@ -149,6 +169,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
centres (a child without a position drops out of both together). -->
{@const childActive =
isConnectorActive(group.parentA, cc.id) && isConnectorActive(group.parentB, cc.id)}
{@const childCross =
isCrossLink(group.parentA, cc.id) || isCrossLink(group.parentB, cc.id)}
<g class="lineage-fade" opacity={connectorOpacity(childActive)}>
<line
x1={cc.x}
@@ -157,6 +179,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={childCross ? CROSS_LINK_DASH : undefined}
stroke-opacity={childCross ? CROSS_LINK_OPACITY : undefined}
/>
</g>
{/each}
@@ -172,6 +196,9 @@ const parentLinks = $derived.by<ParentLinks>(() => {
{@const childTopY = childCenter.y - NODE_H / 2}
{@const barY = (parentBottomY + childTopY) / 2}
{@const active = isConnectorActive(link.parentId, link.childId)}
{@const cross = isCrossLink(link.parentId, link.childId)}
{@const dash = cross ? CROSS_LINK_DASH : undefined}
{@const strokeOpacity = cross ? CROSS_LINK_OPACITY : undefined}
<g class="lineage-fade" opacity={connectorOpacity(active)}>
<line
x1={parentCenter.x}
@@ -180,6 +207,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
{#if parentCenter.x !== childCenter.x}
<line
@@ -189,6 +218,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={barY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
{/if}
<line
@@ -198,6 +229,8 @@ const parentLinks = $derived.by<ParentLinks>(() => {
y2={childTopY}
stroke="var(--c-primary)"
stroke-width="1.5"
stroke-dasharray={dash}
stroke-opacity={strokeOpacity}
/>
</g>
{/if}

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import StammbaumConnectors from './StammbaumConnectors.svelte';
import { NODE_H } from './layout/buildLayout';
import type { components } from '$lib/generated/api';
type RelationshipDTO = components['schemas']['RelationshipDTO'];
const P = '00000000-0000-0000-0000-0000000000c1';
const C = '00000000-0000-0000-0000-0000000000c2';
const H = '00000000-0000-0000-0000-0000000000c3';
const W = '00000000-0000-0000-0000-0000000000c4';
function parentEdge(parentId: string, childId: string): RelationshipDTO {
return {
id: `${parentId}>${childId}`,
personId: parentId,
relatedPersonId: childId,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
};
}
function endedSpouseEdge(a: string, b: string): RelationshipDTO {
return {
id: `${a}~${b}`,
personId: a,
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
toYear: 1950
};
}
const positions = new Map([
[P, { x: 0, y: 0 }],
[C, { x: 400, y: NODE_H + 80 }],
[H, { x: 0, y: 400 }],
[W, { x: 300, y: 400 }]
]);
const dashesOf = (selector: string) =>
Array.from(document.querySelectorAll(selector))
.map((el) => el.getAttribute('stroke-dasharray'))
.filter((d): d is string => d !== null);
describe('StammbaumConnectors — cross-link cadence (#724)', () => {
it('renders a cross-level link with the distinct 2 6 dash, never the 4 4 ended-marriage dash', async () => {
render(StammbaumConnectors, {
edges: [parentEdge(P, C), endedSpouseEdge(H, W)],
positions,
crossLinks: [{ parentId: P, childId: C }]
});
await vi.waitFor(() => {
const dashes = dashesOf('line');
// The cross-link cadence is present …
expect(dashes).toContain('2 6');
// … the ended-marriage cadence is present …
expect(dashes).toContain('4 4');
// … and the two are genuinely different cadences (WCAG 1.4.1: not by
// stroke alone, but they must not collapse into the same pattern).
expect('2 6').not.toBe('4 4');
});
});
it('draws a normal parent→child connector solid when it is NOT a cross-link', async () => {
render(StammbaumConnectors, {
edges: [parentEdge(P, C)],
positions,
crossLinks: []
});
await vi.waitFor(() => {
// No dashed parent lines at all when nothing is a cross-link.
expect(dashesOf('line')).not.toContain('2 6');
expect(document.querySelectorAll('line').length).toBeGreaterThan(0);
});
});
});

View File

@@ -309,6 +309,7 @@ function handleCanvasKey(event: KeyboardEvent) {
<StammbaumConnectors
edges={edges}
positions={layout.positions}
crossLinks={layout.crossLinks}
isConnectorActive={isConnectorActive}
/>

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import { buildFamilyForest, type Unit } from './familyForest';
import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api';
@@ -20,6 +21,20 @@ function node(id: string, displayName: string, generation: number | null = null)
: { id, displayName, familyMember: true, generation };
}
// Richer factory than node(): lets ordering tests set birthYear (which the
// sibling/branch comparator sorts on) and generation independently. node()
// never sets birthYear, so every birth-year ordering assertion needs this.
function makeNode(
id: string,
displayName: string,
opts: { birthYear?: number; generation?: number } = {}
): PersonNodeDTO {
const n: PersonNodeDTO = { id, displayName, familyMember: true };
if (opts.birthYear != null) n.birthYear = opts.birthYear;
if (opts.generation != null) n.generation = opts.generation;
return n;
}
function parentEdge(parentId: string, childId: string, id = parentId + childId): RelationshipDTO {
return {
id,
@@ -48,6 +63,19 @@ function yOf(layout: ReturnType<typeof buildLayout>, id: string): number {
return p.y;
}
describe('makeNode factory', () => {
it('sets birthYear and generation only when provided', () => {
expect(makeNode('a', 'A')).toEqual({ id: 'a', displayName: 'A', familyMember: true });
expect(makeNode('b', 'B', { birthYear: 1900, generation: 2 })).toEqual({
id: 'b',
displayName: 'B',
familyMember: true,
birthYear: 1900,
generation: 2
});
});
});
describe('buildLayout — generation seeding (#689)', () => {
it('Herbert Cram regression: two parented G=3 spouses share the same row', () => {
// Both Herbert (G 3) and Clara (G 3) are parented children of their respective
@@ -279,6 +307,11 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
if (p.y !== posA2.y) continue;
expect(p.x <= minX || p.x >= maxX).toBe(true);
}
// Same-level bond (both parents are roots): the displaced parent edge
// can be ordered adjacent, so it stays a solid connector — NOT a dashed
// cross-link. The cross-link set is therefore empty for this case.
expect(layout.crossLinks).toEqual([]);
});
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
@@ -332,3 +365,359 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
}
});
});
describe('buildLayout — cross-level marriage fallback (#724)', () => {
// A bond is cross-level when the two spouses' parents sit at different
// structural levels. Adjacency cannot keep both connectors short, so the
// structural owner keeps its hierarchy edge and the other parent → spouse
// edge becomes a distinct cross-link.
const GP = '00000000-0000-0000-0000-0000000000e1'; // G0, deep branch ancestor
const P = '00000000-0000-0000-0000-0000000000e2'; // G1, child of GP
const A = '00000000-0000-0000-0000-0000000000e3'; // G2, child of P (nested deep)
const R = '00000000-0000-0000-0000-0000000000e4'; // G1 root
const B = '00000000-0000-0000-0000-0000000000e5'; // G2, child of R
it('records the displaced parent edge as a cross-link and keeps the couple adjacent', () => {
const layout = buildLayout(
[
node(GP, 'GP', 0),
node(P, 'P', 1),
{ ...node(A, 'A', 2), birthYear: 1900 }, // earlier birth → A is structural owner
node(R, 'R', 1),
{ ...node(B, 'B', 2), birthYear: 1910 }
],
[
parentEdge(GP, P, 'g1'),
parentEdge(P, A, 'g2'),
parentEdge(R, B, 'g3'),
spouseEdge(A, B, 'sp')
]
);
// A owns; B is absorbed into A's run → couple is exactly adjacent.
const posA = layout.positions.get(A)!;
const posB = layout.positions.get(B)!;
expect(posA.y).toBe(posB.y);
expect(Math.abs(posA.x - posB.x)).toBe(NODE_W + COL_GAP);
// R → B is cross-level: it surfaces as a distinct cross-link, and the
// geometry still lands on B's real position (carried redundantly).
expect(layout.crossLinks).toEqual([{ parentId: R, childId: B }]);
expect(layout.positions.get(B)).toBeDefined();
});
});
function centerX(layout: ReturnType<typeof buildLayout>, id: string): number {
return layout.positions.get(id)!.x + NODE_W / 2;
}
describe('buildLayout — named-bug guard: deep bloodline (#724)', () => {
// A 5-generation single bloodline whose deepest generation fans out wide.
// The OLD per-generation packer (now removed) stranded the apex ancestor at
// the LEFT edge of its descendants — the Albert/Martin symptom. The bottom-up
// tidy-tree centres every ancestor over the span of its descendants.
const gg = '00000000-0000-0000-0000-0000000000f0'; // G0 great-great-grandparent
const g = '00000000-0000-0000-0000-0000000000f1'; // G1
const p = '00000000-0000-0000-0000-0000000000f2'; // G2
const d = '00000000-0000-0000-0000-0000000000f3'; // G3
const leaves = ['a', 'b', 'c', 'd', 'e'].map(
(s, i) => `00000000-0000-0000-0000-0000000000${(0xf4 + i).toString(16)}`
);
function buildBloodline() {
return buildLayout(
[
node(gg, 'gg', 0),
node(g, 'g', 1),
node(p, 'p', 2),
node(d, 'd', 3),
...leaves.map((id, i) => node(id, `leaf-${i}`, 4))
],
[
parentEdge(gg, g, 'e1'),
parentEdge(g, p, 'e2'),
parentEdge(p, d, 'e3'),
...leaves.map((id, i) => parentEdge(d, id, `el${i}`))
]
);
}
it('great_great_grandparent_is_not_stranded_left_of_descendants', () => {
const layout = buildBloodline();
const leafCenters = leaves.map((id) => centerX(layout, id));
const minLeaf = Math.min(...leafCenters);
const maxLeaf = Math.max(...leafCenters);
const ggX = centerX(layout, gg);
// The apex ancestor sits strictly inside its descendant span — not pinned
// to the left edge as the old packer left it.
expect(ggX).toBeGreaterThan(minLeaf);
expect(ggX).toBeLessThan(maxLeaf);
// In fact it sits exactly at the centre of the descendant fan-out, and so
// does every ancestor in the chain (single-child chains inherit the centre).
const mid = (minLeaf + maxLeaf) / 2;
expect(ggX).toBe(mid);
expect(centerX(layout, g)).toBe(mid);
expect(centerX(layout, p)).toBe(mid);
expect(centerX(layout, d)).toBe(mid);
});
});
// Centre-x of a unit's run, derived from its primary's left edge + run width.
function unitCenter(layout: ReturnType<typeof buildLayout>, u: Unit): number {
const left = layout.positions.get(u.id)!.x;
const width = u.members.length * NODE_W + (u.members.length - 1) * COL_GAP;
return left + width / 2;
}
describe('buildLayout — ancestor centring invariant (#724)', () => {
const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
it('every unit centre sits within its child units span (canonical + synthetic)', () => {
const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [
['canonical', fixtureNodes, fixtureEdges],
[
'synthetic',
[node('R', 'R', 0), node('c1', 'c1', 1), node('c2', 'c2', 1), node('c3', 'c3', 1)],
[parentEdge('R', 'c1'), parentEdge('R', 'c2'), parentEdge('R', 'c3')]
]
];
for (const [label, nodes, edges] of cases) {
const layout = buildLayout(nodes, edges);
const forest = buildFamilyForest(nodes, edges);
const walk = (u: Unit) => {
if (u.children.length > 0) {
const childCenters = u.children.map((c) => unitCenter(layout, c));
const lo = Math.min(...childCenters);
const hi = Math.max(...childCenters);
const c = unitCenter(layout, u);
expect(c, `${label}: unit ${u.id} centred in child span`).toBeGreaterThanOrEqual(lo);
expect(c, `${label}: unit ${u.id} centred in child span`).toBeLessThanOrEqual(hi);
}
u.children.forEach(walk);
};
forest.roots.forEach(walk);
}
});
it('no two nodes on the same row overlap (canonical + synthetic)', () => {
const cases: [string, PersonNodeDTO[], RelationshipDTO[]][] = [
['canonical', fixtureNodes, fixtureEdges],
[
'synthetic deep',
[
node('R', 'R', 0),
node('p1', 'p1', 1),
node('p2', 'p2', 1),
node('g1', 'g1', 2),
node('g2', 'g2', 2)
],
[
parentEdge('R', 'p1'),
parentEdge('R', 'p2'),
parentEdge('p1', 'g1'),
parentEdge('p1', 'g2')
]
]
];
for (const [label, nodes, edges] of cases) {
const layout = buildLayout(nodes, edges);
const entries = [...layout.positions.entries()];
for (let i = 0; i < entries.length; i++) {
for (let j = i + 1; j < entries.length; j++) {
const [, a] = entries[i];
const [, b] = entries[j];
if (a.y !== b.y) continue;
expect(
Math.abs(a.x - b.x),
`${label}: ${entries[i][0]} vs ${entries[j][0]} overlap on y=${a.y}`
).toBeGreaterThanOrEqual(NODE_W + COL_GAP);
}
}
}
});
it('a bloodline occupies one contiguous band — no foreign node interleaved', () => {
// Two roots, each its own bloodline. The first fans out two generations
// deep; the contour pack must keep it as one band with nothing from the
// other bloodline wedged inside its horizontal span.
const nodes = [
node('R1', 'R1', 0),
node('a', 'a', 1),
node('b', 'b', 1),
node('a1', 'a1', 2),
node('R2', 'R2', 0),
node('c', 'c', 1)
];
const edges = [
parentEdge('R1', 'a'),
parentEdge('R1', 'b'),
parentEdge('a', 'a1'),
parentEdge('R2', 'c')
];
const layout = buildLayout(nodes, edges);
const forest = buildFamilyForest(nodes, edges);
// Collect the R1 bloodline: every member of its unit subtree.
const r1 = forest.roots.find((u) => u.id === 'R1')!;
const bloodline = new Set<string>();
const collect = (u: Unit) => {
u.members.forEach((m) => bloodline.add(m));
u.children.forEach(collect);
};
collect(r1);
const bandXs = [...bloodline].map((id) => layout.positions.get(id)!.x);
const minX = Math.min(...bandXs);
const maxX = Math.max(...bandXs) + NODE_W;
// No node outside the bloodline may intrude into its [minX, maxX] band.
for (const [id, p] of layout.positions) {
if (bloodline.has(id)) continue;
const intrudes = p.x < maxX && p.x + NODE_W > minX;
expect(intrudes, `foreign node ${id} interleaved into the R1 band`).toBe(false);
}
});
it('no bloodline is smeared across the canvas (per-bloodline span regression)', () => {
// Golden constant: measured 2026-06-04, the OLD per-generation block
// packer laid the canonical fixture out 4860px wide and smeared a single
// bloodline (Albert de Gruyter G0 … a far-right descendant) across that
// whole span — the bug. Total canvas width is NOT the right metric for the
// fix (centring every ancestor makes a 24-root forest wider overall, and
// pan/zoom from #692 handles navigation); per-bloodline compactness is.
// Each contiguous bloodline must occupy far less than the old full-canvas
// smear. Today the widest is Albert's at ~960px.
const OLD_FULL_CANVAS = 4860;
const layout = buildLayout(fixtureNodes, fixtureEdges);
const forest = buildFamilyForest(fixtureNodes, fixtureEdges);
const bloodlineSpan = (root: Unit): number => {
const ids: string[] = [];
const collect = (u: Unit) => {
ids.push(...u.members);
u.children.forEach(collect);
};
collect(root);
const xs = ids.map((id) => layout.positions.get(id)!.x);
return Math.max(...xs) + NODE_W - Math.min(...xs);
};
const spans = forest.roots.map(bloodlineSpan);
const widest = Math.max(...spans);
// Every bloodline is dramatically more compact than the old smear …
expect(widest).toBeLessThan(OLD_FULL_CANVAS);
// … and in fact comfortably under a quarter of it (no smear creeps back).
expect(widest).toBeLessThanOrEqual(OLD_FULL_CANVAS / 4);
});
});
// Deterministic FisherYates using a seeded mulberry32 PRNG (never Math.random,
// which would make the determinism test itself non-reproducible).
function seededShuffle<T>(input: T[], seed: number): T[] {
let s = seed >>> 0;
const rand = () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const out = input.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
describe('buildLayout — determinism (#724)', () => {
const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
it('produces identical positions regardless of node/edge input order', () => {
const base = buildLayout(fixtureNodes, fixtureEdges);
const shuffled = buildLayout(
seededShuffle(fixtureNodes, 1337),
seededShuffle(fixtureEdges, 4242)
);
expect(shuffled.positions.size).toBe(base.positions.size);
for (const [id, p] of base.positions) {
expect(shuffled.positions.get(id), `position for ${id} is order-independent`).toEqual(p);
}
});
});
describe('buildLayout — termination + once-only on cyclic input (#724)', () => {
// buildLayout runs inside a $derived, so non-termination = a frozen tab.
// User-entered data can form parent cycles; the layout must fail closed —
// produce *a* finite layout, every node placed exactly once.
const A = '00000000-0000-0000-0000-0000000000a1';
const B = '00000000-0000-0000-0000-0000000000a2';
const C = '00000000-0000-0000-0000-0000000000a3';
it('A↔B parent cycle returns a finite layout with one position per node', () => {
let layout!: ReturnType<typeof buildLayout>;
expect(() => {
layout = buildLayout(
[node(A, 'A'), node(B, 'B')],
[parentEdge(A, B, 'ab'), parentEdge(B, A, 'ba')]
);
}).not.toThrow();
expect(layout.positions.size).toBe(2);
expect(layout.positions.get(A)).toBeDefined();
expect(layout.positions.get(B)).toBeDefined();
});
it('two founders both reaching a re-entrant 3-cycle terminate with every node placed', () => {
// F is a founder parenting A; A→B→C→A is a parent cycle, and C also
// re-enters via F. The visited/once-only guarantees keep it finite.
const F = '00000000-0000-0000-0000-0000000000a0';
let layout!: ReturnType<typeof buildLayout>;
expect(() => {
layout = buildLayout(
[node(F, 'F'), node(A, 'A'), node(B, 'B'), node(C, 'C')],
[
parentEdge(F, A, 'fa'),
parentEdge(A, B, 'ab'),
parentEdge(B, C, 'bc'),
parentEdge(C, A, 'ca')
]
);
}).not.toThrow();
// Every node is placed exactly once (Map keys are unique by construction;
// the count equality proves none were dropped or duplicated).
expect(layout.positions.size).toBe(4);
for (const id of [F, A, B, C]) expect(layout.positions.get(id)).toBeDefined();
});
});
describe('buildLayout — degenerate graphs (#724)', () => {
it('empty graph yields no positions and the MIN viewBox (no NaN)', () => {
// Runs on a fresh /stammbaum visit before any data loads — must not throw
// or emit NaN into the SVG viewBox.
const layout = buildLayout([], []);
expect(layout.positions.size).toBe(0);
expect(layout.crossLinks).toEqual([]);
expect(layout.generations.size).toBe(0);
for (const v of [layout.viewX, layout.viewY, layout.viewW, layout.viewH]) {
expect(Number.isFinite(v)).toBe(true);
}
// computeViewBox falls back to MIN dimensions when there is no content.
expect(layout.viewW).toBe(1200);
expect(layout.viewH).toBe(800);
});
it('single isolated node is placed once at rank 0', () => {
const layout = buildLayout([node(PARENT, 'Solo', 0)], []);
expect(layout.positions.size).toBe(1);
expect(layout.positions.get(PARENT)).toEqual({ x: 0, y: 0 });
expect(layout.crossLinks).toEqual([]);
});
});

View File

@@ -1,4 +1,6 @@
import type { components } from '$lib/generated/api';
import { buildFamilyForest, type Unit } from './familyForest';
import { layoutForest, type TidyNode } from './tidyTree';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -14,6 +16,9 @@ export const MIN_VIEWBOX_H = 800;
export type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
// Displaced parent→child edges that span structural levels: the connector
// renders these with a distinct dash (never the ended-marriage 4 4 cadence).
crossLinks: { parentId: string; childId: string }[];
viewX: number;
viewY: number;
viewW: number;
@@ -21,37 +26,35 @@ export type Layout = {
};
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
// spousePairs is a Set per person so multi-spouse cases (#361) preserve all
// marriages instead of having later edges silently clobber earlier ones.
const spousePairs = new Map<string, Set<string>>();
const allNodeIds = new Set(allNodes.map((n) => n.id));
// Marriage years keyed by undirected pair (#361) drive the multi-spouse
// sort order: fromYear ASC NULLS LAST, displayName ASC.
const spouseFromYear = new Map<string, number | undefined>();
for (const e of allEdges) {
// Defensive guard against edges referencing IDs outside the node list
// (stale or partial graph snapshots) — applied to BOTH edge types so an
// unknown id is never dereferenced into an undefined position.
if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) continue;
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
// Defensive guard against edges referencing IDs outside the
// node list (stale or partial graph snapshots) — keeps every
// downstream iteration safely scoped to known nodes.
if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) break;
mapAddToSet(spousePairs, e.personId, e.relatedPersonId);
mapAddToSet(spousePairs, e.relatedPersonId, e.personId);
spouseFromYear.set(spousePairKey(e.personId, e.relatedPersonId), e.fromYear);
break;
}
}
// Vertical (y) is still driven entirely by generation rank — #689 seeding
// and spouse pull-down are untouched.
const rank = assignRanks(allNodes, childToParents, spousePairs);
// Group by rank, then sort within rank by display name.
// Group by rank, then sort within rank by display name. Consumed by
// StammbaumTree's gutter rows / generation rail — kept with the same
// rank-keyed semantics and deliberately NOT folded into the x rewrite.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = rank.get(n.id) ?? 0;
@@ -67,218 +70,52 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
});
}
// Per-generation layout:
//
// 1. Build sibling-groups (children of the same parent set) — these become
// the layout "blocks" that are centred under their parents' midpoint.
// 2. Attach loose spouses (people with no parents in the graph but a
// spouse who *is* in a sibling group) on the outside of their partner,
// so the spouse line stays short and adjacent.
// 3. Merge dual-loose spouse pairs into a single 2-person block.
// 4. Centre each block such that its *parented* members average sits
// exactly under the parent midpoint (keeping all connectors at 90°),
// then pack blocks left-to-right.
type Block = {
members: { id: string; parented: boolean }[];
center: number;
};
// Horizontal (x): bottom-up tidy-tree over the family forest. Each unit's
// run width is its member count laid out card+gap; tidyTree assigns the
// run's left edge, and we spread the run's members rightward from there.
// y comes from rank (never tree depth), so a child seeded more than one
// generation below its parent simply gets a taller connector.
const forest = buildFamilyForest(allNodes, allEdges);
const toTidy = (u: Unit): TidyNode => ({
id: u.id,
width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP,
// Pass the unit's rank as the contour level so unrelated roots that sit
// at different generations can share x-columns instead of smearing wide.
level: rank.get(u.id) ?? 0,
children: u.children.map(toTidy)
});
const runX = layoutForest(forest.roots.map(toTidy), COL_GAP);
const positions = new Map<string, { x: number; y: number }>();
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi];
const ids = generations.get(g)!;
const y = g * (NODE_H + ROW_GAP);
const blocksByKey = new Map<string, Block>();
const memberLookup = new Map<string, { key: string; parented: boolean }>();
// Step 1: place every node with parents-in-graph into a sibling block.
for (const id of ids) {
const parents = childToParents.get(id) ?? [];
if (parents.length === 0) continue;
const blockKey = [...parents].sort().join('|');
let block = blocksByKey.get(blockKey);
if (!block) {
const parentCenters: number[] = [];
for (const pid of parents) {
const p = positions.get(pid);
if (p) parentCenters.push(p.x + NODE_W / 2);
}
const center =
parentCenters.length > 0
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
: 0;
block = { members: [], center };
blocksByKey.set(blockKey, block);
}
block.members.push({ id, parented: true });
memberLookup.set(id, { key: blockKey, parented: true });
}
// Sort members within each sibling block alphabetically.
for (const block of blocksByKey.values()) {
block.members.sort((a, b) =>
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
);
}
// Step 2: handle loose nodes.
//
// First pass collects every loose node that has a parented partner,
// grouped by that partner. A second pass sorts each group by
// (fromYear ASC NULLS LAST, displayName ASC) and inserts all spouses
// immediately to the right of the parented partner in one splice —
// matching Leonie's UX rule ("All spouses render to the right of the
// focal person, ordered by marriage date, earliest closest").
// Truly-loose nodes (no parented partner) get their own block here
// and merge with their dual-loose partner in step 3.
type LooseAttachment = { id: string; fromYear: number | undefined };
const looseByParented = new Map<string, LooseAttachment[]>();
for (const id of ids) {
if (memberLookup.has(id)) continue;
const partners = spousePairs.get(id);
let parentedSpouse: string | undefined;
if (partners) {
for (const partnerId of partners) {
if (memberLookup.get(partnerId)?.parented) {
parentedSpouse = partnerId;
break;
}
}
}
if (parentedSpouse) {
const lookupKey = memberLookup.get(parentedSpouse)!.key;
mapPush(looseByParented, parentedSpouse, {
id,
fromYear: spouseFromYear.get(spousePairKey(id, parentedSpouse))
});
memberLookup.set(id, { key: lookupKey, parented: false });
} else {
const blockKey = `__loose__${id}`;
blocksByKey.set(blockKey, {
members: [{ id, parented: false }],
center: 0
});
memberLookup.set(id, { key: blockKey, parented: false });
}
}
for (const [parentedId, attachments] of looseByParented) {
attachments.sort((a, b) => {
const ya = a.fromYear ?? Number.POSITIVE_INFINITY;
const yb = b.fromYear ?? Number.POSITIVE_INFINITY;
if (ya !== yb) return ya - yb;
const an = byId.get(a.id)?.displayName ?? '';
const bn = byId.get(b.id)?.displayName ?? '';
return an.localeCompare(bn);
const place = (u: Unit) => {
const left = runX.get(u.id) ?? 0;
u.members.forEach((mid, i) => {
positions.set(mid, {
x: left + i * (NODE_W + COL_GAP),
y: (rank.get(mid) ?? 0) * (NODE_H + ROW_GAP)
});
const block = blocksByKey.get(memberLookup.get(parentedId)!.key)!;
const parentedIdx = block.members.findIndex((m) => m.id === parentedId);
block.members.splice(
parentedIdx + 1,
0,
...attachments.map((a) => ({ id: a.id, parented: false }))
);
}
});
u.children.forEach(place);
};
forest.roots.forEach(place);
// Merge dual-loose spouse blocks into a single block. With multi-spouse,
// iterate every partner so a loose person with N loose marriages ends
// up in one shared block. Partners are sorted by (fromYear ASC NULLS
// LAST, displayName ASC) before iteration so the resulting block
// places spouses in the UX-spec order to the right of the focal.
const removed = new Set<string>();
for (const [key, block] of blocksByKey) {
if (!key.startsWith('__loose__')) continue;
if (removed.has(key)) continue;
const member = block.members[0];
const partners = spousePairs.get(member.id);
if (!partners) continue;
const sortedPartners = [...partners].sort((a, b) => {
const ya = spouseFromYear.get(spousePairKey(member.id, a)) ?? Number.POSITIVE_INFINITY;
const yb = spouseFromYear.get(spousePairKey(member.id, b)) ?? Number.POSITIVE_INFINITY;
if (ya !== yb) return ya - yb;
const an = byId.get(a)?.displayName ?? '';
const bn = byId.get(b)?.displayName ?? '';
return an.localeCompare(bn);
});
for (const partnerId of sortedPartners) {
const spouseLookup = memberLookup.get(partnerId);
if (!spouseLookup || removed.has(spouseLookup.key)) continue;
if (spouseLookup.key === key) continue;
if (!spouseLookup.key.startsWith('__loose__')) continue;
const otherBlock = blocksByKey.get(spouseLookup.key)!;
block.members.push(...otherBlock.members);
removed.add(spouseLookup.key);
}
}
for (const key of removed) blocksByKey.delete(key);
// Step 3.5 (#361 AC2): Intra-family marriage. Two parented members at
// the same rank in different sibling blocks who marry each other are
// merged into one block — A's siblings on the left, the spouses on
// the join boundary, B's siblings on the right — so the spouse line
// stays short and no other node sits between them.
const mergedKeys = new Set<string>();
for (const [aKey, aBlock] of blocksByKey) {
if (aKey.startsWith('__loose__')) continue;
if (mergedKeys.has(aKey)) continue;
for (const aMember of aBlock.members) {
if (!aMember.parented) continue;
const partners = spousePairs.get(aMember.id);
if (!partners) continue;
for (const partnerId of partners) {
const partnerLookup = memberLookup.get(partnerId);
if (!partnerLookup || !partnerLookup.parented) continue;
if (partnerLookup.key === aKey) continue;
if (partnerLookup.key.startsWith('__loose__')) continue;
if (mergedKeys.has(partnerLookup.key)) continue;
const bBlock = blocksByKey.get(partnerLookup.key)!;
// A's spouse to the right-most slot in A's block; B's spouse
// to the left-most slot in B's block; then concatenate.
moveMemberToEnd(aBlock.members, aMember.id);
moveMemberToStart(bBlock.members, partnerId);
for (const m of bBlock.members) {
memberLookup.set(m.id, { key: aKey, parented: m.parented });
}
aBlock.members.push(...bBlock.members);
aBlock.center = (aBlock.center + bBlock.center) / 2;
mergedKeys.add(partnerLookup.key);
}
}
}
for (const key of mergedKeys) blocksByKey.delete(key);
// Step 4: centre each block on its anchor (parented members) and pack.
const ordered = [...blocksByKey.values()].sort((a, b) => a.center - b.center);
let cursorRight = -Infinity;
for (const block of ordered) {
const n = block.members.length;
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
const anchorIndices: number[] = [];
for (let i = 0; i < n; i++) {
if (block.members[i].parented) anchorIndices.push(i);
}
const avgAnchorIdx =
anchorIndices.length > 0
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
: (n - 1) / 2;
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
for (let i = 0; i < n; i++) {
positions.set(block.members[i].id, {
x: groupLeft + i * (NODE_W + COL_GAP),
y
});
}
cursorRight = groupLeft + groupWidth;
}
// Safety net: any node the forest did not place (it shouldn't leave any)
// still gets a deterministic slot rather than vanishing from the canvas.
let spare = runX.size;
for (const n of allNodes) {
if (positions.has(n.id)) continue;
positions.set(n.id, {
x: spare++ * (NODE_W + COL_GAP),
y: (rank.get(n.id) ?? 0) * (NODE_H + ROW_GAP)
});
}
const crossLinks = forest.crossLinks
.filter((c) => !c.sameLevel)
.map(({ parentId, childId }) => ({ parentId, childId }));
const viewBox = computeViewBox(positions);
return { positions, generations, ...viewBox };
return { positions, generations, crossLinks, ...viewBox };
}
// Bounding box around the actual content, expanded to MIN dimensions (so a
@@ -395,21 +232,3 @@ function mapAddToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
if (s) s.add(value);
else map.set(key, new Set([value]));
}
function spousePairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function moveMemberToEnd<T extends { id: string }>(members: T[], id: string) {
const idx = members.findIndex((m) => m.id === id);
if (idx < 0 || idx === members.length - 1) return;
const [m] = members.splice(idx, 1);
members.push(m);
}
function moveMemberToStart<T extends { id: string }>(members: T[], id: string) {
const idx = members.findIndex((m) => m.id === id);
if (idx <= 0) return;
const [m] = members.splice(idx, 1);
members.unshift(m);
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest';
import { pickStructuralOwner, buildFamilyForest, type Unit } from './familyForest';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
type Person = { id: string; birthYear?: number };
function person(id: string, opts: { birthYear?: number; generation?: number } = {}): PersonNodeDTO {
const n: PersonNodeDTO = { id, displayName: id, familyMember: true };
if (opts.birthYear != null) n.birthYear = opts.birthYear;
if (opts.generation != null) n.generation = opts.generation;
return n;
}
function parent(p: string, c: string): RelationshipDTO {
return {
id: `${p}>${c}`,
personId: p,
relatedPersonId: c,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
};
}
function spouse(a: string, b: string, fromYear?: number): RelationshipDTO {
return {
id: `${a}~${b}`,
personId: a,
relatedPersonId: b,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'SPOUSE_OF',
...(fromYear != null ? { fromYear } : {})
};
}
// Find the unit (anywhere in the forest) whose primary id matches.
function findUnit(roots: Unit[], primaryId: string): Unit | undefined {
for (const r of roots) {
if (r.id === primaryId) return r;
const found = findUnit(r.children, primaryId);
if (found) return found;
}
return undefined;
}
describe('pickStructuralOwner', () => {
const p = (id: string, birthYear?: number): Person => ({ id, birthYear });
it('picks the earlier-born spouse as structural owner', () => {
expect(pickStructuralOwner(p('a', 1900), p('b', 1920))).toBe('a');
expect(pickStructuralOwner(p('a', 1920), p('b', 1900))).toBe('b');
});
it('sorts a missing birthYear last (the dated spouse owns)', () => {
expect(pickStructuralOwner(p('a'), p('b', 1900))).toBe('b');
expect(pickStructuralOwner(p('a', 1900), p('b'))).toBe('a');
});
it('breaks ties on stable id when birth years match or are both missing', () => {
expect(pickStructuralOwner(p('zzz', 1900), p('aaa', 1900))).toBe('aaa');
expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa');
});
});
describe('buildFamilyForest — loose-spouse absorption', () => {
it('absorbs a parentless spouse into the partner run; their child anchors to the couple', () => {
// A (founder) ⚭ S (married-in, no parents). Their child C. S has no
// ancestor subtree of its own, but C still hangs off the couple.
const forest = buildFamilyForest(
[
person('A', { birthYear: 1900 }),
person('S', { birthYear: 1905 }),
person('C', { birthYear: 1930 })
],
[spouse('A', 'S'), parent('A', 'C'), parent('S', 'C')]
);
// One root unit (A), with S absorbed — S is not its own root.
expect(forest.roots.map((r) => r.id)).toEqual(['A']);
const a = findUnit(forest.roots, 'A')!;
expect(a.members).toEqual(['A', 'S']);
// The child anchors through the couple unit.
expect(a.children.map((u) => u.id)).toEqual(['C']);
// S is parentless, so no displaced cross-link.
expect(forest.crossLinks).toEqual([]);
});
it('keeps all marriages of a multi-spouse founder in one run (marriage-year order)', () => {
// Albert-like: one founder, three parentless wives. All absorbed into the
// founder run, ordered by marriage year NULLS LAST then displayName/id.
const forest = buildFamilyForest(
[
person('alb', { birthYear: 1829 }),
person('w1925', { birthYear: 1900 }),
person('wNull', { birthYear: 1901 }),
person('w1910', { birthYear: 1902 })
],
[spouse('alb', 'w1925', 1925), spouse('alb', 'wNull'), spouse('alb', 'w1910', 1910)]
);
expect(forest.roots.map((r) => r.id)).toEqual(['alb']);
const alb = findUnit(forest.roots, 'alb')!;
// Founder first, then spouses by marriage year (1910, 1925, null last).
expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']);
});
});
describe('buildFamilyForest — sibling/branch ordering', () => {
it('orders children by birthYear ASC, NULLS LAST, then displayName, then id', () => {
// Provided out of order; the comparator must reorder to 1910, 1920, then
// the two undated by displayName/id (NULLS LAST).
const undatedB: PersonNodeDTO = { id: 'u-b', displayName: 'Zoe', familyMember: true };
const undatedA: PersonNodeDTO = { id: 'u-a', displayName: 'Anna', familyMember: true };
const forest = buildFamilyForest(
[
person('P', { birthYear: 1880 }),
person('c1920', { birthYear: 1920 }),
undatedB,
person('c1910', { birthYear: 1910 }),
undatedA
],
[parent('P', 'c1920'), parent('P', 'u-b'), parent('P', 'c1910'), parent('P', 'u-a')]
);
const p = findUnit(forest.roots, 'P')!;
// 1910, 1920 (dated ASC), then undated by displayName (Anna < Zoe).
expect(p.children.map((u) => u.id)).toEqual(['c1910', 'c1920', 'u-a', 'u-b']);
});
it('orders roots by the same rule', () => {
const forest = buildFamilyForest(
[person('late', { birthYear: 1900 }), person('early', { birthYear: 1850 })],
[]
);
expect(forest.roots.map((r) => r.id)).toEqual(['early', 'late']);
});
});

View File

@@ -0,0 +1,252 @@
// Domain-aware construction of the genealogy "family forest" (#724).
//
// This module owns every piece of genealogy knowledge the layout needs —
// spouse runs, birth-year ordering, structural-owner selection, intra-family
// marriage resolution and cross-links — and flattens it into the abstract
// { id, width, children } nodes that the domain-agnostic tidyTree.ts packs.
// buildLayout.ts orchestrates the two and maps run positions back to persons.
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
/**
* A family unit = one bloodline carrier (the primary) plus the spouse(s)
* absorbed into its run. `members[0]` is the primary; the rest are spouses in
* marriage-year order. `children` are the units anchored by the couple's
* offspring (the forest hierarchy).
*/
export type Unit = {
id: string; // == primary's id; also the tidyTree node id
members: string[]; // run order: [primary, ...absorbed spouses]
children: Unit[];
};
/**
* A parent→child edge whose child is NOT positioned under that parent (the
* child lives in a spouse's run elsewhere). `sameLevel` is true when the
* displaced parent can be ordered adjacent to the child's anchor (a short,
* solid connector — the intra-family "adjacency" case); false marks a genuine
* cross-level link that must render with a distinct dash.
*/
export type CrossLink = { parentId: string; childId: string; sameLevel: boolean };
export type FamilyForest = { roots: Unit[]; crossLinks: CrossLink[] };
const ROOT_GROUP = '__ROOT__';
/**
* Choose the structural owner of a couple: the spouse who keeps the bloodline
* (hierarchy) position. Earlier birth year wins; a missing birth year sorts
* last; ties break on the stable id. Shared by the cycle, cross-link and
* intra-family paths so the rule is defined exactly once.
*/
export function pickStructuralOwner(
a: { id: string; birthYear?: number | null },
b: { id: string; birthYear?: number | null }
): string {
const ra = ownerRank(a);
const rb = ownerRank(b);
if (ra !== rb) return ra < rb ? a.id : b.id;
return a.id <= b.id ? a.id : b.id;
}
// Lower is "more owning". Missing birth year → +Infinity (sorts last).
function ownerRank(p: { birthYear?: number | null }): number {
return p.birthYear == null ? Number.POSITIVE_INFINITY : p.birthYear;
}
/**
* Build the family forest: assign every person to exactly one unit (a primary,
* or a spouse absorbed into a primary's run), wire the parent/child hierarchy,
* and record displaced parent edges as cross-links. The packer (tidyTree) and
* the orchestrator (buildLayout) consume the result; this module holds all the
* genealogy semantics.
*/
export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO[]): FamilyForest {
const byId = new Map(nodes.map((n) => [n.id, n]));
const allIds = new Set(nodes.map((n) => n.id));
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spouses = new Map<string, Set<string>>();
const spouseYear = new Map<string, number | undefined>();
for (const e of edges) {
// Unknown-id guard for BOTH edge types: an edge to an id outside the node
// list is dropped, never dereferenced into an undefined position.
if (!allIds.has(e.personId) || !allIds.has(e.relatedPersonId)) continue;
if (e.relationType === 'PARENT_OF') {
push(parentToChildren, e.personId, e.relatedPersonId);
push(childToParents, e.relatedPersonId, e.personId);
} else if (e.relationType === 'SPOUSE_OF') {
addToSet(spouses, e.personId, e.relatedPersonId);
addToSet(spouses, e.relatedPersonId, e.personId);
spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined);
}
}
const hasParents = (id: string) => (childToParents.get(id)?.length ?? 0) > 0;
// --- Absorption: decide who is absorbed into whose run. ---
const absorbedInto = new Map<string, string>();
const runOwner = (id: string): string => {
let cur = id;
const seen = new Set([cur]);
while (absorbedInto.has(cur)) {
cur = absorbedInto.get(cur)!;
if (seen.has(cur)) break; // defensive: never loop on a self-referential chain
seen.add(cur);
}
return cur;
};
const owners = new Set<string>();
const canAbsorb = (x: string) => !absorbedInto.has(x) && !owners.has(x);
for (const [a, b] of marriagesInOrder(spouses)) {
if (runOwner(a) === runOwner(b)) continue;
const aP = hasParents(a);
const bP = hasParents(b);
let owner: string;
let absorbed: string;
if (aP === bP) {
// Both parented (intra-family) or both parentless (dual-loose founders):
// the structural owner keeps the bloodline position.
owner = pickStructuralOwner(byId.get(a)!, byId.get(b)!);
absorbed = owner === a ? b : a;
} else if (aP) {
owner = a;
absorbed = b;
} else {
owner = b;
absorbed = a;
}
if (canAbsorb(absorbed)) {
const o = runOwner(owner);
absorbedInto.set(absorbed, o);
owners.add(o);
} else if (canAbsorb(owner)) {
const o = runOwner(absorbed);
absorbedInto.set(owner, o);
owners.add(o);
}
// else: both already fixed in other runs — the marriage still draws a
// plain spouse line via the connector; no absorption is forced.
}
// --- Unit membership ---
const unitOf = new Map<string, string>(); // person → primary id
for (const n of nodes) unitOf.set(n.id, runOwner(n.id));
const primaries = nodes.map((n) => n.id).filter((id) => unitOf.get(id) === id);
const absorbedOf = new Map<string, string[]>();
for (const k of absorbedInto.keys()) push(absorbedOf, runOwner(k), k);
const membersOf = new Map<string, string[]>();
for (const p of primaries) {
const spousesOfP = (absorbedOf.get(p) ?? [])
.slice()
.sort((x, y) => spouseRun(p, x, y, spouseYear, byId));
membersOf.set(p, [p, ...spousesOfP]);
}
// hierarchy parent unit of a primary: the unit of its structural-owner parent
// (married co-parents share a unit anyway, so the choice is only material for
// the rare unmarried-co-parent case).
const hierParentUnit = (primary: string): string | null => {
const parents = childToParents.get(primary) ?? [];
if (parents.length === 0) return null;
let chosen = parents[0];
for (const pa of parents.slice(1))
chosen = pickStructuralOwner(byId.get(chosen)!, byId.get(pa)!);
const u = unitOf.get(chosen)!;
return u === primary ? null : u; // guard: a self-parent edge cannot anchor
};
const groupKey = (primary: string) => hierParentUnit(primary) ?? ROOT_GROUP;
// --- Build Unit objects + hierarchy ---
const unitObj = new Map<string, Unit>();
for (const p of primaries) unitObj.set(p, { id: p, members: membersOf.get(p)!, children: [] });
const roots: Unit[] = [];
for (const p of primaries) {
const hp = hierParentUnit(p);
if (hp == null) roots.push(unitObj.get(p)!);
else unitObj.get(hp)!.children.push(unitObj.get(p)!);
}
// Sibling/branch order: birthYear ASC NULLS LAST → displayName → id. Applied
// to roots and to every unit's children so x never depends on Map order.
const branchOrder = (u: Unit, v: Unit) => {
const a = byId.get(u.id)!;
const b = byId.get(v.id)!;
const ya = a.birthYear ?? Number.POSITIVE_INFINITY;
const yb = b.birthYear ?? Number.POSITIVE_INFINITY;
if (ya !== yb) return ya - yb;
return a.displayName.localeCompare(b.displayName) || (u.id < v.id ? -1 : 1);
};
roots.sort(branchOrder);
for (const u of unitObj.values()) u.children.sort(branchOrder);
// --- Cross-links: displaced parent → absorbed-spouse edges. ---
const crossLinks: CrossLink[] = [];
for (const k of absorbedInto.keys()) {
const owner = runOwner(k);
const hu = hierParentUnit(owner);
for (const pa of childToParents.get(k) ?? []) {
const sameLevel = hu != null && groupKey(unitOf.get(pa)!) === groupKey(hu);
crossLinks.push({ parentId: pa, childId: k, sameLevel });
}
}
return { roots, crossLinks };
}
// Spouse-run comparator: marriage year ASC NULLS LAST, then displayName, then id.
function spouseRun(
primary: string,
x: string,
y: string,
spouseYear: Map<string, number | undefined>,
byId: Map<string, PersonNodeDTO>
): number {
const yx = spouseYear.get(pairKey(primary, x)) ?? Number.POSITIVE_INFINITY;
const yy = spouseYear.get(pairKey(primary, y)) ?? Number.POSITIVE_INFINITY;
if (yx !== yy) return yx - yy;
const nx = byId.get(x)?.displayName ?? '';
const ny = byId.get(y)?.displayName ?? '';
return nx.localeCompare(ny) || (x < y ? -1 : 1);
}
// Unique undirected marriages in a deterministic (pair-key) order.
function marriagesInOrder(spouses: Map<string, Set<string>>): [string, string][] {
const seen = new Set<string>();
const out: [string, string][] = [];
for (const a of [...spouses.keys()].sort()) {
for (const b of [...spouses.get(a)!].sort()) {
const k = pairKey(a, b);
if (seen.has(k)) continue;
seen.add(k);
out.push(a < b ? [a, b] : [b, a]);
}
}
out.sort((m1, m2) => (pairKey(m1[0], m1[1]) < pairKey(m2[0], m2[1]) ? -1 : 1));
return out;
}
function push<K, V>(map: Map<K, V[]>, key: K, value: V) {
const arr = map.get(key);
if (arr) arr.push(value);
else map.set(key, [value]);
}
function addToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
const s = map.get(key);
if (s) s.add(value);
else map.set(key, new Set([value]));
}
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
export type { PersonNodeDTO };

View File

@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import { layoutForest, type TidyNode } from './tidyTree';
// tidyTree is domain-agnostic: it lays out abstract { id, width, children }
// nodes, so these tests use hand-built trees with no PersonNodeDTO import.
const W = 160;
const GAP = 40;
function leaf(id: string, width = W): TidyNode {
return { id, width, children: [] };
}
function node(id: string, children: TidyNode[], width = W): TidyNode {
return { id, width, children };
}
function center(x: Map<string, number>, n: TidyNode): number {
return x.get(n.id)! + n.width / 2;
}
// Walk a forest, recording each node's tree depth and width.
function depths(roots: TidyNode[]): Map<string, { depth: number; width: number }> {
const out = new Map<string, { depth: number; width: number }>();
const walk = (n: TidyNode, d: number) => {
out.set(n.id, { depth: d, width: n.width });
for (const c of n.children) walk(c, d + 1);
};
for (const r of roots) walk(r, 0);
return out;
}
// Assert no two boxes at the same depth overlap (clearance >= gap).
function expectNoOverlap(x: Map<string, number>, roots: TidyNode[], gap: number) {
const meta = depths(roots);
const ids = [...x.keys()];
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = meta.get(ids[i])!;
const b = meta.get(ids[j])!;
if (a.depth !== b.depth) continue;
const xa = x.get(ids[i])!;
const xb = x.get(ids[j])!;
const lo = Math.min(xa, xb);
const hi = Math.max(xa, xb);
const loW = xa <= xb ? a.width : b.width;
expect(hi - lo).toBeGreaterThanOrEqual(loW + gap);
}
}
}
describe('tidyTree — leaf base case', () => {
it('a single leaf lays out at x = 0', () => {
const a = leaf('a');
const x = layoutForest([a], GAP);
expect(x.get('a')).toBe(0);
});
});
describe('tidyTree — ancestor centring', () => {
it('a parent is centred over the span of its two children', () => {
const c1 = leaf('c1');
const c2 = leaf('c2');
const p = node('p', [c1, c2]);
const x = layoutForest([p], GAP);
const pc = center(x, p);
const lo = center(x, c1);
const hi = center(x, c2);
// Parent sits exactly at the midpoint of its children's centres …
expect(pc).toBe((lo + hi) / 2);
// … which is within their span (the named-bug guard generalises this).
expect(pc).toBeGreaterThanOrEqual(Math.min(lo, hi));
expect(pc).toBeLessThanOrEqual(Math.max(lo, hi));
// Children do not overlap.
expect(Math.abs(x.get('c2')! - x.get('c1')!)).toBeGreaterThanOrEqual(W + GAP);
});
});
describe('tidyTree — contour nesting', () => {
it('a deep subtree and a shallow sibling nest without overlap', () => {
// root
// ├─ a (leaf)
// └─ b ─ b1, b2 (deeper)
// The contour push must keep b's whole subtree clear of leaf a, and a
// clear of b's grandchildren, at every depth.
const a = leaf('a');
const b = node('b', [leaf('b1'), leaf('b2')]);
const root = node('root', [a, b]);
const x = layoutForest([root], GAP);
expectNoOverlap(x, [root], GAP);
// Each parent still centred over its own children.
expect(center(x, b)).toBe(
(center(x, { id: 'b1', width: W, children: [] }) +
center(x, { id: 'b2', width: W, children: [] })) /
2
);
});
});
describe('tidyTree — multi-root packing', () => {
it('packs multiple roots left-to-right with no overlap', () => {
// A real Stammbaum is a forest of ~50 roots, not a single tree — this is
// the dominant compactness win. Three roots of differing shapes.
const r1 = node('r1', [leaf('r1a'), leaf('r1b')]);
const r2 = leaf('r2');
const r3 = node('r3', [leaf('r3a')]);
const roots = [r1, r2, r3];
const x = layoutForest(roots, GAP);
expectNoOverlap(x, roots, GAP);
// Roots keep input order left-to-right.
expect(x.get('r1')!).toBeLessThan(x.get('r2')!);
expect(x.get('r2')!).toBeLessThan(x.get('r3')!);
// Forest is normalised so the leftmost edge is at 0.
expect(Math.min(...x.values())).toBe(0);
});
});
describe('tidyTree — variable-width run', () => {
it('centres a wide couple run over its children and clears its siblings', () => {
// A couple unit is one node whose width spans two cards + the gap. It
// must still centre over its own children and stay clear of siblings by
// the FULL run width, not a single card.
const COUPLE = 2 * W + GAP;
const couple = node('couple', [leaf('g1'), leaf('g2')], COUPLE);
const sibling = node('sib', [leaf('s1')]);
const roots = [sibling, couple];
const x = layoutForest(roots, GAP);
expectNoOverlap(x, roots, GAP);
// Couple centred over its two children.
expect(center(x, couple)).toBe((center(x, leaf('g1')) + center(x, leaf('g2'))) / 2);
// The sibling root clears the couple's full run width.
expect(x.get('couple')! - x.get('sib')!).toBeGreaterThanOrEqual(W + GAP);
});
});

View File

@@ -0,0 +1,168 @@
// Domain-agnostic bottom-up "tidy tree" contour packer (#724).
//
// This module knows NOTHING about persons, spouses, ranks, or the generated
// API — it lays out abstract { id, width, children, level? } nodes purely by
// structure, so it is unit-testable with hand-built 3-line trees. All genealogy
// knowledge (spouse runs, birth-year order, cross-links) lives in
// familyForest.ts, which flattens its domain model into these abstract nodes.
//
// Algorithm (a plain ReingoldTilford / Walker contour pack, NOT Buchheim's
// O(n) threaded variant — at ~62 nodes the simple shift-and-merge is fast
// enough and far easier to verify):
//
// 1. Post-order: lay out every child subtree first.
// 2. Pack children left-to-right; each new subtree is shifted right just far
// enough that its LEFT contour clears the running RIGHT contour of the
// already-placed siblings by `gap` at every shared LEVEL (mergeContour +
// shiftSubtree). Deep and shallow branches therefore nest without overlap.
// 3. Place the node's own (variable-width) run centred over the span of its
// children's centres, so an ancestor always sits above its descendants.
//
// Contours are indexed by ABSOLUTE level (generation rank), NOT tree depth.
// This is what lets two unrelated roots that sit at different generations share
// the same x-column instead of being smeared across the canvas — the family
// graph is a forest of ~50 roots over 62 nodes, so packing every root at
// depth 0 would more than double the width. When `level` is omitted (the
// abstract unit tests) it falls back to tree depth, so a plain tree behaves
// exactly like classic ReingoldTilford.
//
// x is the LEFT edge of each node's box. y is NOT computed here — the caller
// derives it from the same generation rank it passes in as `level`. Positions
// are kept on the integer grid (root centres are rounded) so the no-overlap
// invariant holds exactly.
export type TidyNode = {
id: string;
/** Total horizontal extent of this node's run (one card, or a couple). */
width: number;
children: TidyNode[];
/** Absolute level (generation rank). Falls back to tree depth when omitted. */
level?: number;
};
// A laid-out subtree in its own local frame: per-id left-edge x plus the left
// and right contours keyed by absolute level.
type Laid = {
x: Map<string, number>;
left: Map<number, number>;
right: Map<number, number>;
};
/**
* Lay out a forest of root subtrees packed left-to-right and return a map of
* node id → left-edge x. The whole forest is normalised so the leftmost edge
* sits at x = 0. `gap` is the minimum horizontal clearance between any two
* boxes on the same level.
*/
export function layoutForest(roots: TidyNode[], gap: number): Map<string, number> {
if (roots.length === 0) return new Map();
const placed = packChildren(
roots.map((r) => layoutUnit(r, gap, null)),
gap
);
const x = new Map<string, number>();
let min = Infinity;
for (const subtree of placed) {
for (const [id, v] of subtree.x) {
x.set(id, v);
if (v < min) min = v;
}
}
if (min !== 0 && min !== Infinity) {
for (const [id, v] of x) x.set(id, v - min);
}
return x;
}
// Post-order layout of one subtree. `parentLevel` seeds the depth fallback.
function layoutUnit(node: TidyNode, gap: number, parentLevel: number | null): Laid {
const level = node.level ?? (parentLevel == null ? 0 : parentLevel + 1);
const children = node.children ?? [];
if (children.length === 0) {
return {
x: new Map([[node.id, 0]]),
left: new Map([[level, 0]]),
right: new Map([[level, node.width]])
};
}
const placed = packChildren(
children.map((c) => layoutUnit(c, gap, level)),
gap
);
// Centre the node's run over the span of its children's centres, then snap
// to the integer grid so sibling/cousin x-differences stay exact.
const firstCenter = childCenter(placed[0], children[0]);
const lastCenter = childCenter(placed[placed.length - 1], children[children.length - 1]);
const rootLeft = Math.round((firstCenter + lastCenter) / 2 - node.width / 2);
const x = new Map<string, number>([[node.id, rootLeft]]);
for (const subtree of placed) {
for (const [id, v] of subtree.x) x.set(id, v);
}
// Subtree contour: children's contours plus this node at its own level.
let left = new Map<number, number>();
let right = new Map<number, number>();
for (const subtree of placed) {
left = mergeContour(left, subtree.left, Math.min);
right = mergeContour(right, subtree.right, Math.max);
}
left.set(level, left.has(level) ? Math.min(left.get(level)!, rootLeft) : rootLeft);
const rootRight = rootLeft + node.width;
right.set(level, right.has(level) ? Math.max(right.get(level)!, rootRight) : rootRight);
return { x, left, right };
}
function childCenter(laid: Laid, node: TidyNode): number {
return laid.x.get(node.id)! + node.width / 2;
}
// Pack already-laid-out subtrees left-to-right, shifting each so its left
// contour clears the running right contour of all previously placed siblings.
function packChildren(children: Laid[], gap: number): Laid[] {
const placed: Laid[] = [];
let accRight = new Map<number, number>();
for (const child of children) {
let shift = 0;
for (const [level, l] of child.left) {
const r = accRight.get(level);
if (r !== undefined) {
const need = r + gap - l;
if (need > shift) shift = need;
}
}
const moved = shiftSubtree(child, shift);
placed.push(moved);
accRight = mergeContour(accRight, moved.right, Math.max);
}
return placed;
}
// Translate a laid-out subtree (positions + both contours) by dx.
function shiftSubtree(laid: Laid, dx: number): Laid {
if (dx === 0) return laid;
const shiftMap = (m: Map<number, number>) => {
const out = new Map<number, number>();
for (const [k, v] of m) out.set(k, v + dx);
return out;
};
const x = new Map<string, number>();
for (const [id, v] of laid.x) x.set(id, v + dx);
return { x, left: shiftMap(laid.left), right: shiftMap(laid.right) };
}
// Combine two level-indexed contours with `pick` (Math.min for left edges,
// Math.max for right edges); levels present in only one contour are kept.
function mergeContour(
acc: Map<number, number>,
add: Map<number, number>,
pick: (a: number, b: number) => number
): Map<number, number> {
const out = new Map(acc);
for (const [level, v] of add) {
out.set(level, out.has(level) ? pick(out.get(level)!, v) : v);
}
return out;
}