diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts index 33ea1c51..ef086bf4 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.test.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.test.ts @@ -1,8 +1,52 @@ import { describe, it, expect } from 'vitest'; -import { pickStructuralOwner } from './familyForest'; +import { pickStructuralOwner, buildFamilyForest, type Unit } from './familyForest'; +import type { components } from '$lib/generated/api'; + +type PersonNodeDTO = components['schemas']['PersonNodeDTO']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; type Person = { id: string; birthYear?: number }; +function person(id: string, opts: { birthYear?: number; generation?: number } = {}): PersonNodeDTO { + const n: PersonNodeDTO = { id, displayName: id, familyMember: true }; + if (opts.birthYear != null) n.birthYear = opts.birthYear; + if (opts.generation != null) n.generation = opts.generation; + return n; +} + +function parent(p: string, c: string): RelationshipDTO { + return { + id: `${p}>${c}`, + personId: p, + relatedPersonId: c, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'PARENT_OF' + }; +} + +function spouse(a: string, b: string, fromYear?: number): RelationshipDTO { + return { + id: `${a}~${b}`, + personId: a, + relatedPersonId: b, + personDisplayName: '', + relatedPersonDisplayName: '', + relationType: 'SPOUSE_OF', + ...(fromYear != null ? { fromYear } : {}) + }; +} + +// Find the unit (anywhere in the forest) whose primary id matches. +function findUnit(roots: Unit[], primaryId: string): Unit | undefined { + for (const r of roots) { + if (r.id === primaryId) return r; + const found = findUnit(r.children, primaryId); + if (found) return found; + } + return undefined; +} + describe('pickStructuralOwner', () => { const p = (id: string, birthYear?: number): Person => ({ id, birthYear }); @@ -21,3 +65,46 @@ describe('pickStructuralOwner', () => { expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa'); }); }); + +describe('buildFamilyForest — loose-spouse absorption', () => { + it('absorbs a parentless spouse into the partner run; their child anchors to the couple', () => { + // A (founder) ⚭ S (married-in, no parents). Their child C. S has no + // ancestor subtree of its own, but C still hangs off the couple. + const forest = buildFamilyForest( + [ + person('A', { birthYear: 1900 }), + person('S', { birthYear: 1905 }), + person('C', { birthYear: 1930 }) + ], + [spouse('A', 'S'), parent('A', 'C'), parent('S', 'C')] + ); + + // One root unit (A), with S absorbed — S is not its own root. + expect(forest.roots.map((r) => r.id)).toEqual(['A']); + const a = findUnit(forest.roots, 'A')!; + expect(a.members).toEqual(['A', 'S']); + // The child anchors through the couple unit. + expect(a.children.map((u) => u.id)).toEqual(['C']); + // S is parentless, so no displaced cross-link. + expect(forest.crossLinks).toEqual([]); + }); + + it('keeps all marriages of a multi-spouse founder in one run (marriage-year order)', () => { + // Albert-like: one founder, three parentless wives. All absorbed into the + // founder run, ordered by marriage year NULLS LAST then displayName/id. + const forest = buildFamilyForest( + [ + person('alb', { birthYear: 1829 }), + person('w1925', { birthYear: 1900 }), + person('wNull', { birthYear: 1901 }), + person('w1910', { birthYear: 1902 }) + ], + [spouse('alb', 'w1925', 1925), spouse('alb', 'wNull'), spouse('alb', 'w1910', 1910)] + ); + + expect(forest.roots.map((r) => r.id)).toEqual(['alb']); + const alb = findUnit(forest.roots, 'alb')!; + // Founder first, then spouses by marriage year (1910, 1925, null last). + expect(alb.members).toEqual(['alb', 'w1910', 'w1925', 'wNull']); + }); +}); diff --git a/frontend/src/lib/person/genealogy/layout/familyForest.ts b/frontend/src/lib/person/genealogy/layout/familyForest.ts index 6feb0a5e..1fc2be4e 100644 --- a/frontend/src/lib/person/genealogy/layout/familyForest.ts +++ b/frontend/src/lib/person/genealogy/layout/familyForest.ts @@ -9,6 +9,32 @@ import type { components } from '$lib/generated/api'; type PersonNodeDTO = components['schemas']['PersonNodeDTO']; +type RelationshipDTO = components['schemas']['RelationshipDTO']; + +/** + * A family unit = one bloodline carrier (the primary) plus the spouse(s) + * absorbed into its run. `members[0]` is the primary; the rest are spouses in + * marriage-year order. `children` are the units anchored by the couple's + * offspring (the forest hierarchy). + */ +export type Unit = { + id: string; // == primary's id; also the tidyTree node id + members: string[]; // run order: [primary, ...absorbed spouses] + children: Unit[]; +}; + +/** + * A parent→child edge whose child is NOT positioned under that parent (the + * child lives in a spouse's run elsewhere). `sameLevel` is true when the + * displaced parent can be ordered adjacent to the child's anchor (a short, + * solid connector — the intra-family "adjacency" case); false marks a genuine + * cross-level link that must render with a distinct dash. + */ +export type CrossLink = { parentId: string; childId: string; sameLevel: boolean }; + +export type FamilyForest = { roots: Unit[]; crossLinks: CrossLink[] }; + +const ROOT_GROUP = '__ROOT__'; /** * Choose the structural owner of a couple: the spouse who keeps the bloodline @@ -31,4 +57,183 @@ function ownerRank(p: { birthYear?: number | null }): number { return p.birthYear == null ? Number.POSITIVE_INFINITY : p.birthYear; } +/** + * Build the family forest: assign every person to exactly one unit (a primary, + * or a spouse absorbed into a primary's run), wire the parent/child hierarchy, + * and record displaced parent edges as cross-links. The packer (tidyTree) and + * the orchestrator (buildLayout) consume the result; this module holds all the + * genealogy semantics. + */ +export function buildFamilyForest(nodes: PersonNodeDTO[], edges: RelationshipDTO[]): FamilyForest { + const byId = new Map(nodes.map((n) => [n.id, n])); + const allIds = new Set(nodes.map((n) => n.id)); + const parentToChildren = new Map(); + const childToParents = new Map(); + const spouses = new Map>(); + const spouseYear = new Map(); + + for (const e of edges) { + // Unknown-id guard for BOTH edge types: an edge to an id outside the node + // list is dropped, never dereferenced into an undefined position. + if (!allIds.has(e.personId) || !allIds.has(e.relatedPersonId)) continue; + if (e.relationType === 'PARENT_OF') { + push(parentToChildren, e.personId, e.relatedPersonId); + push(childToParents, e.relatedPersonId, e.personId); + } else if (e.relationType === 'SPOUSE_OF') { + addToSet(spouses, e.personId, e.relatedPersonId); + addToSet(spouses, e.relatedPersonId, e.personId); + spouseYear.set(pairKey(e.personId, e.relatedPersonId), e.fromYear ?? undefined); + } + } + + const hasParents = (id: string) => (childToParents.get(id)?.length ?? 0) > 0; + + // --- Absorption: decide who is absorbed into whose run. --- + const absorbedInto = new Map(); + const runOwner = (id: string): string => { + let cur = id; + const seen = new Set([cur]); + while (absorbedInto.has(cur)) { + cur = absorbedInto.get(cur)!; + if (seen.has(cur)) break; // defensive: never loop on a self-referential chain + seen.add(cur); + } + return cur; + }; + const owners = new Set(); + const canAbsorb = (x: string) => !absorbedInto.has(x) && !owners.has(x); + + for (const [a, b] of marriagesInOrder(spouses)) { + if (runOwner(a) === runOwner(b)) continue; + const aP = hasParents(a); + const bP = hasParents(b); + let owner: string; + let absorbed: string; + if (aP === bP) { + // Both parented (intra-family) or both parentless (dual-loose founders): + // the structural owner keeps the bloodline position. + owner = pickStructuralOwner(byId.get(a)!, byId.get(b)!); + absorbed = owner === a ? b : a; + } else if (aP) { + owner = a; + absorbed = b; + } else { + owner = b; + absorbed = a; + } + if (canAbsorb(absorbed)) { + const o = runOwner(owner); + absorbedInto.set(absorbed, o); + owners.add(o); + } else if (canAbsorb(owner)) { + const o = runOwner(absorbed); + absorbedInto.set(owner, o); + owners.add(o); + } + // else: both already fixed in other runs — the marriage still draws a + // plain spouse line via the connector; no absorption is forced. + } + + // --- Unit membership --- + const unitOf = new Map(); // person → primary id + for (const n of nodes) unitOf.set(n.id, runOwner(n.id)); + const primaries = nodes.map((n) => n.id).filter((id) => unitOf.get(id) === id); + + const absorbedOf = new Map(); + for (const k of absorbedInto.keys()) push(absorbedOf, runOwner(k), k); + + const membersOf = new Map(); + for (const p of primaries) { + const spousesOfP = (absorbedOf.get(p) ?? []) + .slice() + .sort((x, y) => spouseRun(p, x, y, spouseYear, byId)); + membersOf.set(p, [p, ...spousesOfP]); + } + + // hierarchy parent unit of a primary: the unit of its structural-owner parent + // (married co-parents share a unit anyway, so the choice is only material for + // the rare unmarried-co-parent case). + const hierParentUnit = (primary: string): string | null => { + const parents = childToParents.get(primary) ?? []; + if (parents.length === 0) return null; + let chosen = parents[0]; + for (const pa of parents.slice(1)) + chosen = pickStructuralOwner(byId.get(chosen)!, byId.get(pa)!); + const u = unitOf.get(chosen)!; + return u === primary ? null : u; // guard: a self-parent edge cannot anchor + }; + const groupKey = (primary: string) => hierParentUnit(primary) ?? ROOT_GROUP; + + // --- Build Unit objects + hierarchy --- + const unitObj = new Map(); + for (const p of primaries) unitObj.set(p, { id: p, members: membersOf.get(p)!, children: [] }); + const roots: Unit[] = []; + for (const p of primaries) { + const hp = hierParentUnit(p); + if (hp == null) roots.push(unitObj.get(p)!); + else unitObj.get(hp)!.children.push(unitObj.get(p)!); + } + + // --- Cross-links: displaced parent → absorbed-spouse edges. --- + const crossLinks: CrossLink[] = []; + for (const k of absorbedInto.keys()) { + const owner = runOwner(k); + const hu = hierParentUnit(owner); + for (const pa of childToParents.get(k) ?? []) { + const sameLevel = hu != null && groupKey(unitOf.get(pa)!) === groupKey(hu); + crossLinks.push({ parentId: pa, childId: k, sameLevel }); + } + } + + return { roots, crossLinks }; +} + +// Spouse-run comparator: marriage year ASC NULLS LAST, then displayName, then id. +function spouseRun( + primary: string, + x: string, + y: string, + spouseYear: Map, + byId: Map +): number { + const yx = spouseYear.get(pairKey(primary, x)) ?? Number.POSITIVE_INFINITY; + const yy = spouseYear.get(pairKey(primary, y)) ?? Number.POSITIVE_INFINITY; + if (yx !== yy) return yx - yy; + const nx = byId.get(x)?.displayName ?? ''; + const ny = byId.get(y)?.displayName ?? ''; + return nx.localeCompare(ny) || (x < y ? -1 : 1); +} + +// Unique undirected marriages in a deterministic (pair-key) order. +function marriagesInOrder(spouses: Map>): [string, string][] { + const seen = new Set(); + const out: [string, string][] = []; + for (const a of [...spouses.keys()].sort()) { + for (const b of [...spouses.get(a)!].sort()) { + const k = pairKey(a, b); + if (seen.has(k)) continue; + seen.add(k); + out.push(a < b ? [a, b] : [b, a]); + } + } + out.sort((m1, m2) => (pairKey(m1[0], m1[1]) < pairKey(m2[0], m2[1]) ? -1 : 1)); + return out; +} + +function push(map: Map, key: K, value: V) { + const arr = map.get(key); + if (arr) arr.push(value); + else map.set(key, [value]); +} + +function addToSet(map: Map>, key: K, value: V) { + const s = map.get(key); + if (s) s.add(value); + else map.set(key, new Set([value])); +} + +function pairKey(a: string, b: string): string { + return a < b ? `${a}|${b}` : `${b}|${a}`; +} + export type { PersonNodeDTO };