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
This commit is contained in:
2026-03-14 18:51:19 +01:00
commit 273e398431
38 changed files with 5232 additions and 0 deletions

1
resources/js/full-diagram.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
/**
* Chart configuration defaults.
*/
export default class Configuration {
constructor() {
this.cardWidth = 200;
this.cardHeight = 80;
this.cardPadding = 10;
this.horizontalSpacing = 30;
this.verticalSpacing = 40;
this.siblingSpacing = 30;
this.duration = 500;
}
}

View File

@@ -0,0 +1,44 @@
/**
* Data loading and management.
*/
export default class DataLoader {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
/**
* Load tree data for a specific individual via AJAX.
*
* @param {string} xref
* @param {object} params
* @returns {Promise<object>}
*/
async load(xref, params = {}) {
const url = new URL(this.baseUrl.replace("__XREF__", xref), window.location.origin);
url.searchParams.set("ajax", "1");
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
const response = await fetch(url.toString(), {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to load data: ${response.status}`);
}
const result = await response.json();
return result.data;
}
/**
* Navigate to a person's chart page.
*
* @param {string} xref
*/
navigateTo(xref) {
window.location.href = this.baseUrl.replace("__XREF__", xref);
}
}

View File

@@ -0,0 +1,270 @@
/**
* Dual-tree D3 hierarchy builder.
*
* Converts the PHP-generated tree data into two D3 hierarchies:
* - Ancestor tree (root at bottom, growing upward)
* - Descendant tree (root at top, growing downward)
*
* Both share the root node at (0, 0) and are merged for rendering.
*
* After D3 layout:
* 1. Shift descendant subtrees so children center under the couple midpoint
* 2. Compute spouse positions at every level
* 3. Resolve all overlaps (person nodes, spouses, siblings) by pushing
* cards apart and propagating shifts to subtrees
*/
import { tree, hierarchy } from "../lib/d3.js";
import { computeSpouseOffset, SPOUSE_GAP } from "../lib/tree/spouse-util.js";
// ─── Ancestor hierarchy ──────────────────────────────────────────────
export function buildAncestorHierarchy(rootData) {
if (!rootData.parents || rootData.parents.length === 0) {
return null;
}
const root = prepareAncestorNode(rootData);
return hierarchy(root, (d) => d._ancestorChildren);
}
function prepareAncestorNode(person) {
const node = { ...person };
if (person.parents && person.parents.length > 0) {
node._ancestorChildren = person.parents.map((p) => prepareAncestorNode(p));
}
return node;
}
// ─── Descendant hierarchy ────────────────────────────────────────────
export function buildDescendantHierarchy(rootData) {
if (!rootData.families || rootData.families.length === 0) {
return null;
}
const root = prepareDescendantNode(rootData);
return hierarchy(root, (d) => d._descendantChildren);
}
function prepareDescendantNode(person) {
const node = { ...person };
const children = [];
if (person.families) {
for (let fi = 0; fi < person.families.length; fi++) {
const family = person.families[fi];
for (const child of family.children || []) {
const childNode = prepareDescendantNode(child);
childNode._familyIndex = fi;
children.push(childNode);
}
}
}
if (children.length > 0) {
node._descendantChildren = children;
}
return node;
}
// ─── Layout computation ──────────────────────────────────────────────
export function computeLayout(ancestorHierarchy, descendantHierarchy, config) {
const nodeWidth = config.cardWidth + config.horizontalSpacing;
const nodeHeight = config.cardHeight + config.verticalSpacing;
const treeLayout = tree().nodeSize([nodeWidth, nodeHeight]);
const result = {
ancestors: [],
descendants: [],
ancestorLinks: [],
descendantLinks: [],
};
// ── Ancestor tree (grows upward) ──
if (ancestorHierarchy) {
treeLayout(ancestorHierarchy);
ancestorHierarchy.each((node) => {
node.y = -Math.abs(node.y);
if (node.depth > 0) result.ancestors.push(node);
});
ancestorHierarchy.links().forEach((link) => {
result.ancestorLinks.push(link);
});
}
// ── Descendant tree (grows downward) ──
if (descendantHierarchy) {
treeLayout(descendantHierarchy);
// Step 1: shift children to center under couple midpoints
shiftChildrenToCoupleCenter(descendantHierarchy, config);
// Step 2: resolve overlaps between all cards on the same row
// (person nodes + their spouse cards)
resolveDescendantOverlaps(descendantHierarchy, config);
descendantHierarchy.each((node) => {
if (node.depth > 0) result.descendants.push(node);
});
descendantHierarchy.links().forEach((link) => {
result.descendantLinks.push(link);
});
}
return result;
}
// ─── Couple-center shifting ──────────────────────────────────────────
function shiftChildrenToCoupleCenter(root, config) {
// Process bottom-up so nested shifts accumulate correctly
root.each((node) => {
const data = node.data;
if (!data.families || data.families.length === 0 || !node.children) return;
if (data.families.length === 1) {
const family = data.families[0];
if (family.spouse) {
const shift = computeSpouseOffset(0, config.cardWidth, SPOUSE_GAP) / 2;
for (const child of node.children) {
shiftSubtree(child, shift);
}
}
return;
}
for (const child of node.children) {
const fi = child.data._familyIndex;
if (fi === undefined) continue;
const family = data.families[fi];
if (family && family.spouse) {
const shift = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP) / 2;
shiftSubtree(child, shift);
}
}
});
}
function shiftSubtree(node, dx) {
node.x += dx;
if (node.children) {
for (const child of node.children) {
shiftSubtree(child, dx);
}
}
}
// ─── Overlap resolution ──────────────────────────────────────────────
/**
* Collect every card rectangle that will be rendered on each row
* (person nodes + spouse cards), detect overlaps, and push apart.
*
* When a person node is pushed, its entire descendant subtree moves too.
*
* Strategy: for each depth row, build a sorted list of "card groups"
* (a person + all their spouses form one rigid group). Then do a single
* left-to-right sweep pushing groups apart when they overlap.
*/
function resolveDescendantOverlaps(root, config) {
const w = config.cardWidth;
const minGap = 20;
// Gather hierarchy nodes by depth
const depthMap = new Map();
root.each((node) => {
if (!depthMap.has(node.depth)) depthMap.set(node.depth, []);
depthMap.get(node.depth).push(node);
});
const depths = [...depthMap.keys()].sort((a, b) => a - b);
for (const depth of depths) {
const nodesAtDepth = depthMap.get(depth);
// Build card groups: each person node + their spouses as a rigid unit
// A group has a leftEdge and rightEdge computed from all its cards.
const groups = [];
for (const node of nodesAtDepth) {
const xs = [node.x]; // person card center
const data = node.data;
if (data.families) {
for (let fi = 0; fi < data.families.length; fi++) {
if (data.families[fi].spouse) {
xs.push(node.x + computeSpouseOffset(fi, w, SPOUSE_GAP));
}
}
}
const leftEdge = Math.min(...xs) - w / 2;
const rightEdge = Math.max(...xs) + w / 2;
groups.push({ node, leftEdge, rightEdge, centerX: node.x });
}
// Sort by the left edge of each group
groups.sort((a, b) => a.leftEdge - b.leftEdge);
// Single left-to-right sweep: push groups apart
for (let i = 1; i < groups.length; i++) {
const prev = groups[i - 1];
const curr = groups[i];
const overlap = prev.rightEdge + minGap - curr.leftEdge;
if (overlap > 0) {
// Push current group (and its subtree) right
shiftSubtree(curr.node, overlap);
curr.leftEdge += overlap;
curr.rightEdge += overlap;
curr.centerX += overlap;
}
}
}
}
// ─── Sibling positions ───────────────────────────────────────────────
/**
* Compute sibling positions at the same Y-level as root (0).
*
* Siblings are placed to the right of root. If the root has spouses,
* siblings start after the rightmost spouse to avoid overlap.
*/
export function computeSiblingPositions(rootData, config) {
const siblings = [];
const links = [];
if (!rootData.siblings || rootData.siblings.length === 0) {
return { siblings, links };
}
// Find the rightmost occupied X at root level (root card + any spouses)
let maxRootX = config.cardWidth / 2; // right edge of root card
if (rootData.families) {
for (let fi = 0; fi < rootData.families.length; fi++) {
if (rootData.families[fi].spouse) {
const spouseX = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP);
const spouseRight = spouseX + config.cardWidth / 2;
maxRootX = Math.max(maxRootX, spouseRight);
}
}
}
const startX = maxRootX + config.siblingSpacing;
rootData.siblings.forEach((sibling, index) => {
const x = startX + index * (config.cardWidth + config.siblingSpacing);
const y = 0;
siblings.push({ x, y, data: sibling });
links.push({ source: { x: 0, y: 0 }, target: { x, y } });
});
return { siblings, links };
}

View File

@@ -0,0 +1,30 @@
/**
* Full Diagram — entry point.
*
* Reads embedded data from the page and initializes the chart.
*/
import Chart from "./lib/chart.js";
async function init() {
const data = window.fullDiagramData;
const baseUrl = window.fullDiagramBaseUrl;
if (!data || !data.persons) {
console.error("Full Diagram: No tree data found.");
return;
}
try {
const chart = new Chart("#full-diagram-container", data, baseUrl);
await chart.render();
} catch (err) {
console.error("Full Diagram: Render failed", err);
}
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}

View File

@@ -0,0 +1,90 @@
/**
* Main chart orchestrator.
*
* Uses ELK for layout (Sugiyama / union-node pattern) and D3 for
* SVG rendering with clean orthogonal bus-line connectors.
*/
import { computeElkLayout } from "./layout/elk-layout.js";
import { createSvg, getCanvas } from "./chart/svg.js";
import { initZoom, createZoomControls } from "./chart/zoom.js";
import { renderPersonCard } from "./chart/box.js";
import { hideTooltip } from "./chart/overlay.js";
import { zoomIdentity } from "./d3.js";
export default class Chart {
constructor(containerSelector, data, baseUrl) {
this.containerSelector = containerSelector;
this.data = data;
this.config = {
cardWidth: 200,
cardHeight: 80,
horizontalSpacing: 30,
verticalSpacing: 60,
};
this.baseUrl = baseUrl;
}
async render() {
const ctr = this.containerSelector;
const chartSelector = `${ctr} .full-diagram-chart`;
const svg = createSvg(chartSelector);
this.svg = svg;
const zoomBehavior = initZoom(svg);
this.zoomBehavior = zoomBehavior;
svg.on("zoom.tooltip", () => hideTooltip());
createZoomControls(ctr, svg, zoomBehavior);
const canvas = getCanvas(svg);
// Compute layout using ELK
const layout = await computeElkLayout(
this.data.persons,
this.data.mainId,
this.config
);
// Click handler
const baseUrl = this.baseUrl;
const onNodeClick = (data) => {
hideTooltip();
const url = baseUrl.replace("__XREF__", data.id);
window.location.href = url;
};
// Draw connections first (behind cards)
this.renderConnections(canvas, layout);
// Draw person cards
for (const person of layout.persons) {
renderPersonCard(canvas, person, this.config, onNodeClick, ctr);
}
// Center on root
this.centerOnRoot();
}
renderConnections(canvas, layout) {
const linkGroup = canvas.append("g").attr("class", "edges");
for (const conn of layout.connections) {
linkGroup
.append("path")
.attr("class", conn.cssClass)
.attr("d", conn.path);
}
}
centerOnRoot() {
const { width, height } = this.svg.node().getBoundingClientRect();
this.svg
.transition()
.duration(500)
.call(
this.zoomBehavior.transform,
zoomIdentity.translate(width / 2, height / 2)
);
}
}

View File

@@ -0,0 +1,210 @@
/**
* Person card (box) renderer.
*
* Shows first + last name (middle names dropped to save space).
* Profile picture displayed if available, otherwise a gendered silhouette.
* Hover shows a rich bio card with dates, places, occupation, age.
*/
import { attachHoverBioCard } from "./overlay.js";
/**
* Render a person card as an SVG group.
*
* @param {d3.Selection} parent - The parent SVG group to append to
* @param {object} person - { x, y, id, isMain, data: { gender, "first name", ... } }
* @param {object} config
* @param {Function} onClick - Click handler receiving { id, data }
* @param {string} containerSelector - Selector for the chart container (for tooltip positioning)
* @returns {d3.Selection}
*/
export function renderPersonCard(parent, person, config, onClick, containerSelector) {
const data = person.data;
const w = config.cardWidth;
const h = config.cardHeight;
const sexClass = `sex-${(data.gender || "u").toLowerCase()}`;
const rootClass = person.isMain ? "is-root" : "";
const g = parent
.append("g")
.attr("class", `person-card ${sexClass} ${rootClass}`.trim())
.attr("transform", `translate(${person.x - w / 2}, ${person.y - h / 2})`)
.style("cursor", "pointer")
.on("click", (event) => {
event.stopPropagation();
onClick({ id: person.id, data });
});
// Card background
g.append("rect")
.attr("width", w)
.attr("height", h)
.attr("rx", 8)
.attr("ry", 8);
// Photo area (left side)
const photoSize = 50;
const photoX = 8;
const photoY = (h - photoSize) / 2;
const textXOffset = photoX + photoSize + 10;
// Clip path for circular photo
const clipId = `clip-${person.id}-${Math.random().toString(36).slice(2, 8)}`;
g.append("clipPath")
.attr("id", clipId)
.append("circle")
.attr("cx", photoX + photoSize / 2)
.attr("cy", photoY + photoSize / 2)
.attr("r", photoSize / 2 - 2);
if (data.avatar) {
// Profile picture
g.append("image")
.attr("href", data.avatar)
.attr("x", photoX)
.attr("y", photoY)
.attr("width", photoSize)
.attr("height", photoSize)
.attr("preserveAspectRatio", "xMidYMid slice")
.attr("clip-path", `url(#${clipId})`);
} else {
// Silhouette placeholder circle
g.append("circle")
.attr("cx", photoX + photoSize / 2)
.attr("cy", photoY + photoSize / 2)
.attr("r", photoSize / 2 - 2)
.attr("class", "photo-placeholder");
// Simple silhouette icon
const cx = photoX + photoSize / 2;
const cy = photoY + photoSize / 2;
// Head
g.append("circle")
.attr("cx", cx)
.attr("cy", cy - 6)
.attr("r", 8)
.attr("class", "silhouette");
// Body
g.append("ellipse")
.attr("cx", cx)
.attr("cy", cy + 14)
.attr("rx", 12)
.attr("ry", 9)
.attr("class", "silhouette");
}
// Name: first + last (drop middle names)
const firstName = data["first name"] || "";
const lastName = data["last name"] || "";
const displayName = formatDisplayName(firstName, lastName, data.fullName);
const maxTextWidth = w - textXOffset - 8;
g.append("text")
.attr("class", "person-name")
.attr("x", textXOffset)
.attr("y", h / 2 - 10)
.text(truncateText(displayName, maxTextWidth));
// Dates line
const dates = formatDates(data.birthYear, data.deathYear, data.isDead);
if (dates) {
g.append("text")
.attr("class", "person-dates")
.attr("x", textXOffset)
.attr("y", h / 2 + 6)
.text(dates);
}
// Occupation as a third line
const subtitle = data.occupation || "";
if (subtitle) {
g.append("text")
.attr("class", "person-subtitle")
.attr("x", textXOffset)
.attr("y", h / 2 + 20)
.text(truncateText(subtitle, maxTextWidth));
}
// "More ancestors" indicator — two small parent boxes at top-right
if (data.hasMoreAncestors) {
const ig = g.append("g").attr("class", "more-ancestors-indicator");
// Positioning: top-right of card, protruding above
const bw = 10; // mini box width
const bh = 7; // mini box height
const gap = 4; // gap between boxes
const cx = w - 25; // center X of the indicator
const topY = -14; // top of mini boxes (above card)
const leftX = cx - gap / 2 - bw;
const rightX = cx + gap / 2;
// Two small rectangles (parents)
ig.append("rect")
.attr("x", leftX).attr("y", topY)
.attr("width", bw).attr("height", bh)
.attr("rx", 2).attr("ry", 2);
ig.append("rect")
.attr("x", rightX).attr("y", topY)
.attr("width", bw).attr("height", bh)
.attr("rx", 2).attr("ry", 2);
// Horizontal bar connecting the two boxes at their bottom center
const barY = topY + bh;
ig.append("line")
.attr("x1", leftX + bw / 2).attr("y1", barY)
.attr("x2", rightX + bw / 2).attr("y2", barY);
// Vertical line from bar center down to card top edge
ig.append("line")
.attr("x1", cx).attr("y1", barY)
.attr("x2", cx).attr("y2", 0);
}
// Attach hover bio card
if (containerSelector) {
attachHoverBioCard(g, data, containerSelector);
}
return g;
}
/**
* Format display name: first name + last name, dropping middle names.
* Handles GEDCOM placeholders: @N.N. = unknown name, @P.N. = unknown given name
*/
function formatDisplayName(firstName, lastName, fullName) {
// Clean GEDCOM unknown-name placeholders
const cleanFirst = firstName && !firstName.match(/^@[A-Z]\.N\.$/) ? firstName : "";
const cleanLast = lastName && !lastName.match(/^@[A-Z]\.N\.$/) ? lastName : "";
if (!cleanFirst && !cleanLast) {
// Also clean fullName of @N.N. patterns
const cleanFull = fullName ? fullName.replace(/@[A-Z]\.N\./g, "\u2026").trim() : "";
return cleanFull || "???";
}
// Take only the first given name (drop middle names)
const firstOnly = cleanFirst ? cleanFirst.split(/\s+/)[0] : "";
if (firstOnly && cleanLast) {
return `${firstOnly} ${cleanLast}`;
}
return firstOnly || cleanLast || "???";
}
function truncateText(text, maxWidth) {
// ~7px per character at 12px font
const maxChars = Math.floor(maxWidth / 7);
if (!text || text.length <= maxChars) return text || "";
return text.substring(0, maxChars - 1) + "\u2026";
}
function formatDates(birth, death, isDead) {
if (!birth && !death) return "";
if (birth && death) return `${birth}\u2013${death}`;
if (birth && isDead) return `${birth}\u2013?`;
if (birth) return `* ${birth}`;
return `\u2020 ${death}`;
}

View File

@@ -0,0 +1,145 @@
/**
* Bio card tooltip on hover.
*
* Shows: full name, profile photo, birth, baptism, marriage, death,
* occupation, residence, current age (if alive) or age at death.
*/
import { select } from "../d3.js";
let activeTooltip = null;
let hideTimer = null;
/**
* Show a bio card tooltip for a person.
*
* @param {object} data - Person data
* @param {SVGElement} cardElement - The SVG card group element
* @param {string} containerSelector
*/
export function showBioCard(data, cardElement, containerSelector) {
hideTooltip();
const container = select(containerSelector);
const containerRect = container.node().getBoundingClientRect();
const cardRect = cardElement.getBoundingClientRect();
// Position tooltip below the card, centered
const left = cardRect.left - containerRect.left + cardRect.width / 2;
const top = cardRect.bottom - containerRect.top + 8;
const tooltip = container
.append("div")
.attr("class", "bio-card")
.style("left", `${left}px`)
.style("top", `${top}px`)
.style("transform", "translateX(-50%)")
.on("mouseenter", () => clearTimeout(hideTimer))
.on("mouseleave", () => scheduleHide());
// Header: photo + name
const header = tooltip.append("div").attr("class", "bio-header");
if (data.avatar) {
header
.append("img")
.attr("src", data.avatar)
.attr("alt", data.fullName || "")
.attr("class", "bio-photo");
}
const headerText = header.append("div").attr("class", "bio-header-text");
headerText.append("div").attr("class", "bio-name").text(data.fullName || "???");
// Age
const ageText = computeAge(data);
if (ageText) {
headerText.append("div").attr("class", "bio-age").text(ageText);
}
// Facts list
const facts = tooltip.append("div").attr("class", "bio-facts");
addFact(facts, "Born", data.birthDate, data.birthPlace);
addFact(facts, "Baptism", data.baptismDate);
addFact(facts, "Marriage", data.marriageDate);
addFact(facts, "Died", data.deathDate, data.deathPlace);
addFact(facts, "Occupation", data.occupation);
addFact(facts, "Residence", data.residence);
// Link to profile
tooltip
.append("a")
.attr("href", data.url)
.attr("class", "bio-link")
.text("View profile \u2192");
activeTooltip = tooltip;
}
function addFact(container, label, value, place) {
if (!value && !place) return;
const row = container.append("div").attr("class", "bio-fact");
row.append("span").attr("class", "bio-fact-label").text(label);
let display = value || "";
if (place) {
display += display ? `, ${place}` : place;
}
row.append("span").attr("class", "bio-fact-value").text(display);
}
function computeAge(data) {
if (!data.birthYear) return "";
const birthYear = parseInt(data.birthYear, 10);
if (isNaN(birthYear)) return "";
if (data.isDead) {
if (data.deathYear) {
const deathYear = parseInt(data.deathYear, 10);
if (!isNaN(deathYear)) {
const age = deathYear - birthYear;
return `Died at age ${age}`;
}
}
return "Deceased";
}
const currentYear = new Date().getFullYear();
const age = currentYear - birthYear;
return `Age ~${age}`;
}
function scheduleHide() {
hideTimer = setTimeout(hideTooltip, 300);
}
/**
* Hide the active tooltip.
*/
export function hideTooltip() {
clearTimeout(hideTimer);
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}
/**
* Attach hover behavior to a person card group.
*
* @param {d3.Selection} cardGroup - The SVG <g> for the person card
* @param {object} data - Person data
* @param {string} containerSelector
*/
export function attachHoverBioCard(cardGroup, data, containerSelector) {
cardGroup
.on("mouseenter", function () {
clearTimeout(hideTimer);
showBioCard(data, this, containerSelector);
})
.on("mouseleave", () => {
scheduleHide();
});
}

View File

@@ -0,0 +1,60 @@
/**
* SVG container setup.
*/
import { select } from "../d3.js";
/**
* Create the main SVG element within the container.
*
* @param {string} selector - CSS selector for the container element
* @returns {d3.Selection} The SVG selection
*/
export function createSvg(selector) {
const container = select(selector);
const { width, height } = container.node().getBoundingClientRect();
const svg = container
.append("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", `0 0 ${width} ${height}`);
// Main group that will be transformed by zoom/pan
svg.append("g").attr("class", "full-diagram-canvas");
return svg;
}
/**
* Get the canvas group (the zoomable/pannable layer).
*
* @param {d3.Selection} svg
* @returns {d3.Selection}
*/
export function getCanvas(svg) {
return svg.select("g.full-diagram-canvas");
}
/**
* Center the canvas on the root node.
*
* @param {d3.Selection} svg
* @param {d3.ZoomBehavior} zoomBehavior
*/
export function centerOnRoot(svg, zoomBehavior) {
const { width, height } = svg.node().getBoundingClientRect();
const initialTransform = {
x: width / 2,
y: height / 2,
k: 1,
};
svg.call(
zoomBehavior.transform,
() =>
new DOMMatrix()
.translate(initialTransform.x, initialTransform.y)
.scale(initialTransform.k)
);
}

View File

@@ -0,0 +1,72 @@
/**
* Pan and zoom behavior via d3-zoom.
*/
import { zoom, zoomIdentity, select } from "../d3.js";
import { getCanvas } from "./svg.js";
/**
* Initialize zoom behavior on the SVG element.
*
* @param {d3.Selection} svg
* @returns {d3.ZoomBehavior}
*/
export function initZoom(svg) {
const canvas = getCanvas(svg);
const zoomBehavior = zoom()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
canvas.attr("transform", event.transform);
});
svg.call(zoomBehavior);
// Disable double-click zoom (we use click for navigation)
svg.on("dblclick.zoom", null);
return zoomBehavior;
}
/**
* Create zoom control buttons.
*
* @param {string} containerSelector
* @param {d3.Selection} svg
* @param {d3.ZoomBehavior} zoomBehavior
*/
export function createZoomControls(containerSelector, svg, zoomBehavior) {
const container = select(containerSelector);
const controls = container
.append("div")
.attr("class", "zoom-controls");
controls
.append("button")
.attr("type", "button")
.attr("title", "Zoom in")
.text("+")
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 1.3));
controls
.append("button")
.attr("type", "button")
.attr("title", "Zoom out")
.text("\u2212")
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 0.7));
controls
.append("button")
.attr("type", "button")
.attr("title", "Reset view")
.text("\u21BA")
.on("click", () => {
const { width, height } = svg.node().getBoundingClientRect();
svg.transition()
.duration(500)
.call(
zoomBehavior.transform,
zoomIdentity.translate(width / 2, height / 2)
);
});
}

6
resources/js/modules/lib/d3.js vendored Normal file
View File

@@ -0,0 +1,6 @@
/**
* Cherry-picked D3 module re-exports.
*/
export { select, selectAll } from "d3-selection";
export { zoom, zoomIdentity } from "d3-zoom";
import "d3-transition";

View File

@@ -0,0 +1,472 @@
/**
* 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 };
}

View File

@@ -0,0 +1,457 @@
/**
* 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 };
}

View File

@@ -0,0 +1,31 @@
/**
* Ancestor tree rendering — draws ancestor nodes and links upward from root.
*/
import { renderPersonCard } from "../chart/box.js";
import { drawElbowLink } from "./link-drawer.js";
/**
* Render the ancestor tree nodes and links.
*
* @param {d3.Selection} canvas - The SVG canvas group
* @param {Array} nodes - Ancestor hierarchy nodes (from computeLayout)
* @param {Array} links - Ancestor hierarchy links
* @param {Configuration} config
* @param {Function} onNodeClick
* @param {string} containerSelector
*/
export function renderAncestorTree(canvas, nodes, links, config, onNodeClick, containerSelector) {
// Draw links first (behind nodes)
const linkGroup = canvas.append("g").attr("class", "ancestor-links");
for (const link of links) {
drawElbowLink(linkGroup, link.source, link.target, "ancestor-link", config);
}
// Draw nodes
const nodeGroup = canvas.append("g").attr("class", "ancestor-nodes");
for (const node of nodes) {
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
}
}

View File

@@ -0,0 +1,150 @@
/**
* Descendant tree rendering.
*
* Key design: children descend from *both* parents. A horizontal connector
* joins the couple, and the vertical drop to children originates from the
* midpoint of that connector — not from a single parent.
*
* Multiple spouses are placed alternating left/right with increasing distance
* to avoid overlap between family branches.
*/
import { renderPersonCard } from "../chart/box.js";
import { computeSpouseOffset, SPOUSE_GAP } from "./spouse-util.js";
/**
* Render the descendant tree: nodes, spouse nodes, and couple-centered links.
*/
export function renderDescendantTree(canvas, nodes, _links, rootData, config, onNodeClick, containerSelector) {
const linkGroup = canvas.append("g").attr("class", "descendant-links");
const nodeGroup = canvas.append("g").attr("class", "descendant-nodes");
// Build a map of xref → D3 node position for all descendant nodes + root
const posMap = new Map();
posMap.set(rootData.xref, { x: 0, y: 0 });
for (const node of nodes) {
posMap.set(node.data.xref, { x: node.x, y: node.y });
}
// Render all descendant person cards
for (const node of nodes) {
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
}
// Render spouses and couple-centered links at every level (including root)
const allPersons = [rootData, ...nodes.map((n) => n.data)];
for (const person of allPersons) {
renderCoupleLinks(linkGroup, nodeGroup, person, posMap, config, onNodeClick, containerSelector);
}
}
/**
* For a person with families, render each spouse and draw the
* couple → children connector.
*
* Multiple spouses alternate left (odd index) / right (even index)
* with increasing distance.
*/
function renderCoupleLinks(linkGroup, nodeGroup, personData, posMap, config, onNodeClick, containerSelector) {
if (!personData.families || personData.families.length === 0) return;
const personPos = posMap.get(personData.xref);
if (!personPos) return;
const w = config.cardWidth;
const h = config.cardHeight;
const halfH = h / 2;
personData.families.forEach((family, familyIndex) => {
// Alternate spouse placement: first right, second left, third further right, etc.
const spouseOffset = computeSpouseOffset(familyIndex, w, SPOUSE_GAP);
let spousePos = null;
if (family.spouse) {
spousePos = {
x: personPos.x + spouseOffset,
y: personPos.y,
};
// Render spouse card
renderPersonCard(
nodeGroup,
{ x: spousePos.x, y: spousePos.y, data: family.spouse },
config,
onNodeClick,
containerSelector
);
// Horizontal connector between the couple (edge-to-edge)
const leftX = Math.min(personPos.x, spousePos.x);
const rightX = Math.max(personPos.x, spousePos.x);
linkGroup
.append("line")
.attr("class", "link spouse-link")
.attr("x1", leftX + w / 2)
.attr("y1", personPos.y)
.attr("x2", rightX - w / 2)
.attr("y2", personPos.y);
}
// Collect children positions for this family
const childPositions = [];
for (const child of family.children || []) {
const childPos = posMap.get(child.xref);
if (childPos) {
childPositions.push(childPos);
}
}
if (childPositions.length === 0) return;
// Couple midpoint X (centered between parents)
const coupleX = spousePos
? (personPos.x + spousePos.x) / 2
: personPos.x;
// Y coordinates: bottom of parent card → top of child card
const parentBottomY = personPos.y + halfH;
const childTopY = childPositions[0].y - halfH;
// Horizontal rail sits 40% of the way down from parent to child
const railY = parentBottomY + (childTopY - parentBottomY) * 0.4;
// 1. Vertical line: couple bottom → rail
linkGroup
.append("line")
.attr("class", "link descendant-link")
.attr("x1", coupleX)
.attr("y1", parentBottomY)
.attr("x2", coupleX)
.attr("y2", railY);
// 2. Horizontal rail spanning all children
const xs = childPositions.map((c) => c.x);
const minX = Math.min(coupleX, ...xs);
const maxX = Math.max(coupleX, ...xs);
linkGroup
.append("line")
.attr("class", "link descendant-link")
.attr("x1", minX)
.attr("y1", railY)
.attr("x2", maxX)
.attr("y2", railY);
// 3. Vertical drops: rail → top of each child card
for (const cp of childPositions) {
linkGroup
.append("line")
.attr("class", "link descendant-link")
.attr("x1", cp.x)
.attr("y1", railY)
.attr("x2", cp.x)
.attr("y2", childTopY);
}
});
}
// Re-export from shared utility
export { computeSpouseOffset } from "./spouse-util.js";

View File

@@ -0,0 +1,83 @@
/**
* Family connector utilities.
*
* Draws special connectors between spouses and from couples to children.
*/
/**
* Draw a family connector: horizontal line between spouses,
* then vertical drop to children.
*
* @param {d3.Selection} group
* @param {object} parent1 - First parent position {x, y}
* @param {object} parent2 - Second parent position {x, y}
* @param {Array} children - Child positions [{x, y}, ...]
* @param {Configuration} config
*/
export function drawFamilyConnector(group, parent1, parent2, children, config) {
const halfHeight = config.cardHeight / 2;
// Horizontal line between spouses
if (parent2) {
group
.append("line")
.attr("class", "link spouse-link")
.attr("x1", parent1.x)
.attr("y1", parent1.y)
.attr("x2", parent2.x)
.attr("y2", parent2.y)
.attr("stroke", "#d4a87b")
.attr("stroke-width", 2);
}
if (children.length === 0) return;
// Midpoint between parents (or just parent1 if single parent)
const coupleX = parent2 ? (parent1.x + parent2.x) / 2 : parent1.x;
const coupleY = parent1.y + halfHeight;
// Vertical drop from couple midpoint
const childrenY = children[0].y - halfHeight;
const midY = (coupleY + childrenY) / 2;
group
.append("line")
.attr("class", "link descendant-link")
.attr("x1", coupleX)
.attr("y1", coupleY)
.attr("x2", coupleX)
.attr("y2", midY);
if (children.length === 1) {
// Single child — straight line down
group
.append("line")
.attr("class", "link descendant-link")
.attr("x1", coupleX)
.attr("y1", midY)
.attr("x2", children[0].x)
.attr("y2", childrenY);
} else {
// Multiple children — horizontal rail
const minX = Math.min(...children.map((c) => c.x));
const maxX = Math.max(...children.map((c) => c.x));
group
.append("line")
.attr("class", "link descendant-link")
.attr("x1", minX)
.attr("y1", midY)
.attr("x2", maxX)
.attr("y2", midY);
for (const child of children) {
group
.append("line")
.attr("class", "link descendant-link")
.attr("x1", child.x)
.attr("y1", midY)
.attr("x2", child.x)
.attr("y2", childrenY);
}
}
}

View File

@@ -0,0 +1,31 @@
/**
* Link/connector drawing utilities.
*
* Uses elbow (right-angle) connectors for a clean genealogy look.
*/
/**
* Draw an elbow link between source and target nodes.
*
* @param {d3.Selection} group - SVG group to append the path to
* @param {object} source - Source node with x, y coordinates
* @param {object} target - Target node with x, y coordinates
* @param {string} cssClass - Additional CSS class for the link
* @param {Configuration} config
*/
export function drawElbowLink(group, source, target, cssClass, config) {
const halfHeight = config.cardHeight / 2;
// Midpoint Y between source and target
const midY = (source.y + target.y) / 2;
const path = `M ${source.x} ${source.y + (target.y > source.y ? halfHeight : -halfHeight)}
L ${source.x} ${midY}
L ${target.x} ${midY}
L ${target.x} ${target.y + (target.y > source.y ? -halfHeight : halfHeight)}`;
group
.append("path")
.attr("class", `link ${cssClass}`)
.attr("d", path);
}

View File

@@ -0,0 +1,60 @@
/**
* Sibling node layout and rendering.
*
* Siblings are placed at the same Y-level as the root, to the left.
* Connected via T-junction from the parent link.
*/
import { renderPersonCard } from "../chart/box.js";
/**
* Render sibling nodes and their connectors.
*
* @param {d3.Selection} canvas
* @param {Array} siblings - Sibling position data from computeSiblingPositions
* @param {Array} links - Sibling links
* @param {Configuration} config
* @param {Function} onNodeClick
* @param {string} containerSelector
*/
export function renderSiblings(canvas, siblings, links, config, onNodeClick, containerSelector) {
if (siblings.length === 0) return;
const siblingGroup = canvas.append("g").attr("class", "sibling-nodes");
const linkGroup = canvas.append("g").attr("class", "sibling-links");
// Draw a horizontal rail connecting all siblings + root
const minX = 0;
const maxX = siblings[siblings.length - 1].x;
const railY = -config.cardHeight / 2 - 15;
// Vertical connector from parent area to rail
linkGroup
.append("line")
.attr("class", "link sibling-link")
.attr("x1", 0)
.attr("y1", -config.cardHeight / 2)
.attr("x2", 0)
.attr("y2", railY);
// Horizontal rail
linkGroup
.append("line")
.attr("class", "link sibling-link")
.attr("x1", minX)
.attr("y1", railY)
.attr("x2", maxX)
.attr("y2", railY);
// Vertical drops from rail to each sibling
for (const sibling of siblings) {
linkGroup
.append("line")
.attr("class", "link sibling-link")
.attr("x1", sibling.x)
.attr("y1", railY)
.attr("x2", sibling.x)
.attr("y2", sibling.y - config.cardHeight / 2);
renderPersonCard(siblingGroup, sibling, config, onNodeClick, containerSelector);
}
}

View File

@@ -0,0 +1,39 @@
/**
* Shared spouse placement utilities.
*/
export const SPOUSE_GAP = 10; // px between partner cards
/**
* Compute the X offset for spouse placement.
*
* Alternates right/left with increasing distance:
* family 0 → right (1 * offset)
* family 1 → left (-1 * offset)
* family 2 → right (2 * offset)
* family 3 → left (-2 * offset)
*
* @param {number} index - Family index (0-based)
* @param {number} cardWidth
* @param {number} gap
* @returns {number} X offset from person position
*/
export function computeSpouseOffset(index, cardWidth, gap) {
const unit = cardWidth + gap;
const distance = Math.floor(index / 2) + 1;
const direction = index % 2 === 0 ? 1 : -1;
return direction * distance * unit;
}
/**
* Compute the couple midpoint X offset for a given family index.
* This is half the spouse offset (the center between person and spouse).
*
* @param {number} familyIndex
* @param {number} cardWidth
* @param {number} gap
* @returns {number}
*/
export function coupleMidpointOffset(familyIndex, cardWidth, gap) {
return computeSpouseOffset(familyIndex, cardWidth, gap) / 2;
}