tai-JY / src /components /KnowledgeGraph /KnowledgeGraph.jsx
youngtsai's picture
只要把 import { Select } from 'antd'; 這一行移除即可。
6a00177
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))
);
});