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):
|
||||
//
|
||||
// 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);
|
||||
}
|
||||
const rank = assignRanks(allNodes, childToParents, spousePairs);
|
||||
|
||||
// Group by rank, then sort within rank by display name.
|
||||
const generations = new Map<number, string[]>();
|
||||
@@ -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<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) {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value);
|
||||
|
||||
Reference in New Issue
Block a user