Compare commits

...
7 Commits
14 changed files with 682 additions and 398 deletions
+178 -170
View File
@@ -1,219 +1,227 @@
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) {
graphRef.current.destroy();
graphRef.current = null;
}
if (graphRef.current) {
try {
graphRef.current.destroy();
} catch (e) {
console.log('🎨 GraphView - Destroy error:', e);
}
graphRef.current = null;
}
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' };
return {
// 转换数据格式以适配 G6
const processedGraphData = {
nodes: data.nodes.map((node: Node) => {
const colors = getNodeColors(node);
return {
id: node.id,
data: {
id: node.id,
data: {
id: node.id,
label: node.label,
type: node.type,
properties: node.properties,
style: node.style,
},
style: {
fill: colors.fill,
stroke: colors.stroke,
lineWidth: 2,
labelText: node.label,
labelFill: '#2C3E50',
labelFontSize: 14,
labelFontWeight: 'bold',
labelPlacement: 'bottom',
},
};
}),
edges: data.edges.map((edge: Edge) => ({
id: edge.id,
label: node.label,
type: node.type,
properties: node.properties,
style: node.style,
},
style: {
fill: colors.fill,
stroke: colors.stroke,
lineWidth: 2,
labelText: node.label,
labelFill: '#2C3E50',
labelFontSize: 14,
labelFontWeight: 'bold' as const,
labelPlacement: 'bottom' as const,
},
};
}),
edges: data.edges.map((edge: Edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
label: edge.label,
data: {
source: edge.source,
target: edge.target,
label: edge.label,
data: {
source: edge.source,
target: edge.target,
label: edge.label,
type: edge.type,
properties: edge.properties,
},
style: {
stroke: '#95A5A6',
lineWidth: 2,
labelText: edge.label,
labelFill: '#666',
labelFontSize: 10,
},
})),
};
type: edge.type,
properties: edge.properties,
},
style: {
stroke: '#95A5A6',
lineWidth: 2,
labelText: edge.label,
labelFill: '#666',
labelFontSize: 10,
},
})),
};
console.log('🎨 GraphView - Processed graph data nodes:', processedGraphData.nodes.length);
console.log('🎨 GraphView - Processed graph data edges:', processedGraphData.edges.length);
const graphConfig: any = {
container: containerRef.current,
width: containerRef.current.clientWidth,
height: height,
data: processedGraphData,
autoFit: 'view',
node: {
type: 'circle',
style: {
size: 50,
lineWidth: 2,
// 创建图谱配置
const graphConfig = {
container: containerRef.current,
width: containerRef.current.clientWidth,
height: height,
data: processedGraphData,
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',
style: {
lineWidth: 1,
endArrow: true,
},
edge: {
type: 'line',
style: {
lineWidth: 1,
endArrow: true,
},
state: {
selected: {
stroke: '#1890FF',
lineWidth: 3,
},
},
layout: {
type: layout.type,
nodeSize: 60,
preventOverlap: true,
linkDistance: 150,
},
layout: {
...FORCE_LAYOUT_CONFIG,
},
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');
}
}
},
},
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
};
{
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);
const graph = new Graph(graphConfig);
graphRef.current = graph;
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('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);
}
// 画布点击事件(取消选择)
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);
}
});
// 清理函数
return () => {
isCancelledRef.current = true;
if (renderPromiseRef.current) {
renderPromiseRef.current.catch(() => {});
renderPromiseRef.current = null;
}
if (graphRef.current) {
try {
graphRef.current.destroy();
} catch (error) {
console.error('Error destroying graph:', error);
}
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);
}
graphRef.current.resize(containerRef.current.clientWidth, height);
}
};
@@ -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>
+78
View File
@@ -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;
+22 -31
View File
@@ -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 && (
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性</div>
<div className="text-sm text-gray-400 italic">
暂无属性数据
</div>
<div className="text-xs text-gray-300 italic mt-1">
Debug: Node ID: {node.id}, Has properties?: {node.properties ? 'Yes' : 'No'}
</div>
{/* 属性部分:统一处理 undefined、null 和空对象 */}
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
属性 {hasProperties && `(${Object.keys(node.properties!).length})`}
</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>
)}
) : (
<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>
+13 -1
View File
@@ -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 && (
+133
View File
@@ -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 };
};
+9
View File
@@ -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
+14
View File
@@ -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'];
+27
View File
@@ -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: '应用' },
],
};
+101
View File
@@ -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,
};
};
+15
View File
@@ -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,
};
};
+43 -121
View File
@@ -1,96 +1,39 @@
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);
}, [graphData]);
const { layout } = useGraphLayout();
const handleNodeSelect = useCallback((nodeId: string) => {
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
if (node) {
setSelectedNode(node);
const legendData = useMemo(() => {
if (!graphData || graphData.nodes.length === 0) {
return [];
}
}, [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 generateLegendData(graphData);
}, [graphData]);
return (
<div className="w-full h-screen flex flex-col bg-gray-50">
@@ -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>
{selectedNode && (
<aside className="w-80 bg-white border-l border-gray-200">
<NodePanel
node={selectedNode}
onClose={() => setSelectedNode(null)}
/>
</aside>
)}
</div>
{selectedNode && (
<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)}
/>
</div>
)}
</>
)}
</div>
+22 -29
View File
@@ -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;
+27 -4
View File
@@ -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;
-42
View File
@@ -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;
};