import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import ReactFlow, { Background, BaseEdge, ConnectionMode, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlowProvider, useEdgesState, useNodesState, useReactFlow, type Connection, type Edge, type EdgeProps, type Node, type NodeProps, type OnConnectStartParams, } from 'reactflow'; import 'reactflow/dist/style.css'; import { collectDescendantGroupIds } from '../../shared/npcs/npcGroups'; import type { NpcGroupId, NpcId, NpcRelationId, ProjectNpc, ProjectNpcGroup, ProjectNpcRelation, } from '../../shared/types'; import { useAssetUrl } from '../shared/useAssetImageUrl'; import styles from './NpcGraph.module.css'; type OpenEdgeMenuFn = (relationId: NpcRelationId, x: number, y: number) => void; type SelectSourceNpcFn = (sourceNpcId: NpcId) => void; const OpenEdgeMenuContext = createContext(null); const SelectSourceNpcContext = createContext(null); export type GraphGroupFilter = 'all' | 'ungrouped' | NpcGroupId; export type NpcGraphUiStrings = { zoomBar: string; zoomIn: string; zoomOut: string; fitAll: string; editRelation: string; deleteRelation: string; untitled: string; graphFilter: string; graphFilterAll: string; graphFilterUngrouped: string; }; type NpcNodeData = { name: string; avatarAssetId: ProjectNpc['avatarAssetId']; active: boolean; groupColor: string | null; dimmed: boolean; }; const NPC_ACCENT = '#60a5fa'; const NPC_EDGE_IDLE = '#a1a1aa'; /** Согласовано с `.node` в CSS (ширина + типичная высота карточки). */ const NPC_NODE_W = 120; const NPC_NODE_H = 150; /** Расстояние между параллельными связями одной пары НПС. */ const PARALLEL_EDGE_GAP = 21; type Side = 'left' | 'right' | 'top' | 'bottom'; type NpcEdgeData = { label: string; /** Смещение в мировых координатах (общее для пары, без учёта направления). */ offset: number; relationId: NpcRelationId; sourceNpcId: NpcId; targetNpcId: NpcId; highlighted: boolean; }; /** Любые связи между одной парой НПС (A→B и B→A) — в одной группе разведения. */ function undirectedPairKey(a: NpcId, b: NpcId): string { return a < b ? `${a}__${b}` : `${b}__${a}`; } function sideToPosition(side: Side): Position { switch (side) { case 'left': return Position.Left; case 'right': return Position.Right; case 'top': return Position.Top; case 'bottom': return Position.Bottom; } } /** Выбираем стороны карточек так, чтобы линия выходила наружу и не шла сквозь блок. */ function pickEndpointSides( sourcePos: { x: number; y: number }, targetPos: { x: number; y: number }, ): { sourceSide: Side; targetSide: Side } { const sx = sourcePos.x + NPC_NODE_W / 2; const sy = sourcePos.y + NPC_NODE_H / 2; const tx = targetPos.x + NPC_NODE_W / 2; const ty = targetPos.y + NPC_NODE_H / 2; const dx = tx - sx; const dy = ty - sy; if (Math.abs(dx) >= Math.abs(dy)) { return dx >= 0 ? { sourceSide: 'right', targetSide: 'left' } : { sourceSide: 'left', targetSide: 'right' }; } return dy >= 0 ? { sourceSide: 'bottom', targetSide: 'top' } : { sourceSide: 'top', targetSide: 'bottom' }; } function NpcNode({ data, selected }: NodeProps) { const url = useAssetUrl(data.avatarAssetId); const sides: Side[] = ['left', 'right', 'top', 'bottom']; const accent = data.groupColor ?? NPC_ACCENT; return (
{sides.map((side) => ( ))}
{url ? : null}
{data.name || '—'}
); } /** * Кривая с перпендикуляром в мировых координатах. * Базис берём от «меньшего» id к «большему», чтобы A→B и B→A с разными offset не совпадали. */ function parallelCubicPath( sourceNpcId: NpcId, targetNpcId: NpcId, sourceX: number, sourceY: number, targetX: number, targetY: number, worldOffset: number, ): { path: string; labelX: number; labelY: number } { // Канонический вектор между концами (не зависит от направления стрелки). const [ax, ay, bx, by] = sourceNpcId < targetNpcId ? [sourceX, sourceY, targetX, targetY] : [targetX, targetY, sourceX, sourceY]; const cdx = bx - ax; const cdy = by - ay; const clen = Math.sqrt(cdx * cdx + cdy * cdy) || 1; const px = (-cdy / clen) * worldOffset; const py = (cdx / clen) * worldOffset; // Сдвигаем всю кривую (включая концы у ручек), чтобы линии не сливались. const sx = sourceX + px; const sy = sourceY + py; const tx = targetX + px; const ty = targetY + py; const dx = tx - sx; const dy = ty - sy; const c1x = sx + dx * 0.35; const c1y = sy + dy * 0.35; const c2x = sx + dx * 0.65; const c2y = sy + dy * 0.65; return { path: `M ${sx},${sy} C ${c1x},${c1y} ${c2x},${c2y} ${tx},${ty}`, labelX: (sx + tx) / 2, labelY: (sy + ty) / 2, }; } function LabeledNpcEdge({ id, sourceX, sourceY, targetX, targetY, style, markerEnd, data, }: EdgeProps) { const openEdgeMenu = useContext(OpenEdgeMenuContext); const selectSourceNpc = useContext(SelectSourceNpcContext); const worldOffset = data?.offset ?? 0; const sourceNpcId = data?.sourceNpcId; const targetNpcId = data?.targetNpcId; const { path, labelX, labelY } = sourceNpcId && targetNpcId ? parallelCubicPath(sourceNpcId, targetNpcId, sourceX, sourceY, targetX, targetY, worldOffset) : { path: `M ${sourceX},${sourceY} L ${targetX},${targetY}`, labelX: (sourceX + targetX) / 2, labelY: (sourceY + targetY) / 2, }; const label = data?.label ?? ''; const relationId = data?.relationId; const highlighted = Boolean(data?.highlighted); return ( <> {label && relationId ? (
{ e.stopPropagation(); if (sourceNpcId) selectSourceNpc?.(sourceNpcId); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); openEdgeMenu?.(relationId, e.clientX, e.clientY); }} > {label}
) : null} ); } function ZoomToolbar({ ui }: { ui: NpcGraphUiStrings }) { const { zoomIn, zoomOut, fitView } = useReactFlow(); return (
); } function FilterToolbar({ ui, groups, value, onChange, }: { ui: NpcGraphUiStrings; groups: ProjectNpcGroup[]; value: GraphGroupFilter; onChange: (v: GraphGroupFilter) => void; }) { return ( ); } export type NpcGraphProps = { npcs: ProjectNpc[]; relations: ProjectNpcRelation[]; npcGroups: ProjectNpcGroup[]; selectedNpcId: NpcId | null; graphFilter: GraphGroupFilter; onGraphFilterChange: (filter: GraphGroupFilter) => void; graphUi: NpcGraphUiStrings; onSelect: (npcId: NpcId) => void; onConnectRequest: (sourceNpcId: NpcId, targetNpcId: NpcId) => void; onNodePositionCommit: (npcId: NpcId, x: number, y: number) => void; onEditRelation: (relationId: NpcRelationId) => void; onDeleteRelation: (relationId: NpcRelationId) => void; }; function NpcGraphInner({ npcs, relations, npcGroups, selectedNpcId, graphFilter, onGraphFilterChange, graphUi, onSelect, onConnectRequest, onNodePositionCommit, onEditRelation, onDeleteRelation, }: NpcGraphProps) { const [menu, setMenu] = useState<{ relationId: NpcRelationId; left: number; top: number } | null>(null); /** Откуда реально начали тянуть связь (Loose mode может перевернуть source/target). */ const connectFromRef = useRef(null); const edgeTypes = useMemo(() => ({ npcRelation: LabeledNpcEdge }), []); const nodeTypes = useMemo(() => ({ npc: NpcNode }), []); const menuPosition = useMemo(() => { if (!menu) return null; const pad = 8; const mw = 180; const mh = 88; return { left: Math.max(pad, Math.min(menu.left, window.innerWidth - mw - pad)), top: Math.max(pad, Math.min(menu.top, window.innerHeight - mh - pad)), }; }, [menu]); const groupColorById = useMemo(() => new Map(npcGroups.map((g) => [g.id, g.color])), [npcGroups]); const filterGroupIds = useMemo(() => { if (graphFilter === 'all' || graphFilter === 'ungrouped') return null; return collectDescendantGroupIds(npcGroups, graphFilter); }, [graphFilter, npcGroups]); const isNpcDimmed = useCallback( (npc: ProjectNpc) => { if (graphFilter === 'all') return false; if (graphFilter === 'ungrouped') return npc.groupId !== null; if (!filterGroupIds) return true; return npc.groupId === null || !filterGroupIds.has(npc.groupId); }, [filterGroupIds, graphFilter], ); const initialNodes: Node[] = useMemo( () => npcs.map((n) => ({ id: n.id, type: 'npc', position: { x: n.x, y: n.y }, data: { name: n.name, avatarAssetId: n.avatarAssetId, active: n.id === selectedNpcId, groupColor: n.groupId ? (groupColorById.get(n.groupId) ?? null) : null, dimmed: isNpcDimmed(n), }, })), [groupColorById, isNpcDimmed, npcs, selectedNpcId], ); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const builtEdges: Edge[] = useMemo(() => { const posById = new Map(); for (const n of nodes) posById.set(n.id, n.position); // Пока nodes ещё пуст/не синхронизирован — берём координаты из проекта. for (const n of npcs) { if (!posById.has(n.id)) posById.set(n.id, { x: n.x, y: n.y }); } // Группируем ВСЕ связи между парой НПС (оба направления), чтобы не накладывались. const groups = new Map(); for (const r of relations) { const key = undirectedPairKey(r.sourceNpcId, r.targetNpcId); const list = groups.get(key) ?? []; list.push(r); groups.set(key, list); } const out: Edge[] = []; for (const group of groups.values()) { const sorted = [...group].sort((a, b) => a.id.localeCompare(b.id)); const total = sorted.length; sorted.forEach((r, index) => { const sourcePos = posById.get(r.sourceNpcId) ?? { x: 0, y: 0 }; const targetPos = posById.get(r.targetNpcId) ?? { x: 0, y: 0 }; const { sourceSide, targetSide } = pickEndpointSides(sourcePos, targetPos); const offset = (index - (total - 1) / 2) * PARALLEL_EDGE_GAP; const highlighted = selectedNpcId !== null && r.sourceNpcId === selectedNpcId; const sourceNpc = npcs.find((n) => n.id === r.sourceNpcId); const targetNpc = npcs.find((n) => n.id === r.targetNpcId); const edgeDimmed = graphFilter !== 'all' && ((sourceNpc && isNpcDimmed(sourceNpc)) || (targetNpc && isNpcDimmed(targetNpc))); const color = highlighted ? NPC_ACCENT : NPC_EDGE_IDLE; out.push({ id: r.id, source: r.sourceNpcId, target: r.targetNpcId, sourceHandle: `s-${sourceSide}`, targetHandle: `t-${targetSide}`, type: 'npcRelation', zIndex: highlighted ? 10 : 0, data: { label: r.label, offset, relationId: r.id, sourceNpcId: r.sourceNpcId, targetNpcId: r.targetNpcId, highlighted, }, style: { stroke: color, strokeWidth: highlighted ? 2.5 : 2, opacity: edgeDimmed ? 0.2 : 1, }, markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color, }, }); }); } return out; }, [graphFilter, isNpcDimmed, nodes, npcs, relations, selectedNpcId]); useEffect(() => { setNodes(initialNodes); }, [initialNodes, setNodes]); useEffect(() => { setEdges(builtEdges); }, [builtEdges, setEdges]); const onConnectStart = useCallback((_event: unknown, params: OnConnectStartParams) => { connectFromRef.current = params.nodeId ? (params.nodeId as NpcId) : null; }, []); const onConnect = useCallback( (conn: Connection) => { if (!conn.source || !conn.target || conn.source === conn.target) return; const from = connectFromRef.current; if (from && (from === conn.source || from === conn.target)) { const to = (from === conn.source ? conn.target : conn.source) as NpcId; onConnectRequest(from, to); return; } onConnectRequest(conn.source as NpcId, conn.target as NpcId); }, [onConnectRequest], ); const openEdgeMenu = useCallback((relationId: NpcRelationId, x: number, y: number) => { setMenu({ relationId, left: x, top: y }); }, []); const selectSourceNpc = useCallback( (sourceNpcId: NpcId) => { setMenu(null); onSelect(sourceNpcId); }, [onSelect], ); return (
{ connectFromRef.current = null; }} nodeTypes={nodeTypes} edgeTypes={edgeTypes} connectionMode={ConnectionMode.Loose} fitView proOptions={{ hideAttribution: true }} onNodeClick={(_e, node) => { setMenu(null); onSelect(node.id as NpcId); }} onNodeDragStop={(_e, node) => { onNodePositionCommit(node.id as NpcId, node.position.x, node.position.y); }} onEdgeClick={(e, edge) => { e.stopPropagation(); setMenu(null); const sourceId = (edge.data as NpcEdgeData | undefined)?.sourceNpcId ?? (edge.source as NpcId); onSelect(sourceId); }} onEdgeContextMenu={(e, edge) => { e.preventDefault(); e.stopPropagation(); const relationId = (edge.data as NpcEdgeData | undefined)?.relationId ?? (edge.id as NpcRelationId); openEdgeMenu(relationId, e.clientX, e.clientY); }} onPaneClick={() => setMenu(null)} onPaneContextMenu={(e) => { e.preventDefault(); setMenu(null); }} > {menu && menuPosition ? createPortal( <>
, document.body, ) : null}
); } export function NpcGraph(props: NpcGraphProps) { return ( ); }