Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d93d492d | ||
|
|
2097dddf3a | ||
|
|
585f28cd23 | ||
|
|
2c18cb8b0d | ||
|
|
655f0c3531 | ||
|
|
e7931335ce | ||
|
|
89bb0b5d65 | ||
|
|
b8ad64dd13 | ||
|
|
9bdd9fb3a5 | ||
|
|
52e48a6b8c | ||
|
|
fd624f6ec8 | ||
|
|
6d8655bad1 | ||
|
|
5167a2ae18 | ||
|
|
4f07527b0f | ||
|
|
0c5f56e9d1 | ||
|
|
652100a9c2 | ||
|
|
557f37be54 | ||
|
|
2a462d0a7c | ||
|
|
36bd7e0414 | ||
|
|
6970cc95fb |
@@ -103,6 +103,30 @@ _See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
|
||||
|
||||
---
|
||||
|
||||
## Stammbaum (Family-Tree Layout) Terms
|
||||
|
||||
**Stammbaum** `[user-facing]` — the genealogy / family-tree view of the archive, accessible at `/stammbaum`. Renders every `Person` as a node positioned by `PersonRelationship` edges (`PARENT_OF`, `SPOUSE_OF`) into rows that correspond to generations. The browser-side layout pipeline lives at `frontend/src/lib/person/genealogy/`.
|
||||
_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).
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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°).
|
||||
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
|
||||
**canonical fixture** (Stammbaum) — `frontend/src/lib/person/genealogy/__fixtures__/stammbaum.json`, a pinned `/api/network` snapshot used by `buildLayout.test.ts` for structural-property assertions against real data. Captured locally via `frontend/scripts/capture-network-fixture.mjs` with explicit credentials and a localhost backend; never invoked from CI. Sanity-gated by `validateFixture.ts` (≥ 50 nodes / ≥ 5 generations / ≥ 1 SPOUSE_OF edge / ≥ 1 multi-spouse person).
|
||||
|
||||
---
|
||||
|
||||
## Other Domain Terms
|
||||
|
||||
**Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline.
|
||||
@@ -145,4 +169,3 @@ _Terms flagged as potentially ambiguous that have not yet been formally defined
|
||||
|
||||
- Terms surfaced by Epic 1 audit findings (#388–#392) — review audit reports under `docs/audits/` when available and add any term flagged as ambiguous.
|
||||
- `OcrBatchService` vs `OcrAsyncRunner` — both handle async OCR orchestration; their division of responsibility should be clarified here.
|
||||
- `Stammbaum` — the genealogy tree view; relationship to `PersonRelationship` entity.
|
||||
|
||||
162
docs/adr/026-stammbaum-layout-in-house.md
Normal file
162
docs/adr/026-stammbaum-layout-in-house.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 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. **Reproducible verification query**
|
||||
(PostgreSQL — paste into a read-only psql session against
|
||||
`familienarchiv_archive`):
|
||||
|
||||
```sql
|
||||
-- AC3 reachability probe. Returns one row per unseeded person who has at
|
||||
-- least one parent edge whose parent IS seeded. A non-zero count means the
|
||||
-- AC3 layout branch becomes reachable for that person and ADR-026 should
|
||||
-- be revisited. Last run May 2026: 0 rows.
|
||||
SELECT p.id, p.display_name
|
||||
FROM persons p
|
||||
WHERE p.generation IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM person_relationships r
|
||||
JOIN persons parent ON parent.id = r.person_id
|
||||
WHERE r.relation_type = 'PARENT_OF'
|
||||
AND r.related_person_id = p.id
|
||||
AND parent.generation IS NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
The same predicate is encoded as a unit-testable JavaScript function — see
|
||||
`findAc3Candidates()` in
|
||||
`frontend/src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs`,
|
||||
asserted against the committed canonical fixture by
|
||||
`validateFixture.test.ts`, and emitted as a stderr soft-warn by
|
||||
`frontend/scripts/capture-network-fixture.mjs` on every recapture. The SQL
|
||||
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()`
|
||||
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.
|
||||
- **Revisit cadence.** Re-evaluate dagre adoption on the first canonical
|
||||
fixture refresh that hits AC3, OR by 2027-05-01 at the latest. Owner: Felix
|
||||
Brandt.
|
||||
@@ -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).
|
||||
214
frontend/scripts/capture-network-fixture.mjs
Normal file
214
frontend/scripts/capture-network-fixture.mjs
Normal file
@@ -0,0 +1,214 @@
|
||||
#!/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';
|
||||
|
||||
import { findAc3Candidates } from '../src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
||||
const EMAIL = process.env.CAPTURE_EMAIL;
|
||||
const PASSWORD = process.env.CAPTURE_PASSWORD;
|
||||
|
||||
// Preflight guards: this script writes the canonical Stammbaum fixture from
|
||||
// a *running* backend with admin-shaped credentials. Two slips would be
|
||||
// silent disasters — running with default creds against staging/prod, or
|
||||
// running with a typo'd BACKEND_URL that happens to resolve. Refuse both
|
||||
// before sending a single byte.
|
||||
preflight();
|
||||
|
||||
function preflight() {
|
||||
const failures = [];
|
||||
if (!EMAIL) {
|
||||
failures.push('CAPTURE_EMAIL must be set explicitly (no default).');
|
||||
}
|
||||
if (!PASSWORD) {
|
||||
failures.push('CAPTURE_PASSWORD must be set explicitly (no default).');
|
||||
}
|
||||
if (!isLocalhost(BACKEND_URL)) {
|
||||
failures.push(
|
||||
`BACKEND_URL must point at localhost / 127.0.0.1 (got: ${BACKEND_URL}). ` +
|
||||
'This script is local-only.'
|
||||
);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error('Preflight failed:');
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalhost(url) {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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, 1 multi-spouse person). The point
|
||||
// is catching a silently empty backend or a structurally-regressed snapshot,
|
||||
// not strict size validation; raise these only if the canonical graph grows
|
||||
// substantially. The matching gates in
|
||||
// src/lib/person/genealogy/__fixtures__/validateFixture.ts cover unit-test
|
||||
// coverage; the floors are intentionally duplicated as numeric constants
|
||||
// (the AC3 revisit predicate, by contrast, now lives in one shared module).
|
||||
const MIN_NODES = 50;
|
||||
const MIN_GENERATIONS = 5;
|
||||
const MIN_SPOUSE_OF_EDGES = 1;
|
||||
const MIN_MULTI_SPOUSE_PERSONS = 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 multiSpousePersons = countMultiSpousePersons(spouseEdges);
|
||||
|
||||
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 (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) {
|
||||
failures.push(
|
||||
`expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` +
|
||||
`got ${multiSpousePersons}`
|
||||
);
|
||||
}
|
||||
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),
|
||||
multiSpousePersons
|
||||
};
|
||||
}
|
||||
|
||||
function countMultiSpousePersons(spouseEdges) {
|
||||
const partners = new Map();
|
||||
for (const e of spouseEdges) {
|
||||
if (!e.personId || !e.relatedPersonId) continue;
|
||||
mapAddToSet(partners, e.personId, e.relatedPersonId);
|
||||
mapAddToSet(partners, e.relatedPersonId, e.personId);
|
||||
}
|
||||
let count = 0;
|
||||
for (const set of partners.values()) {
|
||||
if (set.size >= 2) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function mapAddToSet(map, key, value) {
|
||||
const s = map.get(key);
|
||||
if (s) s.add(value);
|
||||
else map.set(key, new Set([value]));
|
||||
}
|
||||
|
||||
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 multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}`
|
||||
);
|
||||
warnIfAc3Reachable(network);
|
||||
}
|
||||
|
||||
function warnIfAc3Reachable(network) {
|
||||
const candidates = findAc3Candidates(network);
|
||||
if (candidates.length === 0) return;
|
||||
// Soft, non-blocking. The ADR-026 deferral decision says: revisit the
|
||||
// dagre adoption choice as soon as the canonical fixture starts to
|
||||
// contain a parented unseeded spouse. This warning is that signal —
|
||||
// fail-open so a recapture still writes the fixture (the human needs
|
||||
// to see the data to decide), but loud enough that nobody can miss it.
|
||||
console.error('');
|
||||
console.error(
|
||||
`⚠ AC3 revisit trigger reached (ADR-026): ${candidates.length} unseeded ` +
|
||||
'person(s) with a seeded parent AND a SPOUSE_OF edge in the captured graph.'
|
||||
);
|
||||
console.error(` Candidates: ${candidates.join(', ')}`);
|
||||
console.error(
|
||||
' Action: re-evaluate the dagre adoption deferral. See ADR-026 §Notes ' +
|
||||
'for the assessor + cadence.'
|
||||
);
|
||||
}
|
||||
|
||||
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,42 @@ 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');
|
||||
// Cycle-2 follow-up from Sara: codify the colour-token side of the
|
||||
// WCAG 1.4.11 contrast contract at the unit level. The permanent axe-
|
||||
// core gate lives in #692; this assertion prevents an accidental
|
||||
// "neutralise the dot" diff (e.g. swap to var(--c-ink-3) or a literal
|
||||
// light token) from stripping the 3:1 contrast guarantee before #692
|
||||
// ships.
|
||||
expect(dot!.getAttribute('fill')).toBe('var(--c-primary)');
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
// The capture script is a local-only utility (never invoked from CI) but its
|
||||
// failure modes are load-bearing for the canonical fixture lifecycle: a slip
|
||||
// in env vars must not silently authenticate to a non-local backend or
|
||||
// overwrite the canonical snapshot with a vacuous response. These guards are
|
||||
// the public contract.
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SCRIPT_PATH = resolve(HERE, '../../../../../scripts/capture-network-fixture.mjs');
|
||||
|
||||
function runScript(env: Record<string, string | undefined>) {
|
||||
return spawnSync(process.execPath, [SCRIPT_PATH], {
|
||||
env: { PATH: process.env.PATH, ...env } as NodeJS.ProcessEnv,
|
||||
encoding: 'utf8',
|
||||
timeout: 5_000
|
||||
});
|
||||
}
|
||||
|
||||
describe('capture-network-fixture.mjs — preflight guards', () => {
|
||||
it('refuses_to_run_without_explicit_CAPTURE_EMAIL', () => {
|
||||
const result = runScript({
|
||||
CAPTURE_PASSWORD: 'pw',
|
||||
BACKEND_URL: 'http://localhost:8080'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/CAPTURE_EMAIL/);
|
||||
});
|
||||
|
||||
it('refuses_to_run_without_explicit_CAPTURE_PASSWORD', () => {
|
||||
const result = runScript({
|
||||
CAPTURE_EMAIL: 'someone@example.test',
|
||||
BACKEND_URL: 'http://localhost:8080'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/CAPTURE_PASSWORD/);
|
||||
});
|
||||
|
||||
it('refuses_to_run_when_BACKEND_URL_is_not_localhost', () => {
|
||||
const result = runScript({
|
||||
CAPTURE_EMAIL: 'someone@example.test',
|
||||
CAPTURE_PASSWORD: 'pw',
|
||||
BACKEND_URL: 'https://staging.example.com'
|
||||
});
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toMatch(/BACKEND_URL/);
|
||||
expect(result.stderr).toMatch(/localhost|127\.0\.0\.1/);
|
||||
});
|
||||
|
||||
it('accepts_127_0_0_1_as_BACKEND_URL', () => {
|
||||
// With creds + a localhost backend the preflight passes; the fetch
|
||||
// then fails (no server) — but the exit message must NOT mention the
|
||||
// preflight guards, proving they let the run through.
|
||||
const result = runScript({
|
||||
CAPTURE_EMAIL: 'someone@example.test',
|
||||
CAPTURE_PASSWORD: 'pw',
|
||||
BACKEND_URL: 'http://127.0.0.1:65500'
|
||||
});
|
||||
expect(result.stderr).not.toMatch(/BACKEND_URL.*localhost/);
|
||||
expect(result.stderr).not.toMatch(/CAPTURE_EMAIL/);
|
||||
expect(result.stderr).not.toMatch(/CAPTURE_PASSWORD/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// AC3 = "unseeded loose spouse whose parents are in the graph" (ADR-026).
|
||||
// Returns the IDs of every captured person matching the predicate. A non-
|
||||
// empty result is the documented revisit trigger for the dagre deferral
|
||||
// decision: the canonical-fixture unit suite asserts an empty result against
|
||||
// the committed JSON, and `frontend/scripts/capture-network-fixture.mjs`
|
||||
// soft-warns to stderr (does NOT fail capture) so a human-in-the-loop notices
|
||||
// when the layout branch becomes reachable.
|
||||
//
|
||||
// Lives as a plain ESM .mjs module so both the Node-run capture script and
|
||||
// the TypeScript validator/test suite can import it without a build step —
|
||||
// single source of truth for the predicate.
|
||||
|
||||
/**
|
||||
* @typedef {{ relationType?: string, personId?: string, relatedPersonId?: string }} Edge
|
||||
* @typedef {{ id?: string, generation?: number | null }} Node
|
||||
* @typedef {{ nodes?: Node[], edges?: Edge[] }} NetworkShape
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {NetworkShape} network
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findAc3Candidates(network) {
|
||||
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
|
||||
const edges = Array.isArray(network.edges) ? network.edges : [];
|
||||
const generationById = new Map();
|
||||
for (const n of nodes) {
|
||||
if (n.id) generationById.set(n.id, n.generation);
|
||||
}
|
||||
|
||||
const hasSpouseEdge = new Set();
|
||||
const seededParentByChild = new Map();
|
||||
for (const e of edges) {
|
||||
if (!e.personId || !e.relatedPersonId) continue;
|
||||
if (e.relationType === 'SPOUSE_OF') {
|
||||
hasSpouseEdge.add(e.personId);
|
||||
hasSpouseEdge.add(e.relatedPersonId);
|
||||
continue;
|
||||
}
|
||||
if (e.relationType === 'PARENT_OF') {
|
||||
const parentGen = generationById.get(e.personId);
|
||||
if (parentGen != null) seededParentByChild.set(e.relatedPersonId, true);
|
||||
}
|
||||
}
|
||||
|
||||
const matches = [];
|
||||
for (const n of nodes) {
|
||||
if (!n.id) continue;
|
||||
if (n.generation != null) continue;
|
||||
if (!seededParentByChild.get(n.id)) continue;
|
||||
if (!hasSpouseEdge.has(n.id)) continue;
|
||||
matches.push(n.id);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
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
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateFixture, findAc3Candidates } from './validateFixture';
|
||||
import canonicalFixture from './stammbaum.json';
|
||||
|
||||
// The fixture validator is the load-bearing contract for the canonical
|
||||
// Stammbaum snapshot: every gate here corresponds to an invariant that
|
||||
// buildLayout.test.ts relies on. Adding or removing a gate without updating
|
||||
// these tests is the failure we want to catch.
|
||||
|
||||
// Lets `generation: null` typecheck without an inline `as number | null`
|
||||
// cast — the AC3 predicate cares specifically about the unseeded-node
|
||||
// branch, so the test fixtures need to express it directly.
|
||||
type TestNode = { id: string; generation: number | null };
|
||||
|
||||
function networkWithNodes(count: number) {
|
||||
return {
|
||||
nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}`, generation: i % 6 })),
|
||||
edges: []
|
||||
};
|
||||
}
|
||||
|
||||
function spouseEdge(a: string, b: string) {
|
||||
return {
|
||||
id: `${a}|${b}`,
|
||||
personId: a,
|
||||
relatedPersonId: b,
|
||||
personDisplayName: '',
|
||||
relatedPersonDisplayName: '',
|
||||
relationType: 'SPOUSE_OF'
|
||||
};
|
||||
}
|
||||
|
||||
function parentEdge(parent: string, child: string) {
|
||||
return {
|
||||
id: `${parent}>${child}`,
|
||||
personId: parent,
|
||||
relatedPersonId: child,
|
||||
personDisplayName: '',
|
||||
relatedPersonDisplayName: '',
|
||||
relationType: 'PARENT_OF'
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateFixture', () => {
|
||||
it('passes_for_the_canonical_fixture', () => {
|
||||
expect(() => validateFixture(canonicalFixture)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects_a_fixture_below_the_min_node_floor', () => {
|
||||
expect(() => validateFixture(networkWithNodes(10))).toThrow(/>= 50 nodes/);
|
||||
});
|
||||
|
||||
it('rejects_a_fixture_with_no_multi_spouse_person', () => {
|
||||
// 50 nodes, 5 generations, several SPOUSE_OF edges — but every spouse
|
||||
// edge connects a different pair, so nobody has more than one partner.
|
||||
// Without the multi-spouse floor this would silently pass and the
|
||||
// canonical_fixture_assigns_a_position_to_every_node_with_multiple_spouses
|
||||
// test in buildLayout.test.ts would degrade to vacuous truth.
|
||||
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
|
||||
const edges = [spouseEdge('n0', 'n1'), spouseEdge('n2', 'n3'), spouseEdge('n4', 'n5')];
|
||||
expect(() => validateFixture({ nodes, edges })).toThrow(
|
||||
/>= 1 person with multiple SPOUSE_OF edges/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts_a_fixture_where_one_person_has_multiple_spouse_edges', () => {
|
||||
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
|
||||
const edges = [spouseEdge('n0', 'n1'), spouseEdge('n0', 'n2')];
|
||||
expect(() => validateFixture({ nodes, edges })).not.toThrow();
|
||||
});
|
||||
|
||||
it('counts_multi_spouse_persons_via_either_edge_direction', () => {
|
||||
const nodes = Array.from({ length: 50 }, (_, i) => ({ id: `n${i}`, generation: i % 5 }));
|
||||
// n5 is the related party in both edges — still counts as multi-spouse.
|
||||
const edges = [spouseEdge('n1', 'n5'), spouseEdge('n2', 'n5')];
|
||||
expect(() => validateFixture({ nodes, edges })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycle-2)', () => {
|
||||
// AC3 = "unseeded loose spouse whose parents are in the graph". Per ADR-026
|
||||
// this is the predicate that reopens the dagre decision once it appears
|
||||
// against the canonical fixture. The capture script runs this on every
|
||||
// recapture and warns to stderr (soft, non-blocking) so the human-in-the-
|
||||
// loop notices the moment the deferral is no longer free.
|
||||
|
||||
it('finds_no_candidate_in_the_canonical_fixture_today', () => {
|
||||
// Anchors ADR-026's "Last run May 2026: 0 rows" annotation against the
|
||||
// committed fixture. Fails the moment the captured graph starts to
|
||||
// contain the AC3 shape — which is exactly the revisit trigger.
|
||||
expect(findAc3Candidates(canonicalFixture)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags_an_unseeded_person_whose_seeded_parent_is_in_graph_and_who_has_a_spouse', () => {
|
||||
const nodes: TestNode[] = [
|
||||
{ id: 'parent', generation: 2 },
|
||||
{ id: 'child', generation: null },
|
||||
{ id: 'spouse', generation: 3 }
|
||||
];
|
||||
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
||||
expect(findAc3Candidates({ nodes, edges })).toEqual(['child']);
|
||||
});
|
||||
|
||||
it('ignores_an_unseeded_person_with_no_spouse_edge', () => {
|
||||
// Unseeded + seeded parent in graph, but no SPOUSE_OF — not AC3 (the
|
||||
// layout branch that hurts is the *loose spouse* branch). The capture
|
||||
// warn must not fire here.
|
||||
const nodes: TestNode[] = [
|
||||
{ id: 'parent', generation: 2 },
|
||||
{ id: 'child', generation: null }
|
||||
];
|
||||
const edges = [parentEdge('parent', 'child')];
|
||||
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores_an_unseeded_person_whose_parent_is_also_unseeded', () => {
|
||||
// "Parents in graph" in the AC3 sense means at least one *seeded*
|
||||
// parent. If both parent and child are unseeded the heuristic
|
||||
// fallback already handles the case without AC3 ever firing.
|
||||
const nodes: TestNode[] = [
|
||||
{ id: 'parent', generation: null },
|
||||
{ id: 'child', generation: null },
|
||||
{ id: 'spouse', generation: 3 }
|
||||
];
|
||||
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
||||
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores_a_seeded_person_with_seeded_parents_and_a_spouse', () => {
|
||||
const nodes = [
|
||||
{ id: 'parent', generation: 2 },
|
||||
{ id: 'child', generation: 3 },
|
||||
{ id: 'spouse', generation: 3 }
|
||||
];
|
||||
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
||||
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Sanity floors for the canonical Stammbaum fixture. Owned by the fixture
|
||||
// directory rather than the capture script so the contract lives next to the
|
||||
// JSON it constrains and is reachable from unit tests.
|
||||
//
|
||||
// Calibrated against the May-2026 canonical dataset (62 nodes, 5 generations,
|
||||
// 28 SPOUSE_OF edges, 1 multi-spouse person). The point of these gates is
|
||||
// catching a silently empty backend or a structurally regressed snapshot, not
|
||||
// strict size validation; raise them only if the canonical graph grows
|
||||
// substantially.
|
||||
|
||||
export const MIN_NODES = 50;
|
||||
export const MIN_GENERATIONS = 5;
|
||||
export const MIN_SPOUSE_OF_EDGES = 1;
|
||||
export const MIN_MULTI_SPOUSE_PERSONS = 1;
|
||||
|
||||
type Edge = { relationType?: string; personId?: string; relatedPersonId?: string };
|
||||
type Node = { id?: string; generation?: number | null };
|
||||
export type NetworkShape = { nodes?: Node[]; edges?: Edge[] };
|
||||
|
||||
// The AC3 revisit-trigger predicate lives in a plain .mjs module so the
|
||||
// Node-run capture script and this TypeScript validator share one
|
||||
// implementation. Re-exported here so consumers of the fixture-directory
|
||||
// barrel keep their existing import surface.
|
||||
export { findAc3Candidates } from './findAc3Candidates.mjs';
|
||||
|
||||
export type FixtureStats = {
|
||||
nodes: number;
|
||||
edges: number;
|
||||
spouseEdges: number;
|
||||
generations: number[];
|
||||
multiSpousePersons: number;
|
||||
};
|
||||
|
||||
export function validateFixture(network: NetworkShape): FixtureStats {
|
||||
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 multiSpousePersons = countMultiSpousePersons(spouseEdges);
|
||||
|
||||
const failures: string[] = [];
|
||||
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 (multiSpousePersons < MIN_MULTI_SPOUSE_PERSONS) {
|
||||
// buildLayout.test.ts asserts the multi-spouse property on this
|
||||
// fixture; a recapture that loses every multi-spouse person would
|
||||
// silently make that test vacuous. Fail loudly instead.
|
||||
failures.push(
|
||||
`expected >= ${MIN_MULTI_SPOUSE_PERSONS} person with multiple SPOUSE_OF edges, ` +
|
||||
`got ${multiSpousePersons}`
|
||||
);
|
||||
}
|
||||
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 as number) - (b as number)) as number[],
|
||||
multiSpousePersons
|
||||
};
|
||||
}
|
||||
|
||||
function countMultiSpousePersons(spouseEdges: Edge[]): number {
|
||||
const partners = new Map<string, Set<string>>();
|
||||
for (const e of spouseEdges) {
|
||||
if (!e.personId || !e.relatedPersonId) continue;
|
||||
addPartner(partners, e.personId, e.relatedPersonId);
|
||||
addPartner(partners, e.relatedPersonId, e.personId);
|
||||
}
|
||||
let count = 0;
|
||||
for (const set of partners.values()) {
|
||||
if (set.size >= 2) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
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]));
|
||||
}
|
||||
@@ -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,223 @@ 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);
|
||||
|
||||
// Tighter contract (Sara's cycle-2 follow-up): no third node may sit
|
||||
// at an x strictly between the two spouses on the same y. The integer-
|
||||
// slot adjacency check above (==NODE_W+COL_GAP) is correct today but
|
||||
// would silently pass if a future layout change introduced fractional
|
||||
// offsets and placed a node at a non-slot x between the spouses.
|
||||
const minX = Math.min(posA2.x, posB2.x);
|
||||
const maxX = Math.max(posA2.x, posB2.x);
|
||||
for (const [id, p] of layout.positions) {
|
||||
if (id === A2 || id === B2) continue;
|
||||
if (p.y !== posA2.y) continue;
|
||||
expect(p.x <= minX || p.x >= maxX).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
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[];
|
||||
|
||||
// Precondition: this test asserts the *fallback* branch of the
|
||||
// multi-spouse sort (fromYear ASC NULLS LAST, displayName ASC), which
|
||||
// only collapses to alphabetical-by-displayName when every SPOUSE_OF
|
||||
// row is null on fromYear. The day any canonical row gets a year
|
||||
// backfilled, this test would silently start asserting year-order;
|
||||
// fail fast instead so the maintainer either updates the test or
|
||||
// splits into a year-branch / name-branch pair.
|
||||
const spouseEdgesWithYear = fixtureEdges.filter(
|
||||
(e) => e.relationType === 'SPOUSE_OF' && e.fromYear != null
|
||||
);
|
||||
expect(
|
||||
spouseEdgesWithYear,
|
||||
'Precondition violated: a canonical SPOUSE_OF row now carries fromYear. Update this test (or split into year-branch / name-branch).'
|
||||
).toHaveLength(0);
|
||||
|
||||
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,72 +38,18 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Two-stage rank assignment (#689):
|
||||
//
|
||||
// 1. Seed: every node with imported generation is locked at that rank.
|
||||
// The fallback heuristic never moves a locked rank, and spouse-pulldown
|
||||
// never pulls a locked rank.
|
||||
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank)
|
||||
// + 1, reading parent rank from the same unified map so an unseeded
|
||||
// child of a seeded G 2 parent correctly inherits rank 3. Spouse-
|
||||
// pulldown ties unseeded spouses to their deeper partner.
|
||||
// 3. Normalise: if any seeded rank is negative (a future G −1 ancestor),
|
||||
// shift the entire map so min(rank) == 0. No-op fast path covers
|
||||
// today's data.
|
||||
const rank = new Map<string, number>();
|
||||
const locked = new Set<string>();
|
||||
for (const n of allNodes) {
|
||||
if (n.generation != null) {
|
||||
rank.set(n.id, n.generation);
|
||||
locked.add(n.id);
|
||||
} else {
|
||||
rank.set(n.id, 0);
|
||||
}
|
||||
}
|
||||
const maxIters = allNodes.length + 4;
|
||||
for (let it = 0; it < maxIters; it++) {
|
||||
let changed = false;
|
||||
for (const n of allNodes) {
|
||||
if (locked.has(n.id)) continue;
|
||||
const parents = childToParents.get(n.id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
let maxParentRank = -Infinity;
|
||||
for (const pid of parents) {
|
||||
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
|
||||
}
|
||||
const newRank = maxParentRank + 1;
|
||||
if ((rank.get(n.id) ?? 0) < newRank) {
|
||||
rank.set(n.id, newRank);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
let minRank = Infinity;
|
||||
for (const r of rank.values()) minRank = Math.min(minRank, r);
|
||||
if (minRank < 0) {
|
||||
const shift = -minRank;
|
||||
for (const [id, r] of rank) rank.set(id, r + shift);
|
||||
}
|
||||
const rank = assignRanks(allNodes, childToParents, spousePairs);
|
||||
|
||||
// Group by rank, then sort within rank by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
@@ -172,25 +124,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 +166,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;
|
||||
@@ -244,9 +277,19 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
||||
}
|
||||
}
|
||||
|
||||
// Bounding box around the actual content, then expanded to MIN dimensions
|
||||
// (so a single node doesn't get scaled up to fill the canvas). The viewBox
|
||||
// is centered on the content.
|
||||
const viewBox = computeViewBox(positions);
|
||||
return { positions, generations, ...viewBox };
|
||||
}
|
||||
|
||||
// Bounding box around the actual content, expanded to MIN dimensions (so a
|
||||
// single node doesn't get scaled up to fill the canvas) and centered on the
|
||||
// content's midpoint.
|
||||
function computeViewBox(positions: Map<string, { x: number; y: number }>): {
|
||||
viewX: number;
|
||||
viewY: number;
|
||||
viewW: number;
|
||||
viewH: number;
|
||||
} {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
@@ -269,7 +312,76 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
||||
const viewH = Math.max(contentH + 2 * VIEWBOX_PAD, MIN_VIEWBOX_H);
|
||||
const viewX = minX + contentW / 2 - viewW / 2;
|
||||
const viewY = minY + contentH / 2 - viewH / 2;
|
||||
return { positions, generations, viewX, viewY, viewW, viewH };
|
||||
return { viewX, viewY, viewW, viewH };
|
||||
}
|
||||
|
||||
// Two-stage rank assignment (#689):
|
||||
//
|
||||
// 1. Seed: every node with imported generation is locked at that rank. The
|
||||
// fallback heuristic never moves a locked rank, and spouse-pulldown never
|
||||
// pulls a locked rank.
|
||||
// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank) + 1,
|
||||
// reading parent rank from the same unified map so an unseeded child of a
|
||||
// seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties
|
||||
// unseeded spouses to their deeper partner.
|
||||
// 3. Normalise: if any seeded rank is negative (a future G −1 ancestor), shift
|
||||
// the entire map so min(rank) == 0. No-op fast path covers today's data.
|
||||
function assignRanks(
|
||||
allNodes: PersonNodeDTO[],
|
||||
childToParents: Map<string, string[]>,
|
||||
spousePairs: Map<string, Set<string>>
|
||||
): Map<string, number> {
|
||||
const rank = new Map<string, number>();
|
||||
const locked = new Set<string>();
|
||||
for (const n of allNodes) {
|
||||
if (n.generation != null) {
|
||||
rank.set(n.id, n.generation);
|
||||
locked.add(n.id);
|
||||
} else {
|
||||
rank.set(n.id, 0);
|
||||
}
|
||||
}
|
||||
const maxIters = allNodes.length + 4;
|
||||
for (let it = 0; it < maxIters; it++) {
|
||||
let changed = false;
|
||||
for (const n of allNodes) {
|
||||
if (locked.has(n.id)) continue;
|
||||
const parents = childToParents.get(n.id) ?? [];
|
||||
if (parents.length === 0) continue;
|
||||
let maxParentRank = -Infinity;
|
||||
for (const pid of parents) {
|
||||
maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0);
|
||||
}
|
||||
const newRank = maxParentRank + 1;
|
||||
if ((rank.get(n.id) ?? 0) < newRank) {
|
||||
rank.set(n.id, newRank);
|
||||
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;
|
||||
}
|
||||
let minRank = Infinity;
|
||||
for (const r of rank.values()) minRank = Math.min(minRank, r);
|
||||
if (minRank < 0) {
|
||||
const shift = -minRank;
|
||||
for (const [id, r] of rank) rank.set(id, r + shift);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||
@@ -277,3 +389,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