Files
WebtreesFullDiagram/resources/js/modules/lib/layout/elk-layout.js
Alexander Bocken 273e398431 Initial commit: webtrees full diagram chart module
Interactive SVG family tree visualization using ELK (Sugiyama) for
layout and D3 for rendering. Shows ancestors, descendants, and siblings
in a single diagram with orthogonal bus-line connectors.

Features:
- Bidirectional tree traversal (ancestors + descendants + siblings)
- Generation-aligned layout with post-processing Y-snap
- Person cards with photos, names, dates, and hover bio cards
- "More ancestors" indicator for persons with hidden parents
- Pan/zoom navigation
- Docker dev environment
2026-03-14 18:52:17 +01:00

473 lines
16 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).
*
* Post-processing snaps all people of the same generation to the same
* Y coordinate and repositions union nodes between generation rows.
* 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 ──
const rawPos = new Map(); // id → { cx, cy }
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;
rawPos.set(elkNode.id, { cx, cy });
if (nodeInfo.isMain) {
rootX = cx;
rootY = cy;
}
}
// ── Step 2: Snap person nodes to generation rows ──
// Group person nodes by generation, compute median Y for each generation
const genGroups = new Map(); // generation → [{ id, cx, cy }]
for (const [id, pos] of rawPos) {
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, ...pos });
}
// For each generation, use the median Y as the canonical row Y
const genY = new Map(); // generation → snapped Y
for (const [gen, nodes] of genGroups) {
const ys = nodes.map((n) => n.cy).sort((a, b) => a - b);
const mid = Math.floor(ys.length / 2);
const medianY =
ys.length % 2 === 0 ? (ys[mid - 1] + ys[mid]) / 2 : ys[mid];
genY.set(gen, medianY);
}
// ── Step 3: Compute union node Y positions ──
// Each union sits between its parent generation row and child generation row
const unionGenY = new Map(); // unionId → snapped Y
for (const [id, node] of builder.nodes) {
if (node.type !== "union") continue;
// Find parent generation (edges INTO this union)
let parentGen = null;
let childGen = null;
for (const edge of builder.edges) {
if (edge.target === id && builder.generations.has(edge.source)) {
parentGen = builder.generations.get(edge.source);
}
if (edge.source === id && builder.generations.has(edge.target)) {
childGen = builder.generations.get(edge.target);
}
}
if (parentGen !== null && childGen !== null) {
const pY = genY.get(parentGen);
const cY = genY.get(childGen);
if (pY !== undefined && cY !== undefined) {
// Place union at: parent bottom edge + 40% of gap to child top edge
const parentBottom = pY + halfH;
const childTop = cY - halfH;
unionGenY.set(id, parentBottom + (childTop - parentBottom) * 0.4);
continue;
}
}
// Fallback: use raw ELK position
const raw = rawPos.get(id);
if (raw) unionGenY.set(id, raw.cy);
}
// ── Step 4: Build final positioned nodes (centered on root) ──
const posMap = new Map(); // id → { x, y }
// Recalculate rootY using the snapped generation Y
const mainGen = builder.generations.get(builder.mainId) || 0;
const snappedRootY = genY.get(mainGen) || rootY;
for (const [id, node] of builder.nodes) {
const raw = rawPos.get(id);
if (!raw) continue;
let finalY;
if (node.type === "person") {
const gen = builder.generations.get(id) || 0;
finalY = (genY.get(gen) || raw.cy) - snappedRootY;
} else {
finalY = (unionGenY.get(id) || raw.cy) - snappedRootY;
}
const finalX = raw.cx - rootX;
posMap.set(id, { x: finalX, y: finalY });
if (node.type === "person") {
persons.push({
x: finalX,
y: finalY,
id: node.id,
isMain: node.isMain,
data: node.data,
});
} else {
unions.push({ id: id, x: finalX, y: finalY });
}
}
// ── Step 5: Build clean bus-line connectors ──
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);
}
}
for (const union of unions) {
const parentIds = incomingToUnion.get(union.id) || [];
const childIds = outgoingFromUnion.get(union.id) || [];
const parents = parentIds
.map((id) => posMap.get(id))
.filter(Boolean);
const children = childIds
.map((id) => posMap.get(id))
.filter(Boolean);
const ux = union.x;
const uy = union.y;
// ── Parent → union connections ──
if (parents.length > 0) {
// Horizontal couple bar at union Y
if (parents.length >= 2) {
const xs = parents.map((p) => p.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 Y
for (const p of parents) {
const bottomY = p.y + halfH;
connections.push({
path: `M ${p.x} ${bottomY} L ${p.x} ${uy}`,
cssClass: "link ancestor-link",
});
}
}
// ── Union → children connections ──
if (children.length > 0) {
// Bus Y halfway between union and children's top edge
const childY = children[0].y;
const busY = uy + (childY - halfH - uy) / 2;
// Vertical stem from union down to bus
connections.push({
path: `M ${ux} ${uy} L ${ux} ${busY}`,
cssClass: "link descendant-link",
});
if (children.length === 1) {
// Single child: continue vertical line
connections.push({
path: `M ${children[0].x} ${busY} L ${children[0].x} ${childY - halfH}`,
cssClass: "link descendant-link",
});
} else {
// Horizontal bus spanning all children
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",
});
// Vertical drops from bus to each child's top edge
for (const c of children) {
connections.push({
path: `M ${c.x} ${busY} L ${c.x} ${childY - halfH}`,
cssClass: "link descendant-link",
});
}
}
}
}
return { persons, unions, connections };
}