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 { Select } from '../shared/ui/controls'; 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 (