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.
247 lines
8.1 KiB
JavaScript
247 lines
8.1 KiB
JavaScript
/**
|
|
* 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 });
|
|
});
|
|
|
|
// "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;
|
|
|
|
// Lines first (behind boxes)
|
|
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);
|
|
|
|
// Boxes on top of lines
|
|
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;
|
|
|
|
// Lines first (behind boxes)
|
|
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);
|
|
|
|
// Boxes on top of lines
|
|
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", 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));
|
|
}
|
|
|
|
// 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}`;
|
|
}
|