Compare commits
7 Commits
a5e3205520
...
4f07527b0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f07527b0f | ||
|
|
0c5f56e9d1 | ||
|
|
652100a9c2 | ||
|
|
557f37be54 | ||
|
|
2a462d0a7c | ||
|
|
36bd7e0414 | ||
|
|
6970cc95fb |
129
docs/adr/026-stammbaum-layout-in-house.md
Normal file
129
docs/adr/026-stammbaum-layout-in-house.md
Normal 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.
|
||||
@@ -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 & dark, resting state</h2>
|
||||
<p>Full tree canvas with Gen I–III nodes and the 268 px side panel showing Maria Raddatz (selected). Rendered at ~65 % scale. Light and dark stacked.</p>
|
||||
<p><em>Mockup <code><rect></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 -->
|
||||
|
||||
22
frontend/scripts/README.md
Normal file
22
frontend/scripts/README.md
Normal 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).
|
||||
121
frontend/scripts/capture-network-fixture.mjs
Normal file
121
frontend/scripts/capture-network-fixture.mjs
Normal 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);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
25
frontend/src/lib/person/genealogy/__fixtures__/README.md
Normal file
25
frontend/src/lib/person/genealogy/__fixtures__/README.md
Normal 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.
|
||||
1147
frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json
Normal file
1147
frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user