divan/GanjooRazor/wwwroot/js/familytree.js
Hamid Reza Mohammadi a0a7558d8b people tags #387
2026-10-02 09:18:40 +03:30

375 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Renders the interactive family-tree chart inside the FamilyTreeWindow.open(id) modal (see
// personwindow.js and FamilyTreeWindow.cshtml). Pure vanilla JS + SVG - no external charting
// library - since the shape of the problem (a handful to a few hundred nodes, drawn once,
// panned/zoomed by the user) doesn't need one, and the project prefers self-hosted, dependency-light
// front-end code.
//
// Input (passed as the render() call's data/requestedRootId arguments - see personwindow.js's
// openFamilyTree(), which reads them from the #ftw-data/#ftw-root-id elements
// FamilyTreeWindow.cshtml embeds): the JSON of
// RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel, camelCased -
// { rootId, persons: [{id, name, birthYearInLHijri, deathYearInLHijri, validBirthDate,
// validDeathDate, description, wikiUrl, familyTreeCaption, ...}], relations: [{person1Id,
// person2Id, relationType, degreeHint}] } where relationType is PersonRelationType's numeric
// value: 0=Parent, 1=Sibling, 2=Spouse, 3=Ancestor (Person1 is the parent/ancestor side for
// 0 and 3; order doesn't matter for 1 and 2).
//
// The graph is a general kinship graph, not a strict tree (a person can have two recorded parents,
// multiple spouses, etc.), so this builds a reasonable tree-like DRAWING out of it with a few
// simplifying choices - see comments below - rather than claiming to be an exact rendering of every
// edge. That mirrors what similar Shahnameh genealogy charts do in practice.
(function () {
'use strict';
function dedup(arr) {
return arr ? Array.from(new Set(arr)) : [];
}
function buildLayout(data, requestedRootId) {
var persons = {};
data.persons.forEach(function (p) { persons[p.id] = p; });
var parentsOf = {}; // childId -> [parentId,...]
var childrenOf = {}; // parentId -> [childId,...]
var spouseOf = {}; // id -> [spouseId,...]
function addParentChild(parentId, childId) {
(parentsOf[childId] = parentsOf[childId] || []).push(parentId);
(childrenOf[parentId] = childrenOf[parentId] || []).push(childId);
}
function addSpouse(a, b) {
(spouseOf[a] = spouseOf[a] || []).push(b);
(spouseOf[b] = spouseOf[b] || []).push(a);
}
var siblingPairs = [];
(data.relations || []).forEach(function (r) {
if (r.relationType === 0 || r.relationType === 3) {
addParentChild(r.person1Id, r.person2Id);
} else if (r.relationType === 2) {
addSpouse(r.person1Id, r.person2Id);
} else if (r.relationType === 1) {
siblingPairs.push([r.person1Id, r.person2Id]);
}
});
// A sibling pair where only one side has a recorded parent (e.g. a sibling relation was
// entered but no separate parent relation for the other sibling) used to leave the parentless
// sibling with no parent at all, turning them into their own disconnected chart root instead
// of being drawn next to their actual family. Have the parentless sibling inherit the same
// parent(s), purely so the drawing places them together - this doesn't change any real data.
siblingPairs.forEach(function (pair) {
var a = pair[0], b = pair[1];
var aHasParent = parentsOf[a] && parentsOf[a].length > 0;
var bHasParent = parentsOf[b] && parentsOf[b].length > 0;
if (aHasParent && !bHasParent) {
parentsOf[a].forEach(function (p) { addParentChild(p, b); });
} else if (bHasParent && !aHasParent) {
parentsOf[b].forEach(function (p) { addParentChild(p, a); });
}
});
Object.keys(childrenOf).forEach(function (k) { childrenOf[k] = dedup(childrenOf[k]); });
Object.keys(parentsOf).forEach(function (k) { parentsOf[k] = dedup(parentsOf[k]); });
Object.keys(spouseOf).forEach(function (k) { spouseOf[k] = dedup(spouseOf[k]); });
var allIds = data.persons.map(function (p) { return p.id; });
var isRoot = {};
allIds.forEach(function (id) { isRoot[id] = !parentsOf[id] || parentsOf[id].length === 0; });
// one child can have two recorded parents (mother + father) - pick a single "primary"
// parent per child to build a drawable tree, preferring whichever parent is NOT themselves
// a chart-root (so the root-level spouse ends up as the "attached" side, see below)
var primaryParentOf = {};
var secondaryParentOf = {};
allIds.forEach(function (childId) {
var ps = parentsOf[childId];
if (!ps || ps.length === 0) return;
if (ps.length === 1) { primaryParentOf[childId] = ps[0]; return; }
var sorted = ps.slice().sort(function (a, b) {
var ar = isRoot[a] ? 1 : 0, br = isRoot[b] ? 1 : 0;
if (ar !== br) return ar - br;
return a - b;
});
primaryParentOf[childId] = sorted[0];
secondaryParentOf[childId] = sorted[1];
});
var hasOwnChildren = {};
Object.keys(primaryParentOf).forEach(function (childId) {
hasOwnChildren[primaryParentOf[childId]] = true;
});
// a childless, parentless spouse (the common case - a wife with no recorded parents/kids
// of her own beyond this marriage) is drawn as a small box attached beside their partner
// rather than given their own column, same visual idea as the heart-linked pairs in
// akhakhafrasiyab.ir's chart
var attachedTo = {}; // spouseId -> primaryId
var attachedList = {}; // primaryId -> [spouseId,...]
function tryAttach(primaryId, spouseId) {
if (attachedTo[spouseId] !== undefined || spouseId === primaryId) return false;
if (isRoot[spouseId] && !hasOwnChildren[spouseId]) {
attachedTo[spouseId] = primaryId;
(attachedList[primaryId] = attachedList[primaryId] || []).push(spouseId);
return true;
}
return false;
}
Object.keys(secondaryParentOf).forEach(function (childId) {
tryAttach(primaryParentOf[childId], secondaryParentOf[childId]);
});
Object.keys(spouseOf).forEach(function (a) {
spouseOf[a].forEach(function (b) {
if (attachedTo[a] !== undefined || attachedTo[b] !== undefined) return;
if (tryAttach(a, b)) return;
tryAttach(b, a);
});
});
var topLevelRoots = allIds.filter(function (id) { return isRoot[id] && attachedTo[id] === undefined; });
function subtreeContains(rootId, targetId, guard) {
guard = guard || {};
if (rootId === targetId) return true;
if (guard[rootId]) return false;
guard[rootId] = true;
var kids = childrenOf[rootId] || [];
for (var i = 0; i < kids.length; i++) if (subtreeContains(kids[i], targetId, guard)) return true;
var att = attachedList[rootId] || [];
for (var j = 0; j < att.length; j++) if (att[j] === targetId) return true;
return false;
}
topLevelRoots.sort(function (a, b) {
var aHas = subtreeContains(a, requestedRootId) ? 0 : 1;
var bHas = subtreeContains(b, requestedRootId) ? 0 : 1;
if (aHas !== bHas) return aHas - bHas;
return a - b;
});
// classic tidy-tree-ish pass: leaves get sequential column indices, a parent's column is
// the midpoint of its children's - good enough for the mostly-linear/lightly-branching
// lineages this data tends to have
var xCounter = 0;
var nodeX = {}, nodeDepth = {};
function layout(id, depth) {
nodeDepth[id] = depth;
var kids = dedup(childrenOf[id] || []).sort(function (a, b) { return a - b; });
if (kids.length === 0) {
nodeX[id] = xCounter++;
} else {
kids.forEach(function (k) { layout(k, depth + 1); });
var xs = kids.map(function (k) { return nodeX[k]; });
nodeX[id] = (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2;
}
}
topLevelRoots.forEach(function (r) { layout(r, 0); });
return {
persons: persons,
childrenOf: childrenOf,
primaryParentOf: primaryParentOf,
secondaryParentOf: secondaryParentOf,
attachedList: attachedList,
topLevelRoots: topLevelRoots,
nodeX: nodeX,
nodeDepth: nodeDepth
};
}
var SVG_NS = 'http://www.w3.org/2000/svg';
function el(tag, attrs) {
var e = document.createElementNS(SVG_NS, tag);
if (attrs) {
Object.keys(attrs).forEach(function (k) { e.setAttribute(k, attrs[k]); });
}
return e;
}
function personLabel(p) {
var s = p.name || '';
return s;
}
function renderFamilyTree(containerId, svgId, tooltipId, data, requestedRootId) {
var container = document.getElementById(containerId);
var svg = document.getElementById(svgId);
var tooltip = document.getElementById(tooltipId);
if (!container || !svg || !data || !data.persons || data.persons.length === 0) return;
var layoutInfo = buildLayout(data, requestedRootId);
var COL_WIDTH = 220;
var ROW_HEIGHT = 130;
var BOX_W = 140;
var BOX_H = 56;
var ATTACH_GAP = 14;
var ATTACH_W = 100;
var MARGIN = 60;
var canvas = el('g', { id: 'ft-canvas', transform: 'translate(0,0) scale(1)' });
while (svg.firstChild) svg.removeChild(svg.firstChild);
svg.appendChild(canvas);
var linesGroup = el('g', { 'class': 'ft-lines' });
var nodesGroup = el('g', { 'class': 'ft-nodes' });
canvas.appendChild(linesGroup);
canvas.appendChild(nodesGroup);
function pos(id) {
return {
x: MARGIN + layoutInfo.nodeX[id] * COL_WIDTH,
y: MARGIN + layoutInfo.nodeDepth[id] * ROW_HEIGHT
};
}
function line(x1, y1, x2, y2, cls) {
var l = el('path', {
d: 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + (y1 + (y2 - y1) / 2) + ', ' + x2 + ' ' + (y1 + (y2 - y1) / 2) + ', ' + x2 + ' ' + y2,
'class': cls || 'ft-line'
});
linesGroup.appendChild(l);
}
// parent -> child connectors
Object.keys(layoutInfo.primaryParentOf).forEach(function (childId) {
childId = parseInt(childId, 10);
var parentId = layoutInfo.primaryParentOf[childId];
if (layoutInfo.nodeX[parentId] === undefined || layoutInfo.nodeX[childId] === undefined) return;
var pp = pos(parentId), cp = pos(childId);
line(pp.x, pp.y + BOX_H / 2, cp.x, cp.y - BOX_H / 2, 'ft-line ft-line-parent');
});
var maxX = 0, maxY = 0;
var drawnPos = {}; // id -> {x,y} for every box actually drawn (primary nodes and attached spouses)
function drawBox(id, x, y, w, h, isRequested) {
var p = layoutInfo.persons[id];
var g = el('g', { 'class': 'ft-node', 'data-person-id': id, style: 'cursor:pointer' });
var rect = el('rect', {
x: x - w / 2, y: y - h / 2, width: w, height: h, rx: 8, ry: 8,
'class': isRequested ? 'ft-box ft-box-current' : 'ft-box'
});
g.appendChild(rect);
var text = el('text', {
x: x, y: y + 5, 'text-anchor': 'middle', 'class': 'ft-box-text', direction: 'rtl'
});
text.textContent = personLabel(p);
g.appendChild(text);
g.addEventListener('click', function () { showDetails(p); });
nodesGroup.appendChild(g);
maxX = Math.max(maxX, x + w / 2);
maxY = Math.max(maxY, y + h / 2);
drawnPos[id] = { x: x, y: y };
}
Object.keys(layoutInfo.nodeX).forEach(function (idStr) {
var id = parseInt(idStr, 10);
var p = pos(id);
drawBox(id, p.x, p.y, BOX_W, BOX_H, id === requestedRootId);
var attached = layoutInfo.attachedList[id] || [];
attached.forEach(function (spouseId, i) {
var ax = p.x + BOX_W / 2 + ATTACH_GAP + ATTACH_W / 2 + i * (ATTACH_W + ATTACH_GAP);
var ay = p.y;
drawBox(spouseId, ax, ay, ATTACH_W, BOX_H, spouseId === requestedRootId);
line(p.x + BOX_W / 2, p.y, ax - ATTACH_W / 2, ay, 'ft-line ft-line-spouse');
var heartX = p.x + BOX_W / 2 + ATTACH_GAP / 2;
var heart = el('text', { x: heartX, y: ay + 4, 'text-anchor': 'middle', 'class': 'ft-heart' });
heart.textContent = '♥';
nodesGroup.appendChild(heart);
maxX = Math.max(maxX, ax + ATTACH_W / 2);
});
});
svg.setAttribute('viewBox', '0 0 ' + (maxX + MARGIN) + ' ' + (maxY + MARGIN));
function showDetails(p) {
if (!tooltip) return;
var html = '<button type="button" class="ft-tooltip-close" aria-label="بستن">×</button>' +
'<h3 style="margin:4px 0 10px 0">' + escapeHtml(personLabel(p)) + '</h3>';
if (p.birthYearInLHijri || p.deathYearInLHijri) {
html += '<p style="margin:4px 0"><small>';
if (p.birthYearInLHijri) html += 'زاده ' + p.birthYearInLHijri.toLocaleString('fa-IR') + (p.validBirthDate ? '' : ' (تخمینی)');
if (p.birthYearInLHijri && p.deathYearInLHijri) html += ' — ';
if (p.deathYearInLHijri) html += 'وفات ' + p.deathYearInLHijri.toLocaleString('fa-IR') + (p.validDeathDate ? '' : ' (تخمینی)');
html += '</small></p>';
}
if (p.description) {
html += '<p style="margin:4px 0"><small>' + escapeHtml(p.description) + '</small></p>';
}
html += '<p style="margin:8px 0 0 0"><a href="javascript:void(0)" onclick="PersonWindow.open(' + p.id + ')">مشاهدهٔ اطلاعات کامل</a></p>';
tooltip.innerHTML = html;
tooltip.style.display = 'block';
tooltip.querySelector('.ft-tooltip-close').addEventListener('click', function () {
tooltip.style.display = 'none';
});
}
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
svg.removeAttribute('viewBox'); // pan/zoom via transform, not viewBox, once we have a canvas group
// The SVG element is always sized to the CONTAINER - never to the full tree - so it acts as a
// fixed "viewport" onto the (possibly much larger) tree, which is panned/zoomed via the canvas
// group's transform below. Previously this grew the SVG itself to (maxX+MARGIN)/(maxY+MARGIN)
// whenever the tree was bigger than the container; combined with the page's RTL layout and the
// container's overflow:hidden, that pinned the oversized SVG's right edge to the container's
// right edge, which could hide the requested root's own subtree entirely if it was laid out
// toward the left - with nothing below ever panning the view back to it.
var svgWidth = container.clientWidth || (maxX + MARGIN);
var svgHeight = container.clientHeight || (maxY + MARGIN);
svg.setAttribute('width', svgWidth);
svg.setAttribute('height', svgHeight);
// pan + zoom - a small, dependency-free version of the usual SVG drag/wheel recipe
var scale = 1;
// Center the initial view on the requested root's own drawn position (falling back to an
// attached/spouse box's position if that's how it was drawn) so the person actually asked for
// is what's visible first, rather than whatever subtree happened to land at low x. When the
// whole tree already fits inside the viewport, center the whole tree instead (matches the old,
// still-correct behavior for small trees).
var tx, ty;
if ((maxX + MARGIN) <= svgWidth && (maxY + MARGIN) <= svgHeight) {
tx = Math.max(0, (svgWidth - (maxX + MARGIN)) / 2);
ty = Math.max(0, (svgHeight - (maxY + MARGIN)) / 2);
} else {
var focus = drawnPos[requestedRootId] || { x: (maxX + MARGIN) / 2, y: (maxY + MARGIN) / 2 };
tx = svgWidth / 2 - focus.x;
ty = svgHeight / 2 - focus.y;
}
var dragging = false, lastX = 0, lastY = 0;
function applyTransform() {
canvas.setAttribute('transform', 'translate(' + tx + ',' + ty + ') scale(' + scale + ')');
}
svg.addEventListener('mousedown', function (e) {
dragging = true; lastX = e.clientX; lastY = e.clientY;
svg.style.cursor = 'grabbing';
});
window.addEventListener('mouseup', function () { dragging = false; svg.style.cursor = 'grab'; });
window.addEventListener('mousemove', function (e) {
if (!dragging) return;
tx += (e.clientX - lastX); ty += (e.clientY - lastY);
lastX = e.clientX; lastY = e.clientY;
applyTransform();
});
svg.addEventListener('wheel', function (e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.1 : 0.9;
scale = Math.min(3, Math.max(0.2, scale * factor));
applyTransform();
}, { passive: false });
svg.style.cursor = 'grab';
applyTransform();
}
window.GanjoorFamilyTree = { render: renderFamilyTree };
})();