Files
WebtreesFullDiagram/resources/js/modules/lib/layout/elk-layout.js
Alexander Bocken 62f1c335e7 Move overlapping childless spouses to nearest row edge
After Y-snapping, spouses without shared children can land on top of
each other. Detect these overlaps and move the spouse to the nearest
edge of the generation row without affecting other cards.
2026-03-14 21:37:42 +01:00

558 lines
20 KiB
JavaScript

/**
* ELK (Eclipse Layout Kernel) based family tree layout.
*
* Uses the union-node pattern with ELK's Sugiyama algorithm for
* guaranteed overlap-free positioning. Connector lines are drawn
* manually using clean orthogonal bus lines (not ELK's edge routing).
*
* ELK handles all node placement — no post-processing adjustments.
* Spouse-grouped node ordering keeps couples placed close together.
*
* Input: flat person array with rels { parents, spouses, children }
* Output: positioned persons + orthogonal connector paths
*/
import ELK from "elkjs/lib/elk.bundled.js";
const elk = new ELK();
/**
* @param {Array} persons - Flat array of { id, data, rels }
* @param {string} mainId - Root person ID
* @param {object} config - Card dimensions and spacing
* @returns {Promise<LayoutResult>}
*/
export async function computeElkLayout(persons, mainId, config) {
const builder = new GraphBuilder(persons, mainId, config);
builder.build();
const graph = builder.buildElkGraph();
const result = await elk.layout(graph);
return extractPositions(result, builder, config);
}
// ─── Graph Builder ───────────────────────────────────────────────────
class GraphBuilder {
constructor(persons, mainId, config) {
this.config = config;
this.personById = new Map();
for (const p of persons) {
this.personById.set(p.id, p);
}
this.mainId = mainId;
this.nodes = new Map(); // id → { id, type, data }
this.edges = [];
this.unionCounter = 0;
// Track which family units we've already created union nodes for
// key = sorted parent IDs joined, value = union node id
this.familyUnions = new Map();
// Generation number per person (0 = main, negative = ancestors, positive = descendants)
this.generations = new Map();
}
build() {
// Add all persons as nodes
for (const [id, person] of this.personById) {
this.nodes.set(id, {
id: id,
type: "person",
data: person.data,
isMain: id === this.mainId,
});
}
// For each person, create union nodes for their family relationships
for (const [id, person] of this.personById) {
const parents = (person.rels.parents || []).filter((pid) =>
this.personById.has(pid)
);
if (parents.length > 0) {
const unionId = this.getOrCreateFamilyUnion(parents);
// union → child
this.addEdge(unionId, id);
}
}
// Compute generation numbers via BFS from main person
this.computeGenerations();
}
/**
* BFS from the main person to assign generation numbers.
* Spouses get the same generation, parents get gen-1, children get gen+1.
* Spouses are processed first to ensure they share a layer.
*/
computeGenerations() {
this.generations.set(this.mainId, 0);
const queue = [this.mainId];
const visited = new Set([this.mainId]);
while (queue.length > 0) {
const id = queue.shift();
const gen = this.generations.get(id);
const person = this.personById.get(id);
if (!person) continue;
// Spouses = same generation (process first for consistency)
for (const sid of person.rels.spouses || []) {
if (!visited.has(sid) && this.personById.has(sid)) {
this.generations.set(sid, gen);
visited.add(sid);
queue.push(sid);
}
}
// Parents = one generation up
for (const pid of person.rels.parents || []) {
if (!visited.has(pid) && this.personById.has(pid)) {
this.generations.set(pid, gen - 1);
visited.add(pid);
queue.push(pid);
}
}
// Children = one generation down
for (const cid of person.rels.children || []) {
if (!visited.has(cid) && this.personById.has(cid)) {
this.generations.set(cid, gen + 1);
visited.add(cid);
queue.push(cid);
}
}
}
}
/**
* Get or create a union node for a set of parents.
* Creates parent → union edges on first creation.
*/
getOrCreateFamilyUnion(parentIds) {
const key = [...parentIds].sort().join("|");
if (this.familyUnions.has(key)) {
return this.familyUnions.get(key);
}
const unionId = `union_${this.unionCounter++}`;
this.nodes.set(unionId, {
id: unionId,
type: "union",
data: null,
});
this.familyUnions.set(key, unionId);
// parent → union edges (high priority to keep parents close)
for (const pid of parentIds) {
this.addEdge(pid, unionId, 10);
}
return unionId;
}
addEdge(source, target, priority = 1) {
const exists = this.edges.some(
(e) => e.source === source && e.target === target
);
if (!exists) {
this.edges.push({ source, target, priority });
}
}
buildElkGraph() {
const w = this.config.cardWidth;
const h = this.config.cardHeight;
const unionSize = 2;
// Order person nodes with spouses adjacent for model-order awareness
const orderedPersonIds = this._orderPersonsBySpouseGroups();
const elkNodes = [];
// Add person nodes in spouse-grouped order
for (const id of orderedPersonIds) {
elkNodes.push({
id: id,
width: w,
height: h,
});
}
// Add union nodes
for (const [id, node] of this.nodes) {
if (node.type !== "union") continue;
elkNodes.push({
id: id,
width: unionSize,
height: unionSize,
});
}
const elkEdges = this.edges.map((e, i) => {
const edge = {
id: `e${i}`,
sources: [e.source],
targets: [e.target],
};
if (e.priority > 1) {
edge.layoutOptions = {
"elk.layered.priority.direction": String(e.priority),
"elk.layered.priority.shortness": String(e.priority),
};
}
return edge;
});
return {
id: "root",
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.spacing.nodeNodeBetweenLayers": String(
this.config.verticalSpacing
),
"elk.spacing.nodeNode": String(this.config.horizontalSpacing),
"elk.layered.spacing.edgeNodeBetweenLayers": "15",
"elk.layered.spacing.edgeEdgeBetweenLayers": "10",
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
"elk.separateConnectedComponents": "false",
"elk.layered.compaction.postCompaction.strategy":
"EDGE_LENGTH",
},
children: elkNodes,
edges: elkEdges,
};
}
/**
* Order person nodes so that spouse pairs are adjacent in the input.
* Combined with considerModelOrder, this keeps couples placed close together.
*/
_orderPersonsBySpouseGroups() {
const ordered = [];
const added = new Set();
for (const [id, person] of this.personById) {
if (added.has(id)) continue;
added.add(id);
ordered.push(id);
// Add spouses immediately after this person
const spouses = (person.rels.spouses || []).filter(
(sid) => this.personById.has(sid) && !added.has(sid)
);
for (const sid of spouses) {
added.add(sid);
ordered.push(sid);
}
}
return ordered;
}
}
// ─── Extract positions & build clean connectors ──────────────────────
function extractPositions(elkResult, builder, config) {
const persons = [];
const unions = [];
const connections = [];
const halfH = config.cardHeight / 2;
// ── Step 1: Read raw ELK positions (centered on root) ──
const posMap = new Map(); // id → { x, y }
let rootX = 0,
rootY = 0;
for (const elkNode of elkResult.children || []) {
const nodeInfo = builder.nodes.get(elkNode.id);
if (!nodeInfo) continue;
const cx = elkNode.x + elkNode.width / 2;
const cy = elkNode.y + elkNode.height / 2;
if (nodeInfo.isMain) {
rootX = cx;
rootY = cy;
}
posMap.set(elkNode.id, { cx, cy });
}
// ── Step 2: Snap person Y to generation rows (keep ELK X untouched) ──
// Group persons by generation, compute median Y per generation
const genGroups = new Map(); // generation → [id, ...]
for (const [id, pos] of posMap) {
const nodeInfo = builder.nodes.get(id);
if (!nodeInfo || nodeInfo.type !== "person") continue;
const gen = builder.generations.get(id) ?? 0;
if (!genGroups.has(gen)) genGroups.set(gen, []);
genGroups.get(gen).push(id);
}
const genY = new Map(); // generation → canonical Y
for (const [gen, ids] of genGroups) {
const ys = ids.map((id) => posMap.get(id).cy).sort((a, b) => a - b);
const mid = Math.floor(ys.length / 2);
genY.set(
gen,
ys.length % 2 === 0 ? (ys[mid - 1] + ys[mid]) / 2 : ys[mid]
);
}
// Apply: center on root, snap person Y to generation row
const mainGen = builder.generations.get(builder.mainId) ?? 0;
const snappedRootY = genY.get(mainGen) ?? rootY;
for (const [id, pos] of posMap) {
const nodeInfo = builder.nodes.get(id);
pos.x = pos.cx - rootX;
if (nodeInfo && nodeInfo.type === "person") {
const gen = builder.generations.get(id) ?? 0;
pos.y = (genY.get(gen) ?? pos.cy) - snappedRootY;
} else {
pos.y = pos.cy - snappedRootY;
}
}
// ── Step 2b: Fix overlapping spouses (childless couples) ──
// After Y-snapping, spouses without shared children may land on top of
// each other. Move the overlapping spouse to the nearest row edge.
const minGap = config.cardWidth + config.horizontalSpacing;
for (const [id, person] of builder.personById) {
const spouseIds = (person.rels.spouses || []).filter((sid) =>
builder.personById.has(sid)
);
if (spouseIds.length === 0) continue;
const pos = posMap.get(id);
if (!pos) continue;
for (const sid of spouseIds) {
const spos = posMap.get(sid);
if (!spos) continue;
// Check if they overlap (same row after Y-snap, too close on X)
if (Math.abs(pos.y - spos.y) > 1) continue;
if (Math.abs(pos.x - spos.x) >= minGap) continue;
// Find the row extents (min/max X of all persons in this gen)
const gen = builder.generations.get(sid) ?? 0;
const rowIds = genGroups.get(gen) || [];
const rowXs = rowIds
.map((rid) => posMap.get(rid)?.x)
.filter((x) => x !== undefined);
const rowMin = Math.min(...rowXs);
const rowMax = Math.max(...rowXs);
// Place at left or right edge, whichever is closer to current pos
const leftTarget = rowMin - minGap;
const rightTarget = rowMax + minGap;
const distLeft = Math.abs(spos.x - leftTarget);
const distRight = Math.abs(spos.x - rightTarget);
spos.x = distLeft <= distRight ? leftTarget : rightTarget;
}
}
// ── Step 3: Build edge maps ──
const incomingToUnion = new Map();
const outgoingFromUnion = new Map();
for (const edge of builder.edges) {
const sourceInfo = builder.nodes.get(edge.source);
const targetInfo = builder.nodes.get(edge.target);
if (targetInfo && targetInfo.type === "union") {
if (!incomingToUnion.has(edge.target))
incomingToUnion.set(edge.target, []);
incomingToUnion.get(edge.target).push(edge.source);
}
if (sourceInfo && sourceInfo.type === "union") {
if (!outgoingFromUnion.has(edge.source))
outgoingFromUnion.set(edge.source, []);
outgoingFromUnion.get(edge.source).push(edge.target);
}
}
// ── Step 4: Snap union Y to grid between generation rows ──
// Compute a consistent couple-bar Y and child-bus Y for each
// pair of adjacent generations, so all connectors align on a grid.
const sortedGens = [...genY.keys()].sort((a, b) => a - b);
// coupleBarY: between parent gen and child gen (40% down from parent bottom)
// childBusY: between parent gen and child gen (70% down from parent bottom)
const coupleBarY = new Map(); // "parentGen|childGen" → Y
const childBusY = new Map();
for (let i = 0; i < sortedGens.length - 1; i++) {
const upperGen = sortedGens[i];
const lowerGen = sortedGens[i + 1];
const upperY = (genY.get(upperGen) ?? 0) - snappedRootY;
const lowerY = (genY.get(lowerGen) ?? 0) - snappedRootY;
const parentBottom = upperY + halfH;
const childTop = lowerY - halfH;
const gap = childTop - parentBottom;
const key = `${upperGen}|${lowerGen}`;
coupleBarY.set(key, parentBottom + gap * 0.35);
childBusY.set(key, parentBottom + gap * 0.65);
}
// Snap each union node Y to the couple-bar grid line
for (const [unionId, node] of builder.nodes) {
if (node.type !== "union") continue;
const parentIds = incomingToUnion.get(unionId) || [];
const childIds = outgoingFromUnion.get(unionId) || [];
if (parentIds.length === 0 || childIds.length === 0) continue;
const parentGen = builder.generations.get(parentIds[0]);
const childGen = builder.generations.get(childIds[0]);
if (parentGen === undefined || childGen === undefined) continue;
const key = `${parentGen}|${childGen}`;
const barY = coupleBarY.get(key);
if (barY !== undefined) {
const pos = posMap.get(unionId);
if (pos) pos.y = barY;
}
}
// ── Step 5: Collect final positioned nodes ──
for (const [id, node] of builder.nodes) {
const pos = posMap.get(id);
if (!pos) continue;
if (node.type === "person") {
persons.push({
x: pos.x,
y: pos.y,
id: node.id,
isMain: node.isMain,
data: node.data,
});
} else {
unions.push({ id: id, x: pos.x, y: pos.y });
}
}
// ── Step 6: Build grid-aligned bus-line connectors ──
// Pre-compute offsets for parents in multiple unions (multiple spouses).
// Each union a parent belongs to gets an offset so the vertical drop
// lines fan out from the card instead of overlapping at center.
const parentToUnions = new Map(); // personId → [unionId, ...]
for (const union of unions) {
for (const pid of incomingToUnion.get(union.id) || []) {
if (!parentToUnions.has(pid)) parentToUnions.set(pid, []);
parentToUnions.get(pid).push(union.id);
}
}
// Sort each parent's unions by their union X so offsets are spatially consistent
const dropOffset = new Map(); // "personId|unionId" → offset pixels
const offsetStep = 14;
for (const [pid, uids] of parentToUnions) {
if (uids.length <= 1) continue;
uids.sort((a, b) => {
const ua = unions.find((u) => u.id === a);
const ub = unions.find((u) => u.id === b);
return (ua?.x ?? 0) - (ub?.x ?? 0);
});
for (let i = 0; i < uids.length; i++) {
const off = (i - (uids.length - 1) / 2) * offsetStep;
dropOffset.set(`${pid}|${uids[i]}`, off);
}
}
for (const union of unions) {
const parentIds = incomingToUnion.get(union.id) || [];
const childIds = outgoingFromUnion.get(union.id) || [];
const children = childIds
.map((id) => posMap.get(id))
.filter(Boolean);
const ux = union.x;
const uy = union.y;
// ── Parent → union connections ──
if (parentIds.length > 0) {
// Compute the drop X for each parent (offset if multi-spouse)
const dropXs = parentIds.map((pid) => {
const pos = posMap.get(pid);
if (!pos) return null;
const off = dropOffset.get(`${pid}|${union.id}`) ?? 0;
return { pid, x: pos.x + off, y: pos.y };
}).filter(Boolean);
// Horizontal couple bar between the drop points
if (dropXs.length >= 2) {
const xs = dropXs.map((d) => d.x).sort((a, b) => a - b);
connections.push({
path: `M ${xs[0]} ${uy} L ${xs[xs.length - 1]} ${uy}`,
cssClass: "link couple-link",
});
}
// Vertical drop from each parent's bottom edge to couple bar
for (const d of dropXs) {
const bottomY = d.y + halfH;
connections.push({
path: `M ${d.x} ${bottomY} L ${d.x} ${uy}`,
cssClass: "link ancestor-link",
});
}
}
// ── Union → children connections ──
if (children.length > 0) {
// Use grid-aligned bus Y for this generation pair
const parentGen = parentIds.length > 0
? builder.generations.get(parentIds[0])
: undefined;
const childGen = childIds.length > 0
? builder.generations.get(childIds[0])
: undefined;
const busKey =
parentGen !== undefined && childGen !== undefined
? `${parentGen}|${childGen}`
: null;
const busY = (busKey && childBusY.get(busKey)) ?? uy + (children[0].y - halfH - uy) / 2;
// Vertical stem from union down to child bus (ELK X)
connections.push({
path: `M ${ux} ${uy} L ${ux} ${busY}`,
cssClass: "link descendant-link",
});
if (children.length === 1) {
connections.push({
path: `M ${children[0].x} ${busY} L ${children[0].x} ${children[0].y - halfH}`,
cssClass: "link descendant-link",
});
} else {
const xs = children
.map((c) => c.x)
.sort((a, b) => a - b);
connections.push({
path: `M ${xs[0]} ${busY} L ${xs[xs.length - 1]} ${busY}`,
cssClass: "link descendant-link",
});
for (const c of children) {
connections.push({
path: `M ${c.x} ${busY} L ${c.x} ${c.y - halfH}`,
cssClass: "link descendant-link",
});
}
}
}
}
return { persons, unions, connections };
}