feat: inverted tree layout, set-root, scan photo

- layout.ts: инверсия иерархии (maxDepth - depth), поддержка rootId с обходом поддерева
- PersonNode.tsx: свап handles Top/Bottom, стрелка ↓ к родителю, кнопка ⊙ set-root
- FamilyTree.tsx: props rootId/onSetRoot, nodeTypes closure с onSetRoot, кнопка «Все древо»
- App.tsx: state rootId + setRootId, передача в FamilyTree
- persons.js: findWikipediaPhoto, findWikidataPhoto, POST /:id/scan-photo
- client.ts: api.scanPhoto
- PersonModal.tsx: аватар с фото, кнопка «Найти фото», ссылка на источник

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-08-05 15:28:54 +03:00
co-authored by Claude Sonnet 4.6
parent 0ebd54dc41
commit 69b8f33f6e
7 changed files with 222 additions and 50 deletions
+73
View File
@@ -75,4 +75,77 @@ router.delete('/:id', authRequired, requireRole('admin'), async (req, res) => {
res.status(204).end();
});
async function findWikipediaPhoto(firstName, lastName, birthYear) {
const langs = ['ru', 'en'];
for (const lang of langs) {
try {
const encoded = encodeURIComponent(`${firstName} ${lastName}`);
const searchRes = await fetch(
`https://${lang}.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encoded}&srlimit=3&format=json&origin=*`,
{ headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) }
);
const searchData = await searchRes.json();
const pages = searchData?.query?.search || [];
if (!pages.length) continue;
const pageTitle = encodeURIComponent(pages[0].title);
const imgRes = await fetch(
`https://${lang}.wikipedia.org/w/api.php?action=query&titles=${pageTitle}&prop=pageimages&pithumbsize=400&format=json&origin=*`,
{ headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) }
);
const imgData = await imgRes.json();
const page = Object.values(imgData?.query?.pages || {})[0];
if (!page?.thumbnail?.source) continue;
return {
photo_url: page.thumbnail.source,
source_url: `https://${lang}.wikipedia.org/wiki/${pageTitle}`,
source_name: `Wikipedia (${lang})`,
};
} catch { continue; }
}
return null;
}
async function findWikidataPhoto(firstName, lastName) {
try {
const q = encodeURIComponent(`${firstName} ${lastName}`);
const res = await fetch(
`https://www.wikidata.org/w/api.php?action=wbsearchentities&search=${q}&language=ru&type=item&limit=3&format=json&origin=*`,
{ headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) }
);
const data = await res.json();
const entity = data?.search?.[0];
if (!entity) return null;
const propRes = await fetch(
`https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${entity.id}&property=P18&format=json&origin=*`,
{ headers: { 'User-Agent': 'HeritageApp/1.0' }, signal: AbortSignal.timeout(8000) }
);
const propData = await propRes.json();
const claims = propData?.claims?.P18;
if (!claims?.length) return null;
const filename = claims[0].mainsnak?.datavalue?.value;
if (!filename) return null;
const encoded = encodeURIComponent(filename.replace(/ /g, '_'));
return {
photo_url: `https://commons.wikimedia.org/wiki/Special:FilePath/${encoded}?width=400`,
source_url: `https://www.wikidata.org/wiki/${entity.id}`,
source_name: 'Wikidata',
};
} catch { return null; }
}
router.post('/:id/scan-photo', authRequired, requireRole('admin', 'editor'), async (req, res) => {
const { id } = req.params;
const person = (await query('SELECT * FROM persons WHERE id=$1', [id])).rows[0];
if (!person) return res.status(404).json({ error: 'Персона не найдена' });
const firstName = req.body.first_name || person.first_name;
const lastName = req.body.last_name || person.last_name || '';
const birthYear = (req.body.birth_date || person.birth_date)?.toString().slice(0, 4);
let result = await findWikipediaPhoto(firstName, lastName, birthYear);
if (!result) result = await findWikidataPhoto(firstName, lastName);
if (!result) return res.json({ found: false });
await query('UPDATE persons SET photo_url=$1, resource_url=$2 WHERE id=$3', [result.photo_url, result.source_url, id]);
await logChange(req.user.id, id, 'scan-photo', result);
res.json({ found: true, photo_url: result.photo_url, source_url: result.source_url, source_name: result.source_name });
});
export default router;
+2 -1
View File
@@ -14,6 +14,7 @@ export default function App() {
const [people, setPeople] = useState<Person[]>([]);
const [viz, setViz] = useState<VizSettings>(DEFAULT_VIZ);
const [selected, setSelected] = useState<string | null>(null);
const [rootId, setRootId] = useState<string | null>(null);
const [dark, setDark] = useState(matchMedia('(prefers-color-scheme: dark)').matches);
const [q, setQ] = useState('');
@@ -73,7 +74,7 @@ export default function App() {
</header>
<main className="flex-1">
<FamilyTree people={people} settings={viz} onSelect={setSelected} />
<FamilyTree people={people} settings={viz} onSelect={setSelected} rootId={rootId} onSetRoot={setRootId} />
</main>
{selected && <PersonModal id={selected} onClose={() => setSelected(null)} onNavigate={setSelected} />}
+2
View File
@@ -34,4 +34,6 @@ export const api = {
history: () => req('/relations/history'),
exportJson: () => req('/io/export/json'),
importJson: (data: unknown) => req('/io/import/json', { method: 'POST', body: JSON.stringify(data) }),
scanPhoto: (id: string, data?: { first_name?: string; last_name?: string; birth_date?: string }) =>
req(`/persons/${id}/scan-photo`, { method: 'POST', body: JSON.stringify(data || {}) }),
};
+39 -20
View File
@@ -11,37 +11,54 @@ import type { Person, VizSettings } from "../types";
import { buildGraph } from "../lib/layout";
import PersonNode from "./PersonNode";
const nodeTypes = { person: PersonNode };
function FamilyTreeInner({
people, settings, onSelect,
people, settings, onSelect, rootId, onSetRoot,
}: {
people: Person[];
settings: VizSettings;
onSelect: (id: string) => void;
rootId: string | null;
onSetRoot: (id: string | null) => void;
}) {
const [collapsed] = useState<Set<string>>(new Set());
const nodeTypes = useMemo(() => ({
person: (props: any) => (
<PersonNode {...props} data={{ ...props.data, onSetRoot }} />
),
}), [onSetRoot]);
const { nodes, edges } = useMemo(
() => buildGraph(people, settings, collapsed),
[people, settings, collapsed]
() => buildGraph(people, settings, collapsed, rootId),
[people, settings, collapsed, rootId]
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={(_, n: Node) => onSelect(n.id)}
fitView
minZoom={0.1}
maxZoom={2.5}
proOptions={{ hideAttribution: true }}
>
<Background gap={24} />
<Controls />
<MiniMap pannable zoomable nodeColor={(n) =>
(n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} />
</ReactFlow>
<div className="relative w-full h-full">
{rootId && (
<button
onClick={() => onSetRoot(null)}
className="absolute top-2 left-2 z-10 px-3 py-1.5 rounded-lg bg-cream dark:bg-ink-2 border border-gold/30 text-sm shadow"
>
← Все древо
</button>
)}
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodeClick={(_, n: Node) => onSelect(n.id)}
fitView
minZoom={0.1}
maxZoom={2.5}
proOptions={{ hideAttribution: true }}
>
<Background gap={24} />
<Controls />
<MiniMap pannable zoomable nodeColor={(n) =>
(n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} />
</ReactFlow>
</div>
);
}
@@ -51,6 +68,8 @@ export default function FamilyTree(props: {
people: Person[];
settings: VizSettings;
onSelect: (id: string) => void;
rootId: string | null;
onSetRoot: (id: string | null) => void;
}) {
return (
<ReactFlowProvider>
+34 -2
View File
@@ -6,9 +6,25 @@ export default function PersonModal({ id, onClose, onNavigate }: {
id: string; onClose: () => void; onNavigate: (id: string) => void;
}) {
const [p, setP] = useState<any>(null);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<{ source_name: string; source_url: string } | null>(null);
useEffect(() => { api.person(id).then(setP).catch(() => {}); }, [id]);
if (!p) return null;
const handleScanPhoto = async () => {
if (!p) return;
setScanning(true);
setScanResult(null);
try {
const res = await api.scanPhoto(p.id);
if (res.found) {
setP((prev: any) => ({ ...prev, photo_url: res.photo_url, resource_url: res.source_url }));
setScanResult({ source_name: res.source_name, source_url: res.source_url });
}
} finally { setScanning(false); }
};
const Chip = ({ rel }: { rel: any }) => (
<button onClick={() => onNavigate(rel.id)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 mr-1.5 mb-1.5 rounded-full border border-gold/30 text-sm hover:border-brick">
@@ -22,8 +38,24 @@ export default function PersonModal({ id, onClose, onNavigate }: {
<div className="bg-cream dark:bg-ink-2 rounded-2xl w-[720px] max-w-full max-h-[90vh] overflow-auto"
onClick={e => e.stopPropagation()}>
<div className="flex gap-4 p-6 border-b border-gold/20 items-center">
<div className={`w-20 h-20 rounded-2xl grid place-items-center text-2xl font-serif font-bold text-cream ${p.gender === 'female' ? 'bg-female' : 'bg-male'}`}>
{(p.first_name?.[0] || '') + (p.last_name?.[0] || '')}
<div className="flex flex-col items-center gap-2">
<div className={`w-20 h-20 rounded-2xl overflow-hidden grid place-items-center text-2xl font-serif font-bold text-cream ${p.gender === 'female' ? 'bg-female' : 'bg-male'}`}>
{p.photo_url
? <img src={p.photo_url} className="w-full h-full object-cover" alt="фото" />
: (p.first_name?.[0] || '') + (p.last_name?.[0] || '')}
</div>
<button
onClick={handleScanPhoto}
disabled={scanning}
className="text-xs px-2.5 py-1 rounded-lg border border-gold/40 text-taupe hover:border-gold hover:text-ink dark:hover:text-cream transition-colors disabled:opacity-50"
>
{scanning ? '⏳ Поиск…' : '🔍 Найти фото'}
</button>
{scanResult && (
<a href={scanResult.source_url} target="_blank" rel="noopener" className="text-xs text-brick underline">
{scanResult.source_name}
</a>
)}
</div>
<div>
<h2 className="text-xl font-serif font-medium">{p.last_name} {p.first_name} {p.middle_name}</h2>
+36 -19
View File
@@ -18,8 +18,9 @@ const fullName = (p: Person) =>
// Person card refreshed in the shadcn/21st minimal spirit: softer surface,
// gender accent bar, smooth hover lift. Ширина расширена до 240px, чтобы ФИО
// показывалось целиком; согласовано с NODE_W в lib/layout.ts.
export default function PersonNode({ data }: { data: { person: Person } }) {
export default function PersonNode({ data }: { data: { person: Person; inverted?: boolean; onSetRoot?: (id: string) => void } }) {
const p = data.person;
const onSetRoot = data.onSetRoot;
const female = p.gender === "female";
const avatar = female ? "bg-female" : "bg-male";
const ring = female ? "ring-female/30" : "ring-male/30";
@@ -63,7 +64,7 @@ export default function PersonNode({ data }: { data: { person: Person } }) {
female ? "bg-female" : "bg-male"
)}
/>
<Handle type="target" position={Position.Top} className="opacity-0" />
<Handle type="source" position={Position.Top} className="opacity-0" />
<div
className={cn(
@@ -86,24 +87,40 @@ export default function PersonNode({ data }: { data: { person: Person } }) {
<div className="text-xs tabular-nums text-taupe/80 mt-0.5">{years(p)}</div>
</div>
{/* переход к карточке родителя */}
{parentId && (
<button
type="button"
onClick={goToParent}
title="К родителю"
aria-label="Перейти к карточке родителя"
className={cn(
"flex-none self-start -mr-1 mt-0.5 w-6 h-6 rounded-full grid place-items-center",
"text-gold/70 hover:text-cream hover:bg-gold/80 border border-gold/40",
"transition-colors duration-150 nodrag"
)}
>
<span className="text-sm leading-none">↑</span>
</button>
)}
{/* кнопки навигации */}
<div className="flex flex-col gap-1 flex-none self-start -mr-1 mt-0.5">
{parentId && (
<button
type="button"
onClick={goToParent}
title="К родителю"
aria-label="Перейти к карточке родителя"
className={cn(
"w-6 h-6 rounded-full grid place-items-center",
"text-gold/70 hover:text-cream hover:bg-gold/80 border border-gold/40",
"transition-colors duration-150 nodrag"
)}
>
<span className="text-sm leading-none">↓</span>
</button>
)}
{onSetRoot && (
<button
type="button"
onClick={e => { e.stopPropagation(); onSetRoot(p.id); }}
title="Сделать вершиной дерева"
className={cn(
"w-6 h-6 rounded-full grid place-items-center",
"text-gold/70 hover:text-cream hover:bg-gold/80 border border-gold/40",
"transition-colors duration-150 nodrag"
)}
>
<span className="text-xs leading-none">⊙</span>
</button>
)}
</div>
<Handle type="source" position={Position.Bottom} className="opacity-0" />
<Handle type="target" position={Position.Bottom} className="opacity-0" />
</div>
);
}
+36 -8
View File
@@ -4,8 +4,32 @@ import type { Edge, Node } from 'reactflow';
const NODE_W = 240, NODE_H = 110, V_GAP = 80, COUPLE_GAP = 26, FAM_GAP = 34;
// Раскладка генеалогического дерева с поддержкой ориентации и пар.
export function buildGraph(people: Person[], s: VizSettings, collapsed: Set<string>) {
const byId = new Map(people.map(p => [p.id, p]));
export function buildGraph(people: Person[], s: VizSettings, collapsed: Set<string>, rootId?: string | null) {
// Если задан rootId — оставляем только его и всех потомков
let peopleToLayout = people;
if (rootId) {
const byIdFull = new Map(people.map(p => [p.id, p]));
const subtree = new Set<string>();
const collect = (id: string) => {
if (subtree.has(id)) return;
subtree.add(id);
const p = byIdFull.get(id);
(p?.child_ids || []).forEach(collect);
(p?.spouse_ids || []).forEach(spId => {
if (!subtree.has(spId)) {
subtree.add(spId);
const sp = byIdFull.get(spId);
(sp?.child_ids || []).forEach(collect);
}
});
};
collect(rootId);
peopleToLayout = people
.filter(p => subtree.has(p.id))
.map(p => p.id === rootId ? { ...p, parent_ids: [] } : p);
}
const byId = new Map(peopleToLayout.map(p => [p.id, p]));
const pos: Record<string, { b: number; depth: number }> = {};
const horiz = s.orientation === 'horizontal';
const BW = horiz ? NODE_H : NODE_W;
@@ -13,7 +37,7 @@ export function buildGraph(people: Person[], s: VizSettings, collapsed: Set<stri
let cursor = 0;
const childrenOf = (id: string, spouse?: string) =>
people.filter(c => {
peopleToLayout.filter(c => {
const pr = c.parent_ids?.[0];
return pr === id || (spouse && pr === spouse);
});
@@ -46,16 +70,20 @@ export function buildGraph(people: Person[], s: VizSettings, collapsed: Set<stri
}
}
people.filter(p => !p.parent_ids?.length).forEach(r => layout(r.id, 0));
people.forEach(p => { if (!pos[p.id]) layout(p.id, 0); });
peopleToLayout.filter(p => !p.parent_ids?.length).forEach(r => layout(r.id, 0));
peopleToLayout.forEach(p => { if (!pos[p.id]) layout(p.id, 0); });
const nodes: Node[] = people.filter(p => pos[p.id]).map(p => {
// Вычисляем maxDepth для инверсии (старшие снизу, младшие сверху)
const maxDepth = Math.max(0, ...Object.values(pos).map(v => v.depth));
const nodes: Node[] = peopleToLayout.filter(p => pos[p.id]).map(p => {
const { b, depth } = pos[p.id];
const invertedDepth = maxDepth - depth;
return {
id: p.id,
type: 'person',
position: horiz ? { x: depth * DEP, y: b } : { x: b, y: depth * DEP },
data: { person: p },
position: horiz ? { x: invertedDepth * DEP, y: b } : { x: b, y: invertedDepth * DEP },
data: { person: p, inverted: true },
};
});