fix: use config paths for static file mounts, redesign knowledge graph
- Fix generated images 404: use settings.generated_images_dir and settings.upload_dir instead of hardcoded relative paths in main.py - Redesign knowledge graph with p5-style particle background, glow nodes, and curved gradient links - Update course-content page border styling Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -139,12 +139,12 @@ app.include_router(course_content.router)
|
||||
app.include_router(forum.router)
|
||||
|
||||
# 静态文件服务
|
||||
if os.path.exists("uploads"):
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
if os.path.exists(settings.upload_dir):
|
||||
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
|
||||
|
||||
# 挂载生成图像目录
|
||||
if os.path.exists("generated_images"):
|
||||
app.mount("/generated_images", StaticFiles(directory="generated_images"), name="generated_images")
|
||||
if os.path.exists(settings.generated_images_dir):
|
||||
app.mount("/generated_images", StaticFiles(directory=settings.generated_images_dir), name="generated_images")
|
||||
|
||||
# 根路径
|
||||
@app.get("/")
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function CourseContentPage() {
|
||||
<>
|
||||
{/* 知识图谱视图 */}
|
||||
{viewMode === 'graph' && (
|
||||
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-border bg-background overflow-hidden">
|
||||
<div className="w-full h-[calc(100vh-250px)] min-h-[600px] lg:min-h-[700px] rounded-lg border border-white/10 overflow-hidden">
|
||||
<KnowledgeGraph bookStructure={bookStructure} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||
import ForceGraph2D from "react-force-graph-2d";
|
||||
import { BookStructure, Chapter, Section, Subsection } from "@/types";
|
||||
import { BookStructure } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import NodeDetailDialog from "./node-detail-dialog";
|
||||
@@ -11,16 +11,16 @@ interface GraphNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'root' | 'chapter' | 'section';
|
||||
nodeId?: number; // 节点在数据库中的ID
|
||||
nodeType?: 'chapter' | 'section'; // 用于详情对话框
|
||||
sectionData?: { subsections: Array<{ id: number; title: string }> }; // 存储节的小节(知识点)数据
|
||||
nodeId?: number;
|
||||
nodeType?: 'chapter' | 'section';
|
||||
sectionData?: { subsections: Array<{ id: number; title: string }> };
|
||||
val?: number;
|
||||
color?: string;
|
||||
fx?: number;
|
||||
fy?: number;
|
||||
x?: number; // 初始x位置
|
||||
y?: number; // 初始y位置
|
||||
_fullLabel?: string; // 完整标签用于tooltip
|
||||
x?: number;
|
||||
y?: number;
|
||||
_fullLabel?: string;
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
@@ -39,30 +39,25 @@ interface KnowledgeGraphProps {
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
}
|
||||
|
||||
// 标签尺寸配置(分级字体大小,不限制宽度)
|
||||
const LABEL_CONFIG = {
|
||||
root: {
|
||||
fontSize: 18,
|
||||
padding: 8,
|
||||
},
|
||||
chapter: {
|
||||
fontSize: 15,
|
||||
padding: 6,
|
||||
},
|
||||
section: {
|
||||
fontSize: 13,
|
||||
padding: 5,
|
||||
},
|
||||
};
|
||||
const CHAPTER_COLORS = [
|
||||
'#a78bfa',
|
||||
'#22d3ee',
|
||||
'#fbbf24',
|
||||
'#34d399',
|
||||
'#f472b6',
|
||||
'#60a5fa',
|
||||
];
|
||||
|
||||
const ROOT_COLOR = '#c4b5fd';
|
||||
|
||||
export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) {
|
||||
const graphRef = useRef<any>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const particleCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [dimensions, setDimensions] = useState({ width: 1200, height: 800 });
|
||||
|
||||
// 响应式尺寸计算
|
||||
useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -73,7 +68,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
@@ -81,136 +75,188 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 将书籍结构数据转换为图谱数据
|
||||
const graphData: GraphData = useMemo(() => {
|
||||
const nodes: GraphNode[] = [];
|
||||
const links: GraphLink[] = [];
|
||||
|
||||
// 调试信息
|
||||
console.log("知识图谱数据:", bookStructure);
|
||||
console.log("章节数量:", bookStructure?.chapters?.length || 0);
|
||||
|
||||
// 创建根节点(书籍,固定在中心)
|
||||
const rootNode: GraphNode = {
|
||||
id: 'root',
|
||||
name: bookStructure.book.title,
|
||||
type: 'root',
|
||||
val: 25, // 增大根节点
|
||||
color: '#3b82f6', // 蓝色
|
||||
fx: 0, // 固定x坐标
|
||||
fy: 0, // 固定y坐标
|
||||
_fullLabel: bookStructure.book.title, // 存储完整标签用于tooltip
|
||||
val: 25,
|
||||
color: ROOT_COLOR,
|
||||
fx: 0,
|
||||
fy: 0,
|
||||
_fullLabel: bookStructure.book.title,
|
||||
};
|
||||
nodes.push(rootNode);
|
||||
|
||||
// 检查是否有章节数据
|
||||
if (!bookStructure.chapters || bookStructure.chapters.length === 0) {
|
||||
console.warn("警告: 没有章节数据");
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
// 章节颜色配置
|
||||
const chapterColors = [
|
||||
'#8b5cf6', // 紫色
|
||||
'#ec4899', // 粉色
|
||||
'#f59e0b', // 橙色
|
||||
'#10b981', // 绿色
|
||||
'#06b6d4', // 青色
|
||||
'#ef4444', // 红色
|
||||
];
|
||||
|
||||
// 为每个章节创建节点和连接
|
||||
const chapterCount = bookStructure.chapters.length;
|
||||
const chapterAngleStep = (2 * Math.PI) / chapterCount;
|
||||
|
||||
|
||||
bookStructure.chapters.forEach((chapter, chapterIndex) => {
|
||||
console.log(`处理章节 ${chapterIndex + 1}:`, chapter.title, "节数量:", chapter.sections?.length || 0);
|
||||
const chapterNodeId = `chapter-${chapter.id}`;
|
||||
const chapterColor = chapterColors[chapterIndex % chapterColors.length];
|
||||
|
||||
// 计算章节节点的初始位置(圆形分布)
|
||||
const chapterRadius = 400;
|
||||
const chapterColor = CHAPTER_COLORS[chapterIndex % CHAPTER_COLORS.length];
|
||||
const angle = chapterIndex * chapterAngleStep;
|
||||
const initialX = Math.cos(angle) * chapterRadius;
|
||||
const initialY = Math.sin(angle) * chapterRadius;
|
||||
|
||||
// 创建章节节点
|
||||
const chapterRadius = 400;
|
||||
|
||||
const chapterNode: GraphNode = {
|
||||
id: chapterNodeId,
|
||||
name: chapter.title,
|
||||
type: 'chapter',
|
||||
nodeId: chapter.id,
|
||||
nodeType: 'chapter',
|
||||
val: 18, // 章节节点大小
|
||||
val: 18,
|
||||
color: chapterColor,
|
||||
_fullLabel: chapter.title,
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
x: Math.cos(angle) * chapterRadius,
|
||||
y: Math.sin(angle) * chapterRadius,
|
||||
};
|
||||
nodes.push(chapterNode);
|
||||
|
||||
// 连接根节点到章节节点
|
||||
links.push({
|
||||
source: 'root',
|
||||
target: chapterNodeId,
|
||||
type: 'root-chapter',
|
||||
});
|
||||
|
||||
// 检查是否有节数据
|
||||
if (!chapter.sections || chapter.sections.length === 0) {
|
||||
console.warn(`章节 ${chapter.title} 没有节数据`);
|
||||
return;
|
||||
}
|
||||
if (!chapter.sections || chapter.sections.length === 0) return;
|
||||
|
||||
// 为每个节创建节点
|
||||
const sectionCount = chapter.sections.length;
|
||||
const sectionAngleStep = sectionCount > 1 ? (2 * Math.PI) / sectionCount : 0;
|
||||
|
||||
|
||||
chapter.sections.forEach((section, sectionIndex) => {
|
||||
const sectionNodeId = `section-${section.id}`;
|
||||
|
||||
// 计算节节点的初始位置(围绕章节节点)
|
||||
const sectionRadius = 250;
|
||||
const sectionAngle = sectionAngleStep * sectionIndex;
|
||||
const sectionX = initialX + Math.cos(angle + sectionAngle) * sectionRadius;
|
||||
const sectionY = initialY + Math.sin(angle + sectionAngle) * sectionRadius;
|
||||
|
||||
// 创建节节点(包含小节数据,用于点击后展开)
|
||||
const sectionX = Math.cos(angle) * chapterRadius + Math.cos(angle + sectionAngle) * sectionRadius;
|
||||
const sectionY = Math.sin(angle) * chapterRadius + Math.sin(angle + sectionAngle) * sectionRadius;
|
||||
|
||||
const sectionNode: GraphNode = {
|
||||
id: sectionNodeId,
|
||||
name: section.title,
|
||||
type: 'section',
|
||||
nodeId: section.id,
|
||||
nodeType: 'section',
|
||||
sectionData: {
|
||||
subsections: section.subsections || []
|
||||
},
|
||||
val: 12, // 节节点大小
|
||||
color: chapterColor + 'CC', // 添加透明度
|
||||
sectionData: { subsections: section.subsections || [] },
|
||||
val: 12,
|
||||
color: chapterColor,
|
||||
_fullLabel: section.title,
|
||||
x: sectionX,
|
||||
y: sectionY,
|
||||
};
|
||||
nodes.push(sectionNode);
|
||||
|
||||
// 连接章节节点到节节点
|
||||
links.push({
|
||||
source: chapterNodeId,
|
||||
target: sectionNodeId,
|
||||
type: 'chapter-section',
|
||||
});
|
||||
|
||||
// 注意:小节(知识点)不在图谱中直接展示,点击节节点后在对话框中展开
|
||||
});
|
||||
});
|
||||
|
||||
console.log("图谱节点数量:", nodes.length, "链接数量:", links.length);
|
||||
return { nodes, links };
|
||||
}, [bookStructure]);
|
||||
|
||||
// 节点点击处理
|
||||
// p5-style ambient particle background
|
||||
useEffect(() => {
|
||||
const canvas = particleCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = dimensions.width;
|
||||
const H = dimensions.height;
|
||||
|
||||
canvas.width = W * dpr;
|
||||
canvas.height = H * dpr;
|
||||
canvas.style.width = `${W}px`;
|
||||
canvas.style.height = `${H}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const particleCount = 40;
|
||||
const connectDist = 130;
|
||||
const particles = Array.from({ length: particleCount }, () => ({
|
||||
x: Math.random() * W,
|
||||
y: Math.random() * H,
|
||||
vx: (Math.random() - 0.5) * 0.2,
|
||||
vy: (Math.random() - 0.5) * 0.2,
|
||||
size: Math.random() * 1.5 + 0.5,
|
||||
hue: 210 + Math.random() * 60,
|
||||
alpha: Math.random() * 0.35 + 0.1,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
}));
|
||||
|
||||
let animId: number;
|
||||
let time = 0;
|
||||
|
||||
const animate = () => {
|
||||
time += 0.01;
|
||||
ctx.fillStyle = '#0a0a1a';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// update positions
|
||||
for (const p of particles) {
|
||||
p.x += p.vx + Math.sin(time + p.phase) * 0.05;
|
||||
p.y += p.vy + Math.cos(time + p.phase) * 0.05;
|
||||
if (p.x < 0 || p.x > W) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > H) p.vy *= -1;
|
||||
p.x = Math.max(0, Math.min(W, p.x));
|
||||
p.y = Math.max(0, Math.min(H, p.y));
|
||||
}
|
||||
|
||||
// draw connections
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < connectDist) {
|
||||
const a = (1 - dist / connectDist) * 0.06;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.strokeStyle = `rgba(120, 140, 220, ${a})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw particles
|
||||
for (const p of particles) {
|
||||
const pulse = Math.sin(time * 2 + p.phase) * 0.3 + 0.7;
|
||||
|
||||
// glow
|
||||
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 6);
|
||||
grd.addColorStop(0, `hsla(${p.hue}, 60%, 60%, ${p.alpha * 0.3 * pulse})`);
|
||||
grd.addColorStop(1, `hsla(${p.hue}, 60%, 60%, 0)`);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size * 6, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// core
|
||||
ctx.fillStyle = `hsla(${p.hue}, 60%, 70%, ${p.alpha * pulse})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
return () => cancelAnimationFrame(animId);
|
||||
}, [dimensions]);
|
||||
|
||||
const handleNodeClick = useCallback((node: GraphNode) => {
|
||||
// 只有非根节点才能点击查看详情
|
||||
if (node.type !== 'root' && node.nodeId && node.nodeType) {
|
||||
setSelectedNode(node);
|
||||
setIsDialogOpen(true);
|
||||
@@ -220,7 +266,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
}
|
||||
}, [onNodeClick]);
|
||||
|
||||
// 重置视图
|
||||
const handleResetView = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
graphRef.current.zoomToFit(400, 20);
|
||||
@@ -228,7 +273,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 放大
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
const currentZoom = graphRef.current.zoom() || 1;
|
||||
@@ -236,7 +280,6 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 缩小
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (graphRef.current) {
|
||||
const currentZoom = graphRef.current.zoom() || 1;
|
||||
@@ -246,13 +289,184 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full h-full">
|
||||
{/* 控制按钮 */}
|
||||
{/* p5-style particle background */}
|
||||
<canvas
|
||||
ref={particleCanvasRef}
|
||||
className="absolute inset-0"
|
||||
style={{ zIndex: 0 }}
|
||||
/>
|
||||
|
||||
{/* Force graph layer */}
|
||||
<div className="absolute inset-0" style={{ zIndex: 1 }}>
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
backgroundColor="rgba(10, 10, 26, 0.88)"
|
||||
nodeLabel={(node: any) => node._fullLabel || node.name}
|
||||
nodeVal={(node: any) => node.val || 8}
|
||||
nodeRelSize={6}
|
||||
d3AlphaDecay={0.02}
|
||||
d3AlphaMin={0.005}
|
||||
cooldownTicks={200}
|
||||
onNodeClick={(node: any) => handleNodeClick(node)}
|
||||
onNodeDragEnd={(node: any) => {
|
||||
if (node.id !== 'root') {
|
||||
node.fx = node.x;
|
||||
node.fy = node.y;
|
||||
}
|
||||
}}
|
||||
onNodeHover={(node: any) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.body.style.cursor = node ? 'pointer' : 'default';
|
||||
}
|
||||
}}
|
||||
onEngineStop={() => {
|
||||
if (graphRef.current) {
|
||||
graphRef.current.centerAt(0, 0, 1000);
|
||||
graphRef.current.zoomToFit(400, 30);
|
||||
}
|
||||
}}
|
||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (!isFinite(node.x) || !isFinite(node.y)) return;
|
||||
const label = node.name;
|
||||
const color = node.color || '#a78bfa';
|
||||
const nodeRadius = Math.max(3, (node.val || 8) * 0.6);
|
||||
|
||||
// 1. outer glow halo
|
||||
const glowR = nodeRadius * 8;
|
||||
const grd = ctx.createRadialGradient(
|
||||
node.x, node.y, 0,
|
||||
node.x, node.y, glowR
|
||||
);
|
||||
grd.addColorStop(0, color + '25');
|
||||
grd.addColorStop(0.4, color + '0c');
|
||||
grd.addColorStop(1, color + '00');
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, glowR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 2. inner glow
|
||||
const innerR = nodeRadius * 3;
|
||||
const grd2 = ctx.createRadialGradient(
|
||||
node.x, node.y, 0,
|
||||
node.x, node.y, innerR
|
||||
);
|
||||
grd2.addColorStop(0, color + '70');
|
||||
grd2.addColorStop(1, color + '00');
|
||||
ctx.fillStyle = grd2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, innerR, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// 3. core
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, nodeRadius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
|
||||
// 4. bright center spot
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x, node.y, nodeRadius * 0.3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
||||
ctx.fill();
|
||||
|
||||
// 5. label
|
||||
const fontSize = Math.max(9, 13 / globalScale);
|
||||
ctx.font = `600 ${fontSize}px "Noto Serif SC", Georgia, serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const tw = ctx.measureText(label).width;
|
||||
const pad = 4 / globalScale;
|
||||
const labelY = node.y + nodeRadius + fontSize / 2 + pad * 3;
|
||||
|
||||
// label background pill
|
||||
const bw = tw + pad * 3;
|
||||
const bh = fontSize + pad * 2;
|
||||
const r = Math.max(2, 3 / globalScale);
|
||||
const lx = node.x - bw / 2;
|
||||
const ly = labelY - bh / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(10, 10, 26, 0.85)';
|
||||
ctx.strokeStyle = color + '40';
|
||||
ctx.lineWidth = Math.max(0.5, 0.8 / globalScale);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx + r, ly);
|
||||
ctx.lineTo(lx + bw - r, ly);
|
||||
ctx.quadraticCurveTo(lx + bw, ly, lx + bw, ly + r);
|
||||
ctx.lineTo(lx + bw, ly + bh - r);
|
||||
ctx.quadraticCurveTo(lx + bw, ly + bh, lx + bw - r, ly + bh);
|
||||
ctx.lineTo(lx + r, ly + bh);
|
||||
ctx.quadraticCurveTo(lx, ly + bh, lx, ly + bh - r);
|
||||
ctx.lineTo(lx, ly + r);
|
||||
ctx.quadraticCurveTo(lx, ly, lx + r, ly);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// label text
|
||||
ctx.fillStyle = '#e2e8f0';
|
||||
ctx.fillText(label, node.x, labelY);
|
||||
}}
|
||||
linkCanvasObjectMode={() => 'replace'}
|
||||
linkCanvasObject={(link: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const src = link.source;
|
||||
const tgt = link.target;
|
||||
if (!isFinite(src.x) || !isFinite(src.y) || !isFinite(tgt.x) || !isFinite(tgt.y)) return;
|
||||
|
||||
const srcColor = src.color || '#a78bfa';
|
||||
const tgtColor = tgt.color || '#a78bfa';
|
||||
|
||||
const dx = tgt.x - src.x;
|
||||
const dy = tgt.y - src.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
|
||||
const curvature = 0.12;
|
||||
const cpX = (src.x + tgt.x) / 2 + (-dy / len) * len * curvature;
|
||||
const cpY = (src.y + tgt.y) / 2 + (dx / len) * len * curvature;
|
||||
|
||||
const gradient = ctx.createLinearGradient(src.x, src.y, tgt.x, tgt.y);
|
||||
gradient.addColorStop(0, srcColor + '45');
|
||||
gradient.addColorStop(1, tgtColor + '45');
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(src.x, src.y);
|
||||
ctx.quadraticCurveTo(cpX, cpY, tgt.x, tgt.y);
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = Math.max(
|
||||
0.5,
|
||||
(link.type === 'root-chapter' ? 1.8 : 0.8) / Math.max(0.3, globalScale)
|
||||
);
|
||||
ctx.stroke();
|
||||
|
||||
// subtle glow line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(src.x, src.y);
|
||||
ctx.quadraticCurveTo(cpX, cpY, tgt.x, tgt.y);
|
||||
const glowGradient = ctx.createLinearGradient(src.x, src.y, tgt.x, tgt.y);
|
||||
glowGradient.addColorStop(0, srcColor + '12');
|
||||
glowGradient.addColorStop(1, tgtColor + '12');
|
||||
ctx.strokeStyle = glowGradient;
|
||||
ctx.lineWidth = Math.max(
|
||||
1.5,
|
||||
(link.type === 'root-chapter' ? 5 : 3) / Math.max(0.3, globalScale)
|
||||
);
|
||||
ctx.stroke();
|
||||
}}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleZoomIn}
|
||||
className="bg-background/80 backdrop-blur-sm"
|
||||
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
|
||||
title="放大"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
@@ -261,7 +475,7 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleZoomOut}
|
||||
className="bg-background/80 backdrop-blur-sm"
|
||||
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
|
||||
title="缩小"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
@@ -270,134 +484,24 @@ export default function KnowledgeGraph({ bookStructure, onNodeClick }: Knowledge
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleResetView}
|
||||
className="bg-background/80 backdrop-blur-sm"
|
||||
className="bg-black/40 backdrop-blur-sm border-white/10 text-white hover:bg-white/10 hover:text-white"
|
||||
title="重置视图"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 知识图谱 */}
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
nodeLabel={(node: any) => node._fullLabel || node.name}
|
||||
nodeColor={(node: any) => node.color || '#3b82f6'}
|
||||
nodeVal={(node: any) => node.val || 8}
|
||||
nodeRelSize={6}
|
||||
// 碰撞检测和力衰减优化
|
||||
d3AlphaDecay={0.02}
|
||||
d3AlphaMin={0.005}
|
||||
cooldownTicks={200}
|
||||
linkColor={(link: any) => {
|
||||
if (link.type === 'root-chapter') {
|
||||
return '#64748b'; // 灰色
|
||||
}
|
||||
return link.source.color || '#94a3b8'; // 使用章节颜色
|
||||
}}
|
||||
linkWidth={(link: any) => {
|
||||
if (link.type === 'root-chapter') {
|
||||
return 3;
|
||||
}
|
||||
return 2;
|
||||
}}
|
||||
linkDirectionalArrowLength={6}
|
||||
linkDirectionalArrowRelPos={1}
|
||||
linkCurvature={0.15}
|
||||
onNodeClick={(node: any) => handleNodeClick(node)}
|
||||
onNodeDragEnd={(node: any) => {
|
||||
// 保持根节点固定
|
||||
if (node.id !== 'root') {
|
||||
node.fx = node.x;
|
||||
node.fy = node.y;
|
||||
}
|
||||
}}
|
||||
onNodeHover={(node: any) => {
|
||||
if (typeof document !== 'undefined') {
|
||||
if (node) {
|
||||
document.body.style.cursor = 'pointer';
|
||||
} else {
|
||||
document.body.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
}}
|
||||
onEngineStop={() => {
|
||||
if (graphRef.current) {
|
||||
// 确保根节点在中心
|
||||
graphRef.current.centerAt(0, 0, 1000);
|
||||
graphRef.current.zoomToFit(400, 30);
|
||||
}
|
||||
}}
|
||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const label = node.name;
|
||||
|
||||
// 获取分级标签配置(只使用字体大小和padding,不限制宽度)
|
||||
const config = LABEL_CONFIG[node.type as 'root' | 'chapter' | 'section'] || LABEL_CONFIG.section;
|
||||
const fontSize = Math.max(9, config.fontSize / globalScale);
|
||||
const padding = config.padding / globalScale;
|
||||
|
||||
// 设置字体
|
||||
ctx.font = `bold ${fontSize}px "Microsoft YaHei", "SimHei", "Arial", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// 测量完整文本尺寸(不截断)
|
||||
const textWidth = ctx.measureText(label).width;
|
||||
const textHeight = fontSize;
|
||||
|
||||
// 计算标签位置(在节点下方,根据节点大小调整间距)
|
||||
const nodeRadius = node.val || 8;
|
||||
const labelY = node.y + nodeRadius + textHeight / 2 + padding * 2;
|
||||
const labelX = node.x;
|
||||
|
||||
// 计算背景尺寸(完全根据实际文本宽度,不限制)
|
||||
const bgWidth = textWidth + padding * 2;
|
||||
const bgHeight = textHeight + padding * 2;
|
||||
const radius = Math.max(2, 4 / globalScale);
|
||||
|
||||
// 背景色(根据节点类型调整透明度)
|
||||
const bgAlpha = node.type === 'knowledge' ? 0.92 : 0.95;
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${bgAlpha})`;
|
||||
ctx.strokeStyle = node.color || '#3b82f6';
|
||||
ctx.lineWidth = Math.max(1, 1.5 / globalScale);
|
||||
|
||||
// 绘制圆角矩形背景
|
||||
const x = labelX - bgWidth / 2;
|
||||
const y = labelY - textHeight / 2 - padding;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + bgWidth - radius, y);
|
||||
ctx.quadraticCurveTo(x + bgWidth, y, x + bgWidth, y + radius);
|
||||
ctx.lineTo(x + bgWidth, y + bgHeight - radius);
|
||||
ctx.quadraticCurveTo(x + bgWidth, y + bgHeight, x + bgWidth - radius, y + bgHeight);
|
||||
ctx.lineTo(x + radius, y + bgHeight);
|
||||
ctx.quadraticCurveTo(x, y + bgHeight, x, y + bgHeight - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制完整文本(使用节点颜色,不截断)
|
||||
ctx.fillStyle = node.color || '#3b82f6';
|
||||
ctx.fillText(label, labelX, labelY);
|
||||
}}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
/>
|
||||
|
||||
{/* 节点详情对话框 */}
|
||||
{selectedNode && selectedNode.nodeId && selectedNode.nodeType && (
|
||||
<NodeDetailDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
nodeType={selectedNode.nodeType}
|
||||
nodeId={selectedNode.nodeId}
|
||||
nodeTitle={selectedNode.name}
|
||||
subsections={selectedNode.sectionData?.subsections}
|
||||
/>
|
||||
)}
|
||||
{/* Node detail dialog */}
|
||||
{selectedNode && selectedNode.nodeId && selectedNode.nodeType && (
|
||||
<NodeDetailDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={setIsDialogOpen}
|
||||
nodeType={selectedNode.nodeType}
|
||||
nodeId={selectedNode.nodeId}
|
||||
nodeTitle={selectedNode.name}
|
||||
subsections={selectedNode.sectionData?.subsections}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user