// dashboard/src/components/CitationGalaxyCanvas.tsx import { useEffect, useRef } from 'react'; import type { CitationNetwork } from '../types'; interface CanvasProps { networks: CitationNetwork[]; activeNetwork: CitationNetwork; nodeLimit: number; 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'; inDb: boolean; } interface Link { source: string; target: string; } export function CitationGalaxyCanvas({ networks, activeNetwork, nodeLimit, 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); const MAX_NODES = nodeLimit; 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); // 根据被引用数量决定中心节点大小 (起步半径 16,按被引量上限 32 比例缩放) const citeCount = net.citation_count || 0; const radius = isActive ? Math.min(32, Math.max(18, 16 + citeCount / 50)) : Math.min(24, Math.max(12, 11 + citeCount / 100)); 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, color: isActive ? '#0284c7' : '#475569', type: 'center', inDb: true, }); } 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 = 120 + Math.random() * 30; const centerNode = nodes.find(n => n.id === net.bibcode); const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, ref) : false; 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: 8, // 初始值,稍后按连线数重算 color: '#d97706', type: 'reference', inDb, }); } 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 = 140 + Math.random() * 40; const centerNode = nodes.find(n => n.id === net.bibcode); const inDb = activeNetwork.citation_counts ? Object.prototype.hasOwnProperty.call(activeNetwork.citation_counts, cit) : false; 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: 8, // 初始值,稍后按连线数重算 color: '#0891b2', type: 'citation', inDb, }); } if (allIds.has(cit)) { links.push({ source: net.bibcode, target: cit }); } }); }); // 计算外围节点在当前渲染网络中的度数(连线数),动态微调其半径大小,凸显网络枢纽节点 const degrees: Record = {}; links.forEach(l => { degrees[l.source] = (degrees[l.source] || 0) + 1; degrees[l.target] = (degrees[l.target] || 0) + 1; }); nodes.forEach(n => { if (n.type !== 'center') { const deg = degrees[n.id] || 0; const citeCount = activeNetwork.citation_counts?.[n.id] || 0; // 融合数据库中该文献的被引数量 (起步+citeCount/40) 与当前渲染网格连线度数,动态决定半径 (最大限制 18) n.radius = Math.min(18, Math.max(6, 6 + citeCount / 40 + Math.min(6, deg * 1.5))); } }); let offsetX = 0; let offsetY = 0; let scale = 1.0; let isDragging = false; let dragStartX = 0; let dragStartY = 0; let hasDragged = false; let animationFrameId: number; let hoveredNode: Node | null = null; let frameCount = 0; const updatePhysics = () => { 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 + 60; 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; } } } } 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; } } }); 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 = () => { frameCount++; updatePhysics(); ctx.clearRect(0, 0, rect.width, rect.height); ctx.save(); // 应用拖拽和缩放的坐标变换 const cx = rect.width / 2; const cy = rect.height / 2; ctx.translate(cx + offsetX, cy + offsetY); ctx.scale(scale, scale); ctx.translate(-cx, -cy); // 绘制背景宇宙引力线 & 刻度圈 const centerNode = nodes.find(n => n.id === activeNetwork.bibcode); if (centerNode) { ctx.strokeStyle = 'rgba(148, 163, 184, 0.15)'; ctx.lineWidth = 1; ctx.setLineDash([4, 6]); ctx.beginPath(); ctx.arc(centerNode.x, centerNode.y, 130, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(centerNode.x, centerNode.y, 180, 0, Math.PI * 2); ctx.stroke(); // 动态圈 ctx.strokeStyle = 'rgba(2, 132, 199, 0.06)'; ctx.setLineDash([]); const pulseRadius = 130 + (frameCount % 120) * 0.4; ctx.beginPath(); ctx.arc(centerNode.x, centerNode.y, pulseRadius, 0, Math.PI * 2); ctx.stroke(); } // 绘制连线 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 = 'rgba(148, 163, 184, 0.25)'; ctx.stroke(); } }); // 绘制节点 nodes.forEach(node => { const isHovered = hoveredNode?.id === node.id; ctx.save(); ctx.globalAlpha = node.inDb ? 1.0 : 0.35; // 未入库文献透明度降为 0.35 ctx.beginPath(); ctx.arc(node.x, node.y, node.radius + (isHovered ? 6 : 3), 0, Math.PI * 2); ctx.fillStyle = node.color + (isHovered ? '25' : '0f'); ctx.fill(); ctx.beginPath(); ctx.arc(node.x, node.y, node.radius, 0, Math.PI * 2); ctx.fillStyle = node.color; ctx.fill(); ctx.beginPath(); ctx.arc(node.x, node.y, node.radius + (isHovered ? 4 : 2), 0, Math.PI * 2); ctx.strokeStyle = node.color + '40'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = isHovered ? '#0284c7' : '#334155'; ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(node.label, node.x, node.y + node.radius + (isHovered ? 16 : 12)); ctx.restore(); }); ctx.restore(); animationFrameId = requestAnimationFrame(render); }; render(); const handleMouseDown = (e: MouseEvent) => { isDragging = true; dragStartX = e.clientX - offsetX; dragStartY = e.clientY - offsetY; hasDragged = false; }; const handleMouseMove = (e: MouseEvent) => { const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; if (isDragging) { const dx = e.clientX - dragStartX; const dy = e.clientY - dragStartY; if (Math.sqrt((dx - offsetX) ** 2 + (dy - offsetY) ** 2) > 3) { hasDragged = true; } offsetX = dx; offsetY = dy; } const cx = rect.width / 2; const cy = rect.height / 2; const gx = (mouseX - cx - offsetX) / scale + cx; const gy = (mouseY - cy - offsetY) / scale + cy; let found: Node | null = null; for (const node of nodes) { let dx = node.x - gx; let dy = node.y - gy; let dist = Math.sqrt(dx * dx + dy * dy); if (dist < node.radius + 6) { found = node; break; } } hoveredNode = found; if (isDragging) { canvas.style.cursor = 'grabbing'; } else { canvas.style.cursor = found ? 'pointer' : 'grab'; } }; const handleMouseUp = () => { isDragging = false; }; const handleMouseLeave = () => { isDragging = false; }; const handleCanvasClick = () => { if (hasDragged) return; if (hoveredNode && hoveredNode.id !== activeNetwork.bibcode) { onNodeClick(hoveredNode.id); } }; const handleWheel = (e: WheelEvent) => { e.preventDefault(); const rect = canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; const cx = rect.width / 2; const cy = rect.height / 2; const gx = (mouseX - cx - offsetX) / scale + cx; const gy = (mouseY - cy - offsetY) / scale + cy; if (e.deltaY < 0) { scale = Math.min(5.0, scale * 1.1); } else { scale = Math.max(0.15, scale / 1.1); } offsetX = mouseX - cx - (gx - cx) * scale; offsetY = mouseY - cy - (gy - cy) * scale; }; canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseup', handleMouseUp); canvas.addEventListener('mouseleave', handleMouseLeave); canvas.addEventListener('click', handleCanvasClick); canvas.addEventListener('wheel', handleWheel, { passive: false }); return () => { cancelAnimationFrame(animationFrameId); canvas.removeEventListener('mousedown', handleMouseDown); canvas.removeEventListener('mousemove', handleMouseMove); canvas.removeEventListener('mouseup', handleMouseUp); canvas.removeEventListener('mouseleave', handleMouseLeave); canvas.removeEventListener('click', handleCanvasClick); canvas.removeEventListener('wheel', handleWheel); }; }, [networks, activeNetwork, onNodeClick]); return ( ); }