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:
Marcel
2026-05-28 20:42:14 +02:00
parent fd624f6ec8
commit 52e48a6b8c

View File

@@ -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);