From e0ab9915b6b4851debd9a32eabc21050a1ef5175 Mon Sep 17 00:00:00 2001 From: Hamid Reza Mohammadi Date: Tue, 29 Sep 2026 19:53:09 +0330 Subject: [PATCH] people tags #387 --- GanjooRazor/Pages/People.cshtml | 5 +- GanjooRazor/Pages/PeopleGraph.cshtml | 104 +++++ GanjooRazor/Pages/PeopleGraph.cshtml.cs | 54 +++ GanjooRazor/Pages/Person.cshtml.cs | 6 +- GanjooRazor/wwwroot/js/peoplegraph.js | 415 ++++++++++++++++++ .../GanjoorRelatedPersonController.cs | 19 + .../Models/Ganjoor/PersonAffiliationType.cs | 18 + .../ViewModels/GanjoorPersonGraphViewModel.cs | 98 +++++ RMuseum/RMuseum.xml | 132 ++++++ .../Services/IGanjoorRelatedPersonService.cs | 8 + .../GanjoorRelatedPersonService.cs | 78 ++++ 11 files changed, 935 insertions(+), 2 deletions(-) create mode 100644 GanjooRazor/Pages/PeopleGraph.cshtml create mode 100644 GanjooRazor/Pages/PeopleGraph.cshtml.cs create mode 100644 GanjooRazor/wwwroot/js/peoplegraph.js create mode 100644 RMuseum/Models/Ganjoor/ViewModels/GanjoorPersonGraphViewModel.cs diff --git a/GanjooRazor/Pages/People.cshtml b/GanjooRazor/Pages/People.cshtml index 5540afd9..2f0256de 100644 --- a/GanjooRazor/Pages/People.cshtml +++ b/GanjooRazor/Pages/People.cshtml @@ -23,7 +23,10 @@ else
 
-

شجره‌نامه‌ها

+

+ شجره‌نامه‌ها + نمایش شبکهٔ روابط همهٔ شخصیت‌ها +

@if (Model.FamilyTreeRoots == null || Model.FamilyTreeRoots.Count == 0) {

هنوز شجره‌نامه‌ای ثبت نشده است.

diff --git a/GanjooRazor/Pages/PeopleGraph.cshtml b/GanjooRazor/Pages/PeopleGraph.cshtml new file mode 100644 index 00000000..ecffbc95 --- /dev/null +++ b/GanjooRazor/Pages/PeopleGraph.cshtml @@ -0,0 +1,104 @@ +@page +@model GanjooRazor.Pages.PeopleGraphModel +@{ + Layout = "_Layout"; +} +@section Head { + +} +@if (!string.IsNullOrEmpty(Model.LastError)) +{ + @Model.LastError +} +else +{ + + +
+ +
+ + + +
 
+ +
+

+ شبکهٔ روابط شخصیت‌ها + فهرست شجره‌نامه‌ها +

+

+ این نمودار همهٔ خویشاوندی‌ها و وابستگی‌های غیرخویشاوندی ثبت‌شده را با هم نشان می‌دهد؛ برخلاف نمودار شجره‌نامه که به یک خانواده محدود است. + روی هر گره کلیک کنید تا فقط ارتباط‌های همان شخص برجسته شود (کلیک دوباره برای بازگشت)؛ برای جابه‌جایی یک گره آن را بکشید (برای رها کردنش دوبار کلیک کنید). + برای پیمایش نمودار آن را با ماوس بکشید و با غلتک ماوس بزرگ/کوچک کنید. +

+ + + +
+ +
+ +
+ +
 
+ +

فهرست ارتباط‌ها

+
+ + + + + + + + + + +
شخص اولنوع ارتباطشخص دومیادداشت
+
+
+ +
 
+ +
+ + + +
+ + + + + + + + @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 ///