前言
起源是个人的一个项目中需要用到正交连线,但是开源的没有单独的模块,结合 A* 算法实现了正交连线,寻路中不少想法都是来自这个博客 ,具体交交互参考为 Lucidchart 。
在线 demo (拖动圆圈连接)
实现
基本思路
计算出路径可能通过的点,通过点与点构建一副地图,用 A* 来查找起始点之间的路径。
A* 算法
A* 算法是一种启发式的寻路算法 ,对于任何一个点,它距离结束点的距离为 f(n) = g(n) + h(n)
,其中 f 为总距离,g 为已确定的距离,h 为以某种方式估算到结束点的距离。
大致思路可分为:
- 维护一个待访问的节点集合和一个已访问的节点集合;
- 每次从待访问的集合中取出 f 值最小的点 p ;
-
检查是否已达到结算点
-
获取点的邻居,标记当前点已访问并从待访问结合中删除
-
依次访问每个邻居 n,如果邻居有效并且没有被标记已访问,计算 g 和 h. 其中 g 为点 p 的 g + 从 p 到 n 所需要的花费,h 一般采用曼哈段距离。
-
网上相关讲解和实现很多,有兴趣可以查看这个博客 。
构建地图
如下图所示:
起始点 S
和结束点 E
前面都有一个转折点 S1
、E1
,可以假定它们之间的距离为 padding,那么我们可以得到一个”约束盒子"。
先考虑两个盒子的距离足够大的情况下,路线在离考开始点 S
和进入结束点 E
之前,总是先经过这层扩大的盒子。可以简化为查找 S1
到 E1
的路径 path
,最终路径为 [S, ...path, E]
路径也与点所在方位相关, 在上边的开始点总是从上出发,其余方位一样,而结束点刚好相反,例如左边的结束点进入方向总是右。
S
和 E
的位置我们可以分为上下左右,定义如下:
enum Direction {
TOP = "top",
LEFT = "left",
RIGHT = "right",
BOTTOM = "bottom"
}
目前为止我们共得到 20 个点,( 盒子( 4 )+ 扩大盒子( 4 )+ 目标点 + 转折点 )* 2,详细如下:
蓝色矩形为扩大后的盒子。
为了让路径经过中点,我们需要 S
, E
之间的中点,和中点做延长线与 S1
、E1
的延长线交点,加起来共 25 个点。将这些点俩俩相交,去重得到构成地图所需要的点,最后我们根据这些点构成一幅格子地图。
路径查找
根据地图,我们已经可以得到路径。但是结果不尽人意,下面我们将从修改 h 和 g 的计算方式来达到效果。
减少拐点
最终得到的路径充满了拐点,我们希望路径尽量减少转向,在 A* 算法中,我们可以让点计算花费时,如果当前点与上次的方向不一样时,我们增加一些额外消耗
const getDirection = (from: number[], to: number[]) => {
const v = subV(to, from);
if (v[0] === 0) {
return v[1] > 0 ? Direction.BOTTOM : Direction.TOP;
}
return v[0] > 0 ? Direction.RIGHT : Direction.LEFT;
};
const lastDir = minP.parent ? getDirection(minP.parent.xy, xy) : null;
const turned = lastDir !== null && lastDir !== dir;
// 转向给些额外惩罚
const G = minP.G + grid.getCost(currentXY) + (turned ? 0.02 : 0);
因为我们不是直接查找的起始点开始点之间的路径,而是查找的是S1
E1
的路径, 仍然会出现在我们看来不希望出现的拐点,如下图所示:
对于点 S1
E1
的路径来说,路径 1 和路径 2 都是一个拐点。这样是因为在 A* 算法中,在最开始获取点的邻居决定了哪个方向具有最高的优先级
比如在上面中,我们应该更偏向于走下面,但如果我们设置的是往右边走,就会出现上面这种情况,既然这样,我们干脆让点在第一个查找的时候,只走一个方向。然后我们循环四次,得到 4 个结果中拐点最少的结果。在拐点比较中,先比较路 S1
E1
路径 path
的拐点,如果相同,再比较 [start, ...path, end]
的拐点,如果这两个都一样,我们取花费最少的结果。
取第一次邻居
const move = getMoveDelta(startDirection, isFirst);
const neighbors =
index !== undefined
? isFirst
? move.slice(index, index + 1)
: move
: move;
isFirst = false;
拐点比较
const result = [0, 1, 2, 4]
.map((index) =>
A(
grid,
startInfo.endpoint,
endInfo.endpoint,
startInfo.direction,
endInfo.direction,
heuristic,
index
)
)
.filter((item) => item.path.length);
let target: { path: number[][]; grid: Grid; G: number } | null = null;
let min1 = Infinity;
let min2 = Infinity;
result.forEach((item) => {
const completedPath = [...item.path];
completedPath.push(endInfo.origin);
completedPath.unshift(startInfo.origin);
const d1 = getNumberOfInflectionPoints(item.path);
const d2 = getNumberOfInflectionPoints(completedPath);
/**
* 1. 拐点数都相同时取最小的 G
* 2. 先取不包含起始点的最小拐点数,再判断包含了起始点的最小拐点数
*/
if (
d1 < min1 ||
(d1 === min1 && d2 < min2) ||
(d1 === min1 && d2 === min2 && item.G < target!.G)
) {
min1 = d1;
min2 = d2;
target = item;
}
});
合法的进入离开方向
起始点 S
第一次只有三个方向,在左边的点只有 上、下、左方向,到达 E1
时,我们不允许从 E
到 E1
,在查找到 E1
时,我们应该检查方向是否合法,比如在右边的点,我们不允许从右到左的方向。
检查结束方向是否合法。
const checkDirectionIsValid = (
from: number[],
to: number[],
direction: Direction
) => {
const d = subV(to, from);
let disabled = false;
switch (direction) {
case Direction.TOP:
disabled = d[0] < 0;
break;
case Direction.LEFT:
disabled = d[1] < 0;
break;
case Direction.RIGHT:
disabled = d[1] > 0;
break;
default:
disabled = d[0] > 0;
}
return !disabled;
};
// 查找到结束点时
if (minP.key === endP.key) {
if (
minP.parent &&
!checkDirectionIsValid(minP.parent.xy, xy, endDirection)
) {
continue;
}
...
break;
}
让路径经过某个点
在方向相对时,比如开始点(左方位),结束点(右方位),路径总是会经过中点,上对下也是一样,总是会经过中点。
假设中点为 waypoint
,每个点到另外一个点的估值函数为 h(n,p)
,可以得到 h'(n, e1) = h(n, waypoint) + h(n, e1)
。在未达到 waypoint 点之前,为了使 h'(n, e1)
最小,只有靠近中点使 h(n, waypoint)
最小 ,而一旦达到中点后,h(n, waypoint)
对于每个点都是一样的距离。
对路径经过的区域作限制
根据两个盒子的距离可以分为三种情况。
两个盒子足够远(蓝色矩形相交)
如下图所示:
查找路径时,路径会穿过黑色线框矩形,如深蓝色线所示,实际上是不希望让路径穿过浅蓝色盒子内部。
假设盒子为:box: number[][]
,
对盒子进行扩展
const extendBox = (box: number[][], d: number) => {
const result = box.map((item) => [...item]);
result[0] = addV(result[0], [-d, -d]);
result[1] = addV(result[1], [d, -d]);
result[2] = addV(result[2], [d, d]);
result[3] = addV(result[3], [-d, d]);
return result;
};
盒子内部可以表示为:extendBox(box, padding - 1)
,蓝色线段的两端为 current
、next
,等同于线段与矩形是否相交。
起点被结束盒子包含且结束点被开始盒子包含
如下图所示:
开始点(蓝色)、结束点(红色)分别被黄色矩形、蓝色矩形包含,这时候,并不希望扩展盒子来约束路径,上面的 25 个点要减去两个扩展盒子的顶点 = 17 个点来生成地图。
开始盒子和结束盒子相交(排除上面的情况)
如下图所示:
黄色盒子与蓝色盒子相交。在起始方向与结束方向相对时,即 TOP <=> BOTTOM
, LEFT <=> RIGHT
尽可能希望路径不穿过淡蓝色区域、淡黄色区域,最不希望穿过蓝色盒子和黄色盒子。这里把深蓝色区域当做山,浅蓝色区域当作丘陵,其余区域当作草地,其中每个区域的花费都不同。
计算约束盒子
设约束盒子的扩展大小为 padding
,考虑以下情况,当两个盒子的距离不足够时,比如盒子的右边框与另一个盒子的左边框的距离小于 padding
的 2 倍时,如下图所示:
假设 padding
设为 20 , 黄色盒子右边框与绿色盒子做边框的距离为 30,那么这两个边框的扩展距离应该 15 , 共 4 个边框、2 个盒子,需要对这 8 个边框都进行修正。
除了距离不够,还需要考虑边框的两个端点是否在另外一个边框两个端点之间,如上图的红色线,红色线在黄色盒子右边框的两个端点之间。
在距离修正中,当点的方向与边框方向一样时,也应该对路径的起始点或者结束点修正。
S1
是未修正前的位置,当 padding
减少时,S1
的 x 也应该减少,为 S1'
。
总结
实现过程中,最大的难度是减少拐点数量,一度是放弃状态,最后用的循环比较,对性能还是有点影响的。
在 Lucidchart 中还可以允许用户自行拖动连线,形成正交连线,实际是给定几个固定点,按照距离依次经过,但是规则没太看明白,这里填个坑,敬请期待。
以上代码在 github.com/raohong/flo… 。