"use client"; import { useMemo, useRef, useCallback, useState, useEffect } from "react"; import ForceGraph2D from "react-force-graph-2d"; import { BookStructure, Chapter, Section, Subsection } from "@/types"; import { Button } from "@/components/ui/button"; import { RotateCcw, ZoomIn, ZoomOut } from "lucide-react"; import NodeDetailDialog from "./node-detail-dialog"; interface GraphNode { id: string; name: string; type: 'root' | 'chapter' | 'section'; nodeId?: number; // 节点在数据库中的ID 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 } interface GraphLink { source: string; target: string; type: 'root-chapter' | 'chapter-section'; } interface GraphData { nodes: GraphNode[]; links: GraphLink[]; } interface KnowledgeGraphProps { bookStructure: BookStructure; onNodeClick?: (node: GraphNode) => void; } // 标签尺寸配置(分级字体大小,不限制宽度) const LABEL_CONFIG = { root: { fontSize: 18, padding: 8, }, chapter: { fontSize: 15, padding: 6, }, section: { fontSize: 13, padding: 5, }, }; export default function KnowledgeGraph({ bookStructure, onNodeClick }: KnowledgeGraphProps) { const graphRef = useRef(null); const containerRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [dimensions, setDimensions] = useState({ width: 1200, height: 800 }); // 响应式尺寸计算 useEffect(() => { const updateDimensions = () => { if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setDimensions({ width: rect.width, height: Math.max(600, rect.height), }); } }; if (typeof window !== 'undefined') { updateDimensions(); window.addEventListener('resize', updateDimensions); return () => window.removeEventListener('resize', updateDimensions); } }, []); // 将书籍结构数据转换为图谱数据 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 }; 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 angle = chapterIndex * chapterAngleStep; const initialX = Math.cos(angle) * chapterRadius; const initialY = Math.sin(angle) * chapterRadius; // 创建章节节点 const chapterNode: GraphNode = { id: chapterNodeId, name: chapter.title, type: 'chapter', nodeId: chapter.id, nodeType: 'chapter', val: 18, // 章节节点大小 color: chapterColor, _fullLabel: chapter.title, x: initialX, y: initialY, }; nodes.push(chapterNode); // 连接根节点到章节节点 links.push({ source: 'root', target: chapterNodeId, type: 'root-chapter', }); // 检查是否有节数据 if (!chapter.sections || chapter.sections.length === 0) { console.warn(`章节 ${chapter.title} 没有节数据`); 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 sectionNode: GraphNode = { id: sectionNodeId, name: section.title, type: 'section', nodeId: section.id, nodeType: 'section', sectionData: { subsections: section.subsections || [] }, val: 12, // 节节点大小 color: chapterColor + 'CC', // 添加透明度 _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]); // 节点点击处理 const handleNodeClick = useCallback((node: GraphNode) => { // 只有非根节点才能点击查看详情 if (node.type !== 'root' && node.nodeId && node.nodeType) { setSelectedNode(node); setIsDialogOpen(true); } if (onNodeClick) { onNodeClick(node); } }, [onNodeClick]); // 重置视图 const handleResetView = useCallback(() => { if (graphRef.current) { graphRef.current.zoomToFit(400, 20); graphRef.current.centerAt(0, 0, 1000); } }, []); // 放大 const handleZoomIn = useCallback(() => { if (graphRef.current) { const currentZoom = graphRef.current.zoom() || 1; graphRef.current.zoom(currentZoom * 1.2, 200); } }, []); // 缩小 const handleZoomOut = useCallback(() => { if (graphRef.current) { const currentZoom = graphRef.current.zoom() || 1; graphRef.current.zoom(currentZoom * 0.8, 200); } }, []); return (
{/* 控制按钮 */}
{/* 知识图谱 */} 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 && ( )}
); }