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,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();
});
}