people tags #387
This commit is contained in:
parent
1c65da5ade
commit
9d12c23400
82
GanjooRazor/Pages/FamilyTree.cshtml
Normal file
82
GanjooRazor/Pages/FamilyTree.cshtml
Normal file
@ -0,0 +1,82 @@
|
||||
@page "{id:int}"
|
||||
@model GanjooRazor.Pages.FamilyTreeModel
|
||||
@{
|
||||
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"> </div>
|
||||
|
||||
<div class="poem">
|
||||
<h1>
|
||||
شجرهنامهٔ @(string.IsNullOrEmpty(Model.RootPerson.FamilyTreeCaption) ? Model.RootPerson.Name : Model.RootPerson.FamilyTreeCaption)
|
||||
<a href="/person/@Model.RootPerson.Id" class="up-btn up-btn--ghost up-btn--sm">بازگشت به صفحهٔ شخصیت</a>
|
||||
</h1>
|
||||
<p class="up-hint"><small>
|
||||
برای مشاهدهٔ توضیحات هر شخصیت، روی آن کلیک کنید. برای پیمایش نمودار آن را با ماوس بکشید و با غلتک ماوس بزرگ/کوچک کنید.
|
||||
این نمودار محدود به فرزندان این شخصیت نیست؛ تمام خویشاوندان بههمپیوسته (از جمله نیاکان و خانوادهٔ همسر) را نشان میدهد.
|
||||
</small></p>
|
||||
|
||||
<div id="familytree-container" style="position:relative;width:100%;height:70vh;border:1px solid #d8cdb8;border-radius:6px;overflow:hidden;background:#faf7f0;">
|
||||
<svg id="familytree-svg" width="100%" height="100%"></svg>
|
||||
<div id="familytree-tooltip" class="ft-tooltip-panel" style="display:none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer"> </div>
|
||||
|
||||
</div>
|
||||
|
||||
<partial name="_FooterPartial" model=@(new _FooterPartialModel(){StickyEnabled = true}) />
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ft-box { fill: #fdf6e6; stroke: #8b6b4a; stroke-width: 1.5; }
|
||||
.ft-box-current { fill: #f3e2b8; stroke: #7a4a2a; stroke-width: 2.5; }
|
||||
.ft-box-text { font-size: 14px; fill: #4a3520; font-family: inherit; }
|
||||
.ft-heart { font-size: 13px; fill: #a0455c; }
|
||||
.ft-line { fill: none; stroke: #a99a7c; stroke-width: 1.5; }
|
||||
.ft-line-spouse { stroke-dasharray: 3,3; }
|
||||
.ft-tooltip-panel {
|
||||
position: absolute; top: 10px; left: 10px; max-width: 300px;
|
||||
background: #fffdf7; border: 1px solid #8b6b4a; border-radius: 8px;
|
||||
padding: 12px 16px; box-shadow: 0 2px 10px rgba(0,0,0,.25); z-index: 10;
|
||||
direction: rtl; text-align: right;
|
||||
}
|
||||
.ft-tooltip-close {
|
||||
float: left; border: none; background: none; font-size: 18px; cursor: pointer; color: #7a4a2a;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
var familyTreeData = @Html.Raw(Model.TreeDataJson);
|
||||
var familyTreeRootId = @Model.RootPerson.Id;
|
||||
</script>
|
||||
<script src="/js/familytree.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
GanjoorFamilyTree.render('familytree-container', 'familytree-svg', 'familytree-tooltip', familyTreeData, familyTreeRootId);
|
||||
});
|
||||
</script>
|
||||
|
||||
@if (ViewData["TrackingScript"] != null && !string.IsNullOrEmpty(ViewData["TrackingScript"].ToString()))
|
||||
{
|
||||
@Html.Raw(ViewData["TrackingScript"])
|
||||
}
|
||||
}
|
||||
70
GanjooRazor/Pages/FamilyTree.cshtml.cs
Normal file
70
GanjooRazor/Pages/FamilyTree.cshtml.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Newtonsoft.Json;
|
||||
using RMuseum.Models.Ganjoor;
|
||||
using RMuseum.Models.Ganjoor.ViewModels;
|
||||
|
||||
namespace GanjooRazor.Pages
|
||||
{
|
||||
/// <summary>
|
||||
/// interactive family-tree chart, drawn client-side (vanilla JS/SVG - see the <script> block
|
||||
/// in FamilyTree.cshtml) from the connected kinship component GET api/people/{id}/familytree
|
||||
/// returns. Public/read-only counterpart of Person.cshtml's plain relatives list, reachable from
|
||||
/// there and from People.cshtml's family-tree index.
|
||||
/// </summary>
|
||||
public class FamilyTreeModel : LoginPartialEnabledPageModel
|
||||
{
|
||||
public FamilyTreeModel(HttpClient httpClient, IConfiguration configuration) : base(httpClient, configuration)
|
||||
{
|
||||
}
|
||||
|
||||
public string LastError { get; set; }
|
||||
|
||||
public GanjoorRelatedPerson RootPerson { get; set; }
|
||||
|
||||
public GanjoorFamilyTreeViewModel Tree { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tree, re-serialized with an explicit camelCase contract (same convention ReviewEdits.cshtml.cs
|
||||
/// uses for AllLocationsJson/AllPeopleJson) so the client-side layout script has a predictable
|
||||
/// shape to parse, regardless of the API's own default casing
|
||||
/// </summary>
|
||||
public string TreeDataJson =>
|
||||
JsonConvert.SerializeObject(
|
||||
Tree,
|
||||
new JsonSerializerSettings { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }
|
||||
);
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
InitializeCommonPageState();
|
||||
|
||||
var personResponse = await _httpClient.GetAsync($"{APIRoot.Url}/api/people/{id}");
|
||||
if (!personResponse.IsSuccessStatusCode)
|
||||
{
|
||||
LastError = await ReadErrorMessageAsync(personResponse);
|
||||
return Page();
|
||||
}
|
||||
RootPerson = JsonConvert.DeserializeObject<GanjoorRelatedPerson>(await personResponse.Content.ReadAsStringAsync());
|
||||
if (RootPerson == null)
|
||||
{
|
||||
LastError = "شخصیتی با این کد پیدا نشد.";
|
||||
return Page();
|
||||
}
|
||||
|
||||
ViewData["Title"] = $"گنجور » شجرهنامهٔ {(string.IsNullOrEmpty(RootPerson.FamilyTreeCaption) ? RootPerson.Name : RootPerson.FamilyTreeCaption)}";
|
||||
|
||||
var treeResponse = await _httpClient.GetAsync($"{APIRoot.Url}/api/people/{id}/familytree");
|
||||
if (!treeResponse.IsSuccessStatusCode)
|
||||
{
|
||||
LastError = await ReadErrorMessageAsync(treeResponse);
|
||||
return Page();
|
||||
}
|
||||
Tree = JsonConvert.DeserializeObject<GanjoorFamilyTreeViewModel>(await treeResponse.Content.ReadAsStringAsync());
|
||||
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,7 @@ else
|
||||
<li>
|
||||
<a href="/person/@root.Id">@root.FamilyTreeCaption</a>
|
||||
<small>(@root.Name)</small>
|
||||
— <a href="/FamilyTree/@root.Id"><small>نمایش نمودار</small></a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@ -66,7 +66,10 @@ else
|
||||
{
|
||||
<div class="spacer"> </div>
|
||||
<div class="poem">
|
||||
<h2>خویشاوندان</h2>
|
||||
<h2>
|
||||
خویشاوندان
|
||||
<a href="/FamilyTree/@Model.Person.Id" class="up-btn up-btn--ghost up-btn--sm">نمایش نمودار شجرهنامه</a>
|
||||
</h2>
|
||||
<ul>
|
||||
@foreach (var row in Model.RelationRows)
|
||||
{
|
||||
|
||||
327
GanjooRazor/wwwroot/js/familytree.js
Normal file
327
GanjooRazor/wwwroot/js/familytree.js
Normal file
@ -0,0 +1,327 @@
|
||||
// Renders the interactive family-tree chart on /FamilyTree/{id}. Pure vanilla JS + SVG - no external
|
||||
// charting library - since the shape of the problem (a handful to a few hundred nodes, drawn once,
|
||||
// panned/zoomed by the user) doesn't need one, and the project prefers self-hosted, dependency-light
|
||||
// front-end code.
|
||||
//
|
||||
// Input (window.familyTreeData, set inline by FamilyTree.cshtml): the JSON of
|
||||
// RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel, camelCased -
|
||||
// { rootId, persons: [{id, name, birthYearInLHijri, deathYearInLHijri, validBirthDate,
|
||||
// validDeathDate, description, wikiUrl, familyTreeCaption, ...}], relations: [{person1Id,
|
||||
// person2Id, relationType, degreeHint}] } where relationType is PersonRelationType's numeric
|
||||
// value: 0=Parent, 1=Sibling, 2=Spouse, 3=Ancestor (Person1 is the parent/ancestor side for
|
||||
// 0 and 3; order doesn't matter for 1 and 2).
|
||||
//
|
||||
// The graph is a general kinship graph, not a strict tree (a person can have two recorded parents,
|
||||
// multiple spouses, etc.), so this builds a reasonable tree-like DRAWING out of it with a few
|
||||
// simplifying choices - see comments below - rather than claiming to be an exact rendering of every
|
||||
// edge. That mirrors what similar Shahnameh genealogy charts do in practice.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function dedup(arr) {
|
||||
return arr ? Array.from(new Set(arr)) : [];
|
||||
}
|
||||
|
||||
function buildLayout(data, requestedRootId) {
|
||||
var persons = {};
|
||||
data.persons.forEach(function (p) { persons[p.id] = p; });
|
||||
|
||||
var parentsOf = {}; // childId -> [parentId,...]
|
||||
var childrenOf = {}; // parentId -> [childId,...]
|
||||
var spouseOf = {}; // id -> [spouseId,...]
|
||||
|
||||
function addParentChild(parentId, childId) {
|
||||
(parentsOf[childId] = parentsOf[childId] || []).push(parentId);
|
||||
(childrenOf[parentId] = childrenOf[parentId] || []).push(childId);
|
||||
}
|
||||
function addSpouse(a, b) {
|
||||
(spouseOf[a] = spouseOf[a] || []).push(b);
|
||||
(spouseOf[b] = spouseOf[b] || []).push(a);
|
||||
}
|
||||
|
||||
(data.relations || []).forEach(function (r) {
|
||||
if (r.relationType === 0 || r.relationType === 3) {
|
||||
addParentChild(r.person1Id, r.person2Id);
|
||||
} else if (r.relationType === 2) {
|
||||
addSpouse(r.person1Id, r.person2Id);
|
||||
}
|
||||
// relationType === 1 (Sibling) isn't needed for layout: siblings normally end up
|
||||
// adjacent anyway once they share a primaryParentOf below.
|
||||
});
|
||||
|
||||
Object.keys(childrenOf).forEach(function (k) { childrenOf[k] = dedup(childrenOf[k]); });
|
||||
Object.keys(parentsOf).forEach(function (k) { parentsOf[k] = dedup(parentsOf[k]); });
|
||||
Object.keys(spouseOf).forEach(function (k) { spouseOf[k] = dedup(spouseOf[k]); });
|
||||
|
||||
var allIds = data.persons.map(function (p) { return p.id; });
|
||||
var isRoot = {};
|
||||
allIds.forEach(function (id) { isRoot[id] = !parentsOf[id] || parentsOf[id].length === 0; });
|
||||
|
||||
// one child can have two recorded parents (mother + father) - pick a single "primary"
|
||||
// parent per child to build a drawable tree, preferring whichever parent is NOT themselves
|
||||
// a chart-root (so the root-level spouse ends up as the "attached" side, see below)
|
||||
var primaryParentOf = {};
|
||||
var secondaryParentOf = {};
|
||||
allIds.forEach(function (childId) {
|
||||
var ps = parentsOf[childId];
|
||||
if (!ps || ps.length === 0) return;
|
||||
if (ps.length === 1) { primaryParentOf[childId] = ps[0]; return; }
|
||||
var sorted = ps.slice().sort(function (a, b) {
|
||||
var ar = isRoot[a] ? 1 : 0, br = isRoot[b] ? 1 : 0;
|
||||
if (ar !== br) return ar - br;
|
||||
return a - b;
|
||||
});
|
||||
primaryParentOf[childId] = sorted[0];
|
||||
secondaryParentOf[childId] = sorted[1];
|
||||
});
|
||||
|
||||
var hasOwnChildren = {};
|
||||
Object.keys(primaryParentOf).forEach(function (childId) {
|
||||
hasOwnChildren[primaryParentOf[childId]] = true;
|
||||
});
|
||||
|
||||
// a childless, parentless spouse (the common case - a wife with no recorded parents/kids
|
||||
// of her own beyond this marriage) is drawn as a small box attached beside their partner
|
||||
// rather than given their own column, same visual idea as the heart-linked pairs in
|
||||
// akhakhafrasiyab.ir's chart
|
||||
var attachedTo = {}; // spouseId -> primaryId
|
||||
var attachedList = {}; // primaryId -> [spouseId,...]
|
||||
|
||||
function tryAttach(primaryId, spouseId) {
|
||||
if (attachedTo[spouseId] !== undefined || spouseId === primaryId) return false;
|
||||
if (isRoot[spouseId] && !hasOwnChildren[spouseId]) {
|
||||
attachedTo[spouseId] = primaryId;
|
||||
(attachedList[primaryId] = attachedList[primaryId] || []).push(spouseId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Object.keys(secondaryParentOf).forEach(function (childId) {
|
||||
tryAttach(primaryParentOf[childId], secondaryParentOf[childId]);
|
||||
});
|
||||
Object.keys(spouseOf).forEach(function (a) {
|
||||
spouseOf[a].forEach(function (b) {
|
||||
if (attachedTo[a] !== undefined || attachedTo[b] !== undefined) return;
|
||||
if (tryAttach(a, b)) return;
|
||||
tryAttach(b, a);
|
||||
});
|
||||
});
|
||||
|
||||
var topLevelRoots = allIds.filter(function (id) { return isRoot[id] && attachedTo[id] === undefined; });
|
||||
|
||||
function subtreeContains(rootId, targetId, guard) {
|
||||
guard = guard || {};
|
||||
if (rootId === targetId) return true;
|
||||
if (guard[rootId]) return false;
|
||||
guard[rootId] = true;
|
||||
var kids = childrenOf[rootId] || [];
|
||||
for (var i = 0; i < kids.length; i++) if (subtreeContains(kids[i], targetId, guard)) return true;
|
||||
var att = attachedList[rootId] || [];
|
||||
for (var j = 0; j < att.length; j++) if (att[j] === targetId) return true;
|
||||
return false;
|
||||
}
|
||||
topLevelRoots.sort(function (a, b) {
|
||||
var aHas = subtreeContains(a, requestedRootId) ? 0 : 1;
|
||||
var bHas = subtreeContains(b, requestedRootId) ? 0 : 1;
|
||||
if (aHas !== bHas) return aHas - bHas;
|
||||
return a - b;
|
||||
});
|
||||
|
||||
// classic tidy-tree-ish pass: leaves get sequential column indices, a parent's column is
|
||||
// the midpoint of its children's - good enough for the mostly-linear/lightly-branching
|
||||
// lineages this data tends to have
|
||||
var xCounter = 0;
|
||||
var nodeX = {}, nodeDepth = {};
|
||||
function layout(id, depth) {
|
||||
nodeDepth[id] = depth;
|
||||
var kids = dedup(childrenOf[id] || []).sort(function (a, b) { return a - b; });
|
||||
if (kids.length === 0) {
|
||||
nodeX[id] = xCounter++;
|
||||
} else {
|
||||
kids.forEach(function (k) { layout(k, depth + 1); });
|
||||
var xs = kids.map(function (k) { return nodeX[k]; });
|
||||
nodeX[id] = (Math.min.apply(null, xs) + Math.max.apply(null, xs)) / 2;
|
||||
}
|
||||
}
|
||||
topLevelRoots.forEach(function (r) { layout(r, 0); });
|
||||
|
||||
return {
|
||||
persons: persons,
|
||||
childrenOf: childrenOf,
|
||||
primaryParentOf: primaryParentOf,
|
||||
secondaryParentOf: secondaryParentOf,
|
||||
attachedList: attachedList,
|
||||
topLevelRoots: topLevelRoots,
|
||||
nodeX: nodeX,
|
||||
nodeDepth: nodeDepth
|
||||
};
|
||||
}
|
||||
|
||||
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 personLabel(p) {
|
||||
var s = p.name || '';
|
||||
return s;
|
||||
}
|
||||
|
||||
function renderFamilyTree(containerId, svgId, tooltipId, data, requestedRootId) {
|
||||
var container = document.getElementById(containerId);
|
||||
var svg = document.getElementById(svgId);
|
||||
var tooltip = document.getElementById(tooltipId);
|
||||
if (!container || !svg || !data || !data.persons || data.persons.length === 0) return;
|
||||
|
||||
var layoutInfo = buildLayout(data, requestedRootId);
|
||||
|
||||
var COL_WIDTH = 220;
|
||||
var ROW_HEIGHT = 130;
|
||||
var BOX_W = 140;
|
||||
var BOX_H = 56;
|
||||
var ATTACH_GAP = 14;
|
||||
var ATTACH_W = 100;
|
||||
var MARGIN = 60;
|
||||
|
||||
var canvas = el('g', { id: 'ft-canvas', transform: 'translate(0,0) scale(1)' });
|
||||
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||
svg.appendChild(canvas);
|
||||
|
||||
var linesGroup = el('g', { 'class': 'ft-lines' });
|
||||
var nodesGroup = el('g', { 'class': 'ft-nodes' });
|
||||
canvas.appendChild(linesGroup);
|
||||
canvas.appendChild(nodesGroup);
|
||||
|
||||
function pos(id) {
|
||||
return {
|
||||
x: MARGIN + layoutInfo.nodeX[id] * COL_WIDTH,
|
||||
y: MARGIN + layoutInfo.nodeDepth[id] * ROW_HEIGHT
|
||||
};
|
||||
}
|
||||
|
||||
function line(x1, y1, x2, y2, cls) {
|
||||
var l = el('path', {
|
||||
d: 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + (y1 + (y2 - y1) / 2) + ', ' + x2 + ' ' + (y1 + (y2 - y1) / 2) + ', ' + x2 + ' ' + y2,
|
||||
'class': cls || 'ft-line'
|
||||
});
|
||||
linesGroup.appendChild(l);
|
||||
}
|
||||
|
||||
// parent -> child connectors
|
||||
Object.keys(layoutInfo.primaryParentOf).forEach(function (childId) {
|
||||
childId = parseInt(childId, 10);
|
||||
var parentId = layoutInfo.primaryParentOf[childId];
|
||||
if (layoutInfo.nodeX[parentId] === undefined || layoutInfo.nodeX[childId] === undefined) return;
|
||||
var pp = pos(parentId), cp = pos(childId);
|
||||
line(pp.x, pp.y + BOX_H / 2, cp.x, cp.y - BOX_H / 2, 'ft-line ft-line-parent');
|
||||
});
|
||||
|
||||
var maxX = 0, maxY = 0;
|
||||
|
||||
function drawBox(id, x, y, w, h, isRequested) {
|
||||
var p = layoutInfo.persons[id];
|
||||
var g = el('g', { 'class': 'ft-node', 'data-person-id': id, style: 'cursor:pointer' });
|
||||
var rect = el('rect', {
|
||||
x: x - w / 2, y: y - h / 2, width: w, height: h, rx: 8, ry: 8,
|
||||
'class': isRequested ? 'ft-box ft-box-current' : 'ft-box'
|
||||
});
|
||||
g.appendChild(rect);
|
||||
var text = el('text', {
|
||||
x: x, y: y + 5, 'text-anchor': 'middle', 'class': 'ft-box-text', direction: 'rtl'
|
||||
});
|
||||
text.textContent = personLabel(p);
|
||||
g.appendChild(text);
|
||||
g.addEventListener('click', function () { showDetails(p); });
|
||||
nodesGroup.appendChild(g);
|
||||
maxX = Math.max(maxX, x + w / 2);
|
||||
maxY = Math.max(maxY, y + h / 2);
|
||||
}
|
||||
|
||||
Object.keys(layoutInfo.nodeX).forEach(function (idStr) {
|
||||
var id = parseInt(idStr, 10);
|
||||
var p = pos(id);
|
||||
drawBox(id, p.x, p.y, BOX_W, BOX_H, id === requestedRootId);
|
||||
|
||||
var attached = layoutInfo.attachedList[id] || [];
|
||||
attached.forEach(function (spouseId, i) {
|
||||
var ax = p.x + BOX_W / 2 + ATTACH_GAP + ATTACH_W / 2 + i * (ATTACH_W + ATTACH_GAP);
|
||||
var ay = p.y;
|
||||
drawBox(spouseId, ax, ay, ATTACH_W, BOX_H, spouseId === requestedRootId);
|
||||
line(p.x + BOX_W / 2, p.y, ax - ATTACH_W / 2, ay, 'ft-line ft-line-spouse');
|
||||
var heartX = p.x + BOX_W / 2 + ATTACH_GAP / 2;
|
||||
var heart = el('text', { x: heartX, y: ay + 4, 'text-anchor': 'middle', 'class': 'ft-heart' });
|
||||
heart.textContent = '♥';
|
||||
nodesGroup.appendChild(heart);
|
||||
maxX = Math.max(maxX, ax + ATTACH_W / 2);
|
||||
});
|
||||
});
|
||||
|
||||
svg.setAttribute('viewBox', '0 0 ' + (maxX + MARGIN) + ' ' + (maxY + MARGIN));
|
||||
|
||||
function showDetails(p) {
|
||||
if (!tooltip) return;
|
||||
var html = '<button type="button" class="ft-tooltip-close" aria-label="بستن">×</button>' +
|
||||
'<h3 style="margin:4px 0 10px 0">' + escapeHtml(personLabel(p)) + '</h3>';
|
||||
if (p.birthYearInLHijri || p.deathYearInLHijri) {
|
||||
html += '<p style="margin:4px 0"><small>';
|
||||
if (p.birthYearInLHijri) html += 'زاده ' + p.birthYearInLHijri.toLocaleString('fa-IR') + (p.validBirthDate ? '' : ' (تخمینی)');
|
||||
if (p.birthYearInLHijri && p.deathYearInLHijri) html += ' — ';
|
||||
if (p.deathYearInLHijri) html += 'وفات ' + p.deathYearInLHijri.toLocaleString('fa-IR') + (p.validDeathDate ? '' : ' (تخمینی)');
|
||||
html += '</small></p>';
|
||||
}
|
||||
if (p.description) {
|
||||
html += '<p style="margin:4px 0"><small>' + escapeHtml(p.description) + '</small></p>';
|
||||
}
|
||||
html += '<p style="margin:8px 0 0 0"><a href="/person/' + p.id + '" target="_blank">مشاهدهٔ صفحهٔ شخصیت</a></p>';
|
||||
tooltip.innerHTML = html;
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.querySelector('.ft-tooltip-close').addEventListener('click', function () {
|
||||
tooltip.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// pan + zoom - a small, dependency-free version of the usual SVG drag/wheel recipe
|
||||
var scale = 1, tx = 0, ty = 0;
|
||||
var dragging = false, lastX = 0, lastY = 0;
|
||||
|
||||
function applyTransform() {
|
||||
canvas.setAttribute('transform', 'translate(' + tx + ',' + ty + ') scale(' + scale + ')');
|
||||
}
|
||||
|
||||
svg.addEventListener('mousedown', function (e) {
|
||||
dragging = true; lastX = e.clientX; lastY = e.clientY;
|
||||
svg.style.cursor = 'grabbing';
|
||||
});
|
||||
window.addEventListener('mouseup', function () { dragging = false; svg.style.cursor = 'grab'; });
|
||||
window.addEventListener('mousemove', function (e) {
|
||||
if (!dragging) return;
|
||||
tx += (e.clientX - lastX); ty += (e.clientY - lastY);
|
||||
lastX = e.clientX; lastY = e.clientY;
|
||||
applyTransform();
|
||||
});
|
||||
svg.addEventListener('wheel', function (e) {
|
||||
e.preventDefault();
|
||||
var factor = e.deltaY < 0 ? 1.1 : 0.9;
|
||||
scale = Math.min(3, Math.max(0.2, scale * factor));
|
||||
applyTransform();
|
||||
}, { passive: false });
|
||||
svg.style.cursor = 'grab';
|
||||
svg.removeAttribute('viewBox'); // pan/zoom via transform, not viewBox, once we have a canvas group
|
||||
svg.setAttribute('width', Math.max(container.clientWidth, maxX + MARGIN));
|
||||
svg.setAttribute('height', Math.max(container.clientHeight, maxY + MARGIN));
|
||||
}
|
||||
|
||||
window.GanjoorFamilyTree = { render: renderFamilyTree };
|
||||
})();
|
||||
@ -111,6 +111,24 @@ namespace RMuseum.Controllers
|
||||
return Ok(res.Result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// get the whole connected kinship component reachable from this person (ancestors,
|
||||
/// descendants, spouses, siblings), for the interactive family-tree chart
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{id:int}/familytree")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(GanjoorFamilyTreeViewModel))]
|
||||
[ProducesResponseType((int)HttpStatusCode.BadRequest, Type = typeof(string))]
|
||||
public async Task<IActionResult> GetFamilyTreeAsync(int id)
|
||||
{
|
||||
var res = await _personService.GetFamilyTreeAsync(id);
|
||||
if (!string.IsNullOrEmpty(res.ExceptionString))
|
||||
return BadRequest(res.ExceptionString);
|
||||
return Ok(res.Result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// suggest an edit to an already-approved person's own fields - any logged-in user, same as
|
||||
/// suggesting a poem correction. Goes into the pending queue; does not change the person.
|
||||
|
||||
6484
RMuseum/Migrations/20260929104427_AddGanjoorPersonEditSuggestion.Designer.cs
generated
Normal file
6484
RMuseum/Migrations/20260929104427_AddGanjoorPersonEditSuggestion.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RMuseum.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGanjoorPersonEditSuggestion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "GanjoorPersonEditSuggestions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
PersonId = table.Column<int>(type: "int", nullable: false),
|
||||
Date = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||
SuggestedName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SuggestedDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SuggestedWikiUrl = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SuggestedBirthYearInLHijri = table.Column<int>(type: "int", nullable: false),
|
||||
SuggestedDeathYearInLHijri = table.Column<int>(type: "int", nullable: false),
|
||||
SuggestedValidBirthDate = table.Column<bool>(type: "bit", nullable: false),
|
||||
SuggestedValidDeathDate = table.Column<bool>(type: "bit", nullable: false),
|
||||
SuggestedBirthLocationId = table.Column<int>(type: "int", nullable: true),
|
||||
SuggestedDeathLocationId = table.Column<int>(type: "int", nullable: true),
|
||||
SuggestedFamilyTreeCaption = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
SuggestionNote = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Reviewed = table.Column<bool>(type: "bit", nullable: false),
|
||||
Result = table.Column<int>(type: "int", nullable: false),
|
||||
ReviewDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ReviewNote = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
ReviewerUserId = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_GanjoorPersonEditSuggestions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_GanjoorPersonEditSuggestions_AspNetUsers_ReviewerUserId",
|
||||
column: x => x.ReviewerUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_GanjoorPersonEditSuggestions_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_GanjoorPersonEditSuggestions_GanjoorGeoLocations_SuggestedBirthLocationId",
|
||||
column: x => x.SuggestedBirthLocationId,
|
||||
principalTable: "GanjoorGeoLocations",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_GanjoorPersonEditSuggestions_GanjoorGeoLocations_SuggestedDeathLocationId",
|
||||
column: x => x.SuggestedDeathLocationId,
|
||||
principalTable: "GanjoorGeoLocations",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_GanjoorPersonEditSuggestions_GanjoorRelatedPersons_PersonId",
|
||||
column: x => x.PersonId,
|
||||
principalTable: "GanjoorRelatedPersons",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GanjoorPersonEditSuggestions_PersonId",
|
||||
table: "GanjoorPersonEditSuggestions",
|
||||
column: "PersonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GanjoorPersonEditSuggestions_ReviewerUserId",
|
||||
table: "GanjoorPersonEditSuggestions",
|
||||
column: "ReviewerUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GanjoorPersonEditSuggestions_SuggestedBirthLocationId",
|
||||
table: "GanjoorPersonEditSuggestions",
|
||||
column: "SuggestedBirthLocationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GanjoorPersonEditSuggestions_SuggestedDeathLocationId",
|
||||
table: "GanjoorPersonEditSuggestions",
|
||||
column: "SuggestedDeathLocationId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_GanjoorPersonEditSuggestions_UserId",
|
||||
table: "GanjoorPersonEditSuggestions",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "GanjoorPersonEditSuggestions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1428,6 +1428,86 @@ namespace RMuseum.Migrations
|
||||
b.ToTable("GanjoorPersonAffiliations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("PersonId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Result")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("ReviewDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ReviewNote")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Reviewed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<Guid?>("ReviewerUserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<int?>("SuggestedBirthLocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SuggestedBirthYearInLHijri")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("SuggestedDeathLocationId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("SuggestedDeathYearInLHijri")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SuggestedDescription")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SuggestedFamilyTreeCaption")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SuggestedName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("SuggestedValidBirthDate")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("SuggestedValidDeathDate")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("SuggestedWikiUrl")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SuggestionNote")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.HasIndex("ReviewerUserId");
|
||||
|
||||
b.HasIndex("SuggestedBirthLocationId");
|
||||
|
||||
b.HasIndex("SuggestedDeathLocationId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("GanjoorPersonEditSuggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorPersonRelation", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@ -5305,6 +5385,43 @@ namespace RMuseum.Migrations
|
||||
b.Navigation("Person2");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion", b =>
|
||||
{
|
||||
b.HasOne("RMuseum.Models.Ganjoor.GanjoorRelatedPerson", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("RSecurityBackend.Models.Auth.Db.RAppUser", "ReviewerUser")
|
||||
.WithMany()
|
||||
.HasForeignKey("ReviewerUserId");
|
||||
|
||||
b.HasOne("RMuseum.Models.Ganjoor.GanjoorGeoLocation", "SuggestedBirthLocation")
|
||||
.WithMany()
|
||||
.HasForeignKey("SuggestedBirthLocationId");
|
||||
|
||||
b.HasOne("RMuseum.Models.Ganjoor.GanjoorGeoLocation", "SuggestedDeathLocation")
|
||||
.WithMany()
|
||||
.HasForeignKey("SuggestedDeathLocationId");
|
||||
|
||||
b.HasOne("RSecurityBackend.Models.Auth.Db.RAppUser", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("ReviewerUser");
|
||||
|
||||
b.Navigation("SuggestedBirthLocation");
|
||||
|
||||
b.Navigation("SuggestedDeathLocation");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("RMuseum.Models.Ganjoor.GanjoorPersonRelation", b =>
|
||||
{
|
||||
b.HasOne("RMuseum.Models.Ganjoor.GanjoorRelatedPerson", "Person1")
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace RMuseum.Models.Ganjoor.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// the whole connected kinship component reachable from one person, for the interactive chart at
|
||||
/// /FamilyTree/{id} (client draws the layout; this just hands over every person and every edge in
|
||||
/// that connected subgraph - see GanjoorRelatedPersonService.GetFamilyTreeAsync). Deliberately not
|
||||
/// scoped to strict descendants of RootId: an ancestor several rows up, or a spouse's own side of
|
||||
/// the family, is part of the same connected component and is included too, exactly like
|
||||
/// akhakhafrasiyab.ir's chart isn't confined to the clicked node's own subtree.
|
||||
/// </summary>
|
||||
public class GanjoorFamilyTreeViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// the person id the chart was requested for (not necessarily the topmost node once rendered -
|
||||
/// the client walks Parent/Ancestor edges to find the actual top of each lineage in Persons)
|
||||
/// </summary>
|
||||
public int RootId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// every person in the connected component, including RootId itself
|
||||
/// </summary>
|
||||
public List<GanjoorRelatedPerson> Persons { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// every kinship edge touching any person in Persons
|
||||
/// </summary>
|
||||
public List<GanjoorFamilyTreeEdge> Relations { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// a GanjoorPersonRelation row stripped down to what the client-side layout needs (no
|
||||
/// Person1/Person2 navigation - Persons above already carries full person data)
|
||||
/// </summary>
|
||||
public class GanjoorFamilyTreeEdge
|
||||
{
|
||||
public int Person1Id { get; set; }
|
||||
public int Person2Id { get; set; }
|
||||
public PersonRelationType RelationType { get; set; }
|
||||
public int? DegreeHint { get; set; }
|
||||
}
|
||||
}
|
||||
@ -2860,6 +2860,14 @@
|
||||
<param name="id"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Controllers.GanjoorRelatedPersonController.GetFamilyTreeAsync(System.Int32)">
|
||||
<summary>
|
||||
get the whole connected kinship component reachable from this person (ancestors,
|
||||
descendants, spouses, siblings), for the interactive family-tree chart
|
||||
</summary>
|
||||
<param name="id"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Controllers.GanjoorRelatedPersonController.SuggestPersonEditAsync(System.Int32,RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion)">
|
||||
<summary>
|
||||
suggest an edit to an already-approved person's own fields - any logged-in user, same as
|
||||
@ -4857,6 +4865,18 @@
|
||||
<member name="M:RMuseum.Migrations.FamilyTreeModels.BuildTargetModel(Microsoft.EntityFrameworkCore.ModelBuilder)">
|
||||
<inheritdoc />
|
||||
</member>
|
||||
<member name="T:RMuseum.Migrations.AddGanjoorPersonEditSuggestion">
|
||||
<inheritdoc />
|
||||
</member>
|
||||
<member name="M:RMuseum.Migrations.AddGanjoorPersonEditSuggestion.Up(Microsoft.EntityFrameworkCore.Migrations.MigrationBuilder)">
|
||||
<inheritdoc />
|
||||
</member>
|
||||
<member name="M:RMuseum.Migrations.AddGanjoorPersonEditSuggestion.Down(Microsoft.EntityFrameworkCore.Migrations.MigrationBuilder)">
|
||||
<inheritdoc />
|
||||
</member>
|
||||
<member name="M:RMuseum.Migrations.AddGanjoorPersonEditSuggestion.BuildTargetModel(Microsoft.EntityFrameworkCore.ModelBuilder)">
|
||||
<inheritdoc />
|
||||
</member>
|
||||
<member name="T:RMuseum.Models.Accounting.DonationExpenditure">
|
||||
<summary>
|
||||
donation expenditures
|
||||
@ -12797,6 +12817,38 @@
|
||||
destination poem first verse
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel">
|
||||
<summary>
|
||||
the whole connected kinship component reachable from one person, for the interactive chart at
|
||||
/FamilyTree/{id} (client draws the layout; this just hands over every person and every edge in
|
||||
that connected subgraph - see GanjoorRelatedPersonService.GetFamilyTreeAsync). Deliberately not
|
||||
scoped to strict descendants of RootId: an ancestor several rows up, or a spouse's own side of
|
||||
the family, is part of the same connected component and is included too, exactly like
|
||||
akhakhafrasiyab.ir's chart isn't confined to the clicked node's own subtree.
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel.RootId">
|
||||
<summary>
|
||||
the person id the chart was requested for (not necessarily the topmost node once rendered -
|
||||
the client walks Parent/Ancestor edges to find the actual top of each lineage in Persons)
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel.Persons">
|
||||
<summary>
|
||||
every person in the connected component, including RootId itself
|
||||
</summary>
|
||||
</member>
|
||||
<member name="P:RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeViewModel.Relations">
|
||||
<summary>
|
||||
every kinship edge touching any person in Persons
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjoorFamilyTreeEdge">
|
||||
<summary>
|
||||
a GanjoorPersonRelation row stripped down to what the client-side layout needs (no
|
||||
Person1/Person2 navigation - Persons above already carries full person data)
|
||||
</summary>
|
||||
</member>
|
||||
<member name="T:RMuseum.Models.Ganjoor.ViewModels.GanjooRhymeAnalysisResult">
|
||||
<summary>
|
||||
Rhyme Analysis Result
|
||||
@ -17912,6 +17964,15 @@
|
||||
<param name="id"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Services.IGanjoorRelatedPersonService.GetFamilyTreeAsync(System.Int32)">
|
||||
<summary>
|
||||
get the whole connected kinship component reachable from this person (ancestors,
|
||||
descendants, spouses, siblings - whichever edges connect to it, transitively), for the
|
||||
interactive family-tree chart at /FamilyTree/{id}
|
||||
</summary>
|
||||
<param name="rootId"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Services.IGanjoorRelatedPersonService.SuggestPersonEditAsync(RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion)">
|
||||
<summary>
|
||||
submit a suggested edit to an already-approved person's own fields - goes into the
|
||||
@ -21416,6 +21477,13 @@
|
||||
<param name="id"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService.GetFamilyTreeAsync(System.Int32)">
|
||||
<summary>
|
||||
get the whole connected kinship component reachable from this person
|
||||
</summary>
|
||||
<param name="rootId"></param>
|
||||
<returns></returns>
|
||||
</member>
|
||||
<member name="M:RMuseum.Services.Implementation.GanjoorRelatedPersonService.SuggestPersonEditAsync(RMuseum.Models.Ganjoor.GanjoorPersonEditSuggestion)">
|
||||
<summary>
|
||||
submit a suggested edit to an already-approved person's own fields
|
||||
|
||||
@ -53,6 +53,15 @@ namespace RMuseum.Services
|
||||
/// <returns></returns>
|
||||
Task<RServiceResult<PoemGeoDateTag[]>> GetPoemsByPersonAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// get the whole connected kinship component reachable from this person (ancestors,
|
||||
/// descendants, spouses, siblings - whichever edges connect to it, transitively), for the
|
||||
/// interactive family-tree chart at /FamilyTree/{id}
|
||||
/// </summary>
|
||||
/// <param name="rootId"></param>
|
||||
/// <returns></returns>
|
||||
Task<RServiceResult<GanjoorFamilyTreeViewModel>> GetFamilyTreeAsync(int rootId);
|
||||
|
||||
/// <summary>
|
||||
/// submit a suggested edit to an already-approved person's own fields - goes into the
|
||||
/// pending queue, does not change the person itself until a moderator approves it
|
||||
|
||||
@ -172,6 +172,93 @@ namespace RMuseum.Services.Implementation
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// get the whole connected kinship component reachable from this person
|
||||
/// </summary>
|
||||
/// <param name="rootId"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<RServiceResult<GanjoorFamilyTreeViewModel>> GetFamilyTreeAsync(int rootId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootExists = await _context.GanjoorRelatedPersons.Where(p => p.Id == rootId).AnyAsync();
|
||||
if (!rootExists)
|
||||
{
|
||||
return new RServiceResult<GanjoorFamilyTreeViewModel>(null, "شخصیت پیدا نشد.");
|
||||
}
|
||||
|
||||
// kinship graph tends to be small (a few hundred rows at most for this kind of data),
|
||||
// so it's simplest/cheapest to load the whole table and walk it in memory rather than
|
||||
// issuing a recursive query
|
||||
var allRelations = await _context.GanjoorPersonRelations.ToListAsync();
|
||||
|
||||
var edgesByPersonId = new Dictionary<int, List<GanjoorPersonRelation>>();
|
||||
void IndexEdge(int personId, GanjoorPersonRelation edge)
|
||||
{
|
||||
if (!edgesByPersonId.TryGetValue(personId, out var list))
|
||||
{
|
||||
list = new List<GanjoorPersonRelation>();
|
||||
edgesByPersonId[personId] = list;
|
||||
}
|
||||
list.Add(edge);
|
||||
}
|
||||
foreach (var edge in allRelations)
|
||||
{
|
||||
IndexEdge(edge.Person1Id, edge);
|
||||
IndexEdge(edge.Person2Id, edge);
|
||||
}
|
||||
|
||||
var visitedPersonIds = new HashSet<int>() { rootId };
|
||||
var visitedRelationIds = new HashSet<int>();
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(rootId);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var personId = queue.Dequeue();
|
||||
if (!edgesByPersonId.TryGetValue(personId, out var touchingEdges))
|
||||
continue;
|
||||
|
||||
foreach (var edge in touchingEdges)
|
||||
{
|
||||
visitedRelationIds.Add(edge.Id);
|
||||
var otherPersonId = edge.Person1Id == personId ? edge.Person2Id : edge.Person1Id;
|
||||
if (visitedPersonIds.Add(otherPersonId))
|
||||
{
|
||||
queue.Enqueue(otherPersonId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var persons = await _context.GanjoorRelatedPersons
|
||||
.Where(p => visitedPersonIds.Contains(p.Id))
|
||||
.OrderBy(p => p.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var relations = allRelations
|
||||
.Where(r => visitedRelationIds.Contains(r.Id))
|
||||
.Select(r => new GanjoorFamilyTreeEdge()
|
||||
{
|
||||
Person1Id = r.Person1Id,
|
||||
Person2Id = r.Person2Id,
|
||||
RelationType = r.RelationType,
|
||||
DegreeHint = r.DegreeHint,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new RServiceResult<GanjoorFamilyTreeViewModel>(new GanjoorFamilyTreeViewModel()
|
||||
{
|
||||
RootId = rootId,
|
||||
Persons = persons,
|
||||
Relations = relations,
|
||||
});
|
||||
}
|
||||
catch (Exception exp)
|
||||
{
|
||||
return new RServiceResult<GanjoorFamilyTreeViewModel>(null, exp.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// submit a suggested edit to an already-approved person's own fields
|
||||
/// </summary>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user