diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.ts index c2ff3260..bb4b41d6 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.ts @@ -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; generations: Map; + // 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(); const childToParents = new Map(); // 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>(); 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(); 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(); 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(); - 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(); - const memberLookup = new Map(); - - // 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(); - 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 }); - } - } - - 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 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) }); - 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 })) - ); - } + }); + u.children.forEach(place); + }; + forest.roots.forEach(place); - // 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(); - 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(); - 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; - } + // 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) + }); } + 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(map: Map>, 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(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(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); -}