273 lines
8.5 KiB
TypeScript
273 lines
8.5 KiB
TypeScript
// dashboard/src/components/CitationGalaxyCanvas.tsx
|
|
import { useEffect, useRef } from 'react';
|
|
import type { CitationNetwork } from '../types';
|
|
|
|
interface CanvasProps {
|
|
networks: CitationNetwork[];
|
|
activeNetwork: CitationNetwork;
|
|
onNodeClick: (bibcode: string) => void;
|
|
}
|
|
|
|
interface Node {
|
|
id: string;
|
|
label: string;
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
radius: number;
|
|
color: string;
|
|
type: 'center' | 'reference' | 'citation';
|
|
}
|
|
|
|
interface Link {
|
|
source: string;
|
|
target: string;
|
|
}
|
|
|
|
export function CitationGalaxyCanvas({ networks, activeNetwork, onNodeClick }: CanvasProps) {
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// 适配高清屏幕像素比
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = rect.width * dpr;
|
|
canvas.height = rect.height * dpr;
|
|
ctx.scale(dpr, dpr);
|
|
|
|
// 合并所有 networks 的节点,去重,最多 50 个
|
|
const MAX_NODES = 50;
|
|
const allIds = new Set<string>();
|
|
const nodes: Node[] = [];
|
|
const links: Link[] = [];
|
|
|
|
networks.forEach((net, netIdx) => {
|
|
const isActive = net.bibcode === activeNetwork.bibcode;
|
|
// 添加中心节点
|
|
if (!allIds.has(net.bibcode) && nodes.length < MAX_NODES) {
|
|
allIds.add(net.bibcode);
|
|
nodes.push({
|
|
id: net.bibcode,
|
|
label: net.bibcode,
|
|
x: rect.width / 2 + (netIdx === 0 ? 0 : (Math.random() - 0.5) * 200),
|
|
y: rect.height / 2 + (netIdx === 0 ? 0 : (Math.random() - 0.5) * 200),
|
|
vx: 0,
|
|
vy: 0,
|
|
radius: isActive ? 24 : 16,
|
|
color: isActive ? '#a855f7' : '#6366f1',
|
|
type: 'center',
|
|
});
|
|
}
|
|
|
|
// 添加参考文献节点
|
|
net.references.forEach((ref, idx) => {
|
|
if (nodes.length >= MAX_NODES) return;
|
|
if (!allIds.has(ref)) {
|
|
allIds.add(ref);
|
|
const angle = (idx / Math.max(1, net.references.length)) * Math.PI * 2;
|
|
const dist = 140 + Math.random() * 30;
|
|
const centerNode = nodes.find(n => n.id === net.bibcode);
|
|
nodes.push({
|
|
id: ref,
|
|
label: ref,
|
|
x: (centerNode?.x ?? rect.width / 2) + Math.cos(angle) * dist,
|
|
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
|
|
vx: 0,
|
|
vy: 0,
|
|
radius: 12,
|
|
color: '#d97706',
|
|
type: 'reference',
|
|
});
|
|
}
|
|
if (allIds.has(ref)) {
|
|
links.push({ source: ref, target: net.bibcode });
|
|
}
|
|
});
|
|
|
|
// 添加被引文献节点
|
|
net.citations.forEach((cit, idx) => {
|
|
if (nodes.length >= MAX_NODES) return;
|
|
if (!allIds.has(cit)) {
|
|
allIds.add(cit);
|
|
const angle = (idx / Math.max(1, net.citations.length)) * Math.PI * 2 + Math.PI;
|
|
const dist = 160 + Math.random() * 40;
|
|
const centerNode = nodes.find(n => n.id === net.bibcode);
|
|
nodes.push({
|
|
id: cit,
|
|
label: cit,
|
|
x: (centerNode?.x ?? rect.width / 2) + Math.cos(angle) * dist,
|
|
y: (centerNode?.y ?? rect.height / 2) + Math.sin(angle) * dist,
|
|
vx: 0,
|
|
vy: 0,
|
|
radius: 12,
|
|
color: '#4f46e5',
|
|
type: 'citation',
|
|
});
|
|
}
|
|
if (allIds.has(cit)) {
|
|
links.push({ source: net.bibcode, target: cit });
|
|
}
|
|
});
|
|
});
|
|
|
|
let animationFrameId: number;
|
|
let hoveredNode: Node | null = null;
|
|
|
|
// 经典力导向算法迭代
|
|
const updatePhysics = () => {
|
|
// 1. 斥力:任何两个节点之间均产生反向推力
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
let dx = nodes[j].x - nodes[i].x;
|
|
let dy = nodes[j].y - nodes[i].y;
|
|
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
let minDist = nodes[i].radius + nodes[j].radius + 50;
|
|
if (dist < minDist) {
|
|
let force = (minDist - dist) * 0.08;
|
|
let fx = (dx / dist) * force;
|
|
let fy = (dy / dist) * force;
|
|
|
|
// 节点不强行推动中心大节点
|
|
if (nodes[i].type !== 'center' || nodes[i].id !== activeNetwork.bibcode) {
|
|
nodes[i].vx -= fx;
|
|
nodes[i].vy -= fy;
|
|
}
|
|
if (nodes[j].type !== 'center' || nodes[j].id !== activeNetwork.bibcode) {
|
|
nodes[j].vx += fx;
|
|
nodes[j].vy += fy;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. 引力与向心力:被连线连接的节点之间产生向中心靠拢力
|
|
links.forEach(link => {
|
|
const sourceNode = nodes.find(n => n.id === link.source);
|
|
const targetNode = nodes.find(n => n.id === link.target);
|
|
if (sourceNode && targetNode) {
|
|
let dx = targetNode.x - sourceNode.x;
|
|
let dy = targetNode.y - sourceNode.y;
|
|
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
let force = dist * 0.003; // 弹性系数
|
|
let fx = (dx / dist) * force;
|
|
let fy = (dy / dist) * force;
|
|
|
|
if (sourceNode.type !== 'center' || sourceNode.id !== activeNetwork.bibcode) {
|
|
sourceNode.vx += fx;
|
|
sourceNode.vy += fy;
|
|
}
|
|
if (targetNode.type !== 'center' || targetNode.id !== activeNetwork.bibcode) {
|
|
targetNode.vx -= fx;
|
|
targetNode.vy -= fy;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 3. 应用阻尼阻力,限制极限加速
|
|
nodes.forEach(node => {
|
|
if (node.id !== activeNetwork.bibcode) {
|
|
node.x += node.vx;
|
|
node.y += node.vy;
|
|
node.vx *= 0.85; // 阻尼
|
|
node.vy *= 0.85;
|
|
}
|
|
});
|
|
};
|
|
|
|
// 画布渲染渲染循环
|
|
const render = () => {
|
|
updatePhysics();
|
|
|
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
// 绘制连线
|
|
ctx.lineWidth = 1;
|
|
links.forEach(link => {
|
|
const sourceNode = nodes.find(n => n.id === link.source);
|
|
const targetNode = nodes.find(n => n.id === link.target);
|
|
if (sourceNode && targetNode) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(sourceNode.x, sourceNode.y);
|
|
ctx.lineTo(targetNode.x, targetNode.y);
|
|
ctx.strokeStyle = sourceNode.type === 'reference' ? 'rgba(245, 158, 11, 0.25)' : 'rgba(129, 140, 248, 0.25)';
|
|
ctx.stroke();
|
|
}
|
|
});
|
|
|
|
// 绘制节点
|
|
nodes.forEach(node => {
|
|
const isHovered = hoveredNode?.id === node.id;
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 0), 0, Math.PI * 2);
|
|
ctx.fillStyle = node.color;
|
|
ctx.fill();
|
|
|
|
// 绘制光晕环绕
|
|
ctx.beginPath();
|
|
ctx.arc(node.x, node.y, node.radius + (isHovered ? 8 : 4), 0, Math.PI * 2);
|
|
ctx.strokeStyle = node.color + '40'; // 附加透明度光晕
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// 绘制 bibcode 文本说明
|
|
ctx.fillStyle = isHovered ? '#0f172a' : '#64748b';
|
|
ctx.font = isHovered ? 'bold 10px monospace' : '9px monospace';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 18 : 14));
|
|
});
|
|
|
|
animationFrameId = requestAnimationFrame(render);
|
|
};
|
|
|
|
render();
|
|
|
|
// 交互鼠标监听
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const mouseX = e.clientX - rect.left;
|
|
const mouseY = e.clientY - rect.top;
|
|
|
|
let found: Node | null = null;
|
|
for (const node of nodes) {
|
|
let dx = node.x - mouseX;
|
|
let dy = node.y - mouseY;
|
|
let dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < node.radius + 5) {
|
|
found = node;
|
|
break;
|
|
}
|
|
}
|
|
hoveredNode = found;
|
|
canvas.style.cursor = found ? 'pointer' : 'default';
|
|
};
|
|
|
|
const handleCanvasClick = () => {
|
|
if (hoveredNode && hoveredNode.id !== activeNetwork.bibcode) {
|
|
onNodeClick(hoveredNode.id);
|
|
}
|
|
};
|
|
|
|
canvas.addEventListener('mousemove', handleMouseMove);
|
|
canvas.addEventListener('click', handleCanvasClick);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animationFrameId);
|
|
canvas.removeEventListener('mousemove', handleMouseMove);
|
|
canvas.removeEventListener('click', handleCanvasClick);
|
|
};
|
|
}, [networks, activeNetwork, onNodeClick]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="w-full h-full min-h-[450px]"
|
|
style={{ display: 'block' }}
|
|
/>
|
|
);
|
|
}
|