AstroResearch/dashboard/src/components/CitationGalaxyCanvas.tsx
Asfmq cd6af4f995 feat: 重构 PDF/文献检索同步机制、升级引力图交互与控制台 UI 样式
- [后端/PDF解析] 重构 MinerU PDF 解析流程:引入预签名两阶段直传机制,解决大文件 API 传输限制问题;支持轮询机制与本地 images 备用目录存储。
- [后端/同步与下载] 新增经典 ADS SCAN 扫描件 PDF 和 ADS_PDF 直接通道的下载逻辑;新增常用同步检索配置的持久化存储与去重管理 API。
- [后端/日志] 重构日志系统,支持控制台 pretty 输出与每日滚动文件日志(使用上海 +08:00 时区),引入 HTTP 路由请求链路追踪。
- [前端/引力图] 升级引用星系图 canvas 交互:支持平移拖拽与滚轮缩放,添加引力圈轨道装饰及未导入文献的半透明视觉区分。
- [前端/控制台] 统一重构为扁平高对比度浅色纯中文控制台样式;重新设计文献详情弹窗与状态进度条。
- [数据库] 新增 papers 表的 doctype 字段及 sync_queries 检索配置表。
2026-06-10 17:29:07 +08:00

407 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<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);
const MAX_NODES = nodeLimit;
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);
// 根据被引用数量决定中心节点大小 (起步半径 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<string, number> = {};
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 (
<canvas
ref={canvasRef}
className="w-full h-full min-h-[450px]"
style={{ display: 'block' }}
/>
);
}