Compare commits
6 Commits
4f07527b0f
...
b8ad64dd13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8ad64dd13 | ||
|
|
9bdd9fb3a5 | ||
|
|
52e48a6b8c | ||
|
|
fd624f6ec8 | ||
|
|
6d8655bad1 | ||
|
|
5167a2ae18 |
@@ -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.
|
||||
|
||||
@@ -86,7 +86,27 @@ threshold, so `packBlocks.ts` is **not** yet warranted.
|
||||
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.
|
||||
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
|
||||
);
|
||||
```
|
||||
- **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()`
|
||||
|
||||
@@ -12,19 +12,60 @@ 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 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). The point is catching a silently
|
||||
// empty backend, not strict size validation; raise these only if the
|
||||
// canonical graph grows substantially.
|
||||
// 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 same gates are unit-tested at
|
||||
// src/lib/person/genealogy/__fixtures__/validateFixture.ts — keep both
|
||||
// definitions in sync.
|
||||
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();
|
||||
@@ -75,6 +116,7 @@ function validate(network) {
|
||||
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) {
|
||||
@@ -86,6 +128,12 @@ function validate(network) {
|
||||
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 - ')}`);
|
||||
}
|
||||
@@ -94,10 +142,31 @@ function validate(network) {
|
||||
nodes: nodes.length,
|
||||
edges: edges.length,
|
||||
spouseEdges: spouseEdges.length,
|
||||
generations: [...generations].sort((a, b) => a - b)
|
||||
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');
|
||||
@@ -111,7 +180,7 @@ async function main() {
|
||||
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')}`
|
||||
`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')}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateFixture } 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.
|
||||
|
||||
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'
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// 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[] };
|
||||
|
||||
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]));
|
||||
}
|
||||
@@ -274,6 +274,22 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
|
||||
// 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>>();
|
||||
|
||||
@@ -49,68 +49,7 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
||||
}
|
||||
}
|
||||
|
||||
// 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, 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);
|
||||
}
|
||||
const rank = assignRanks(allNodes, childToParents, spousePairs);
|
||||
|
||||
// Group by rank, then sort within rank by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
@@ -338,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;
|
||||
@@ -363,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) {
|
||||
|
||||
Reference in New Issue
Block a user