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

原文链接

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

在流程编排、审批流、任务依赖图等场景中,节点通常通过有向边表达"谁先于谁"或"谁依赖谁"。节点数量增加后,完全依靠手动拖拽容易出现错位、重叠和连线混乱,因此可以根据图结构自动生成从左到右的分层布局。

这套实现可以准确概括为:

基于 Kahn 算法式拓扑遍历的最长路径分层,再将层级转换为画布坐标。

拓扑遍历解决"先处理哪个节点",最长路径计算解决"节点应该位于哪一列",坐标计算则负责确定节点在画布中的具体位置。

一、把画布抽象成有向图

在代码中,可以把画布建模成一个有向图:

  • 节点 node 是图中的顶点;
  • edge 表示从一个节点指向另一个节点;
  • predecessors 保存每个节点的前驱,即上游节点;
  • successors 保存每个节点的后继,即下游节点。

例如,下面的图表示两个分支最终汇聚到同一个节点:

text 复制代码
root ──> prepare ──> merge
  └──> validate ────┘

其中,root 是没有前驱的源节点,merge 有两个前驱节点。

代码中多次使用 String(node.id),这是必要的防御性处理。节点 ID 可能来自接口、本地状态或组件参数,统一转换为字符串,可以避免数字 1 和字符串 '1' 被误认为两个不同节点。

二、使用拓扑遍历计算层级

核心函数位于 layout.js

js 复制代码
function 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 列

初始化时,代码统计每个节点的前驱数量,并将其保存到 remainingPredecessors

js 复制代码
const count = predecessors.get(id).length
remainingPredecessors.set(id, count)

这个 Map 表示每个节点还有多少个上游节点未处理。

没有前驱的节点是源节点,可以直接设置为第 0 列,并加入队列:

js 复制代码
if (count === 0) {
  ranks.set(id, 0)
  queue.push(id)
}

2. 队列保证依赖顺序

代码按照队列顺序处理节点。处理某个节点时,会遍历它的所有后继,并将后继节点的剩余前驱数量减一:

js 复制代码
const remaining = remainingPredecessors.get(targetId) - 1
remainingPredecessors.set(targetId, remaining)

只有当目标节点的所有前驱都处理完成时,才将它加入队列:

js 复制代码
if (remaining === 0) {
  queue.push(targetId)
}

这种从入度为 0 的节点开始、处理后减少后继节点剩余入度的过程,属于 Kahn 算法式的拓扑遍历。对于无环图,它能够保证上游节点先于下游节点处理。

三、最长上游路径决定列级

整套实现最关键的代码是:

js 复制代码
ranks.set(
  targetId,
  Math.max(ranks.get(targetId) || 0, sourceRank + 1)
)

可以理解为:

js 复制代码
target.rank = Math.max(
  target.rank,
  source.rank + 1
)

它表示目标节点的层级必须取所有上游路径候选值中的最大值。

假设存在以下两条路径:

text 复制代码
root → A → C
root → B → D → C

则:

text 复制代码
rank(root) = 0
rank(A)    = 1
rank(B)    = 1
rank(D)    = 2

处理 A → C 时,得到候选值 2;处理 D → C 时,得到候选值 3。最终结果为:

text 复制代码
rank(C) = max(2, 3) = 3

因此,rank 可以理解为从任意源节点到当前节点的最长路径边数。

如果只采用第一次访问到的层级,C 可能被错误地放到第 2 列,较长的依赖链就会被压缩。使用 Math.max 可以避免这个问题。

这几个概念需要区分:

概念 作用
拓扑遍历 保证上游节点先于下游节点处理
最长路径动态更新 计算节点所在层级
rank 分组 将同一层节点放入同一列
坐标计算 将列级和行级转换为 xy

所以,这并不是单纯的"拓扑排序布局",而是:

text 复制代码
Kahn 算法式拓扑遍历 + 最长路径分层 + 基础坐标排布

四、有向环是当前实现的边界

这套算法理想上要求输入是 DAG,即有向无环图。

例如:

text 复制代码
A → B → C → A

环内节点不会出现"所有前驱都已处理完成"的时刻,因此不会进入队列。当前实现最后的兜底逻辑:

js 复制代码
nodes.forEach((node) => {
  const id = String(node.id)
  if (!ranks.has(id)) ranks.set(id, 0)
})

只能避免布局结果完全缺失,并没有真正解决环检测问题。环内节点会被统一降级到第 0 列,可能与真正的源节点混在一起。

如果需要更严格的处理,可以在遍历过程中统计实际出队并处理的节点数量,例如维护 processedCount。如果处理数量小于节点总数,就可以判断图中存在有向环,进而向用户返回明确提示,而不是静默降级。

五、将 rank 转换成画布坐标

得到每个节点的层级后,calculateCanvasAutoLayout 会先按 rank 分组,再计算具体位置:

js 复制代码
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 的结构可以理解为:

text 复制代码
Map<rank, nodes[]>

例如:

text 复制代码
rank 0 → root
rank 1 → level-1-a、level-1-b
rank 2 → level-2

这里完成了从图算法层级到视觉列级的映射:

text 复制代码
rank 0 → 第 0 列
rank 1 → 第 1 列
rank 2 → 第 2 列

2. 列级决定横坐标

初始横坐标由 originX 决定。每完成一列,横坐标增加当前列的最大宽度和列间距:

js 复制代码
columnX += columnWidth + config.columnGap

因此,rank 并不直接等于固定的 x 值。由于每列节点的宽度可能不同,实际横坐标必须结合节点尺寸计算。

3. 同列节点纵向排列

同一列中的节点从 originY 开始依次向下排列:

js 复制代码
rowY += size.height + config.rowGap

最终可以得到类似结果:

js 复制代码
[
  { 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. 为什么要计算列宽

同一列中可能有宽度不同的节点。使用列内最大宽度,可以让下一列从当前列的右边界之后开始,降低横向重叠的可能性:

js 复制代码
columnWidth = Math.max(columnWidth, size.width)

不过,这只是基础的列间距处理,并不等于完整的碰撞检测,也不能自动减少所有边交叉。

六、Hook 负责业务编排

布局函数只负责根据节点和边计算位置,不直接处理 Toast、Store 或视口调整。这些业务动作由 useCanvasAutoLayout 负责:

js 复制代码
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 }
}

它主要完成以下工作:

  1. 检查画布是否为空;
  2. 调用纯布局函数;
  3. 将坐标提交给 Store,并判断是否发生变化;
  4. 等待 Vue 响应式更新完成;
  5. 调整画布视口并提示用户。

nextTick 用于等待下一轮 DOM 更新完成,再执行 fitView,从而避免视口适配时仍使用旧的节点位置。Vue 官方文档也将 nextTick 定义为等待下一轮 DOM 更新刷新的工具。

七、Store 一次性提交全部位置

Store 中的 applyNodePositions 负责批量更新节点位置:

js 复制代码
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()

因此,自动布局会作为一次批量操作提交,而不是逐个节点修改并逐个保存:

text 复制代码
布局算法输出位置数组
        ↓
Store 批量更新节点
        ↓
判断是否发生变化
        ↓
调用一次 queuePersist

如果 queuePersist 内部还负责生成历史记录,那么该操作也可以被归并为一次历史记录;仅根据当前展示的 applyNodePositions 代码,能够确定的是它只调用了一次持久化入口。

八、依赖注入与完整调用链

FlowCanvas.vue 中,Hook 通过参数接收画布业务能力:

js 复制代码
const { arrangeCanvas } = useCanvasAutoLayout({
  nodes,
  edges,
  applyNodePositions,
  fitView: fitCanvasView,
})

Hook 不需要直接依赖具体 Store,只需要使用以下能力:

  • nodes:当前节点集合;
  • edges:当前边集合;
  • applyNodePositions:批量提交位置;
  • fitView:调整画布视口。

完整调用链为:

text 复制代码
点击整理
  ↓
arrangeCanvas()
  ↓
calculateCanvasAutoLayout()
  ↓
buildGraph()
  ↓
calculateRanks()
  ↓
按 rank 分列并计算坐标
  ↓
applyNodePositions()
  ↓
queuePersist()
  ↓
nextTick()
  ↓
fitView()

九、当前方案的适用范围与边界

当前实现已经完成:

  • 基于前驱和后继关系的图构建;
  • Kahn 算法式拓扑遍历;
  • 基于最长上游路径的层级分配;
  • rank 到列级的转换;
  • 列内纵向排列;
  • 基于节点宽高的基础间距计算;
  • Hook、Store、持久化和视口适配的业务串联。

但它还没有实现:

  • 同列节点的智能排序;
  • 边交叉最小化;
  • 完整的节点碰撞检测;
  • 跨多列边的虚拟节点或复杂路由;
  • 环结构的明确检测和具体环路提示;
  • 节点尺寸异步变化后的二次布局。

因此,这套实现更适合被定位为:

面向中小型流程图的轻量级自动布局基础实现。

如果后续需要更高的布局质量,可以继续增加同层排序、边交叉优化、碰撞检测和边路由,也可以引入专门的分层图布局引擎。Graphviz 的 dot 布局同样采用面向有向图的分层绘制思路,并提供方向、层间距等布局参数,但它还包含边交叉和边长度等更复杂的优化目标,因此不能简单等同于当前的基础列布局。

总结

这套自动布局的核心不是简单地依次遍历节点,而是将图算法与前端工程拆成几个明确步骤:

  1. 使用 predecessorssuccessors 建立有向图;
  2. 从入度为 0 的源节点开始进行 Kahn 算法式拓扑遍历;
  3. 使用 Math.max(target.rank, source.rank + 1) 计算最长上游路径层级;
  4. rank 转换为从左到右的列级;
  5. 根据节点尺寸和间距计算具体坐标;
  6. 由 Hook 负责业务编排、视口适配和用户提示;
  7. 由 Store 批量提交节点位置,并调用持久化逻辑。

一句话概括:

拓扑遍历解决"先处理谁",最长路径分层解决"放在哪一列",坐标计算解决"具体放在哪里",Hook 和 Store 则负责把算法结果安全地应用到真实画布中。

参考资料

相关推荐
way_hj1 天前
Flask笔记(2)快速web项目
笔记·flask·vue·web
Sterting1 天前
初识Vue3与开发环境搭建
前端·vue
sugar__salt2 天前
跟着 Demo 学 Pinia:两种仓库写法 + 完整 TodoList 复现
前端·javascript·vue.js·前端框架·vue
弹简特2 天前
【Vue3速成】09-Element Plus 核心组件实战与原理解析
vue·element-plus
钛态3 天前
AI 辅助:前端框架反模式:过度封装、状态滥用与副作用失控
前端·vue·react·web
by__csdn3 天前
Vue vs React vs Angular:前端框架终极对决
前端·vue.js·react.js·前端框架·vue·react·angular
钛态3 天前
前端框架选型决策框架:React、Vue、Svelte、Solid 的 2026 年横评
前端·vue·react·web
反正我还没长大3 天前
Vue3响应式原理深度解析:揭秘现代前端框架的核心引擎
vue.js·前端框架·vue·proxy模式
FeelTouch Labs3 天前
Node.js 中的 spawn 和 exec:子进程执行的不同方式对比
node.js·编辑器·vue·vim