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.
This commit is contained in:
2026-03-16 14:02:13 +01:00
commit 0369f781fa
17 changed files with 2737 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,207 @@
/**
* Compact person card renderer for the sidebar graph.
*
* Smaller cards (130x42) with circular photo and name only.
* Birth/death years shown as a second line.
* Indicators show when more ancestors/descendants exist beyond the graph.
*/
/**
* Render a compact person card as an SVG group.
*/
export function renderPersonCard(parent, person, config, onClick) {
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 });
});
// "More ancestors" indicator — drawn first so card renders on top
if (data.hasMoreAncestors) {
const ig = g.append("g")
.attr("class", "more-ancestors-indicator")
.style("cursor", "pointer")
.on("click", (event) => {
event.stopPropagation();
onClick({ id: person.id, data });
});
const bw = 10, bh = 7, gap = 4;
const cx = w - 25;
const topY = -14;
const leftX = cx - gap / 2 - bw;
const rightX = cx + gap / 2;
const barY = topY + bh;
ig.append("line")
.attr("x1", leftX + bw / 2).attr("y1", barY)
.attr("x2", rightX + bw / 2).attr("y2", barY);
ig.append("line")
.attr("x1", cx).attr("y1", barY)
.attr("x2", cx).attr("y2", 0);
ig.append("rect")
.attr("x", leftX).attr("y", topY)
.attr("width", bw).attr("height", bh)
.attr("rx", 1).attr("ry", 1);
ig.append("rect")
.attr("x", rightX).attr("y", topY)
.attr("width", bw).attr("height", bh)
.attr("rx", 1).attr("ry", 1);
}
// "More descendants" indicator — drawn first so card renders on top
if (data.hasMoreDescendants) {
const ig = g.append("g")
.attr("class", "more-descendants-indicator")
.style("cursor", "pointer")
.on("click", (event) => {
event.stopPropagation();
onClick({ id: person.id, data });
});
const bw = 10, bh = 7, gap = 4;
const cx = w - 25;
const boxTop = h + 7;
const leftX = cx - gap / 2 - bw;
const rightX = cx + gap / 2;
const barY = boxTop;
ig.append("line")
.attr("x1", cx).attr("y1", h)
.attr("x2", cx).attr("y2", barY);
ig.append("line")
.attr("x1", leftX + bw / 2).attr("y1", barY)
.attr("x2", rightX + bw / 2).attr("y2", barY);
ig.append("rect")
.attr("x", leftX).attr("y", boxTop)
.attr("width", bw).attr("height", bh)
.attr("rx", 1).attr("ry", 1);
ig.append("rect")
.attr("x", rightX).attr("y", boxTop)
.attr("width", bw).attr("height", bh)
.attr("rx", 1).attr("ry", 1);
}
// Card background
g.append("rect")
.attr("width", w)
.attr("height", h)
.attr("rx", 6)
.attr("ry", 6);
// Photo area (left side)
const photoSize = 28;
const photoX = 6;
const photoY = (h - photoSize) / 2;
const textXOffset = photoX + photoSize + 7;
// Clip path for circular photo
const clipId = `fng-clip-${person.id}-${Math.random().toString(36).slice(2, 6)}`;
g.append("clipPath")
.attr("id", clipId)
.append("circle")
.attr("cx", photoX + photoSize / 2)
.attr("cy", photoY + photoSize / 2)
.attr("r", photoSize / 2 - 1);
if (data.avatar) {
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
g.append("circle")
.attr("cx", photoX + photoSize / 2)
.attr("cy", photoY + photoSize / 2)
.attr("r", photoSize / 2 - 1)
.attr("class", "photo-placeholder");
const cx = photoX + photoSize / 2;
const cy = photoY + photoSize / 2;
g.append("circle")
.attr("cx", cx)
.attr("cy", cy - 3)
.attr("r", 5)
.attr("class", "silhouette");
g.append("ellipse")
.attr("cx", cx)
.attr("cy", cy + 8)
.attr("rx", 7)
.attr("ry", 5)
.attr("class", "silhouette");
}
// Name
const firstName = data["first name"] || "";
const lastName = data["last name"] || "";
const displayName = formatDisplayName(firstName, lastName, data.fullName);
const maxTextWidth = w - textXOffset - 5;
g.append("text")
.attr("class", "person-name")
.attr("x", textXOffset)
.attr("y", h / 2 - 4)
.text(truncateText(displayName, maxTextWidth, 6.5));
// Dates line (compact)
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 + 9)
.text(dates);
}
return g;
}
function formatDisplayName(firstName, lastName, fullName) {
const cleanFirst = firstName && !firstName.match(/^@[A-Z]\.N\.$/) ? firstName : "";
const cleanLast = lastName && !lastName.match(/^@[A-Z]\.N\.$/) ? lastName : "";
if (!cleanFirst && !cleanLast) {
const cleanFull = fullName ? fullName.replace(/@[A-Z]\.N\./g, "\u2026").trim() : "";
return cleanFull || "???";
}
const firstOnly = cleanFirst ? cleanFirst.split(/\s+/)[0] : "";
if (firstOnly && cleanLast) {
return `${firstOnly} ${cleanLast}`;
}
return firstOnly || cleanLast || "???";
}
function truncateText(text, maxWidth, charWidth) {
const maxChars = Math.floor(maxWidth / (charWidth || 6.5));
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,162 @@
/**
* Bio card tooltip on hover for the sidebar graph.
*
* Shows full details: name, photo, birth, death, marriage, occupation, age.
* Uses window.familyNavGraphI18n for translated labels.
*/
import { select } from "../d3.js";
let activeTooltip = null;
let hideTimer = null;
function t(key, ...args) {
const i18n = window.familyNavGraphI18n || {};
let str = i18n[key] || key;
for (const arg of args) {
str = str.replace("__AGE__", arg);
}
return str;
}
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 + 6;
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
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 || "???");
const ageText = computeAge(data);
if (ageText) {
headerText.append("div").attr("class", "bio-age").text(ageText);
}
// Facts
const facts = tooltip.append("div").attr("class", "bio-facts");
addFact(facts, t("Born"), data.birthDate, data.birthPlace);
addFact(facts, t("Baptism"), data.baptismDate);
addFact(facts, t("Marriage"), data.marriageDate);
addFact(facts, t("Died"), data.deathDate, data.deathPlace);
addFact(facts, t("Occupation"), data.occupation);
addFact(facts, t("Residence"), data.residence);
// Profile link
tooltip
.append("a")
.attr("href", data.url)
.attr("class", "bio-link")
.text(t("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 parseDate(dateStr) {
if (!dateStr) return null;
const d = new Date(dateStr);
if (!isNaN(d.getTime())) return d;
return null;
}
function computeAge(data) {
if (!data.birthYear) return "";
const birthYear = parseInt(data.birthYear, 10);
if (isNaN(birthYear)) return "";
if (data.isDead) {
const birthDate = parseDate(data.birthDate);
const deathDate = parseDate(data.deathDate);
if (birthDate && deathDate) {
let age = deathDate.getFullYear() - birthDate.getFullYear();
const monthDiff = deathDate.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && deathDate.getDate() < birthDate.getDate())) {
age--;
}
return t("Died at age %s", age);
}
if (data.deathYear) {
const deathYear = parseInt(data.deathYear, 10);
if (!isNaN(deathYear)) {
return t("Died at age %s", deathYear - birthYear);
}
}
return t("Deceased");
}
const birthDate = parseDate(data.birthDate);
const now = new Date();
if (birthDate) {
let age = now.getFullYear() - birthDate.getFullYear();
const monthDiff = now.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age--;
}
return t("Age ~%s", age);
}
const age = now.getFullYear() - birthYear;
return t("Age ~%s", age);
}
function scheduleHide() {
hideTimer = setTimeout(hideTooltip, 300);
}
export function hideTooltip() {
clearTimeout(hideTimer);
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}
export function attachHoverBioCard(cardGroup, data, containerSelector) {
cardGroup
.on("mouseenter", function () {
clearTimeout(hideTimer);
showBioCard(data, this, containerSelector);
})
.on("mouseleave", () => {
scheduleHide();
});
}

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

@@ -0,0 +1,4 @@
/**
* D3 re-exports used by the family navigator graph.
*/
export { select } from "d3-selection";

View File

@@ -0,0 +1,684 @@
/**
* Custom family layout for the sidebar graph.
*
* Purpose-built for immediate-family display: parents, siblings,
* self + spouse(s), and children. Handles wrapping for large families
* and half-siblings from multiple parent couples.
*
* No external dependencies (replaces ELK).
*/
/**
* @param {Array} persons - Flat array of { id, data, rels }
* @param {string} mainId - Root person ID
* @param {object} config - { cardWidth, cardHeight, horizontalSpacing, verticalSpacing, targetWidth }
* @returns {{ persons: Array, connections: Array }}
*/
export function computeLayout(persons, mainId, config) {
const personById = new Map();
for (const p of persons) personById.set(p.id, p);
const cw = config.cardWidth;
const ch = config.cardHeight;
const hGap = config.horizontalSpacing;
const vGap = config.verticalSpacing;
const halfH = ch / 2;
const targetWidth = config.targetWidth || 320;
const maxPerRow = Math.max(2, Math.floor(targetWidth / (cw + hGap)));
// 1. Assign generations via BFS
const gen = assignGenerations(personById, mainId);
// 2. Identify family units (couple → shared children)
const families = identifyFamilies(personById, gen);
// 2a. Identify root's spouse families with children (max 2 shown)
const rootChildFamilies = families.filter(
(f) => f.parents.includes(mainId) && f.children.length > 0
);
const activeRootFamilies = rootChildFamilies.slice(0, 2);
// 2b. Move siblings to their own row (gen -0.5) above root+spouse(s)
// But if root has no spouses, keep siblings at gen 0 on the same row
const root = personById.get(mainId);
const rootSpouses = new Set(root ? root.rels.spouses || [] : []);
if (rootSpouses.size > 0) {
for (const [id, g] of gen) {
if (g === 0 && id !== mainId && !rootSpouses.has(id)) {
gen.set(id, -0.5);
}
}
}
// 2c. When multiple spouse families have children, move those spouses
// to gen 0.5 (row below root). Childless spouses stay at gen 0 with root.
const activeSpouseIds = new Set();
for (const f of activeRootFamilies) {
for (const pid of f.parents) {
if (pid !== mainId) activeSpouseIds.add(pid);
}
}
if (activeRootFamilies.length >= 2) {
for (const sid of activeSpouseIds) {
if (gen.has(sid)) gen.set(sid, 0.5);
}
}
// 3. Group by generation
const genGroups = new Map();
for (const [id, g] of gen) {
if (!genGroups.has(g)) genGroups.set(g, []);
genGroups.get(g).push(id);
}
// 4. Order + position each generation row
const positions = new Map();
const sortedGens = [...genGroups.keys()].sort((a, b) => a - b);
let currentY = 0;
for (const g of sortedGens) {
const ids = genGroups.get(g);
const ordered = orderGeneration(ids, g, mainId, families, personById, activeRootFamilies);
// Multi-spouse children: position each family's children as
// two side-by-side columns (1 card wide each) to stay within 2-card width
if (g >= 1 && activeRootFamilies.length >= 2) {
const leftKids = ordered.filter((id) => activeRootFamilies[0].children.includes(id));
const rightKids = ordered.filter((id) => activeRootFamilies[1].children.includes(id));
// Each group gets 1 card per row, aligned to the standard 2-wide grid
const leftRows = wrapIntoRows(leftKids, 1);
const rightRows = wrapIntoRows(rightKids, 1);
const maxRowCount = Math.max(leftRows.length, rightRows.length);
const rowWidth = 2 * cw + hGap;
const leftX = -rowWidth / 2 + cw / 2;
const rightX = leftX + cw + hGap;
for (let r = 0; r < maxRowCount; r++) {
const lRow = leftRows[r] || [];
const rRow = rightRows[r] || [];
for (const id of lRow) {
positions.set(id, { x: leftX, y: currentY });
}
for (const id of rRow) {
positions.set(id, { x: rightX, y: currentY });
}
currentY += ch + (r < maxRowCount - 1 ? vGap * 0.5 : 0);
}
currentY += vGap;
continue;
}
// Gen-0 (root + spouses): never wrap — all on one row
const rowLimit = (g === 0) ? ordered.length : 2;
const rows = wrapIntoRows(ordered, rowLimit);
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.length * cw + (row.length - 1) * hGap;
let startX = -rowWidth / 2 + cw / 2;
// Sibling row with odd count: offset left so no card
// sits dead center — the connector drops through the gap
if (g === -0.5 && row.length % 2 === 1) {
startX -= (cw + hGap) / 2;
}
for (let c = 0; c < row.length; c++) {
positions.set(row[c], {
x: startX + c * (cw + hGap),
y: currentY,
});
}
currentY += ch + (r < rows.length - 1 ? vGap * 0.5 : 0);
}
// Less vertical space for tightly coupled rows
currentY += (g === -0.5 || g === 0.5) ? vGap * 0.6 : vGap;
}
// 5. Center on root
const rootPos = positions.get(mainId);
if (rootPos) {
const ox = rootPos.x;
const oy = rootPos.y;
for (const pos of positions.values()) {
pos.x -= ox;
pos.y -= oy;
}
}
// 6. Build output
const outPersons = [];
for (const [id, pos] of positions) {
const p = personById.get(id);
outPersons.push({
x: pos.x, y: pos.y,
id, isMain: id === mainId,
data: p.data,
});
}
// 7. Build connectors
const connections = buildConnectors(families, positions, config, mainId, activeRootFamilies, personById);
return { persons: outPersons, connections };
}
// ─── Generation assignment ────────────────────────────────────────────
function assignGenerations(personById, mainId) {
const gen = new Map();
gen.set(mainId, 0);
const queue = [mainId];
const visited = new Set([mainId]);
while (queue.length > 0) {
const id = queue.shift();
const g = gen.get(id);
const p = personById.get(id);
if (!p) continue;
// Spouses = same generation
for (const sid of p.rels.spouses || []) {
if (!visited.has(sid) && personById.has(sid)) {
gen.set(sid, g);
visited.add(sid);
queue.push(sid);
}
}
// Parents = one up
for (const pid of p.rels.parents || []) {
if (!visited.has(pid) && personById.has(pid)) {
gen.set(pid, g - 1);
visited.add(pid);
queue.push(pid);
}
}
// Children = one down
for (const cid of p.rels.children || []) {
if (!visited.has(cid) && personById.has(cid)) {
gen.set(cid, g + 1);
visited.add(cid);
queue.push(cid);
}
}
}
return gen;
}
// ─── Family identification ────────────────────────────────────────────
/**
* Identify family units from children's parent sets.
* Each family = { parents: [id, ...], children: [id, ...] }
*/
function identifyFamilies(personById, gen) {
const familyMap = new Map(); // "pA|pB" → { parents, children }
for (const [id, person] of personById) {
const parents = (person.rels.parents || []).filter((pid) => personById.has(pid));
if (parents.length === 0) continue;
const key = [...parents].sort().join("|");
if (!familyMap.has(key)) {
familyMap.set(key, { parents: [...parents].sort(), children: [] });
}
familyMap.get(key).children.push(id);
}
return [...familyMap.values()];
}
// ─── Generation ordering ──────────────────────────────────────────────
function orderGeneration(ids, g, mainId, families, personById, activeRootFamilies) {
if (g === -0.5) return orderSiblings(ids, families, personById);
if (g < 0) return orderParents(ids, families);
if (g === 0) return orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies);
if (g === 0.5) return orderActiveSpouses(ids, activeRootFamilies);
return orderChildren(ids, mainId, families, activeRootFamilies);
}
/**
* Parents: group by couple, bridging through shared parents.
* e.g. [Dad, Mom, StepDad] when Mom is in two families.
*/
function orderParents(ids, families) {
const parentFamilies = families.filter((f) =>
f.parents.some((pid) => ids.includes(pid))
);
if (parentFamilies.length === 0) return [...ids];
const ordered = [];
const placed = new Set();
const remaining = [...parentFamilies];
// Start with first family
const first = remaining.shift();
for (const pid of first.parents) {
if (ids.includes(pid) && !placed.has(pid)) {
ordered.push(pid);
placed.add(pid);
}
}
// Process remaining, preferring those that share a parent (bridge)
while (remaining.length > 0) {
const idx = remaining.findIndex((f) =>
f.parents.some((pid) => placed.has(pid))
);
const next =
idx >= 0 ? remaining.splice(idx, 1)[0] : remaining.shift();
for (const pid of next.parents) {
if (ids.includes(pid) && !placed.has(pid)) {
ordered.push(pid);
placed.add(pid);
}
}
}
// Any remaining
for (const id of ids) {
if (!placed.has(id)) ordered.push(id);
}
return ordered;
}
/**
* Siblings row: sorted chronologically by birth year.
* Grouped by parent family, with each group sorted by birth.
*/
function orderSiblings(ids, families, personById) {
const ordered = [];
const placed = new Set();
// Group siblings by parent family
for (const family of families) {
const famSiblings = family.children.filter(
(cid) => ids.includes(cid) && !placed.has(cid)
);
// Sort by birth year (unknown years go last)
famSiblings.sort((a, b) => {
const ya = personById.get(a)?.data?.birthYear || 9999;
const yb = personById.get(b)?.data?.birthYear || 9999;
return ya - yb;
});
for (const cid of famSiblings) {
ordered.push(cid);
placed.add(cid);
}
}
// Any remaining
for (const id of ids) {
if (!placed.has(id)) ordered.push(id);
}
return ordered;
}
/**
* Self generation: root + spouse(s).
* Multi-spouse: active spouses are at gen 0.5, so gen 0 has root + childless spouses.
* Single spouse: root + spouse on same row.
*/
function orderSelfGeneration(ids, mainId, families, personById, activeRootFamilies) {
const root = personById.get(mainId);
const ordered = [];
const placed = new Set();
// Root first
ordered.push(mainId);
placed.add(mainId);
// Remaining spouses (childless ones in multi-spouse, or all in single-spouse)
for (const sid of root.rels.spouses || []) {
if (ids.includes(sid) && !placed.has(sid)) {
ordered.push(sid);
placed.add(sid);
}
}
// Any remaining gen-0 persons
for (const id of ids) {
if (!placed.has(id)) ordered.push(id);
}
return ordered;
}
/**
* Active spouses (gen 0.5): ordered to match activeRootFamilies (left, right).
*/
function orderActiveSpouses(ids, activeRootFamilies) {
const ordered = [];
const placed = new Set();
for (const family of activeRootFamilies) {
for (const pid of family.parents) {
if (ids.includes(pid) && !placed.has(pid)) {
ordered.push(pid);
placed.add(pid);
}
}
}
for (const id of ids) {
if (!placed.has(id)) ordered.push(id);
}
return ordered;
}
/**
* Children: grouped by spouse family.
* Uses activeRootFamilies order so left family's kids come first.
*/
function orderChildren(ids, mainId, families, activeRootFamilies) {
const ordered = [];
const placed = new Set();
// Active root families first (left, then right)
for (const family of activeRootFamilies) {
for (const cid of family.children) {
if (ids.includes(cid) && !placed.has(cid)) {
ordered.push(cid);
placed.add(cid);
}
}
}
// Any remaining children from other families
const rootFamilies = families.filter((f) => f.parents.includes(mainId));
for (const family of rootFamilies) {
if (activeRootFamilies.includes(family)) continue;
for (const cid of family.children) {
if (ids.includes(cid) && !placed.has(cid)) {
ordered.push(cid);
placed.add(cid);
}
}
}
for (const id of ids) {
if (!placed.has(id)) ordered.push(id);
}
return ordered;
}
// ─── Row wrapping ─────────────────────────────────────────────────────
function wrapIntoRows(ids, maxPerRow) {
const rows = [];
for (let i = 0; i < ids.length; i += maxPerRow) {
rows.push(ids.slice(i, i + maxPerRow));
}
return rows;
}
// ─── Connector drawing ────────────────────────────────────────────────
function buildConnectors(families, positions, config, mainId, activeRootFamilies, personById) {
const connections = [];
const halfH = config.cardHeight / 2;
const cw = config.cardWidth;
const vGap = config.verticalSpacing;
const multiSpouse = activeRootFamilies.length >= 2;
const rootPos = positions.get(mainId);
const margin = 12;
for (const family of families) {
const rootFamIdx = activeRootFamilies.indexOf(family);
const isActiveRootFamily = rootFamIdx >= 0;
// Multi-spouse active families get custom routing below
if (isActiveRootFamily && multiSpouse) continue;
const parentPos = family.parents
.map((pid) => positions.get(pid))
.filter(Boolean);
const childPos = family.children
.map((cid) => ({ id: cid, ...positions.get(cid) }))
.filter((c) => c.x !== undefined);
if (parentPos.length === 0 || childPos.length === 0) continue;
// Standard family connector (parents, siblings, single-spouse children)
const parentBottomY = Math.max(...parentPos.map((p) => p.y)) + halfH;
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
const gap = childTopY - parentBottomY;
const coupleBarY = parentBottomY + gap * 0.3;
const unionX =
parentPos.reduce((s, p) => s + p.x, 0) / parentPos.length;
if (parentPos.length >= 2) {
const xs = parentPos.map((p) => p.x).sort((a, b) => a - b);
connections.push({
path: `M ${xs[0]} ${coupleBarY} L ${xs[xs.length - 1]} ${coupleBarY}`,
cssClass: "link couple-link",
});
}
for (const p of parentPos) {
connections.push({
path: `M ${p.x} ${p.y + halfH} L ${p.x} ${coupleBarY}`,
cssClass: "link ancestor-link",
});
}
drawChildConnectors(connections, childPos, unionX, coupleBarY, halfH, vGap);
}
// ── Multi-spouse: custom routing for active root families ──
if (multiSpouse && rootPos) {
for (let fi = 0; fi < activeRootFamilies.length; fi++) {
const family = activeRootFamilies[fi];
const spouseId = family.parents.find((p) => p !== mainId);
const spousePos = spouseId ? positions.get(spouseId) : null;
const childPos = family.children
.map((cid) => ({ id: cid, ...positions.get(cid) }))
.filter((c) => c.x !== undefined);
if (!spousePos || childPos.length === 0) continue;
const spouseBottomY = spousePos.y + halfH;
const childTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
const gap = childTopY - spouseBottomY;
const singleChild = childPos.length === 1;
// Outer spine X: outside edge for routing from root
let outerX;
if (fi === 0) {
outerX = Math.min(...childPos.map((c) => c.x)) - cw / 2 - margin;
} else {
outerX = Math.max(...childPos.map((c) => c.x)) + cw / 2 + margin;
}
// Couple bar below spouse — marriage bar that connects everything
const coupleBarY = spouseBottomY + gap * 0.3;
// ── Root down outer edge to couple bar ──
const junctionY = (rootPos.y + halfH + spousePos.y - halfH) / 2;
connections.push({
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${junctionY}`,
cssClass: "link couple-link",
});
connections.push({
path: `M ${rootPos.x} ${junctionY} L ${outerX} ${junctionY}`,
cssClass: "link couple-link",
});
// Outer edge down to couple bar
connections.push({
path: `M ${outerX} ${junctionY} L ${outerX} ${coupleBarY}`,
cssClass: "link couple-link",
});
// ── Spouse bottom down to couple bar ──
connections.push({
path: `M ${spousePos.x} ${spouseBottomY} L ${spousePos.x} ${coupleBarY}`,
cssClass: "link couple-link",
});
// ── Horizontal couple bar connecting outer spine and spouse ──
connections.push({
path: `M ${outerX} ${coupleBarY} L ${spousePos.x} ${coupleBarY}`,
cssClass: "link couple-link",
});
// ── Children from couple bar ──
if (singleChild) {
// Single child: straight down from spouse X on the couple bar
connections.push({
path: `M ${spousePos.x} ${coupleBarY} L ${spousePos.x} ${childPos[0].y - halfH}`,
cssClass: "link descendant-link",
});
} else {
// Multiple children: add a second horizontal bar below the couple bar,
// connected by a short vertical at the couple bar midpoint —
// visually separates marriage connector from children connector
const firstChildTopY = Math.min(...childPos.map((c) => c.y)) - halfH;
const childBarY = firstChildTopY - 6;
const coupleBarMidX = (outerX + spousePos.x) / 2;
// Short vertical from couple bar midpoint down to children bar
connections.push({
path: `M ${coupleBarMidX} ${coupleBarY} L ${coupleBarMidX} ${childBarY}`,
cssClass: "link descendant-link",
});
// Children spine at outerX
drawChildConnectors(connections, childPos, outerX, childBarY, halfH, vGap);
}
}
}
// ── Couple links for childless spouses (same row as root) ──
const rootPerson = personById.get(mainId);
const childlessActiveIds = new Set();
for (const f of activeRootFamilies) {
for (const pid of f.parents) {
if (pid !== mainId) childlessActiveIds.add(pid);
}
}
if (rootPerson && rootPos && multiSpouse) {
for (const sid of rootPerson.rels.spouses || []) {
if (childlessActiveIds.has(sid)) continue;
const spPos = positions.get(sid);
if (!spPos) continue;
// Horizontal couple bar on same row
const barY = rootPos.y + halfH + 8;
connections.push({
path: `M ${rootPos.x} ${rootPos.y + halfH} L ${rootPos.x} ${barY}`,
cssClass: "link couple-link",
});
connections.push({
path: `M ${rootPos.x} ${barY} L ${spPos.x} ${barY}`,
cssClass: "link couple-link",
});
connections.push({
path: `M ${spPos.x} ${barY} L ${spPos.x} ${spPos.y + halfH}`,
cssClass: "link couple-link",
});
}
}
return connections;
}
/**
* Draw connectors from a spine X down to a set of children.
* Handles single-row and wrapped (multi-row) children.
*/
function drawChildConnectors(connections, childPos, spineX, coupleBarY, halfH, vGap) {
// Group children by Y level (detects wrapping)
const rowMap = new Map();
for (const c of childPos) {
const ry = Math.round(c.y * 10) / 10;
if (!rowMap.has(ry)) rowMap.set(ry, []);
rowMap.get(ry).push(c);
}
const rowYs = [...rowMap.keys()].sort((a, b) => a - b);
const wrapped = rowYs.length > 1;
if (!wrapped) {
const computedBusY = rowYs[0] - halfH - 6;
// Use whichever is closer to the children — if the caller already
// positioned childBarY near the cards, honour that instead of recalculating
const busBaseY = Math.max(computedBusY, coupleBarY);
// Vertical stem from couple bar to bus
if (coupleBarY < busBaseY) {
connections.push({
path: `M ${spineX} ${coupleBarY} L ${spineX} ${busBaseY}`,
cssClass: "link descendant-link",
});
}
if (childPos.length === 1) {
if (Math.abs(childPos[0].x - spineX) > 1) {
connections.push({
path: `M ${spineX} ${busBaseY} L ${childPos[0].x} ${busBaseY}`,
cssClass: "link descendant-link",
});
}
connections.push({
path: `M ${childPos[0].x} ${busBaseY} L ${childPos[0].x} ${childPos[0].y - halfH}`,
cssClass: "link descendant-link",
});
} else {
const xs = childPos.map((c) => c.x).sort((a, b) => a - b);
const busL = Math.min(xs[0], spineX);
const busR = Math.max(xs[xs.length - 1], spineX);
connections.push({
path: `M ${busL} ${busBaseY} L ${busR} ${busBaseY}`,
cssClass: "link descendant-link",
});
for (const c of childPos) {
connections.push({
path: `M ${c.x} ${busBaseY} L ${c.x} ${c.y - halfH}`,
cssClass: "link descendant-link",
});
}
}
} else {
// Wrapped children — one continuous vertical spine with
// horizontal buses branching off per sub-row
const rowBuses = rowYs.map((ry) => ry - halfH - 6);
// If caller positioned coupleBarY below the first bus, use it instead
if (coupleBarY > rowBuses[0]) rowBuses[0] = coupleBarY;
// Draw one continuous spine from couple bar to last bus
const spineEnd = rowBuses[rowBuses.length - 1];
connections.push({
path: `M ${spineX} ${coupleBarY} L ${spineX} ${spineEnd}`,
cssClass: "link descendant-link",
});
for (let ri = 0; ri < rowYs.length; ri++) {
const rowChildren = rowMap.get(rowYs[ri]);
const rowBusY = rowBuses[ri];
const xs = rowChildren.map((c) => c.x).sort((a, b) => a - b);
const busL = Math.min(xs[0], spineX);
const busR = Math.max(xs[xs.length - 1], spineX);
connections.push({
path: `M ${busL} ${rowBusY} L ${busR} ${rowBusY}`,
cssClass: "link descendant-link",
});
for (const c of rowChildren) {
connections.push({
path: `M ${c.x} ${rowBusY} L ${c.x} ${c.y - halfH}`,
cssClass: "link descendant-link",
});
}
}
}
}