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 type { components } from '$lib/generated/api';
|
||||||
|
import { buildFamilyForest, type Unit } from './familyForest';
|
||||||
|
import { layoutForest, type TidyNode } from './tidyTree';
|
||||||
|
|
||||||
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
type PersonNodeDTO = components['schemas']['PersonNodeDTO'];
|
||||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||||
@@ -14,6 +16,9 @@ export const MIN_VIEWBOX_H = 800;
|
|||||||
export type Layout = {
|
export type Layout = {
|
||||||
positions: Map<string, { x: number; y: number }>;
|
positions: Map<string, { x: number; y: number }>;
|
||||||
generations: Map<number, string[]>;
|
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;
|
viewX: number;
|
||||||
viewY: number;
|
viewY: number;
|
||||||
viewW: number;
|
viewW: number;
|
||||||
@@ -21,37 +26,35 @@ export type Layout = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO[]): Layout {
|
||||||
const parentToChildren = new Map<string, string[]>();
|
|
||||||
const childToParents = new Map<string, string[]>();
|
const childToParents = new Map<string, string[]>();
|
||||||
// spousePairs is a Set per person so multi-spouse cases (#361) preserve all
|
// spousePairs is a Set per person so multi-spouse cases (#361) preserve all
|
||||||
// marriages instead of having later edges silently clobber earlier ones.
|
// marriages instead of having later edges silently clobber earlier ones.
|
||||||
const spousePairs = new Map<string, Set<string>>();
|
const spousePairs = new Map<string, Set<string>>();
|
||||||
const allNodeIds = new Set(allNodes.map((n) => n.id));
|
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) {
|
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) {
|
switch (e.relationType) {
|
||||||
case 'PARENT_OF':
|
case 'PARENT_OF':
|
||||||
mapPush(parentToChildren, e.personId, e.relatedPersonId);
|
|
||||||
mapPush(childToParents, e.relatedPersonId, e.personId);
|
mapPush(childToParents, e.relatedPersonId, e.personId);
|
||||||
break;
|
break;
|
||||||
case 'SPOUSE_OF':
|
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.personId, e.relatedPersonId);
|
||||||
mapAddToSet(spousePairs, e.relatedPersonId, e.personId);
|
mapAddToSet(spousePairs, e.relatedPersonId, e.personId);
|
||||||
spouseFromYear.set(spousePairKey(e.personId, e.relatedPersonId), e.fromYear);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vertical (y) is still driven entirely by generation rank — #689 seeding
|
||||||
|
// and spouse pull-down are untouched.
|
||||||
const rank = assignRanks(allNodes, childToParents, spousePairs);
|
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[]>();
|
const generations = new Map<number, string[]>();
|
||||||
for (const n of allNodes) {
|
for (const n of allNodes) {
|
||||||
const g = rank.get(n.id) ?? 0;
|
const g = rank.get(n.id) ?? 0;
|
||||||
@@ -67,218 +70,49 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-generation layout:
|
// 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
|
||||||
// 1. Build sibling-groups (children of the same parent set) — these become
|
// run's left edge, and we spread the run's members rightward from there.
|
||||||
// the layout "blocks" that are centred under their parents' midpoint.
|
// y comes from rank (never tree depth), so a child seeded more than one
|
||||||
// 2. Attach loose spouses (people with no parents in the graph but a
|
// generation below its parent simply gets a taller connector.
|
||||||
// spouse who *is* in a sibling group) on the outside of their partner,
|
const forest = buildFamilyForest(allNodes, allEdges);
|
||||||
// so the spouse line stays short and adjacent.
|
const toTidy = (u: Unit): TidyNode => ({
|
||||||
// 3. Merge dual-loose spouse pairs into a single 2-person block.
|
id: u.id,
|
||||||
// 4. Centre each block such that its *parented* members average sits
|
width: u.members.length * NODE_W + (u.members.length - 1) * COL_GAP,
|
||||||
// exactly under the parent midpoint (keeping all connectors at 90°),
|
children: u.children.map(toTidy)
|
||||||
// then pack blocks left-to-right.
|
});
|
||||||
type Block = {
|
const runX = layoutForest(forest.roots.map(toTidy), COL_GAP);
|
||||||
members: { id: string; parented: boolean }[];
|
|
||||||
center: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
const sortedGens = [...generations.keys()].sort((a, b) => a - b);
|
const place = (u: Unit) => {
|
||||||
|
const left = runX.get(u.id) ?? 0;
|
||||||
for (let gi = 0; gi < sortedGens.length; gi++) {
|
u.members.forEach((mid, i) => {
|
||||||
const g = sortedGens[gi];
|
positions.set(mid, {
|
||||||
const ids = generations.get(g)!;
|
x: left + i * (NODE_W + COL_GAP),
|
||||||
const y = g * (NODE_H + ROW_GAP);
|
y: (rank.get(mid) ?? 0) * (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))
|
|
||||||
});
|
});
|
||||||
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) {
|
// Safety net: any node the forest did not place (it shouldn't leave any)
|
||||||
attachments.sort((a, b) => {
|
// still gets a deterministic slot rather than vanishing from the canvas.
|
||||||
const ya = a.fromYear ?? Number.POSITIVE_INFINITY;
|
let spare = runX.size;
|
||||||
const yb = b.fromYear ?? Number.POSITIVE_INFINITY;
|
for (const n of allNodes) {
|
||||||
if (ya !== yb) return ya - yb;
|
if (positions.has(n.id)) continue;
|
||||||
const an = byId.get(a.id)?.displayName ?? '';
|
positions.set(n.id, {
|
||||||
const bn = byId.get(b.id)?.displayName ?? '';
|
x: spare++ * (NODE_W + COL_GAP),
|
||||||
return an.localeCompare(bn);
|
y: (rank.get(n.id) ?? 0) * (NODE_H + ROW_GAP)
|
||||||
});
|
|
||||||
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
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cursorRight = groupLeft + groupWidth;
|
|
||||||
}
|
const crossLinks = forest.crossLinks
|
||||||
}
|
.filter((c) => !c.sameLevel)
|
||||||
|
.map(({ parentId, childId }) => ({ parentId, childId }));
|
||||||
|
|
||||||
const viewBox = computeViewBox(positions);
|
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
|
// 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);
|
if (s) s.add(value);
|
||||||
else map.set(key, new Set([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