frontend: sync deployed changes from vm-mts1 — полное ФИО + переход к родителю (PersonNode), ReactFlowProvider (FamilyTree), ширины узлов (layout)
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import ReactFlow, { Background, Controls, MiniMap, type Node } from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import type { Person, VizSettings } from '../types';
|
||||
import { buildGraph } from '../lib/layout';
|
||||
import PersonNode from './PersonNode';
|
||||
import { useMemo, useState } from "react";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
ReactFlowProvider,
|
||||
type Node,
|
||||
} from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
import type { Person, VizSettings } from "../types";
|
||||
import { buildGraph } from "../lib/layout";
|
||||
import PersonNode from "./PersonNode";
|
||||
|
||||
const nodeTypes = { person: PersonNode };
|
||||
|
||||
export default function FamilyTree({
|
||||
function FamilyTreeInner({
|
||||
people, settings, onSelect,
|
||||
}: {
|
||||
people: Person[];
|
||||
@@ -34,7 +40,21 @@ export default function FamilyTree({
|
||||
<Background gap={24} />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable nodeColor={(n) =>
|
||||
(n.data?.person?.gender === 'female') ? '#9C5A6F' : '#3F6F6F'} />
|
||||
(n.data?.person?.gender === "female") ? "#9C5A6F" : "#3F6F6F"} />
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
// ReactFlowProvider гарантирует доступность useReactFlow() внутри карточек узлов
|
||||
// (нужно для плавного перехода к карточке родителя).
|
||||
export default function FamilyTree(props: {
|
||||
people: Person[];
|
||||
settings: VizSettings;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FamilyTreeInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,108 @@
|
||||
import { Handle, Position } from 'reactflow';
|
||||
import type { Person } from '../types';
|
||||
import { Handle, Position, useReactFlow } from "reactflow";
|
||||
import type { Person } from "../types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const initials = (p: Person) =>
|
||||
((p.first_name?.[0] || "") + (p.last_name?.[0] || "")).toUpperCase();
|
||||
|
||||
const initials = (p: Person) => ((p.first_name?.[0] || '') + (p.last_name?.[0] || '')).toUpperCase();
|
||||
const years = (p: Person) => {
|
||||
const b = p.birth_date?.slice(0, 4) || '?';
|
||||
const b = p.birth_date?.slice(0, 4) || "?";
|
||||
const d = p.death_date?.slice(0, 4);
|
||||
return d ? `${b} – ${d}` : `р. ${b}`;
|
||||
};
|
||||
|
||||
// Полное ФИО в порядке «Фамилия Имя Отчество» (части склеиваются без пустот).
|
||||
const fullName = (p: Person) =>
|
||||
[p.last_name, p.first_name, p.middle_name].filter(Boolean).join(" ");
|
||||
|
||||
// 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 } }) {
|
||||
const p = data.person;
|
||||
const accent = p.gender === 'female' ? 'border-female' : 'border-male';
|
||||
const avatar = p.gender === 'female' ? 'bg-female' : 'bg-male';
|
||||
const female = p.gender === "female";
|
||||
const avatar = female ? "bg-female" : "bg-male";
|
||||
const ring = female ? "ring-female/30" : "ring-male/30";
|
||||
|
||||
const { getNode, setCenter, getZoom, setNodes } = useReactFlow();
|
||||
|
||||
const parentId = p.parent_ids?.[0];
|
||||
|
||||
// Плавно центрирует вид на карточке родителя и выделяет его.
|
||||
const goToParent = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // не открывать модалку при переходе к родителю
|
||||
if (!parentId) return; // корень (Михаил) — родителя нет
|
||||
const parent = getNode(parentId);
|
||||
if (!parent) return;
|
||||
const w = (parent.width ?? 240) as number;
|
||||
const h = (parent.height ?? 110) as number;
|
||||
setCenter(
|
||||
parent.position.x + w / 2,
|
||||
parent.position.y + h / 2,
|
||||
{ zoom: getZoom(), duration: 600 }
|
||||
);
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => ({ ...n, selected: n.id === parentId }))
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-[180px] h-[96px] rounded-xl border-2 ${accent} bg-cream dark:bg-ink-2 shadow-md flex items-center gap-3 px-3`}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-[240px] min-h-[110px] overflow-hidden rounded-xl",
|
||||
"border border-gold/25 bg-cream dark:bg-ink-2 shadow-sm",
|
||||
"flex items-center gap-3 pl-4 pr-3 py-2",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
"hover:-translate-y-0.5 hover:shadow-md hover:border-gold/60"
|
||||
)}
|
||||
>
|
||||
{/* gender accent bar */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full w-1.5",
|
||||
female ? "bg-female" : "bg-male"
|
||||
)}
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} className="opacity-0" />
|
||||
<div className={`w-12 h-12 rounded-full ${avatar} text-cream grid place-items-center font-serif font-bold flex-none`}>
|
||||
{p.photo_url ? <img src={p.photo_url} className="w-full h-full rounded-full object-cover" /> : initials(p)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex-none w-12 h-12 rounded-full grid place-items-center font-serif font-bold text-cream",
|
||||
"ring-2 ring-offset-2 ring-offset-cream dark:ring-offset-ink-2 transition-all duration-200",
|
||||
avatar, ring, "group-hover:ring-4"
|
||||
)}
|
||||
>
|
||||
{p.photo_url ? (
|
||||
<img src={p.photo_url} className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
initials(p)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-serif font-medium text-sm text-ink dark:text-cream truncate">{p.first_name} {p.last_name}</div>
|
||||
<div className="text-xs text-taupe truncate">{p.middle_name}</div>
|
||||
<div className="text-xs text-taupe/80">{years(p)}</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-serif font-medium text-sm leading-snug text-ink dark:text-cream whitespace-normal break-words">
|
||||
{fullName(p)}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="opacity-0" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Person, VizSettings } from '../types';
|
||||
import type { Edge, Node } from 'reactflow';
|
||||
|
||||
const NODE_W = 180, NODE_H = 96, V_GAP = 80, COUPLE_GAP = 26, FAM_GAP = 34;
|
||||
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>) {
|
||||
|
||||
Reference in New Issue
Block a user