people tags #387

This commit is contained in:
Hamid Reza Mohammadi 2026-09-29 17:13:02 +03:30
parent 1c65da5ade
commit 9d12c23400
13 changed files with 7415 additions and 1 deletions

View 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">&nbsp;</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">&nbsp;</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"])
}
}

View 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 &lt;script&gt; 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();
}
}
}

View File

@ -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>

View File

@ -66,7 +66,10 @@ else
{
<div class="spacer">&nbsp;</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)
{

View 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 };
})();

View File

@ -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.

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

View File

@ -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")

View File

@ -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; }
}
}

View File

@ -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

View File

@ -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

View File

@ -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>