diff --git a/frontend/src/lib/person/genealogy/layout/buildLayout.ts b/frontend/src/lib/person/genealogy/layout/buildLayout.ts index 6bfa4262..819b1fe2 100644 --- a/frontend/src/lib/person/genealogy/layout/buildLayout.ts +++ b/frontend/src/lib/person/genealogy/layout/buildLayout.ts @@ -49,68 +49,7 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO } } - // Two-stage rank assignment (#689): - // - // 1. Seed: every node with imported generation is locked at that rank. - // The fallback heuristic never moves a locked rank, and spouse-pulldown - // never pulls a locked rank. - // 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank) - // + 1, reading parent rank from the same unified map so an unseeded - // child of a seeded G 2 parent correctly inherits rank 3. Spouse- - // pulldown ties unseeded spouses to their deeper partner. - // 3. Normalise: if any seeded rank is negative (a future G −1 ancestor), - // shift the entire map so min(rank) == 0. No-op fast path covers - // today's data. - const rank = new Map(); - const locked = new Set(); - for (const n of allNodes) { - if (n.generation != null) { - rank.set(n.id, n.generation); - locked.add(n.id); - } else { - rank.set(n.id, 0); - } - } - const maxIters = allNodes.length + 4; - for (let it = 0; it < maxIters; it++) { - let changed = false; - for (const n of allNodes) { - if (locked.has(n.id)) continue; - const parents = childToParents.get(n.id) ?? []; - if (parents.length === 0) continue; - let maxParentRank = -Infinity; - for (const pid of parents) { - maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0); - } - const newRank = maxParentRank + 1; - if ((rank.get(n.id) ?? 0) < newRank) { - rank.set(n.id, newRank); - changed = true; - } - } - for (const [a, partners] of spousePairs) { - for (const b of partners) { - const ra = rank.get(a) ?? 0; - const rb = rank.get(b) ?? 0; - const m = Math.max(ra, rb); - if (!locked.has(a) && ra < m) { - rank.set(a, m); - changed = true; - } - if (!locked.has(b) && rb < m) { - rank.set(b, m); - changed = true; - } - } - } - if (!changed) break; - } - let minRank = Infinity; - for (const r of rank.values()) minRank = Math.min(minRank, r); - if (minRank < 0) { - const shift = -minRank; - for (const [id, r] of rank) rank.set(id, r + shift); - } + const rank = assignRanks(allNodes, childToParents, spousePairs); // Group by rank, then sort within rank by display name. const generations = new Map(); @@ -366,6 +305,75 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO return { positions, generations, viewX, viewY, viewW, viewH }; } +// Two-stage rank assignment (#689): +// +// 1. Seed: every node with imported generation is locked at that rank. The +// fallback heuristic never moves a locked rank, and spouse-pulldown never +// pulls a locked rank. +// 2. Fallback: for the remaining (unseeded) nodes, rank = max(parent rank) + 1, +// reading parent rank from the same unified map so an unseeded child of a +// seeded G 2 parent correctly inherits rank 3. Spouse-pulldown ties +// unseeded spouses to their deeper partner. +// 3. Normalise: if any seeded rank is negative (a future G −1 ancestor), shift +// the entire map so min(rank) == 0. No-op fast path covers today's data. +function assignRanks( + allNodes: PersonNodeDTO[], + childToParents: Map, + spousePairs: Map> +): Map { + const rank = new Map(); + const locked = new Set(); + for (const n of allNodes) { + if (n.generation != null) { + rank.set(n.id, n.generation); + locked.add(n.id); + } else { + rank.set(n.id, 0); + } + } + const maxIters = allNodes.length + 4; + for (let it = 0; it < maxIters; it++) { + let changed = false; + for (const n of allNodes) { + if (locked.has(n.id)) continue; + const parents = childToParents.get(n.id) ?? []; + if (parents.length === 0) continue; + let maxParentRank = -Infinity; + for (const pid of parents) { + maxParentRank = Math.max(maxParentRank, rank.get(pid) ?? 0); + } + const newRank = maxParentRank + 1; + if ((rank.get(n.id) ?? 0) < newRank) { + rank.set(n.id, newRank); + changed = true; + } + } + for (const [a, partners] of spousePairs) { + for (const b of partners) { + const ra = rank.get(a) ?? 0; + const rb = rank.get(b) ?? 0; + const m = Math.max(ra, rb); + if (!locked.has(a) && ra < m) { + rank.set(a, m); + changed = true; + } + if (!locked.has(b) && rb < m) { + rank.set(b, m); + changed = true; + } + } + } + if (!changed) break; + } + let minRank = Infinity; + for (const r of rank.values()) minRank = Math.min(minRank, r); + if (minRank < 0) { + const shift = -minRank; + for (const [id, r] of rank) rank.set(id, r + shift); + } + return rank; +} + function mapPush(map: Map, key: K, value: V) { const arr = map.get(key); if (arr) arr.push(value);