Files
WebtreesFullDiagram/resources/js/modules/lib/tree/descendant-tree.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

151 lines
5.2 KiB
JavaScript

/**
* 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";