ddbb79b9f6
单容器 Docker 架构的国土空间规划课程智能问答系统,集成 FastAPI 后端与 Next.js 前端。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
"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<any>(null);
|
|
const containerRef = useRef<HTMLDivElement>(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) {
|
|
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 (
|
|
<div ref={containerRef} className="relative w-full h-full">
|
|
{/* 控制按钮 */}
|
|
<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"
|
|
title="放大"
|
|
>
|
|
<ZoomIn className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleZoomOut}
|
|
className="bg-background/80 backdrop-blur-sm"
|
|
title="缩小"
|
|
>
|
|
<ZoomOut className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleResetView}
|
|
className="bg-background/80 backdrop-blur-sm"
|
|
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}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|