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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user