+ این نمودار همهٔ خویشاوندیها و وابستگیهای غیرخویشاوندی ثبتشده را با هم نشان میدهد؛ برخلاف نمودار شجرهنامه که به یک خانواده محدود است.
+ روی هر گره کلیک کنید تا فقط ارتباطهای همان شخص برجسته شود (کلیک دوباره برای بازگشت)؛ برای جابهجایی یک گره آن را بکشید (برای رها کردنش دوبار کلیک کنید).
+ برای پیمایش نمودار آن را با ماوس بکشید و با غلتک ماوس بزرگ/کوچک کنید.
+
+
+
+
+
+
+
+
+ @if (ViewData["TrackingScript"] != null && !string.IsNullOrEmpty(ViewData["TrackingScript"].ToString()))
+ {
+ @Html.Raw(ViewData["TrackingScript"])
+ }
+}
diff --git a/GanjooRazor/Pages/PeopleGraph.cshtml.cs b/GanjooRazor/Pages/PeopleGraph.cshtml.cs
new file mode 100644
index 00000000..15869386
--- /dev/null
+++ b/GanjooRazor/Pages/PeopleGraph.cshtml.cs
@@ -0,0 +1,54 @@
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Newtonsoft.Json;
+using RMuseum.Models.Ganjoor.ViewModels;
+
+namespace GanjooRazor.Pages
+{
+ ///
+ /// force-directed "ontology" explorer over the whole known people network (kinship edges and
+ /// non-family ties together), drawn client-side (vanilla JS/SVG - see the <script> block in
+ /// PeopleGraph.cshtml) from what GET api/people/graph returns. The nowruzgan-style counterpart of
+ /// the strict-tree /FamilyTree/{id} view: not limited to one family, shows every recorded
+ /// relationship at once with a synced data table and click-to-focus ego network.
+ ///
+ public class PeopleGraphModel : LoginPartialEnabledPageModel
+ {
+ public PeopleGraphModel(HttpClient httpClient, IConfiguration configuration) : base(httpClient, configuration)
+ {
+ }
+
+ public string LastError { get; set; }
+
+ public GanjoorPersonGraphViewModel Graph { get; set; }
+
+ ///
+ /// Graph, re-serialized with an explicit camelCase contract (same convention FamilyTree.cshtml.cs
+ /// uses for TreeDataJson) so the client-side layout script has a predictable shape to parse
+ ///
+ public string GraphDataJson =>
+ JsonConvert.SerializeObject(
+ Graph,
+ new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }
+ );
+
+ public async Task OnGetAsync()
+ {
+ InitializeCommonPageState();
+
+ ViewData["Title"] = "گنجور » شبکهٔ روابط شخصیتها";
+
+ var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/people/graph");
+ if (!response.IsSuccessStatusCode)
+ {
+ LastError = await ReadErrorMessageAsync(response);
+ return Page();
+ }
+ Graph = JsonConvert.DeserializeObject(await response.Content.ReadAsStringAsync());
+
+ return Page();
+ }
+ }
+}
diff --git a/GanjooRazor/Pages/Person.cshtml.cs b/GanjooRazor/Pages/Person.cshtml.cs
index 6b4094b1..6a8d7fcc 100644
--- a/GanjooRazor/Pages/Person.cshtml.cs
+++ b/GanjooRazor/Pages/Person.cshtml.cs
@@ -100,11 +100,15 @@ namespace GanjooRazor.Pages
{ PersonAffiliationType.Patron, "حامیِ" },
{ PersonAffiliationType.Ally, "متحدِ" },
{ PersonAffiliationType.Rival, "رقیبِ" },
+ { PersonAffiliationType.Servant, "خدمتکارِ" },
+ { PersonAffiliationType.Companion, "همراهِ" },
+ { PersonAffiliationType.Successor, "جانشینِ" },
{ PersonAffiliationType.Other, "دارای نسبتی (به یادداشت نگاه کنید) با" },
};
private static bool IsSymmetricAffiliation(PersonAffiliationType t) =>
- t == PersonAffiliationType.Ally || t == PersonAffiliationType.Rival || t == PersonAffiliationType.Other;
+ t == PersonAffiliationType.Ally || t == PersonAffiliationType.Rival ||
+ t == PersonAffiliationType.Companion || t == PersonAffiliationType.Other;
public async Task OnGetAsync(int id)
{
diff --git a/GanjooRazor/wwwroot/js/peoplegraph.js b/GanjooRazor/wwwroot/js/peoplegraph.js
new file mode 100644
index 00000000..60aebfb5
--- /dev/null
+++ b/GanjooRazor/wwwroot/js/peoplegraph.js
@@ -0,0 +1,415 @@
+// 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.
+//
+// Input (window.peopleGraphData, set inline by PeopleGraph.cshtml): the JSON of
+// RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel, camelCased -
+// { nodes: [{id, name, hasFamilyTree}], 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,99=Other).
+//
+// 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: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,
+ 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 };
+ });
+
+ function nodeRadius(n) {
+ return (n.hasFamilyTree ? 10 : 7) + 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 circle = el('circle', {
+ r: nodeRadius(n),
+ 'class': n.hasFamilyTree ? 'pg-circle pg-circle-tree' : 'pg-circle'
+ });
+ var text = el('text', {
+ 'text-anchor': 'middle', dy: -(nodeRadius(n) + 6), 'class': 'pg-label', direction: 'rtl'
+ });
+ text.textContent = n.name;
+ 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 = '' + 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 =
+ '
' + escapeHtml(e.raw.person1Name) + '
' +
+ '
' + escapeHtml(e.meta.label) + '
' +
+ '
' + escapeHtml(e.raw.person2Name) + '
' +
+ '
' + escapeHtml(e.note || '') + '
';
+ tr.addEventListener('click', function () { focusOnPersons([e.raw.person1Id, e.raw.person2Id]); });
+ 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 };
+})();
diff --git a/RMuseum/Controllers/GanjoorRelatedPersonController.cs b/RMuseum/Controllers/GanjoorRelatedPersonController.cs
index f6962e06..ab0422c9 100644
--- a/RMuseum/Controllers/GanjoorRelatedPersonController.cs
+++ b/RMuseum/Controllers/GanjoorRelatedPersonController.cs
@@ -59,6 +59,25 @@ namespace RMuseum.Controllers
return Ok(res.Result);
}
+ ///
+ /// get the whole known network of people (every person with at least one kinship edge or
+ /// non-family tie, plus every one of those edges/ties), for the force-directed "ontology"
+ /// explorer at /PeopleGraph. Registered before the "{id}" route below (and constrained to
+ /// int there) so this literal segment isn't swallowed as an id.
+ ///
+ ///
+ [HttpGet("graph")]
+ [AllowAnonymous]
+ [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonGraphViewModel))]
+ [ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
+ public async Task GetPersonGraphAsync()
+ {
+ var res = await _personService.GetPersonGraphAsync();
+ if (!string.IsNullOrEmpty(res.ExceptionString))
+ return BadRequest(res.ExceptionString);
+ return Ok(res.Result);
+ }
+
///
/// get person by id
///
diff --git a/RMuseum/Models/Ganjoor/PersonAffiliationType.cs b/RMuseum/Models/Ganjoor/PersonAffiliationType.cs
index 0cd421b9..4e3be546 100644
--- a/RMuseum/Models/Ganjoor/PersonAffiliationType.cs
+++ b/RMuseum/Models/Ganjoor/PersonAffiliationType.cs
@@ -40,6 +40,24 @@ namespace RMuseum.Models.Ganjoor
///
Rival = 5,
+ ///
+ /// Person1 was a servant (personal attendant, not a court office) of Person2 - narrower than
+ /// Courtier, for a purely domestic/personal-service tie rather than a court role
+ ///
+ Servant = 6,
+
+ ///
+ /// Person1 and Person2 were companions/comrades - e.g. fellow travelers, brothers-in-arms
+ /// (symmetric - order doesn't matter)
+ ///
+ Companion = 7,
+
+ ///
+ /// Person1 succeeded Person2 in a role/office/throne (directional - Person1 is the one who
+ /// came after)
+ ///
+ Successor = 8,
+
///
/// doesn't fit any of the above - rely on Note for what the tie actually is
///
diff --git a/RMuseum/Models/Ganjoor/ViewModels/GanjoorPersonGraphViewModel.cs b/RMuseum/Models/Ganjoor/ViewModels/GanjoorPersonGraphViewModel.cs
new file mode 100644
index 00000000..75a2f84b
--- /dev/null
+++ b/RMuseum/Models/Ganjoor/ViewModels/GanjoorPersonGraphViewModel.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+
+namespace RMuseum.Models.Ganjoor.ViewModels
+{
+ ///
+ /// the whole known network of people - every GanjoorRelatedPerson that has at least one kinship
+ /// edge or non-family tie, plus every one of those edges/ties - served by GET api/people/graph
+ /// for the force-directed "ontology" explorer at /PeopleGraph, the nowruzgan-style counterpart of
+ /// the strict-tree /FamilyTree/{id} view. Loaded whole (like GetFamilyTreeAsync does for a single
+ /// component) since the graph as a whole is small - a few hundred rows at most.
+ ///
+ public class GanjoorPersonGraphViewModel
+ {
+ ///
+ /// every person that appears in at least one edge below
+ ///
+ public List Nodes { get; set; }
+
+ ///
+ /// every kinship edge (GanjoorPersonRelation) and non-family tie (GanjoorPersonAffiliation),
+ /// merged into one flat list - see GanjoorPersonGraphEdge.Category to tell them apart
+ ///
+ public List Edges { get; set; }
+ }
+
+ ///
+ /// one node of the people graph
+ ///
+ public class GanjoorPersonGraphNode
+ {
+ ///
+ /// GanjoorRelatedPerson.Id
+ ///
+ public int Id { get; set; }
+
+ ///
+ /// GanjoorRelatedPerson.Name
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// true if this person captions a family tree (GanjoorRelatedPerson.FamilyTreeCaption not
+ /// empty) - used client-side to draw such nodes slightly larger/more prominent, and to link
+ /// to their /FamilyTree/{id} strict-tree view
+ ///
+ public bool HasFamilyTree { get; set; }
+ }
+
+ ///
+ /// one edge of the people graph - either a kinship edge or a non-family tie, flattened to a
+ /// common shape so the client can draw/list them uniformly
+ ///
+ public class GanjoorPersonGraphEdge
+ {
+ ///
+ /// first person - directional meaning (if any) depends on Category/TypeValue, same as the
+ /// underlying GanjoorPersonRelation/GanjoorPersonAffiliation row
+ ///
+ public int Person1Id { get; set; }
+
+ ///
+ /// first person's name, denormalized here so the client doesn't need a second lookup
+ ///
+ public string Person1Name { get; set; }
+
+ ///
+ /// second person
+ ///
+ public int Person2Id { get; set; }
+
+ ///
+ /// second person's name
+ ///
+ public string Person2Name { get; set; }
+
+ ///
+ /// "Relation" for a GanjoorPersonRelation edge, "Affiliation" for a GanjoorPersonAffiliation
+ /// one - tells the client which enum TypeValue is a numeric value of
+ ///
+ public string Category { get; set; }
+
+ ///
+ /// the numeric value of PersonRelationType (when Category is "Relation") or
+ /// PersonAffiliationType (when Category is "Affiliation")
+ ///
+ public int TypeValue { get; set; }
+
+ ///
+ /// see GanjoorPersonRelation.DegreeHint - null for an Affiliation edge
+ ///
+ public int? DegreeHint { get; set; }
+
+ ///
+ /// free-text note
+ ///
+ public string Note { get; set; }
+ }
+}
diff --git a/RMuseum/RMuseum.xml b/RMuseum/RMuseum.xml
index 11955906..a5ffc6f4 100644
--- a/RMuseum/RMuseum.xml
+++ b/RMuseum/RMuseum.xml
@@ -2838,6 +2838,15 @@
+
+
+ get the whole known network of people (every person with at least one kinship edge or
+ non-family tie, plus every one of those edges/ties), for the force-directed "ontology"
+ explorer at /PeopleGraph. Registered before the "{id}" route below (and constrained to
+ int there) so this literal segment isn't swallowed as an id.
+
+
+
get person by id
@@ -11749,6 +11758,24 @@
Person1 and Person2 were rivals/enemies (symmetric - order doesn't matter)
+
+
+ Person1 was a servant (personal attendant, not a court office) of Person2 - narrower than
+ Courtier, for a purely domestic/personal-service tie rather than a court role
+
+
+
+
+ Person1 and Person2 were companions/comrades - e.g. fellow travelers, brothers-in-arms
+ (symmetric - order doesn't matter)
+
+
+
+
+ Person1 succeeded Person2 in a role/office/throne (directional - Person1 is the one who
+ came after)
+
+
doesn't fit any of the above - rely on Note for what the tie actually is
@@ -13292,6 +13319,97 @@
FullUrl
+
+
+ the whole known network of people - every GanjoorRelatedPerson that has at least one kinship
+ edge or non-family tie, plus every one of those edges/ties - served by GET api/people/graph
+ for the force-directed "ontology" explorer at /PeopleGraph, the nowruzgan-style counterpart of
+ the strict-tree /FamilyTree/{id} view. Loaded whole (like GetFamilyTreeAsync does for a single
+ component) since the graph as a whole is small - a few hundred rows at most.
+
+
+
+
+ every person that appears in at least one edge below
+
+
+
+
+ every kinship edge (GanjoorPersonRelation) and non-family tie (GanjoorPersonAffiliation),
+ merged into one flat list - see GanjoorPersonGraphEdge.Category to tell them apart
+
+
+
+
+ one node of the people graph
+
+
+
+
+ GanjoorRelatedPerson.Id
+
+
+
+
+ GanjoorRelatedPerson.Name
+
+
+
+
+ true if this person captions a family tree (GanjoorRelatedPerson.FamilyTreeCaption not
+ empty) - used client-side to draw such nodes slightly larger/more prominent, and to link
+ to their /FamilyTree/{id} strict-tree view
+
+
+
+
+ one edge of the people graph - either a kinship edge or a non-family tie, flattened to a
+ common shape so the client can draw/list them uniformly
+
+
+
+
+ first person - directional meaning (if any) depends on Category/TypeValue, same as the
+ underlying GanjoorPersonRelation/GanjoorPersonAffiliation row
+
+
+
+
+ first person's name, denormalized here so the client doesn't need a second lookup
+
+
+
+
+ second person
+
+
+
+
+ second person's name
+
+
+
+
+ "Relation" for a GanjoorPersonRelation edge, "Affiliation" for a GanjoorPersonAffiliation
+ one - tells the client which enum TypeValue is a numeric value of
+
+
+
+
+ the numeric value of PersonRelationType (when Category is "Relation") or
+ PersonAffiliationType (when Category is "Affiliation")
+
+
+
+
+ see GanjoorPersonRelation.DegreeHint - null for an Affiliation edge
+
+
+
+
+ free-text note
+
+
a person plus their kinship/affiliation edges, resolved with the other side's name - the
@@ -18259,6 +18377,14 @@
+
+
+ get the whole known network of people (every person with at least one kinship edge or
+ non-family tie, plus every one of those edges/ties) for the force-directed "ontology"
+ explorer at /PeopleGraph - see GanjoorPersonGraphViewModel
+
+
+
Ganjoor Poems Content Privider Service
@@ -21811,6 +21937,12 @@
+
+
+ get the whole known network of people, for the force-directed "ontology" explorer
+
+
+
Database Context
diff --git a/RMuseum/Services/IGanjoorRelatedPersonService.cs b/RMuseum/Services/IGanjoorRelatedPersonService.cs
index b42df220..9f20ec22 100644
--- a/RMuseum/Services/IGanjoorRelatedPersonService.cs
+++ b/RMuseum/Services/IGanjoorRelatedPersonService.cs
@@ -138,5 +138,13 @@ namespace RMuseum.Services
///
///
Task> ModeratePersonRelationEditSuggestionAsync(Guid moderatorUserId, int suggestionId, CorrectionReviewResult result, string reviewNote);
+
+ ///
+ /// get the whole known network of people (every person with at least one kinship edge or
+ /// non-family tie, plus every one of those edges/ties) for the force-directed "ontology"
+ /// explorer at /PeopleGraph - see GanjoorPersonGraphViewModel
+ ///
+ ///
+ Task> GetPersonGraphAsync();
}
}
diff --git a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs
index ee0367be..57daeb75 100644
--- a/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs
+++ b/RMuseum/Services/Implementation/GanjoorRelatedPersonService.cs
@@ -805,6 +805,84 @@ namespace RMuseum.Services.Implementation
}
}
+ ///
+ /// get the whole known network of people, for the force-directed "ontology" explorer
+ ///
+ ///
+ public async Task> GetPersonGraphAsync()
+ {
+ try
+ {
+ var relations = await _context.GanjoorPersonRelations.ToListAsync();
+ var affiliations = await _context.GanjoorPersonAffiliations.ToListAsync();
+
+ var involvedPersonIds = new HashSet();
+ foreach (var r in relations)
+ {
+ involvedPersonIds.Add(r.Person1Id);
+ involvedPersonIds.Add(r.Person2Id);
+ }
+ foreach (var a in affiliations)
+ {
+ involvedPersonIds.Add(a.Person1Id);
+ involvedPersonIds.Add(a.Person2Id);
+ }
+
+ var persons = await _context.GanjoorRelatedPersons
+ .Where(p => involvedPersonIds.Contains(p.Id))
+ .ToListAsync();
+ var personById = persons.ToDictionary(p => p.Id);
+
+ var nodes = persons.Select(p => new GanjoorPersonGraphNode()
+ {
+ Id = p.Id,
+ Name = p.Name,
+ HasFamilyTree = !string.IsNullOrEmpty(p.FamilyTreeCaption),
+ }).ToList();
+
+ var edges = new List();
+
+ foreach (var r in relations)
+ {
+ edges.Add(new GanjoorPersonGraphEdge()
+ {
+ Person1Id = r.Person1Id,
+ Person1Name = personById.TryGetValue(r.Person1Id, out var rp1) ? rp1.Name : "",
+ Person2Id = r.Person2Id,
+ Person2Name = personById.TryGetValue(r.Person2Id, out var rp2) ? rp2.Name : "",
+ Category = "Relation",
+ TypeValue = (int)r.RelationType,
+ DegreeHint = r.DegreeHint,
+ Note = r.Note,
+ });
+ }
+
+ foreach (var a in affiliations)
+ {
+ edges.Add(new GanjoorPersonGraphEdge()
+ {
+ Person1Id = a.Person1Id,
+ Person1Name = personById.TryGetValue(a.Person1Id, out var ap1) ? ap1.Name : "",
+ Person2Id = a.Person2Id,
+ Person2Name = personById.TryGetValue(a.Person2Id, out var ap2) ? ap2.Name : "",
+ Category = "Affiliation",
+ TypeValue = (int)a.AffiliationType,
+ Note = a.Note,
+ });
+ }
+
+ return new RServiceResult(new GanjoorPersonGraphViewModel()
+ {
+ Nodes = nodes,
+ Edges = edges,
+ });
+ }
+ catch (Exception exp)
+ {
+ return new RServiceResult(null, exp.ToString());
+ }
+ }
+
///
/// Database Context
///