/** * 知识图谱布局纯函数。 * * 从 knowledge-graph.tsx 抽离,便于单元测试。 */ import type { KnowledgePoint } from "./types" export interface GraphNode extends KnowledgePoint { x: number y: number } export interface GraphEdge { id: string x1: number y1: number x2: number y2: number } export interface GraphLayout { nodes: GraphNode[] edges: GraphEdge[] width: number height: number } /** 节点尺寸常量 */ export const NODE_WIDTH = 160 export const NODE_HEIGHT = 52 export const GAP_X = 40 export const GAP_Y = 90 /** * 计算知识图谱的分层布局。 * * 算法: * 1. 根据 parentId 构建父子关系 * 2. BFS 计算每个节点的层级(level) * 3. 同层节点按出现顺序水平排列 * 4. 生成节点坐标和边坐标 * * @param knowledgePoints 知识点列表 * @returns 图布局(节点带坐标、边、总宽高) */ export function computeGraphLayout( knowledgePoints: KnowledgePoint[] ): GraphLayout { if (knowledgePoints.length === 0) { return { nodes: [], edges: [], width: 0, height: 0 } } const byId = new Map() for (const kp of knowledgePoints) byId.set(kp.id, kp) const children = new Map() const roots: string[] = [] for (const kp of knowledgePoints) { if (kp.parentId && byId.has(kp.parentId)) { const arr = children.get(kp.parentId) ?? [] arr.push(kp.id) children.set(kp.parentId, arr) } else { roots.push(kp.id) } } const levelMap = new Map() const levels: string[][] = [] const queue = [...roots].map((id) => ({ id, level: 0 })) // 容错:如果没有任何根节点(全部循环引用),把所有节点放第 0 层 if (queue.length === 0) { for (const kp of knowledgePoints) queue.push({ id: kp.id, level: 0 }) } while (queue.length > 0) { const item = queue.shift() if (!item) continue if (levelMap.has(item.id)) continue levelMap.set(item.id, item.level) if (!levels[item.level]) levels[item.level] = [] levels[item.level].push(item.id) const kids = children.get(item.id) ?? [] for (const kid of kids) { if (!levelMap.has(kid)) queue.push({ id: kid, level: item.level + 1 }) } } // 容错:处理孤立节点(未在 BFS 中访问到) for (const kp of knowledgePoints) { if (!levelMap.has(kp.id)) { const level = levels.length levelMap.set(kp.id, level) if (!levels[level]) levels[level] = [] levels[level].push(kp.id) } } const maxCount = Math.max(...levels.map((l) => l.length), 1) const width = maxCount * (NODE_WIDTH + GAP_X) + GAP_X const height = levels.length * (NODE_HEIGHT + GAP_Y) + GAP_Y const positions = new Map() levels.forEach((ids, level) => { ids.forEach((id, index) => { const x = GAP_X + index * (NODE_WIDTH + GAP_X) const y = GAP_Y + level * (NODE_HEIGHT + GAP_Y) positions.set(id, { x, y }) }) }) const nodes = knowledgePoints.map((kp) => { const pos = positions.get(kp.id) ?? { x: GAP_X, y: GAP_Y } return { ...kp, x: pos.x, y: pos.y } }) const edges = knowledgePoints .filter((kp) => kp.parentId && positions.has(kp.parentId)) .map((kp) => { const parentId = kp.parentId as string const parentPos = positions.get(parentId) const childPos = positions.get(kp.id) // 类型守卫:两个位置都必须存在(已在 filter 中保证,但 TS 需要 narrowing) if (!parentPos || !childPos) { return null } return { id: `${parentId}-${kp.id}`, x1: parentPos.x + NODE_WIDTH / 2, y1: parentPos.y + NODE_HEIGHT, x2: childPos.x + NODE_WIDTH / 2, y2: childPos.y, } }) .filter((e): e is GraphEdge => e !== null) return { nodes, edges, width, height } }