// Domain-aware construction of the genealogy "family forest" (#724). // // This module owns every piece of genealogy knowledge the layout needs — // spouse runs, birth-year ordering, structural-owner selection, intra-family // marriage resolution and cross-links — and flattens it into the abstract // { id, width, children } nodes that the domain-agnostic tidyTree.ts packs. // buildLayout.ts orchestrates the two and maps run positions back to persons. 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 * (hierarchy) position. Earlier birth year wins; a missing birth year sorts * last; ties break on the stable id. Shared by the cycle, cross-link and * intra-family paths so the rule is defined exactly once. */ export function pickStructuralOwner( a: { id: string; birthYear?: number | null }, b: { id: string; birthYear?: number | null } ): string { const ra = ownerRank(a); const rb = ownerRank(b); if (ra !== rb) return ra < rb ? a.id : b.id; return a.id <= b.id ? a.id : b.id; } // Lower is "more owning". Missing birth year → +Infinity (sorts last). 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)!); } // Sibling/branch order: birthYear ASC NULLS LAST → displayName → id. Applied // to roots and to every unit's children so x never depends on Map order. const branchOrder = (u: Unit, v: Unit) => { const a = byId.get(u.id)!; const b = byId.get(v.id)!; const ya = a.birthYear ?? Number.POSITIVE_INFINITY; const yb = b.birthYear ?? Number.POSITIVE_INFINITY; if (ya !== yb) return ya - yb; return a.displayName.localeCompare(b.displayName) || (u.id < v.id ? -1 : 1); }; roots.sort(branchOrder); for (const u of unitObj.values()) u.children.sort(branchOrder); // --- 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 };