/** * V5-8:画布自动布局 * * 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。 * 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。 */ import dagre from "@dagrejs/dagre"; import type { AnyLessonPlanNode, AnyLessonPlanEdge, } from "../types"; export interface AutoLayoutOptions { /** 布局方向:TB(上→下)/ LR(左→右),默认 TB */ direction?: "TB" | "LR"; /** 节点宽度,默认 240 */ nodeWidth?: number; /** 节点高度,默认 120 */ nodeHeight?: number; /** 节点水平间距,默认 40 */ rankSep?: number; /** 节点垂直间距,默认 60 */ nodeSep?: number; } /** * 计算自动布局,返回每个节点的新位置(含原始位置作为兜底) */ export function computeAutoLayout( nodes: AnyLessonPlanNode[], edges: AnyLessonPlanEdge[], options: AutoLayoutOptions = {}, ): Map { const { direction = "TB", nodeWidth = 240, nodeHeight = 120, rankSep = 60, nodeSep = 40, } = options; const result = new Map(); if (nodes.length === 0) return result; const g = new dagre.graphlib.Graph(); g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep }); g.setDefaultEdgeLabel(() => ({})); // 仅对教学节点布局(正文节点固定位置) const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content"); const layoutableIds = new Set(layoutableNodes.map((n) => n.id)); for (const node of layoutableNodes) { g.setNode(node.id, { width: nodeWidth, height: nodeHeight }); } // 仅添加两端都可布局的 flow 边 for (const edge of edges) { if (edge.type !== "flow") continue; if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue; g.setEdge(edge.source, edge.target); } // 孤立节点(无边)也要 setNode,dagre 会自动排列 dagre.layout(g); for (const node of layoutableNodes) { const laid = g.node(node.id); if (laid) { // dagre 返回中心点,React Flow 使用左上角,需减去半宽/半高 result.set(node.id, { x: laid.x - nodeWidth / 2, y: laid.y - nodeHeight / 2, }); } else { result.set(node.id, node.position); } } return result; }