Files
WebtreesFullDiagram/resources/js/modules/lib/chart/overlay.js
Alexander Bocken 96d1425036 Improve indicators and fix hover card age calculation
Draw ancestor/descendant indicators behind card, make them clickable
(navigates to person), use sharper box corners (rx=1). Fix age
calculation to use full birth/death dates instead of just years,
correctly accounting for whether the birthday has passed.
2026-03-15 09:22:04 +01:00

192 lines
5.7 KiB
JavaScript

/**
* Bio card tooltip on hover.
*
* Shows: full name, profile photo, birth, baptism, marriage, death,
* occupation, residence, current age (if alive) or age at death.
*
* Uses window.fullDiagramI18n for translated labels.
*/
import { select } from "../d3.js";
let activeTooltip = null;
let hideTimer = null;
/** Get a translated string, with optional substitution. */
function t(key, ...args) {
const i18n = window.fullDiagramI18n || {};
let str = i18n[key] || key;
for (const arg of args) {
str = str.replace("__AGE__", arg);
}
return str;
}
/**
* Show a bio card tooltip for a person.
*
* @param {object} data - Person data
* @param {SVGElement} cardElement - The SVG card group element
* @param {string} containerSelector
*/
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 + 8;
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: photo + name
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 || "???");
// Age
const ageText = computeAge(data);
if (ageText) {
headerText.append("div").attr("class", "bio-age").text(ageText);
}
// Facts list
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);
// Link to profile
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);
}
/**
* Try to parse a webtrees display date string into a Date object.
* Handles formats like "15 March 1985", "March 1985", "1985".
*/
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) {
// Try precise calculation from full dates
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);
}
// Fallback to year-based
if (data.deathYear) {
const deathYear = parseInt(data.deathYear, 10);
if (!isNaN(deathYear)) {
return t("Died at age %s", deathYear - birthYear);
}
}
return t("Deceased");
}
// Living person — try precise calculation
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);
}
// Fallback to year-based approximation
const age = now.getFullYear() - birthYear;
return t("Age ~%s", age);
}
function scheduleHide() {
hideTimer = setTimeout(hideTooltip, 300);
}
/**
* Hide the active tooltip.
*/
export function hideTooltip() {
clearTimeout(hideTimer);
if (activeTooltip) {
activeTooltip.remove();
activeTooltip = null;
}
}
/**
* Attach hover behavior to a person card group.
*
* @param {d3.Selection} cardGroup - The SVG <g> for the person card
* @param {object} data - Person data
* @param {string} containerSelector
*/
export function attachHoverBioCard(cardGroup, data, containerSelector) {
cardGroup
.on("mouseenter", function () {
clearTimeout(hideTimer);
showBioCard(data, this, containerSelector);
})
.on("mouseleave", () => {
scheduleHide();
});
}