divan/GanjooRazor/wwwroot/js/peoplegraph.js
Hamid Reza Mohammadi 94859046a3 people tags #387
2026-10-04 08:46:55 +03:30

466 lines
22 KiB
JavaScript

// Renders the force-directed "ontology" explorer on /PeopleGraph. Pure vanilla JS + SVG - no
// external charting/physics library - same rationale as familytree.js: the graph is small (a few
// hundred nodes/edges at most), drawn once and then lightly interacted with, and the project prefers
// self-hosted, dependency-light front-end code over pulling in something like d3-force.
//
// Data shape (RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel, camelCased) -
// { nodes: [{id, name, hasFamilyTree, directlyTagged, importance}], edges: [{person1Id, person1Name,
// person2Id, person2Name, category, typeValue, degreeHint, note}] } where category is
// "Relation" (typeValue is PersonRelationType: 0=Parent,1=Sibling,2=Spouse,3=Ancestor) or
// "Affiliation" (typeValue is PersonAffiliationType: 0=Minister,1=Advisor,2=Courtier,3=Patron,
// 4=Ally,5=Rival,6=Servant,7=Companion,8=Successor,9=Panegyrized,10=Satirized,11=MilitaryCommander,
// 12=Champion,13=Contemporary,14=Killer,99=Other). directlyTagged is false only on
// the category-scoped graph (GET api/ganjoor/cat/{id}/persongraph, the "characters in this work"
// tab): such a node was pulled in as a one-hop relative/affiliate of someone actually named in the
// work's verses, and is never false on the whole-site graph fed by PeopleExplorer.cshtml. importance
// is PersonImportance (0=Normal,1=Important,2=VeryImportant), an editorial prominence set on the
// person themselves (see Editor.cshtml's new-person widget and SuggestPersonEdit.cshtml) - used
// below, alongside hasFamilyTree and degree, to size a node's circle (see nodeRadius()).
//
// Called with an options object (see PeopleExplorer.cshtml and _PersonGraphPartial.cshtml for two
// call sites with different element-id prefixes and data payloads), so the same renderer serves
// both the whole-site "explore all characters" modal (PeopleExplorer.open() in personwindow.js)
// and any number of category-scoped "شخصیت‌ها" tabs on cat/poet pages. A clicked node label or
// table-row name opens that person's own profile via PersonWindow.open(id) (personwindow.js),
// which is why this file assumes personwindow.js is also loaded on the page.
//
// Unlike FamilyTree.cshtml (a strict tree layout for one connected component), this lays out the
// WHOLE graph with a simple force simulation (mutual repulsion + spring edges + light centering,
// cooled down like d3-force's alpha decay) since the data isn't tree-shaped - the same node can be
// someone's minister AND brother-in-law AND rival, all at once - and a force layout is the natural
// way to show that without picking one relationship to draw and hiding the rest.
(function () {
'use strict';
// one entry per (category, typeValue) pair actually in use - label for the legend/table, color
// for the edge/legend swatch, directional true when Person1 -> Person2 has a specific meaning
// (drawn with an arrowhead), symmetric ones are drawn as plain lines
var KIND_META = {
'Relation:0': { label: 'پدر/مادر و فرزند', color: '#7a4a2a', directional: true },
'Relation:1': { label: 'خواهر/برادر', color: '#4a7a5a', directional: false },
'Relation:2': { label: 'همسر', color: '#a0455c', directional: false },
'Relation:3': { label: 'نیا و نواده', color: '#8b6b4a', directional: true },
'Affiliation:0': { label: 'وزیر', color: '#3a6ea5', directional: true },
'Affiliation:1': { label: 'مشاور', color: '#3a8ea5', directional: true },
'Affiliation:2': { label: 'درباری', color: '#5a7ea5', directional: true },
'Affiliation:3': { label: 'حامی', color: '#2a5e8a', directional: true },
'Affiliation:4': { label: 'دوست/متحد', color: '#3a9a4a', directional: false },
'Affiliation:5': { label: 'دشمن/رقیب', color: '#c0392b', directional: false },
'Affiliation:6': { label: 'خدمتکار', color: '#c07a2b', directional: true },
'Affiliation:7': { label: 'همراه', color: '#5aa08a', directional: false },
'Affiliation:8': { label: 'جانشین', color: '#8a4ac0', directional: true },
'Affiliation:9': { label: 'مدح‌گو', color: '#b08a2a', directional: true },
'Affiliation:10': { label: 'هجوگو', color: '#7a3a9a', directional: true },
'Affiliation:11': { label: 'سردار', color: '#4a6ea5', directional: true },
'Affiliation:12': { label: 'پهلوان', color: '#a56e3a', directional: true },
'Affiliation:13': { label: 'هم‌عصر', color: '#6a8a6a', directional: false },
'Affiliation:14': { label: 'قاتل', color: '#8a1a1a', directional: true },
'Affiliation:99': { label: 'سایر', color: '#888888', directional: false }
};
function kindKey(edge) { return edge.category + ':' + edge.typeValue; }
function kindMeta(edge) { return KIND_META[kindKey(edge)] || { label: edge.category, color: '#888888', directional: false }; }
function escapeHtml(s) {
var d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
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 renderPeopleGraph(opts) {
var container = document.getElementById(opts.containerId);
var svg = document.getElementById(opts.svgId);
var tableBody = document.getElementById(opts.tableBodyId);
var legend = document.getElementById(opts.legendId);
var searchInput = opts.searchInputId ? document.getElementById(opts.searchInputId) : null;
var resetBtn = opts.resetButtonId ? document.getElementById(opts.resetButtonId) : null;
var data = opts.data;
if (!container || !svg || !data || !data.nodes || data.nodes.length === 0) return;
var W = Math.max(container.clientWidth, 600);
var H = Math.max(container.clientHeight, 500);
// --- build simulation state -----------------------------------------------------------
var nodesById = {};
var nodes = data.nodes.map(function (n) {
var node = {
id: n.id, name: n.name, hasFamilyTree: n.hasFamilyTree, importance: n.importance || 0,
// absent on the whole-site graph payload (always directly tagged there) - default true
directlyTagged: n.directlyTagged !== false,
x: W / 2 + (Math.random() - 0.5) * W * 0.6,
y: H / 2 + (Math.random() - 0.5) * H * 0.6,
vx: 0, vy: 0, fx: null, fy: null, degree: 0
};
nodesById[n.id] = node;
return node;
});
var edges = (data.edges || []).filter(function (e) {
return nodesById[e.person1Id] && nodesById[e.person2Id];
}).map(function (e) {
nodesById[e.person1Id].degree++;
nodesById[e.person2Id].degree++;
return {
source: nodesById[e.person1Id], target: nodesById[e.person2Id],
meta: kindMeta(e), note: e.note, degreeHint: e.degreeHint, raw: e
};
});
// --- force simulation (simplified d3-force-alike, no external library) ----------------
var REPULSION = 2600;
var SPRING_LENGTH = 110;
var SPRING_K = 0.02;
var CENTER_K = 0.01;
var DAMPING = 0.82;
var alpha = 1, alphaDecay = 0.985, alphaMin = 0.005;
function tick() {
if (alpha < alphaMin) return false;
// mutual repulsion - O(n^2), fine for a few hundred nodes
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var a = nodes[i], b = nodes[j];
var dx = a.x - b.x, dy = a.y - b.y;
var distSq = dx * dx + dy * dy || 0.01;
var dist = Math.sqrt(distSq);
var force = (REPULSION / distSq) * alpha;
var fx = (dx / dist) * force, fy = (dy / dist) * force;
a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy;
}
}
// spring attraction along edges
edges.forEach(function (e) {
var dx = e.target.x - e.source.x, dy = e.target.y - e.source.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
var force = SPRING_K * (dist - SPRING_LENGTH) * alpha;
var fx = (dx / dist) * force, fy = (dy / dist) * force;
e.source.vx += fx; e.source.vy += fy;
e.target.vx -= fx; e.target.vy -= fy;
});
// light centering so the whole graph doesn't drift off-canvas
nodes.forEach(function (n) {
n.vx += (W / 2 - n.x) * CENTER_K * alpha;
n.vy += (H / 2 - n.y) * CENTER_K * alpha;
});
nodes.forEach(function (n) {
if (n.fx != null) { n.x = n.fx; n.y = n.fy; n.vx = 0; n.vy = 0; return; }
n.vx *= DAMPING; n.vy *= DAMPING;
n.x += n.vx; n.y += n.vy;
});
alpha *= alphaDecay;
return true;
}
// --- SVG scaffolding --------------------------------------------------------------------
while (svg.firstChild) svg.removeChild(svg.firstChild);
svg.setAttribute('width', W);
svg.setAttribute('height', H);
svg.removeAttribute('viewBox');
var canvas = el('g', { id: 'pg-canvas', transform: 'translate(0,0) scale(1)' });
svg.appendChild(canvas);
// arrowhead marker per directional kind color (SVG markers can't take dynamic per-edge fill
// easily, so define one marker per color actually in use)
var defs = el('defs');
canvas.appendChild(defs);
var markerIdsByColor = {};
function markerFor(color) {
var safeId = 'pg-arrow-' + color.replace('#', '');
if (markerIdsByColor[color]) return markerIdsByColor[color];
var marker = el('marker', {
id: safeId, viewBox: '0 0 10 10', refX: '8', refY: '5',
markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse'
});
var path = el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: color });
marker.appendChild(path);
defs.appendChild(marker);
markerIdsByColor[color] = safeId;
return safeId;
}
var edgesGroup = el('g', { 'class': 'pg-edges' });
var nodesGroup = el('g', { 'class': 'pg-nodes' });
canvas.appendChild(edgesGroup);
canvas.appendChild(nodesGroup);
var edgeEls = edges.map(function (e) {
var line = el('line', {
stroke: e.meta.color, 'stroke-width': 1.6, opacity: 0.55, 'class': 'pg-edge'
});
if (e.meta.directional) {
line.setAttribute('marker-end', 'url(#' + markerFor(e.meta.color) + ')');
}
edgesGroup.appendChild(line);
return { el: line, edge: e };
});
// importance (PersonImportance: 0=Normal,1=Important,2=VeryImportant) is the dominant term -
// a VeryImportant person should read as clearly bigger even with few edges - hasFamilyTree
// adds a smaller, independent bump on top (a tree root is worth noting regardless of its
// own importance), and degree only fine-tunes within that.
var IMPORTANCE_RADIUS_BUMP = { 0: 0, 1: 4, 2: 9 };
function nodeRadius(n) {
var base = (n.hasFamilyTree ? 10 : 7) + (IMPORTANCE_RADIUS_BUMP[n.importance] || 0);
return base + Math.min(6, n.degree * 0.6);
}
var nodeEls = nodes.map(function (n) {
var g = el('g', { 'class': 'pg-node', 'data-person-id': n.id, style: 'cursor:pointer' });
var circleClass = n.hasFamilyTree ? 'pg-circle pg-circle-tree' : 'pg-circle';
if (!n.directlyTagged) {
// context pulled in one hop out (a relative/affiliate never actually named in the
// work's verses) - draw dashed/dimmer so it reads as secondary, not part of the text
circleClass += ' pg-circle-secondary';
}
var circle = el('circle', {
r: nodeRadius(n),
'class': circleClass
});
var text = el('text', {
'text-anchor': 'middle', dy: -(nodeRadius(n) + 6),
'class': n.directlyTagged ? 'pg-label' : 'pg-label pg-label-secondary', direction: 'rtl',
style: 'cursor:pointer'
});
text.textContent = n.name;
// the label opens the person's profile window; the circle (handled below, on the
// whole <g>) keeps its own click behaviour of focusing this node's ego-network, so the
// label needs its own listener with stopPropagation to not also trigger that
text.addEventListener('click', function (evt) {
evt.stopPropagation();
if (window.PersonWindow) window.PersonWindow.open(n.id);
});
g.appendChild(circle);
g.appendChild(text);
nodesGroup.appendChild(g);
return { el: g, circle: circle, text: text, node: n };
});
function applyPositions() {
edgeEls.forEach(function (item) {
item.el.setAttribute('x1', item.edge.source.x);
item.el.setAttribute('y1', item.edge.source.y);
item.el.setAttribute('x2', item.edge.target.x);
item.el.setAttribute('y2', item.edge.target.y);
});
nodeEls.forEach(function (item) {
item.el.setAttribute('transform', 'translate(' + item.node.x + ',' + item.node.y + ')');
});
}
// --- table + legend ----------------------------------------------------------------------
function buildLegend() {
if (!legend) return;
var usedKeys = {};
edges.forEach(function (e) { usedKeys[kindKey(e.raw)] = e.meta; });
legend.innerHTML = '';
Object.keys(usedKeys).forEach(function (k) {
var meta = usedKeys[k];
var item = document.createElement('span');
item.className = 'pg-legend-item';
item.innerHTML = '<span class="pg-legend-swatch" style="background:' + meta.color + '"></span>' + escapeHtml(meta.label);
legend.appendChild(item);
});
}
var tableRows = [];
function buildTable() {
if (!tableBody) return;
tableBody.innerHTML = '';
edges.forEach(function (e, idx) {
var tr = document.createElement('tr');
tr.setAttribute('data-edge-index', idx);
tr.style.cursor = 'pointer';
tr.innerHTML =
'<td><a href="javascript:void(0)" class="pg-person-link" data-person-id="' + e.raw.person1Id + '">' + escapeHtml(e.raw.person1Name) + '</a></td>' +
'<td><span class="pg-legend-swatch" style="background:' + e.meta.color + '"></span> ' + escapeHtml(e.meta.label) + '</td>' +
'<td><a href="javascript:void(0)" class="pg-person-link" data-person-id="' + e.raw.person2Id + '">' + escapeHtml(e.raw.person2Name) + '</a></td>' +
'<td><small>' + escapeHtml(e.note || '') + '</small></td>';
tr.addEventListener('click', function () { focusOnPersons([e.raw.person1Id, e.raw.person2Id]); });
// the name links open the person's profile window without also triggering the
// row's own focus-on-click behaviour above
tr.querySelectorAll('.pg-person-link').forEach(function (link) {
link.addEventListener('click', function (evt) {
evt.stopPropagation();
if (window.PersonWindow) window.PersonWindow.open(Number(link.getAttribute('data-person-id')));
});
});
tableBody.appendChild(tr);
tableRows.push(tr);
});
}
// --- focus / ego-network highlighting -----------------------------------------------------
var focusedIds = null; // null = nothing focused (everything full opacity)
function neighborsOf(personId) {
var set = { };
set[personId] = true;
edges.forEach(function (e) {
if (e.source.id === personId) set[e.target.id] = true;
if (e.target.id === personId) set[e.source.id] = true;
});
return set;
}
function focusOnPersons(personIds) {
var set = {};
personIds.forEach(function (id) {
var nb = neighborsOf(id);
Object.keys(nb).forEach(function (k) { set[k] = true; });
});
focusedIds = set;
applyFocus();
}
function clearFocus() {
focusedIds = null;
applyFocus();
}
function applyFocus() {
nodeEls.forEach(function (item) {
var dim = focusedIds && !focusedIds[item.node.id];
item.el.style.opacity = dim ? 0.15 : 1;
});
edgeEls.forEach(function (item) {
var dim = focusedIds && !(focusedIds[item.edge.source.id] && focusedIds[item.edge.target.id]);
item.el.style.opacity = dim ? 0.05 : 0.55;
});
tableRows.forEach(function (tr, idx) {
var e = edges[idx];
var dim = focusedIds && !(focusedIds[e.source.id] && focusedIds[e.target.id]);
tr.style.display = dim ? 'none' : '';
});
}
nodeEls.forEach(function (item) {
item.el.addEventListener('click', function (evt) {
evt.stopPropagation();
if (focusedIds && focusedIds[item.node.id] && Object.keys(focusedIds).length <= (item.node.degree + 1)) {
// clicking an already-focused node again clears the focus (toggle)
clearFocus();
} else {
focusOnPersons([item.node.id]);
}
});
});
if (resetBtn) {
resetBtn.addEventListener('click', function () { clearFocus(); if (searchInput) searchInput.value = ''; });
}
if (searchInput) {
searchInput.addEventListener('input', function () {
var q = searchInput.value.trim();
if (!q) { clearFocus(); return; }
var matchIds = nodes.filter(function (n) { return n.name && n.name.indexOf(q) !== -1; }).map(function (n) { return n.id; });
if (matchIds.length === 0) { focusedIds = {}; applyFocus(); return; }
focusOnPersons(matchIds);
});
}
// --- drag to reposition (pins the node; double-click releases it) ------------------------
var draggingNode = null;
var panDragging = false, lastX = 0, lastY = 0;
var scale = 1, tx = 0, ty = 0;
function applyTransform() {
canvas.setAttribute('transform', 'translate(' + tx + ',' + ty + ') scale(' + scale + ')');
}
function svgPoint(evt) {
var rect = svg.getBoundingClientRect();
return {
x: (evt.clientX - rect.left - tx) / scale,
y: (evt.clientY - rect.top - ty) / scale
};
}
nodeEls.forEach(function (item) {
item.el.addEventListener('mousedown', function (evt) {
evt.stopPropagation();
draggingNode = item.node;
});
item.el.addEventListener('dblclick', function (evt) {
evt.stopPropagation();
item.node.fx = null; item.node.fy = null;
alpha = Math.max(alpha, 0.3);
ensureRunning();
});
});
svg.addEventListener('mousedown', function (evt) {
if (draggingNode) return;
panDragging = true; lastX = evt.clientX; lastY = evt.clientY;
svg.style.cursor = 'grabbing';
});
window.addEventListener('mousemove', function (evt) {
if (draggingNode) {
var p = svgPoint(evt);
draggingNode.fx = p.x; draggingNode.fy = p.y;
draggingNode.x = p.x; draggingNode.y = p.y;
if (alpha < 0.05) { alpha = 0.05; ensureRunning(); }
return;
}
if (!panDragging) return;
tx += (evt.clientX - lastX); ty += (evt.clientY - lastY);
lastX = evt.clientX; lastY = evt.clientY;
applyTransform();
});
window.addEventListener('mouseup', function () {
draggingNode = null;
panDragging = false;
svg.style.cursor = 'grab';
});
svg.addEventListener('wheel', function (evt) {
evt.preventDefault();
var factor = evt.deltaY < 0 ? 1.1 : 0.9;
scale = Math.min(3, Math.max(0.2, scale * factor));
applyTransform();
}, { passive: false });
svg.style.cursor = 'grab';
// --- animation loop -----------------------------------------------------------------------
var running = false;
function ensureRunning() {
if (running) return;
running = true;
requestAnimationFrame(step);
}
function step() {
var keepGoing = tick();
applyPositions();
if (keepGoing) {
requestAnimationFrame(step);
} else {
running = false;
}
}
buildLegend();
buildTable();
applyPositions();
ensureRunning();
}
window.GanjoorPeopleGraph = { render: renderPeopleGraph };
})();