people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-09-29 19:53:09 +03:30
parent bb68c074b0
commit e0ab9915b6
11 changed files with 935 additions and 2 deletions

View File

@ -23,7 +23,10 @@ else
<div class="spacer">&nbsp;</div>
<div class="poem">
<h1>شجره‌نامه‌ها</h1>
<h1>
شجره‌نامه‌ها
<a href="/PeopleGraph" class="up-btn up-btn--ghost up-btn--sm">نمایش شبکهٔ روابط همهٔ شخصیت‌ها</a>
</h1>
@if (Model.FamilyTreeRoots == null || Model.FamilyTreeRoots.Count == 0)
{
<p><small>هنوز شجره‌نامه‌ای ثبت نشده است.</small></p>

View File

@ -0,0 +1,104 @@
@page
@model GanjooRazor.Pages.PeopleGraphModel
@{
Layout = "_Layout";
}
@section Head {
<meta name="robots" content="noindex, nofollow" />
}
@if (!string.IsNullOrEmpty(Model.LastError))
{
@Model.LastError
}
else
{
<!-- #hdr2 -->
<div id="fa">
<div id="maincnt">
<partial name="_InlineLoginPartial" model="Model" />
<div class="spacer">&nbsp;</div>
<div class="poem">
<h1>
شبکهٔ روابط شخصیت‌ها
<a href="/people" class="up-btn up-btn--ghost up-btn--sm">فهرست شجره‌نامه‌ها</a>
</h1>
<p class="up-hint"><small>
این نمودار همهٔ خویشاوندی‌ها و وابستگی‌های غیرخویشاوندی ثبت‌شده را با هم نشان می‌دهد؛ برخلاف نمودار شجره‌نامه که به یک خانواده محدود است.
روی هر گره کلیک کنید تا فقط ارتباط‌های همان شخص برجسته شود (کلیک دوباره برای بازگشت)؛ برای جابه‌جایی یک گره آن را بکشید (برای رها کردنش دوبار کلیک کنید).
برای پیمایش نمودار آن را با ماوس بکشید و با غلتک ماوس بزرگ/کوچک کنید.
</small></p>
<div style="margin-bottom:10px;">
<input type="text" id="pg-search" placeholder="جستجوی نام شخصیت..." style="width:240px;" />
<a role="button" id="pg-reset" class="up-btn up-btn--ghost up-btn--sm">نمایش همه</a>
</div>
<div id="pg-legend" class="pg-legend"></div>
<div id="peoplegraph-container" style="position:relative;width:100%;height:65vh;border:1px solid #d8cdb8;border-radius:6px;overflow:hidden;background:#faf7f0;">
<svg id="peoplegraph-svg" width="100%" height="100%"></svg>
</div>
<div class="spacer">&nbsp;</div>
<h2>فهرست ارتباط‌ها</h2>
<div style="max-height:40vh;overflow:auto;">
<table class="up-table">
<thead>
<tr>
<th>شخص اول</th>
<th>نوع ارتباط</th>
<th>شخص دوم</th>
<th>یادداشت</th>
</tr>
</thead>
<tbody id="pg-table-body"></tbody>
</table>
</div>
</div>
<div class="spacer">&nbsp;</div>
</div>
<partial name="_FooterPartial" model=@(new _FooterPartialModel(){StickyEnabled = true}) />
</div>
<style>
.pg-circle { fill: #fdf6e6; stroke: #8b6b4a; stroke-width: 1.5; }
.pg-circle-tree { fill: #f3e2b8; stroke: #7a4a2a; stroke-width: 2; }
.pg-label { font-size: 11px; fill: #4a3520; font-family: inherit; }
.pg-legend { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
.pg-legend-item { font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
.pg-legend-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: middle; }
</style>
<script>
var peopleGraphData = @Html.Raw(Model.GraphDataJson);
</script>
<script src="/js/peoplegraph.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
GanjoorPeopleGraph.render({
containerId: 'peoplegraph-container',
svgId: 'peoplegraph-svg',
tableBodyId: 'pg-table-body',
legendId: 'pg-legend',
searchInputId: 'pg-search',
resetButtonId: 'pg-reset',
data: peopleGraphData
});
});
</script>
@if (ViewData["TrackingScript"] != null && !string.IsNullOrEmpty(ViewData["TrackingScript"].ToString()))
{
@Html.Raw(ViewData["TrackingScript"])
}
}

View File

@ -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
{
/// <summary>
/// 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 &lt;script&gt; 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.
/// </summary>
public class PeopleGraphModel : LoginPartialEnabledPageModel
{
public PeopleGraphModel(HttpClient httpClient, IConfiguration configuration) : base(httpClient, configuration)
{
}
public string LastError { get; set; }
public GanjoorPersonGraphViewModel Graph { get; set; }
/// <summary>
/// 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
/// </summary>
public string GraphDataJson =>
JsonConvert.SerializeObject(
Graph,
new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }
);
public async Task<IActionResult> 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<GanjoorPersonGraphViewModel>(await response.Content.ReadAsStringAsync());
return Page();
}
}
}

View File

@ -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<IActionResult> OnGetAsync(int id)
{

View File

@ -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 = '<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>' + escapeHtml(e.raw.person1Name) + '</td>' +
'<td><span class="pg-legend-swatch" style="background:' + e.meta.color + '"></span> ' + escapeHtml(e.meta.label) + '</td>' +
'<td>' + escapeHtml(e.raw.person2Name) + '</td>' +
'<td><small>' + escapeHtml(e.note || '') + '</small></td>';
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 };
})();

View File

@ -59,6 +59,25 @@ namespace RMuseum.Controllers
return Ok(res.Result);
}
/// <summary>
/// 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.
/// </summary>
/// <returns></returns>
[HttpGet("graph")]
[AllowAnonymous]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonGraphViewModel))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public async Task<IActionResult> GetPersonGraphAsync()
{
var res = await _personService.GetPersonGraphAsync();
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
return Ok(res.Result);
}
/// <summary>
/// get person by id
/// </summary>

View File

@ -40,6 +40,24 @@ namespace RMuseum.Models.Ganjoor
/// </summary>
Rival = 5,
/// <summary>
/// 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
/// </summary>
Servant = 6,
/// <summary>
/// Person1 and Person2 were companions/comrades - e.g. fellow travelers, brothers-in-arms
/// (symmetric - order doesn't matter)
/// </summary>
Companion = 7,
/// <summary>
/// Person1 succeeded Person2 in a role/office/throne (directional - Person1 is the one who
/// came after)
/// </summary>
Successor = 8,
/// <summary>
/// doesn't fit any of the above - rely on Note for what the tie actually is
/// </summary>

View File

@ -0,0 +1,98 @@
using System.Collections.Generic;
namespace RMuseum.Models.Ganjoor.ViewModels
{
/// <summary>
/// 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.
/// </summary>
public class GanjoorPersonGraphViewModel
{
/// <summary>
/// every person that appears in at least one edge below
/// </summary>
public List<GanjoorPersonGraphNode> Nodes { get; set; }
/// <summary>
/// every kinship edge (GanjoorPersonRelation) and non-family tie (GanjoorPersonAffiliation),
/// merged into one flat list - see GanjoorPersonGraphEdge.Category to tell them apart
/// </summary>
public List<GanjoorPersonGraphEdge> Edges { get; set; }
}
/// <summary>
/// one node of the people graph
/// </summary>
public class GanjoorPersonGraphNode
{
/// <summary>
/// GanjoorRelatedPerson.Id
/// </summary>
public int Id { get; set; }
/// <summary>
/// GanjoorRelatedPerson.Name
/// </summary>
public string Name { get; set; }
/// <summary>
/// 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
/// </summary>
public bool HasFamilyTree { get; set; }
}
/// <summary>
/// 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
/// </summary>
public class GanjoorPersonGraphEdge
{
/// <summary>
/// first person - directional meaning (if any) depends on Category/TypeValue, same as the
/// underlying GanjoorPersonRelation/GanjoorPersonAffiliation row
/// </summary>
public int Person1Id { get; set; }
/// <summary>
/// first person's name, denormalized here so the client doesn't need a second lookup
/// </summary>
public string Person1Name { get; set; }
/// <summary>
/// second person
/// </summary>
public int Person2Id { get; set; }
/// <summary>
/// second person's name
/// </summary>
public string Person2Name { get; set; }
/// <summary>
/// "Relation" for a GanjoorPersonRelation edge, "Affiliation" for a GanjoorPersonAffiliation
/// one - tells the client which enum TypeValue is a numeric value of
/// </summary>
public string Category { get; set; }
/// <summary>
/// the numeric value of PersonRelationType (when Category is "Relation") or
/// PersonAffiliationType (when Category is "Affiliation")
/// </summary>
public int TypeValue { get; set; }
/// <summary>
/// see GanjoorPersonRelation.DegreeHint - null for an Affiliation edge
/// </summary>
public int? DegreeHint { get; set; }
/// <summary>
/// free-text note
/// </summary>
public string Note { get; set; }
}
}

View File

@ -2838,6 +2838,15 @@
</summary>
<returns></returns>
</member>
<member name="M:RMuseum.Controllers.GanjoorRelatedPersonController.GetPersonGraphAsync">
<summary>
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.
</summary>
<returns></returns>
</member>
<member name="M:RMuseum.Controllers.GanjoorRelatedPersonController.GetPersonAsync(System.Int32)">
<summary>
get person by id
@ -11749,6 +11758,24 @@
Person1 and Person2 were rivals/enemies (symmetric - order doesn't matter)
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Servant">
<summary>
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
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Companion">
<summary>
Person1 and Person2 were companions/comrades - e.g. fellow travelers, brothers-in-arms
(symmetric - order doesn't matter)
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Successor">
<summary>
Person1 succeeded Person2 in a role/office/throne (directional - Person1 is the one who
came after)
</summary>
</member>
<member name="F:RMuseum.Models.Ganjoor.PersonAffiliationType.Other">
<summary>
doesn't fit any of the above - rely on Note for what the tie actually is
@ -13292,6 +13319,97 @@
FullUrl
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel">
<summary>
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.
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel.Nodes">
<summary>
every person that appears in at least one edge below
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel.Edges">
<summary>
every kinship edge (GanjoorPersonRelation) and non-family tie (GanjoorPersonAffiliation),
merged into one flat list - see GanjoorPersonGraphEdge.Category to tell them apart
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphNode">
<summary>
one node of the people graph
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphNode.Id">
<summary>
GanjoorRelatedPerson.Id
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphNode.Name">
<summary>
GanjoorRelatedPerson.Name
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphNode.HasFamilyTree">
<summary>
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
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge">
<summary>
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
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Person1Id">
<summary>
first person - directional meaning (if any) depends on Category/TypeValue, same as the
underlying GanjoorPersonRelation/GanjoorPersonAffiliation row
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Person1Name">
<summary>
first person's name, denormalized here so the client doesn't need a second lookup
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Person2Id">
<summary>
second person
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Person2Name">
<summary>
second person's name
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Category">
<summary>
"Relation" for a GanjoorPersonRelation edge, "Affiliation" for a GanjoorPersonAffiliation
one - tells the client which enum TypeValue is a numeric value of
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.TypeValue">
<summary>
the numeric value of PersonRelationType (when Category is "Relation") or
PersonAffiliationType (when Category is "Affiliation")
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.DegreeHint">
<summary>
see GanjoorPersonRelation.DegreeHint - null for an Affiliation edge
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphEdge.Note">
<summary>
free-text note
</summary>
</member>
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonRelationsViewModel">
<summary>
a person plus their kinship/affiliation edges, resolved with the other side's name - the
@ -18259,6 +18377,14 @@
<param name="reviewNote"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Services.IGanjoorRelatedPersonService.GetPersonGraphAsync">
<summary>
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
</summary>
<returns></returns>
</member>
<member name="T:RMuseum.Services.IGanjoorService">
<summary>
Ganjoor Poems Content Privider Service
@ -21811,6 +21937,12 @@
<param name="reviewNote"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService.GetPersonGraphAsync">
<summary>
get the whole known network of people, for the force-directed "ontology" explorer
</summary>
<returns></returns>
</member>
<member name="F:RMuseum.Services.Implementation.GanjoorRelatedPersonService._context">
<summary>
Database Context

View File

@ -138,5 +138,13 @@ namespace RMuseum.Services
/// <param name="reviewNote"></param>
/// <returns></returns>
Task<RServiceResult<GanjoorPersonRelationEditSuggestion>> ModeratePersonRelationEditSuggestionAsync(Guid moderatorUserId, int suggestionId, CorrectionReviewResult result, string reviewNote);
/// <summary>
/// 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
/// </summary>
/// <returns></returns>
Task<RServiceResult<GanjoorPersonGraphViewModel>> GetPersonGraphAsync();
}
}

View File

@ -805,6 +805,84 @@ namespace RMuseum.Services.Implementation
}
}
/// <summary>
/// get the whole known network of people, for the force-directed "ontology" explorer
/// </summary>
/// <returns></returns>
public async Task<RServiceResult<GanjoorPersonGraphViewModel>> GetPersonGraphAsync()
{
try
{
var relations = await _context.GanjoorPersonRelations.ToListAsync();
var affiliations = await _context.GanjoorPersonAffiliations.ToListAsync();
var involvedPersonIds = new HashSet<int>();
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<GanjoorPersonGraphEdge>();
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<GanjoorPersonGraphViewModel>(new GanjoorPersonGraphViewModel()
{
Nodes = nodes,
Edges = edges,
});
}
catch (Exception exp)
{
return new RServiceResult<GanjoorPersonGraphViewModel>(null, exp.ToString());
}
}
/// <summary>
/// Database Context
/// </summary>