Files
WebtreesFamilyNavigatorGraph/resources/js/modules/lib/chart.js
Alexander Bocken 0369f781fa Initial commit: webtrees Family Navigator Graph sidebar module
SVG-based sidebar widget displaying immediate family relationships
(parents, siblings, spouses, children) with compact card layout,
multi-spouse routing, wrapped rows, and ancestor/descendant indicators.
2026-03-16 14:02:26 +01:00

117 lines
3.8 KiB
JavaScript

/**
* Compact sidebar chart orchestrator.
*
* Renders an inline, auto-sized SVG that flows naturally in the page.
* No zoom/pan — the graph fits the sidebar width, growing vertically as needed.
* Wide generations (many children) are wrapped into multiple rows.
*/
import { computeLayout } from "./layout/layout.js";
import { renderPersonCard } from "./chart/box.js";
import { select } from "./d3.js";
export default class Chart {
constructor(containerSelector, data) {
this.containerSelector = containerSelector;
this.data = data;
this.config = {
cardWidth: 130,
cardHeight: 42,
horizontalSpacing: 18,
verticalSpacing: 45,
};
}
async render() {
const ctr = this.containerSelector;
const chartEl = select(`${ctr} .full-diagram-chart`);
const containerWidth = chartEl.node().getBoundingClientRect().width || 320;
// Pass sidebar width so layout can wrap wide generations
this.config.targetWidth = containerWidth;
const layout = computeLayout(
this.data.persons,
this.data.mainId,
this.config
);
// Compute bounding box of all content
const pad = 12;
const bounds = this.computeBounds(layout, pad);
// Create auto-sized SVG
const svgWidth = bounds.width;
const svgHeight = bounds.height;
const svg = chartEl
.append("svg")
.attr("viewBox", `${bounds.minX} ${bounds.minY} ${svgWidth} ${svgHeight}`)
.attr("width", "100%")
.attr("preserveAspectRatio", "xMidYMid meet")
.style("display", "block");
const canvas = svg.append("g").attr("class", "full-diagram-canvas");
// Click navigates to person's individual page
const onNodeClick = (data) => {
if (data.data.url) {
window.location.href = data.data.url;
}
};
// Draw connections first (behind cards)
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);
}
// Draw person cards
for (const person of layout.persons) {
renderPersonCard(canvas, person, this.config, onNodeClick);
}
}
computeBounds(layout, pad) {
const hw = this.config.cardWidth / 2;
const hh = this.config.cardHeight / 2;
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (const p of layout.persons) {
minX = Math.min(minX, p.x - hw);
maxX = Math.max(maxX, p.x + hw);
// Expand for ancestor/descendant indicators (14px above / 14px below card)
const topExtra = p.data.hasMoreAncestors ? 14 : 0;
const bottomExtra = p.data.hasMoreDescendants ? 14 : 0;
minY = Math.min(minY, p.y - hh - topExtra);
maxY = Math.max(maxY, p.y + hh + bottomExtra);
}
// Include connector endpoints in bounds
for (const c of layout.connections) {
const coords = c.path.match(/-?[\d.]+/g);
if (coords) {
for (let i = 0; i < coords.length; i += 2) {
const x = parseFloat(coords[i]);
const y = parseFloat(coords[i + 1]);
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
}
}
return {
minX: minX - pad,
minY: minY - pad,
width: (maxX - minX) + pad * 2,
height: (maxY - minY) + pad * 2,
};
}
}