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