Spaces:
Sleeping
Sleeping
| import React, { useCallback, useEffect, useRef, useState } from 'react'; | |
| import ReactFlow, { | |
| Background, | |
| Controls, | |
| MiniMap, | |
| ReactFlowProvider, | |
| MarkerType, | |
| useNodesState, | |
| useEdgesState, | |
| useReactFlow | |
| } from 'reactflow'; | |
| import dagre from 'dagre'; // 需要先安裝:npm install dagre | |
| import 'reactflow/dist/style.css'; | |
| import axios from 'axios'; | |
| const getLayoutedElements = (nodes, edges) => { | |
| const dagreGraph = new dagre.graphlib.Graph(); | |
| dagreGraph.setDefaultEdgeLabel(() => ({})); | |
| // 優化佈局配置,避免節點與連線重疊 | |
| dagreGraph.setGraph({ | |
| rankdir: 'TB', // Top-to-Bottom 方向 | |
| nodesep: 150, // 增加節點間的水平間距 | |
| ranksep: 200, // 增加層級間的垂直間距 | |
| edgesep: 80, // 增加邊之間的間距 | |
| marginx: 50, // 增加圖的水平邊距 | |
| marginy: 50, // 增加圖的垂直邊距 | |
| acyclicer: 'greedy', // 處理循環依賴 | |
| ranker: 'network-simplex' // 使用更好的排序算法 | |
| }); | |
| // 設置節點大小 (增加節點大小,減少重疊) | |
| nodes.forEach((node) => { | |
| dagreGraph.setNode(node.id, { width: 200, height: 60 }); | |
| }); | |
| // 添加邊 | |
| edges.forEach((edge) => { | |
| dagreGraph.setEdge(edge.source, edge.target); | |
| }); | |
| // 計算布局 | |
| dagre.layout(dagreGraph); | |
| // 獲取計算後的位置 | |
| const layoutedNodes = nodes.map(node => { | |
| const nodeWithPosition = dagreGraph.node(node.id); | |
| // 根據節點大小調整中心點 | |
| const nodeWidth = 200; | |
| const nodeHeight = 60; | |
| return { | |
| ...node, | |
| position: { | |
| x: Number.isFinite(nodeWithPosition.x) ? nodeWithPosition.x - nodeWidth / 2 : 0, | |
| y: Number.isFinite(nodeWithPosition.y) ? nodeWithPosition.y - nodeHeight / 2 : 0 | |
| }, | |
| }; | |
| }); | |
| return layoutedNodes; | |
| }; | |
| // 定義邊的類型樣式和中文標籤 | |
| const edgeTypes = { | |
| 'basic': { | |
| label: '基礎概念', | |
| color: '#2196f3' // 藍色 | |
| }, | |
| 'advanced': { | |
| label: '進階概念', | |
| color: '#f44336' // 紅色 | |
| }, | |
| 'related': { | |
| label: '相關概念', | |
| color: '#4caf50' // 綠色 | |
| }, | |
| 'contains': { | |
| label: '包含', | |
| color: '#9c27b0' // 紫色 | |
| }, | |
| 'example': { | |
| label: '案例', | |
| color: '#ff9800' // 橙色 | |
| }, | |
| 'reference': { | |
| label: '參考', | |
| color: '#795548' // 棕色 | |
| } | |
| }; | |
| // 添加一個全局變量來保存圖譜數據 | |
| const graphDataCache = { | |
| current: null | |
| }; | |
| // 支援的 layout 類型 | |
| const LAYOUT_OPTIONS = [ | |
| { value: 'TB', label: '上到下(預設)' }, | |
| { value: 'LR', label: '左到右' }, | |
| { value: 'mindmap', label: '心智圖式' }, | |
| { value: 'waterfall', label: '瀑布式' } | |
| ]; | |
| // 在 KnowledgeGraph 外部新增 | |
| function AutoFitView({ layoutType, flowNodes }) { | |
| const { fitView } = useReactFlow(); | |
| useEffect(() => { | |
| if (flowNodes && flowNodes.length > 0) { | |
| setTimeout(() => { | |
| fitView({ padding: 0.2, includeHiddenNodes: true }); | |
| }, 0); | |
| } | |
| }, [layoutType, flowNodes, fitView]); | |
| return null; | |
| } | |
| function KnowledgeGraph({ nodes, edges, onNodeClick, selectedNodeId, graphId }) { | |
| const containerRef = useRef(null); | |
| const [flowNodes, setFlowNodes] = useNodesState([]); | |
| const [flowEdges, setFlowEdges] = useEdgesState([]); | |
| const [localNodes, setLocalNodes] = useState(nodes); | |
| const [localEdges, setLocalEdges] = useState(edges); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [layoutType, setLayoutType] = useState('TB'); // 新增 layout 狀態 | |
| // 當 props 中的 nodes 和 edges 變化時,更新本地狀態 | |
| useEffect(() => { | |
| if (nodes && nodes.length > 0) { | |
| setLocalNodes(nodes); | |
| // 同時更新緩存 | |
| if (graphId) { | |
| graphDataCache[graphId] = { nodes, edges }; | |
| } | |
| } | |
| if (edges && edges.length > 0) { | |
| setLocalEdges(edges); | |
| } | |
| }, [nodes, edges, graphId]); | |
| // 如果 props 中的數據為空但有緩存,則使用緩存 | |
| useEffect(() => { | |
| if ((!nodes || nodes.length === 0) && graphId && graphDataCache[graphId]) { | |
| console.log(`使用緩存的圖譜數據: ${graphId}`); | |
| setLocalNodes(graphDataCache[graphId].nodes); | |
| setLocalEdges(graphDataCache[graphId].edges); | |
| } | |
| }, [nodes, edges, graphId]); | |
| // 如果本地狀態為空且有 graphId,則嘗試從服務器加載 | |
| useEffect(() => { | |
| if ((!localNodes || localNodes.length === 0) && graphId && !isLoading) { | |
| setIsLoading(true); | |
| console.log(`嘗試從服務器加載圖譜: ${graphId}`); | |
| axios.get(`/api/graph/${graphId}`) | |
| .then(response => { | |
| const graphData = response.data.graph; | |
| setLocalNodes(graphData.nodes); | |
| setLocalEdges(graphData.edges); | |
| // 更新緩存 | |
| graphDataCache[graphId] = { | |
| nodes: graphData.nodes, | |
| edges: graphData.edges | |
| }; | |
| console.log(`成功從服務器加載圖譜: ${graphId}`); | |
| }) | |
| .catch(error => { | |
| console.error(`加載圖譜失敗: ${graphId}`, error); | |
| }) | |
| .finally(() => { | |
| setIsLoading(false); | |
| }); | |
| } | |
| }, [localNodes, graphId, isLoading]); | |
| // 取得不同 layout 的節點位置 | |
| const getLayoutedNodes = useCallback((nodes, edges, layoutType) => { | |
| if (layoutType === 'mindmap') { | |
| // 簡單心智圖:root 在左,其他節點橫向展開 | |
| const root = nodes[0]; | |
| return nodes.map((node, i) => ({ | |
| ...node, | |
| position: { | |
| x: i * 250, | |
| y: (i % 2 === 0 ? 1 : -1) * (80 + 80 * Math.floor(i / 2)) | |
| } | |
| })); | |
| } | |
| if (layoutType === 'waterfall') { | |
| // 簡單瀑布式:每層一行,橫向排列 | |
| return nodes.map((node, i) => ({ | |
| ...node, | |
| position: { | |
| x: (i % 4) * 220, | |
| y: Math.floor(i / 4) * 120 | |
| } | |
| })); | |
| } | |
| // 其餘用 dagre | |
| return getLayoutedElements( | |
| nodes, | |
| edges, | |
| layoutType | |
| ); | |
| }, []); | |
| // 修改 getLayoutedElements 支援 rankdir | |
| const getLayoutedElements = (nodes, edges, rankdir = 'TB') => { | |
| const dagreGraph = new dagre.graphlib.Graph(); | |
| dagreGraph.setDefaultEdgeLabel(() => ({})); | |
| dagreGraph.setGraph({ | |
| rankdir, | |
| nodesep: 150, | |
| ranksep: 200, | |
| edgesep: 80, | |
| marginx: 50, | |
| marginy: 50, | |
| acyclicer: 'greedy', | |
| ranker: 'network-simplex' | |
| }); | |
| nodes.forEach((node) => { | |
| dagreGraph.setNode(node.id, { width: 200, height: 60 }); | |
| }); | |
| edges.forEach((edge) => { | |
| dagreGraph.setEdge(edge.source, edge.target); | |
| }); | |
| dagre.layout(dagreGraph); | |
| const layoutedNodes = nodes.map(node => { | |
| const nodeWithPosition = dagreGraph.node(node.id); | |
| const nodeWidth = 200; | |
| const nodeHeight = 60; | |
| return { | |
| ...node, | |
| position: { | |
| x: Number.isFinite(nodeWithPosition.x) ? nodeWithPosition.x - nodeWidth / 2 : 0, | |
| y: Number.isFinite(nodeWithPosition.y) ? nodeWithPosition.y - nodeHeight / 2 : 0 | |
| }, | |
| }; | |
| }); | |
| return layoutedNodes; | |
| }; | |
| // 使用 useEffect 來更新 ReactFlow 的節點和邊 | |
| useEffect(() => { | |
| if (!localNodes || localNodes.length === 0) return; | |
| try { | |
| const validNodes = Array.isArray(localNodes) ? localNodes : Object.values(localNodes); | |
| const layoutedNodes = getLayoutedNodes( | |
| validNodes.map(node => ({ | |
| id: node.id, | |
| data: { label: node.title }, | |
| position: { x: 0, y: 0 }, | |
| style: { | |
| background: selectedNodeId === node.id ? '#ff0072' : '#fff', | |
| border: '1px solid #777', | |
| padding: 10, | |
| width: 180, | |
| height: 50, | |
| borderRadius: 8, | |
| fontSize: '14px', | |
| textAlign: 'center' | |
| } | |
| })), | |
| localEdges || [], | |
| layoutType | |
| ); | |
| const styledEdges = (localEdges || []).map(edge => ({ | |
| ...edge, | |
| id: `${edge.source}-${edge.target}`, | |
| type: 'smoothstep', | |
| animated: true, | |
| style: { | |
| stroke: edgeTypes[edge.type]?.color || '#888', | |
| strokeWidth: 2 | |
| }, | |
| markerEnd: { | |
| type: MarkerType.ArrowClosed, | |
| width: 20, | |
| height: 20, | |
| color: edgeTypes[edge.type]?.color || '#888', | |
| }, | |
| label: edgeTypes[edge.type]?.label || edge.type, | |
| labelStyle: { | |
| fill: '#888', | |
| fontSize: 12, | |
| fontWeight: 500, | |
| background: '#ffffff', | |
| padding: '4px 8px', | |
| }, | |
| labelBgStyle: { | |
| fill: '#ffffff', | |
| fillOpacity: 0.8, | |
| rx: 4, | |
| stroke: '#eee', | |
| strokeWidth: 1, | |
| }, | |
| pathOptions: { | |
| offset: 30, | |
| borderRadius: 20 | |
| } | |
| })); | |
| setFlowNodes(layoutedNodes); | |
| setFlowEdges(styledEdges); | |
| } catch (error) { | |
| console.error('處理圖譜數據時出錯:', error); | |
| } | |
| }, [localNodes, localEdges, selectedNodeId, setFlowNodes, setFlowEdges, layoutType, getLayoutedNodes]); | |
| // 使用 useCallback 來記憶化事件處理函數 | |
| const handleNodeClick = useCallback((_, node) => { | |
| if (onNodeClick) { | |
| onNodeClick(node.id); | |
| } | |
| }, [onNodeClick]); | |
| // 如果正在加載,顯示加載指示器 | |
| if (isLoading) { | |
| return ( | |
| <div style={{ | |
| width: '100%', | |
| height: '100%', | |
| display: 'flex', | |
| justifyContent: 'center', | |
| alignItems: 'center', | |
| backgroundColor: '#f5f5f5' | |
| }}> | |
| <div>加載圖譜中...</div> | |
| </div> | |
| ); | |
| } | |
| // 如果沒有節點數據,顯示錯誤信息 | |
| if (!flowNodes || flowNodes.length === 0) { | |
| return ( | |
| <div style={{ | |
| width: '100%', | |
| height: '100%', | |
| display: 'flex', | |
| justifyContent: 'center', | |
| alignItems: 'center', | |
| flexDirection: 'column', | |
| backgroundColor: '#f5f5f5' | |
| }}> | |
| <div style={{ marginBottom: '16px' }}>無法顯示圖譜</div> | |
| {graphId && ( | |
| <button | |
| onClick={() => { | |
| setIsLoading(true); | |
| axios.get(`/api/graph/${graphId}`) | |
| .then(response => { | |
| const graphData = response.data.graph; | |
| setLocalNodes(graphData.nodes); | |
| setLocalEdges(graphData.edges); | |
| graphDataCache[graphId] = { | |
| nodes: graphData.nodes, | |
| edges: graphData.edges | |
| }; | |
| }) | |
| .catch(error => { | |
| console.error(`重新加載圖譜失敗: ${graphId}`, error); | |
| }) | |
| .finally(() => { | |
| setIsLoading(false); | |
| }); | |
| }} | |
| style={{ | |
| padding: '8px 16px', | |
| backgroundColor: '#2196f3', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer' | |
| }} | |
| > | |
| 重新加載圖譜 | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div ref={containerRef} style={{ width: '100%', height: '100%', position: 'relative' }}> | |
| {/* 只保留 layout 選擇器 */} | |
| <div style={{ | |
| position: 'absolute', | |
| top: 12, | |
| right: 12, | |
| zIndex: 10, | |
| background: '#fff', | |
| borderRadius: 6, | |
| boxShadow: '0 2px 8px rgba(0,0,0,0.08)', | |
| padding: '4px 12px' | |
| }}> | |
| <select | |
| value={layoutType} | |
| onChange={e => setLayoutType(e.target.value)} | |
| style={{ fontSize: 14, padding: '2px 8px' }} | |
| > | |
| {LAYOUT_OPTIONS.map(opt => ( | |
| <option key={opt.value} value={opt.value}>{opt.label}</option> | |
| ))} | |
| </select> | |
| </div> | |
| <ReactFlowProvider> | |
| <div style={{ width: '100%', height: '100%' }}> | |
| <ReactFlow | |
| nodes={flowNodes} | |
| edges={flowEdges} | |
| onNodeClick={handleNodeClick} | |
| fitView | |
| fitViewOptions={{ | |
| padding: 0.2, | |
| includeHiddenNodes: true | |
| }} | |
| minZoom={0.1} | |
| maxZoom={2} | |
| defaultViewport={{ | |
| x: 0, | |
| y: 0, | |
| zoom: 1 | |
| }} | |
| nodesDraggable={false} | |
| nodesConnectable={false} | |
| elementsSelectable={true} | |
| > | |
| <Background | |
| color="#aaa" | |
| gap={16} | |
| size={1} | |
| variant="dots" | |
| /> | |
| <Controls /> | |
| <MiniMap | |
| nodeStrokeColor={(n) => n.id === selectedNodeId ? '#ff0072' : '#000'} | |
| nodeColor={(n) => n.id === selectedNodeId ? '#ff0072' : '#fff'} | |
| maskColor="rgba(0, 0, 0, 0.1)" | |
| style={{ height: 120, width: 160 }} | |
| /> | |
| {/* 這裡插入自動置中元件 */} | |
| <AutoFitView layoutType={layoutType} flowNodes={flowNodes} /> | |
| </ReactFlow> | |
| </div> | |
| </ReactFlowProvider> | |
| </div> | |
| ); | |
| } | |
| // 使用 React.memo 並提供比較函數 | |
| export default React.memo(KnowledgeGraph, (prevProps, nextProps) => { | |
| // 只有當這些 props 都相同時才跳過重新渲染 | |
| return ( | |
| prevProps.selectedNodeId === nextProps.selectedNodeId && | |
| prevProps.graphId === nextProps.graphId && | |
| // 如果 nodes 和 edges 為空但 graphId 相同,也視為相同 | |
| ((prevProps.nodes === nextProps.nodes && prevProps.edges === nextProps.edges) || | |
| (!prevProps.nodes && !nextProps.nodes && !prevProps.edges && !nextProps.edges && prevProps.graphId === nextProps.graphId)) | |
| ); | |
| }); |