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
151 lines
5.2 KiB
JavaScript
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";
|