feat(stammbaum): buildFamilyForest with loose-spouse absorption + multi-spouse runs (#724)
Assigns every person to one unit: a primary, or a spouse absorbed into the primary's run (marriage-year order, #361 preserved). Wires the parent/child hierarchy from each primary's structural-owner parent and records displaced parent edges as cross-links (classified same-level vs cross-level for later distinct rendering). Unknown-id guard covers PARENT_OF and SPOUSE_OF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,52 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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 };
|
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', () => {
|
describe('pickStructuralOwner', () => {
|
||||||
const p = (id: string, birthYear?: number): Person => ({ id, birthYear });
|
const p = (id: string, birthYear?: number): Person => ({ id, birthYear });
|
||||||
|
|
||||||
@@ -21,3 +65,46 @@ describe('pickStructuralOwner', () => {
|
|||||||
expect(pickStructuralOwner(p('zzz'), p('aaa'))).toBe('aaa');
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,32 @@
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
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
|
* 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;
|
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<string, string[]>();
|
||||||
|
const childToParents = new Map<string, string[]>();
|
||||||
|
const spouses = new Map<string, Set<string>>();
|
||||||
|
const spouseYear = new Map<string, number | undefined>();
|
||||||
|
|
||||||
|
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<string, string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, string>(); // 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<string, string[]>();
|
||||||
|
for (const k of absorbedInto.keys()) push(absorbedOf, runOwner(k), k);
|
||||||
|
|
||||||
|
const membersOf = new Map<string, string[]>();
|
||||||
|
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<string, Unit>();
|
||||||
|
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<string, number | undefined>,
|
||||||
|
byId: Map<string, PersonNodeDTO>
|
||||||
|
): 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, Set<string>>): [string, string][] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||||
|
const arr = map.get(key);
|
||||||
|
if (arr) arr.push(value);
|
||||||
|
else map.set(key, [value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToSet<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 pairKey(a: string, b: string): string {
|
||||||
|
return a < b ? `${a}|${b}` : `${b}|${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
export type { PersonNodeDTO };
|
export type { PersonNodeDTO };
|
||||||
|
|||||||
Reference in New Issue
Block a user