正交线是流程图中最常用的连线形式了,它可以准确的表达图形元素之间的逻辑关系,使用起来既简单又美观,下图黑色的连线就是一条正交连线:
所谓正交连线就是它的线段一定是正相交的,从上图也可以基本看出流程图中正交连线的特点:
-
连线不可穿越图形元素
-
连线一般是最短的
-
为了美观,连线一般要基于中线对称
-
环绕图形元素的场景需要预留一定的间距(下图示意)
今天主要来分享下这样的正交连线路径是如何实现的,核心分为三个部分:
-
思路说明
-
最短路径算法
-
算法执行过程
思路说明
思路一:基于穷举
最初我们在实现连线的时候对这块技术没有很了解,所以直接借鉴了 react-flow 的连线实现,它是一种简单的实现方式:对两个节点的位置关系及连接点方位做情况区分,然后基于不同的情况确定不同的连线规则。
我将这种实现定义为穷举,这种思路的主要问题是它要考虑考虑和区分的情况非常多,即使做了分类情况也可能非常多。
思路二:基于最短路径算法
这个也是社区讨论和实现比较多的一种方案,因为正交连线和基于图的最短路径有一些相似之处,图形的正交连线本质上是一条最短路径,而且在最短路径算法中还有类似于 A* 这种的既高效又可以灵活扩展的实现,我们今天主要就分享基于最短路径算法实现正交连线,它相较于穷举的思路的优点是它把复杂的情况区分简单化了,不同的情况统统指向最短路径,所以就用最短路径算法来化解复杂度。
最短路径算法
这里简单介绍最短路径算法,相信大家也应该并不陌生,它是基于图数据结构的算法,常见的实现有迪杰斯特拉算法(Dijkstra)、A* 算法等,这里对这个两种算法进行简单的介绍,以便大家更好的理解我们用到的 A* 算法,了解它为什么可以更好的匹配正交连线需求。
为了更好的理解这两种我们先从【广度优先搜索】算法开始。
广度优先搜索(Breadth First Search)
在各个方向上平等地探索。 这是一种非常有用的算法,不仅适用于常规寻路,还适用于程序地图生成、流场寻路、距离图和其他类型的地图分析。
迪杰斯特拉算法(Dijkstra's Algorithm)
(也称为统一成本搜索)让我们可以优先考虑要探索的路径。 它不是平等地探索所有可能的路径,而是倾向于成本较低的路径。 我们可以分配较低的成本来鼓励在某些路径上移动,分配更高的成本来在避免特定的路径移动等等。 当要考虑移动成本时,我们使用它而不是广度优先搜索,并且 Dijkstra 在成本计算过程中增加了缓存,所以它相较于广度优先搜索效率也更高一些。
A*
A* 是 Dijkstra 算法的修改版,针对单个目的地进行了优化。 Dijkstra算法可以找到到达所有地点的路径,A* 查找到一个位置或多个位置中最近的位置的路径。 它优先考虑那些似乎更接近目标的路径,这个过程通过增加 Heuristic 函数实现。
Heuristic 函数(启发函数)
通过广度优先搜索和 Dijkstra 算法,边界向各个方向扩展。 如果您试图找到通往所有位置或许多位置的路径,这是一个合理的选择。 然而,一种常见的情况是找到一条仅到达一个位置的路径。 让我们将边界向目标扩展,而不是向其他方向扩展。 这是,我们就需要一个启发式函数,它告诉我们离目标有多近:
css
def heuristic(a, b): # Manhattan distance on a square grid return abs(a.x - b.x) + abs(a.y - b.y)
这个启发函数逻辑也很简单,就是计算扩展的边界点与最终目标的水平和垂直距离总和,这样得到的结果是:算法会倾向先向目标位置所在的边界扩展。
算法说明
首先 Dijkstra 和 A* 都是以 Breadth First Search 算法为基础进行的。
Dijkstra 在计算最短路径的过程中增加了一个 cost 的概念,就是通过不同路径的成本花销,比如屏幕坐标系中的路径长度就可以作为 cost,路径距离短 cost 越小,算法在遍历节点时会优先遍历 cost 小的路径。
A* 算法是 Dijkstra 的修改版,针对单个目的地进行了优化。Dijkstra算法可以找到到达所有地点的路径, A* 查找到一个位置或多个位置中最近的位置的路径,它优先考虑那些似乎更接近目标的路径。
A* 算法通过增加 Heuristic 函数(一般是计算当前点与目标点的水平和垂直距离的绝对值之和)将更接近目标位置和 Dijkstra 中的 cost 一并考虑作为遍历的优先级,越接近目标越优先遍历,而且 A* 算法一旦找到目标算法立即结束,所以它只能得到一条最短路径。
算法局限性
① 拐点最少
在我们流程图正交线场景下,符合连线规则的最短路径其实有无数条,但只有一条路径是我们期望的,其中多拐点路径都是其中的重要干扰项,因为连线的拐点增加其实并未增加连线的长度,所以按照算法逻辑拐点多的路径也是正确路径,但是在我们正交连线的场景它是不正确的:
我们更希望算法得到的是红色的改点最少的路径,而不是类似于蓝色拐点较多的路径,这个可以在 A* 算法上稍加改进解决,当路径要拐弯时增加它的 cost ,让路径更倾向于走直线,以获取拐点最少的路径。
② 经过中线
某些情况下拐点最少的路径并不是我们理想的路径,还是前面的那个示意图,我们最终需要的是下图所示的黑色的路径,也就是我们希望连线的路径最好可以经过中线,这样连线看起更对称。
这个需求折磨了我们一段时间,最初我们也想在 A* 算法上改进,但是发现计算图形之间是否有中线(水平中线和垂直中线)、以及是否应该走中线(比如上图中应该走垂直中线、而不应该走水平中线)的情况过于复杂,并且通过走中线需要减 cost,而前面的拐点最少逻辑是加 cost,有点冲突了,很容易让 A* 算法无法正常退出。最后我们想到了中线纠正的方案,也就是不在 A* 算法中处理走中线的逻辑,而是基于 A* 算法的结果进行中线纠正,经过验证发现,这个方案可行并且复杂度和效果都比较理想,中线纠正的细节后面讲【算法执行 -> 中线纠正】时再进行详细介绍,这里只是先说明它的逻辑已经超出了 A* 算法的范畴了,而前面说到的【拐点最少】其实在 A* 算法的范畴,只是对路径的权重计算逻辑进行了稍加改进。
关于最短路径算法的详细介绍参考: www.redblobgames.com/pathfinding... ,文章中有各个算法的示例代码,算法执行过程动画说明,是非常好的学习素材。
算法执行过程
这里的算法不单指【最短路径算法】,而是整个连线规划的过程,除了 A* 之外还有数据结构的构造、中线纠正等。
大致流程
-
数据准备
-
构建网格点
-
构建图结构
-
跑 A* 算法
-
中线纠正
为了大家更好的理解算法执行的大致流程,我写了一个示意算法执行整体流程说明的一个 在线 Demo ,拆解了算法的执行过程,并且中间结果增加辅助的元素,以便大家更清晰的看到每一个过程执行的结果。
一、准备数据
如下图所示,我们需要先构造基于源图形的 rectangle、outerRectangle(紫色)和基于目标图形 rectangle、outerRectangle(红色):
rectangle 本质就是源或者目标图形的边界,而 outerRectangle 是基于图形边界定义保护区域,就是连线最多可以沿 outerRectangle 的边界经过不可以,不可以穿越,连线点(连线与 rectangle 的连接点)和 next 点(连线与 outerRectangle)的连线除外。
二、构造点
如下图所示,橙色点就是构造的连线点,它的构造思路很简单,就是找 ⑦ 条水平线和 ⑦ 条垂直线的交点,⑦ 条水平线:源/目标图形的 outerRectangle 上下边界各 ② 条(共 ④ 条)、源/目标图形的图形中线各 ① 条(共 ② 条)、源/目标图形的垂直中线 ① 条:
三、构造图结构
基于连线点和他们的连通关系,构造图数据结构,为跑 A* 算法做准备,水平和垂直方向上的点基本上是需要互相连通的,跨域 outerRectangle 的点是不可以连通的,图结构的图形化表达如下图橙色点和绿色连线所示:
四、跑 A* 算法
前面说到【最短路径算法】时说到这里用到的 A* 算法是稍加改造的,计算连线 cost 时除了要考虑路径的长度、曼哈顿距离还有考虑避免拐点,核心代码如下:
kotlin
let newCost = costSoFar.get(current!.node)! + this.heuristic(next.data, current!.node.data);const previousNode = this.cameFrom.get(current!.node);// 拐点权重,出现拐点则 cost + 1 以避免拐点路径// 以三点一线确定是否出现拐点const previousPoint = previousNode ? previousNode.data : previousStart;const x = previousPoint[0] === current?.node.data[0] && previousPoint[0] === next.data[0];const y = previousPoint[1] === current?.node.data[1] && previousPoint[1] === next.data[1];if (!x && !y) { newCost = newCost + 1;}
此时 A* 算法跑出来的最短路径如下图红线所示(拐点最少,仅有两个拐点):
五、中线纠正
这个是整体实现中比较难的部分,因为思路及实现都是我们一点点试出来的,它的思路的出发点是基于一个已知的路径(拐点最少的最短路径)做中线匹配逻辑上比较简单,思路就是找这个已知的路径中是否有平行于中线的线段,如果有就说明存在映射到中线上的可能,可以尝试做纠正,如果没有则 A* 算法得到的路径就是最终的路径。
大致步骤如下:
ruby
// 中线纠正: 基于水平中线/垂直中线纠正最短路径 route// 1. 找水平中线(xAxis)/垂直中线(yAxis)// 2. 在 route 中找到和 xAxis/yAxis 相交的点、在 route 中找和 xAxis/yAxis 平行的线段// 3. 基于步骤上一步找到的相交点和平行线构建矩形// 4. 判断矩形是否和元素相交,不相交则可以基于上步构建的矩形进行中线的映射// 5. 判断映射中线后的路径是否符合约束条件(拐点数据不能增加)
前面示例中的相交点和平行线示意如下图所示:
由平行线的上下两个端点和相交点可以构建一个矩形,进而将路径映射到中线上(改走蓝色的两条边),具体实现细节不在这里细说了有兴趣可以看这块 源代码 ,最终的纠正后的路径如下图(绿色)所示:
算法的执行过程就简单介绍这么多。
实现回顾思考
回顾我们基于最短路径实现正交连线的过程,感觉还是值得简单总结下的,因为我们也踩了一些坑,比如最开始的时候不理解算法真正解决的问题,过度的借鉴开源实现而没有理解实现细节,导致我们一直没有主心骨,遇到问题也没有明确的思路,后面我们开始从头开始推演整体实现思路、自己构建数据结构、自己实现 A* 算法,然后基于问题思考解决方案,才慢慢的走上正轨。
我觉得值得反思的还是从一开始我们就应该梳理实现思路、了解算法细节,而且尽量要自己一点点构建实现代码,盲目的把开源实现搬过来在一些复杂的问题上面还是会有相应的问题的,最后就是遇到问题了不断的去尝试可能的方案,前面介绍的【中线纠正】就是在不断的摸索中找到的一条解法。
总结
本文主要介绍我们最近基于最短路径重构的流程图正交连线的实现思路及核心技术。
为了更清楚的说明算法的执行过程我写了一个辅助的 Demo,在 demo 代码上标记出了算法执行的每个阶段及阶段的中间成果:
在线地址: plait-gamma.vercel.app/?init=route
Demo 代码: github.com/worktile/pl...
核心逻辑可以参考我们在 plait 框架中的落地实现:
框架仓储: github.com/worktile/pl...
代码位置:github.com/worktile/pl...
在实现过程中社区的技术资料及开源实现提供了很多重要的思路,在此感谢。
核心参考:
routing-orthogonal-diagram-connectors-in-javascript