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
458 lines
15 KiB
JavaScript
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 };
|
|
}
|