refactor(stammbaum): extract assignRanks() helper from buildLayout (#361)
@Felix + @Markus on PR #693: buildLayout was a 367-line orchestrator doing five sequential phases. assignRanks() is one of the two self-contained phases that reads top-down on its own. Pure refactor under green tests — no behaviour change, no test diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -49,68 +49,7 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two-stage rank assignment (#689):
|
const rank = assignRanks(allNodes, childToParents, spousePairs);
|
||||||
//
|
|
||||||
// 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<string, number>();
|
|
||||||
const locked = new Set<string>();
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by rank, then sort within rank by display name.
|
// Group by rank, then sort within rank by display name.
|
||||||
const generations = new Map<number, string[]>();
|
const generations = new Map<number, string[]>();
|
||||||
@@ -366,6 +305,75 @@ export function buildLayout(allNodes: PersonNodeDTO[], allEdges: RelationshipDTO
|
|||||||
return { positions, generations, viewX, viewY, viewW, viewH };
|
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<string, string[]>,
|
||||||
|
spousePairs: Map<string, Set<string>>
|
||||||
|
): Map<string, number> {
|
||||||
|
const rank = new Map<string, number>();
|
||||||
|
const locked = new Set<string>();
|
||||||
|
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<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
function mapPush<K, V>(map: Map<K, V[]>, key: K, value: V) {
|
||||||
const arr = map.get(key);
|
const arr = map.get(key);
|
||||||
if (arr) arr.push(value);
|
if (arr) arr.push(value);
|
||||||
|
|||||||
Reference in New Issue
Block a user