// 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(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(); 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 ( ); }