Files
stu-ai-demo/src/components/features/adaptive-graph.tsx

500 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<TNodeData = any> {
// 原始节点数据
nodes: TNodeData[]
// 原始边数据
edges: Array<{ source: string; target: string; label?: string }>
// 节点类型定义
nodeTypes: NodeTypes
// 过滤函数:根据搜索查询返回过滤后的节点 ID 集合和高亮节点 ID 集合(可选)
onFilter?: (nodes: TNodeData[], query: string) => {
filteredNodeIds: Set<string>
highlightedNodeIds: Set<string>
}
// 节点转换函数:将原始节点数据转换为 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<ReactFlowProps>
}
/**
* GraphContent - 图表渲染核心组件
*
* 这是一个独立的图表渲染组件,负责实际的 ReactFlow 图表展示和交互。
* 它必须在 ReactFlowProvider 内部使用,因为它依赖 useReactFlow hook。
*
* 职责:
* - 渲染 ReactFlow 图表(节点、边、控件、小地图等)
* - 管理节点和边的交互状态(拖拽、选择等)
* - 处理搜索输入和全屏按钮的 UI
* - 自动调整视野以适应过滤后的节点
*
* 与 AdaptiveGraph 的关系:
* - AdaptiveGraph 是外层容器组件,负责数据处理、状态管理和布局计算
* - GraphContent 是内层渲染组件,接收处理好的数据并负责图表的实际展示
* - AdaptiveGraph 会在两个地方使用 GraphContent普通视图和全屏对话框
* - 两者通过 ReactFlowProvider 隔离,确保每个实例有独立的 ReactFlow 上下文
*/
function GraphContent<TNodeData = any>({
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<HTMLInputElement>) => void
isFullscreen: boolean
setFullscreenOpen?: (open: boolean) => void
renderStats?: (filteredCount: number, totalCount: number, matchedCount: number) => React.ReactNode
filteredNodeIds: Set<string>
rawNodes: TNodeData[]
highlightedNodeIds: Set<string>
reactFlowProps: Partial<ReactFlowProps>
filteredNodeIdsForFitView: Set<string>
}) {
const { fitView } = useReactFlow()
const fitViewTimeoutRef = useRef<number>(0)
const previousFilteredNodeIdsRef = useRef<Set<string>>(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 ?
<div className="mt-2 text-xs text-muted-foreground">
{filteredCount} / {totalCount}
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
</div> : <></>
)
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
fitView
minZoom={0.1}
maxZoom={2}
defaultEdgeOptions={{
type: 'smoothstep',
animated: false,
}}
proOptions={{ hideAttribution: true }}
colorMode={themeIsDark ? 'dark' : 'light'}
{...reactFlowProps}
>
<Background />
<Controls />
<MiniMap
nodeColor={getNodeColor || (() => '#cbd5e1')}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Panel position="top-left" className="bg-background/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border">
<div className="flex items-center gap-2">
{onFilter && (
<InputGroup className="min-w-[300px]">
<InputGroupAddon>
<InputGroupText>
<Search className="h-4 w-4" />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
placeholder={searchPlaceholder}
value={inputValue}
onChange={handleInputChange}
/>
</InputGroup>
)}
{!isFullscreen && setFullscreenOpen && (
<Button
variant="ghost"
size="sm"
className="h-9 px-3"
title="全屏显示"
onClick={() => setFullscreenOpen(true)}
>
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
</Button>
)}
</div>
{(renderStats || defaultRenderStats)(filteredNodeIds.size, rawNodes.length, highlightedNodeIds.size)}
</Panel>
</ReactFlow>
)
}
/**
* AdaptiveGraph - 自适应图表容器组件
*
* 这是一个高度可配置的图表容器组件,提供完整的图表展示解决方案。
* 它处理数据转换、过滤、布局计算和状态管理,然后将处理好的数据传递给 GraphContent 进行渲染。
*
* 主要功能:
* - 接收原始节点和边数据,通过 transformNode/transformEdge 转换为 ReactFlow 格式
* - 支持自定义过滤逻辑(搜索、高亮)
* - 使用 dagre 算法自动计算节点布局
* - 提供普通视图和全屏对话框两种展示模式
* - 管理搜索状态和防抖处理
* ```
*/
export function AdaptiveGraph<TNodeData = any>({
nodes: rawNodes,
edges: rawEdges,
nodeTypes,
onFilter,
transformNode,
transformEdge,
getNodeColor,
onNodeClick,
searchPlaceholder = '搜索...',
renderStats,
layoutConfig,
className = 'h-[800px] max-h-[calc(100vh-12rem)]',
reactFlowProps = {},
}: AdaptiveGraphProps<TNodeData>) {
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<string>(),
}
}
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<HTMLInputElement>) => {
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 (
<>
<div className={cn('w-full border rounded-lg bg-background', className)}>
<ReactFlowProvider>
<GraphContent {...graphContentProps} />
</ReactFlowProvider>
</div>
{/* 全屏对话框 */}
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
<DialogContent className="p-0" variant="fullscreen">
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
<DialogTitle className="px-6 text-base"></DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<div className="h-full my-3 px-6">
<ReactFlowProvider>
<GraphContent {...graphContentProps} isFullscreen={true} setFullscreenOpen={undefined} />
</ReactFlowProvider>
</div>
<DialogFooter className="px-6 py-4 border-t border-border">
<DialogClose asChild>
<Button type="button"></Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}