關聯線探究,如何連線流程圖的兩個節點

2022-06-29 06:01:23

如果你用過流程圖繪製工具,那麼可能會好奇節點之間的連線線是如何計算出來的:

不要走開,跟隨本文一起來探究一下吧。

最終效果預覽:https://wanglin2.github.io/AssociationLineDemo/

基本結構

先使用Vue3搭建一下頁面的基本結構,為了簡化canvas操作,我們使用konvajs庫來繪製圖形。

頁面模板部分,提供一個容器即可:

<div class="container" ref="container"></div>

js部分,主要是使用konvajs來建立兩個可拖拽的矩形元素及一個連線線元素,當然目前連線線並沒有頂點資料:

import { onMounted, ref } from "vue";
import Konva from "konva";

const container = ref(null);

// 建立兩個矩形、一個折線元素
let layer, rect1, rect2, line;

// 矩形移動事件
const onDragMove = () => {
  // 獲取矩形實時位置
  console.log(rect1.x(), rect1.y(), rect2.x(), rect2.y());
};

// 初始化圖形
const init = () => {
  const { width, height } = container.value.getBoundingClientRect();

  // 建立舞臺
  let stage = new Konva.Stage({
    container: container.value,
    width,
    height,
  });

  // 建立圖層
  layer = new Konva.Layer();

  // 建立兩個矩形
  rect1 = new Konva.Rect({
    x: 400,
    y: 200,
    width: 100,
    height: 100,
    fill: "#fbfbfb",
    stroke: "black",
    strokeWidth: 4,
    draggable: true,// 圖形允許拖拽
  });

  rect2 = new Konva.Rect({
    x: 800,
    y: 600,
    width: 100,
    height: 100,
    fill: "#fbfbfb",
    stroke: "black",
    strokeWidth: 4,
    draggable: true,
  });

  // 監聽進行拖拽事件
  rect1.on("dragmove", onDragMove);
  rect2.on("dragmove", onDragMove);

  // 矩形新增到圖層
  layer.add(rect1);
  layer.add(rect2);

  // 建立折線元素
  line = new Konva.Line({
    points: [],// 當前它的頂點資料是空的,所以你還看不見這個元素
    stroke: "green",
    strokeWidth: 2,
    lineJoin: "round",
  });
    
  // 折線新增到圖層
  layer.add(line);

  // 圖層新增到舞臺
  stage.add(layer);

  // 繪製
  layer.draw();
};

onMounted(() => {
  init();
});

效果如下:

接下來我們只要在圖形拖拽時實時計算出關聯線的頂點然後更新到折線元素裡就可以繪製出這條連線線。

計算出關聯線最有可能經過的點

整個畫布上所有的點其實都是可能經過的點,但是我們的連線線是【橫平豎直】的,且要儘可能是最短路線,所以考慮所有的點沒有必要,我們可以按照一定規則縮小範圍,然後再從中計算出最優路線。

首先起點和終點兩個點肯定是必不可少的,以下圖為例,假設我們要從左上角的矩形頂部中間位置連線到右下角的矩形頂部中間位置:

接下來我們定兩個原則:

1.連線線儘量不能和圖形的邊重疊

2.連線線儘量不能穿過元素

為什麼說盡量呢,因為當兩個元素距離過近或有重疊的話這些都是無法避免的。

結合上面兩個原則我們可以規定元素周圍一定距離內都不允許線經過(當然除了連線起終點的線段),這樣就相當於給元素外面套了個矩形的包圍框:

經過起終點且垂直於起終點所在邊的直線與包圍框的交點一定是會經過的,並且這兩個點是唯一能直接和起終點相連的點,所以我們可以把這兩個點當做是「起點"和"終點」,這樣在計算的時候可以少計算兩個點:

在矩形移動事件裡進行點的計算,首先快取一下矩形的位置和尺寸資訊,然後定義起點和終點的座標,最後定義一個陣列用來存放所有可能經過的點:

// 矩形移動事件
const onDragMove = () => {
  computedProbablyPoints();
};

// 計算所有可能經過的點
let rect1X, rect1Y, rect1W, rect1H, rect2X, rect2Y, rect2W, rect2H;
let startPoint = null, endPoint = null;
const computedProbablyPoints = () => {
  // 儲存矩形的尺寸、位置資訊
  rect1X = rect1.x();
  rect1Y = rect1.y();
  rect1W = rect1.width();
  rect1H = rect1.height();
    
  rect2X = rect2.x();
  rect2Y = rect2.y();
  rect2W = rect2.width();
  rect2H = rect2.height();
    
  // 起終點
  startPoint = [rect1X + rect1W / 2, rect1Y];
  endPoint = [rect2X + rect2W / 2, rect2Y];
    
  // 儲存所有可能經過的點
  let points = [];
}

因為起終點可以在矩形的任一方向,所以我們寫個方法來獲取偽起點和偽終點,並將它們新增到陣列裡:

const computedProbablyPoints = () => {
    // ...
    // 偽起點:經過起點且垂直於起點所在邊的線與包圍框線的交點
    let fakeStartPoint = findStartNextOrEndPrePoint(rect1, startPoint);
    points.push(fakeStartPoint);

    // 偽終點:經過終點且垂直於終點所在邊的線與包圍框線的交點
    let fakeEndPoint = findStartNextOrEndPrePoint(rect2, endPoint);
    points.push(fakeEndPoint);
}

// 找出起點的下一個點或終點的前一個點
const MIN_DISTANCE = 30;
const findStartNextOrEndPrePoint = (rect, point) => {
  // 起點或終點在左邊
  if (point[0] === rect.x()) {
    return [rect.x() - MIN_DISTANCE, rect.y() + rect.height() / 2];
  } else if (point[1] === rect.y()) {
    // 起點或終點在上邊
    return [rect.x() + rect.width() / 2, rect.y() - MIN_DISTANCE];
  } else if (point[0] === rect.x() + rect.width()) {
    // 起點或終點在右邊
    return [
      rect.x() + rect.width() + MIN_DISTANCE,
      rect.y() + rect.height() / 2,
    ];
  } else if (point[1] === rect.y() + rect.height()) {
    // 起點或終點在下邊
    return [
      rect.x() + rect.width() / 2,
      rect.y() + rect.height() + MIN_DISTANCE,
    ];
  }
};

偽起點和偽終點會形成一個矩形,這個矩形和起點元素包圍框可以組成一個更大的矩形,這個矩形的四個角是連線線有可能經過的點:

將這幾個點新增到陣列裡,有一個點和偽終點重複了,不過沒關係,我們最後再去重即可:

const computedProbablyPoints = () => {
    // ...
    // 偽起點和偽終點形成的矩形 和 起點元素包圍框 組成一個大矩形 的四個頂點
    points.push(
        ...getBoundingBox([
            // 偽起點終點
            fakeStartPoint,
            fakeEndPoint,
            // 起點元素包圍框
            [rect1X - MIN_DISTANCE, rect1Y - MIN_DISTANCE], // 左上頂點
            [rect1X + rect1W + MIN_DISTANCE, rect1Y + rect1H + MIN_DISTANCE], // 右下頂點
        ])
    );
}

// 計算出給定點可以形成的最大的矩形的四個頂點
const getBoundingBox = (points) => {
    let boundingBoxXList = [];
    let boundingBoxYList = [];
    points.forEach((item) => {
        boundingBoxXList.push(item[0]);
        boundingBoxYList.push(item[1]);
    });
    let minBoundingBoxX = Math.min(...boundingBoxXList);
    let minBoundingBoxY = Math.min(...boundingBoxYList);
    let maxBoundingBoxX = Math.max(...boundingBoxXList);
    let maxBoundingBoxY = Math.max(...boundingBoxYList);
    return [
        [minBoundingBoxX, minBoundingBoxY],
        [maxBoundingBoxX, minBoundingBoxY],
        [minBoundingBoxX, maxBoundingBoxY],
        [maxBoundingBoxX, maxBoundingBoxY],
    ];
};

從圖上可以看出來,關聯線要麼從右邊連過去,要麼從左邊連過去。

同樣,偽起點和偽終點形成的矩形也會和終點元素包圍框形成一個更大的矩形,這個矩形的四個頂點也是有可能會經過的,這當終點元素位於起點元素上方時會經過:

// 偽起點和偽終點形成的矩形 和 終點元素包圍框 組成一個大矩形 的四個頂點
points.push(
    ...getBoundingBox([
        // 偽起點終點
        fakeStartPoint,
        fakeEndPoint,
        // 終點元素包圍框
        [rect2X - MIN_DISTANCE, rect2Y - MIN_DISTANCE], // 左上頂點
        [rect2X + rect2W + MIN_DISTANCE, rect2Y + rect2H + MIN_DISTANCE], // 右下頂點
    ])
);

以上這些點基本能滿足起終點都在元素上方的情況,但是對於下面這種起點在上面終點在左邊情況就不行了:

很明顯看到如果存在下面這個點就可以了:

這其實就是前面所說的經過起終點且垂直於起終點所在邊的兩條線的交點,求交點可以先根據兩個點計算出直線方程,再聯立兩個方程計算交點,但是我們的線都是橫平豎直的,所以沒必要這麼麻煩,兩條線要麼是平行的,要麼是一條水平一條垂直,很容易羅列完所有情況:

// 計算兩條線段的交點
const getIntersection = (seg1, seg2) => {
  // 兩條垂直線不會相交
  if (seg1[0][0] === seg1[1][0] && seg2[0][0] === seg2[1][0]) {
    return null;
  }
  // 兩條水平線不會相交
  if (seg1[0][1] === seg1[1][1] && seg2[0][1] === seg2[1][1]) {
    return null;
  }
  // seg1是水平線、seg2是垂直線
  if (seg1[0][1] === seg1[1][1] && seg2[0][0] === seg2[1][0]) {
    return [seg2[0][0], seg1[0][1]];
  }
  // seg1是垂直線、seg2是水平線
  if (seg1[0][0] === seg1[1][0] && seg2[0][1] === seg2[1][1]) {
    return [seg1[0][0], seg2[0][1]];
  }
};

有了這個方法我們就可以把這個交點新增到陣列裡:

const computedProbablyPoints = () => {
    // ...
    // 經過起點且垂直於起點所在邊的線 與 經過終點且垂直於終點所在邊的線 的交點
    let startEndPointVerticalLineIntersection = getIntersection([startPoint, fakeStartPoint], [endPoint, fakeEndPoint]);
    startEndPointVerticalLineIntersection && points.push(startEndPointVerticalLineIntersection);
}

到這裡計算出來的點能滿足大部分情況了,但是還有一種情況滿足不了,當起終點相對時:

所以當前面計算的startEndPointVerticalLineIntersection點不存在的時候我們就計算經過偽起點和偽終點的一條垂直線和一條水平線的交點(黃色的兩個點):

const computedProbablyPoints = () => {
    // ...
    // 當 經過起點且垂直於起點所在邊的線 與 經過終點且垂直於終點所在邊的線 平行時,計算一條垂直線與經過另一個點的偽點的水平線 的節點
    if (!startEndPointVerticalLineIntersection) {
        let p1 = getIntersection(
            [startPoint, fakeStartPoint],// 假設經過起點的垂直線是垂直的
            [fakeEndPoint, [fakeEndPoint[0] + 10, fakeEndPoint[1]]]// 那麼就要計算經過偽終點的水平線。水平線上的點y座標相同,所以x座標隨便加減多少數值都可以
        );
        p1 && points.push(p1);
        let p2 = getIntersection(
            [startPoint, fakeStartPoint],// 假設經過起點的垂直線是水平的
            [fakeEndPoint, [fakeEndPoint[0], fakeEndPoint[1] + 10]]// 那麼就要計算經過偽終點的垂直線。
        );
        p2 && points.push(p2);
        // 下面同上
        let p3 = getIntersection(
            [endPoint, fakeEndPoint],
            [fakeStartPoint, [fakeStartPoint[0] + 10, fakeStartPoint[1]]]
        );
        p3 && points.push(p3);
        let p4 = getIntersection(
            [endPoint, fakeEndPoint],
            [fakeStartPoint, [fakeStartPoint[0], fakeStartPoint[1] + 10]]
        );
        p4 && points.push(p4);
    }
}

到這裡可能經過的點就找的差不多了,一共有:

接下來進行去重以及匯出相關的資料:

const computedProbablyPoints = () => {
    // ...
    // 去重
    points = removeDuplicatePoint(points);

    return {
        startPoint,
        endPoint,
        fakeStartPoint,
        fakeEndPoint,
        points,
    };
}

// 去重
const removeDuplicatePoint = (points) => {
    let res = [];
    let cache = {};
    points.forEach(([x, y]) => {
        if (cache[x + "-" + y]) {
            return;
        } else {
            cache[x + "-" + y] = true;
            res.push([x, y]);
        }
    });
    return res;
};

暴力求解:回溯演演算法

如果不考慮效率和最短距離,我們可以直接使用廣度優先搜尋或者說是回溯演演算法,也就是從起點開始,挨個嘗試起點周邊的點,到下一個點又挨個嘗試下一個點周邊所有的點,如果遇到終點,那麼結束,把所經過的點連線起來就是一條路徑,接下來我們嘗試一下。

在開始演演算法之前需要先實現如何找出一個點周邊的點,如果是在網格中,那麼很簡單,一個點周邊的點就是x、y座標加1或減1,但是我們這些點彼此之間的距離是不確定的,所以只能根據座標進行搜尋,比如要找一個點右邊最近的點,那麼根據該點的y座標進行搜尋,看有沒有y座標相同的點,有的話再找出其中最近的,當然,還要檢測找出的這個點和目標點的連線是否會穿過起終點元素,是的話這個點也要跳過:

// 找出一個點周邊的點
const getNextPoints = (point, points) => {
  let [x, y] = point;
  let xSamePoints = [];
  let ySamePoints = [];

   // 找出x或y座標相同的點
   points.forEach((item) => {
    // 跳過目標點
    if (checkIsSamePoint(point, item)) {
      return;
    }
    if (item[0] === x) {
      xSamePoints.push(item);
    }
    if (item[1] === y) {
      ySamePoints.push(item);
    }
  });

  // 找出x方向最近的點
  let xNextPoints = getNextPoint(x, y, ySamePoints, "x");

  // 找出y方向最近的點
  let yNextPoints = getNextPoint(x, y, xSamePoints, "y");
    
  return [...xNextPoints, ...yNextPoints];
};

// 檢測是否為同一個點
const checkIsSamePoint = (a, b) => {
  if (!a || !b) {
    return false;
  }
  return a[0] === b[0] && a[1] === b[1];
};

接下來就是getNextPoint方法的實現:

// 找出水平或垂直方向上最近的點
const getNextPoint = (x, y, list, dir) => {
  let index = dir === "x" ? 0 : 1;// 求水平方向上最近的點,那麼它們y座標都是相同的,要比較x座標,反之亦然
  let value = dir === "x" ? x : y;
  let nextLeftTopPoint = null;
  let nextRIghtBottomPoint = null;
  for (let i = 0; i < list.length; i++) {
    let cur = list[i];
    // 檢查當前點和目標點的連線是否穿過起終點元素
    if (checkLineThroughElements([x, y], cur)) {
      continue;
    }
    // 左側或上方最近的點
    if (cur[index] < value) {
      if (nextLeftTopPoint) {
        if (cur[index] > nextLeftTopPoint[index]) {
          nextLeftTopPoint = cur;
        }
      } else {
        nextLeftTopPoint = cur;
      }
    }
    // 右側或下方最近的點
    if (cur[index] > value) {
      if (nextRIghtBottomPoint) {
        if (cur[index] < nextRIghtBottomPoint[index]) {
          nextRIghtBottomPoint = cur;
        }
      } else {
        nextRIghtBottomPoint = cur;
      }
    }
  }
  return [nextLeftTopPoint, nextRIghtBottomPoint].filter((point) => {
    return !!point;
  });
};

checkLineThroughElements方法用來判斷一條線段是否穿過或和起終點元素有重疊,也是一個簡單的比較邏輯:

// 檢查兩個點組成的線段是否穿過起終點元素
const checkLineThroughElements = (a, b) => {
  let rects = [rect1, rect2];
  let minX = Math.min(a[0], b[0]);
  let maxX = Math.max(a[0], b[0]);
  let minY = Math.min(a[1], b[1]);
  let maxY = Math.max(a[1], b[1]);

  // 水平線
  if (a[1] === b[1]) {
    for (let i = 0; i < rects.length; i++) {
      let rect = rects[i];
      if (
        minY >= rect.y() &&
        minY <= rect.y() + rect.height() &&
        minX <= rect.x() + rect.width() &&
        maxX >= rect.x()
      ) {
        return true;
      }
    }
  } else if (a[0] === b[0]) {
    // 垂直線
    for (let i = 0; i < rects.length; i++) {
      let rect = rects[i];
      if (
        minX >= rect.x() &&
        minX <= rect.x() + rect.width() &&
        minY <= rect.y() + rect.height() &&
        maxY >= rect.y()
      ) {
        return true;
      }
    }
  }

  return false;
};

接下來就可以使用回溯演演算法來找出其中的一條路徑,回溯演演算法很簡單,因為不是本文的重點,所以就不詳細介紹了,有興趣的可以閱讀回溯(DFS)演演算法解題套路框架

計算出座標點後再更新連線元素,記得要把我們真正的起點和終點座標加上去:

// 矩形移動事件
const onDragMove = () => {
  // 計算出所有可能的點
  let { startPoint, endPoint, fakeStartPoint, fakeEndPoint, points } =
    computedProbablyPoints();
  // 使用回溯演演算法找出其中一條路徑
  const routes = useDFS(fakeStartPoint, fakeEndPoint, points);
  // 更新連線元素
  line.points(
    // 加上真正的起點和終點
    (routes.length > 0 ? [startPoint, ...routes, endPoint] : []).reduce(
      (path, cur) => {
        path.push(cur[0], cur[1]);
        return path;
      },
      []
    )
  );
};

// 使用回溯演演算法尋找路徑
const useDFS = (startPoint, endPoint, points) => {
  let res = [];
  let used = {};
  let track = (path, selects) => {
    for (let i = 0; i < selects.length; i++) {
      let cur = selects[i];
      // 到達終點了
      if (checkIsSamePoint(cur, endPoint)) {
        res = [...path, cur];
        break;
      }
      let key = cur[0] + "-" + cur[1];
      // 該點已經被選擇過了
      if (used[key]) {
        continue;
      }
      used[key] = true;
      track([...path, cur], getNextPoints(cur, points));
      used[key] = false;
    }
  };
  track([], [startPoint]);
  return res;
};

效果如下:

可以看到確實計算出了一條連線線路徑,但是顯然不是最短路徑,並且回溯演演算法是一種暴力演演算法,點多了可能會存在效能問題。

使用A*演演算法結合曼哈頓路徑計算最短路徑

前面我們使用回溯演演算法找出了其中一條關聯線路徑,但是很多情況下計算出來的路徑都不是最短的,接下來我們就使用A*演演算法來找出最短路徑。

A*演演算法和回溯演演算法有點相似,但是不是盲目的挨個遍歷一個點周圍的點,而是會從中找出最有可能的點優先進行嘗試,完整的演演算法過程描述如下:

1.建立兩個陣列,openList存放待遍歷的點,closeList存放已經遍歷的點;

2.將起點放入openList中;

3.如果openList不為空,那麼從中選取優先順序最高的點,假設為n

  • 3.1.如果n為終點,那麼結束迴圈,從n出發,依次向前找出父節點,也就是最短路徑;

  • 3.2.如果n不為終點,那麼:

    • 3.2.1.將nopenList中刪除,新增到closeList中;

    • 3.2.2.遍歷n周圍的點:

      • 3.2.2.1.如果該點在closeList中,那麼跳過該點;

      • 3.2.2.2.如果該點也不在openList中,那麼:

        • 3.2.2.2.1.設定n為該點的父節點;

        • 3.2.2.2.2.計算該點的代價,代價越高,優先順序越低,反之越高;

        • 3.3.3.3.3.將該點新增到openList

    • 3.2.3.繼續3的迴圈過程,直到找到終點,或openList為空,沒有結果;

根據以上過程,我們建立一個A*類:

// A*演演算法類
class AStar {
  constructor() {
    this.startPoint = null;
    this.endPoint = null;
    this.pointList = [];
    
    // 存放待遍歷的點
    this.openList = [];
    // 存放已經遍歷的點
    this.closeList = [];
  }

  // 演演算法主流程
  start(startPoint, endPoint, pointList) {
    this.startPoint = startPoint;
    this.endPoint = endPoint;
    this.pointList = pointList;
      
    this.openList = [
      {
        point: this.startPoint, // 起點加入openList
        cost: 0, // 代價
        parent: null, // 父節點
      },
    ];
    this.closeList = [];
      
    while (this.openList.length) {
      // 在openList中找出優先順序最高的點
      let point = this.getBestPoint();
      // point為終點,那麼演演算法結束,輸出最短路徑
      if (checkIsSamePoint(point.point, this.endPoint)) {
        return this.getRoutes(point);
      } else {
        // 將point從openList中刪除
        this.removeFromOpenList(point);
        // 將point新增到closeList中
        this.closeList.push(point);
        // 遍歷point周圍的點
        let nextPoints = getNextPoints(point.point, this.pointList);
        for (let i = 0; i < nextPoints.length; i++) {
          let cur = nextPoints[i];
          // 如果該點在closeList中,那麼跳過該點
          if (this.checkIsInList(cur, this.closeList)) {
            continue;
          } else if (!this.checkIsInList(cur, this.openList)) {
            // 如果該點也不在openList中
            let pointObj = {
              point: cur,
              parent: point,// 設定point為當前點的父節點
              cost: 0,
            };
            // 計算當前點的代價
            this.computeCost(pointObj);
            // 新增到openList中
            this.openList.push(pointObj);
          }
        }
      }
    }
    return []
  }

  // 獲取openList中優先順序最高的點,也就是代價最小的點
  getBestPoint() {
    let min = Infinity;
    let point = null;
    this.openList.forEach((item) => {
      if (item.cost < min) {
        point = item;
        min = item.cost;
      }
    });
    return point;
  }

  // 從point出發,找出其所有祖宗節點,也就是最短路徑
  getRoutes(point) {
    let res = [point];
    let par = point.parent;
    while (par) {
      res.unshift(par);
      par = par.parent;
    }
    return res.map((item) => {
      return item.point;
    });
  }

  // 將點從openList中刪除
  removeFromOpenList(point) {
    let index = this.openList.findIndex((item) => {
      return checkIsSamePoint(point.point, item.point);
    });
    this.openList.splice(index, 1);
  }

  // 檢查點是否在列表中
  checkIsInList(point, list) {
    return list.find((item) => {
      return checkIsSamePoint(item.point, point);
    });
  }

  // 計算一個點的代價
  computeCost(point) {
    // TODO
  }
}

程式碼有點長,但是邏輯很簡單,start方法基本就是對前面的演演算法過程進行還原,其他就是一些輔助工具方法,只有一個computeCost方法暫時沒有實現,這個方法也就是A*演演算法的核心。

A*演演算法的所說的節點優先順序是由兩部分決定的:

f(n) = g(n) + h(n)

g(n)代表節點n距離起點的代價。

f(n)代表節點n到終點的代價,當然這個代價只是預估的。

f(n)g(n)加上h(n),就代表節點n的綜合代價,也就是優先順序,代價越低,當然優先順序越高,修改一下computeCost方法,拆解成兩個方法:

// 計算一個點的優先順序
computePriority(point) {
    point.cost = this.computedGCost(point) + this.computedHCost(point);
}

g(n)的計算很簡單,把它所有祖先節點的代價累加起來即可:

// 計算代價g(n)
computedGCost(point) {
    let res = 0;
    let par = point.parent;
    while (par) {
        res += par.cost;
        par = par.parent;
    }
    return res;
}

h(n)的計算就會用到曼哈頓距離,兩個點的曼哈頓距離指的就是這兩個點的水平和垂直方向的距離加起來的總距離:

對於我們的計算,也就是當前節點到終點的曼哈頓距離:

// 計算代價h(n)
computedHCost(point) {
    return (
        Math.abs(this.endPoint[0] - point.point[0]) +
        Math.abs(this.endPoint[1] - point.point[1])
    );
}

接下來範例化一個AStar類,然後使用它來計算最短路徑:

const aStar = new AStar();

const onDragMove = () => {
    let { startPoint, endPoint, fakeStartPoint, fakeEndPoint, points } =
        computedProbablyPoints(startPos.value, endPos.value);
    const routes = aStar.start(fakeStartPoint, fakeEndPoint, points);
    // 更新連線元素
    // ...
}

可以看到不會出現回溯演演算法計算出來的超長路徑。

優化

到上一節已經基本可以找出最短路徑,但是會存在幾個問題,本小節來試著優化一下。

1.連線線突破了包圍框

如上圖所示,垂直部分的連線線顯然離元素過近,雖然還沒有和元素重疊,但是已經突破了包圍框,更好的連線點應該是右邊兩個,下圖的情況也是類似的:

解決方法也很簡單,前面我們實現了一個判斷線段是否穿過或和起終點元素重疊的方法,我們修改一下比較條件,把比較物件由元素本身改成元素的包圍框:

export const checkLineThroughElements = (a, b) => {
  // ...

  // 水平線
  if (a[1] === b[1]) {
    for (let i = 0; i < rects.length; i++) {
      let rect = rects[i];
      if (
        minY > rect.y() - MIN_DISTANCE &&// 增加或減去MIN_DISTANCE來將比較目標由元素改成元素的包圍框
        minY < rect.y() + rect.height() + MIN_DISTANCE &&
        minX < rect.x() + rect.width() + MIN_DISTANCE &&
        maxX > rect.x() - MIN_DISTANCE
      ) {
        return true;
      }
    }
  } else if (a[0] === b[0]) {
    // 垂直線
    for (let i = 0; i < rects.length; i++) {
      let rect = rects[i];
      if (
        minX > rect.x() - MIN_DISTANCE &&
        minX < rect.x() + rect.width() + MIN_DISTANCE &&
        minY < rect.y() + rect.height() + MIN_DISTANCE &&
        maxY > rect.y() - MIN_DISTANCE
      ) {
        return true;
      }
    }
  }

  return false;
};

效果如下:

2.距離太近沒有連線線

目前我們的邏輯如果當兩個元素太近了,那麼是計算不出來符合要求的點的,自然就沒有線了:

解決方法也很簡單,當第一次路徑計算沒有結果時我們假設是因為距離很近導致的,然後我們再以寬鬆模式計算一次,所謂寬鬆模式就是去掉是否穿過或和元素有交叉的判斷,也就是跳過checkLineThroughElements這個方法,另外真正的起點和終點也要加入點列表裡參加計算,並且計算的起點和終點也不再使用偽起點和偽終點,而是使用真正的起點和終點,不然會出現如下的情況:

首先修改一下onDragMove方法,將路徑計算單獨提成一個方法,方便複用:

const onDragMove = () => {
    // 計算點和路徑提取成一個方法
    let { startPoint, endPoint, routes } = computeRoutes();
    // 如果沒有計算出來路徑,那麼就以寬鬆模式再計算一次可能的點,也就是允許和元素交叉
    if (routes.length <= 0) {
        let res = computeRoutes(true);
        routes = res.routes;
    }
    // 更新連線元素
    updateLine(
        (routes.length > 0 ? [startPoint, ...routes, endPoint] : []).reduce(
            (path, cur) => {
                path.push(cur[0], cur[1]);
                return path;
            },
            []
        )
    );
};

// 計算路徑
const computeRoutes = (easy) => {
    // 計算出所有可能的點
    let { startPoint, endPoint, fakeStartPoint, fakeEndPoint, points } =
        computedProbablyPoints(startPos.value, endPos.value, easy);
    // 使用A*演演算法
    let routes =  = aStar.start(
        easy ? startPoint : fakeStartPoint,// 如果是寬鬆模式則使用真正的起點和終點
      	easy ? endPoint : fakeEndPoint,
        points
    );
    return {
        startPoint,
        endPoint,
        routes,
    };
};

然後修改一下computedProbablyPoints方法,增加一個easy引數,當該引數為true時就將真正的起點和終點加入點列表中:

const computedProbablyPoints = (startPos, endPos, easy) => {
    // ...

    // 是否是寬鬆模式
    easyMode = easy;

    // 儲存所有可能經過的點
    let points = [];

    // 寬鬆模式則把真正的起點和終點加入點列表中
    if (easy) {
        points.push(startPoint, endPoint);
    }
    // ...
}

最後再修改一下計算一個點周邊的點的方法,去掉是否穿過或和元素交叉的檢測:

const getNextPoint = (x, y, list, dir) => {
    // ...
    for (let i = 0; i < list.length; i++) {
        let cur = list[i];
        // 檢查當前點和目標點的連線是否穿過起終點元素,寬鬆模式下直接跳過該檢測
        if (!easyMode && checkLineThroughElements([x, y], cur)) {
            continue;
        }
    }
}

最終效果如下:

總結

本文嘗試通過A*演演算法實現了尋找節點的關聯線路徑,原本以為難點在於演演算法,沒想到在實現過程中發現最難之處在於找點,如果有更好的找點方式歡迎評論區留言。

原始碼地址:https://github.com/wanglin2/AssociationLineDemo

參考文章

路徑規劃之 A* 演演算法

LogicFlow 邊的繪製與互動