547 lines
23 KiB
JavaScript
547 lines
23 KiB
JavaScript
"use strict";
|
|
|
|
function lineageNodeSort(a, b) {
|
|
return (a.generation || 1) - (b.generation || 1) ||
|
|
(a.birthTime || 0) - (b.birthTime || 0) ||
|
|
String(a.name || "").localeCompare(String(b.name || "")) ||
|
|
String(a.id || "").localeCompare(String(b.id || ""));
|
|
}
|
|
|
|
function lineageTreeNodeHtml(n, x, y) {
|
|
const entity = n?.id ? world.tarinai.find(t => (t.familyKey || t.id) === n.id) : null;
|
|
const live = entity && !entity.dead ? entity : null;
|
|
const img = n ? tarinaiSpritePathForId(n.id) : SPRITES[0].path;
|
|
const name = escapeHtml(n?.name || "\u4e0d\u660e");
|
|
const status = familyStatusText(n);
|
|
const gen = n?.generation ? `G${n.generation}` : "G?";
|
|
const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : "";
|
|
const briefReason = familyDeathReasonLabel(rawReason, entity);
|
|
const reason = briefReason ? ` / ${briefReason}` : "";
|
|
const rawTags = live?.personalityTags ? live.personalityTags() : (Array.isArray(n?.personalityTags) ? n.personalityTags : []);
|
|
const tags = rawTags.slice();
|
|
const tagText = tags.length ? ` / ${tags.join(" ")}` : "";
|
|
const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}${tagText}`);
|
|
const deathLine = briefReason ? `<span class="death-reason">${escapeHtml(briefReason)}</span>` : "";
|
|
const tagLine = tags.length ? `<span class="family-personality-tags">${tags.map(tag => `<em>${escapeHtml(tag)}</em>`).join("")}</span>` : "";
|
|
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
|
|
const childCls = (n?.parents || []).length ? " child" : "";
|
|
const iconBaseScale = live
|
|
? (live.effectiveScale ? live.effectiveScale() : (Number.isFinite(live.scale) ? live.scale : (Number.isFinite(live.adultScale) ? live.adultScale : 0.28)))
|
|
: (Number.isFinite(n?.scale) ? n.scale : (Number.isFinite(n?.adultScale) ? n.adultScale : 0.28));
|
|
const iconScale = clamp((Number(iconBaseScale) || 0.28) / 0.28, 0.64, 1.42).toFixed(2);
|
|
const data = n?.id ? ` data-family-tarinai-id="${escapeHtml(n.id)}"` : "";
|
|
return `<div class="family-node tree-node ${cls}${childCls}"${data} title="${title}" style="left:${Math.round(x)}px;top:${Math.round(y)}px;--family-icon-scale:${iconScale}">
|
|
<div class="family-portrait" aria-hidden="true"><img src="${img}" loading="lazy" decoding="async" alt=""></div>
|
|
<div class="family-copy">
|
|
<strong>${name}</strong>
|
|
<span>${escapeHtml(gen)} / ${escapeHtml(status)}</span>
|
|
${deathLine}
|
|
${tagLine}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
/*
|
|
* Family archive rendering is intentionally a top-down tree diagram.
|
|
* Do not replace this section with list/cards/readable grids: the archive must remain a \u5bb6\u7cfb\u56f3.
|
|
* The layout below may reorder siblings/parents inside the same generation only to reduce crossings.
|
|
*/
|
|
|
|
function lineageParentIds(n, idSet, family = world.family || {}) {
|
|
return lineageMutualParentIds(n, family, idSet).slice(0, 2);
|
|
}
|
|
|
|
function lineageParentKey(n, idSet) {
|
|
const parents = lineageParentIds(n, idSet);
|
|
if (!parents.length) return `root:${n?.id || Math.random()}`;
|
|
return parents.slice().sort().join("+");
|
|
}
|
|
|
|
function lineageMedian(values) {
|
|
if (!values.length) return Number.POSITIVE_INFINITY;
|
|
const sorted = values.slice().sort((a, b) => a - b);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
}
|
|
|
|
function lineageRankMap(rows) {
|
|
const rank = new Map();
|
|
rows.forEach((row, rowIndex) => {
|
|
row.forEach((n, i) => rank.set(n.id, rowIndex * 10000 + i));
|
|
});
|
|
return rank;
|
|
}
|
|
|
|
function lineageChildIds(n, idSet, family = world.family || {}) {
|
|
return lineageMutualChildIds(n, family, idSet);
|
|
}
|
|
|
|
function lineageOrderChildGroups(row, idSet, rank) {
|
|
const groups = new Map();
|
|
for (const n of row) {
|
|
const key = lineageParentKey(n, idSet);
|
|
if (!groups.has(key)) groups.set(key, []);
|
|
groups.get(key).push(n);
|
|
}
|
|
const records = Array.from(groups.entries()).map(([key, items]) => {
|
|
const parents = key.startsWith("root:") ? [] : key.split("+").filter(Boolean);
|
|
const parentRanks = parents.map(id => rank.get(id)).filter(v => Number.isFinite(v));
|
|
const score = parentRanks.length ? lineageMedian(parentRanks) : Number.POSITIVE_INFINITY;
|
|
items.sort(lineageNodeSort);
|
|
return {
|
|
key,
|
|
items,
|
|
score,
|
|
fallback: Math.min(...items.map(n => n.birthTime || 0)),
|
|
generation: Math.min(...items.map(n => n.generation || 1)),
|
|
};
|
|
});
|
|
records.sort((a, b) => a.score - b.score || a.generation - b.generation || a.fallback - b.fallback || String(a.key).localeCompare(String(b.key)));
|
|
return records.flatMap(r => r.items);
|
|
}
|
|
|
|
function lineageOrderParentsByChildren(row, idSet, childRank) {
|
|
if (!row.length || !childRank?.size) return row;
|
|
const records = row.map((n, index) => {
|
|
const childRanks = lineageChildIds(n, idSet).map(id => childRank.get(id)).filter(v => Number.isFinite(v));
|
|
return {
|
|
node: n,
|
|
score: childRanks.length ? lineageMedian(childRanks) : Number.POSITIVE_INFINITY,
|
|
original: index,
|
|
};
|
|
});
|
|
records.sort((a, b) => a.score - b.score || a.original - b.original || lineageNodeSort(a.node, b.node));
|
|
return records.map(r => r.node);
|
|
}
|
|
|
|
function lineagePartnerEdgesByGeneration(nodes, idToNode, idSet) {
|
|
const byGeneration = new Map();
|
|
const seen = new Set();
|
|
for (const child of nodes) {
|
|
const parents = lineageParentIds(child, idSet);
|
|
if (parents.length < 2) continue;
|
|
const a = idToNode.get(parents[0]);
|
|
const b = idToNode.get(parents[1]);
|
|
if (!a || !b) continue;
|
|
const generation = Math.min(a.generation || 1, b.generation || 1);
|
|
const key = parents.slice().sort().join("+");
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
if (!byGeneration.has(generation)) byGeneration.set(generation, []);
|
|
byGeneration.get(generation).push(parents.slice());
|
|
}
|
|
return byGeneration;
|
|
}
|
|
|
|
function lineageOrderPartnerComponent(ids, adjacency, rank, idToNode) {
|
|
if (ids.length <= 2) {
|
|
return ids.slice().sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b)));
|
|
}
|
|
const degree = id => (adjacency.get(id) || new Set()).size;
|
|
const start = ids.slice().sort((a, b) => degree(b) - degree(a) || (rank.get(a) ?? 0) - (rank.get(b) ?? 0))[0];
|
|
const neighbors = Array.from(adjacency.get(start) || []).filter(id => ids.includes(id));
|
|
const left = [];
|
|
const right = [];
|
|
neighbors.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b)));
|
|
neighbors.forEach((id, i) => (i % 2 ? left : right).push(id));
|
|
const placed = new Set([start, ...neighbors]);
|
|
const rest = ids.filter(id => !placed.has(id)).sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0) || lineageNodeSort(idToNode.get(a), idToNode.get(b)));
|
|
return [...left.reverse(), start, ...right, ...rest];
|
|
}
|
|
|
|
function lineageOrderPartnerClusters(row, partnerEdges, rank, idToNode) {
|
|
if (!partnerEdges?.length) return row;
|
|
const rowIds = new Set(row.map(n => n.id));
|
|
const adjacency = new Map();
|
|
for (const [a, b] of partnerEdges) {
|
|
if (!rowIds.has(a) || !rowIds.has(b)) continue;
|
|
if (!adjacency.has(a)) adjacency.set(a, new Set());
|
|
if (!adjacency.has(b)) adjacency.set(b, new Set());
|
|
adjacency.get(a).add(b);
|
|
adjacency.get(b).add(a);
|
|
}
|
|
if (!adjacency.size) return row;
|
|
|
|
const clusteredIds = new Set(adjacency.keys());
|
|
const seen = new Set();
|
|
const records = [];
|
|
for (const n of row) {
|
|
if (!clusteredIds.has(n.id)) {
|
|
records.push({ ids: [n.id], score: rank.get(n.id) ?? Number.POSITIVE_INFINITY, fallback: idToNode.get(n.id)?.birthTime || 0 });
|
|
continue;
|
|
}
|
|
if (seen.has(n.id)) continue;
|
|
const stack = [n.id];
|
|
const ids = [];
|
|
seen.add(n.id);
|
|
while (stack.length) {
|
|
const id = stack.pop();
|
|
ids.push(id);
|
|
for (const next of adjacency.get(id) || []) {
|
|
if (seen.has(next)) continue;
|
|
seen.add(next);
|
|
stack.push(next);
|
|
}
|
|
}
|
|
const orderedIds = lineageOrderPartnerComponent(ids, adjacency, rank, idToNode);
|
|
records.push({
|
|
ids: orderedIds,
|
|
score: lineageMedian(orderedIds.map(id => rank.get(id)).filter(v => Number.isFinite(v))),
|
|
fallback: Math.min(...orderedIds.map(id => idToNode.get(id)?.birthTime || 0)),
|
|
});
|
|
}
|
|
records.sort((a, b) => a.score - b.score || a.fallback - b.fallback || String(a.ids[0]).localeCompare(String(b.ids[0])));
|
|
return records.flatMap(c => c.ids.map(id => idToNode.get(id)).filter(Boolean));
|
|
}
|
|
|
|
function lineageAdjacentCrossCost(parentRow, childRow, idSet) {
|
|
if (!parentRow?.length || !childRow?.length) return 0;
|
|
const parentPos = new Map(parentRow.map((n, i) => [n.id, i]));
|
|
const edges = [];
|
|
for (let childIndex = 0; childIndex < childRow.length; childIndex++) {
|
|
const child = childRow[childIndex];
|
|
const parents = lineageParentIds(child, idSet).filter(id => parentPos.has(id));
|
|
if (!parents.length) continue;
|
|
const from = lineageMedian(parents.map(id => parentPos.get(id)));
|
|
edges.push({ from, to: childIndex, weight: parents.length >= 2 ? 1.4 : 1 });
|
|
}
|
|
let cost = 0;
|
|
for (let i = 0; i < edges.length; i++) {
|
|
for (let j = i + 1; j < edges.length; j++) {
|
|
const a = edges[i];
|
|
const b = edges[j];
|
|
const cross = (a.from - b.from) * (a.to - b.to) < 0;
|
|
if (cross) cost += a.weight * b.weight * 4.5;
|
|
}
|
|
}
|
|
return cost;
|
|
}
|
|
|
|
function lineageRowCost(row, rowIndex, rows, idSet) {
|
|
const pos = new Map(row.map((n, i) => [n.id, i]));
|
|
let cost = 0;
|
|
for (const n of row) {
|
|
const i = pos.get(n.id);
|
|
const parents = lineageParentIds(n, idSet);
|
|
if (parents.length && rowIndex > 0) {
|
|
const parentRow = rows[rowIndex - 1] || [];
|
|
const parentPos = parents.map(id => parentRow.findIndex(x => x.id === id)).filter(v => v >= 0);
|
|
if (parentPos.length) cost += Math.abs(i - lineageMedian(parentPos)) * (parents.length >= 2 ? 1.5 : 1.2);
|
|
}
|
|
const childRow = rows[rowIndex + 1] || [];
|
|
const childPos = childRow.filter(x => lineageParentIds(x, idSet).includes(n.id)).map(x => childRow.findIndex(y => y.id === x.id));
|
|
if (childPos.length) cost += Math.abs(i - lineageMedian(childPos)) * 1.0;
|
|
|
|
const siblingPos = row.filter(x => x.id !== n.id && lineageParentKey(x, idSet) === lineageParentKey(n, idSet)).map(x => pos.get(x.id));
|
|
if (siblingPos.length) cost += Math.abs(i - lineageMedian(siblingPos)) * 0.22;
|
|
}
|
|
|
|
const seenPairs = new Set();
|
|
for (const child of rows[rowIndex + 1] || []) {
|
|
const parents = lineageParentIds(child, idSet).filter(id => pos.has(id));
|
|
if (parents.length < 2) continue;
|
|
const key = parents.slice().sort().join("+");
|
|
if (seenPairs.has(key)) continue;
|
|
seenPairs.add(key);
|
|
const pair = parents.slice().sort((a, b) => pos.get(a) - pos.get(b));
|
|
const span = pos.get(pair[1]) - pos.get(pair[0]);
|
|
cost += span * span * 3.4;
|
|
if (span > 1) cost += 12;
|
|
}
|
|
|
|
return cost;
|
|
}
|
|
|
|
function lineageTotalCost(rows, idSet) {
|
|
let cost = 0;
|
|
for (let i = 0; i < rows.length; i++) {
|
|
cost += lineageRowCost(rows[i], i, rows, idSet);
|
|
if (i < rows.length - 1) cost += lineageAdjacentCrossCost(rows[i], rows[i + 1], idSet);
|
|
}
|
|
return cost;
|
|
}
|
|
|
|
function lineageMoveItem(row, fromIndex, toIndex) {
|
|
if (fromIndex === toIndex) return row.slice();
|
|
const next = row.slice();
|
|
const [item] = next.splice(fromIndex, 1);
|
|
next.splice(toIndex, 0, item);
|
|
return next;
|
|
}
|
|
|
|
function lineageOptimizeRows(rows, idSet) {
|
|
const totalNodes = rows.reduce((sum, row) => sum + row.length, 0);
|
|
const maxRow = Math.max(0, ...rows.map(row => row.length));
|
|
// The old exhaustive optimizer was cubic-to-quartic in practice and froze on
|
|
// large archives. Keep it only for small families; large trees use the
|
|
// barycentric ordering above, which is stable and linear-ish.
|
|
if (totalNodes > 90 || maxRow > 16) return rows.map(row => row.slice());
|
|
let bestRows = rows.map(row => row.slice());
|
|
let bestCost = lineageTotalCost(bestRows, idSet);
|
|
const passes = totalNodes > 54 || maxRow > 10 ? 3 : 6;
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
let changed = false;
|
|
for (let rowIndex = 0; rowIndex < bestRows.length; rowIndex++) {
|
|
const row = bestRows[rowIndex];
|
|
if (!row || row.length < 2) continue;
|
|
for (let from = 0; from < row.length; from++) {
|
|
const span = row.length > 10 ? 3 : row.length;
|
|
const minTo = Math.max(0, from - span);
|
|
const maxTo = Math.min(row.length - 1, from + span);
|
|
for (let to = minTo; to <= maxTo; to++) {
|
|
if (from === to) continue;
|
|
const moved = lineageMoveItem(bestRows[rowIndex], from, to);
|
|
const candidate = bestRows.slice();
|
|
candidate[rowIndex] = moved;
|
|
const cost = lineageTotalCost(candidate, idSet);
|
|
if (cost + 0.001 < bestCost) {
|
|
bestRows = candidate;
|
|
bestCost = cost;
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!changed) break;
|
|
}
|
|
return bestRows;
|
|
}
|
|
|
|
function lineageRowsForLayout(nodes, generations, idToNode, idSet) {
|
|
const byGeneration = new Map(generations.map(g => [g, []]));
|
|
for (const n of nodes) byGeneration.get(n.generation || 1).push(n);
|
|
let rows = generations.map(g => (byGeneration.get(g) || []).slice().sort(lineageNodeSort));
|
|
const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet);
|
|
const totalNodes = nodes.length;
|
|
const maxRow = Math.max(0, ...rows.map(row => row.length));
|
|
const veryLarge = totalNodes > 220 || maxRow > 44;
|
|
const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6));
|
|
|
|
for (let pass = 0; pass < passes; pass++) {
|
|
let rank = lineageRankMap(rows);
|
|
rows = rows.map(row => lineageOrderChildGroups(row, idSet, rank));
|
|
rank = lineageRankMap(rows);
|
|
for (let i = rows.length - 2; i >= 0; i--) {
|
|
const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index]));
|
|
rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank);
|
|
}
|
|
if (!veryLarge) {
|
|
rank = lineageRankMap(rows);
|
|
rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode));
|
|
if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet);
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function lineageBuildRowGroups(row, idSet) {
|
|
const groups = [];
|
|
let i = 0;
|
|
while (i < row.length) {
|
|
const key = lineageParentKey(row[i], idSet);
|
|
const items = [row[i]];
|
|
i += 1;
|
|
while (i < row.length && lineageParentKey(row[i], idSet) === key) {
|
|
items.push(row[i]);
|
|
i += 1;
|
|
}
|
|
groups.push({ key, items });
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function lineageLayoutRows(rows, idSet) {
|
|
const NODE_W = 132;
|
|
const NODE_H = 84;
|
|
const SIBLING_GAP = 32;
|
|
const GROUP_GAP = 52;
|
|
const PARTNER_GROUP_GAP = 24;
|
|
const ROW_GAP = 138;
|
|
const LEFT_PAD = 82;
|
|
const TOP_PAD = 44;
|
|
const RIGHT_PAD = 48;
|
|
const BOTTOM_PAD = 30;
|
|
|
|
const rowGroups = rows.map(row => lineageBuildRowGroups(row, idSet).map(group => ({
|
|
...group,
|
|
groupW: group.items.length * NODE_W + Math.max(0, group.items.length - 1) * SIBLING_GAP,
|
|
})));
|
|
|
|
const pairedKeysByRow = rows.map((row) => {
|
|
const set = new Set();
|
|
const rowIds = new Set(row.map(n => n.id));
|
|
for (const futureRow of rows) {
|
|
for (const child of futureRow) {
|
|
const parents = lineageParentIds(child, rowIds);
|
|
if (parents.length >= 2) set.add(parents.slice().sort().join("+"));
|
|
}
|
|
}
|
|
return set;
|
|
});
|
|
|
|
let positions = new Map();
|
|
let width = 420;
|
|
|
|
const placePass = (existingPositions) => {
|
|
const nextPositions = new Map();
|
|
let nextWidth = 420;
|
|
const rowLayouts = [];
|
|
|
|
rows.forEach((row, rowIndex) => {
|
|
const groups = rowGroups[rowIndex] || [];
|
|
const desiredLefts = [];
|
|
const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP);
|
|
const nextRow = rows[rowIndex + 1] || [];
|
|
|
|
for (let gIndex = 0; gIndex < groups.length; gIndex++) {
|
|
const group = groups[gIndex];
|
|
const first = group.items[0];
|
|
const parentCenters = lineageParentIds(first, idSet)
|
|
.map(id => existingPositions.get(id) || nextPositions.get(id))
|
|
.filter(Boolean)
|
|
.map(p => p.x + NODE_W / 2);
|
|
const groupIds = new Set(group.items.map(n => n.id));
|
|
const childCenters = nextRow
|
|
.filter(child => lineageParentIds(child, idSet).some(id => groupIds.has(id)))
|
|
.map(child => existingPositions.get(child.id))
|
|
.filter(Boolean)
|
|
.map(p => p.x + NODE_W / 2);
|
|
const desiredCenterValues = [];
|
|
if (parentCenters.length) desiredCenterValues.push(...parentCenters, ...parentCenters);
|
|
if (childCenters.length) desiredCenterValues.push(...childCenters);
|
|
const fallbackCenter = LEFT_PAD + group.groupW / 2 + gIndex * (group.groupW + GROUP_GAP);
|
|
const desiredCenter = Number.isFinite(lineageMedian(desiredCenterValues)) ? lineageMedian(desiredCenterValues) : fallbackCenter;
|
|
desiredLefts[gIndex] = desiredCenter - group.groupW / 2;
|
|
}
|
|
|
|
const lefts = [];
|
|
const SOFT_EXTRA_GAP = 68;
|
|
for (let gIndex = 0; gIndex < groups.length; gIndex++) {
|
|
const prevGroup = groups[gIndex - 1];
|
|
const pairGap = prevGroup && prevGroup.items.length === 1 && groups[gIndex].items.length === 1
|
|
&& pairedKeysByRow[rowIndex].has([prevGroup.items[0].id, groups[gIndex].items[0].id].sort().join("+"));
|
|
const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP;
|
|
const minLeft = gIndex === 0 ? LEFT_PAD : lefts[gIndex - 1] + groups[gIndex - 1].groupW + gap;
|
|
const desired = Number.isFinite(desiredLefts[gIndex]) ? desiredLefts[gIndex] : minLeft;
|
|
lefts[gIndex] = Math.max(minLeft, Math.min(desired, minLeft + SOFT_EXTRA_GAP));
|
|
}
|
|
for (let gIndex = groups.length - 2; gIndex >= 0; gIndex--) {
|
|
const nextGroup = groups[gIndex + 1];
|
|
const pairGap = nextGroup && groups[gIndex].items.length === 1 && nextGroup.items.length === 1
|
|
&& pairedKeysByRow[rowIndex].has([groups[gIndex].items[0].id, nextGroup.items[0].id].sort().join("+"));
|
|
const gap = pairGap ? PARTNER_GROUP_GAP : GROUP_GAP;
|
|
const maxLeft = lefts[gIndex + 1] - groups[gIndex].groupW - gap;
|
|
lefts[gIndex] = Math.min(lefts[gIndex], maxLeft);
|
|
if (gIndex === 0) lefts[gIndex] = Math.max(lefts[gIndex], LEFT_PAD);
|
|
}
|
|
|
|
const contentW = groups.length ? (lefts[lefts.length - 1] + groups[groups.length - 1].groupW - LEFT_PAD) : 0;
|
|
nextWidth = Math.max(nextWidth, LEFT_PAD + contentW + RIGHT_PAD);
|
|
groups.forEach((group, gIndex) => {
|
|
const left = lefts[gIndex];
|
|
group.items.forEach((n, i) => {
|
|
const x = left + i * (NODE_W + SIBLING_GAP);
|
|
nextPositions.set(n.id, { x, y, rowIndex, node: n });
|
|
});
|
|
});
|
|
rowLayouts.push({ rowIndex, groups, lefts, contentW, y });
|
|
});
|
|
|
|
return { positions: nextPositions, width: nextWidth, rowLayouts };
|
|
};
|
|
|
|
for (let pass = 0; pass < 5; pass++) {
|
|
const result = placePass(positions);
|
|
positions = result.positions;
|
|
width = result.width;
|
|
}
|
|
|
|
// Re-run once more and center the entire family as one block to avoid left-heavy layouts
|
|
// without breaking parent/child alignment between rows.
|
|
const finalResult = placePass(positions);
|
|
positions = new Map(finalResult.positions);
|
|
let minX = Number.POSITIVE_INFINITY;
|
|
let maxX = 0;
|
|
for (const pos of positions.values()) {
|
|
minX = Math.min(minX, pos.x);
|
|
maxX = Math.max(maxX, pos.x + NODE_W);
|
|
}
|
|
const contentW = Number.isFinite(minX) ? Math.max(0, maxX - minX) : 0;
|
|
width = Math.max(finalResult.width, LEFT_PAD + contentW + RIGHT_PAD);
|
|
if (Number.isFinite(minX)) {
|
|
const targetLeft = LEFT_PAD + Math.max(0, (width - LEFT_PAD - RIGHT_PAD - contentW) / 2);
|
|
const shift = targetLeft - minX;
|
|
if (shift) {
|
|
for (const [id, prev] of positions.entries()) positions.set(id, { ...prev, x: prev.x + shift });
|
|
}
|
|
}
|
|
|
|
const groupBounds = (group) => {
|
|
const xs = group.items.map(n => positions.get(n.id)?.x).filter(Number.isFinite);
|
|
if (!xs.length) return null;
|
|
return { left: Math.min(...xs), right: Math.max(...xs) + NODE_W, width: group.groupW };
|
|
};
|
|
const groupGap = (leftGroup, rightGroup, rowIndex) => {
|
|
const pairGap = leftGroup && rightGroup && leftGroup.items.length === 1 && rightGroup.items.length === 1
|
|
&& pairedKeysByRow[rowIndex].has([leftGroup.items[0].id, rightGroup.items[0].id].sort().join("+"));
|
|
return pairGap ? PARTNER_GROUP_GAP : GROUP_GAP;
|
|
};
|
|
const shiftGroup = (group, dx) => {
|
|
if (!dx) return;
|
|
for (const n of group.items) {
|
|
const prev = positions.get(n.id);
|
|
if (prev) positions.set(n.id, { ...prev, x: prev.x + dx });
|
|
}
|
|
};
|
|
for (let pass = 0; pass < 3; pass++) {
|
|
for (let rowIndex = 1; rowIndex < rowGroups.length; rowIndex++) {
|
|
const groups = rowGroups[rowIndex] || [];
|
|
for (let gIndex = 0; gIndex < groups.length; gIndex++) {
|
|
const group = groups[gIndex];
|
|
const first = group.items[0];
|
|
const parentIds = lineageParentIds(first, idSet);
|
|
if (!parentIds.length) continue;
|
|
const parentPositions = parentIds.map(id => positions.get(id)).filter(Boolean);
|
|
if (!parentPositions.length) continue;
|
|
const parentCenter = parentPositions.reduce((sum, p) => sum + p.x + NODE_W / 2, 0) / parentPositions.length;
|
|
const bounds = groupBounds(group);
|
|
if (!bounds) continue;
|
|
const idealLeft = parentCenter - bounds.width / 2;
|
|
let minShift = LEFT_PAD - bounds.left;
|
|
let maxShift = Number.POSITIVE_INFINITY;
|
|
const prevGroup = groups[gIndex - 1];
|
|
if (prevGroup) {
|
|
const pb = groupBounds(prevGroup);
|
|
if (pb) minShift = Math.max(minShift, pb.right + groupGap(prevGroup, group, rowIndex) - bounds.left);
|
|
}
|
|
const nextGroup = groups[gIndex + 1];
|
|
if (nextGroup) {
|
|
const nb = groupBounds(nextGroup);
|
|
if (nb) maxShift = nb.left - groupGap(group, nextGroup, rowIndex) - bounds.right;
|
|
}
|
|
const desiredShift = idealLeft - bounds.left;
|
|
const shift = clamp(desiredShift, minShift, maxShift);
|
|
if (Math.abs(shift) > 0.2) shiftGroup(group, shift);
|
|
}
|
|
}
|
|
}
|
|
width = Math.max(width, ...Array.from(positions.values()).map(p => p.x + NODE_W + RIGHT_PAD));
|
|
|
|
// Fallback placement for any node that somehow missed layout.
|
|
rows.forEach((row, rowIndex) => {
|
|
const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP);
|
|
let fallbackX = LEFT_PAD;
|
|
for (const n of row) {
|
|
if (positions.has(n.id)) {
|
|
fallbackX = Math.max(fallbackX, positions.get(n.id).x + NODE_W + GROUP_GAP);
|
|
continue;
|
|
}
|
|
positions.set(n.id, { x: fallbackX, y, rowIndex, node: n });
|
|
fallbackX += NODE_W + GROUP_GAP;
|
|
width = Math.max(width, fallbackX + RIGHT_PAD);
|
|
}
|
|
});
|
|
|
|
const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD;
|
|
return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP };
|
|
}
|