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:
co-authored by
Claude Sonnet 4.6
parent
0ebd54dc41
commit
69b8f33f6e
@@ -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;
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 || {}) }),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user