多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于拓扑排序的画布自动布局:用最长上游路径决定节点列级

基于拓扑排序的画布自动布局:用最长上游路径决定节点列级 原文链接基于拓扑排序的画布自动布局用最长上游路径决定节点列级在流程编排、审批流、任务依赖图等场景中节点通常通过有向边表达“谁先于谁”或“谁依赖谁”。节点数量增加后完全依靠手动拖拽容易出现错位、重叠和连线混乱因此可以根据图结构自动生成从左到右的分层布局。这套实现可以准确概括为基于 Kahn 算法式拓扑遍历的最长路径分层再将层级转换为画布坐标。拓扑遍历解决“先处理哪个节点”最长路径计算解决“节点应该位于哪一列”坐标计算则负责确定节点在画布中的具体位置。一、把画布抽象成有向图在代码中可以把画布建模成一个有向图节点node是图中的顶点边edge表示从一个节点指向另一个节点predecessors保存每个节点的前驱即上游节点successors保存每个节点的后继即下游节点。例如下面的图表示两个分支最终汇聚到同一个节点root ── prepare ── merge └── validate ────┘其中root是没有前驱的源节点merge有两个前驱节点。代码中多次使用String(node.id)这是必要的防御性处理。节点 ID 可能来自接口、本地状态或组件参数统一转换为字符串可以避免数字1和字符串1被误认为两个不同节点。二、使用拓扑遍历计算层级核心函数位于layout.jsfunction calculateRanks(nodes, predecessors, successors) { const ranks new Map() const remainingPredecessors new Map() const queue [] nodes.forEach((node) { const id String(node.id) const count predecessors.get(id).length remainingPredecessors.set(id, count) // 没有上游的节点从第 0 列开始 if (count 0) { ranks.set(id, 0) queue.push(id) } }) for (let index 0; index queue.length; index 1) { const sourceId queue[index] const sourceRank ranks.get(sourceId) || 0 successors.get(sourceId).forEach((targetId) { // 目标节点取所有上游路径中的最大层级 1 ranks.set( targetId, Math.max(ranks.get(targetId) || 0, sourceRank 1) ) const remaining remainingPredecessors.get(targetId) - 1 remainingPredecessors.set(targetId, remaining) if (remaining 0) { queue.push(targetId) } }) } // 环形结构无法计算最长路径基础版统一降级到第 0 列 nodes.forEach((node) { const id String(node.id) if (!ranks.has(id)) ranks.set(id, 0) }) return ranks }1. 源节点进入第 0 列初始化时代码统计每个节点的前驱数量并将其保存到remainingPredecessorsconst count predecessors.get(id).length remainingPredecessors.set(id, count)这个 Map 表示每个节点还有多少个上游节点未处理。没有前驱的节点是源节点可以直接设置为第 0 列并加入队列if (count 0) { ranks.set(id, 0) queue.push(id) }2. 队列保证依赖顺序代码按照队列顺序处理节点。处理某个节点时会遍历它的所有后继并将后继节点的剩余前驱数量减一const remaining remainingPredecessors.get(targetId) - 1 remainingPredecessors.set(targetId, remaining)只有当目标节点的所有前驱都处理完成时才将它加入队列if (remaining 0) { queue.push(targetId) }这种从入度为 0 的节点开始、处理后减少后继节点剩余入度的过程属于 Kahn 算法式的拓扑遍历。对于无环图它能够保证上游节点先于下游节点处理。三、最长上游路径决定列级整套实现最关键的代码是ranks.set( targetId, Math.max(ranks.get(targetId) || 0, sourceRank 1) )可以理解为target.rank Math.max( target.rank, source.rank 1 )它表示目标节点的层级必须取所有上游路径候选值中的最大值。假设存在以下两条路径root → A → C root → B → D → C则rank(root) 0 rank(A) 1 rank(B) 1 rank(D) 2处理A → C时得到候选值2处理D → C时得到候选值3。最终结果为rank(C) max(2, 3) 3因此rank可以理解为从任意源节点到当前节点的最长路径边数。如果只采用第一次访问到的层级C可能被错误地放到第 2 列较长的依赖链就会被压缩。使用Math.max可以避免这个问题。这几个概念需要区分概念作用拓扑遍历保证上游节点先于下游节点处理最长路径动态更新计算节点所在层级rank分组将同一层节点放入同一列坐标计算将列级和行级转换为x、y所以这并不是单纯的“拓扑排序布局”而是Kahn 算法式拓扑遍历 最长路径分层 基础坐标排布四、有向环是当前实现的边界这套算法理想上要求输入是 DAG即有向无环图。例如A → B → C → A环内节点不会出现“所有前驱都已处理完成”的时刻因此不会进入队列。当前实现最后的兜底逻辑nodes.forEach((node) { const id String(node.id) if (!ranks.has(id)) ranks.set(id, 0) })只能避免布局结果完全缺失并没有真正解决环检测问题。环内节点会被统一降级到第 0 列可能与真正的源节点混在一起。如果需要更严格的处理可以在遍历过程中统计实际出队并处理的节点数量例如维护processedCount。如果处理数量小于节点总数就可以判断图中存在有向环进而向用户返回明确提示而不是静默降级。五、将rank转换成画布坐标得到每个节点的层级后calculateCanvasAutoLayout会先按rank分组再计算具体位置export function calculateCanvasAutoLayout(nodes [], edges [], options {}) { const config { columnGap: 150, rowGap: 64, originX: 80, originY: 80, ...options, } const { predecessors, successors } buildGraph(nodes, edges) const ranks calculateRanks(nodes, predecessors, successors) const columns new Map() nodes.forEach((node) { const rank ranks.get(String(node.id)) || 0 if (!columns.has(rank)) { columns.set(rank, []) } columns.get(rank).push(node) }) const positions [] let columnX config.originX for (let rank 0; rank Math.max(...columns.keys()); rank 1) { const column columns.get(rank) || [] let rowY config.originY let columnWidth 0 column.forEach((node) { const size getNodeSize(node) positions.push({ id: String(node.id), position: { x: columnX, y: rowY, }, }) rowY size.height config.rowGap columnWidth Math.max(columnWidth, size.width) }) columnX columnWidth config.columnGap } return positions }1. 按层级分列columns的结构可以理解为Maprank, nodes[]例如rank 0 → root rank 1 → level-1-a、level-1-b rank 2 → level-2这里完成了从图算法层级到视觉列级的映射rank 0 → 第 0 列 rank 1 → 第 1 列 rank 2 → 第 2 列2. 列级决定横坐标初始横坐标由originX决定。每完成一列横坐标增加当前列的最大宽度和列间距columnX columnWidth config.columnGap因此rank并不直接等于固定的x值。由于每列节点的宽度可能不同实际横坐标必须结合节点尺寸计算。3. 同列节点纵向排列同一列中的节点从originY开始依次向下排列rowY size.height config.rowGap最终可以得到类似结果[ { id: root, position: { x: 80, y: 80 } }, { id: level-1-a, position: { x: 496, y: 80 } }, { id: level-1-b, position: { x: 496, y: 344 } }, { id: level-2, position: { x: 912, y: 80 } }, ]具体的x值取决于前面各列的实际最大宽度和配置间距因此示例坐标只是可能的输出不是固定结果。4. 为什么要计算列宽同一列中可能有宽度不同的节点。使用列内最大宽度可以让下一列从当前列的右边界之后开始降低横向重叠的可能性columnWidth Math.max(columnWidth, size.width)不过这只是基础的列间距处理并不等于完整的碰撞检测也不能自动减少所有边交叉。六、Hook 负责业务编排布局函数只负责根据节点和边计算位置不直接处理 Toast、Store 或视口调整。这些业务动作由useCanvasAutoLayout负责export function useCanvasAutoLayout({ nodes, edges, applyNodePositions, fitView, }) { async function arrangeCanvas() { if (!nodes.value.length) { globalToast.warning(画布中暂无可整理的节点) return false } const positions calculateCanvasAutoLayout( nodes.value, edges.value ) const changed applyNodePositions(positions) if (!changed) return false await nextTick() await fitView?.() globalToast.success(已整理 ${positions.length} 个节点) return true } return { arrangeCanvas } }它主要完成以下工作检查画布是否为空调用纯布局函数将坐标提交给 Store并判断是否发生变化等待 Vue 响应式更新完成调整画布视口并提示用户。nextTick用于等待下一轮 DOM 更新完成再执行fitView从而避免视口适配时仍使用旧的节点位置。Vue 官方文档也将nextTick定义为等待下一轮 DOM 更新刷新的工具。七、Store 一次性提交全部位置Store 中的applyNodePositions负责批量更新节点位置applyNodePositions(items []) { const positions new Map( items.map((item) [ String(item.id), item.position, ]) ) let changed false this.nodes this.nodes.map((node) { const position positions.get(String(node.id)) if (!position) return node const nextPosition { x: Math.round(Number(position.x) || 0), y: Math.round(Number(position.y) || 0), } if ( node.position?.x nextPosition.x node.position?.y nextPosition.y ) { return node } changed true return { ...node, position: nextPosition, } }) if (!changed) return false // 一次保存是否产生历史记录取决于 queuePersist 的具体实现 this.queuePersist() return true }这里的处理重点有四个使用Map根据节点 ID 快速查找目标位置只更新位置结果中包含的节点将坐标统一转换为数字并取整只有在确实发生变化时才调用queuePersist()。因此自动布局会作为一次批量操作提交而不是逐个节点修改并逐个保存布局算法输出位置数组 ↓ Store 批量更新节点 ↓ 判断是否发生变化 ↓ 调用一次 queuePersist如果queuePersist内部还负责生成历史记录那么该操作也可以被归并为一次历史记录仅根据当前展示的applyNodePositions代码能够确定的是它只调用了一次持久化入口。八、依赖注入与完整调用链在FlowCanvas.vue中Hook 通过参数接收画布业务能力const { arrangeCanvas } useCanvasAutoLayout({ nodes, edges, applyNodePositions, fitView: fitCanvasView, })Hook 不需要直接依赖具体 Store只需要使用以下能力nodes当前节点集合edges当前边集合applyNodePositions批量提交位置fitView调整画布视口。完整调用链为点击整理 ↓ arrangeCanvas() ↓ calculateCanvasAutoLayout() ↓ buildGraph() ↓ calculateRanks() ↓ 按 rank 分列并计算坐标 ↓ applyNodePositions() ↓ queuePersist() ↓ nextTick() ↓ fitView()九、当前方案的适用范围与边界当前实现已经完成基于前驱和后继关系的图构建Kahn 算法式拓扑遍历基于最长上游路径的层级分配rank到列级的转换列内纵向排列基于节点宽高的基础间距计算Hook、Store、持久化和视口适配的业务串联。但它还没有实现同列节点的智能排序边交叉最小化完整的节点碰撞检测跨多列边的虚拟节点或复杂路由环结构的明确检测和具体环路提示节点尺寸异步变化后的二次布局。因此这套实现更适合被定位为面向中小型流程图的轻量级自动布局基础实现。如果后续需要更高的布局质量可以继续增加同层排序、边交叉优化、碰撞检测和边路由也可以引入专门的分层图布局引擎。Graphviz 的dot布局同样采用面向有向图的分层绘制思路并提供方向、层间距等布局参数但它还包含边交叉和边长度等更复杂的优化目标因此不能简单等同于当前的基础列布局。总结这套自动布局的核心不是简单地依次遍历节点而是将图算法与前端工程拆成几个明确步骤使用predecessors和successors建立有向图从入度为 0 的源节点开始进行 Kahn 算法式拓扑遍历使用Math.max(target.rank, source.rank 1)计算最长上游路径层级将rank转换为从左到右的列级根据节点尺寸和间距计算具体坐标由 Hook 负责业务编排、视口适配和用户提示由 Store 批量提交节点位置并调用持久化逻辑。一句话概括拓扑遍历解决“先处理谁”最长路径分层解决“放在哪一列”坐标计算解决“具体放在哪里”Hook 和 Store 则负责把算法结果安全地应用到真实画布中。参考资料dag_longest_path — NetworkX 文档Directed Acyclic Graphs 算法 — NetworkX 文档dot 分层布局 — Graphviz 文档rankdir — Graphviz 文档ranksep — Graphviz 文档Vue 全局 APInextTick
返回列表