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,145 @@
|
||||
/**
|
||||
* Bio card tooltip on hover.
|
||||
*
|
||||
* Shows: full name, profile photo, birth, baptism, marriage, death,
|
||||
* occupation, residence, current age (if alive) or age at death.
|
||||
*/
|
||||
import { select } from "../d3.js";
|
||||
|
||||
let activeTooltip = null;
|
||||
let hideTimer = null;
|
||||
|
||||
/**
|
||||
* 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, "Born", data.birthDate, data.birthPlace);
|
||||
addFact(facts, "Baptism", data.baptismDate);
|
||||
addFact(facts, "Marriage", data.marriageDate);
|
||||
addFact(facts, "Died", data.deathDate, data.deathPlace);
|
||||
addFact(facts, "Occupation", data.occupation);
|
||||
addFact(facts, "Residence", data.residence);
|
||||
|
||||
// Link to profile
|
||||
tooltip
|
||||
.append("a")
|
||||
.attr("href", data.url)
|
||||
.attr("class", "bio-link")
|
||||
.text("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 computeAge(data) {
|
||||
if (!data.birthYear) return "";
|
||||
|
||||
const birthYear = parseInt(data.birthYear, 10);
|
||||
if (isNaN(birthYear)) return "";
|
||||
|
||||
if (data.isDead) {
|
||||
if (data.deathYear) {
|
||||
const deathYear = parseInt(data.deathYear, 10);
|
||||
if (!isNaN(deathYear)) {
|
||||
const age = deathYear - birthYear;
|
||||
return `Died at age ${age}`;
|
||||
}
|
||||
}
|
||||
return "Deceased";
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const age = currentYear - birthYear;
|
||||
return `Age ~${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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user