import React, { useState, useRef, useEffect } from 'react';
import { Search, Network, History, Clock, ZoomIn, ZoomOut, Maximize, ChevronRight, X, RefreshCw, Database, Tag, GitFork, Route, ArrowRight } from 'lucide-react';
import ForceGraph2D from 'react-force-graph-2d';
import useMeasure from 'react-use-measure';
// @ts-ignore - d3-force types may not be available
import { forceCollide } from 'd3-force';
interface Entity {
uuid: string;
name: string;
type: string;
[key: string]: any;
}
interface Episode {
uuid: string;
content: string;
timestamp: string;
group_id?: string;
}
interface GraphNode {
id: string;
name: string;
val: number;
color: string;
group: string;
entity: any;
x?: number;
y?: number;
}
interface GraphLink {
source: string | GraphNode;
target: string | GraphNode;
label: string;
color: string;
}
interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
}
interface MetaInfo {
labels: Array<{name: string, count: number}>;
relationshipTypes: Array<{name: string, count: number}>;
database: {
name: string;
version: string;
edition: string;
user: string;
};
}
export default function GraphExplorer() {
const [viewMode, setViewMode] = useState<'search' | 'history' | 'meta' | 'path'>('search');
// UI State
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [rightPanelOpen, setRightPanelOpen] = useState(true);
// Data State
const [query, setQuery] = useState('');
const [results, setResults] = useState<Entity[]>([]);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [metaInfo, setMetaInfo] = useState<MetaInfo | null>(null);
const [loadingHistory, setLoadingHistory] = useState(false);
const [loadingLabelGraph, setLoadingLabelGraph] = useState(false);
const [searchExecuted, setSearchExecuted] = useState(false);
const [activeLabel, setActiveLabel] = useState<string | null>(null);
const [pathInfo, setPathInfo] = useState<string | null>(null);
// Path Finding State
const [startNode, setStartNode] = useState<Entity | null>(null);
const [endNode, setEndNode] = useState<Entity | null>(null);
const [pathFound, setPathFound] = useState(false);
const [selectedItem, setSelectedItem] = useState<Entity | Episode | null>(null);
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], links: [] });
const [cursor, setCursor] = useState('default');
const [error, setError] = useState<string | null>(null);
const graphRef = useRef<any>();
const [containerRef, bounds] = useMeasure();
// 用于取消正在进行的请求,避免竞态条件
const abortControllerRef = useRef<AbortController | null>(null);
// --- Data Fetching ---
const handleSearch = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!query.trim()) {
setResults([]);
setSearchExecuted(false);
return;
}
try {
const res = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error('搜索失败');
setResults(await res.json());
setSearchExecuted(true);
setError(null);
} catch (err) {
console.error(err);
setError('搜索失败,请检查 Neo4j 连接或稍后重试。');
}
};
const fetchEntityGraph = async (entity: Entity) => {
const uuid = entity.uuid || (entity as any).element_id;
if (!uuid) return;
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const res = await fetch(`/api/neighbors?uuid=${uuid}`, {
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error('加载邻居节点失败');
const neighbors = await res.json();
// 验证返回的数据
if (!Array.isArray(neighbors)) {
throw new Error('返回的数据格式不正确');
}
const nodes = new Map<string, GraphNode>();
const links: GraphLink[] = [];
nodes.set(uuid, {
id: uuid,
name: entity.name || entity.content?.slice(0, 20) || 'Unknown',
val: 20,
color: '#60a5fa', // blue-400
group: 'center',
entity: entity
});
neighbors.forEach((n: any, i: number) => {
// 验证邻居数据
if (!n || !n.neighbor) {
console.warn('Invalid neighbor data:', n);
return;
}
const nId = n.neighbor.uuid || `n-${i}`;
if (!nodes.has(nId)) {
nodes.set(nId, {
id: nId,
name: n.neighbor.name || n.neighbor.content || (n.neighbor.label && Array.isArray(n.neighbor.label) && n.neighbor.label[0]) || 'Unknown',
val: 10,
color: n.direction === 'OUT' ? '#4ade80' : '#fb923c', // green-400 / orange-400
group: 'neighbor',
entity: n.neighbor
});
}
// 验证关系类型
if (n.type) {
links.push({
source: n.direction === 'OUT' ? uuid : nId,
target: n.direction === 'OUT' ? nId : uuid,
label: String(n.type),
color: '#cbd5e1'
});
}
});
setGraphData({ nodes: Array.from(nodes.values()), links });
setError(null);
// 自动缩放由统一的 useEffect 处理,避免重复缩放导致视角跳动
} catch (err: any) {
// 如果是取消请求,不显示错误
if (err.name === 'AbortError') return;
console.error(err);
setError('加载邻居节点失败,请检查 Neo4j 连接。');
// 清空图谱数据,避免显示旧数据
setGraphData({ nodes: [], links: [] });
}
};
const fetchHistory = async () => {
setLoadingHistory(true);
try {
const res = await fetch('/api/episodes/list');
if (!res.ok) throw new Error('加载历史记录失败');
setEpisodes(await res.json());
setError(null);
} catch (err) {
console.error(err);
setError('加载历史 Episode 失败,请检查 Neo4j 连接。');
}
finally { setLoadingHistory(false); }
};
const fetchMeta = async () => {
try {
const res = await fetch('/api/meta');
if (!res.ok) throw new Error('加载数据库信息失败');
setMetaInfo(await res.json());
setError(null);
} catch (err) {
console.error(err);
setError('加载数据库元信息失败,请检查 Neo4j 连接。');
}
};
const fetchPath = async () => {
if (!startNode || !endNode) return;
const startId = startNode.uuid || (startNode as any).element_id;
const endId = endNode.uuid || (endNode as any).element_id;
// 检查起点和终点是否相同
if (startId === endId) {
setPathFound(false);
setGraphData({ nodes: [], links: [] });
setPathInfo('起点和终点不能是同一个节点。');
setError(null);
return;
}
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const res = await fetch(`/api/graph/path?start_uuid=${startId}&end_uuid=${endId}`, {
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error('路径查询失败');
const data = await res.json();
// 验证返回的数据
if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.links)) {
throw new Error('返回的数据格式不正确');
}
if (data.nodes.length === 0) {
setPathFound(false);
setGraphData({ nodes: [], links: [] });
setPathInfo('未找到路径,请检查起点/终点是否在同一连通子图中。');
return;
}
const nodes = data.nodes
.filter((n: any) => n && n.id) // 过滤无效节点
.map((n: any) => ({
...n,
val: 15,
color: n.id === startId ? '#22c55e' : n.id === endId ? '#ef4444' : '#eab308', // Start:Green, End:Red, Path:Yellow
entity: n.entity || n
}));
const links = data.links
.filter((l: any) => l && l.source && l.target) // 过滤无效连接
.map((l: any) => l);
setGraphData({ nodes, links });
setPathFound(true);
setPathInfo(`已找到从起点到终点的最短路径(节点数:${nodes.length})`);
setError(null);
// Auto zoom
setTimeout(() => {
if (graphRef.current) graphRef.current.zoomToFit(600, 100);
}, 1000);
} catch (err: any) {
// 如果是取消请求,不显示错误
if (err.name === 'AbortError') return;
console.error(err);
setPathFound(false);
setPathInfo('路径查询失败,请检查图谱连接与数据。');
setError('路径查询失败,请检查图谱连接与数据。');
// 清空图谱数据,避免显示旧数据
setGraphData({ nodes: [], links: [] });
}
};
const fetchEpisodeGraph = async (episode: Episode) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
// episode 既可能来自左侧历史列表(有 uuid),也可能来自图谱节点(只有 id 或嵌套 entity)
const raw: any = episode;
const episodeId = raw.uuid || raw.id || raw.element_id || raw.entity?.uuid || raw.entity?.id;
if (!episodeId) return;
const res = await fetch(`/api/episodes/${encodeURIComponent(episodeId)}/graph`, {
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error('加载 Episode 关联图失败');
const data = await res.json();
// 验证返回的数据
if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.links)) {
throw new Error('返回的数据格式不正确');
}
const nodes = data.nodes
.filter((n: any) => n && n.id) // 过滤无效节点
.map((n: any) => ({
...n,
val: n.type === 'Episode' ? 25 : 12,
color: n.type === 'Episode' ? '#a78bfa' : '#60a5fa', // violet-400 / blue-400
entity: n
}));
const links = data.links
.filter((l: any) => l && l.source && l.target) // 过滤无效连接
.map((l: any) => ({...l, color: '#cbd5e1'}));
setGraphData({ nodes, links });
setError(null);
// 自动缩放由统一的 useEffect 处理,避免重复缩放导致视角跳动
} catch (err: any) {
// 如果是取消请求,不显示错误
if (err.name === 'AbortError') return;
console.error(err);
setError('加载 Episode 关联图失败,请检查 Neo4j 连接。');
// 清空图谱数据,避免显示旧数据
setGraphData({ nodes: [], links: [] });
}
};
const fetchLabelGraph = async (label: string) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
setLoadingLabelGraph(true);
setActiveLabel(label);
const res = await fetch(`/api/graph/by-label?label=${encodeURIComponent(label)}&limit=50`, {
signal: abortControllerRef.current.signal
});
if (!res.ok) throw new Error('加载标签图谱失败');
const data = await res.json();
// 验证返回的数据
if (!data || !Array.isArray(data.nodes) || !Array.isArray(data.links)) {
throw new Error('返回的数据格式不正确');
}
const nodes = data.nodes
.filter((n: any) => n && n.id) // 过滤无效节点
.map((n: any) => ({
...n,
val: n.group === 'center' ? 18 : 10,
color: n.group === 'center' ? '#0ea5e9' : '#38bdf8', // sky-500 / sky-400
}));
let links = data.links
.filter((l: any) => l && l.source && l.target) // 过滤无效连接
.map((l: any) => ({ ...l, color: '#cbd5e1' }));
// Neo4j Browser 风格:如果节点之间没有连接,创建虚拟连接网络
// 让所有节点像锁链一样连接,实现"锁链+磁铁"效果
if (links.length === 0 && nodes.length > 1) {
// 创建最小生成树连接(每个节点至少连接到一个其他节点)
// 对于少量节点(<=5),创建完全连接图
if (nodes.length <= 5) {
// 完全连接:每个节点连接到其他所有节点
links = [];
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
links.push({
source: nodes[i].id,
target: nodes[j].id,
color: 'rgba(203, 213, 225, 0.3)', // 半透明的灰色,表示虚拟连接
virtual: true, // 标记为虚拟连接
distance: nodes.length <= 3 ? 400 : nodes.length <= 5 ? 350 : 300 // 直接在 link 上设置距离
});
}
}
} else {
// 对于更多节点,创建星形连接(一个中心节点连接到其他所有节点)
const centerNode = nodes[0];
links = [];
for (let i = 1; i < nodes.length; i++) {
links.push({
source: centerNode.id,
target: nodes[i].id,
color: 'rgba(203, 213, 225, 0.3)',
virtual: true,
distance: 300 // 直接在 link 上设置距离
});
}
}
}
setGraphData({ nodes, links });
setSelectedItem(null);
setError(null);
// 自动缩放由统一的 useEffect 处理,避免重复缩放导致视角跳动
} catch (err: any) {
// 如果是取消请求,不显示错误,但需要清理加载状态
if (err.name === 'AbortError') {
setLoadingLabelGraph(false);
return;
}
console.error(err);
setError('加载标签图谱失败,请检查 Neo4j 连接。');
// 清空图谱数据,避免显示旧数据
setGraphData({ nodes: [], links: [] });
} finally {
setLoadingLabelGraph(false);
}
};
// Effects
useEffect(() => {
// 切换模式时,取消所有正在进行的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// 切换模式时,清空之前的图谱数据,避免显示错误的数据
// 但保留 selectedItem,让用户可以在详情面板中继续查看
setGraphData({ nodes: [], links: [] });
setPathFound(false);
setPathInfo(null);
// 清空路径相关的状态(仅在切换到非路径模式时)
if (viewMode !== 'path') {
setStartNode(null);
setEndNode(null);
}
// 根据模式加载相应的数据
if (viewMode === 'history' && episodes.length === 0) fetchHistory();
if (viewMode === 'meta' && !metaInfo) fetchMeta();
// 组件卸载时清理
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode]); // fetchHistory 和 fetchMeta 是稳定的函数,不需要加入依赖
// 当起点或终点变化时,清空之前的路径结果(仅在路径模式下)
useEffect(() => {
if (viewMode === 'path' && pathFound) {
// 只有当起点或终点确实发生变化时才清空路径结果
// 避免在初始化时清空
setPathFound(false);
setPathInfo(null);
setGraphData({ nodes: [], links: [] });
}
}, [startNode, endNode, viewMode, pathFound]);
// Reset view when switching view modes or when graph data is cleared
useEffect(() => {
if (graphRef.current && graphData.nodes.length === 0) {
// Reset zoom and center when graph is cleared
setTimeout(() => {
if (graphRef.current) {
graphRef.current.zoom(1, 0);
graphRef.current.centerAt(0, 0, 0);
}
}, 100);
}
}, [graphData.nodes.length, viewMode]);
// 统一的自动缩放逻辑:当图形数据变化时,等待力模拟稳定后自动调整视角
// 使用 useRef 来跟踪是否正在缩放,避免重复触发导致视角跳动
const isZoomingRef = useRef(false);
const zoomTimerRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
// 清除之前的定时器
if (zoomTimerRef.current) {
clearTimeout(zoomTimerRef.current);
zoomTimerRef.current = null;
}
if (graphRef.current && graphData.nodes.length > 0 && !pathFound) {
// 如果正在缩放,跳过
if (isZoomingRef.current) return;
// Don't auto-zoom if just showing path result (handled in fetchPath)
// Wait longer for force simulation to stabilize before zooming
zoomTimerRef.current = setTimeout(() => {
if (!graphRef.current || graphData.nodes.length === 0) {
isZoomingRef.current = false;
return;
}
// 标记正在缩放
isZoomingRef.current = true;
try {
if (graphData.nodes.length === 1) {
// Single node: center and zoom in
graphRef.current.centerAt(0, 0, 400);
graphRef.current.zoom(3, 400);
} else {
// Multiple nodes: zoom to fit all nodes with padding
// Use larger padding to ensure all nodes are visible
graphRef.current.zoomToFit(600, 100);
}
} finally {
// 缩放完成后,延迟重置标志,避免立即再次触发
setTimeout(() => {
isZoomingRef.current = false;
}, 500);
}
}, 1000); // 增加延迟到 1 秒,确保力模拟完全稳定后再缩放
}
return () => {
if (zoomTimerRef.current) {
clearTimeout(zoomTimerRef.current);
zoomTimerRef.current = null;
}
isZoomingRef.current = false;
};
}, [graphData, bounds.width, pathFound]);
// 搜索输入实时防抖:停顿一小段时间后自动搜索(在搜索模式和路径模式下都生效)
useEffect(() => {
if (viewMode !== 'search' && viewMode !== 'path') return;
if (!query.trim()) {
setResults([]);
setSearchExecuted(false);
return;
}
const handle = setTimeout(() => {
handleSearch();
}, 400);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, viewMode]); // handleSearch 是稳定的函数,不需要加入依赖
// Neo4j 风格的力配置和边界约束
useEffect(() => {
// 检查条件:必须有 graphRef、bounds 有有效尺寸、有节点数据
const hasValidBounds = bounds.width && bounds.height && bounds.width > 0 && bounds.height > 0;
if (!graphRef.current || !hasValidBounds || !graphData?.nodes?.length) {
return;
}
const fg = graphRef.current;
// 延迟执行,确保 graphData 已经更新到组件中,并且容器已经渲染完成
const timer = setTimeout(() => {
try {
// Neo4j Browser 风格的力配置
// 关键:实现"锁链+磁铁"效果 - 节点之间有连接力(锁链)和排斥力(磁铁)
// 配置 link force(锁链效果)- 这是 Neo4j Browser 的关键
const linkForce = fg.d3Force('link');
if (linkForce) {
// 强制设置 link force 的距离和强度
// 使用非常大的距离,确保节点不黏在一起
let linkDist = 350; // 固定使用很大的距离
// 强制设置距离和强度
linkForce.distance(linkDist);
linkForce.strength(0.1); // 非常弱的连接强度
}
// 配置节点间的斥力(磁铁排斥效果)
const chargeForce = fg.d3Force('charge');
if (chargeForce) {
// Neo4j Browser 风格:使用非常强的斥力,让节点保持舒适距离
let chargeStrength = -1000; // 固定使用非常强的斥力
chargeForce.strength(chargeStrength);
}
// 配置中心力(Neo4j Browser:使用较弱的中心力,让节点自然分布)
const centerForce = fg.d3Force('center');
if (centerForce) {
centerForce.strength(0.02); // 非常弱的中心力,让节点自然分布
}
// 添加 collide 力防止节点重叠(Neo4j Browser 的关键特性)
try {
fg.d3Force('collide', forceCollide()
.radius((node: any) => {
const nodeRadius = node.val ? node.val * 0.8 : 10;
// Neo4j Browser 风格:非常大的间距,让节点和标签都有足够空间
// 大幅增加间距,确保节点完全不重叠
return nodeRadius + 100; // 节点半径 + 100px 间距,确保标签不重叠,节点不黏在一起
})
.strength(1.0) // 最大碰撞强度,确保节点不重叠
.iterations(5) // 增加迭代次数,提高碰撞检测精度
);
} catch (err) {
console.warn('配置 collide force 失败:', err);
}
// 重新启动模拟,确保配置生效
try {
const sim = (fg as any).d3Force?.();
if (sim) {
sim.alpha(1).restart(); // 重新启动模拟,让新配置生效
}
} catch (e) {
console.error('重启模拟失败:', e);
}
} catch (error) {
console.error('Error configuring forces:', error);
}
}, 200); // 延迟 200ms 执行,确保容器完全渲染
return () => clearTimeout(timer);
}, [bounds.width, bounds.height, graphData]);
const handleItemClick = (item: Entity | Episode) => {
setSelectedItem(item);
setRightPanelOpen(true);
// 根据当前模式和项目类型来决定加载哪个图谱
// 保持当前模式不变,不要自动切换模式
const isEpisode = (item as any).type === 'Episode' ||
((item as any).labels && (item as any).labels.includes('Episode')) ||
(item as Episode).content !== undefined; // Episode 有 content 属性
if (viewMode === 'search') {
// 搜索模式下,根据项目类型决定加载方式
if (isEpisode) {
fetchEpisodeGraph(item as Episode);
} else {
fetchEntityGraph(item as Entity);
}
} else if (viewMode === 'history') {
// 时光机模式下,只加载 Episode 图谱
fetchEpisodeGraph(item as Episode);
}
// In 'path' mode, clicking a node just highlights it, doesn't fetch new graph
};
const handleRefresh = () => {
if (viewMode === 'path') {
setGraphData({nodes:[], links:[]});
setPathFound(false);
setPathInfo(null);
// 保留起点和终点,允许用户重新查询
}
else if (selectedItem) handleItemClick(selectedItem);
else if (viewMode === 'history') fetchHistory();
else if (viewMode === 'meta') fetchMeta();
else if (viewMode === 'search' && query.trim()) handleSearch();
};
return (
<div
className="relative w-full h-[calc(100vh-8rem)] bg-slate-50 rounded-xl overflow-hidden border border-slate-200 shadow-sm"
ref={containerRef}
style={{ cursor }}
>
{/* --- Graph Visualization --- */}
<div className="absolute inset-0">
<ForceGraph2D
ref={graphRef}
width={bounds.width}
height={bounds.height}
graphData={graphData}
// Neo4j 风格的力参数:更平滑的阻尼和冷却
d3VelocityDecay={0.4}
cooldownTicks={300}
d3AlphaDecay={0.0228}
// 边的理想长度和强度通过 d3Force 在 useEffect 中配置
nodeRelSize={8}
nodeLabel="name"
backgroundColor="#f8fafc"
linkColor={(link) => {
// 虚拟连接使用半透明颜色,实际连接使用正常颜色
const linkColor = (link as any).color || '#cbd5e1';
return linkColor;
}}
linkVisibility={() => {
// 虚拟连接可以稍微透明,但依然可见
return true;
}}
linkDirectionalArrowLength={3.5}
linkDirectionalArrowRelPos={1}
linkWidth={pathFound ? 3 : 1.5}
onEngineTick={() => {
// 在每次 tick 时强制执行边界约束和速度限制,防止节点无限散开
// 这是 Neo4j Browser 的关键特性:严格的边界约束和速度控制
if (!graphRef.current) return;
const fg = graphRef.current;
const data = fg.graphData && fg.graphData();
if (!data || !data.nodes || !bounds.width || !bounds.height) return;
const padding = 60;
const maxX = bounds.width / 2 - padding;
const maxY = bounds.height / 2 - padding;
const maxVelocity = 2;
// 合并两次遍历为一次,提高性能
let isDragging = false;
data.nodes.forEach((node: any) => {
// 检查是否有节点正在被拖拽
if (node.fx !== undefined && node.fy !== undefined && node.__isDragging) {
isDragging = true;
}
// 只对未固定的节点应用边界约束和速度限制
if (node.fx !== undefined || node.fy !== undefined) return;
if (typeof node.x !== 'number' || typeof node.y !== 'number') return;
// 强制限制在边界内
const oldX = node.x;
const oldY = node.y;
const newX = Math.max(-maxX, Math.min(maxX, node.x));
const newY = Math.max(-maxY, Math.min(maxY, node.y));
// 如果节点被边界约束修正,完全停止其速度
if (newX !== oldX) {
node.x = newX;
node.vx = 0;
} else {
node.x = newX;
}
if (newY !== oldY) {
node.y = newY;
node.vy = 0;
} else {
node.y = newY;
}
// 关键:如果正在拖拽,大幅减小其他节点的速度,让它们快速停止
if (isDragging) {
if (node.vx !== undefined) node.vx = node.vx * 0.05;
if (node.vy !== undefined) node.vy = node.vy * 0.05;
}
// 限制最大速度,防止节点移动过快
if (node.vx !== undefined && Math.abs(node.vx) > maxVelocity) {
node.vx = node.vx > 0 ? maxVelocity : -maxVelocity;
}
if (node.vy !== undefined && Math.abs(node.vy) > maxVelocity) {
node.vy = node.vy > 0 ? maxVelocity : -maxVelocity;
}
});
}}
nodeCanvasObject={(node, ctx, globalScale) => {
// 安全检查:确保节点有有效的位置
if (typeof node.x !== 'number' || typeof node.y !== 'number') {
return;
}
// 使用 fallback 逻辑获取节点名称
const label = node.name || (node.entity && (node.entity.name || node.entity.content)) || 'Unknown';
const fontSize = Math.max(10, 12/globalScale);
// 增大节点半径,使其更可见(类似 Neo4j)
const r = node.val ? Math.max(8, node.val * 0.8) : 10;
// 选中节点的高亮效果
if (selectedItem && (selectedItem as any).uuid === node.id) {
ctx.beginPath();
ctx.arc(node.x, node.y, r + 4, 0, 2 * Math.PI, false);
ctx.fillStyle = 'rgba(59, 130, 246, 0.2)';
ctx.fill();
ctx.beginPath();
ctx.arc(node.x, node.y, r + 2, 0, 2 * Math.PI, false);
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2 / globalScale;
ctx.stroke();
}
// 绘制节点
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, 2 * Math.PI, false);
ctx.fillStyle = node.color || '#60a5fa';
ctx.fill();
// 添加节点边框,使其更清晰
ctx.strokeStyle = 'rgba(0, 0, 0, 0.1)';
ctx.lineWidth = 1 / globalScale;
ctx.stroke();
// 总是显示标签(类似 Neo4j),但根据缩放调整大小
if (label && label !== 'Unknown') {
ctx.font = `${globalScale > 1 ? '600' : '500'} ${fontSize}px Sans-Serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#1e293b';
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3 / globalScale;
ctx.strokeText(label, node.x, node.y + r + fontSize + 3);
ctx.fillText(label, node.x, node.y + r + fontSize + 3);
}
}}
enableNodeDrag={true}
onNodeHover={(node) => setCursor(node ? 'pointer' : 'default')}
onNodeDrag={(node: any) => {
// Neo4j Browser 风格:拖拽时保持模拟运行,让其他节点通过力自然避让
// 检查是否是第一次拖拽(开始拖拽)
if (node.__isDragging === undefined) {
node.__isDragging = true;
// 轻微激活模拟,让其他节点通过力自然避让(像锁链一样)
if (graphRef.current) {
try {
const fg = graphRef.current;
const sim = (fg as any).d3Force?.();
if (sim && sim.alpha() < 0.1) {
sim.alpha(0.1).restart(); // 使用较小的 alpha,让节点自然避让
}
} catch {}
}
}
// 拖拽过程中,只更新被拖拽节点的固定位置
// 让其他节点通过 link force 和 charge force 自然避让
node.fx = node.x;
node.fy = node.y;
// 保持模拟运行,让力发挥作用
if (graphRef.current) {
try {
const fg = graphRef.current;
const sim = (fg as any).d3Force?.();
if (sim && sim.alpha() < 0.05) {
sim.alpha(0.05).restart(); // 保持模拟运行,但使用很小的 alpha
}
} catch {}
}
}}
onNodeDragEnd={(node: any) => {
// Neo4j Browser 风格:拖拽结束后,解冻被拖拽的节点
if (!graphRef.current) return;
const fg = graphRef.current;
// 移除拖拽标记
node.__isDragging = false;
// 解冻被拖拽的节点
node.fx = undefined;
node.fy = undefined;
// 轻微激活模拟,让所有节点自然稳定
try {
const sim = (fg as any).d3Force?.();
if (sim) {
sim.alpha(0.1).restart(); // 使用较小的 alpha,让节点逐渐稳定
}
} catch {}
}}
onNodeClick={(node) => {
if (node.entity) {
const entity = node.entity;
setSelectedItem(entity);
setRightPanelOpen(true);
// 点击节点时,根据当前模式和节点类型加载图谱,但不自动切换模式
// 保持用户当前选择的模式,避免意外的模式切换
if (viewMode === 'search' || viewMode === 'history') {
const isEpisode = entity.type === 'Episode' ||
(entity.labels && entity.labels.includes('Episode'));
if (isEpisode) {
// 如果是 Episode,加载 Episode 图谱,但保持当前模式
fetchEpisodeGraph(entity as Episode);
} else {
// 如果是 Entity,加载 Entity 图谱,但保持当前模式
fetchEntityGraph(entity as Entity);
}
}
// 在 path 模式下,点击节点只显示详情,不加载新图谱
}
}}
/>
</div>
{/* Path 模式下的图中央引导文案(仅当当前没有任何路径图时) */}
{viewMode === 'path' && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="bg-white/90 backdrop-blur border border-dashed border-slate-300 rounded-xl px-6 py-4 text-xs text-slate-500 max-w-sm text-center shadow-sm">
<div className="font-semibold mb-1 text-slate-700">路径模式使用指南</div>
<div className="space-y-1">
<p>1. 在下方「搜索候选节点」中搜索想要的节点,或在其它模式中点击节点并在右侧设为起点/终点。</p>
<p>2. 左侧起点 / 终点都选好后,点击「寻找最短路径」。</p>
<p>3. 如果没有路径,会在左侧显示提示;有路径时,这里会展示仅包含该路径的子图。</p>
</div>
</div>
</div>
)}
{/* --- Top Controls --- */}
<div className="absolute top-4 left-4 flex gap-2 z-20">
<div className="flex bg-white/90 backdrop-blur border border-slate-200 rounded-lg p-1 shadow-sm">
<button
onClick={() => { setLeftPanelOpen(true); setViewMode('search'); }}
className={`px-3 py-1.5 text-xs font-medium rounded-md flex items-center gap-2 transition-colors ${viewMode === 'search' && leftPanelOpen ? 'bg-primary text-primary-foreground' : 'text-slate-500 hover:bg-slate-100'}`}
>
<Search className="w-3.5 h-3.5" /> 搜索
</button>
<button
onClick={() => { setLeftPanelOpen(true); setViewMode('history'); }}
className={`px-3 py-1.5 text-xs font-medium rounded-md flex items-center gap-2 transition-colors ${viewMode === 'history' && leftPanelOpen ? 'bg-violet-600 text-white' : 'text-slate-500 hover:bg-slate-100'}`}
>
<History className="w-3.5 h-3.5" /> 时光机
</button>
<button
onClick={() => { setLeftPanelOpen(true); setViewMode('meta'); }}
className={`px-3 py-1.5 text-xs font-medium rounded-md flex items-center gap-2 transition-colors ${viewMode === 'meta' && leftPanelOpen ? 'bg-slate-800 text-white' : 'text-slate-500 hover:bg-slate-100'}`}
>
<Database className="w-3.5 h-3.5" /> 数据库
</button>
<button
onClick={() => { setLeftPanelOpen(true); setViewMode('path'); }}
className={`px-3 py-1.5 text-xs font-medium rounded-md flex items-center gap-2 transition-colors ${viewMode === 'path' && leftPanelOpen ? 'bg-yellow-500 text-white' : 'text-slate-500 hover:bg-slate-100'}`}
>
<Route className="w-3.5 h-3.5" /> 路径
</button>
</div>
<button
onClick={handleRefresh}
className="bg-white/90 backdrop-blur border border-slate-200 rounded-lg p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 shadow-sm"
title="刷新 / 重置"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
{error && (
<div className="absolute top-20 left-4 max-w-md z-20 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 shadow-sm">
{error}
</div>
)}
{/* --- Zoom Controls --- */}
<div className="absolute bottom-6 right-6 flex flex-col gap-2 z-20">
<div className="bg-white/90 backdrop-blur border border-slate-200 rounded-lg p-1 shadow-sm flex flex-col gap-1">
<button onClick={() => graphRef.current?.zoom(graphRef.current.zoom() * 1.2, 400)} className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded" aria-label="放大"><ZoomIn className="w-4 h-4" /></button>
<button onClick={() => graphRef.current?.zoom(graphRef.current.zoom() / 1.2, 400)} className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded" aria-label="缩小"><ZoomOut className="w-4 h-4" /></button>
<button onClick={() => graphRef.current?.zoomToFit(400, 50)} className="p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-100 rounded" aria-label="适应窗口"><Maximize className="w-4 h-4" /></button>
</div>
</div>
{/* --- Left Sidebar --- */}
<div className={`absolute top-16 left-4 bottom-4 w-80 bg-white/95 backdrop-blur border border-slate-200 rounded-xl shadow-lg flex flex-col transition-transform duration-300 z-30 ${leftPanelOpen ? 'translate-x-0' : '-translate-x-[120%]'}`}>
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="text-slate-800 font-semibold text-sm flex items-center gap-2">
{viewMode === 'search' ? <Search className="w-4 h-4" /> :
viewMode === 'history' ? <History className="w-4 h-4" /> :
viewMode === 'path' ? <Route className="w-4 h-4" /> :
<Database className="w-4 h-4" />}
{viewMode === 'search' ? '查找节点' :
viewMode === 'history' ? '历史记录' :
viewMode === 'path' ? '路径发现' :
'数据库信息'}
</h3>
<button onClick={() => setLeftPanelOpen(false)} className="text-slate-400 hover:text-slate-700" aria-label="关闭左侧面板"><X className="w-4 h-4" /></button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar">
{viewMode === 'path' ? (
<div className="p-2 space-y-4">
<div className="space-y-2">
<div className="text-xs font-semibold text-slate-800">起点 (Start)</div>
{startNode ? (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg flex justify-between items-center">
<span className="text-sm font-medium text-green-900 truncate">{startNode.name || startNode.content || (startNode as any).label || 'Unknown'}</span>
<button onClick={() => setStartNode(null)} className="text-green-600 hover:text-green-800" aria-label="清除起点"><X className="w-3 h-3" /></button>
</div>
) : (
<div className="p-3 bg-slate-50 border border-slate-200 border-dashed rounded-lg text-xs text-slate-700 text-center font-medium">
请在搜索结果或详情中选择起点
</div>
)}
</div>
<div className="flex justify-center"><ArrowRight className="w-4 h-4 text-slate-400" /></div>
<div className="space-y-2">
<div className="text-xs font-semibold text-slate-800">终点 (End)</div>
{endNode ? (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex justify-between items-center">
<span className="text-sm font-medium text-red-900 truncate">{endNode.name || endNode.content || (endNode as any).label || 'Unknown'}</span>
<button onClick={() => setEndNode(null)} className="text-red-600 hover:text-red-800" aria-label="清除终点"><X className="w-3 h-3" /></button>
</div>
) : (
<div className="p-3 bg-slate-50 border border-slate-200 border-dashed rounded-lg text-xs text-slate-700 text-center font-medium">
请在搜索结果或详情中选择终点
</div>
)}
</div>
<button
onClick={fetchPath}
disabled={!startNode || !endNode}
className="w-full py-2 bg-yellow-500 hover:bg-yellow-600 disabled:bg-slate-200 disabled:text-slate-400 text-white rounded-md text-sm font-medium transition-colors shadow-sm flex items-center justify-center gap-2"
aria-label={!startNode || !endNode ? "请先选择起点和终点" : "寻找最短路径"}
>
<Route className="w-4 h-4" />
寻找最短路径
</button>
{pathInfo && (
<div className={`text-[11px] leading-relaxed ${
pathFound
? 'text-green-700 bg-green-50 border border-green-200 rounded-md px-2 py-1.5'
: pathInfo.includes('未找到') || pathInfo.includes('不能') || pathInfo.includes('失败')
? 'text-red-700 bg-red-50 border border-red-200 rounded-md px-2 py-1.5'
: 'text-slate-500'
}`}>
{pathInfo}
</div>
)}
{/* Helper Search for Path */}
<div className="mt-6 pt-4 border-t border-slate-100">
<div className="text-xs font-bold text-slate-900 mb-2">搜索候选节点</div>
<form onSubmit={handleSearch} className="mb-2">
<label htmlFor="path-search-input" className="sr-only">搜索节点</label>
<input id="path-search-input" className="w-full bg-white border-2 border-slate-300 rounded-md px-3 py-2 text-xs text-slate-900 placeholder:text-slate-400 focus:outline-none focus:border-yellow-500 focus:bg-white focus:ring-2 focus:ring-yellow-200" placeholder="搜索节点..." value={query} onChange={e => setQuery(e.target.value)} aria-label="搜索节点"/>
</form>
<div className="space-y-1 max-h-[200px] overflow-y-auto">
{searchExecuted && results.length === 0 && query.trim() && (
<div className="px-2 py-2 text-xs text-slate-400 text-center">
未找到匹配节点,请尝试其他关键词。
</div>
)}
{results.map((entity) => {
const displayName = entity.name || entity.content || (entity as any).label || 'Unknown';
const entityKey = entity.uuid || (entity as any).element_id || (entity as any).id || `entity-${displayName}`;
return (
<div key={entityKey} className="flex items-center gap-2 p-2 hover:bg-slate-50 rounded-md group cursor-pointer" onClick={() => {
setSelectedItem(entity);
setRightPanelOpen(true);
// 在路径模式下,点击搜索结果只显示详情,不加载图谱
// 不调用 handleItemClick,避免加载图谱
}}>
<span className="flex-1 text-xs truncate text-slate-900 font-medium">{displayName}</span>
{viewMode === 'path' && (
<>
<button onClick={(e) => { e.stopPropagation(); setStartNode(entity); }} className="text-[10px] px-1.5 py-0.5 bg-green-100 text-green-700 rounded hover:bg-green-200 opacity-0 group-hover:opacity-100">起点</button>
<button onClick={(e) => { e.stopPropagation(); setEndNode(entity); }} className="text-[10px] px-1.5 py-0.5 bg-red-100 text-red-700 rounded hover:bg-red-200 opacity-0 group-hover:opacity-100">终点</button>
</>
)}
</div>
);
})}
</div>
</div>
</div>
) : viewMode === 'search' ? (
<div>
<form onSubmit={handleSearch} className="px-2 mb-2">
<label htmlFor="search-input" className="sr-only">搜索关键词</label>
<input id="search-input" className="w-full bg-slate-50 border border-slate-200 rounded-md px-3 py-2 text-sm text-slate-900 placeholder:text-slate-500 focus:outline-none focus:border-primary focus:bg-white" placeholder="输入关键词..." value={query} onChange={e => setQuery(e.target.value)} aria-label="搜索关键词"/>
</form>
{searchExecuted && results.length === 0 && (
<div className="px-3 py-2 text-xs text-slate-400">
未找到匹配节点,请尝试其他关键词。
</div>
)}
{results.map((entity) => {
const displayName = entity.name || entity.content || (entity as any).label || 'Unknown';
const entityKey = entity.uuid || (entity as any).element_id || (entity as any).id || `entity-${displayName}`;
return (
<button key={entityKey} onClick={() => handleItemClick(entity)} className={`w-full text-left p-3 rounded-lg text-sm transition-all border border-transparent flex items-center gap-3 ${(selectedItem as Entity)?.uuid === entity.uuid ? 'bg-primary/10 border-primary/20 text-primary' : 'text-slate-700 hover:bg-slate-100 hover:border-slate-200'}`}>
<Network className="w-4 h-4 opacity-70" /><span className="truncate font-medium">{displayName}</span>
</button>
);
})}
</div>
) : viewMode === 'history' ? (
<div>
{loadingHistory && <div className="p-2 text-center text-xs text-slate-500 animate-pulse">加载历史记录中...</div>}
{episodes.map((ep) => {
const episodeKey = ep.uuid || (ep as any).element_id || (ep as any).id || `episode-${ep.timestamp}`;
return (
<button key={episodeKey} onClick={() => handleItemClick(ep)} className={`w-full text-left p-3 rounded-lg text-sm transition-all border border-transparent flex flex-col gap-1 ${(selectedItem as Episode)?.uuid === ep.uuid ? 'bg-violet-50 border-violet-200 text-violet-700' : 'text-slate-600 hover:bg-slate-100 hover:border-slate-200'}`}>
<div className="flex items-center gap-2 text-xs opacity-70"><Clock className="w-3 h-3" />{new Date(ep.timestamp).toLocaleString()}</div>
<div className="truncate font-medium">{ep.content || '无内容'}</div>
</button>
);
})}
</div>
) : (
// Meta Info
metaInfo && (
<div className="space-y-6 p-2">
{/* Labels */}
<div>
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2 px-1">Node Labels</div>
<div className="flex flex-wrap gap-2">
{metaInfo.labels.map((label) => (
<button
key={label.name}
onClick={() => fetchLabelGraph(label.name)}
className="flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-full px-3 py-1 text-xs hover:bg-slate-100 hover:border-slate-300 transition-colors"
>
<Tag className="w-3 h-3 text-blue-500" />
<span className="font-medium">{label.name}</span>
<span className="bg-slate-200 text-slate-600 px-1.5 rounded-full text-[10px]">{label.count}</span>
</button>
))}
</div>
{loadingLabelGraph && (
<div className="mt-2 text-[11px] text-slate-400 px-1">
正在加载标签图谱...
</div>
)}
{activeLabel && !loadingLabelGraph && (
<div className="mt-2 text-[11px] text-slate-500 px-1">
当前图视图:Label = <span className="font-mono">{activeLabel}</span>
</div>
)}
</div>
{/* Relationships */}
<div>
<div className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-2 px-1">Relationship Types</div>
<div className="flex flex-wrap gap-2">
{metaInfo.relationshipTypes.map((rel) => (
<div key={rel.name} className="flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-full px-3 py-1 text-xs">
<GitFork className="w-3 h-3 text-orange-500" />
<span className="font-medium">{rel.name}</span>
<span className="bg-slate-200 text-slate-600 px-1.5 rounded-full text-[10px]">{rel.count}</span>
</div>
))}
</div>
</div>
{/* DB Info */}
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200 text-xs space-y-2">
<div className="font-bold text-slate-500 uppercase mb-1">Database</div>
<div className="flex justify-between"><span>Name:</span> <span className="font-mono">{metaInfo.database.name}</span></div>
<div className="flex justify-between"><span>Version:</span> <span className="font-mono">{metaInfo.database.version}</span></div>
<div className="flex justify-between"><span>Edition:</span> <span className="font-mono">{metaInfo.database.edition}</span></div>
<div className="flex justify-between"><span>User:</span> <span className="font-mono">{metaInfo.database.user}</span></div>
</div>
</div>
)
)}
</div>
</div>
{!leftPanelOpen && <button onClick={() => setLeftPanelOpen(true)} className="absolute top-20 left-0 bg-white border border-slate-200 border-l-0 rounded-r-md p-2 text-slate-500 hover:text-slate-900 hover:bg-slate-50 shadow-sm z-20" aria-label="打开左侧面板"><ChevronRight className="w-4 h-4" /></button>}
{/* Right Sidebar */}
<div className={`absolute top-4 right-4 bottom-4 w-80 bg-white/95 backdrop-blur border border-slate-200 rounded-xl shadow-lg flex flex-col transition-transform duration-300 z-30 ${rightPanelOpen && selectedItem ? 'translate-x-0' : 'translate-x-[120%]'}`}>
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
<div className="flex items-center gap-2 overflow-hidden">
<div className={`p-1.5 rounded-md ${viewMode === 'path' ? 'bg-yellow-100 text-yellow-700' : viewMode === 'history' ? 'bg-violet-100 text-violet-700' : 'bg-blue-100 text-blue-700'}`}>
{viewMode === 'path' ? <Route className="w-4 h-4" /> : viewMode === 'history' ? <Clock className="w-4 h-4" /> : <Network className="w-4 h-4" />}
</div>
<span className="text-slate-800 font-semibold text-sm truncate">Properties</span>
</div>
<button onClick={() => setRightPanelOpen(false)} className="text-slate-400 hover:text-slate-700" aria-label="关闭右侧面板"><X className="w-4 h-4" /></button>
</div>
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
{selectedItem && (
<div className="space-y-4">
<div><label className="text-xs text-slate-500 uppercase font-bold tracking-wider">Content</label><div className="mt-1 text-slate-900 text-sm font-medium break-words">{(selectedItem as any).name || (selectedItem as any).content}</div></div>
{viewMode === 'path' && ((selectedItem as any).uuid || (selectedItem as any).element_id || (selectedItem as any).id) && (
<div className="flex gap-2">
<button onClick={() => setStartNode(selectedItem as Entity)} className="flex-1 bg-green-50 border border-green-200 text-green-700 text-xs py-1.5 rounded hover:bg-green-100">设为起点</button>
<button onClick={() => setEndNode(selectedItem as Entity)} className="flex-1 bg-red-50 border border-red-200 text-red-700 text-xs py-1.5 rounded hover:bg-red-100">设为终点</button>
</div>
)}
<div><label className="text-xs text-slate-500 uppercase font-bold tracking-wider">Properties</label><div className="mt-2 bg-slate-50 rounded-lg p-3 border border-slate-200"><pre className="text-xs text-slate-600 font-mono whitespace-pre-wrap break-all">{JSON.stringify(selectedItem, null, 2)}</pre></div></div>
</div>
)}
</div>
</div>
</div>
);
}