feat(stammbaum): replace per-generation packer with tidy-tree orchestration (#724)

buildLayout now builds the family forest, packs it bottom-up via tidyTree, and
maps each unit's run x back to per-person positions (x from structure, y from
rank). assignRanks, the generations map, and computeViewBox are reused
unchanged. The unknown-id guard now covers PARENT_OF as well as SPOUSE_OF, and
displaced cross-level edges are exposed as crossLinks for distinct rendering.
The ~210-line block packer (and its block/merge helpers) is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-04 13:25:20 +02:00
committed by marcel
parent add619d81d
commit e48c794c12

View File

@@ -1,4 +1,6 @@
import type { components } from '$lib/generated/api';
import { buildFamilyForest, type Unit } from './familyForest';
import { layoutForest, type TidyNode } from './tidyTree';
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
type RelationshipDTO = components['schemas']['RelationshipDTO'];
@@ -14,6 +16,9 @@ export const MIN_VIEWBOX_H = 800;
export type Layout = {
positions: Map<string, { x: number; y: number }>;
generations: Map<number, string[]>;
// Displaced parent→child edges that span structural levels: the connector
// renders these with a distinct dash (never the ended-marriage 4 4 cadence).
crossLinks: { parentId: string; childId: string }[];
viewX: number;
viewY: number;
viewW: number;
@@ -21,37 +26,35 @@ export type Layout = {
};
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
const parentToChildren = new Map<string, string[]>();
const childToParents = 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) {
// Defensive guard against edges referencing IDs outside the node list
// (stale or partial graph snapshots) — applied to BOTH edge types so an
// unknown id is never dereferenced into an undefined position.
if (!allNodeIds.has(e.personId) || !allNodeIds.has(e.relatedPersonId)) continue;
switch (e.relationType) {
case 'PARENT_OF':
mapPush(parentToChildren, e.personId, e.relatedPersonId);
mapPush(childToParents, e.relatedPersonId, e.personId);
break;
case 'SPOUSE_OF':
// 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;
}
}
// Vertical (y) is still driven entirely by generation rank — #689 seeding
// and spouse pull-down are untouched.
const rank = assignRanks(allNodes, childToParents, spousePairs);
// Group by rank, then sort within rank by display name.
// Group by rank, then sort within rank by display name. Consumed by
// StammbaumTree's gutter rows / generation rail — kept with the same
// rank-keyed semantics and deliberately NOT folded into the x rewrite.
const generations = new Map<number, string[]>();
for (const n of allNodes) {
const g = rank.get(n.id) ?? 0;
@@ -67,218 +70,49 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
});
}
// Per-generation layout:
//
// 1. Build sibling-groups (children of the same parent set) — these become
// the layout "blocks" that are centred under their parents' midpoint.
// 2. Attach loose spouses (people with no parents in the graph but a
// spouse who *is* in a sibling group) on the outside of their partner,
// so the spouse line stays short and adjacent.
// 3. Merge dual-loose spouse pairs into a single 2-person block.
// 4. Centre each block such that its *parented* members average sits
// exactly under the parent midpoint (keeping all connectors at 90°),
// then pack blocks left-to-right.
type Block = {
members: { id: string; parented: boolean }[];
center: number;
};
// Horizontal (x): bottom-up tidy-tree over the family forest. Each unit's
// run width is its member count laid out card+gap; tidyTree assigns the
// run's left edge, and we spread the run's members rightward from there.
// y comes from rank (never tree depth), so a child seeded more than one
// generation below its parent simply gets a taller connector.
const forest = buildFamilyForest(allNodes, allEdges);
const toTidy = (u: Unit): TidyNode => ({
id: u.id,
width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP,
children: u.children.map(toTidy)
});
const runX = layoutForest(forest.roots.map(toTidy), COL_GAP);
const positions = new Map<string, { x: number; y: number }>();
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
for (let gi = 0; gi < sortedGens.length; gi++) {
const g = sortedGens[gi];
const ids = generations.get(g)!;
const y = g * (NODE_H + ROW_GAP);
const blocksByKey = new Map<string, Block>();
const memberLookup = new Map<string, { key: string; parented: boolean }>();
// Step 1: place every node with parents-in-graph into a sibling block.
for (const id of ids) {
const parents = childToParents.get(id) ?? [];
if (parents.length === 0) continue;
const blockKey = [...parents].sort().join('|');
let block = blocksByKey.get(blockKey);
if (!block) {
const parentCenters: number[] = [];
for (const pid of parents) {
const p = positions.get(pid);
if (p) parentCenters.push(p.x + NODE_W / 2);
}
const center =
parentCenters.length > 0
? parentCenters.reduce((a, b) => a + b, 0) / parentCenters.length
: 0;
block = { members: [], center };
blocksByKey.set(blockKey, block);
}
block.members.push({ id, parented: true });
memberLookup.set(id, { key: blockKey, parented: true });
}
// Sort members within each sibling block alphabetically.
for (const block of blocksByKey.values()) {
block.members.sort((a, b) =>
(byId.get(a.id)?.displayName ?? '').localeCompare(byId.get(b.id)?.displayName ?? '')
);
}
// 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 partners = spousePairs.get(id);
let parentedSpouse: string | undefined;
if (partners) {
for (const partnerId of partners) {
if (memberLookup.get(partnerId)?.parented) {
parentedSpouse = partnerId;
break;
}
}
}
if (parentedSpouse) {
const lookupKey = memberLookup.get(parentedSpouse)!.key;
mapPush(looseByParented, parentedSpouse, {
id,
fromYear: spouseFromYear.get(spousePairKey(id, parentedSpouse))
const place = (u: Unit) => {
const left = runX.get(u.id) ?? 0;
u.members.forEach((mid, i) => {
positions.set(mid, {
x: left + i * (NODE_W + COL_GAP),
y: (rank.get(mid) ?? 0) * (NODE_H + ROW_GAP)
});
memberLookup.set(id, { key: lookupKey, parented: false });
} else {
const blockKey = `__loose__${id}`;
blocksByKey.set(blockKey, {
members: [{ id, parented: false }],
center: 0
});
memberLookup.set(id, { key: blockKey, parented: false });
}
}
u.children.forEach(place);
};
forest.roots.forEach(place);
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 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;
for (const block of ordered) {
const n = block.members.length;
const groupWidth = n * NODE_W + (n - 1) * COL_GAP;
const anchorIndices: number[] = [];
for (let i = 0; i < n; i++) {
if (block.members[i].parented) anchorIndices.push(i);
}
const avgAnchorIdx =
anchorIndices.length > 0
? anchorIndices.reduce((a, b) => a + b, 0) / anchorIndices.length
: (n - 1) / 2;
let groupLeft = block.center - NODE_W / 2 - avgAnchorIdx * (NODE_W + COL_GAP);
if (groupLeft < cursorRight + COL_GAP) groupLeft = cursorRight + COL_GAP;
for (let i = 0; i < n; i++) {
positions.set(block.members[i].id, {
x: groupLeft + i * (NODE_W + COL_GAP),
y
// Safety net: any node the forest did not place (it shouldn't leave any)
// still gets a deterministic slot rather than vanishing from the canvas.
let spare = runX.size;
for (const n of allNodes) {
if (positions.has(n.id)) continue;
positions.set(n.id, {
x: spare++ * (NODE_W + COL_GAP),
y: (rank.get(n.id) ?? 0) * (NODE_H + ROW_GAP)
});
}
cursorRight = groupLeft + groupWidth;
}
}
const crossLinks = forest.crossLinks
.filter((c) => !c.sameLevel)
.map(({ parentId, childId }) => ({ parentId, childId }));
const viewBox = computeViewBox(positions);
return { positions, generations, ...viewBox };
return { positions, generations, crossLinks, ...viewBox };
}
// Bounding box around the actual content, expanded to MIN dimensions (so a
@@ -395,21 +229,3 @@ function mapAddToSet<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
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);
}