import React, { useRef, useEffect, useCallback, useState, forwardRef, } from "react"; import ForceGraph2D from "react-force-graph-2d"; import type { GraphData, GraphNode, GraphLink } from "../types"; import { useGraphStore } from "../store/useGraphStore"; import { useThemeStore } from "@/modules/theme-bw/stores/theme.store"; interface ForceGraphProps { data: GraphData; onNodeRightClick: (node: GraphNode, event: MouseEvent) => void; onLinkRightClick: (link: GraphLink, event: MouseEvent) => void; } export const ForceGraph = forwardRef( ({ data, onNodeRightClick, onLinkRightClick }, ref) => { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: 480, height: 600 }); const highlightNodes = useGraphStore((s) => s.highlightNodes); const highlightLinks = useGraphStore((s) => s.highlightLinks); const selectedNode = useGraphStore((s) => s.selectedNode); const isLinkMode = useGraphStore((s) => s.isLinkMode); const theme = useThemeStore((s) => s.theme); const isDark = theme === "dark"; // Определяем цвета текста в зависимости от темы const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937"; const nodeTextLetterColor = isDark ? "#ffffff" : "#000000"; // ResizeObserver для корректного отслеживания размеров useEffect(() => { const container = containerRef.current; if (!container) return; const updateDimensions = () => { setDimensions({ width: container.clientWidth, height: container.clientHeight, }); }; updateDimensions(); const observer = new ResizeObserver(updateDimensions); observer.observe(container); return () => observer.disconnect(); }, []); const handleNodeClick = useCallback((node: GraphNode) => { const store = useGraphStore.getState(); if (store.isLinkMode) { if (store.selectedNode === null) { store.setSelectedNode(node); } else if (store.selectedNode.id !== node.id) { store.createLink(store.selectedNode.id, node.id); store.setSelectedNode(null); store.toggleLinkMode(); } else { store.setSelectedNode(null); } } }, []); const handleNodeHover = (node: GraphNode | null) => { const newHighlightNodes = new Set(); const newHighlightLinks = new Set(); if (node) { newHighlightNodes.add(node.id); data.links.forEach((link) => { if (link.source === node.id || link.target === node.id) { newHighlightLinks.add(link); newHighlightNodes.add(link.source as string); newHighlightNodes.add(link.target as string); } }); } useGraphStore .getState() .setHighlight(newHighlightNodes, newHighlightLinks); }; const getNodeColor = (node: GraphNode) => { if (highlightNodes.has(node.id)) return "#fbbf24"; if (selectedNode?.id === node.id && isLinkMode) return "#f97316"; switch (node.type) { case "service": return "#3b82f6"; case "agent": return "#8b5cf6"; default: return "#6b7280"; } }; const getNodeSize = (node: GraphNode) => { switch (node.type) { case "service": return 3; case "agent": return 3; default: return 5; } }; const renderNode = ( node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number, ) => { const size = getNodeSize(node); const color = getNodeColor(node); if (!node.x || !node.y) return; ctx.beginPath(); ctx.arc(node.x, node.y, size, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); ctx.fillStyle = nodeTextLetterColor; ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; if (node.type === "service") { ctx.fillText("S", node.x, node.y); } else if (node.type === "agent") { ctx.fillText("A", node.x, node.y); } if (globalScale > 0.5) { ctx.fillStyle = nodeTextColor; ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`; ctx.textAlign = "center"; ctx.fillText(node.name, node.x, node.y + size + 8); } }; const handleEngineStop = () => { if (typeof ref !== "function" && ref && "current" in ref && ref.current) { ref.current.zoomToFit(400); } }; return (
{ return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`; }} linkLabel={(link: GraphLink) => { const sourceName = data.nodes.find((n) => n.id === link.source)?.name || link.source; const targetName = data.nodes.find((n) => n.id === link.target)?.name || link.target; return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`; }} linkColor={(link: any) => { return highlightLinks.has(link) ? "#fbbf24" : "#4b5563"; }} linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)} linkDirectionalParticles={0} onNodeClick={handleNodeClick} onNodeRightClick={onNodeRightClick} onLinkRightClick={onLinkRightClick} onNodeHover={handleNodeHover} cooldownTicks={50} cooldownTime={2000} d3AlphaDecay={0.03} d3VelocityDecay={0.4} warmupTicks={50} onEngineStop={handleEngineStop} />
); }, ); ForceGraph.displayName = "ForceGraph";