Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dd25066ad | ||
|
|
cb8f7f6cbe | ||
|
|
57eaaaffee | ||
|
|
1f9c3f86ff | ||
|
|
dca7f5f35c | ||
|
|
5e2717c8fc | ||
|
|
120edca338 |
@@ -1,56 +1,53 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Graph } from '@antv/g6';
|
||||
import type { GraphData, Node, Edge, GraphLayoutConfig } from '../../types/graph';
|
||||
import { getNodeColors } from '../../config/colors';
|
||||
import { FORCE_LAYOUT_CONFIG, GRAPH_BEHAVIORS } from '../../config/layout';
|
||||
import { GRAPH_DEFAULT_HEIGHT } from '../../config/constants';
|
||||
|
||||
interface GraphViewProps {
|
||||
data: GraphData;
|
||||
layout?: GraphLayoutConfig;
|
||||
onNodeClick?: (node: Node) => void;
|
||||
onEdgeClick?: (edge: Edge) => void;
|
||||
onNodeHover?: (node: Node | null) => void;
|
||||
onNodeClick?: (node: Node | null) => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识图谱可视化组件
|
||||
* 使用 AntV G6 渲染交互式图谱,支持节点点击、悬停等交互
|
||||
*/
|
||||
export const GraphView = ({
|
||||
data,
|
||||
layout = { type: 'force' },
|
||||
layout = FORCE_LAYOUT_CONFIG,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeHover,
|
||||
height = 600,
|
||||
height = GRAPH_DEFAULT_HEIGHT,
|
||||
}: GraphViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const isCancelledRef = useRef(false);
|
||||
const renderPromiseRef = useRef<Promise<void> | null>(null);
|
||||
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
|
||||
useEffect(() => {
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
}, [onNodeClick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!data.id || data.nodes.length === 0) return;
|
||||
|
||||
try {
|
||||
console.log('🎨 GraphView - Initializing graph with data:', data);
|
||||
console.log('🎨 GraphView - Container dimensions:', {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: height
|
||||
});
|
||||
|
||||
// 清理现有图谱实例
|
||||
if (graphRef.current) {
|
||||
try {
|
||||
graphRef.current.destroy();
|
||||
} catch (e) {
|
||||
console.log('🎨 GraphView - Destroy error:', e);
|
||||
}
|
||||
graphRef.current = null;
|
||||
}
|
||||
|
||||
// 转换数据格式以适配 G6
|
||||
const processedGraphData = {
|
||||
nodes: data.nodes.map((node: Node) => {
|
||||
const typeColors: Record<string, { fill: string; stroke: string }> = {
|
||||
'概念': { fill: '#4ECDC4', stroke: '#45B7B0' },
|
||||
'工具': { fill: '#FFA07A', stroke: '#FF7F50' },
|
||||
'应用': { fill: '#2ECC71', stroke: '#27AE60' },
|
||||
};
|
||||
|
||||
const colors = typeColors[node.type] || { fill: '#C6E5FF', stroke: '#5B8FF9' };
|
||||
|
||||
const colors = getNodeColors(node);
|
||||
return {
|
||||
id: node.id,
|
||||
data: {
|
||||
@@ -67,8 +64,8 @@ export const GraphView = ({
|
||||
labelText: node.label,
|
||||
labelFill: '#2C3E50',
|
||||
labelFontSize: 14,
|
||||
labelFontWeight: 'bold',
|
||||
labelPlacement: 'bottom',
|
||||
labelFontWeight: 'bold' as const,
|
||||
labelPlacement: 'bottom' as const,
|
||||
},
|
||||
};
|
||||
}),
|
||||
@@ -94,21 +91,28 @@ export const GraphView = ({
|
||||
})),
|
||||
};
|
||||
|
||||
console.log('🎨 GraphView - Processed graph data nodes:', processedGraphData.nodes.length);
|
||||
console.log('🎨 GraphView - Processed graph data edges:', processedGraphData.edges.length);
|
||||
|
||||
const graphConfig: any = {
|
||||
// 创建图谱配置
|
||||
const graphConfig = {
|
||||
container: containerRef.current,
|
||||
width: containerRef.current.clientWidth,
|
||||
height: height,
|
||||
data: processedGraphData,
|
||||
autoFit: 'view',
|
||||
autoFit: 'view' as const,
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
size: 50,
|
||||
lineWidth: 2,
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#1890FF',
|
||||
lineWidth: 4,
|
||||
halo: true,
|
||||
haloColor: 'rgba(24, 144, 255, 0.3)',
|
||||
haloLineWidth: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
@@ -116,104 +120,108 @@ export const GraphView = ({
|
||||
lineWidth: 1,
|
||||
endArrow: true,
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#1890FF',
|
||||
lineWidth: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
type: layout.type,
|
||||
nodeSize: 60,
|
||||
preventOverlap: true,
|
||||
linkDistance: 150,
|
||||
...FORCE_LAYOUT_CONFIG,
|
||||
},
|
||||
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
|
||||
behaviors: [
|
||||
...GRAPH_BEHAVIORS,
|
||||
{
|
||||
type: 'click-select',
|
||||
key: 'click-select',
|
||||
degree: 0,
|
||||
state: 'selected',
|
||||
multiple: false,
|
||||
animation: true,
|
||||
onClick: (event: any) => {
|
||||
console.log('[GraphView] Node click event triggered', event);
|
||||
console.log('[GraphView] event.target:', event.target);
|
||||
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
||||
|
||||
if (event.target && event.target.id) {
|
||||
const nodeId = event.target.id;
|
||||
console.log('[GraphView] Looking for node with id:', nodeId);
|
||||
console.log('[GraphView] Available nodes:', data.nodes.map((n: Node) => n.id));
|
||||
|
||||
const node = data.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('[GraphView] Found node:', nodeId, node);
|
||||
|
||||
if (node && onNodeClickRef.current) {
|
||||
console.log('[GraphView] Calling onNodeClick callback with node:', node);
|
||||
onNodeClickRef.current(node);
|
||||
} else {
|
||||
console.log('[GraphView] Failed: node not found or callback not available');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'drag-element-force',
|
||||
key: 'drag-element-force',
|
||||
state: 'selected',
|
||||
fixed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
console.log('🎨 GraphView - Creating graph with config:', graphConfig);
|
||||
|
||||
const graph = new Graph(graphConfig);
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
console.log('🎨 GraphView - Starting render');
|
||||
graph.render()
|
||||
.then(() => {
|
||||
console.log('🎨 GraphView - Graph rendered successfully');
|
||||
})
|
||||
.catch((renderError) => {
|
||||
console.error('🎨 GraphView - Render error:', renderError);
|
||||
});
|
||||
isCancelledRef.current = false;
|
||||
cleanupRef.current = () => {
|
||||
isCancelledRef.current = true;
|
||||
};
|
||||
|
||||
graph.on('node:click', (evt) => {
|
||||
console.log('🔗 G6 - Node clicked, evt:', evt);
|
||||
const nodeId = evt.itemId;
|
||||
console.log('🔗 G6 - Node ID:', nodeId);
|
||||
|
||||
if (nodeId && onNodeClick) {
|
||||
const nodeData = data.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('🔗 G6 - Found node data:', nodeData);
|
||||
if (nodeData) {
|
||||
console.log('🔗 G6 - Node properties:', nodeData.properties);
|
||||
onNodeClick(nodeData);
|
||||
} else {
|
||||
console.log('🔗 G6 - Node data not found for ID:', nodeId);
|
||||
}
|
||||
renderPromiseRef.current = graph.render().catch((renderError) => {
|
||||
if (!isCancelledRef.current) {
|
||||
console.error('Graph render error:', renderError);
|
||||
}
|
||||
});
|
||||
|
||||
graph.on('edge:click', (evt) => {
|
||||
if (evt.itemId && onEdgeClick) {
|
||||
const edgeData = data.edges.find((e: Edge) => e.id === evt.itemId);
|
||||
if (edgeData) {
|
||||
onEdgeClick(edgeData);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 画布点击事件(取消选择)
|
||||
graph.on('canvas:click', () => {
|
||||
console.log('[GraphView] Canvas click - deselecting node');
|
||||
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
||||
if (onNodeClickRef.current) {
|
||||
console.log('[GraphView] Calling onNodeClick with null');
|
||||
onNodeClickRef.current(null);
|
||||
}
|
||||
});
|
||||
|
||||
graph.on('node:mouseenter', (evt) => {
|
||||
if (evt.itemId && onNodeHover) {
|
||||
const nodeData = data.nodes.find((n: Node) => n.id === evt.itemId);
|
||||
if (nodeData) {
|
||||
onNodeHover(nodeData);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
graph.on('node:mouseleave', () => {
|
||||
if (onNodeHover) {
|
||||
onNodeHover(null);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing graph:', error);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (graphRef.current) {
|
||||
try {
|
||||
graphRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error destroying graph:', error);
|
||||
isCancelledRef.current = true;
|
||||
|
||||
if (renderPromiseRef.current) {
|
||||
renderPromiseRef.current.catch(() => {});
|
||||
renderPromiseRef.current = null;
|
||||
}
|
||||
|
||||
if (graphRef.current) {
|
||||
graphRef.current.off('canvas:click');
|
||||
graphRef.current.destroy();
|
||||
graphRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
height,
|
||||
layout.type,
|
||||
layout.nodeSize,
|
||||
layout.linkDistance,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeHover,
|
||||
data,
|
||||
]);
|
||||
}, [height, layout.type, data.id, onNodeClickRef]);
|
||||
|
||||
// 窗口大小调整响应
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (graphRef.current && containerRef.current) {
|
||||
try {
|
||||
graphRef.current.resize(containerRef.current.clientWidth, height);
|
||||
} catch (error) {
|
||||
console.error('Error resizing graph:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,7 +233,7 @@ export const GraphView = ({
|
||||
<div className="relative w-full h-full">
|
||||
<div ref={containerRef} className="w-full h-full" style={{ height: `${height}px` }} />
|
||||
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg shadow-lg p-3">
|
||||
<div className="text-xs text-gray-600 mb-2">布局类型: {layout.type}</div>
|
||||
<div className="text-xs text-gray-600 mb-2">布局: {layout.type}</div>
|
||||
<div className="text-xs text-gray-600">节点: {data.nodes.length} | 边: {data.edges.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface LegendData {
|
||||
type: string;
|
||||
colors: { fill: string; stroke: string };
|
||||
labels: { zh: string; en: string };
|
||||
}
|
||||
|
||||
interface LegendProps {
|
||||
legendData: LegendData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图例组件
|
||||
* 显示知识图谱中不同节点类型及其对应的颜色
|
||||
* 支持折叠/展开展示
|
||||
* @param {LegendData[]} legendData - 图例数据,包含类型、颜色和标签信息
|
||||
*/
|
||||
export const Legend = ({ legendData }: LegendProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
if (!legendData || legendData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-4 transition-all duration-300 hover:shadow-xl z-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700">图例</h3>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors focus:outline-none"
|
||||
aria-label={isExpanded ? '折叠图例' : '展开图例'}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-2">
|
||||
{legendData.map((item, index) => (
|
||||
<div
|
||||
key={item.type}
|
||||
className="flex items-center space-x-3 p-2 rounded hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex-shrink-0 shadow-sm"
|
||||
style={{
|
||||
backgroundColor: item.colors.fill,
|
||||
border: `1px solid ${item.colors.stroke}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium text-gray-800 truncate">
|
||||
{item.labels.zh}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-500 truncate">
|
||||
{item.labels.en}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Legend;
|
||||
@@ -5,22 +5,25 @@ interface NodePanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点详情面板组件
|
||||
* 展示选中节点的详细信息,包括属性和样式
|
||||
*/
|
||||
export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
if (!node) return null;
|
||||
|
||||
console.log('📋 NodePanel - Rendering node:', node);
|
||||
console.log('📋 NodePanel - Node properties:', node.properties);
|
||||
console.log('📋 NodePanel - Has properties:', !!node.properties);
|
||||
console.log('📋 NodePanel - Properties keys:', node.properties ? Object.keys(node.properties) : []);
|
||||
// 判断属性是否可展示
|
||||
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 shadow-lg overflow-y-auto">
|
||||
<div className="w-full overflow-y-auto">
|
||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="关闭面板"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -30,6 +33,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">节点 ID</div>
|
||||
<div className="font-mono text-sm bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
@@ -51,32 +55,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!node.properties && (
|
||||
{/* 属性部分:统一处理 undefined、null 和空对象 */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性</div>
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
暂无属性数据
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||
属性 {hasProperties && `(${Object.keys(node.properties!).length})`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 italic mt-1">
|
||||
Debug: Node ID: {node.id}, Has properties?: {node.properties ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.properties && Object.keys(node.properties).length === 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性</div>
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
属性对象存在但为空
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.properties && Object.keys(node.properties).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性 ({Object.keys(node.properties).length})</div>
|
||||
{hasProperties ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(node.properties).map(([key, value]) => (
|
||||
{Object.entries(node.properties!).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between items-start">
|
||||
<div className="text-sm text-gray-600">{key}</div>
|
||||
<div className="text-sm font-medium text-gray-800 text-right max-w-[50%] break-all">
|
||||
@@ -85,9 +71,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
暂无属性数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 样式信息 */}
|
||||
{node.style && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">样式</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import type { SearchNode } from '../../types/graph';
|
||||
import { SEARCH_RESULT_MAX_HEIGHT } from '../../config/constants';
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch?: (query: string) => void;
|
||||
@@ -8,6 +9,10 @@ interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索栏组件
|
||||
* 支持实时搜索和结果展示
|
||||
*/
|
||||
export const SearchBar = ({
|
||||
onSearch,
|
||||
onNodeSelect,
|
||||
@@ -17,6 +22,7 @@ export const SearchBar = ({
|
||||
const [query, setQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
@@ -28,6 +34,7 @@ export const SearchBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件(Enter 确认搜索)
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (onSearch && query.trim()) {
|
||||
@@ -37,6 +44,7 @@ export const SearchBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 选择搜索结果中的节点
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(nodeId);
|
||||
@@ -63,6 +71,7 @@ export const SearchBar = ({
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -73,13 +82,16 @@ export const SearchBar = ({
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果下拉列表 */}
|
||||
{showResults && searchResults.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
|
||||
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto" style={{ maxHeight: `${SEARCH_RESULT_MAX_HEIGHT}px` }}>
|
||||
{searchResults.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node.id)}
|
||||
className="px-4 py-2 hover:bg-purple-50 cursor-pointer border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium text-gray-800">{node.label}</div>
|
||||
{node.type && (
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Node, GraphData } from '../types/graph';
|
||||
|
||||
interface ColorConfig {
|
||||
fill: string;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
interface NodeTypeInfo {
|
||||
type: string;
|
||||
colors: ColorConfig;
|
||||
labels: { zh: string; en: string };
|
||||
}
|
||||
|
||||
// 预定义颜色池 - 12种可区分的颜色对
|
||||
const COLOR_POOL: ColorConfig[] = [
|
||||
{ fill: '#4ECDC4', stroke: '#45B7B0' },
|
||||
{ fill: '#FFA07A', stroke: '#FF7F50' },
|
||||
{ fill: '#2ECC71', stroke: '#27AE60' },
|
||||
{ fill: '#FF6B9D', stroke: '#E84393' },
|
||||
{ fill: '#F39C12', stroke: '#D68910' },
|
||||
{ fill: '#9B59B6', stroke: '#8E44AD' },
|
||||
{ fill: '#3498DB', stroke: '#2980B9' },
|
||||
{ fill: '#1ABC9C', stroke: '#16A085' },
|
||||
{ fill: '#E74C3C', stroke: '#C0392B' },
|
||||
{ fill: '#607D8B', stroke: '#455A64' },
|
||||
{ fill: '#FF9800', stroke: '#F57C00' },
|
||||
{ fill: '#3F51B5', stroke: '#303F9F' },
|
||||
];
|
||||
|
||||
// 默认节点颜色(当类型未匹配时)
|
||||
export const DEFAULT_NODE_COLORS: ColorConfig = { fill: '#C6E5FF', stroke: '#5B8FF9' };
|
||||
|
||||
// 节点类型双语标签映射
|
||||
export const NODE_TYPE_LABELS: Record<string, { zh: string; en: string }> = {
|
||||
'概念': { zh: '概念', en: 'Concept' },
|
||||
'工具': { zh: '工具', en: 'Tool' },
|
||||
'应用': { zh: '应用', en: 'Application' },
|
||||
'属性': { zh: '属性', en: 'Attribute' },
|
||||
'实体': { zh: '实体', en: 'Entity' },
|
||||
'关系': { zh: '关系', en: 'Relation' },
|
||||
'方法': { zh: '方法', en: 'Method' },
|
||||
'函数': { zh: '函数', en: 'Function' },
|
||||
'类': { zh: '类', en: 'Class' },
|
||||
'模块': { zh: '模块', en: 'Module' },
|
||||
};
|
||||
|
||||
// 动态颜色分配缓存
|
||||
const colorAssignmentCache: Map<string, ColorConfig> = new Map();
|
||||
let colorPoolIndex = 0;
|
||||
|
||||
/**
|
||||
* 从颜色池中获取或分配颜色
|
||||
* @param type 节点类型
|
||||
* @returns 颜色配置对象
|
||||
*/
|
||||
const getOrAssignColor = (type: string): ColorConfig => {
|
||||
if (!type) return DEFAULT_NODE_COLORS;
|
||||
|
||||
// 从缓存中查找
|
||||
if (colorAssignmentCache.has(type)) {
|
||||
return colorAssignmentCache.get(type)!;
|
||||
}
|
||||
|
||||
// 从颜色池中分配新颜色
|
||||
const colorConfig = COLOR_POOL[colorPoolIndex % COLOR_POOL.length];
|
||||
colorAssignmentCache.set(type, colorConfig);
|
||||
colorPoolIndex++;
|
||||
|
||||
return colorConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置颜色分配缓存
|
||||
* 用于重新加载图谱数据时清除之前的分配
|
||||
*/
|
||||
export const resetColorAssignment = (): void => {
|
||||
colorAssignmentCache.clear();
|
||||
colorPoolIndex = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从图谱数据中提取所有唯一的节点类型
|
||||
* @param graphData 图谱数据
|
||||
* @returns 唯一的节点类型数组
|
||||
*/
|
||||
export const extractNodeTypes = (graphData: GraphData): string[] => {
|
||||
const typeSet = new Set<string>();
|
||||
graphData.nodes.forEach((node) => {
|
||||
if (node.type) {
|
||||
typeSet.add(node.type);
|
||||
}
|
||||
});
|
||||
return Array.from(typeSet);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成图例数据
|
||||
* @param graphData 图谱数据
|
||||
* @returns 图例数据数组
|
||||
*/
|
||||
export const generateLegendData = (graphData: GraphData): NodeTypeInfo[] => {
|
||||
const nodeTypes = extractNodeTypes(graphData);
|
||||
|
||||
return nodeTypes.map((type) => {
|
||||
const colors = getOrAssignColor(type);
|
||||
const labels = NODE_TYPE_LABELS[type] || { zh: type, en: type };
|
||||
|
||||
return {
|
||||
type,
|
||||
colors,
|
||||
labels,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点颜色配置
|
||||
* @param node 节点对象
|
||||
* @returns 颜色配置对象
|
||||
*/
|
||||
export const getNodeColors = (node: Node): ColorConfig => {
|
||||
if (!node.type) return DEFAULT_NODE_COLORS;
|
||||
return getOrAssignColor(node.type);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点双语标签
|
||||
* @param type 节点类型
|
||||
* @returns 双语标签对象
|
||||
*/
|
||||
export const getNodeLabels = (type: string): { zh: string; en: string } => {
|
||||
return NODE_TYPE_LABELS[type] || { zh: type, en: type };
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// API 相关常量
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
||||
export const API_TIMEOUT = 10000;
|
||||
|
||||
// 图谱渲染配置
|
||||
export const GRAPH_DEFAULT_HEIGHT = 600;
|
||||
|
||||
// 搜索相关常量
|
||||
export const SEARCH_RESULT_MAX_HEIGHT = 256; // 16rem
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
|
||||
// 力导向布局默认配置
|
||||
export const FORCE_LAYOUT_CONFIG = {
|
||||
type: 'd3-force' as const,
|
||||
collide: { radius: 60 } as const,
|
||||
link: { distance: 150 } as const,
|
||||
};
|
||||
|
||||
// 默认布局配置
|
||||
export const DEFAULT_LAYOUT: GraphLayoutConfig = FORCE_LAYOUT_CONFIG;
|
||||
|
||||
// 图谱交互行为配置
|
||||
export const GRAPH_BEHAVIORS = ['drag-canvas', 'zoom-canvas', 'drag-element-force'];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GraphData } from '../types/graph';
|
||||
|
||||
/**
|
||||
* 示例知识图谱数据
|
||||
* 用于开发、演示或后端返回空数据时的展示
|
||||
*/
|
||||
export const SAMPLE_GRAPH_DATA: GraphData = {
|
||||
nodes: [
|
||||
{ id: '1', label: '人工智能', type: '概念', properties: { importance: 1 } },
|
||||
{ id: '2', label: '机器学习', type: '概念', properties: { importance: 0.8 } },
|
||||
{ id: '3', label: '深度学习', type: '概念', properties: { importance: 0.7 } },
|
||||
{ id: '4', label: '神经网络', type: '概念', properties: { importance: 0.6 } },
|
||||
{ id: '5', label: 'TensorFlow', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '6', label: 'PyTorch', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '7', label: '自然语言处理', type: '应用', properties: { importance: 0.6 } },
|
||||
{ id: '8', label: '计算机视觉', type: '应用', properties: { importance: 0.6 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', label: '包含' },
|
||||
{ id: 'e2', source: '2', target: '3', label: '子领域' },
|
||||
{ id: 'e3', source: '3', target: '4', label: '基于' },
|
||||
{ id: 'e4', source: '3', target: '5', label: '框架' },
|
||||
{ id: 'e5', source: '3', target: '6', label: '框架' },
|
||||
{ id: 'e6', source: '1', target: '7', label: '应用' },
|
||||
{ id: 'e7', source: '1', target: '8', label: '应用' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { GraphData, Node, SearchNode } from '../types/graph';
|
||||
import { graphApi } from '../services/graphApi';
|
||||
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
||||
import { resetColorAssignment } from '../config/colors';
|
||||
|
||||
/**
|
||||
* 知识图谱数据管理 Hook
|
||||
* 负责图谱数据的加载、搜索和节点选择
|
||||
*/
|
||||
export const useGraphData = () => {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 从后端加载图谱数据
|
||||
const loadGraphData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await graphApi.getData();
|
||||
|
||||
// 重置颜色分配缓存,确保每次加载数据时颜色分配一致
|
||||
resetColorAssignment();
|
||||
|
||||
// 如果后端返回空数据,使用示例数据
|
||||
if (data.nodes.length === 0) {
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` });
|
||||
} else {
|
||||
setGraphData({ ...data, id: `data-${Date.now()}` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load graph data:', error);
|
||||
// 重置颜色分配缓存
|
||||
resetColorAssignment();
|
||||
// API 失败时使用示例数据作为降级方案
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 搜索节点(前端过滤,可根据需要切换到后端搜索)
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
const results = graphData.nodes
|
||||
.filter((node: Node) =>
|
||||
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
node.id.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.map((node: Node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
matched: true,
|
||||
}));
|
||||
setSearchResults(results);
|
||||
}, [graphData]);
|
||||
|
||||
// 通过 ID 选择节点(用于搜索结果点击)
|
||||
const handleNodeSelectById = useCallback((nodeId: string) => {
|
||||
console.log('[useGraphData] handleNodeSelectById called with id:', nodeId);
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('[useGraphData] Found node:', node);
|
||||
if (node) {
|
||||
console.log('[useGraphData] Setting selectedNode:', node);
|
||||
setSelectedNode(node);
|
||||
} else {
|
||||
console.log('[useGraphData] Node not found with id:', nodeId);
|
||||
}
|
||||
}, [graphData.nodes]);
|
||||
|
||||
// 选择节点(用于图谱点击)
|
||||
const handleNodeSelect = useCallback((node: Node | null) => {
|
||||
console.log('[useGraphData] handleNodeSelect called with node:', node);
|
||||
console.log('[useGraphData] About to call setSelectedNode');
|
||||
setSelectedNode(node);
|
||||
console.log('[useGraphData] setSelectedNode called');
|
||||
}, []);
|
||||
|
||||
// 监听 selectedNode 变化,用于调试
|
||||
useEffect(() => {
|
||||
console.log('[useGraphData] selectedNode state changed:', selectedNode);
|
||||
console.log('[useGraphData] selectedNode truthiness:', !!selectedNode);
|
||||
}, [selectedNode]);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadGraphData();
|
||||
}, [loadGraphData]);
|
||||
|
||||
return {
|
||||
graphData,
|
||||
selectedNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
|
||||
/**
|
||||
* 知识图谱布局管理 Hook
|
||||
* 返回固定的默认布局配置
|
||||
*/
|
||||
export const useGraphLayout = () => {
|
||||
return {
|
||||
layout: {
|
||||
type: 'd3-force' as const,
|
||||
collide: { radius: 60 } as const,
|
||||
link: { distance: 150 } as const,
|
||||
} as GraphLayoutConfig,
|
||||
};
|
||||
};
|
||||
@@ -1,97 +1,40 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import GraphView from '../components/GraphView/index';
|
||||
import SearchBar from '../components/SearchBar/index';
|
||||
import NodePanel from '../components/NodePanel/index';
|
||||
import type { GraphData, Node, SearchNode, GraphLayoutConfig } from '../types/graph';
|
||||
import { graphApi } from '../services/graphApi';
|
||||
import Legend from '../components/Legend/index';
|
||||
import { useGraphData } from '../hooks/useGraphData';
|
||||
import { useGraphLayout } from '../hooks/useGraphLayout';
|
||||
import { generateLegendData } from '../config/colors';
|
||||
|
||||
/**
|
||||
* 知识图谱主页面组件
|
||||
* 功能:
|
||||
* - 展示交互式知识图谱
|
||||
* - 支持节点搜索和选择
|
||||
* - 支持查看节点详情
|
||||
*/
|
||||
export const KnowledgeGraph = () => {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<Node | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchNode[]>([]);
|
||||
const [layout, setLayout] = useState<GraphLayoutConfig>({ type: 'force' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
graphData,
|
||||
selectedNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
} = useGraphData();
|
||||
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
const results = graphData.nodes
|
||||
.filter((node: Node) =>
|
||||
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
node.id.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.map((node: Node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
matched: true,
|
||||
}));
|
||||
setSearchResults(results);
|
||||
const { layout } = useGraphLayout();
|
||||
|
||||
const legendData = useMemo(() => {
|
||||
if (!graphData || graphData.nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return generateLegendData(graphData);
|
||||
}, [graphData]);
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string) => {
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
}, [graphData.nodes]);
|
||||
|
||||
const handleNodeClick = useCallback((node: Node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
const handleLayoutChange = useCallback((newLayout: GraphLayoutConfig['type']) => {
|
||||
setLayout({ type: newLayout });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGraphData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await graphApi.getData();
|
||||
|
||||
console.log('🎯 KnowledgeGraph - Received data:', data);
|
||||
console.log('🎯 KnowledgeGraph - Node count:', data.nodes.length);
|
||||
console.log('🎯 KnowledgeGraph - First node properties:', data.nodes[0]?.properties);
|
||||
|
||||
// 添加一些示例数据,如果 API 返回空数据
|
||||
if (data.nodes.length === 0) {
|
||||
console.log('⚠️ API returned empty data, using sample data');
|
||||
const sampleData: GraphData = {
|
||||
nodes: [
|
||||
{ id: '1', label: '人工智能', type: '概念', properties: { importance: 1 } },
|
||||
{ id: '2', label: '机器学习', type: '概念', properties: { importance: 0.8 } },
|
||||
{ id: '3', label: '深度学习', type: '概念', properties: { importance: 0.7 } },
|
||||
{ id: '4', label: '神经网络', type: '概念', properties: { importance: 0.6 } },
|
||||
{ id: '5', label: 'TensorFlow', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '6', label: 'PyTorch', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '7', label: '自然语言处理', type: '应用', properties: { importance: 0.6 } },
|
||||
{ id: '8', label: '计算机视觉', type: '应用', properties: { importance: 0.6 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', label: '包含' },
|
||||
{ id: 'e2', source: '2', target: '3', label: '子领域' },
|
||||
{ id: 'e3', source: '3', target: '4', label: '基于' },
|
||||
{ id: 'e4', source: '3', target: '5', label: '框架' },
|
||||
{ id: 'e5', source: '3', target: '6', label: '框架' },
|
||||
{ id: 'e6', source: '1', target: '7', label: '应用' },
|
||||
{ id: 'e7', source: '1', target: '8', label: '应用' },
|
||||
],
|
||||
};
|
||||
setGraphData(sampleData);
|
||||
} else {
|
||||
console.log('✅ Setting graph data from API');
|
||||
setGraphData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load graph data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGraphData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col bg-gray-50">
|
||||
{loading ? (
|
||||
@@ -103,27 +46,15 @@ export const KnowledgeGraph = () => {
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-xl font-semibold text-gray-800">知识图谱</h1>
|
||||
<div className="flex space-x-2">
|
||||
{(['force', 'circular', 'grid', 'radial'] as const).map((layoutType) => (
|
||||
<button
|
||||
key={layoutType}
|
||||
onClick={() => handleLayoutChange(layoutType)}
|
||||
className={`px-3 py-1 rounded text-sm transition-colors ${
|
||||
layout.type === layoutType
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{layoutType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
{layout.type} 布局
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeSelect={handleNodeSelectById}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
</div>
|
||||
@@ -131,32 +62,23 @@ export const KnowledgeGraph = () => {
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<main className="flex-1 bg-white relative">
|
||||
{legendData.length > 0 && <Legend legendData={legendData} />}
|
||||
<GraphView
|
||||
data={graphData}
|
||||
layout={layout}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={setHoveredNode}
|
||||
onNodeClick={handleNodeSelect}
|
||||
/>
|
||||
|
||||
{hoveredNode && (
|
||||
<div className="absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-3 max-w-xs">
|
||||
<div className="font-medium text-gray-800">{hoveredNode.label}</div>
|
||||
{hoveredNode.type && (
|
||||
<div className="text-sm text-gray-500">{hoveredNode.type}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<aside className="w-80 bg-white border-l border-gray-200">
|
||||
<div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
|
||||
<NodePanel
|
||||
node={selectedNode}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,59 +1,52 @@
|
||||
import axios from 'axios';
|
||||
import type { GraphData, Node, Edge } from '../types/graph';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
import type { GraphData, Node } from '../types/graph';
|
||||
import { API_BASE_URL, API_TIMEOUT } from '../config/constants';
|
||||
|
||||
/**
|
||||
* API 客户端实例
|
||||
* 统一配置了 base URL、超时时间和默认请求头
|
||||
*/
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
timeout: API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 知识图谱 API 服务
|
||||
* 提供与后端交互的所有图谱相关接口
|
||||
*/
|
||||
export const graphApi = {
|
||||
/**
|
||||
* 获取完整的知识图谱数据
|
||||
* @returns 节点和边的集合
|
||||
*/
|
||||
async getData(): Promise<GraphData> {
|
||||
try {
|
||||
const response = await apiClient.get<GraphData>('/graph');
|
||||
console.log('🔍 API Response - Graph Data:', JSON.stringify(response.data, null, 2));
|
||||
console.log('🔍 API Response - First Node:', response.data.nodes[0]);
|
||||
console.log('🔍 API Response - First Node Properties:', response.data.nodes[0]?.properties);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching graph data:', error);
|
||||
console.error('Failed to fetch graph data:', error);
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
},
|
||||
|
||||
async searchNodes(query: string): Promise<Node[]> {
|
||||
try {
|
||||
const response = await apiClient.get<Node[]>(`/search?q=${encodeURIComponent(query)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error searching nodes:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据 ID 获取节点详情
|
||||
* @param id 节点 ID
|
||||
* @returns 节点对象或 null
|
||||
*/
|
||||
async getNodeById(id: string): Promise<Node | null> {
|
||||
try {
|
||||
const response = await apiClient.get<Node>(`/nodes/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching node:', error);
|
||||
console.error('Failed to fetch node:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getNeighbors(nodeId: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||
try {
|
||||
const response = await apiClient.get(`/nodes/${nodeId}/neighbors`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching neighbors:', error);
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 知识图谱数据类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图谱节点
|
||||
* 表示知识图谱中的一个实体或概念
|
||||
*/
|
||||
export interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -13,6 +21,10 @@ export interface Node {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱边
|
||||
* 表示节点之间的关系
|
||||
*/
|
||||
export interface Edge {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -27,18 +39,29 @@ export interface Edge {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱数据
|
||||
* 包含所有节点和边的集合
|
||||
*/
|
||||
export interface GraphData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱布局配置
|
||||
* 支持 d3-force 力导向布局
|
||||
*/
|
||||
export interface GraphLayoutConfig {
|
||||
type: 'force' | 'circular' | 'grid' | 'radial';
|
||||
nodeSize?: number;
|
||||
linkDistance?: number;
|
||||
center?: [number, number];
|
||||
type: 'd3-force';
|
||||
collide?: { radius: number; strength?: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果节点
|
||||
* 用于搜索功能展示的简化节点信息
|
||||
*/
|
||||
export interface SearchNode {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
export const debounce = <T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
timeout = setTimeout(() => func(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
export const throttle = <T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
limit: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let inThrottle: boolean = false;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const generateUUID = (): string => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
export const calculateNodeSize = (importance: number = 1): number => {
|
||||
const baseSize = 40;
|
||||
const multiplier = importance * 20;
|
||||
return baseSize + multiplier;
|
||||
};
|
||||
Reference in New Issue
Block a user