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 (
加載圖譜中...
); } // 如果沒有節點數據,顯示錯誤信息 if (!flowNodes || flowNodes.length === 0) { return (
無法顯示圖譜
{graphId && ( )}
); } return (
{/* 只保留 layout 選擇器 */}
n.id === selectedNodeId ? '#ff0072' : '#000'} nodeColor={(n) => n.id === selectedNodeId ? '#ff0072' : '#fff'} maskColor="rgba(0, 0, 0, 0.1)" style={{ height: 120, width: 160 }} /> {/* 這裡插入自動置中元件 */}
); } // 使用 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)) ); });