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:
1
resources/js/full-diagram.min.js
vendored
Normal file
1
resources/js/full-diagram.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
resources/js/modules/custom/configuration.js
Normal file
14
resources/js/modules/custom/configuration.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Chart configuration defaults.
|
||||
*/
|
||||
export default class Configuration {
|
||||
constructor() {
|
||||
this.cardWidth = 200;
|
||||
this.cardHeight = 80;
|
||||
this.cardPadding = 10;
|
||||
this.horizontalSpacing = 30;
|
||||
this.verticalSpacing = 40;
|
||||
this.siblingSpacing = 30;
|
||||
this.duration = 500;
|
||||
}
|
||||
}
|
||||
44
resources/js/modules/custom/data.js
Normal file
44
resources/js/modules/custom/data.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Data loading and management.
|
||||
*/
|
||||
export default class DataLoader {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load tree data for a specific individual via AJAX.
|
||||
*
|
||||
* @param {string} xref
|
||||
* @param {object} params
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async load(xref, params = {}) {
|
||||
const url = new URL(this.baseUrl.replace("__XREF__", xref), window.location.origin);
|
||||
url.searchParams.set("ajax", "1");
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load data: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a person's chart page.
|
||||
*
|
||||
* @param {string} xref
|
||||
*/
|
||||
navigateTo(xref) {
|
||||
window.location.href = this.baseUrl.replace("__XREF__", xref);
|
||||
}
|
||||
}
|
||||
270
resources/js/modules/custom/hierarchy.js
Normal file
270
resources/js/modules/custom/hierarchy.js
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Dual-tree D3 hierarchy builder.
|
||||
*
|
||||
* Converts the PHP-generated tree data into two D3 hierarchies:
|
||||
* - Ancestor tree (root at bottom, growing upward)
|
||||
* - Descendant tree (root at top, growing downward)
|
||||
*
|
||||
* Both share the root node at (0, 0) and are merged for rendering.
|
||||
*
|
||||
* After D3 layout:
|
||||
* 1. Shift descendant subtrees so children center under the couple midpoint
|
||||
* 2. Compute spouse positions at every level
|
||||
* 3. Resolve all overlaps (person nodes, spouses, siblings) by pushing
|
||||
* cards apart and propagating shifts to subtrees
|
||||
*/
|
||||
import { tree, hierarchy } from "../lib/d3.js";
|
||||
import { computeSpouseOffset, SPOUSE_GAP } from "../lib/tree/spouse-util.js";
|
||||
|
||||
// ─── Ancestor hierarchy ──────────────────────────────────────────────
|
||||
|
||||
export function buildAncestorHierarchy(rootData) {
|
||||
if (!rootData.parents || rootData.parents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const root = prepareAncestorNode(rootData);
|
||||
return hierarchy(root, (d) => d._ancestorChildren);
|
||||
}
|
||||
|
||||
function prepareAncestorNode(person) {
|
||||
const node = { ...person };
|
||||
if (person.parents && person.parents.length > 0) {
|
||||
node._ancestorChildren = person.parents.map((p) => prepareAncestorNode(p));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── Descendant hierarchy ────────────────────────────────────────────
|
||||
|
||||
export function buildDescendantHierarchy(rootData) {
|
||||
if (!rootData.families || rootData.families.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const root = prepareDescendantNode(rootData);
|
||||
return hierarchy(root, (d) => d._descendantChildren);
|
||||
}
|
||||
|
||||
function prepareDescendantNode(person) {
|
||||
const node = { ...person };
|
||||
const children = [];
|
||||
|
||||
if (person.families) {
|
||||
for (let fi = 0; fi < person.families.length; fi++) {
|
||||
const family = person.families[fi];
|
||||
for (const child of family.children || []) {
|
||||
const childNode = prepareDescendantNode(child);
|
||||
childNode._familyIndex = fi;
|
||||
children.push(childNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (children.length > 0) {
|
||||
node._descendantChildren = children;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ─── Layout computation ──────────────────────────────────────────────
|
||||
|
||||
export function computeLayout(ancestorHierarchy, descendantHierarchy, config) {
|
||||
const nodeWidth = config.cardWidth + config.horizontalSpacing;
|
||||
const nodeHeight = config.cardHeight + config.verticalSpacing;
|
||||
const treeLayout = tree().nodeSize([nodeWidth, nodeHeight]);
|
||||
|
||||
const result = {
|
||||
ancestors: [],
|
||||
descendants: [],
|
||||
ancestorLinks: [],
|
||||
descendantLinks: [],
|
||||
};
|
||||
|
||||
// ── Ancestor tree (grows upward) ──
|
||||
if (ancestorHierarchy) {
|
||||
treeLayout(ancestorHierarchy);
|
||||
|
||||
ancestorHierarchy.each((node) => {
|
||||
node.y = -Math.abs(node.y);
|
||||
if (node.depth > 0) result.ancestors.push(node);
|
||||
});
|
||||
|
||||
ancestorHierarchy.links().forEach((link) => {
|
||||
result.ancestorLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Descendant tree (grows downward) ──
|
||||
if (descendantHierarchy) {
|
||||
treeLayout(descendantHierarchy);
|
||||
|
||||
// Step 1: shift children to center under couple midpoints
|
||||
shiftChildrenToCoupleCenter(descendantHierarchy, config);
|
||||
|
||||
// Step 2: resolve overlaps between all cards on the same row
|
||||
// (person nodes + their spouse cards)
|
||||
resolveDescendantOverlaps(descendantHierarchy, config);
|
||||
|
||||
descendantHierarchy.each((node) => {
|
||||
if (node.depth > 0) result.descendants.push(node);
|
||||
});
|
||||
|
||||
descendantHierarchy.links().forEach((link) => {
|
||||
result.descendantLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Couple-center shifting ──────────────────────────────────────────
|
||||
|
||||
function shiftChildrenToCoupleCenter(root, config) {
|
||||
// Process bottom-up so nested shifts accumulate correctly
|
||||
root.each((node) => {
|
||||
const data = node.data;
|
||||
if (!data.families || data.families.length === 0 || !node.children) return;
|
||||
|
||||
if (data.families.length === 1) {
|
||||
const family = data.families[0];
|
||||
if (family.spouse) {
|
||||
const shift = computeSpouseOffset(0, config.cardWidth, SPOUSE_GAP) / 2;
|
||||
for (const child of node.children) {
|
||||
shiftSubtree(child, shift);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const fi = child.data._familyIndex;
|
||||
if (fi === undefined) continue;
|
||||
const family = data.families[fi];
|
||||
if (family && family.spouse) {
|
||||
const shift = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP) / 2;
|
||||
shiftSubtree(child, shift);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shiftSubtree(node, dx) {
|
||||
node.x += dx;
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
shiftSubtree(child, dx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Overlap resolution ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect every card rectangle that will be rendered on each row
|
||||
* (person nodes + spouse cards), detect overlaps, and push apart.
|
||||
*
|
||||
* When a person node is pushed, its entire descendant subtree moves too.
|
||||
*
|
||||
* Strategy: for each depth row, build a sorted list of "card groups"
|
||||
* (a person + all their spouses form one rigid group). Then do a single
|
||||
* left-to-right sweep pushing groups apart when they overlap.
|
||||
*/
|
||||
function resolveDescendantOverlaps(root, config) {
|
||||
const w = config.cardWidth;
|
||||
const minGap = 20;
|
||||
|
||||
// Gather hierarchy nodes by depth
|
||||
const depthMap = new Map();
|
||||
root.each((node) => {
|
||||
if (!depthMap.has(node.depth)) depthMap.set(node.depth, []);
|
||||
depthMap.get(node.depth).push(node);
|
||||
});
|
||||
|
||||
const depths = [...depthMap.keys()].sort((a, b) => a - b);
|
||||
|
||||
for (const depth of depths) {
|
||||
const nodesAtDepth = depthMap.get(depth);
|
||||
|
||||
// Build card groups: each person node + their spouses as a rigid unit
|
||||
// A group has a leftEdge and rightEdge computed from all its cards.
|
||||
const groups = [];
|
||||
|
||||
for (const node of nodesAtDepth) {
|
||||
const xs = [node.x]; // person card center
|
||||
|
||||
const data = node.data;
|
||||
if (data.families) {
|
||||
for (let fi = 0; fi < data.families.length; fi++) {
|
||||
if (data.families[fi].spouse) {
|
||||
xs.push(node.x + computeSpouseOffset(fi, w, SPOUSE_GAP));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const leftEdge = Math.min(...xs) - w / 2;
|
||||
const rightEdge = Math.max(...xs) + w / 2;
|
||||
|
||||
groups.push({ node, leftEdge, rightEdge, centerX: node.x });
|
||||
}
|
||||
|
||||
// Sort by the left edge of each group
|
||||
groups.sort((a, b) => a.leftEdge - b.leftEdge);
|
||||
|
||||
// Single left-to-right sweep: push groups apart
|
||||
for (let i = 1; i < groups.length; i++) {
|
||||
const prev = groups[i - 1];
|
||||
const curr = groups[i];
|
||||
|
||||
const overlap = prev.rightEdge + minGap - curr.leftEdge;
|
||||
|
||||
if (overlap > 0) {
|
||||
// Push current group (and its subtree) right
|
||||
shiftSubtree(curr.node, overlap);
|
||||
curr.leftEdge += overlap;
|
||||
curr.rightEdge += overlap;
|
||||
curr.centerX += overlap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sibling positions ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute sibling positions at the same Y-level as root (0).
|
||||
*
|
||||
* Siblings are placed to the right of root. If the root has spouses,
|
||||
* siblings start after the rightmost spouse to avoid overlap.
|
||||
*/
|
||||
export function computeSiblingPositions(rootData, config) {
|
||||
const siblings = [];
|
||||
const links = [];
|
||||
|
||||
if (!rootData.siblings || rootData.siblings.length === 0) {
|
||||
return { siblings, links };
|
||||
}
|
||||
|
||||
// Find the rightmost occupied X at root level (root card + any spouses)
|
||||
let maxRootX = config.cardWidth / 2; // right edge of root card
|
||||
|
||||
if (rootData.families) {
|
||||
for (let fi = 0; fi < rootData.families.length; fi++) {
|
||||
if (rootData.families[fi].spouse) {
|
||||
const spouseX = computeSpouseOffset(fi, config.cardWidth, SPOUSE_GAP);
|
||||
const spouseRight = spouseX + config.cardWidth / 2;
|
||||
maxRootX = Math.max(maxRootX, spouseRight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const startX = maxRootX + config.siblingSpacing;
|
||||
|
||||
rootData.siblings.forEach((sibling, index) => {
|
||||
const x = startX + index * (config.cardWidth + config.siblingSpacing);
|
||||
const y = 0;
|
||||
|
||||
siblings.push({ x, y, data: sibling });
|
||||
links.push({ source: { x: 0, y: 0 }, target: { x, y } });
|
||||
});
|
||||
|
||||
return { siblings, links };
|
||||
}
|
||||
30
resources/js/modules/index.js
Normal file
30
resources/js/modules/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Full Diagram — entry point.
|
||||
*
|
||||
* Reads embedded data from the page and initializes the chart.
|
||||
*/
|
||||
import Chart from "./lib/chart.js";
|
||||
|
||||
async function init() {
|
||||
const data = window.fullDiagramData;
|
||||
const baseUrl = window.fullDiagramBaseUrl;
|
||||
|
||||
if (!data || !data.persons) {
|
||||
console.error("Full Diagram: No tree data found.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const chart = new Chart("#full-diagram-container", data, baseUrl);
|
||||
await chart.render();
|
||||
} catch (err) {
|
||||
console.error("Full Diagram: Render failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
90
resources/js/modules/lib/chart.js
Normal file
90
resources/js/modules/lib/chart.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Main chart orchestrator.
|
||||
*
|
||||
* Uses ELK for layout (Sugiyama / union-node pattern) and D3 for
|
||||
* SVG rendering with clean orthogonal bus-line connectors.
|
||||
*/
|
||||
import { computeElkLayout } from "./layout/elk-layout.js";
|
||||
import { createSvg, getCanvas } from "./chart/svg.js";
|
||||
import { initZoom, createZoomControls } from "./chart/zoom.js";
|
||||
import { renderPersonCard } from "./chart/box.js";
|
||||
import { hideTooltip } from "./chart/overlay.js";
|
||||
import { zoomIdentity } from "./d3.js";
|
||||
|
||||
export default class Chart {
|
||||
constructor(containerSelector, data, baseUrl) {
|
||||
this.containerSelector = containerSelector;
|
||||
this.data = data;
|
||||
this.config = {
|
||||
cardWidth: 200,
|
||||
cardHeight: 80,
|
||||
horizontalSpacing: 30,
|
||||
verticalSpacing: 60,
|
||||
};
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async render() {
|
||||
const ctr = this.containerSelector;
|
||||
const chartSelector = `${ctr} .full-diagram-chart`;
|
||||
|
||||
const svg = createSvg(chartSelector);
|
||||
this.svg = svg;
|
||||
|
||||
const zoomBehavior = initZoom(svg);
|
||||
this.zoomBehavior = zoomBehavior;
|
||||
|
||||
svg.on("zoom.tooltip", () => hideTooltip());
|
||||
createZoomControls(ctr, svg, zoomBehavior);
|
||||
|
||||
const canvas = getCanvas(svg);
|
||||
|
||||
// Compute layout using ELK
|
||||
const layout = await computeElkLayout(
|
||||
this.data.persons,
|
||||
this.data.mainId,
|
||||
this.config
|
||||
);
|
||||
|
||||
// Click handler
|
||||
const baseUrl = this.baseUrl;
|
||||
const onNodeClick = (data) => {
|
||||
hideTooltip();
|
||||
const url = baseUrl.replace("__XREF__", data.id);
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
// Draw connections first (behind cards)
|
||||
this.renderConnections(canvas, layout);
|
||||
|
||||
// Draw person cards
|
||||
for (const person of layout.persons) {
|
||||
renderPersonCard(canvas, person, this.config, onNodeClick, ctr);
|
||||
}
|
||||
|
||||
// Center on root
|
||||
this.centerOnRoot();
|
||||
}
|
||||
|
||||
renderConnections(canvas, layout) {
|
||||
const linkGroup = canvas.append("g").attr("class", "edges");
|
||||
|
||||
for (const conn of layout.connections) {
|
||||
linkGroup
|
||||
.append("path")
|
||||
.attr("class", conn.cssClass)
|
||||
.attr("d", conn.path);
|
||||
}
|
||||
}
|
||||
|
||||
centerOnRoot() {
|
||||
const { width, height } = this.svg.node().getBoundingClientRect();
|
||||
this.svg
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(
|
||||
this.zoomBehavior.transform,
|
||||
zoomIdentity.translate(width / 2, height / 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
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)
|
||||
);
|
||||
});
|
||||
}
|
||||
6
resources/js/modules/lib/d3.js
vendored
Normal file
6
resources/js/modules/lib/d3.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Cherry-picked D3 module re-exports.
|
||||
*/
|
||||
export { select, selectAll } from "d3-selection";
|
||||
export { zoom, zoomIdentity } from "d3-zoom";
|
||||
import "d3-transition";
|
||||
472
resources/js/modules/lib/layout/elk-layout.js
Normal file
472
resources/js/modules/lib/layout/elk-layout.js
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* ELK (Eclipse Layout Kernel) based family tree layout.
|
||||
*
|
||||
* Uses the union-node pattern with ELK's Sugiyama algorithm for
|
||||
* guaranteed overlap-free positioning. Connector lines are drawn
|
||||
* manually using clean orthogonal bus lines (not ELK's edge routing).
|
||||
*
|
||||
* Post-processing snaps all people of the same generation to the same
|
||||
* Y coordinate and repositions union nodes between generation rows.
|
||||
* Spouse-grouped node ordering keeps couples placed close together.
|
||||
*
|
||||
* Input: flat person array with rels { parents, spouses, children }
|
||||
* Output: positioned persons + orthogonal connector paths
|
||||
*/
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
/**
|
||||
* @param {Array} persons - Flat array of { id, data, rels }
|
||||
* @param {string} mainId - Root person ID
|
||||
* @param {object} config - Card dimensions and spacing
|
||||
* @returns {Promise<LayoutResult>}
|
||||
*/
|
||||
export async function computeElkLayout(persons, mainId, config) {
|
||||
const builder = new GraphBuilder(persons, mainId, config);
|
||||
builder.build();
|
||||
const graph = builder.buildElkGraph();
|
||||
const result = await elk.layout(graph);
|
||||
return extractPositions(result, builder, config);
|
||||
}
|
||||
|
||||
// ─── Graph Builder ───────────────────────────────────────────────────
|
||||
|
||||
class GraphBuilder {
|
||||
constructor(persons, mainId, config) {
|
||||
this.config = config;
|
||||
this.personById = new Map();
|
||||
for (const p of persons) {
|
||||
this.personById.set(p.id, p);
|
||||
}
|
||||
this.mainId = mainId;
|
||||
|
||||
this.nodes = new Map(); // id → { id, type, data }
|
||||
this.edges = [];
|
||||
this.unionCounter = 0;
|
||||
|
||||
// Track which family units we've already created union nodes for
|
||||
// key = sorted parent IDs joined, value = union node id
|
||||
this.familyUnions = new Map();
|
||||
|
||||
// Generation number per person (0 = main, negative = ancestors, positive = descendants)
|
||||
this.generations = new Map();
|
||||
}
|
||||
|
||||
build() {
|
||||
// Add all persons as nodes
|
||||
for (const [id, person] of this.personById) {
|
||||
this.nodes.set(id, {
|
||||
id: id,
|
||||
type: "person",
|
||||
data: person.data,
|
||||
isMain: id === this.mainId,
|
||||
});
|
||||
}
|
||||
|
||||
// For each person, create union nodes for their family relationships
|
||||
for (const [id, person] of this.personById) {
|
||||
const parents = (person.rels.parents || []).filter((pid) =>
|
||||
this.personById.has(pid)
|
||||
);
|
||||
|
||||
if (parents.length > 0) {
|
||||
const unionId = this.getOrCreateFamilyUnion(parents);
|
||||
// union → child
|
||||
this.addEdge(unionId, id);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute generation numbers via BFS from main person
|
||||
this.computeGenerations();
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS from the main person to assign generation numbers.
|
||||
* Spouses get the same generation, parents get gen-1, children get gen+1.
|
||||
* Spouses are processed first to ensure they share a layer.
|
||||
*/
|
||||
computeGenerations() {
|
||||
this.generations.set(this.mainId, 0);
|
||||
const queue = [this.mainId];
|
||||
const visited = new Set([this.mainId]);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift();
|
||||
const gen = this.generations.get(id);
|
||||
const person = this.personById.get(id);
|
||||
if (!person) continue;
|
||||
|
||||
// Spouses = same generation (process first for consistency)
|
||||
for (const sid of person.rels.spouses || []) {
|
||||
if (!visited.has(sid) && this.personById.has(sid)) {
|
||||
this.generations.set(sid, gen);
|
||||
visited.add(sid);
|
||||
queue.push(sid);
|
||||
}
|
||||
}
|
||||
|
||||
// Parents = one generation up
|
||||
for (const pid of person.rels.parents || []) {
|
||||
if (!visited.has(pid) && this.personById.has(pid)) {
|
||||
this.generations.set(pid, gen - 1);
|
||||
visited.add(pid);
|
||||
queue.push(pid);
|
||||
}
|
||||
}
|
||||
|
||||
// Children = one generation down
|
||||
for (const cid of person.rels.children || []) {
|
||||
if (!visited.has(cid) && this.personById.has(cid)) {
|
||||
this.generations.set(cid, gen + 1);
|
||||
visited.add(cid);
|
||||
queue.push(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a union node for a set of parents.
|
||||
* Creates parent → union edges on first creation.
|
||||
*/
|
||||
getOrCreateFamilyUnion(parentIds) {
|
||||
const key = [...parentIds].sort().join("|");
|
||||
if (this.familyUnions.has(key)) {
|
||||
return this.familyUnions.get(key);
|
||||
}
|
||||
|
||||
const unionId = `union_${this.unionCounter++}`;
|
||||
this.nodes.set(unionId, {
|
||||
id: unionId,
|
||||
type: "union",
|
||||
data: null,
|
||||
});
|
||||
this.familyUnions.set(key, unionId);
|
||||
|
||||
// parent → union edges (high priority to keep parents close)
|
||||
for (const pid of parentIds) {
|
||||
this.addEdge(pid, unionId, 10);
|
||||
}
|
||||
|
||||
return unionId;
|
||||
}
|
||||
|
||||
addEdge(source, target, priority = 1) {
|
||||
const exists = this.edges.some(
|
||||
(e) => e.source === source && e.target === target
|
||||
);
|
||||
if (!exists) {
|
||||
this.edges.push({ source, target, priority });
|
||||
}
|
||||
}
|
||||
|
||||
buildElkGraph() {
|
||||
const w = this.config.cardWidth;
|
||||
const h = this.config.cardHeight;
|
||||
const unionSize = 2;
|
||||
|
||||
// Order person nodes with spouses adjacent for model-order awareness
|
||||
const orderedPersonIds = this._orderPersonsBySpouseGroups();
|
||||
|
||||
const elkNodes = [];
|
||||
|
||||
// Add person nodes in spouse-grouped order
|
||||
for (const id of orderedPersonIds) {
|
||||
elkNodes.push({
|
||||
id: id,
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
}
|
||||
|
||||
// Add union nodes
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (node.type !== "union") continue;
|
||||
elkNodes.push({
|
||||
id: id,
|
||||
width: unionSize,
|
||||
height: unionSize,
|
||||
});
|
||||
}
|
||||
|
||||
const elkEdges = this.edges.map((e, i) => {
|
||||
const edge = {
|
||||
id: `e${i}`,
|
||||
sources: [e.source],
|
||||
targets: [e.target],
|
||||
};
|
||||
if (e.priority > 1) {
|
||||
edge.layoutOptions = {
|
||||
"elk.layered.priority.direction": String(e.priority),
|
||||
"elk.layered.priority.shortness": String(e.priority),
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
});
|
||||
|
||||
return {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": "DOWN",
|
||||
"elk.edgeRouting": "ORTHOGONAL",
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": String(
|
||||
this.config.verticalSpacing
|
||||
),
|
||||
"elk.spacing.nodeNode": String(this.config.horizontalSpacing),
|
||||
"elk.layered.spacing.edgeNodeBetweenLayers": "15",
|
||||
"elk.layered.spacing.edgeEdgeBetweenLayers": "10",
|
||||
"elk.layered.nodePlacement.strategy": "NETWORK_SIMPLEX",
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
"elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
|
||||
"elk.separateConnectedComponents": "false",
|
||||
"elk.layered.compaction.postCompaction.strategy":
|
||||
"EDGE_LENGTH",
|
||||
},
|
||||
children: elkNodes,
|
||||
edges: elkEdges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Order person nodes so that spouse pairs are adjacent in the input.
|
||||
* Combined with considerModelOrder, this keeps couples placed close together.
|
||||
*/
|
||||
_orderPersonsBySpouseGroups() {
|
||||
const ordered = [];
|
||||
const added = new Set();
|
||||
|
||||
for (const [id, person] of this.personById) {
|
||||
if (added.has(id)) continue;
|
||||
added.add(id);
|
||||
ordered.push(id);
|
||||
|
||||
// Add spouses immediately after this person
|
||||
const spouses = (person.rels.spouses || []).filter(
|
||||
(sid) => this.personById.has(sid) && !added.has(sid)
|
||||
);
|
||||
for (const sid of spouses) {
|
||||
added.add(sid);
|
||||
ordered.push(sid);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extract positions & build clean connectors ──────────────────────
|
||||
|
||||
function extractPositions(elkResult, builder, config) {
|
||||
const persons = [];
|
||||
const unions = [];
|
||||
const connections = [];
|
||||
|
||||
const halfH = config.cardHeight / 2;
|
||||
|
||||
// ── Step 1: Read raw ELK positions ──
|
||||
const rawPos = new Map(); // id → { cx, cy }
|
||||
let rootX = 0,
|
||||
rootY = 0;
|
||||
|
||||
for (const elkNode of elkResult.children || []) {
|
||||
const nodeInfo = builder.nodes.get(elkNode.id);
|
||||
if (!nodeInfo) continue;
|
||||
const cx = elkNode.x + elkNode.width / 2;
|
||||
const cy = elkNode.y + elkNode.height / 2;
|
||||
rawPos.set(elkNode.id, { cx, cy });
|
||||
if (nodeInfo.isMain) {
|
||||
rootX = cx;
|
||||
rootY = cy;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Snap person nodes to generation rows ──
|
||||
// Group person nodes by generation, compute median Y for each generation
|
||||
const genGroups = new Map(); // generation → [{ id, cx, cy }]
|
||||
for (const [id, pos] of rawPos) {
|
||||
const nodeInfo = builder.nodes.get(id);
|
||||
if (!nodeInfo || nodeInfo.type !== "person") continue;
|
||||
const gen = builder.generations.get(id) || 0;
|
||||
if (!genGroups.has(gen)) genGroups.set(gen, []);
|
||||
genGroups.get(gen).push({ id, ...pos });
|
||||
}
|
||||
|
||||
// For each generation, use the median Y as the canonical row Y
|
||||
const genY = new Map(); // generation → snapped Y
|
||||
for (const [gen, nodes] of genGroups) {
|
||||
const ys = nodes.map((n) => n.cy).sort((a, b) => a - b);
|
||||
const mid = Math.floor(ys.length / 2);
|
||||
const medianY =
|
||||
ys.length % 2 === 0 ? (ys[mid - 1] + ys[mid]) / 2 : ys[mid];
|
||||
genY.set(gen, medianY);
|
||||
}
|
||||
|
||||
// ── Step 3: Compute union node Y positions ──
|
||||
// Each union sits between its parent generation row and child generation row
|
||||
const unionGenY = new Map(); // unionId → snapped Y
|
||||
for (const [id, node] of builder.nodes) {
|
||||
if (node.type !== "union") continue;
|
||||
|
||||
// Find parent generation (edges INTO this union)
|
||||
let parentGen = null;
|
||||
let childGen = null;
|
||||
for (const edge of builder.edges) {
|
||||
if (edge.target === id && builder.generations.has(edge.source)) {
|
||||
parentGen = builder.generations.get(edge.source);
|
||||
}
|
||||
if (edge.source === id && builder.generations.has(edge.target)) {
|
||||
childGen = builder.generations.get(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
if (parentGen !== null && childGen !== null) {
|
||||
const pY = genY.get(parentGen);
|
||||
const cY = genY.get(childGen);
|
||||
if (pY !== undefined && cY !== undefined) {
|
||||
// Place union at: parent bottom edge + 40% of gap to child top edge
|
||||
const parentBottom = pY + halfH;
|
||||
const childTop = cY - halfH;
|
||||
unionGenY.set(id, parentBottom + (childTop - parentBottom) * 0.4);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use raw ELK position
|
||||
const raw = rawPos.get(id);
|
||||
if (raw) unionGenY.set(id, raw.cy);
|
||||
}
|
||||
|
||||
// ── Step 4: Build final positioned nodes (centered on root) ──
|
||||
const posMap = new Map(); // id → { x, y }
|
||||
|
||||
// Recalculate rootY using the snapped generation Y
|
||||
const mainGen = builder.generations.get(builder.mainId) || 0;
|
||||
const snappedRootY = genY.get(mainGen) || rootY;
|
||||
|
||||
for (const [id, node] of builder.nodes) {
|
||||
const raw = rawPos.get(id);
|
||||
if (!raw) continue;
|
||||
|
||||
let finalY;
|
||||
if (node.type === "person") {
|
||||
const gen = builder.generations.get(id) || 0;
|
||||
finalY = (genY.get(gen) || raw.cy) - snappedRootY;
|
||||
} else {
|
||||
finalY = (unionGenY.get(id) || raw.cy) - snappedRootY;
|
||||
}
|
||||
|
||||
const finalX = raw.cx - rootX;
|
||||
posMap.set(id, { x: finalX, y: finalY });
|
||||
|
||||
if (node.type === "person") {
|
||||
persons.push({
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
id: node.id,
|
||||
isMain: node.isMain,
|
||||
data: node.data,
|
||||
});
|
||||
} else {
|
||||
unions.push({ id: id, x: finalX, y: finalY });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: Build clean bus-line connectors ──
|
||||
const incomingToUnion = new Map();
|
||||
const outgoingFromUnion = new Map();
|
||||
|
||||
for (const edge of builder.edges) {
|
||||
const sourceInfo = builder.nodes.get(edge.source);
|
||||
const targetInfo = builder.nodes.get(edge.target);
|
||||
|
||||
if (targetInfo && targetInfo.type === "union") {
|
||||
if (!incomingToUnion.has(edge.target))
|
||||
incomingToUnion.set(edge.target, []);
|
||||
incomingToUnion.get(edge.target).push(edge.source);
|
||||
}
|
||||
|
||||
if (sourceInfo && sourceInfo.type === "union") {
|
||||
if (!outgoingFromUnion.has(edge.source))
|
||||
outgoingFromUnion.set(edge.source, []);
|
||||
outgoingFromUnion.get(edge.source).push(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
for (const union of unions) {
|
||||
const parentIds = incomingToUnion.get(union.id) || [];
|
||||
const childIds = outgoingFromUnion.get(union.id) || [];
|
||||
|
||||
const parents = parentIds
|
||||
.map((id) => posMap.get(id))
|
||||
.filter(Boolean);
|
||||
const children = childIds
|
||||
.map((id) => posMap.get(id))
|
||||
.filter(Boolean);
|
||||
|
||||
const ux = union.x;
|
||||
const uy = union.y;
|
||||
|
||||
// ── Parent → union connections ──
|
||||
if (parents.length > 0) {
|
||||
// Horizontal couple bar at union Y
|
||||
if (parents.length >= 2) {
|
||||
const xs = parents.map((p) => p.x).sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${uy} L ${xs[xs.length - 1]} ${uy}`,
|
||||
cssClass: "link couple-link",
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical drop from each parent's bottom edge to couple bar Y
|
||||
for (const p of parents) {
|
||||
const bottomY = p.y + halfH;
|
||||
connections.push({
|
||||
path: `M ${p.x} ${bottomY} L ${p.x} ${uy}`,
|
||||
cssClass: "link ancestor-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Union → children connections ──
|
||||
if (children.length > 0) {
|
||||
// Bus Y halfway between union and children's top edge
|
||||
const childY = children[0].y;
|
||||
const busY = uy + (childY - halfH - uy) / 2;
|
||||
|
||||
// Vertical stem from union down to bus
|
||||
connections.push({
|
||||
path: `M ${ux} ${uy} L ${ux} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child: continue vertical line
|
||||
connections.push({
|
||||
path: `M ${children[0].x} ${busY} L ${children[0].x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
} else {
|
||||
// Horizontal bus spanning all children
|
||||
const xs = children
|
||||
.map((c) => c.x)
|
||||
.sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${busY} L ${xs[xs.length - 1]} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
// Vertical drops from bus to each child's top edge
|
||||
for (const c of children) {
|
||||
connections.push({
|
||||
path: `M ${c.x} ${busY} L ${c.x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { persons, unions, connections };
|
||||
}
|
||||
457
resources/js/modules/lib/layout/graphviz-layout.js
Normal file
457
resources/js/modules/lib/layout/graphviz-layout.js
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Graphviz (viz.js) based family tree layout.
|
||||
*
|
||||
* Architecture: union-node pattern
|
||||
* ─────────────────────────────────
|
||||
* Each marriage/partnership is modeled as a small "union" node.
|
||||
* parent1 ──→ union ──→ child1
|
||||
* parent2 ──→ union ──→ child2
|
||||
*
|
||||
* Graphviz's "dot" algorithm (Sugiyama) then places:
|
||||
* - Each generation on its own rank
|
||||
* - Union nodes on intermediate ranks between parents and children
|
||||
* - Couples side-by-side (connected to the same union)
|
||||
* - Children centered below the union node
|
||||
*
|
||||
* The layout guarantees no overlaps and handles multiple marriages,
|
||||
* siblings, and complex family structures correctly.
|
||||
*/
|
||||
import { instance as vizInstance } from "@viz-js/viz";
|
||||
|
||||
let viz = null;
|
||||
|
||||
async function getViz() {
|
||||
if (!viz) {
|
||||
viz = await vizInstance();
|
||||
}
|
||||
return viz;
|
||||
}
|
||||
|
||||
// Graphviz uses inches; 1 inch = 72 points
|
||||
const PPI = 72;
|
||||
|
||||
/**
|
||||
* Build a Graphviz DOT graph from the tree data and compute layout.
|
||||
*
|
||||
* @param {object} rootData - The root person data from PHP
|
||||
* @param {Configuration} config
|
||||
* @returns {Promise<LayoutResult>} Positioned nodes and edges
|
||||
*/
|
||||
export async function computeGraphvizLayout(rootData, config) {
|
||||
const builder = new GraphBuilder(config);
|
||||
builder.addPersonFromData(rootData);
|
||||
const dot = builder.buildDotGraph();
|
||||
|
||||
const viz = await getViz();
|
||||
const result = viz.renderJSON(dot);
|
||||
|
||||
return extractPositions(result, builder, config);
|
||||
}
|
||||
|
||||
// ─── Graph Builder ───────────────────────────────────────────────────
|
||||
|
||||
class GraphBuilder {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.nodes = new Map(); // id → { id, type, data, layer }
|
||||
this.edges = []; // { source, target }
|
||||
this.visited = new Set();
|
||||
this.unionCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively add a person and all their relatives to the graph.
|
||||
*/
|
||||
addPersonFromData(data, currentLayer = 0) {
|
||||
if (this.visited.has(data.xref)) return;
|
||||
this.visited.add(data.xref);
|
||||
|
||||
this.addPersonNode(data, currentLayer);
|
||||
|
||||
// Ancestors: go up via parentFamilies
|
||||
if (data.parentFamilies && data.parentFamilies.length > 0) {
|
||||
this.addAncestorFamilies(data, currentLayer);
|
||||
}
|
||||
|
||||
// Descendants: go down
|
||||
if (data.families && data.families.length > 0) {
|
||||
this.addDescendants(data, currentLayer);
|
||||
}
|
||||
}
|
||||
|
||||
addPersonNode(data, layer) {
|
||||
if (this.nodes.has(data.xref)) return;
|
||||
this.nodes.set(data.xref, {
|
||||
id: data.xref,
|
||||
type: "person",
|
||||
data: data,
|
||||
layer: layer,
|
||||
});
|
||||
}
|
||||
|
||||
addUnionNode(layer) {
|
||||
const id = `union_${this.unionCounter++}`;
|
||||
this.nodes.set(id, {
|
||||
id: id,
|
||||
type: "union",
|
||||
data: null,
|
||||
layer: layer,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
addEdge(source, target) {
|
||||
const exists = this.edges.some(
|
||||
(e) => e.source === source && e.target === target
|
||||
);
|
||||
if (!exists) {
|
||||
this.edges.push({ source, target });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ancestor families: each parentFamily is a FamilyNode with
|
||||
* .parents (both mother & father) and .children (siblings).
|
||||
*
|
||||
* Creates: parent → union → child (for each child including the person)
|
||||
*/
|
||||
addAncestorFamilies(personData, personLayer) {
|
||||
const parentFamilies = personData.parentFamilies;
|
||||
if (!parentFamilies || parentFamilies.length === 0) return;
|
||||
|
||||
for (const family of parentFamilies) {
|
||||
const unionLayer = personLayer - 1;
|
||||
const parentLayer = personLayer - 2;
|
||||
const unionId = this.addUnionNode(unionLayer);
|
||||
|
||||
// Union → person (this child)
|
||||
this.addEdge(unionId, personData.xref);
|
||||
|
||||
// Each parent → union, then recurse into their ancestors
|
||||
for (const parent of family.parents || []) {
|
||||
this.addPersonNode(parent, parentLayer);
|
||||
this.addEdge(parent.xref, unionId);
|
||||
|
||||
if (!this.visited.has(parent.xref)) {
|
||||
this.visited.add(parent.xref);
|
||||
if (
|
||||
parent.parentFamilies &&
|
||||
parent.parentFamilies.length > 0
|
||||
) {
|
||||
this.addAncestorFamilies(parent, parentLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Siblings (other children of this family) → same union
|
||||
for (const sibling of family.children || []) {
|
||||
this.addPersonNode(sibling, personLayer);
|
||||
this.addEdge(unionId, sibling.xref);
|
||||
|
||||
// Process sibling's descendants
|
||||
if (!this.visited.has(sibling.xref)) {
|
||||
this.visited.add(sibling.xref);
|
||||
if (sibling.families && sibling.families.length > 0) {
|
||||
this.addDescendants(sibling, personLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add descendant chain: person → union → children
|
||||
*/
|
||||
addDescendants(personData, personLayer) {
|
||||
if (!personData.families) return;
|
||||
|
||||
for (const family of personData.families) {
|
||||
const unionLayer = personLayer + 1;
|
||||
const childLayer = personLayer + 2;
|
||||
const unionId = this.addUnionNode(unionLayer);
|
||||
|
||||
// Person → union
|
||||
this.addEdge(personData.xref, unionId);
|
||||
|
||||
// Spouse → union
|
||||
if (family.spouse) {
|
||||
this.addPersonNode(family.spouse, personLayer);
|
||||
this.addEdge(family.spouse.xref, unionId);
|
||||
}
|
||||
|
||||
// Union → each child
|
||||
for (const child of family.children || []) {
|
||||
this.addPersonNode(child, childLayer);
|
||||
this.addEdge(unionId, child.xref);
|
||||
|
||||
// Recurse into child's descendants
|
||||
if (!this.visited.has(child.xref)) {
|
||||
this.visited.add(child.xref);
|
||||
if (child.families && child.families.length > 0) {
|
||||
this.addDescendants(child, childLayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Graphviz DOT language graph.
|
||||
*/
|
||||
buildDotGraph() {
|
||||
const w = this.config.cardWidth / PPI;
|
||||
const h = this.config.cardHeight / PPI;
|
||||
const nodesep = this.config.horizontalSpacing / PPI;
|
||||
const ranksep = this.config.verticalSpacing / PPI;
|
||||
|
||||
let dot = "digraph G {\n";
|
||||
dot += " rankdir=TB;\n";
|
||||
dot += ` nodesep=${nodesep.toFixed(3)};\n`;
|
||||
dot += ` ranksep=${ranksep.toFixed(3)};\n`;
|
||||
dot += " splines=none;\n";
|
||||
dot += " ordering=out;\n";
|
||||
dot += "\n";
|
||||
|
||||
// Add nodes
|
||||
for (const [id, node] of this.nodes) {
|
||||
// Escape quotes in IDs
|
||||
const safeId = id.replace(/"/g, '\\"');
|
||||
if (node.type === "person") {
|
||||
dot += ` "${safeId}" [shape=box, fixedsize=true, width=${w.toFixed(3)}, height=${h.toFixed(3)}];\n`;
|
||||
} else {
|
||||
dot += ` "${safeId}" [shape=point, width=0.01, height=0.01];\n`;
|
||||
}
|
||||
}
|
||||
|
||||
dot += "\n";
|
||||
|
||||
// Add edges
|
||||
for (const edge of this.edges) {
|
||||
const src = edge.source.replace(/"/g, '\\"');
|
||||
const tgt = edge.target.replace(/"/g, '\\"');
|
||||
dot += ` "${src}" -> "${tgt}";\n`;
|
||||
}
|
||||
|
||||
dot += "\n";
|
||||
|
||||
// Add rank constraints to group nodes at the same layer
|
||||
const layerGroups = new Map();
|
||||
for (const [id, node] of this.nodes) {
|
||||
const layer = node.layer;
|
||||
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
|
||||
layerGroups.get(layer).push(id);
|
||||
}
|
||||
|
||||
for (const [, ids] of layerGroups) {
|
||||
if (ids.length > 1) {
|
||||
const quoted = ids
|
||||
.map((id) => `"${id.replace(/"/g, '\\"')}"`)
|
||||
.join("; ");
|
||||
dot += ` { rank=same; ${quoted}; }\n`;
|
||||
}
|
||||
}
|
||||
|
||||
dot += "}\n";
|
||||
return dot;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extract results ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Graphviz "x,y" position string into {x, y} in pixels.
|
||||
* Graphviz Y-axis goes bottom-to-top, so we negate Y for SVG (top-to-bottom).
|
||||
*/
|
||||
function parsePos(posStr) {
|
||||
const parts = posStr.split(",");
|
||||
return { x: parseFloat(parts[0]), y: -parseFloat(parts[1]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node positions from Graphviz and build family connections ourselves.
|
||||
*
|
||||
* We IGNORE Graphviz's edge routing entirely. Instead we use node positions
|
||||
* and the graph structure to draw clean family-tree connectors:
|
||||
*
|
||||
* Parent1 Parent2
|
||||
* | |
|
||||
* +----+-----+ ← horizontal couple bar
|
||||
* |
|
||||
* ------+------ ← horizontal children bus
|
||||
* | | | |
|
||||
* C1 C2 C3 C4
|
||||
*
|
||||
* This gives merged, clean orthogonal lines at consistent heights.
|
||||
*
|
||||
* @returns {LayoutResult}
|
||||
*/
|
||||
function extractPositions(gvResult, builder, config) {
|
||||
const persons = [];
|
||||
const unions = [];
|
||||
const connections = []; // family connections, not raw edges
|
||||
|
||||
// Map node names to their Graphviz positions
|
||||
const nodePositions = new Map(); // name → { x, y }
|
||||
|
||||
let rootX = 0;
|
||||
let rootY = 0;
|
||||
|
||||
// First pass: collect all positions, find root
|
||||
for (const obj of gvResult.objects || []) {
|
||||
if (!obj.name || !obj.pos) continue;
|
||||
const nodeInfo = builder.nodes.get(obj.name);
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
const pos = parsePos(obj.pos);
|
||||
nodePositions.set(obj.name, pos);
|
||||
|
||||
if (
|
||||
nodeInfo.type === "person" &&
|
||||
nodeInfo.data &&
|
||||
nodeInfo.data.isRoot
|
||||
) {
|
||||
rootX = pos.x;
|
||||
rootY = pos.y;
|
||||
}
|
||||
}
|
||||
|
||||
const halfW = config.cardWidth / 2;
|
||||
const halfH = config.cardHeight / 2;
|
||||
|
||||
// Second pass: build positioned nodes centered on root
|
||||
for (const [name, pos] of nodePositions) {
|
||||
const nodeInfo = builder.nodes.get(name);
|
||||
if (!nodeInfo) continue;
|
||||
|
||||
const cx = pos.x - rootX;
|
||||
const cy = pos.y - rootY;
|
||||
|
||||
if (nodeInfo.type === "person") {
|
||||
persons.push({
|
||||
x: cx,
|
||||
y: cy,
|
||||
data: nodeInfo.data,
|
||||
});
|
||||
} else {
|
||||
unions.push({
|
||||
id: name,
|
||||
x: cx,
|
||||
y: cy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Third pass: build family connections from graph structure.
|
||||
// For each union node, find its parents (edges INTO it) and
|
||||
// children (edges OUT of it), then build connector paths.
|
||||
const incomingToUnion = new Map(); // unionId → [nodeId, ...]
|
||||
const outgoingFromUnion = new Map(); // unionId → [nodeId, ...]
|
||||
|
||||
for (const edge of builder.edges) {
|
||||
const sourceInfo = builder.nodes.get(edge.source);
|
||||
const targetInfo = builder.nodes.get(edge.target);
|
||||
|
||||
if (targetInfo && targetInfo.type === "union") {
|
||||
// person → union (parent/spouse)
|
||||
if (!incomingToUnion.has(edge.target))
|
||||
incomingToUnion.set(edge.target, []);
|
||||
incomingToUnion.get(edge.target).push(edge.source);
|
||||
}
|
||||
|
||||
if (sourceInfo && sourceInfo.type === "union") {
|
||||
// union → person (child)
|
||||
if (!outgoingFromUnion.has(edge.source))
|
||||
outgoingFromUnion.set(edge.source, []);
|
||||
outgoingFromUnion.get(edge.source).push(edge.target);
|
||||
}
|
||||
}
|
||||
|
||||
// For each union, generate clean family-tree connector paths
|
||||
for (const [unionId, union] of unions.map((u) => [u.id, u])) {
|
||||
const parents = (incomingToUnion.get(unionId) || [])
|
||||
.map((id) => {
|
||||
const pos = nodePositions.get(id);
|
||||
return pos
|
||||
? { id, x: pos.x - rootX, y: pos.y - rootY }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const children = (outgoingFromUnion.get(unionId) || [])
|
||||
.map((id) => {
|
||||
const pos = nodePositions.get(id);
|
||||
return pos
|
||||
? { id, x: pos.x - rootX, y: pos.y - rootY }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const ux = union.x;
|
||||
const uy = union.y;
|
||||
|
||||
// --- Parent-to-union connections ---
|
||||
// Each parent drops a vertical line from card bottom to the union Y,
|
||||
// then a horizontal bar connects them at union Y.
|
||||
if (parents.length > 0) {
|
||||
// Horizontal couple bar at union Y
|
||||
if (parents.length >= 2) {
|
||||
const xs = parents.map((p) => p.x).sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${uy} L ${xs[xs.length - 1]} ${uy}`,
|
||||
cssClass: "link couple-link",
|
||||
});
|
||||
}
|
||||
|
||||
// Vertical drops from each parent's bottom edge to couple bar
|
||||
for (const p of parents) {
|
||||
const bottomY = p.y + halfH;
|
||||
connections.push({
|
||||
path: `M ${p.x} ${bottomY} L ${p.x} ${uy}`,
|
||||
cssClass: "link ancestor-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Union-to-children connections ---
|
||||
// Vertical line from union down to bus Y, horizontal bus spanning
|
||||
// all children, then vertical drops from bus to each child's top.
|
||||
if (children.length > 0) {
|
||||
// Bus Y is halfway between union and the first child row
|
||||
const childY = children[0].y;
|
||||
const busY = uy + (childY - halfH - uy) / 2;
|
||||
|
||||
// Vertical stem from union (or couple bar) down to bus
|
||||
connections.push({
|
||||
path: `M ${ux} ${uy} L ${ux} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child: just continue the vertical line
|
||||
connections.push({
|
||||
path: `M ${children[0].x} ${busY} L ${children[0].x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
} else {
|
||||
// Horizontal bus spanning all children
|
||||
const xs = children
|
||||
.map((c) => c.x)
|
||||
.sort((a, b) => a - b);
|
||||
connections.push({
|
||||
path: `M ${xs[0]} ${busY} L ${xs[xs.length - 1]} ${busY}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
|
||||
// Vertical drops from bus to each child's top edge
|
||||
for (const c of children) {
|
||||
connections.push({
|
||||
path: `M ${c.x} ${busY} L ${c.x} ${childY - halfH}`,
|
||||
cssClass: "link descendant-link",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { persons, unions, connections };
|
||||
}
|
||||
31
resources/js/modules/lib/tree/ancestor-tree.js
Normal file
31
resources/js/modules/lib/tree/ancestor-tree.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Ancestor tree rendering — draws ancestor nodes and links upward from root.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
import { drawElbowLink } from "./link-drawer.js";
|
||||
|
||||
/**
|
||||
* Render the ancestor tree nodes and links.
|
||||
*
|
||||
* @param {d3.Selection} canvas - The SVG canvas group
|
||||
* @param {Array} nodes - Ancestor hierarchy nodes (from computeLayout)
|
||||
* @param {Array} links - Ancestor hierarchy links
|
||||
* @param {Configuration} config
|
||||
* @param {Function} onNodeClick
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function renderAncestorTree(canvas, nodes, links, config, onNodeClick, containerSelector) {
|
||||
// Draw links first (behind nodes)
|
||||
const linkGroup = canvas.append("g").attr("class", "ancestor-links");
|
||||
|
||||
for (const link of links) {
|
||||
drawElbowLink(linkGroup, link.source, link.target, "ancestor-link", config);
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
const nodeGroup = canvas.append("g").attr("class", "ancestor-nodes");
|
||||
|
||||
for (const node of nodes) {
|
||||
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
150
resources/js/modules/lib/tree/descendant-tree.js
Normal file
150
resources/js/modules/lib/tree/descendant-tree.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Descendant tree rendering.
|
||||
*
|
||||
* Key design: children descend from *both* parents. A horizontal connector
|
||||
* joins the couple, and the vertical drop to children originates from the
|
||||
* midpoint of that connector — not from a single parent.
|
||||
*
|
||||
* Multiple spouses are placed alternating left/right with increasing distance
|
||||
* to avoid overlap between family branches.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
import { computeSpouseOffset, SPOUSE_GAP } from "./spouse-util.js";
|
||||
|
||||
/**
|
||||
* Render the descendant tree: nodes, spouse nodes, and couple-centered links.
|
||||
*/
|
||||
export function renderDescendantTree(canvas, nodes, _links, rootData, config, onNodeClick, containerSelector) {
|
||||
const linkGroup = canvas.append("g").attr("class", "descendant-links");
|
||||
const nodeGroup = canvas.append("g").attr("class", "descendant-nodes");
|
||||
|
||||
// Build a map of xref → D3 node position for all descendant nodes + root
|
||||
const posMap = new Map();
|
||||
posMap.set(rootData.xref, { x: 0, y: 0 });
|
||||
for (const node of nodes) {
|
||||
posMap.set(node.data.xref, { x: node.x, y: node.y });
|
||||
}
|
||||
|
||||
// Render all descendant person cards
|
||||
for (const node of nodes) {
|
||||
renderPersonCard(nodeGroup, node, config, onNodeClick, containerSelector);
|
||||
}
|
||||
|
||||
// Render spouses and couple-centered links at every level (including root)
|
||||
const allPersons = [rootData, ...nodes.map((n) => n.data)];
|
||||
for (const person of allPersons) {
|
||||
renderCoupleLinks(linkGroup, nodeGroup, person, posMap, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a person with families, render each spouse and draw the
|
||||
* couple → children connector.
|
||||
*
|
||||
* Multiple spouses alternate left (odd index) / right (even index)
|
||||
* with increasing distance.
|
||||
*/
|
||||
function renderCoupleLinks(linkGroup, nodeGroup, personData, posMap, config, onNodeClick, containerSelector) {
|
||||
if (!personData.families || personData.families.length === 0) return;
|
||||
|
||||
const personPos = posMap.get(personData.xref);
|
||||
if (!personPos) return;
|
||||
|
||||
const w = config.cardWidth;
|
||||
const h = config.cardHeight;
|
||||
const halfH = h / 2;
|
||||
|
||||
personData.families.forEach((family, familyIndex) => {
|
||||
// Alternate spouse placement: first right, second left, third further right, etc.
|
||||
const spouseOffset = computeSpouseOffset(familyIndex, w, SPOUSE_GAP);
|
||||
|
||||
let spousePos = null;
|
||||
|
||||
if (family.spouse) {
|
||||
spousePos = {
|
||||
x: personPos.x + spouseOffset,
|
||||
y: personPos.y,
|
||||
};
|
||||
|
||||
// Render spouse card
|
||||
renderPersonCard(
|
||||
nodeGroup,
|
||||
{ x: spousePos.x, y: spousePos.y, data: family.spouse },
|
||||
config,
|
||||
onNodeClick,
|
||||
containerSelector
|
||||
);
|
||||
|
||||
// Horizontal connector between the couple (edge-to-edge)
|
||||
const leftX = Math.min(personPos.x, spousePos.x);
|
||||
const rightX = Math.max(personPos.x, spousePos.x);
|
||||
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link spouse-link")
|
||||
.attr("x1", leftX + w / 2)
|
||||
.attr("y1", personPos.y)
|
||||
.attr("x2", rightX - w / 2)
|
||||
.attr("y2", personPos.y);
|
||||
}
|
||||
|
||||
// Collect children positions for this family
|
||||
const childPositions = [];
|
||||
for (const child of family.children || []) {
|
||||
const childPos = posMap.get(child.xref);
|
||||
if (childPos) {
|
||||
childPositions.push(childPos);
|
||||
}
|
||||
}
|
||||
|
||||
if (childPositions.length === 0) return;
|
||||
|
||||
// Couple midpoint X (centered between parents)
|
||||
const coupleX = spousePos
|
||||
? (personPos.x + spousePos.x) / 2
|
||||
: personPos.x;
|
||||
|
||||
// Y coordinates: bottom of parent card → top of child card
|
||||
const parentBottomY = personPos.y + halfH;
|
||||
const childTopY = childPositions[0].y - halfH;
|
||||
|
||||
// Horizontal rail sits 40% of the way down from parent to child
|
||||
const railY = parentBottomY + (childTopY - parentBottomY) * 0.4;
|
||||
|
||||
// 1. Vertical line: couple bottom → rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", parentBottomY)
|
||||
.attr("x2", coupleX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// 2. Horizontal rail spanning all children
|
||||
const xs = childPositions.map((c) => c.x);
|
||||
const minX = Math.min(coupleX, ...xs);
|
||||
const maxX = Math.max(coupleX, ...xs);
|
||||
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// 3. Vertical drops: rail → top of each child card
|
||||
for (const cp of childPositions) {
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", cp.x)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", cp.x)
|
||||
.attr("y2", childTopY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export from shared utility
|
||||
export { computeSpouseOffset } from "./spouse-util.js";
|
||||
83
resources/js/modules/lib/tree/family-connector.js
Normal file
83
resources/js/modules/lib/tree/family-connector.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Family connector utilities.
|
||||
*
|
||||
* Draws special connectors between spouses and from couples to children.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Draw a family connector: horizontal line between spouses,
|
||||
* then vertical drop to children.
|
||||
*
|
||||
* @param {d3.Selection} group
|
||||
* @param {object} parent1 - First parent position {x, y}
|
||||
* @param {object} parent2 - Second parent position {x, y}
|
||||
* @param {Array} children - Child positions [{x, y}, ...]
|
||||
* @param {Configuration} config
|
||||
*/
|
||||
export function drawFamilyConnector(group, parent1, parent2, children, config) {
|
||||
const halfHeight = config.cardHeight / 2;
|
||||
|
||||
// Horizontal line between spouses
|
||||
if (parent2) {
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link spouse-link")
|
||||
.attr("x1", parent1.x)
|
||||
.attr("y1", parent1.y)
|
||||
.attr("x2", parent2.x)
|
||||
.attr("y2", parent2.y)
|
||||
.attr("stroke", "#d4a87b")
|
||||
.attr("stroke-width", 2);
|
||||
}
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
// Midpoint between parents (or just parent1 if single parent)
|
||||
const coupleX = parent2 ? (parent1.x + parent2.x) / 2 : parent1.x;
|
||||
const coupleY = parent1.y + halfHeight;
|
||||
|
||||
// Vertical drop from couple midpoint
|
||||
const childrenY = children[0].y - halfHeight;
|
||||
const midY = (coupleY + childrenY) / 2;
|
||||
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", coupleY)
|
||||
.attr("x2", coupleX)
|
||||
.attr("y2", midY);
|
||||
|
||||
if (children.length === 1) {
|
||||
// Single child — straight line down
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", coupleX)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", children[0].x)
|
||||
.attr("y2", childrenY);
|
||||
} else {
|
||||
// Multiple children — horizontal rail
|
||||
const minX = Math.min(...children.map((c) => c.x));
|
||||
const maxX = Math.max(...children.map((c) => c.x));
|
||||
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", midY);
|
||||
|
||||
for (const child of children) {
|
||||
group
|
||||
.append("line")
|
||||
.attr("class", "link descendant-link")
|
||||
.attr("x1", child.x)
|
||||
.attr("y1", midY)
|
||||
.attr("x2", child.x)
|
||||
.attr("y2", childrenY);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
resources/js/modules/lib/tree/link-drawer.js
Normal file
31
resources/js/modules/lib/tree/link-drawer.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Link/connector drawing utilities.
|
||||
*
|
||||
* Uses elbow (right-angle) connectors for a clean genealogy look.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Draw an elbow link between source and target nodes.
|
||||
*
|
||||
* @param {d3.Selection} group - SVG group to append the path to
|
||||
* @param {object} source - Source node with x, y coordinates
|
||||
* @param {object} target - Target node with x, y coordinates
|
||||
* @param {string} cssClass - Additional CSS class for the link
|
||||
* @param {Configuration} config
|
||||
*/
|
||||
export function drawElbowLink(group, source, target, cssClass, config) {
|
||||
const halfHeight = config.cardHeight / 2;
|
||||
|
||||
// Midpoint Y between source and target
|
||||
const midY = (source.y + target.y) / 2;
|
||||
|
||||
const path = `M ${source.x} ${source.y + (target.y > source.y ? halfHeight : -halfHeight)}
|
||||
L ${source.x} ${midY}
|
||||
L ${target.x} ${midY}
|
||||
L ${target.x} ${target.y + (target.y > source.y ? -halfHeight : halfHeight)}`;
|
||||
|
||||
group
|
||||
.append("path")
|
||||
.attr("class", `link ${cssClass}`)
|
||||
.attr("d", path);
|
||||
}
|
||||
60
resources/js/modules/lib/tree/sibling-layout.js
Normal file
60
resources/js/modules/lib/tree/sibling-layout.js
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Sibling node layout and rendering.
|
||||
*
|
||||
* Siblings are placed at the same Y-level as the root, to the left.
|
||||
* Connected via T-junction from the parent link.
|
||||
*/
|
||||
import { renderPersonCard } from "../chart/box.js";
|
||||
|
||||
/**
|
||||
* Render sibling nodes and their connectors.
|
||||
*
|
||||
* @param {d3.Selection} canvas
|
||||
* @param {Array} siblings - Sibling position data from computeSiblingPositions
|
||||
* @param {Array} links - Sibling links
|
||||
* @param {Configuration} config
|
||||
* @param {Function} onNodeClick
|
||||
* @param {string} containerSelector
|
||||
*/
|
||||
export function renderSiblings(canvas, siblings, links, config, onNodeClick, containerSelector) {
|
||||
if (siblings.length === 0) return;
|
||||
|
||||
const siblingGroup = canvas.append("g").attr("class", "sibling-nodes");
|
||||
const linkGroup = canvas.append("g").attr("class", "sibling-links");
|
||||
|
||||
// Draw a horizontal rail connecting all siblings + root
|
||||
const minX = 0;
|
||||
const maxX = siblings[siblings.length - 1].x;
|
||||
const railY = -config.cardHeight / 2 - 15;
|
||||
|
||||
// Vertical connector from parent area to rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", -config.cardHeight / 2)
|
||||
.attr("x2", 0)
|
||||
.attr("y2", railY);
|
||||
|
||||
// Horizontal rail
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", minX)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", maxX)
|
||||
.attr("y2", railY);
|
||||
|
||||
// Vertical drops from rail to each sibling
|
||||
for (const sibling of siblings) {
|
||||
linkGroup
|
||||
.append("line")
|
||||
.attr("class", "link sibling-link")
|
||||
.attr("x1", sibling.x)
|
||||
.attr("y1", railY)
|
||||
.attr("x2", sibling.x)
|
||||
.attr("y2", sibling.y - config.cardHeight / 2);
|
||||
|
||||
renderPersonCard(siblingGroup, sibling, config, onNodeClick, containerSelector);
|
||||
}
|
||||
}
|
||||
39
resources/js/modules/lib/tree/spouse-util.js
Normal file
39
resources/js/modules/lib/tree/spouse-util.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Shared spouse placement utilities.
|
||||
*/
|
||||
|
||||
export const SPOUSE_GAP = 10; // px between partner cards
|
||||
|
||||
/**
|
||||
* Compute the X offset for spouse placement.
|
||||
*
|
||||
* Alternates right/left with increasing distance:
|
||||
* family 0 → right (1 * offset)
|
||||
* family 1 → left (-1 * offset)
|
||||
* family 2 → right (2 * offset)
|
||||
* family 3 → left (-2 * offset)
|
||||
*
|
||||
* @param {number} index - Family index (0-based)
|
||||
* @param {number} cardWidth
|
||||
* @param {number} gap
|
||||
* @returns {number} X offset from person position
|
||||
*/
|
||||
export function computeSpouseOffset(index, cardWidth, gap) {
|
||||
const unit = cardWidth + gap;
|
||||
const distance = Math.floor(index / 2) + 1;
|
||||
const direction = index % 2 === 0 ? 1 : -1;
|
||||
return direction * distance * unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the couple midpoint X offset for a given family index.
|
||||
* This is half the spouse offset (the center between person and spouse).
|
||||
*
|
||||
* @param {number} familyIndex
|
||||
* @param {number} cardWidth
|
||||
* @param {number} gap
|
||||
* @returns {number}
|
||||
*/
|
||||
export function coupleMidpointOffset(familyIndex, cardWidth, gap) {
|
||||
return computeSpouseOffset(familyIndex, cardWidth, gap) / 2;
|
||||
}
|
||||
Reference in New Issue
Block a user