流程图正交连线的一种实现

2,291 阅读7分钟

前言

起源是个人的一个项目中需要用到正交连线,但是开源的没有单独的模块,结合 A* 算法实现了正交连线,寻路中不少想法都是来自这个博客 ,具体交交互参考为 Lucidchart

在线 demo (拖动圆圈连接)

实现

基本思路

计算出路径可能通过的点,通过点与点构建一副地图,用 A* 来查找起始点之间的路径。

A* 算法

A* 算法是一种启发式的寻路算法 ,对于任何一个点,它距离结束点的距离为 f(n) = g(n) + h(n),其中 f 为总距离,g 为已确定的距离,h 为以某种方式估算到结束点的距离。

大致思路可分为:

  • 维护一个待访问的节点集合和一个已访问的节点集合;
  • 每次从待访问的集合中取出 f 值最小的点 p ;
    1. 检查是否已达到结算点

    2. 获取点的邻居,标记当前点已访问并从待访问结合中删除

    3. 依次访问每个邻居 n,如果邻居有效并且没有被标记已访问,计算 g 和 h. 其中 g 为点 p 的 g + 从 p 到 n 所需要的花费,h 一般采用曼哈段距离。

网上相关讲解和实现很多,有兴趣可以查看这个博客

构建地图

如下图所示:

截屏2021-05-29 下午8.12.45.png

起始点 S 和结束点 E 前面都有一个转折点 S1E1 ,可以假定它们之间的距离为 padding,那么我们可以得到一个”约束盒子"。

先考虑两个盒子的距离足够大的情况下,路线在离考开始点 S 和进入结束点 E 之前,总是先经过这层扩大的盒子。可以简化为查找 S1E1 的路径 path,最终路径为 [S, ...path, E]

路径也与点所在方位相关, 在上边的开始点总是从上出发,其余方位一样,而结束点刚好相反,例如左边的结束点进入方向总是右。

SE 的位置我们可以分为上下左右,定义如下:

enum Direction {
  TOP = "top",
  LEFT = "left",
  RIGHT = "right",
  BOTTOM = "bottom"
}

目前为止我们共得到 20 个点,( 盒子( 4 )+ 扩大盒子( 4 )+ 目标点 + 转折点 )* 2,详细如下:

截屏2021-05-29 下午8.03.08.png

蓝色矩形为扩大后的盒子。

为了让路径经过中点,我们需要 SE 之间的中点,和中点做延长线与 S1E1 的延长线交点,加起来共 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的路径, 仍然会出现在我们看来不希望出现的拐点,如下图所示:

截屏2021-05-29 下午9.46.45.png

对于点 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 时,我们不允许从 EE1 ,在查找到 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;
  }

让路径经过某个点

在方向相对时,比如开始点(左方位),结束点(右方位),路径总是会经过中点,上对下也是一样,总是会经过中点。

截屏2021-05-29 下午10.39.26.png

假设中点为 waypoint,每个点到另外一个点的估值函数为 h(n,p),可以得到 h'(n, e1) = h(n, waypoint) + h(n, e1)。在未达到 waypoint 点之前,为了使 h'(n, e1) 最小,只有靠近中点使 h(n, waypoint) 最小 ,而一旦达到中点后,h(n, waypoint) 对于每个点都是一样的距离。

对路径经过的区域作限制

根据两个盒子的距离可以分为三种情况。

两个盒子足够远(蓝色矩形相交)

如下图所示:

截屏2021-05-29 下午8.03.08.png

查找路径时,路径会穿过黑色线框矩形,如深蓝色线所示,实际上是不希望让路径穿过浅蓝色盒子内部。

假设盒子为: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) ,蓝色线段的两端为 currentnext,等同于线段与矩形是否相交。

起点被结束盒子包含且结束点被开始盒子包含

如下图所示:

截屏2021-06-02 下午10.39.36.png

开始点(蓝色)、结束点(红色)分别被黄色矩形、蓝色矩形包含,这时候,并不希望扩展盒子来约束路径,上面的 25 个点要减去两个扩展盒子的顶点 = 17 个点来生成地图。

开始盒子和结束盒子相交(排除上面的情况)

如下图所示:

截屏2021-06-02 下午10.52.40.png

黄色盒子与蓝色盒子相交。在起始方向与结束方向相对时,即 TOP <=> BOTTOM , LEFT <=> RIGHT 尽可能希望路径不穿过淡蓝色区域、淡黄色区域,最不希望穿过蓝色盒子和黄色盒子。这里把深蓝色区域当做山,浅蓝色区域当作丘陵,其余区域当作草地,其中每个区域的花费都不同。

计算约束盒子

设约束盒子的扩展大小为 padding ,考虑以下情况,当两个盒子的距离不足够时,比如盒子的右边框与另一个盒子的左边框的距离小于 padding 的 2 倍时,如下图所示:

截屏2021-06-02 下午11.12.25.png

假设 padding 设为 20 , 黄色盒子右边框与绿色盒子做边框的距离为 30,那么这两个边框的扩展距离应该 15 , 共 4 个边框、2 个盒子,需要对这 8 个边框都进行修正。

除了距离不够,还需要考虑边框的两个端点是否在另外一个边框两个端点之间,如上图的红色线,红色线在黄色盒子右边框的两个端点之间。

在距离修正中,当点的方向与边框方向一样时,也应该对路径的起始点或者结束点修正。

截屏2021-06-02 下午11.28.18.png

S1 是未修正前的位置,当 padding 减少时,S1 的 x 也应该减少,为 S1'

总结

实现过程中,最大的难度是减少拐点数量,一度是放弃状态,最后用的循环比较,对性能还是有点影响的。

在 Lucidchart 中还可以允许用户自行拖动连线,形成正交连线,实际是给定几个固定点,按照距离依次经过,但是规则没太看明白,这里填个坑,敬请期待。

以上代码在 github.com/raohong/flo…

参考资料

Amit’s A* Pages

Heuristics