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:
@@ -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";
|
||||
Reference in New Issue
Block a user