Compare commits

..

7 Commits

Author SHA1 Message Date
Marcel
4f07527b0f docs(adr): ADR-026 in-house Stammbaum layout, dagre deferred (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m32s
CI / OCR Service Tests (pull_request) Successful in 21s
CI / Backend Unit Tests (pull_request) Successful in 3m56s
CI / fail2ban Regex (pull_request) Successful in 46s
CI / Semgrep Security Scan (pull_request) Successful in 25s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m6s
Records the decision to keep Stammbaum layout in-house, with the in-house
fixes from commits 1-6 of #361 as the implementation, and a UX-signal-only
stop trigger as the dagre re-evaluation criterion. Captures the deferred
acceptance criteria (AC3, AC6, AC7) with explicit revisit triggers so
future maintainers do not silently inherit unbounded scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:22:18 +02:00
Marcel
0c5f56e9d1 test+fix(stammbaum): enlarge marriage-line midpoint dot to r=6 (#361)
Once the dot starts stacking to disambiguate multiple marriages on
multi-spouse rows it carries meaning, so it's no longer decorative —
WCAG 1.4.11 (3:1) applies. r=6 (12 px diameter) covers the contrast
gap; the existing brand-navy fill against the gutter and surface
backgrounds satisfies the ratio without a hue change.

Impl-ref table in stammbaum-tree-spec.html updated to match (r=6 /
12 px dia / Informational), with the WCAG reference noted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:20:51 +02:00
Marcel
652100a9c2 test+feat(stammbaum): merge sibling blocks across same-rank spouse edge (#361)
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>
2026-05-28 20:18:23 +02:00
Marcel
557f37be54 test+feat(stammbaum): order multi-spouses by fromYear then displayName (#361)
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>
2026-05-28 20:14:23 +02:00
Marcel
2a462d0a7c test+feat(stammbaum): preserve all SPOUSE_OF edges in layout (#361)
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>
2026-05-28 20:03:52 +02:00
Marcel
36bd7e0414 chore(stammbaum): add /api/network capture script + canonical fixture (#361)
Local-only developer utility that authenticates against the running backend,
captures the current /api/network snapshot, and writes it to
src/lib/person/genealogy/__fixtures__/stammbaum.json. Sanity gates exit
non-zero on a vacuous capture (< 50 nodes, < 5 generations, 0 SPOUSE_OF
edges). Fixture and script land together so the fixture is reproducible from
the script that generated it.

Captured snapshot: 62 nodes, 43 edges, 28 SPOUSE_OF (0 with fromYear),
generations G0-G4. Albert de Gruyter is the canonical multi-spouse case with
4 marriages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:55:30 +02:00
Marcel
6970cc95fb docs(stammbaum): reconcile spec geometry to 160x56 and document seeded-rank invariant (#361)
Updates the impl-ref constants table to match buildLayout.ts (NODE_W=160,
NODE_H=56) and adds an explicit Layout rules section asserting the seeded-
rank invariant honoured since #689. Mockup <rect> dimensions stay at 144x50
with an explanatory annotation; re-pixel-pushing the illustrative SVG has
disproportionate blast radius for a spec doc.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 19:51:13 +02:00
10 changed files with 1840 additions and 48 deletions

View File

@@ -0,0 +1,129 @@
# ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred
**Date:** 2026-05-28
**Status:** Accepted
**Issue:** #361
**Supersedes:** _none_
**Supersedes-on-trigger:** A future ADR-027 if any acceptance criterion below stops converging in-house.
---
## Context
After #689 shipped the seeded-rank invariant — `buildLayout.ts` treats imported
`persons.generation` as a strict row anchor and the iterative heuristic only
runs for unseeded nodes — the question "should we adopt
[@dagrejs/dagre](https://www.npmjs.com/package/@dagrejs/dagre) for Stammbaum
layout?" had to be re-evaluated.
dagre's headline value is **rank assignment** via `network-simplex` /
`longest-path`. That value is now mostly redundant: curated import data already
pins ranks for the family graph, and the residual heuristic only places
unseeded nodes (today: family members imported without a `generation` column,
spouses with no parents in the graph).
What remains are **position-within-rank** problems:
1. Multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages) whose
secondary marriages were silently dropped by a `Map<string, string>` shape.
2. Intra-family marriages — two persons in different sibling blocks at the
same rank who marry each other (latent; zero cases in current data).
3. Unseeded loose spouses whose parents are also in the graph (latent; zero
cases — 0 of 942 unseeded persons match the predicate in the May-2026
snapshot).
Six persona walkthroughs on #361 (Leonie/UX, Felix/Dev, Markus/Architect,
Nora/Security, Sara/QA, Tobias/DevOps, Elicit/Requirements) converged on the
same recommendation: try the in-house fix path first, against the canonical
dataset, with quantitative exit triggers — adopt dagre only if any acceptance
criterion fails to converge.
---
## Decision
**Keep Stammbaum layout in-house. Do not adopt dagre at this time.**
The fix path lands as six commits on #361:
1. Spec geometry reconcile (`NODE_W=160, NODE_H=56` matches `buildLayout.ts`)
and an explicit seeded-rank-invariant Layout-rules line.
2. Canonical `/api/network` fixture capture script + pinned snapshot for
structural assertions in `buildLayout.test.ts`.
3. `spousePairs: Map<string, string>``Map<string, Set<string>>`. Preserves
all marriages; closes Nora's robustness gap (edges referencing IDs outside
`allNodes` are guarded at ingestion).
4. Multi-spouse ordering: `(fromYear ASC NULLS LAST, displayName ASC)`,
inserted to the right of the parented focal — matches Leonie's UX rule.
5. Intra-family-marriage block merge across same-rank parented sibling blocks
(AC2) — adjacent placement at the join boundary.
6. Marriage-line midpoint dot enlarged from `r=4.5` to `r=6` (WCAG 1.4.11
informational contrast — the dot disambiguates stacked marriages and is
no longer decorative).
The block-packer + AC2 merge stays well under Markus's 80-LoC extraction
threshold, so `packBlocks.ts` is **not** yet warranted.
---
## Consequences
### Accepted today
- **AC1 (multi-spouse preservation)** is now a property of `buildLayout`, verified
by both synthetic and canonical fixture tests.
- **AC2 (intra-family marriage)** ships latent but covered by a synthetic
two-family regression test.
- **AC4 (seeded-rank invariant)** preserved end-to-end by every `buildLayout.test.ts`
case from #689.
- **AC5 (spec ↔ code geometry)** reconciled in commit 1.
### Deferred with revisit triggers
- **AC3 — Unseeded loose spouse with parents-in-graph.** Database verification:
0 of 942 unseeded persons match the predicate today. Structurally, every
realistic case maps to a **curation/import gap** (P's parents were imported
with `generation`, P themselves was not) and belongs in the canonical import
sheet rather than `buildLayout`. **Revisit trigger:** first canonical fixture
containing a parented unseeded spouse — at which point this ADR is updated
in place or superseded by an ADR-027.
- **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()`
permanently dropped (high running cost, speculative coverage). The axe-core
3:1 contrast check for the enlarged marriage dot is verified one-shot at
PR time, not committed; the permanent contrast/breakpoint gate lands with
#692 (mobile pan/zoom epic) alongside the breakpoint visual-regression
infrastructure.
### UX-signal-only stop trigger for dagre adoption
There is **no LoC cap** on the in-house path. The only divergence signal that
warrants reopening the dagre decision is a **UX failure against the canonical
fixture** — specifically, Albert de Gruyter's 4 marriages failing the read
test ("can a 67-year-old researcher unambiguously see all four spouses?").
If that ever happens, Felix posts a divergence-evidence comment on #361 (or
the equivalent successor issue), the team re-runs brainstorming with the
dagre option on the table, and adoption proceeds under the supply-chain
controls already documented in #361's body (`@dagrejs/dagre` exact-pinned, no
auto-merge, try/catch fallback with structured log, deterministic input sort).
### Operational
- **No CI, image, or compose changes.** Pure frontend layout work; standard
frontend rebuild covers the deploy.
- **No service topology changes.** No new env vars, ports, resource limits.
---
## Notes
- `frontend/scripts/capture-network-fixture.mjs` is a **local-only developer
utility**, never invoked from CI. Re-run intentionally; commit the resulting
JSON in one atomic commit when a new structural case appears (new edge type,
new marriage configuration, new generation range).
- The canonical fixture contains real family names. Repository is private;
scrubbing is a single-commit migration if it ever opens.
- Brand-mint enforcement on SVG strokes (Leonie's "all connectors render in
brand-navy, hierarchy comes from shape") stays a **code-review check at PR
time**. No CI grep, no custom ESLint rule.

View File

@@ -336,6 +336,7 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<div class="sh">
<h2>2 · Desktop (1280 px) — light &amp; dark, resting state</h2>
<p>Full tree canvas with Gen IIII nodes and the 268 px side panel showing Maria Raddatz (selected). Rendered at ~65 % scale. Light and dark stacked.</p>
<p><em>Mockup <code>&lt;rect&gt;</code> dimensions (144 × 50) below are illustrative; authoritative node geometry is in the impl-ref constants table in §6 (<code>NODE_W = 160</code>, <code>NODE_H = 56</code>).</em></p>
</div>
<div>
@@ -950,14 +951,14 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
<tbody>
<tr>
<td>Tree node</td>
<td><code>w-[144px] h-[50px] rounded-sm</code></td>
<td>144 × 50</td>
<td>SVG <code>rect rx="2"</code>; same size all generations</td>
<td><code>w-[160px] h-[56px] rounded-sm</code></td>
<td>160 × 56</td>
<td>SVG <code>rect rx="2"</code>; same size all generations. Constants <code>NODE_W = 160</code>, <code>NODE_H = 56</code> in <code>buildLayout.ts</code> are the source of truth.</td>
</tr>
<tr>
<td>Selected accent bar</td>
<td><code>w-1 h-full bg-accent</code></td>
<td>4 × 50</td>
<td>4 × 56</td>
<td>Mint on light; navy on dark; left edge of selected node</td>
</tr>
<tr>
@@ -998,9 +999,9 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
</tr>
<tr>
<td>Marriage dot</td>
<td>SVG <code>r="4.5"</code></td>
<td>9 px dia</td>
<td>Filled circle at connector midpoints; same color as connectors</td>
<td>SVG <code>r="6"</code></td>
<td>12 px dia</td>
<td>Filled circle at connector midpoints; same color as connectors. Informational (disambiguates stacked marriages on multi-spouse rows) — WCAG 1.4.11 (3:1) applies.</td>
</tr>
<tr>
<td>Gutter label</td>
@@ -1047,6 +1048,14 @@ body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1
</tbody>
</table>
</div>
<div class="sh" style="margin-top:24px">
<h2>6.1 · Layout rules</h2>
<p>Invariants honoured by <code>buildLayout.ts</code> and asserted in <code>buildLayout.test.ts</code>:</p>
<ul style="font-size:11px;color:#444;line-height:1.7;padding-left:20px">
<li><strong>Seeded-rank invariant.</strong> When <code>node.generation</code> is non-null, the node renders at row index equal to <code>node.generation</code> (modulo the negative-generation shift that normalises <code>min(rank) → 0</code>). This is independent of edge structure: spouse-pulldown and parent-fallback never move a seeded rank.</li>
</ul>
</div>
</div>
</div><!-- /doc -->

View File

@@ -0,0 +1,22 @@
# `frontend/scripts/`
One-off developer utilities. Each script is local-only and never invoked from
CI. Re-run intentionally when needed; commit any generated artefacts as a
separate, atomic commit.
| Script | Purpose |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `capture-network-fixture.mjs` | Capture the canonical `GET /api/network` response into `src/lib/person/genealogy/__fixtures__/stammbaum.json`. Used by `buildLayout.test.ts`. Re-capture when the production family graph grows a new structural case (new edge type, new marriage configuration). |
## Usage
```bash
cd frontend
node scripts/capture-network-fixture.mjs
```
Defaults to `BACKEND_URL=http://localhost:8080` and the dev admin credentials.
Override via env vars (`BACKEND_URL`, `CAPTURE_EMAIL`, `CAPTURE_PASSWORD`).
The script exits non-zero if the captured fixture would be vacuous (fewer than
100 nodes, fewer than 5 generations, or zero `SPOUSE_OF` edges).

View File

@@ -0,0 +1,121 @@
#!/usr/bin/env node
// Local-only. Never invoked from CI. Re-run intentionally; commit the
// resulting JSON in one atomic commit.
//
// Captures the current /api/network response into the canonical fixture used
// by buildLayout.test.ts. Asserts a minimum shape so a silently-empty backend
// can't write a vacuous fixture.
import { writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
const EMAIL = process.env.CAPTURE_EMAIL ?? 'admin@familyarchive.local';
const PASSWORD = process.env.CAPTURE_PASSWORD ?? 'admin123';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum.json`;
// Sanity floors — calibrated against the canonical dataset (May 2026: 62
// nodes, 5 generations, 28 SPOUSE_OF edges). The point is catching a silently
// empty backend, not strict size validation; raise these only if the
// canonical graph grows substantially.
const MIN_NODES = 50;
const MIN_GENERATIONS = 5;
const MIN_SPOUSE_OF_EDGES = 1;
function parseSetCookies(headers) {
const out = new Map();
const raw = headers.getSetCookie?.() ?? [];
for (const line of raw) {
const [pair] = line.split(';');
const eq = pair.indexOf('=');
if (eq < 0) continue;
out.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
return out;
}
function serialiseCookies(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function login(jar) {
const xsrf = randomUUID();
jar.set('XSRF-TOKEN', xsrf);
const res = await fetch(`${BACKEND_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-XSRF-TOKEN': xsrf,
Cookie: serialiseCookies(jar)
},
body: JSON.stringify({ email: EMAIL, password: PASSWORD })
});
if (!res.ok) {
throw new Error(`Login failed: ${res.status} ${await res.text()}`);
}
for (const [k, v] of parseSetCookies(res.headers)) jar.set(k, v);
}
async function fetchNetwork(jar) {
const res = await fetch(`${BACKEND_URL}/api/network`, {
headers: { Cookie: serialiseCookies(jar) }
});
if (!res.ok) {
throw new Error(`GET /api/network failed: ${res.status} ${await res.text()}`);
}
return res.json();
}
function validate(network) {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const spouseEdges = edges.filter((e) => e.relationType === 'SPOUSE_OF');
const generations = new Set(nodes.map((n) => n.generation).filter((g) => g != null));
const failures = [];
if (nodes.length < MIN_NODES) {
failures.push(`expected >= ${MIN_NODES} nodes, got ${nodes.length}`);
}
if (generations.size < MIN_GENERATIONS) {
failures.push(`expected >= ${MIN_GENERATIONS} distinct generations, got ${generations.size}`);
}
if (spouseEdges.length < MIN_SPOUSE_OF_EDGES) {
failures.push(`expected >= ${MIN_SPOUSE_OF_EDGES} SPOUSE_OF edges, got ${spouseEdges.length}`);
}
if (failures.length > 0) {
throw new Error(`Sanity gates failed:\n - ${failures.join('\n - ')}`);
}
return {
nodes: nodes.length,
edges: edges.length,
spouseEdges: spouseEdges.length,
generations: [...generations].sort((a, b) => a - b)
};
}
function writeFixture(network) {
mkdirSync(dirname(FIXTURE_PATH), { recursive: true });
writeFileSync(FIXTURE_PATH, JSON.stringify(network, null, '\t') + '\n', 'utf8');
}
async function main() {
const jar = new Map();
console.error(`Capturing /api/network from ${BACKEND_URL} as ${EMAIL} ...`);
await login(jar);
const network = await fetchNetwork(jar);
const stats = validate(network);
writeFixture(network);
console.error(
`Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n generations: G${stats.generations.join(', G')}`
);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});

View File

@@ -319,7 +319,7 @@ const parentLinks = $derived.by<ParentLinks>(() => {
<circle
cx={(aCenter.x + bCenter.x) / 2}
cy={(aCenter.y + bCenter.y) / 2}
r="4.5"
r="6"
fill="var(--c-primary)"
/>
{/if}

View File

@@ -315,6 +315,35 @@ describe('StammbaumTree viewBox', () => {
expect(slanted).toHaveLength(0);
});
it('renders the marriage-line midpoint dot at r=6 for WCAG 1.4.11 informational contrast (#361)', async () => {
// Once the dot stacks to disambiguate multiple marriages it carries
// meaning, so it moves from "decorative" to "informational" and the
// 3:1 contrast rule requires the larger 12 px diameter.
render(StammbaumTree, {
nodes: [
{ id: ID_A, displayName: 'Anna', familyMember: true },
{ id: ID_B, displayName: 'Bertha', familyMember: true }
],
edges: [
{
id: 'e1',
personId: ID_A,
relatedPersonId: ID_B,
personDisplayName: 'Anna',
relatedPersonDisplayName: 'Bertha',
relationType: 'SPOUSE_OF'
}
],
selectedId: null,
zoom: 1,
onSelect: () => {}
});
const dot = document.querySelector('svg circle');
expect(dot).not.toBeNull();
expect(dot!.getAttribute('r')).toBe('6');
});
it('centers two spouse nodes within the minimum viewBox', async () => {
render(StammbaumTree, {
nodes: [

View File

@@ -0,0 +1,25 @@
# `__fixtures__/`
Pinned real-data fixtures used by Stammbaum layout tests.
## `stammbaum.json`
Snapshot of `GET /api/network` against the canonical Familienarchiv dataset.
Captured by `frontend/scripts/capture-network-fixture.mjs`.
### Lifecycle
The fixture is **pinned**, not auto-tracked. Tests assert _structural_
properties (e.g. "a person with ≥ 2 spouses exists") rather than identity
("Albert has exactly 4 spouses"), so the fixture survives data growth without
mechanical edits.
Re-capture and update the affected tests in a single intentional commit when a
new structural case appears in the production graph (new edge type, new
marriage configuration, new generation range).
### PII
The repository is private and the fixture contains real family names. If the
repository ever opens, scrubbing is a one-shot migration commit, not a
permanent test-authoring constraint.

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { buildLayout, NODE_H, ROW_GAP } from './buildLayout';
import { buildLayout, NODE_W, NODE_H, COL_GAP, ROW_GAP } from './buildLayout';
import canonicalFixture from '../__fixtures__/stammbaum.json';
import type { components } from '$lib/generated/api';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
@@ -111,3 +112,194 @@ describe('buildLayout — generation seeding (#689)', () => {
expect(yOf(layout, NEGATIVE_C)).toBe(2 * (NODE_H + ROW_GAP));
});
});
describe('buildLayout — multi-spouse + intra-family marriage (#361)', () => {
const FOCAL = '00000000-0000-0000-0000-000000000010';
const SPOUSE_X = '00000000-0000-0000-0000-000000000011';
const SPOUSE_Y = '00000000-0000-0000-0000-000000000012';
const UNKNOWN = '00000000-0000-0000-0000-000000000099';
it('preserves_both_marriages_when_person_has_two_SPOUSE_OF_edges', () => {
// Before #361 the spouse map was Map<string, string>; the second
// .set() clobbered the first, so a person with N spouses (Albert de
// Gruyter, 4) silently lost N-1 of them. Asserting that every spouse
// has a layout position is the minimal presence check.
const layout = buildLayout(
[node(FOCAL, 'Focal', 3), node(SPOUSE_X, 'Alice'), node(SPOUSE_Y, 'Bob')],
[spouseEdge(FOCAL, SPOUSE_X, 'fx'), spouseEdge(FOCAL, SPOUSE_Y, 'fy')]
);
expect(layout.positions.get(FOCAL)).toBeDefined();
expect(layout.positions.get(SPOUSE_X)).toBeDefined();
expect(layout.positions.get(SPOUSE_Y)).toBeDefined();
});
it('ignores_SPOUSE_OF_edge_with_unknown_relatedPersonId', () => {
// Robustness gap flagged by NullX during persona review: an edge
// pointing to a UUID not in the node list must not crash buildLayout
// and must not introduce a phantom node into the positions map.
const buildIt = () =>
buildLayout([node(FOCAL, 'Focal', 3)], [spouseEdge(FOCAL, UNKNOWN, 'fu')]);
expect(buildIt).not.toThrow();
const layout = buildIt();
expect(layout.positions.get(FOCAL)).toBeDefined();
expect(layout.positions.get(UNKNOWN)).toBeUndefined();
});
it('canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses', () => {
// Real-data structural assertion against the canonical Stammbaum
// snapshot. Today the only multi-spouse case is Albert de Gruyter
// (4 marriages); the assertion stays valid as the graph grows.
const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
const layout = buildLayout(fixtureNodes, fixtureEdges);
const partners = new Map<string, Set<string>>();
for (const e of fixtureEdges) {
if (e.relationType !== 'SPOUSE_OF') continue;
addPartner(partners, e.personId, e.relatedPersonId);
addPartner(partners, e.relatedPersonId, e.personId);
}
const multi = [...partners.entries()].filter(([, set]) => set.size >= 2);
expect(multi.length).toBeGreaterThan(0);
for (const [id, set] of multi) {
expect(layout.positions.get(id)).toBeDefined();
for (const partnerId of set) {
expect(layout.positions.get(partnerId)).toBeDefined();
}
}
});
});
function addPartner(map: Map<string, Set<string>>, key: string, value: string) {
const s = map.get(key);
if (s) s.add(value);
else map.set(key, new Set([value]));
}
describe('buildLayout — multi-spouse ordering (#361)', () => {
const PARENT = '00000000-0000-0000-0000-0000000000c0';
const FOCAL = '00000000-0000-0000-0000-0000000000c1';
const SPOUSE_1925 = '00000000-0000-0000-0000-0000000000c2';
const SPOUSE_NULL = '00000000-0000-0000-0000-0000000000c3';
const SPOUSE_1910 = '00000000-0000-0000-0000-0000000000c4';
function spouseEdgeWithYear(
a: string,
b: string,
fromYear: number | undefined,
id = a + b
): RelationshipDTO {
return { ...spouseEdge(a, b, id), fromYear };
}
it('multi_spouses_ordered_by_fromYear_then_displayName', () => {
// Synthetic year-branch exercise. Focal X is parented (under PARENT)
// at G=1, with three loose spouses at years 1925, null, 1910. After
// the sort, the order to the right of X is: 1910, 1925, null —
// earliest first, NULLS LAST, displayName tiebreaker.
const layout = buildLayout(
[
node(PARENT, 'P', 0),
node(FOCAL, 'Focal', 1),
// Names chosen so alphabetical order does NOT match the
// year-sort order — otherwise the test couldn't tell the
// two sort keys apart.
node(SPOUSE_1925, 'Alpha'),
node(SPOUSE_NULL, 'Beta'),
node(SPOUSE_1910, 'Gamma')
],
[
parentEdge(PARENT, FOCAL),
spouseEdgeWithYear(FOCAL, SPOUSE_1925, 1925, 'ya'),
spouseEdgeWithYear(FOCAL, SPOUSE_NULL, undefined, 'yn'),
spouseEdgeWithYear(FOCAL, SPOUSE_1910, 1910, 'yg')
]
);
const pos = (id: string) => layout.positions.get(id)!;
const xFocal = pos(FOCAL).x;
const x1910 = pos(SPOUSE_1910).x;
const x1925 = pos(SPOUSE_1925).x;
const xNull = pos(SPOUSE_NULL).x;
// All spouses sit to the right of focal …
expect(x1910).toBeGreaterThan(xFocal);
expect(x1925).toBeGreaterThan(xFocal);
expect(xNull).toBeGreaterThan(xFocal);
// … in year-sort order.
expect(x1910).toBeLessThan(x1925);
expect(x1925).toBeLessThan(xNull);
});
it('intra_family_marriage_places_both_spouses_adjacent_across_sibling_blocks', () => {
// AC2 (#361). Two parented persons at the same imported generation,
// each in a separate sibling block under their own parent, marry each
// other. Before the fix the block-packer left them split, drawing a
// long spouse line across an intervening sibling. After the fix the
// two blocks merge with the spouses sitting on the join boundary.
const A1 = '00000000-0000-0000-0000-0000000000d1';
const B1 = '00000000-0000-0000-0000-0000000000d2';
const A2 = '00000000-0000-0000-0000-0000000000d3';
const A3 = '00000000-0000-0000-0000-0000000000d4';
const B2 = '00000000-0000-0000-0000-0000000000d5';
const layout = buildLayout(
[
node(A1, 'A1', 0),
node(B1, 'B1', 0),
node(A2, 'A2', 1),
node(A3, 'A3', 1),
node(B2, 'B2', 1)
],
[
parentEdge(A1, A2, 'p1'),
parentEdge(A1, A3, 'p2'),
parentEdge(B1, B2, 'p3'),
spouseEdge(A2, B2, 'sp')
]
);
const posA2 = layout.positions.get(A2)!;
const posB2 = layout.positions.get(B2)!;
expect(posA2.y).toBe(posB2.y);
expect(Math.abs(posA2.x - posB2.x)).toBe(NODE_W + COL_GAP);
});
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {
// Real-data assertion: 0 of 28 SPOUSE_OF rows in the canonical fixture
// have fromYear populated, so the sort collapses to alphabetical by
// displayName for the only multi-spouse person (Albert de Gruyter).
const fixtureNodes = canonicalFixture.nodes as unknown as PersonNodeDTO[];
const fixtureEdges = canonicalFixture.edges as unknown as RelationshipDTO[];
const layout = buildLayout(fixtureNodes, fixtureEdges);
const partners = new Map<string, Set<string>>();
for (const e of fixtureEdges) {
if (e.relationType !== 'SPOUSE_OF') continue;
addPartner(partners, e.personId, e.relatedPersonId);
addPartner(partners, e.relatedPersonId, e.personId);
}
const [multiPersonId, multiPartnerSet] =
[...partners.entries()].find(([, set]) => set.size >= 3) ?? [];
expect(multiPersonId).toBeDefined();
if (!multiPersonId || !multiPartnerSet) return;
const focalX = layout.positions.get(multiPersonId)!.x;
const partnerNames = new Map(
fixtureNodes.filter((n) => multiPartnerSet.has(n.id)).map((n) => [n.id, n.displayName])
);
// Spouses ordered alphabetically by displayName, all to the right of focal.
const sorted = [...multiPartnerSet].sort((a, b) =>
(partnerNames.get(a) ?? '').localeCompare(partnerNames.get(b) ?? '')
);
let prevX = focalX;
for (const id of sorted) {
const x = layout.positions.get(id)!.x;
expect(x).toBeGreaterThan(prevX);
prevX = x;
}
});
});

View File

@@ -23,7 +23,13 @@ export type Layout = {
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = new Map<string, string[]>();
const spousePairs = 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) {
switch (e.relationType) {
@@ -32,8 +38,13 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
spousePairs.set(e.personId, e.relatedPersonId);
spousePairs.set(e.relatedPersonId, e.personId);
// 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;
}
}
@@ -77,17 +88,19 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
changed = true;
}
}
for (const [a, b] of spousePairs) {
const ra = rank.get(a) ?? 0;
const rb = rank.get(b) ?? 0;
const m = Math.max(ra, rb);
if (!locked.has(a) && ra < m) {
rank.set(a, m);
changed = true;
}
if (!locked.has(b) && rb < m) {
rank.set(b, m);
changed = true;
for (const [a, partners] of spousePairs) {
for (const b of partners) {
const ra = rank.get(a) ?? 0;
const rb = rank.get(b) ?? 0;
const m = Math.max(ra, rb);
if (!locked.has(a) && ra < m) {
rank.set(a, m);
changed = true;
}
if (!locked.has(b) && rb < m) {
rank.set(b, m);
changed = true;
}
}
}
if (!changed) break;
@@ -172,25 +185,39 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
);
}
// Step 2 + 3: handle loose nodes.
// 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 spouse = spousePairs.get(id);
const spouseLookup = spouse ? memberLookup.get(spouse) : undefined;
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 (spouseLookup && spouseLookup.parented) {
// Spouse is parented — attach this loose node next to them on
// the outer edge of their sibling block so the marriage line
// is short and the sibling order is preserved.
const block = blocksByKey.get(spouseLookup.key)!;
const spouseIdx = block.members.findIndex((m) => m.id === spouse);
const insertOnRight = spouseIdx >= block.members.length / 2;
const insertAt = insertOnRight ? spouseIdx + 1 : spouseIdx;
block.members.splice(insertAt, 0, { id, parented: false });
memberLookup.set(id, { key: spouseLookup.key, parented: false });
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 {
// No usable parented spouse: put in its own loose block. We
// merge dual-loose spouse pairs in the next pass.
const blockKey = `__loose__${id}`;
blocksByKey.set(blockKey, {
members: [{ id, parented: false }],
@@ -200,24 +227,91 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
}
}
// Merge dual-loose spouse blocks into a single 2-person block.
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 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 }))
);
}
// 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 spouse = spousePairs.get(member.id);
if (!spouse) continue;
const spouseLookup = memberLookup.get(spouse);
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);
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;
@@ -277,3 +371,27 @@ function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
if (arr) arr.push(value);
else map.set(key, [value]);
}
function mapAddToSet<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 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);
}