'use client' import React, { useCallback, useMemo, useState, useRef } from 'react' import { ReactFlow, Node, Edge, Controls, Background, MiniMap, Panel, useReactFlow, ReactFlowProvider, NodeTypes, MarkerType, ReactFlowProps, useNodesState, useEdgesState, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { Search, Maximize2 } from 'lucide-react' import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@/components/ui/input-group' import { Button } from '@/components/ui/button' import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import dagre from 'dagre' import { useDebouncedCallback } from '@/hooks/use-debounced-callback' import { useTheme } from 'next-themes' import { cn } from '@/lib/utils' // 布局配置类型 export interface LayoutConfig { direction?: 'TB' | 'LR' ranksep?: number nodesep?: number nodeWidth?: number nodeHeight?: number } // 自动布局函数 const getLayoutedElements = ( nodes: Node[], edges: Edge[], config: LayoutConfig = {} ) => { const { direction = 'TB', ranksep = 100, nodesep = 80, nodeWidth = 200, nodeHeight = 80, } = config const dagreGraph = new dagre.graphlib.Graph() dagreGraph.setDefaultEdgeLabel(() => ({})) dagreGraph.setGraph({ rankdir: direction, ranksep, nodesep }) nodes.forEach((node) => { dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }) }) edges.forEach((edge) => { dagreGraph.setEdge(edge.source, edge.target) }) dagre.layout(dagreGraph) const layoutedNodes = nodes.map((node) => { const nodeWithPosition = dagreGraph.node(node.id) return { ...node, position: { x: nodeWithPosition.x - nodeWidth / 2, y: nodeWithPosition.y - nodeHeight / 2, }, } }) return { nodes: layoutedNodes, edges } } // 通用图组件 Props export interface AdaptiveGraphProps { // 原始节点数据 nodes: TNodeData[] // 原始边数据 edges: Array<{ source: string; target: string; label?: string }> // 节点类型定义 nodeTypes: NodeTypes // 过滤函数:根据搜索查询返回过滤后的节点 ID 集合和高亮节点 ID 集合(可选) onFilter?: (nodes: TNodeData[], query: string) => { filteredNodeIds: Set highlightedNodeIds: Set } // 节点转换函数:将原始节点数据转换为 ReactFlow 节点 transformNode: ( node: TNodeData, options: { isHighlighted: boolean isDimmed: boolean } ) => Node // 边转换函数:将原始边数据转换为 ReactFlow 边(可选) transformEdge?: ( edge: { source: string; target: string; label?: string }, index: number, options: { isHighlighted: boolean } ) => Edge // MiniMap 节点颜色函数(可选) getNodeColor?: (node: Node) => string // 节点点击回调 onNodeClick?: (node: TNodeData) => void // 搜索占位符文本 searchPlaceholder?: string // 统计信息渲染函数(可选) renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode // 布局配置 layoutConfig?: LayoutConfig // 容器样式类名(用于控制高度等样式) className?: string // ReactFlow 额外属性(可选) reactFlowProps?: Partial } /** * GraphContent - 图表渲染核心组件 * * 这是一个独立的图表渲染组件,负责实际的 ReactFlow 图表展示和交互。 * 它必须在 ReactFlowProvider 内部使用,因为它依赖 useReactFlow hook。 * * 职责: * - 渲染 ReactFlow 图表(节点、边、控件、小地图等) * - 管理节点和边的交互状态(拖拽、选择等) * - 处理搜索输入和全屏按钮的 UI * - 自动调整视野以适应过滤后的节点 * * 与 AdaptiveGraph 的关系: * - AdaptiveGraph 是外层容器组件,负责数据处理、状态管理和布局计算 * - GraphContent 是内层渲染组件,接收处理好的数据并负责图表的实际展示 * - AdaptiveGraph 会在两个地方使用 GraphContent:普通视图和全屏对话框 * - 两者通过 ReactFlowProvider 隔离,确保每个实例有独立的 ReactFlow 上下文 */ function GraphContent({ flowNodes, flowEdges, handleNodeClick, nodeTypes, getNodeColor, onFilter, searchPlaceholder, inputValue, handleInputChange, isFullscreen, setFullscreenOpen, renderStats, filteredNodeIds, rawNodes, highlightedNodeIds, reactFlowProps, filteredNodeIdsForFitView, }: { flowNodes: Node[] flowEdges: Edge[] handleNodeClick: (event: React.MouseEvent, node: Node) => void nodeTypes: NodeTypes getNodeColor?: (node: Node) => string onFilter?: any searchPlaceholder: string inputValue: string handleInputChange: (e: React.ChangeEvent) => void isFullscreen: boolean setFullscreenOpen?: (open: boolean) => void renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode filteredNodeIds: Set rawNodes: TNodeData[] highlightedNodeIds: Set reactFlowProps: Partial filteredNodeIdsForFitView: Set }) { const { fitView } = useReactFlow() const fitViewTimeoutRef = useRef(0) const previousFilteredNodeIdsRef = useRef>(new Set()) // 使用 useNodesState 和 useEdgesState 来管理节点和边的状态,支持拖拽等交互 const [nodes, setNodes, onNodesChange] = useNodesState(flowNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(flowEdges) const { theme, resolvedTheme } = useTheme() // Tailwind CSS的样式变化无法自动传递到ReactFlow内部,需要显式监听网站主题的变化 const themeIsDark = resolvedTheme === 'dark' || (theme === 'system' && resolvedTheme === 'dark') // 当 flowNodes 或 flowEdges 变化时,更新内部状态 React.useEffect(() => { setNodes(flowNodes) }, [flowNodes, setNodes]) React.useEffect(() => { setEdges(flowEdges) }, [flowEdges, setEdges]) // 当过滤节点变化时,自动调整视野 React.useEffect(() => { // 比较 filteredNodeIds 的实际内容是否发生变化 const previousIds = previousFilteredNodeIdsRef.current const currentIds = filteredNodeIdsForFitView // 检查节点ID集合是否真正发生了变化 const hasChanged = previousIds.size !== currentIds.size || Array.from(currentIds).some(id => !previousIds.has(id)) if (hasChanged) { // 更新引用 previousFilteredNodeIdsRef.current = new Set(currentIds) // 清除之前的定时器 if (fitViewTimeoutRef.current) { window.clearTimeout(fitViewTimeoutRef.current) } // 延迟执行 fitView,确保节点已经渲染 fitViewTimeoutRef.current = window.setTimeout(() => { if (flowNodes.length > 0) { fitView({ padding: 0.2, duration: 300, maxZoom: 1.5, minZoom: 0.1, }) } }, 100) } return () => { if (fitViewTimeoutRef.current) { window.clearTimeout(fitViewTimeoutRef.current) } } }, [filteredNodeIdsForFitView, fitView, flowNodes.length]) // 默认统计信息渲染 const defaultRenderStats = (filteredCount: number, totalCount: number, matchedCount: number) => ( onFilter ?
显示 {filteredCount} / {totalCount} 个节点 {matchedCount > 0 && ` (${matchedCount} 个匹配)`}
: <> ) return ( '#cbd5e1')} maskColor="rgba(0, 0, 0, 0.1)" />
{onFilter && ( )} {!isFullscreen && setFullscreenOpen && ( )}
{(renderStats || defaultRenderStats)(filteredNodeIds.size, rawNodes.length, highlightedNodeIds.size)}
) } /** * AdaptiveGraph - 自适应图表容器组件 * * 这是一个高度可配置的图表容器组件,提供完整的图表展示解决方案。 * 它处理数据转换、过滤、布局计算和状态管理,然后将处理好的数据传递给 GraphContent 进行渲染。 * * 主要功能: * - 接收原始节点和边数据,通过 transformNode/transformEdge 转换为 ReactFlow 格式 * - 支持自定义过滤逻辑(搜索、高亮) * - 使用 dagre 算法自动计算节点布局 * - 提供普通视图和全屏对话框两种展示模式 * - 管理搜索状态和防抖处理 * ``` */ export function AdaptiveGraph({ nodes: rawNodes, edges: rawEdges, nodeTypes, onFilter, transformNode, transformEdge, getNodeColor, onNodeClick, searchPlaceholder = '搜索...', renderStats, layoutConfig, className = 'h-[800px] max-h-[calc(100vh-12rem)]', reactFlowProps = {}, }: AdaptiveGraphProps) { const [searchQuery, setSearchQuery] = useState('') const [inputValue, setInputValue] = useState('') const [fullscreenOpen, setFullscreenOpen] = useState(false) // 使用外部提供的过滤函数,如果未提供则显示所有节点 const { filteredNodeIds, highlightedNodeIds } = useMemo(() => { if (!onFilter) { // 未提供过滤函数时,显示所有节点且不高亮 const allNodeIds = new Set( rawNodes.map((node) => { const transformed = transformNode(node, { isHighlighted: false, isDimmed: false }) return transformed.id }) ) return { filteredNodeIds: allNodeIds, highlightedNodeIds: new Set(), } } return onFilter(rawNodes, searchQuery) }, [rawNodes, searchQuery, onFilter, transformNode]) // 转换为 ReactFlow 节点 const nodes = useMemo(() => { return rawNodes .filter((node) => { const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false }) return filteredNodeIds.has(nodeData.id) }) .map((node) => { const nodeData = transformNode(node, { isHighlighted: false, isDimmed: false }) return transformNode(node, { isHighlighted: highlightedNodeIds.has(nodeData.id), isDimmed: searchQuery.trim() !== '' && !highlightedNodeIds.has(nodeData.id), }) }) }, [rawNodes, filteredNodeIds, highlightedNodeIds, searchQuery, transformNode]) // 转换为 ReactFlow 边 const edges = useMemo(() => { const defaultTransform = ( edge: { source: string; target: string; label?: string }, index: number, options: { isHighlighted: boolean } ): Edge => ({ id: `${edge.source}-${edge.target}-${index}`, source: edge.source, target: edge.target, type: 'smoothstep', animated: options.isHighlighted, style: { stroke: options.isHighlighted ? '#3b82f6' : '#94a3b8', strokeWidth: options.isHighlighted ? 2 : 1, }, markerEnd: { type: MarkerType.ArrowClosed, color: options.isHighlighted ? '#3b82f6' : '#94a3b8', }, }) const transform = transformEdge || defaultTransform return rawEdges .filter((edge) => filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target)) .map((edge, index) => transform(edge, index, { isHighlighted: highlightedNodeIds.has(edge.source) || highlightedNodeIds.has(edge.target), }) ) }, [rawEdges, filteredNodeIds, highlightedNodeIds, transformEdge]) // 应用自动布局 const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => { return getLayoutedElements(nodes, edges, layoutConfig) }, [nodes, edges, layoutConfig]) // 防抖更新搜索查询 const debouncedSetSearchQuery = useDebouncedCallback((value: string) => { setSearchQuery(value) }, 300) // 处理输入变化 const handleInputChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value setInputValue(value) debouncedSetSearchQuery(value) }, [debouncedSetSearchQuery] ) // 处理节点点击 const handleNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { const nodeData = rawNodes.find((n) => { const transformed = transformNode(n, { isHighlighted: false, isDimmed: false }) return transformed.id === node.id }) if (nodeData && onNodeClick) { onNodeClick(nodeData) } }, [rawNodes, transformNode, onNodeClick] ) // 共享的 GraphContent props const graphContentProps = { flowNodes: layoutedNodes, flowEdges: layoutedEdges, handleNodeClick, nodeTypes, getNodeColor, onFilter, searchPlaceholder, inputValue, handleInputChange, isFullscreen: false, setFullscreenOpen, renderStats, filteredNodeIds, rawNodes, highlightedNodeIds, reactFlowProps, filteredNodeIdsForFitView: filteredNodeIds, } return ( <>
{/* 全屏对话框 */} 图表查看 全屏查看图表
) }