people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-09-29 21:10:46 +03:30
parent e0ab9915b6
commit 0cb288228d
14 changed files with 576 additions and 48 deletions

View File

@ -86,6 +86,14 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js"></script>
}
}
@if (Model.IsCatPage || Model.IsPoetPage)
{
@* the on-demand "شخصیت‌ها" tab (_CatPagePartial.cshtml / _PoetPagePartial.cshtml) can be
clicked at any time on any cat/poet page, so this is loaded unconditionally here (unlike
the leaflet.js block above, which only loads when geo tags are already known to exist) *@
<script src="/js/peoplegraph.js"></script>
}
}
@if (Model.OfflineMode)

View File

@ -1285,6 +1285,29 @@ namespace GanjooRazor.Pages
});
}
/// <summary>
/// on-demand "شخصیت‌ها" (characters) tab on a poet/category page - fetches the
/// category-scoped person/relation graph (see GanjoorRelatedPersonService.GetCatPersonGraphAsync)
/// and renders it with the same peoplegraph.js force-directed viewer used by /PeopleGraph
/// </summary>
/// <param name="catId"></param>
/// <returns></returns>
public async Task<IActionResult> OnGetPersonGraphAsync(int catId)
{
var response = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/cat/{catId}/persongraph");
if (!response.IsSuccessStatusCode)
{
return BadRequest(await ReadErrorMessageAsync(response));
}
var graph = JsonConvert.DeserializeObject<GanjoorPersonGraphViewModel>(await response.Content.ReadAsStringAsync());
return Partial("~/Pages/Partials/GanjoorPage/_PersonGraphPartial.cshtml", new _PersonGraphPartialModel()
{
CatId = catId,
Graph = graph,
});
}
private async Task<_CategoryWordsCountPartialModel> _GetCategoryWordCountsAsync(int catId, int poetId, bool remStopWords = false)
{
var wordSumsResponse = await _httpClient.GetAsync($"{APIRoot.Url}/api/ganjoor/wordsums/{catId}");

View File

@ -32,6 +32,7 @@
<div class="poemtablinks"><a href="#pretab">آمار</a></div>
<div class="poemtablinks active"><a href="#words" onclick="javascript:loadWordCounts(@Model.GanjoorPage.PoetOrCat.Cat.Id, @Model.GanjoorPage.PoetOrCat.Poet.Id, false)">واژگان</a></div>
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
@ -54,6 +55,7 @@
<div class="poemtablinks"><a href="#pretab">آمار</a></div>
<div class="poemtablinks"><a href="#words" onclick="javascript:loadWordCounts(@Model.GanjoorPage.PoetOrCat.Cat.Id, @Model.GanjoorPage.PoetOrCat.Poet.Id, false)">واژگان</a></div>
<div class="poemtablinks active"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
@ -70,12 +72,29 @@
}
</div>
@* Person Graph ("characters in this work") Section *@
<div class="poempretab" id="persongraph"></div>
<div class="poemtab">
<div class="poemtablinks"><a href="#pretab">آمار</a></div>
<div class="poemtablinks"><a href="#words" onclick="javascript:loadWordCounts(@Model.GanjoorPage.PoetOrCat.Cat.Id, @Model.GanjoorPage.PoetOrCat.Poet.Id, false)">واژگان</a></div>
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks active"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
<div class="century cursor-pointer" id="load-persongraph" onclick="loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">
بارگذاری شخصیت‌های این بخش
</div>
<div id="persongraph-placeholder"></div>
</div>
@* Top Visits Section *@
<div class="poempretab" id="topvisits"></div>
<div class="poemtab">
<div class="poemtablinks"><a href="#pretab">آمار</a></div>
<div class="poemtablinks"><a href="#words" onclick="javascript:loadWordCounts(@Model.GanjoorPage.PoetOrCat.Cat.Id, @Model.GanjoorPage.PoetOrCat.Poet.Id, false)">واژگان</a></div>
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks active"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">

View File

@ -54,6 +54,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
@ -71,6 +72,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
@ -86,6 +88,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
@ -109,6 +112,7 @@
<div class="poemtablinks active"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
@ -134,6 +138,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks active"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="poemtabcontent">
@ -169,6 +174,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks active"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="poemtabcontent related-images-frame">
@ -210,6 +216,7 @@
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks active"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
@ -219,6 +226,25 @@
<div id="topvisits-placeholder"></div>
</div>
@* Person Graph ("characters in this work") Section *@
<div class="poempretab" id="persongraph"></div>
<div class="poemtab">
<div class="poemtablinks"><a href="#pretab">آمار</a></div>
<div class="poemtablinks"><a href="#quoteds">مشق شعر</a></div>
<div class="poemtablinks"><a href="#words" onclick="javascript:loadWordCounts(@Model.GanjoorPage.PoetOrCat.Cat.Id, @Model.GanjoorPage.PoetOrCat.Poet.Id, false)">واژگان</a></div>
<div class="poemtablinks"><a href="#recitations" onclick="javascript:loadCatRecitations(@Model.GanjoorPage.PoetOrCat.Cat.Id)">خوانش‌ها</a></div>
<div class="poemtablinks"><a href="#photos">تصاویر چهره</a></div>
<div class="poemtablinks"><a href="#papersources">منابع کاغذی</a></div>
<div class="poemtablinks active"><a href="#persongraph" onclick="javascript:loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">شخصیت‌ها</a></div>
<div class="poemtablinks"><a href="#topvisits" onclick="javascript:loadTopVisits('@Model.GanjoorPage.FullUrl')">پربازدیدها</a></div>
</div>
<div class="related-images-frame poemtabcontent">
<div class="century cursor-pointer" id="load-persongraph" onclick="loadPersonGraph(@Model.GanjoorPage.PoetOrCat.Cat.Id)">
بارگذاری شخصیت‌های این بخش
</div>
<div id="persongraph-placeholder"></div>
</div>
@* Map *@
@if (!Model.OfflineMode && Model.CategoryPoemGeoDateTags != null && Model.CategoryPoemGeoDateTags.Length > 0)
{

View File

@ -0,0 +1,64 @@
@model GanjooRazor.Pages._PersonGraphPartialModel
@if (Model.Graph == null || Model.Graph.Nodes == null || Model.Graph.Nodes.Count == 0)
{
<p>برای این بخش هنوز شخصیتی برچسب‌گذاری نشده است.</p>
}
else
{
<div id="pg-cat-section">
<p class="up-hint"><small>
روی هر گره کلیک کنید تا فقط ارتباط‌های همان شخص برجسته شود (کلیک دوباره برای بازگشت)؛ برای جابه‌جایی یک گره آن را بکشید (برای رها کردنش دوبار کلیک کنید).
برای پیمایش نمودار آن را با ماوس بکشید و با غلتک ماوس بزرگ/کوچک کنید.
@if (Model.HasSecondaryNodes)
{
<text> گره‌های نقطه‌چین/کم‌رنگ، خویشاوندان یا وابستگانی هستند که در متن این بخش نامشان نیامده، اما برای آشنایی بیشتر با داستان نشان داده شده‌اند.</text>
}
</small></p>
<div style="margin-bottom:10px;">
<input type="text" id="pg-cat-search" placeholder="جستجوی نام شخصیت..." style="width:240px;" />
<a role="button" id="pg-cat-reset" class="up-btn up-btn--ghost up-btn--sm">نمایش همه</a>
<a role="button" href="/PeopleGraph" class="up-btn up-btn--ghost up-btn--sm">نمایش شبکهٔ کامل شخصیت‌ها</a>
</div>
<div id="pg-cat-legend" class="pg-legend"></div>
<div id="pg-cat-container" style="position:relative;width:100%;height:55vh;border:1px solid #d8cdb8;border-radius:6px;overflow:hidden;background:#faf7f0;">
<svg id="pg-cat-svg" width="100%" height="100%"></svg>
</div>
<div class="spacer">&nbsp;</div>
<div style="max-height:30vh;overflow:auto;">
<table class="up-table">
<thead>
<tr>
<th>شخص اول</th>
<th>نوع ارتباط</th>
<th>شخص دوم</th>
<th>یادداشت</th>
</tr>
</thead>
<tbody id="pg-cat-table-body"></tbody>
</table>
</div>
<style>
.pg-circle { fill: #fdf6e6; stroke: #8b6b4a; stroke-width: 1.5; }
.pg-circle-tree { fill: #f3e2b8; stroke: #7a4a2a; stroke-width: 2; }
.pg-circle-secondary { stroke-dasharray: 3,2; opacity: 0.7; }
.pg-label { font-size: 11px; fill: #4a3520; font-family: inherit; }
.pg-label-secondary { opacity: 0.7; font-style: italic; }
.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>
@* not an executable script - just a data holder, parsed and rendered explicitly by
loadPersonGraph() in bk.js once this fragment has been appended to the page, same
convention used for the JSON blob is deliberately not the ajax response's inline <script>
since script tags injected via $(data).appendTo(...) don't reliably auto-execute *@
<script type="application/json" id="pg-cat-data">@Html.Raw(Model.GraphDataJson)</script>
</div>
}

View File

@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Mvc.RazorPages;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using RMuseum.Models.Ganjoor.ViewModels;
namespace GanjooRazor.Pages
{
/// <summary>
/// model for the on-demand "شخصیت‌ها" (characters) tab shown on a poet/category page - renders
/// the same force-directed graph as PeopleGraph.cshtml/peoplegraph.js, but scoped to one
/// work/category (see GanjoorRelatedPersonService.GetCatPersonGraphAsync) instead of the whole
/// site. Element ids are prefixed "pg-cat-" so this partial can sit on the same page as (and not
/// collide with) any other graph instance.
/// </summary>
public class _PersonGraphPartialModel : PageModel
{
/// <summary>
/// category id this graph is scoped to
/// </summary>
public int CatId { get; set; }
/// <summary>
/// the graph data returned by GET api/ganjoor/cat/{id}/persongraph
/// </summary>
public GanjoorPersonGraphViewModel Graph { get; set; }
/// <summary>
/// Graph, re-serialized with an explicit camelCase contract (same convention
/// PeopleGraphModel.GraphDataJson/FamilyTree's TreeDataJson use), so peoplegraph.js has a
/// predictable shape to parse regardless of which page embeds it
/// </summary>
public string GraphDataJson =>
JsonConvert.SerializeObject(
Graph,
new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }
);
/// <summary>
/// true if the graph has at least one node pulled in only as a one-hop relative/affiliate
/// (never itself tagged in this work's verses) - used to decide whether to show the
/// explanatory legend note about dashed/secondary nodes
/// </summary>
public bool HasSecondaryNodes
{
get
{
if (Graph?.Nodes == null)
return false;
foreach (var node in Graph.Nodes)
{
if (!node.DirectlyTagged)
return true;
}
return false;
}
}
}
}

View File

@ -73,7 +73,9 @@ else
<style>
.pg-circle { fill: #fdf6e6; stroke: #8b6b4a; stroke-width: 1.5; }
.pg-circle-tree { fill: #f3e2b8; stroke: #7a4a2a; stroke-width: 2; }
.pg-circle-secondary { stroke-dasharray: 3,2; opacity: 0.7; }
.pg-label { font-size: 11px; fill: #4a3520; font-family: inherit; }
.pg-label-secondary { opacity: 0.7; font-style: italic; }
.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; }

View File

@ -1692,6 +1692,53 @@ function loadCatRecitations(catId) {
}
// on-demand "شخصیت‌ها" tab on a poet/category page - loads the category-scoped person/relation
// graph (see _PersonGraphPartial.cshtml / OnGetPersonGraphAsync) and hands its JSON payload to the
// same peoplegraph.js force-directed renderer used by /PeopleGraph, with element ids prefixed
// "pg-cat-" so it doesn't collide with anything else on the page
function loadPersonGraph(catId) {
var loadButton = document.getElementById("load-persongraph");
if (loadButton == null) {
return;
}
loadButton.remove();
var divParent = document.getElementById('persongraph-placeholder');
var imgElementId = 'loadingpersongraphimg';
divParent.innerHTML = divParent.innerHTML + '<div class="bnumdiv" id="remove-this-persongraph"><img id="' + imgElementId + '" src="/image/loading.gif" alt="بارگذاری"/></div>';
$.ajax({
type: "GET",
url: '?Handler=PersonGraph&catId=' + String(catId),
error: function () {
if (document.getElementById("remove-this-persongraph") != null) {
document.getElementById("remove-this-persongraph").remove();
}
},
success: function (data) {
if (document.getElementById("remove-this-persongraph") != null) {
document.getElementById("remove-this-persongraph").remove();
}
$(data).appendTo(divParent);
var dataElement = document.getElementById('pg-cat-data');
if (dataElement != null) {
var graphData = JSON.parse(dataElement.textContent);
GanjoorPeopleGraph.render({
containerId: 'pg-cat-container',
svgId: 'pg-cat-svg',
tableBodyId: 'pg-cat-table-body',
legendId: 'pg-cat-legend',
searchInputId: 'pg-cat-search',
resetButtonId: 'pg-cat-reset',
data: graphData
});
}
},
});
}
// https://stackoverflow.com/questions/56300132/how-to-override-css-prefers-color-scheme-setting
// Return the system level color scheme, but if something's in local storage, return that
// Unless the system scheme matches the the stored scheme, in which case... remove from local storage

View File

@ -3,13 +3,20 @@
// 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).
// Data shape (RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphViewModel, camelCased) -
// { nodes: [{id, name, hasFamilyTree, directlyTagged}], 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). directlyTagged is false only on
// the category-scoped graph (GET api/ganjoor/cat/{id}/persongraph, the "characters in this work"
// tab): such a node was pulled in as a one-hop relative/affiliate of someone actually named in the
// work's verses, and is never false on the whole-site graph fed by PeopleGraph.cshtml.
//
// Called with an options object (see PeopleGraph.cshtml and _PersonGraphPartial.cshtml for two call
// sites with different element-id prefixes and data payloads), so the same renderer serves both the
// whole-site /PeopleGraph explorer and any number of category-scoped "شخصیت‌ها" tabs on cat/poet
// pages.
//
// 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,
@ -77,6 +84,8 @@
var nodes = data.nodes.map(function (n) {
var node = {
id: n.id, name: n.name, hasFamilyTree: n.hasFamilyTree,
// absent on the whole-site graph payload (always directly tagged there) - default true
directlyTagged: n.directlyTagged !== false,
x: W / 2 + (Math.random() - 0.5) * W * 0.6,
y: H / 2 + (Math.random() - 0.5) * H * 0.6,
vx: 0, vy: 0, fx: null, fy: null, degree: 0
@ -197,12 +206,19 @@
var nodeEls = nodes.map(function (n) {
var g = el('g', { 'class': 'pg-node', 'data-person-id': n.id, style: 'cursor:pointer' });
var circleClass = n.hasFamilyTree ? 'pg-circle pg-circle-tree' : 'pg-circle';
if (!n.directlyTagged) {
// context pulled in one hop out (a relative/affiliate never actually named in the
// work's verses) - draw dashed/dimmer so it reads as secondary, not part of the text
circleClass += ' pg-circle-secondary';
}
var circle = el('circle', {
r: nodeRadius(n),
'class': n.hasFamilyTree ? 'pg-circle pg-circle-tree' : 'pg-circle'
'class': circleClass
});
var text = el('text', {
'text-anchor': 'middle', dy: -(nodeRadius(n) + 6), 'class': 'pg-label', direction: 'rtl'
'text-anchor': 'middle', dy: -(nodeRadius(n) + 6),
'class': n.directlyTagged ? 'pg-label' : 'pg-label pg-label-secondary', direction: 'rtl'
});
text.textContent = n.name;
g.appendChild(circle);

View File

@ -3833,6 +3833,28 @@ namespace RMuseum.Controllers
return Ok(res.Result);
}
/// <summary>
/// get the network of people relevant to this category/work (every person tagged in a poem
/// under its subtree, plus their relatives/affiliates one hop out) - for the "شخصیت‌ها" tab
/// on a category/poet page
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet]
[Route("cat/{id}/persongraph")]
[AllowAnonymous]
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorPersonGraphViewModel))]
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
public async Task<IActionResult> GetCatPersonGraphAsync(int id)
{
var res =
await _personService.GetCatPersonGraphAsync(id);
if (!string.IsNullOrEmpty(res.ExceptionString))
return BadRequest(res.ExceptionString);
return Ok(res.Result);
}
/// <summary>
/// synchronize https:://naskban.ir links (logs in and then out to naskban.ir using auth info)
/// </summary>
@ -5091,6 +5113,12 @@ namespace RMuseum.Controllers
/// </summary>
protected IConfiguration Configuration { get; }
/// <summary>
/// related people (family tree / person tagging) service - used here only for the
/// category-scoped "cat/{id}/persongraph" endpoint; everything else about people lives in
/// GanjoorRelatedPersonController
/// </summary>
protected readonly IGanjoorRelatedPersonService _personService;
/// <summary>
/// constructor
@ -5101,13 +5129,15 @@ namespace RMuseum.Controllers
/// <param name="imageFileService"></param>
/// <param name="memoryCache"></param>
/// <param name="configuration"></param>
/// <param name="personService"></param>
public GanjoorController(
IGanjoorService ganjoorService,
IAppUserService appUserService,
IHttpContextAccessor httpContextAccessor,
IImageFileService imageFileService,
IMemoryCache memoryCache,
IConfiguration configuration
IConfiguration configuration,
IGanjoorRelatedPersonService personService
)
{
_ganjoorService = ganjoorService;
@ -5116,6 +5146,7 @@ namespace RMuseum.Controllers
_imageFileService = imageFileService;
_memoryCache = memoryCache;
Configuration = configuration;
_personService = personService;
}
}
}

View File

@ -44,6 +44,15 @@ namespace RMuseum.Models.Ganjoor.ViewModels
/// to their /FamilyTree/{id} strict-tree view
/// </summary>
public bool HasFamilyTree { get; set; }
/// <summary>
/// true unless this node was pulled in only because it's one hop away (a relative or
/// affiliate) from a person actually tagged in a work's verses - see
/// GetCatPersonGraphAsync. Always true for the whole-site graph (GetPersonGraphAsync),
/// where there's no "work" to be tagged within. Client-side, a false value draws the node
/// as secondary (e.g. dashed/dimmer) since it's context the reader wasn't shown directly.
/// </summary>
public bool DirectlyTagged { get; set; } = true;
}
/// <summary>

View File

@ -2241,6 +2241,15 @@
<param name="id"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Controllers.GanjoorController.GetCatPersonGraphAsync(System.Int32)">
<summary>
get the network of people relevant to this category/work (every person tagged in a poem
under its subtree, plus their relatives/affiliates one hop out) - for the "شخصیت‌ها" tab
on a category/poet page
</summary>
<param name="id"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Controllers.GanjoorController.SynchronizeNaskbanLinks(RSecurityBackend.Models.Auth.ViewModels.LoginViewModel)">
<summary>
synchronize https:://naskban.ir links (logs in and then out to naskban.ir using auth info)
@ -2684,7 +2693,14 @@
Configuration
</summary>
</member>
<member name="M:RMuseum.Controllers.GanjoorController.#ctor(RMuseum.Services.IGanjoorService,RSecurityBackend.Services.IAppUserService,Microsoft.AspNetCore.Http.IHttpContextAccessor,RSecurityBackend.Services.IImageFileService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.Extensions.Configuration.IConfiguration)">
<member name="F:RMuseum.Controllers.GanjoorController._personService">
<summary>
related people (family tree / person tagging) service - used here only for the
category-scoped "cat/{id}/persongraph" endpoint; everything else about people lives in
GanjoorRelatedPersonController
</summary>
</member>
<member name="M:RMuseum.Controllers.GanjoorController.#ctor(RMuseum.Services.IGanjoorService,RSecurityBackend.Services.IAppUserService,Microsoft.AspNetCore.Http.IHttpContextAccessor,RSecurityBackend.Services.IImageFileService,Microsoft.Extensions.Caching.Memory.IMemoryCache,Microsoft.Extensions.Configuration.IConfiguration,RMuseum.Services.IGanjoorRelatedPersonService)">
<summary>
constructor
</summary>
@ -2694,6 +2710,7 @@
<param name="imageFileService"></param>
<param name="memoryCache"></param>
<param name="configuration"></param>
<param name="personService"></param>
</member>
<member name="M:RMuseum.Controllers.GanjoorPoetSuggestedPhotosController.GetPoetSuggestedPhotosAsync(System.Int32)">
<summary>
@ -13361,6 +13378,15 @@
to their /FamilyTree/{id} strict-tree view
</summary>
</member>
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorPersonGraphNode.DirectlyTagged">
<summary>
true unless this node was pulled in only because it's one hop away (a relative or
affiliate) from a person actually tagged in a work's verses - see
GetCatPersonGraphAsync. Always true for the whole-site graph (GetPersonGraphAsync),
where there's no "work" to be tagged within. Client-side, a false value draws the node
as secondary (e.g. dashed/dimmer) since it's context the reader wasn't shown directly.
</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
@ -18385,6 +18411,16 @@
</summary>
<returns></returns>
</member>
<member name="M:RMuseum.Services.IGanjoorRelatedPersonService.GetCatPersonGraphAsync(System.Int32)">
<summary>
get the network of people relevant to one work/category (a poet's whole corpus, one book
like the Shahnameh, or a narrower story within it) - every person tagged in a poem under
catId's subtree, plus their relatives/affiliates one hop out even if never tagged
themselves - see GanjoorPersonGraphNode.DirectlyTagged
</summary>
<param name="catId"></param>
<returns></returns>
</member>
<member name="T:RMuseum.Services.IGanjoorService">
<summary>
Ganjoor Poems Content Privider Service
@ -21943,6 +21979,43 @@
</summary>
<returns></returns>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._GetCategorySubtreeIdsAsync(System.Int32)">
<summary>
get every category id in the subtree rooted at catId (catId itself plus every descendant,
walked breadth-first) - a lighter-weight, purpose-built counterpart of
GanjoorService._populateCategoryChildren (which builds full category objects via
_GetCatById); this only needs ids, so it queries them directly
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService.GetCatPersonGraphAsync(System.Int32)">
<summary>
get the network of people relevant to one work/category (e.g. a poet's Shahnameh, or one
story within it like Nezami's Leyli o Majnoon) - the category-scoped counterpart of
GetPersonGraphAsync, for the "شخصیت‌ها" tab on a category/poet page. Starts from every
person directly tagged (PoemGeoDateTag.PersonId) in a poem under catId's subtree, then
adds their relatives/affiliates one hop out even when
those relatives are never tagged in the work themselves - so, say, a hero's father still
shows up if he's known but never named in a verse, which helps a reader unfamiliar with
the story rather than leaving the tree looking broken. Those one-hop additions are marked
DirectlyTagged = false so the client can draw them as secondary.
</summary>
<param name="catId"></param>
<returns></returns>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._BuildGraphNodes(System.Collections.Generic.List{RMuseum.Models.Ganjoor.GanjoorRelatedPerson},System.Collections.Generic.HashSet{System.Int32})">
<summary>
shared node-list builder for GetPersonGraphAsync/GetCatPersonGraphAsync - directlyTaggedIds
null means "everyone counts as directly part of the graph" (the whole-site graph has no
notion of one-hop additions)
</summary>
</member>
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService._BuildGraphEdges(System.Collections.Generic.List{RMuseum.Models.Ganjoor.GanjoorPersonRelation},System.Collections.Generic.List{RMuseum.Models.Ganjoor.GanjoorPersonAffiliation},System.Collections.Generic.Dictionary{System.Int32,RMuseum.Models.Ganjoor.GanjoorRelatedPerson})">
<summary>
shared edge-list builder for GetPersonGraphAsync/GetCatPersonGraphAsync - flattens
GanjoorPersonRelation and GanjoorPersonAffiliation rows into the common
GanjoorPersonGraphEdge shape, resolving both sides' names from personById
</summary>
</member>
<member name="F:RMuseum.Services.Implementation.GanjoorRelatedPersonService._context">
<summary>
Database Context

View File

@ -146,5 +146,15 @@ namespace RMuseum.Services
/// </summary>
/// <returns></returns>
Task<RServiceResult<GanjoorPersonGraphViewModel>> GetPersonGraphAsync();
/// <summary>
/// get the network of people relevant to one work/category (a poet's whole corpus, one book
/// like the Shahnameh, or a narrower story within it) - every person tagged in a poem under
/// catId's subtree, plus their relatives/affiliates one hop out even if never tagged
/// themselves - see GanjoorPersonGraphNode.DirectlyTagged
/// </summary>
/// <param name="catId"></param>
/// <returns></returns>
Task<RServiceResult<GanjoorPersonGraphViewModel>> GetCatPersonGraphAsync(int catId);
}
}

View File

@ -833,43 +833,10 @@ namespace RMuseum.Services.Implementation
.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,
});
}
// no "work" scope here, so every node is directly part of the graph - none of them
// are a one-hop addition the way GetCatPersonGraphAsync's are
var nodes = _BuildGraphNodes(persons, null);
var edges = _BuildGraphEdges(relations, affiliations, personById);
return new RServiceResult<GanjoorPersonGraphViewModel>(new GanjoorPersonGraphViewModel()
{
@ -883,6 +850,181 @@ namespace RMuseum.Services.Implementation
}
}
/// <summary>
/// get every category id in the subtree rooted at catId (catId itself plus every descendant,
/// walked breadth-first) - a lighter-weight, purpose-built counterpart of
/// GanjoorService._populateCategoryChildren (which builds full category objects via
/// _GetCatById); this only needs ids, so it queries them directly
/// </summary>
private async Task<List<int>> _GetCategorySubtreeIdsAsync(int catId)
{
var result = new List<int> { catId };
var queue = new Queue<int>();
queue.Enqueue(catId);
while (queue.Count > 0)
{
var current = queue.Dequeue();
var childIds = await _context.GanjoorCategories
.Where(c => c.ParentId == current)
.Select(c => c.Id)
.ToListAsync();
foreach (var childId in childIds)
{
result.Add(childId);
queue.Enqueue(childId);
}
}
return result;
}
/// <summary>
/// get the network of people relevant to one work/category (e.g. a poet's Shahnameh, or one
/// story within it like Nezami's Leyli o Majnoon) - the category-scoped counterpart of
/// GetPersonGraphAsync, for the "شخصیت‌ها" tab on a category/poet page. Starts from every
/// person directly tagged (PoemGeoDateTag.PersonId) in a poem under catId's subtree, then
/// adds their relatives/affiliates one hop out even when
/// those relatives are never tagged in the work themselves - so, say, a hero's father still
/// shows up if he's known but never named in a verse, which helps a reader unfamiliar with
/// the story rather than leaving the tree looking broken. Those one-hop additions are marked
/// DirectlyTagged = false so the client can draw them as secondary.
/// </summary>
/// <param name="catId"></param>
/// <returns></returns>
public async Task<RServiceResult<GanjoorPersonGraphViewModel>> GetCatPersonGraphAsync(int catId)
{
try
{
var catExists = await _context.GanjoorCategories.Where(c => c.Id == catId).AnyAsync();
if (!catExists)
{
return new RServiceResult<GanjoorPersonGraphViewModel>(null, "بخش پیدا نشد.");
}
var catIds = await _GetCategorySubtreeIdsAsync(catId);
var directPersonIds = await _context.PoemGeoDateTags
.Where(t => t.MachineGenerated == false && t.PersonId != null && catIds.Contains(t.Poem.CatId))
.Select(t => t.PersonId.Value)
.Distinct()
.ToListAsync();
if (directPersonIds.Count == 0)
{
return new RServiceResult<GanjoorPersonGraphViewModel>(new GanjoorPersonGraphViewModel()
{
Nodes = new List<GanjoorPersonGraphNode>(),
Edges = new List<GanjoorPersonGraphEdge>(),
});
}
var directIdSet = new HashSet<int>(directPersonIds);
var oneHopRelations = await _context.GanjoorPersonRelations
.Where(r => directIdSet.Contains(r.Person1Id) || directIdSet.Contains(r.Person2Id))
.ToListAsync();
var oneHopAffiliations = await _context.GanjoorPersonAffiliations
.Where(a => directIdSet.Contains(a.Person1Id) || directIdSet.Contains(a.Person2Id))
.ToListAsync();
var allIdSet = new HashSet<int>(directIdSet);
foreach (var r in oneHopRelations)
{
allIdSet.Add(r.Person1Id);
allIdSet.Add(r.Person2Id);
}
foreach (var a in oneHopAffiliations)
{
allIdSet.Add(a.Person1Id);
allIdSet.Add(a.Person2Id);
}
// re-query rather than reuse oneHopRelations/oneHopAffiliations, so an edge between
// two one-hop additions (not just their link back to a directly-tagged person) is
// also included, now that the final node set is known
var relations = await _context.GanjoorPersonRelations
.Where(r => allIdSet.Contains(r.Person1Id) && allIdSet.Contains(r.Person2Id))
.ToListAsync();
var affiliations = await _context.GanjoorPersonAffiliations
.Where(a => allIdSet.Contains(a.Person1Id) && allIdSet.Contains(a.Person2Id))
.ToListAsync();
var persons = await _context.GanjoorRelatedPersons
.Where(p => allIdSet.Contains(p.Id))
.ToListAsync();
var personById = persons.ToDictionary(p => p.Id);
var nodes = _BuildGraphNodes(persons, directIdSet);
var edges = _BuildGraphEdges(relations, affiliations, personById);
return new RServiceResult<GanjoorPersonGraphViewModel>(new GanjoorPersonGraphViewModel()
{
Nodes = nodes,
Edges = edges,
});
}
catch (Exception exp)
{
return new RServiceResult<GanjoorPersonGraphViewModel>(null, exp.ToString());
}
}
/// <summary>
/// shared node-list builder for GetPersonGraphAsync/GetCatPersonGraphAsync - directlyTaggedIds
/// null means "everyone counts as directly part of the graph" (the whole-site graph has no
/// notion of one-hop additions)
/// </summary>
private static List<GanjoorPersonGraphNode> _BuildGraphNodes(List<GanjoorRelatedPerson> persons, HashSet<int> directlyTaggedIds)
{
return persons.Select(p => new GanjoorPersonGraphNode()
{
Id = p.Id,
Name = p.Name,
HasFamilyTree = !string.IsNullOrEmpty(p.FamilyTreeCaption),
DirectlyTagged = directlyTaggedIds == null || directlyTaggedIds.Contains(p.Id),
}).ToList();
}
/// <summary>
/// shared edge-list builder for GetPersonGraphAsync/GetCatPersonGraphAsync - flattens
/// GanjoorPersonRelation and GanjoorPersonAffiliation rows into the common
/// GanjoorPersonGraphEdge shape, resolving both sides' names from personById
/// </summary>
private static List<GanjoorPersonGraphEdge> _BuildGraphEdges(List<GanjoorPersonRelation> relations, List<GanjoorPersonAffiliation> affiliations, Dictionary<int, GanjoorRelatedPerson> personById)
{
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 edges;
}
/// <summary>
/// Database Context
/// </summary>