Files
WebtreesFullDiagram/resources/js/modules/lib/layout/graphviz-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

458 lines
15 KiB
JavaScript

/**
* Graphviz (viz.js) based family tree layout.
*
* Architecture: union-node pattern
* ─────────────────────────────────
* Each marriage/partnership is modeled as a small "union" node.
* parent1 ──→ union ──→ child1
* parent2 ──→ union ──→ child2
*
* Graphviz's "dot" algorithm (Sugiyama) then places:
* - Each generation on its own rank
* - Union nodes on intermediate ranks between parents and children
* - Couples side-by-side (connected to the same union)
* - Children centered below the union node
*
* The layout guarantees no overlaps and handles multiple marriages,
* siblings, and complex family structures correctly.
*/
import { instance as vizInstance } from "@viz-js/viz";
let viz = null;
async function getViz() {
if (!viz) {
viz = await vizInstance();
}
return viz;
}
// Graphviz uses inches; 1 inch = 72 points
const PPI = 72;
/**
* Build a Graphviz DOT graph from the tree data and compute layout.
*
* @param {object} rootData - The root person data from PHP
* @param {Configuration} config
* @returns {Promise<LayoutResult>} Positioned nodes and edges
*/
export async function computeGraphvizLayout(rootData, config) {
const builder = new GraphBuilder(config);
builder.addPersonFromData(rootData);
const dot = builder.buildDotGraph();
const viz = await getViz();
const result = viz.renderJSON(dot);
return extractPositions(result, builder, config);
}
// ─── Graph Builder ───────────────────────────────────────────────────
class GraphBuilder {
constructor(config) {
this.config = config;
this.nodes = new Map(); // id → { id, type, data, layer }
this.edges = []; // { source, target }
this.visited = new Set();
this.unionCounter = 0;
}
/**
* Recursively add a person and all their relatives to the graph.
*/
addPersonFromData(data, currentLayer = 0) {
if (this.visited.has(data.xref)) return;
this.visited.add(data.xref);
this.addPersonNode(data, currentLayer);
// Ancestors: go up via parentFamilies
if (data.parentFamilies && data.parentFamilies.length > 0) {
this.addAncestorFamilies(data, currentLayer);
}
// Descendants: go down
if (data.families && data.families.length > 0) {
this.addDescendants(data, currentLayer);
}
}
addPersonNode(data, layer) {
if (this.nodes.has(data.xref)) return;
this.nodes.set(data.xref, {
id: data.xref,
type: "person",
data: data,
layer: layer,
});
}
addUnionNode(layer) {
const id = `union_${this.unionCounter++}`;
this.nodes.set(id, {
id: id,
type: "union",
data: null,
layer: layer,
});
return id;
}
addEdge(source, target) {
const exists = this.edges.some(
(e) => e.source === source && e.target === target
);
if (!exists) {
this.edges.push({ source, target });
}
}
/**
* Add ancestor families: each parentFamily is a FamilyNode with
* .parents (both mother & father) and .children (siblings).
*
* Creates: parent → union → child (for each child including the person)
*/
addAncestorFamilies(personData, personLayer) {
const parentFamilies = personData.parentFamilies;
if (!parentFamilies || parentFamilies.length === 0) return;
for (const family of parentFamilies) {
const unionLayer = personLayer - 1;
const parentLayer = personLayer - 2;
const unionId = this.addUnionNode(unionLayer);
// Union → person (this child)
this.addEdge(unionId, personData.xref);
// Each parent → union, then recurse into their ancestors
for (const parent of family.parents || []) {
this.addPersonNode(parent, parentLayer);
this.addEdge(parent.xref, unionId);
if (!this.visited.has(parent.xref)) {
this.visited.add(parent.xref);
if (
parent.parentFamilies &&
parent.parentFamilies.length > 0
) {
this.addAncestorFamilies(parent, parentLayer);
}
}
}
// Siblings (other children of this family) → same union
for (const sibling of family.children || []) {
this.addPersonNode(sibling, personLayer);
this.addEdge(unionId, sibling.xref);
// Process sibling's descendants
if (!this.visited.has(sibling.xref)) {
this.visited.add(sibling.xref);
if (sibling.families && sibling.families.length > 0) {
this.addDescendants(sibling, personLayer);
}
}
}
}
}
/**
* Add descendant chain: person → union → children
*/
addDescendants(personData, personLayer) {
if (!personData.families) return;
for (const family of personData.families) {
const unionLayer = personLayer + 1;
const childLayer = personLayer + 2;
const unionId = this.addUnionNode(unionLayer);
// Person → union
this.addEdge(personData.xref, unionId);
// Spouse → union
if (family.spouse) {
this.addPersonNode(family.spouse, personLayer);
this.addEdge(family.spouse.xref, unionId);
}
// Union → each child
for (const child of family.children || []) {
this.addPersonNode(child, childLayer);
this.addEdge(unionId, child.xref);
// Recurse into child's descendants
if (!this.visited.has(child.xref)) {
this.visited.add(child.xref);
if (child.families && child.families.length > 0) {
this.addDescendants(child, childLayer);
}
}
}
}
}
/**
* Build a Graphviz DOT language graph.
*/
buildDotGraph() {
const w = this.config.cardWidth / PPI;
const h = this.config.cardHeight / PPI;
const nodesep = this.config.horizontalSpacing / PPI;
const ranksep = this.config.verticalSpacing / PPI;
let dot = "digraph G {\n";
dot += " rankdir=TB;\n";
dot += ` nodesep=${nodesep.toFixed(3)};\n`;
dot += ` ranksep=${ranksep.toFixed(3)};\n`;
dot += " splines=none;\n";
dot += " ordering=out;\n";
dot += "\n";
// Add nodes
for (const [id, node] of this.nodes) {
// Escape quotes in IDs
const safeId = id.replace(/"/g, '\\"');
if (node.type === "person") {
dot += ` "${safeId}" [shape=box, fixedsize=true, width=${w.toFixed(3)}, height=${h.toFixed(3)}];\n`;
} else {
dot += ` "${safeId}" [shape=point, width=0.01, height=0.01];\n`;
}
}
dot += "\n";
// Add edges
for (const edge of this.edges) {
const src = edge.source.replace(/"/g, '\\"');
const tgt = edge.target.replace(/"/g, '\\"');
dot += ` "${src}" -> "${tgt}";\n`;
}
dot += "\n";
// Add rank constraints to group nodes at the same layer
const layerGroups = new Map();
for (const [id, node] of this.nodes) {
const layer = node.layer;
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
layerGroups.get(layer).push(id);
}
for (const [, ids] of layerGroups) {
if (ids.length > 1) {
const quoted = ids
.map((id) => `"${id.replace(/"/g, '\\"')}"`)
.join("; ");
dot += ` { rank=same; ${quoted}; }\n`;
}
}
dot += "}\n";
return dot;
}
}
// ─── Extract results ─────────────────────────────────────────────────
/**
* Parse a Graphviz "x,y" position string into {x, y} in pixels.
* Graphviz Y-axis goes bottom-to-top, so we negate Y for SVG (top-to-bottom).
*/
function parsePos(posStr) {
const parts = posStr.split(",");
return { x: parseFloat(parts[0]), y: -parseFloat(parts[1]) };
}
/**
* Extract node positions from Graphviz and build family connections ourselves.
*
* We IGNORE Graphviz's edge routing entirely. Instead we use node positions
* and the graph structure to draw clean family-tree connectors:
*
* Parent1 Parent2
* | |
* +----+-----+ ← horizontal couple bar
* |
* ------+------ ← horizontal children bus
* | | | |
* C1 C2 C3 C4
*
* This gives merged, clean orthogonal lines at consistent heights.
*
* @returns {LayoutResult}
*/
function extractPositions(gvResult, builder, config) {
const persons = [];
const unions = [];
const connections = []; // family connections, not raw edges
// Map node names to their Graphviz positions
const nodePositions = new Map(); // name → { x, y }
let rootX = 0;
let rootY = 0;
// First pass: collect all positions, find root
for (const obj of gvResult.objects || []) {
if (!obj.name || !obj.pos) continue;
const nodeInfo = builder.nodes.get(obj.name);
if (!nodeInfo) continue;
const pos = parsePos(obj.pos);
nodePositions.set(obj.name, pos);
if (
nodeInfo.type === "person" &&
nodeInfo.data &&
nodeInfo.data.isRoot
) {
rootX = pos.x;
rootY = pos.y;
}
}
const halfW = config.cardWidth / 2;
const halfH = config.cardHeight / 2;
// Second pass: build positioned nodes centered on root
for (const [name, pos] of nodePositions) {
const nodeInfo = builder.nodes.get(name);
if (!nodeInfo) continue;
const cx = pos.x - rootX;
const cy = pos.y - rootY;
if (nodeInfo.type === "person") {
persons.push({
x: cx,
y: cy,
data: nodeInfo.data,
});
} else {
unions.push({
id: name,
x: cx,
y: cy,
});
}
}
// Third pass: build family connections from graph structure.
// For each union node, find its parents (edges INTO it) and
// children (edges OUT of it), then build connector paths.
const incomingToUnion = new Map(); // unionId → [nodeId, ...]
const outgoingFromUnion = new Map(); // unionId → [nodeId, ...]
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") {
// person → union (parent/spouse)
if (!incomingToUnion.has(edge.target))
incomingToUnion.set(edge.target, []);
incomingToUnion.get(edge.target).push(edge.source);
}
if (sourceInfo && sourceInfo.type === "union") {
// union → person (child)
if (!outgoingFromUnion.has(edge.source))
outgoingFromUnion.set(edge.source, []);
outgoingFromUnion.get(edge.source).push(edge.target);
}
}
// For each union, generate clean family-tree connector paths
for (const [unionId, union] of unions.map((u) => [u.id, u])) {
const parents = (incomingToUnion.get(unionId) || [])
.map((id) => {
const pos = nodePositions.get(id);
return pos
? { id, x: pos.x - rootX, y: pos.y - rootY }
: null;
})
.filter(Boolean);
const children = (outgoingFromUnion.get(unionId) || [])
.map((id) => {
const pos = nodePositions.get(id);
return pos
? { id, x: pos.x - rootX, y: pos.y - rootY }
: null;
})
.filter(Boolean);
const ux = union.x;
const uy = union.y;
// --- Parent-to-union connections ---
// Each parent drops a vertical line from card bottom to the union Y,
// then a horizontal bar connects them at union Y.
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 drops from each parent's bottom edge to couple bar
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-to-children connections ---
// Vertical line from union down to bus Y, horizontal bus spanning
// all children, then vertical drops from bus to each child's top.
if (children.length > 0) {
// Bus Y is halfway between union and the first child row
const childY = children[0].y;
const busY = uy + (childY - halfH - uy) / 2;
// Vertical stem from union (or couple bar) down to bus
connections.push({
path: `M ${ux} ${uy} L ${ux} ${busY}`,
cssClass: "link descendant-link",
});
if (children.length === 1) {
// Single child: just continue the 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 };
}