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:
210
resources/js/modules/lib/chart/box.js
Normal file
210
resources/js/modules/lib/chart/box.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Person card (box) renderer.
|
||||
*
|
||||
* Shows first + last name (middle names dropped to save space).
|
||||
* Profile picture displayed if available, otherwise a gendered silhouette.
|
||||
* Hover shows a rich bio card with dates, places, occupation, age.
|
||||
*/
|
||||
import { attachHoverBioCard } from "./overlay.js";
|
||||
|
||||
/**
|
||||
* Render a person card as an SVG group.
|
||||
*
|
||||
* @param {d3.Selection} parent - The parent SVG group to append to
|
||||
* @param {object} person - { x, y, id, isMain, data: { gender, "first name", ... } }
|
||||
* @param {object} config
|
||||
* @param {Function} onClick - Click handler receiving { id, data }
|
||||
* @param {string} containerSelector - Selector for the chart container (for tooltip positioning)
|
||||
* @returns {d3.Selection}
|
||||
*/
|
||||
export function renderPersonCard(parent, person, config, onClick, containerSelector) {
|
||||
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 });
|
||||
});
|
||||
|
||||
// Card background
|
||||
g.append("rect")
|
||||
.attr("width", w)
|
||||
.attr("height", h)
|
||||
.attr("rx", 8)
|
||||
.attr("ry", 8);
|
||||
|
||||
// Photo area (left side)
|
||||
const photoSize = 50;
|
||||
const photoX = 8;
|
||||
const photoY = (h - photoSize) / 2;
|
||||
const textXOffset = photoX + photoSize + 10;
|
||||
|
||||
// Clip path for circular photo
|
||||
const clipId = `clip-${person.id}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
g.append("clipPath")
|
||||
.attr("id", clipId)
|
||||
.append("circle")
|
||||
.attr("cx", photoX + photoSize / 2)
|
||||
.attr("cy", photoY + photoSize / 2)
|
||||
.attr("r", photoSize / 2 - 2);
|
||||
|
||||
if (data.avatar) {
|
||||
// Profile picture
|
||||
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 circle
|
||||
g.append("circle")
|
||||
.attr("cx", photoX + photoSize / 2)
|
||||
.attr("cy", photoY + photoSize / 2)
|
||||
.attr("r", photoSize / 2 - 2)
|
||||
.attr("class", "photo-placeholder");
|
||||
|
||||
// Simple silhouette icon
|
||||
const cx = photoX + photoSize / 2;
|
||||
const cy = photoY + photoSize / 2;
|
||||
// Head
|
||||
g.append("circle")
|
||||
.attr("cx", cx)
|
||||
.attr("cy", cy - 6)
|
||||
.attr("r", 8)
|
||||
.attr("class", "silhouette");
|
||||
// Body
|
||||
g.append("ellipse")
|
||||
.attr("cx", cx)
|
||||
.attr("cy", cy + 14)
|
||||
.attr("rx", 12)
|
||||
.attr("ry", 9)
|
||||
.attr("class", "silhouette");
|
||||
}
|
||||
|
||||
// Name: first + last (drop middle names)
|
||||
const firstName = data["first name"] || "";
|
||||
const lastName = data["last name"] || "";
|
||||
const displayName = formatDisplayName(firstName, lastName, data.fullName);
|
||||
const maxTextWidth = w - textXOffset - 8;
|
||||
|
||||
g.append("text")
|
||||
.attr("class", "person-name")
|
||||
.attr("x", textXOffset)
|
||||
.attr("y", h / 2 - 10)
|
||||
.text(truncateText(displayName, maxTextWidth));
|
||||
|
||||
// Dates line
|
||||
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 + 6)
|
||||
.text(dates);
|
||||
}
|
||||
|
||||
// Occupation as a third line
|
||||
const subtitle = data.occupation || "";
|
||||
if (subtitle) {
|
||||
g.append("text")
|
||||
.attr("class", "person-subtitle")
|
||||
.attr("x", textXOffset)
|
||||
.attr("y", h / 2 + 20)
|
||||
.text(truncateText(subtitle, maxTextWidth));
|
||||
}
|
||||
|
||||
// "More ancestors" indicator — two small parent boxes at top-right
|
||||
if (data.hasMoreAncestors) {
|
||||
const ig = g.append("g").attr("class", "more-ancestors-indicator");
|
||||
|
||||
// Positioning: top-right of card, protruding above
|
||||
const bw = 10; // mini box width
|
||||
const bh = 7; // mini box height
|
||||
const gap = 4; // gap between boxes
|
||||
const cx = w - 25; // center X of the indicator
|
||||
const topY = -14; // top of mini boxes (above card)
|
||||
|
||||
const leftX = cx - gap / 2 - bw;
|
||||
const rightX = cx + gap / 2;
|
||||
|
||||
// Two small rectangles (parents)
|
||||
ig.append("rect")
|
||||
.attr("x", leftX).attr("y", topY)
|
||||
.attr("width", bw).attr("height", bh)
|
||||
.attr("rx", 2).attr("ry", 2);
|
||||
ig.append("rect")
|
||||
.attr("x", rightX).attr("y", topY)
|
||||
.attr("width", bw).attr("height", bh)
|
||||
.attr("rx", 2).attr("ry", 2);
|
||||
|
||||
// Horizontal bar connecting the two boxes at their bottom center
|
||||
const barY = topY + bh;
|
||||
ig.append("line")
|
||||
.attr("x1", leftX + bw / 2).attr("y1", barY)
|
||||
.attr("x2", rightX + bw / 2).attr("y2", barY);
|
||||
|
||||
// Vertical line from bar center down to card top edge
|
||||
ig.append("line")
|
||||
.attr("x1", cx).attr("y1", barY)
|
||||
.attr("x2", cx).attr("y2", 0);
|
||||
}
|
||||
|
||||
// Attach hover bio card
|
||||
if (containerSelector) {
|
||||
attachHoverBioCard(g, data, containerSelector);
|
||||
}
|
||||
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format display name: first name + last name, dropping middle names.
|
||||
* Handles GEDCOM placeholders: @N.N. = unknown name, @P.N. = unknown given name
|
||||
*/
|
||||
function formatDisplayName(firstName, lastName, fullName) {
|
||||
// Clean GEDCOM unknown-name placeholders
|
||||
const cleanFirst = firstName && !firstName.match(/^@[A-Z]\.N\.$/) ? firstName : "";
|
||||
const cleanLast = lastName && !lastName.match(/^@[A-Z]\.N\.$/) ? lastName : "";
|
||||
|
||||
if (!cleanFirst && !cleanLast) {
|
||||
// Also clean fullName of @N.N. patterns
|
||||
const cleanFull = fullName ? fullName.replace(/@[A-Z]\.N\./g, "\u2026").trim() : "";
|
||||
return cleanFull || "???";
|
||||
}
|
||||
|
||||
// Take only the first given name (drop middle names)
|
||||
const firstOnly = cleanFirst ? cleanFirst.split(/\s+/)[0] : "";
|
||||
|
||||
if (firstOnly && cleanLast) {
|
||||
return `${firstOnly} ${cleanLast}`;
|
||||
}
|
||||
|
||||
return firstOnly || cleanLast || "???";
|
||||
}
|
||||
|
||||
function truncateText(text, maxWidth) {
|
||||
// ~7px per character at 12px font
|
||||
const maxChars = Math.floor(maxWidth / 7);
|
||||
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}`;
|
||||
}
|
||||
145
resources/js/modules/lib/chart/overlay.js
Normal file
145
resources/js/modules/lib/chart/overlay.js
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
60
resources/js/modules/lib/chart/svg.js
Normal file
60
resources/js/modules/lib/chart/svg.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* SVG container setup.
|
||||
*/
|
||||
import { select } from "../d3.js";
|
||||
|
||||
/**
|
||||
* Create the main SVG element within the container.
|
||||
*
|
||||
* @param {string} selector - CSS selector for the container element
|
||||
* @returns {d3.Selection} The SVG selection
|
||||
*/
|
||||
export function createSvg(selector) {
|
||||
const container = select(selector);
|
||||
const { width, height } = container.node().getBoundingClientRect();
|
||||
|
||||
const svg = container
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
// Main group that will be transformed by zoom/pan
|
||||
svg.append("g").attr("class", "full-diagram-canvas");
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the canvas group (the zoomable/pannable layer).
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @returns {d3.Selection}
|
||||
*/
|
||||
export function getCanvas(svg) {
|
||||
return svg.select("g.full-diagram-canvas");
|
||||
}
|
||||
|
||||
/**
|
||||
* Center the canvas on the root node.
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @param {d3.ZoomBehavior} zoomBehavior
|
||||
*/
|
||||
export function centerOnRoot(svg, zoomBehavior) {
|
||||
const { width, height } = svg.node().getBoundingClientRect();
|
||||
|
||||
const initialTransform = {
|
||||
x: width / 2,
|
||||
y: height / 2,
|
||||
k: 1,
|
||||
};
|
||||
|
||||
svg.call(
|
||||
zoomBehavior.transform,
|
||||
() =>
|
||||
new DOMMatrix()
|
||||
.translate(initialTransform.x, initialTransform.y)
|
||||
.scale(initialTransform.k)
|
||||
);
|
||||
}
|
||||
72
resources/js/modules/lib/chart/zoom.js
Normal file
72
resources/js/modules/lib/chart/zoom.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pan and zoom behavior via d3-zoom.
|
||||
*/
|
||||
import { zoom, zoomIdentity, select } from "../d3.js";
|
||||
import { getCanvas } from "./svg.js";
|
||||
|
||||
/**
|
||||
* Initialize zoom behavior on the SVG element.
|
||||
*
|
||||
* @param {d3.Selection} svg
|
||||
* @returns {d3.ZoomBehavior}
|
||||
*/
|
||||
export function initZoom(svg) {
|
||||
const canvas = getCanvas(svg);
|
||||
|
||||
const zoomBehavior = zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on("zoom", (event) => {
|
||||
canvas.attr("transform", event.transform);
|
||||
});
|
||||
|
||||
svg.call(zoomBehavior);
|
||||
|
||||
// Disable double-click zoom (we use click for navigation)
|
||||
svg.on("dblclick.zoom", null);
|
||||
|
||||
return zoomBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create zoom control buttons.
|
||||
*
|
||||
* @param {string} containerSelector
|
||||
* @param {d3.Selection} svg
|
||||
* @param {d3.ZoomBehavior} zoomBehavior
|
||||
*/
|
||||
export function createZoomControls(containerSelector, svg, zoomBehavior) {
|
||||
const container = select(containerSelector);
|
||||
|
||||
const controls = container
|
||||
.append("div")
|
||||
.attr("class", "zoom-controls");
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Zoom in")
|
||||
.text("+")
|
||||
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 1.3));
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Zoom out")
|
||||
.text("\u2212")
|
||||
.on("click", () => svg.transition().duration(300).call(zoomBehavior.scaleBy, 0.7));
|
||||
|
||||
controls
|
||||
.append("button")
|
||||
.attr("type", "button")
|
||||
.attr("title", "Reset view")
|
||||
.text("\u21BA")
|
||||
.on("click", () => {
|
||||
const { width, height } = svg.node().getBoundingClientRect();
|
||||
svg.transition()
|
||||
.duration(500)
|
||||
.call(
|
||||
zoomBehavior.transform,
|
||||
zoomIdentity.translate(width / 2, height / 2)
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user