前端使用 Konva 實現視覺化設計器(14)- 折線 - 最優路徑應用【程式碼篇】

xachary發表於2024-06-11

話接上回《前端使用 Konva 實現視覺化設計器(13)- 折線 - 最優路徑應用【思路篇】》,這一章繼續說說相關的程式碼如何構思的,如何一步步構建資料模型可供 AStar 演算法進行路徑規劃,最終畫出節點之間的連線折線。

請大家動動小手,給我一個免費的 Star 吧~

大家如果發現了 Bug,歡迎來提 Issue 喲~

github原始碼

gitee原始碼

示例地址

補充說明

上一章說到使用了開源 AStar 演算法,它並不支援計算折線拐彎的代價,最終結果會出現不必要的拐彎,現已經把演算法替換成自定義 AStar 演算法,支援計算拐彎代價,減少了不必要的折線拐彎。

AStar 演算法基本邏輯可以參考《C++: A*(AStar)演算法》,本示例的自定義 AStar 演算法,是在此基礎上增加支援:格子代價、拐彎代價。

程式碼不長,可以直接看看:

關鍵要理解 AStar 演算法的基本思路,特別是“open 和 closed 列表”、“每個節點的 f, g, h 值”

// src\Render\utils\aStar.ts

export interface Node {
  x: number
  y: number
  cost?: number
  parent?: Node
}

export default function aStar(config: {
  from: Node
  to: Node
  matrix: number[][]
  maxCost: number
}): Node[] {
  const { from, to, matrix, maxCost = 1 } = config

  const grid: Node[][] = matrixToGrid(matrix)

  const start = grid[from.y][from.x]
  const goal = grid[to.y][to.x]

  // 初始化 open 和 closed 列表
  const open: Node[] = [start]
  const closed = new Set<Node>()

  // 初始化每個節點的 f, g, h 值
  const f = new Map<Node, number>()
  const g = new Map<Node, number>()
  const h = new Map<Node, number>()
  g.set(start, 0)
  h.set(start, manhattanDistance(start, goal))
  f.set(start, g.get(start)! + h.get(start)!)

  // A* 演算法主迴圈
  while (open.length > 0) {
    // 從 open 列表中找到 f 值最小的節點
    const current = open.reduce((a, b) => (f.get(a)! < f.get(b)! ? a : b))

    // 如果當前節點是目標節點,返回路徑
    if (current === goal) {
      return reconstructPath(goal)
    }

    // 將當前節點從 open 列表中移除,並加入 closed 列表
    open.splice(open.indexOf(current), 1)
    closed.add(current)

    // 遍歷當前節點的鄰居
    for (const neighbor of getNeighbors(current, grid)) {
      // 如果鄰居節點已經在 closed 列表中,跳過
      if (closed.has(neighbor)) {
        continue
      }

      // 計算從起點到鄰居節點的距離(轉彎距離增加)
      const tentativeG =
        g.get(current)! +
        (neighbor.cost ?? 0) +
        ((current.x === current.parent?.x && current.x !== neighbor.x) ||
        (current.y === current.parent?.y && current.y !== neighbor.y)
          ? Math.max(grid.length, grid[0].length)
          : 0)

      // 如果鄰居節點不在 open 列表中,或者新的 g 值更小,更新鄰居節點的 g, h, f 值,並將其加入 open 列表
      if (!open.includes(neighbor) || tentativeG < g.get(neighbor)!) {
        g.set(neighbor, tentativeG)
        h.set(neighbor, manhattanDistance(neighbor, goal))
        f.set(neighbor, g.get(neighbor)! + h.get(neighbor)!)
        neighbor.parent = current
        if (!open.includes(neighbor)) {
          open.push(neighbor)
        }
      }
    }
  }

  // 如果 open 列表為空,表示無法到達目標節點,返回 null
  return []

  // 資料轉換
  function matrixToGrid(matrix: number[][]) {
    const mt: Node[][] = []

    for (let y = 0; y < matrix.length; y++) {
      if (mt[y] === void 0) {
        mt[y] = []
      }
      for (let x = 0; x < matrix[y].length; x++) {
        mt[y].push({
          x,
          y,
          cost: matrix[y][x]
        })
      }
    }

    return mt
  }

  // 從目標節點開始,沿著 parent 指標重構路徑
  function reconstructPath(node: Node): Node[] {
    const path = [node]
    while (node.parent) {
      path.push(node.parent)
      node = node.parent
    }
    return path.reverse()
  }

  // 計算曼哈頓距離
  function manhattanDistance(a: Node, b: Node): number {
    return Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
  }

  // 獲取當前節點的鄰居
  function getNeighbors(node: Node, grid: Node[][]): Node[] {
    const neighbors = []
    const { x, y } = node
    if (x > 0 && (grid[y][x - 1].cost ?? 0) < maxCost) {
      neighbors.push(grid[y][x - 1])
    }
    if (x < grid[0].length - 1 && (grid[y][x + 1].cost ?? 0) < maxCost) {
      neighbors.push(grid[y][x + 1])
    }
    if (y > 0 && (grid[y - 1][x].cost ?? 0) < maxCost) {
      neighbors.push(grid[y - 1][x])
    }
    if (y < grid.length - 1 && (grid[y + 1][x].cost ?? 0) < maxCost) {
      neighbors.push(grid[y + 1][x])
    }
    return neighbors
  }
}

在資料結構上,還有最佳化空間,應該可以減少效能和記憶體的消耗,暫且如此。

演算法建模

主要邏輯在 src\Render\draws\LinkDraw.ts 的 draw 方法的畫連線線的部分:

事前準備,把所有 節點、連線點、連線對 整理出來:

  override draw() {
    this.clear()

    // stage 狀態
    const stageState = this.render.getStageState()

    const groups = this.render.layer.find('.asset') as Konva.Group[]

    const points = groups.reduce((ps, group) => {
      return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
    }, [] as LinkDrawPoint[])

    const pairs = points.reduce((ps, point) => {
      return ps.concat(point.pairs ? point.pairs : [])
    }, [] as LinkDrawPair[])

    // 略
  }

主要邏輯,請看程式碼備註(一些工具方法,後面單獨說明):

  override draw() {
    // 略

    // 連線線(根據連線對繪製)
    for (const pair of pairs) {
      // 連線起點節點、連線點
      const fromGroup = groups.find((o) => o.id() === pair.from.groupId)
      const fromPoint = points.find((o) => o.id === pair.from.pointId)
	  // 連線終點節點、連線點
      const toGroup = groups.find((o) => o.id() === pair.to.groupId)
      const toPoint = points.find((o) => o.id === pair.to.pointId)

      // 最小區域
      const fromGroupLinkArea = this.getGroupLinkArea(fromGroup)
      const toGroupLinkArea = this.getGroupLinkArea(toGroup)

      // 兩區域的最短距離(用於動態縮短連線點及其出入口的距離)
      const groupDistance = this.getGroupPairDistance(fromGroupLinkArea, toGroupLinkArea)

      // 不可透過區域
      const fromGroupForbiddenArea = this.getGroupForbiddenArea(
        fromGroupLinkArea,
        groupDistance - 2
      )
      const toGroupForbiddenArea = this.getGroupForbiddenArea(toGroupLinkArea, groupDistance - 2)

      // 兩區域擴充套件
      const groupForbiddenArea = this.getGroupPairArea(fromGroupForbiddenArea, toGroupForbiddenArea)

      // 連線透過區域
      const groupAccessArea = this.getGroupPairArea(
        this.getGroupAccessArea(fromGroupForbiddenArea, groupDistance),
        this.getGroupAccessArea(toGroupForbiddenArea, groupDistance)
      )

      if (fromGroup && toGroup && fromPoint && toPoint) {
        // 起點、終點的錨點
        const fromAnchor = fromGroup.findOne(`#${fromPoint.id}`)
        const toAnchor = toGroup.findOne(`#${toPoint.id}`)

        // 錨點資訊
        const fromAnchorPos = this.getAnchorPos(fromAnchor)
        const toAnchorPos = this.getAnchorPos(toAnchor)

        if (fromAnchor && toAnchor) {
          // 連線出入口
          const fromEntry: Konva.Vector2d = this.getEntry(
            fromAnchor,
            fromGroupLinkArea,
            groupDistance
          )
          const toEntry: Konva.Vector2d = this.getEntry(toAnchor, toGroupLinkArea, groupDistance)

          type matrixPoint = {
            x: number
            y: number
            type?: 'from' | 'to' | 'from-entry' | 'to-entry'
          }
          // 可能點(人為定義的希望折線可以拐彎的位置)
          let matrixPoints: matrixPoint[] = []

          // 透過區域 四角
          matrixPoints.push({ x: groupAccessArea.x1, y: groupAccessArea.y1 })
          matrixPoints.push({ x: groupAccessArea.x2, y: groupAccessArea.y2 })
          matrixPoints.push({ x: groupAccessArea.x1, y: groupAccessArea.y2 })
          matrixPoints.push({ x: groupAccessArea.x2, y: groupAccessArea.y1 })

          // 最小區域 四角
          matrixPoints.push({ x: groupForbiddenArea.x1, y: groupForbiddenArea.y1 })
          matrixPoints.push({ x: groupForbiddenArea.x2, y: groupForbiddenArea.y2 })
          matrixPoints.push({ x: groupForbiddenArea.x1, y: groupForbiddenArea.y2 })
          matrixPoints.push({ x: groupForbiddenArea.x2, y: groupForbiddenArea.y1 })

          // 起點
          matrixPoints.push({
            ...fromAnchorPos,
            type: 'from'
          })
          // 起點 出口
          matrixPoints.push({ ...fromEntry, type: 'from-entry' })

          // 終點
          matrixPoints.push({
            ...toAnchorPos,
            type: 'to'
          })
          // 終點 入口
          matrixPoints.push({ ...toEntry, type: 'to-entry' })

          // 透過區域 中點
          matrixPoints.push({
            x: (groupAccessArea.x1 + groupAccessArea.x2) * 0.5,
            y: (groupAccessArea.y1 + groupAccessArea.y2) * 0.5
          })

          // 去重
          matrixPoints = matrixPoints.reduce(
            (arr, item) => {
              if (item.type === void 0) {
                if (arr.findIndex((o) => o.x === item.x && o.y === item.y) < 0) {
                  arr.push(item)
                }
              } else {
                const idx = arr.findIndex((o) => o.x === item.x && o.y === item.y)
                if (idx > -1) {
                  arr.splice(idx, 1)
                }
                arr.push(item)
              }

              return arr
            },
            [] as typeof matrixPoints
          )

          // 上文提到的:“牆”不同於連線點,需要補充一些點
          const columns = [
            ...matrixPoints.map((o) => o.x),
            // 增加列
            fromGroupForbiddenArea.x1,
            fromGroupForbiddenArea.x2,
            toGroupForbiddenArea.x1,
            toGroupForbiddenArea.x2
          ].sort((a, b) => a - b)

          // 去重
          for (let x = columns.length - 1; x > 0; x--) {
            if (columns[x] === columns[x - 1]) {
              columns.splice(x, 1)
            }
          }
          
          const rows = [
            ...matrixPoints.map((o) => o.y),
            // 增加行
            fromGroupForbiddenArea.y1,
            fromGroupForbiddenArea.y2,
            toGroupForbiddenArea.y1,
            toGroupForbiddenArea.y2
          ].sort((a, b) => a - b)

	      // 去重
          for (let y = rows.length - 1; y > 0; y--) {
            if (rows[y] === rows[y - 1]) {
              rows.splice(y, 1)
            }
          }

          // 遮蔽區域(序號)
          const columnFromStart = columns.findIndex((o) => o === fromGroupForbiddenArea.x1)
          const columnFromEnd = columns.findIndex((o) => o === fromGroupForbiddenArea.x2)
          const rowFromStart = rows.findIndex((o) => o === fromGroupForbiddenArea.y1)
          const rowFromEnd = rows.findIndex((o) => o === fromGroupForbiddenArea.y2)

          const columnToStart = columns.findIndex((o) => o === toGroupForbiddenArea.x1)
          const columnToEnd = columns.findIndex((o) => o === toGroupForbiddenArea.x2)
          const rowToStart = rows.findIndex((o) => o === toGroupForbiddenArea.y1)
          const rowToEnd = rows.findIndex((o) => o === toGroupForbiddenArea.y2)

          // 演算法矩陣起點、終點
          let matrixStart: Konva.Vector2d | null = null
          let matrixEnd: Konva.Vector2d | null = null

          // 演算法地圖矩陣
          const matrix: Array<number[]> = []

          for (let y = 0; y < rows.length; y++) {
            // 新增行
            if (matrix[y] === void 0) {
              matrix[y] = []
            }

            for (let x = 0; x < columns.length; x++) {
              // 不可透過區域(把範圍內的點設定為“牆”)
              if (
                x >= columnFromStart &&
                x <= columnFromEnd &&
                y >= rowFromStart &&
                y <= rowFromEnd
              ) {
                // 起點節點範圍內
                matrix[y][x] = 2
              } else if (
                x >= columnToStart &&
                x <= columnToEnd &&
                y >= rowToStart &&
                y <= rowToEnd
              ) {
                // 終點節點範圍內
                matrix[y][x] = 2
              } else {
                // 可透過區域
                matrix[y][x] = 0
              }

              // 起點、終點 -> 演算法 起點、終點

              if (columns[x] === fromAnchorPos.x && rows[y] === fromAnchorPos.y) {
                matrixStart = { x, y }
              } else if (columns[x] === toAnchorPos.x && rows[y] === toAnchorPos.y) {
                matrixEnd = { x, y }
              }

              // 從 不可透過區域 中找 起點、出口、終點、入口,設定為 可透過(因為與不可透過區域有重疊,所以要單獨設定一下)

              if (fromEntry.x === fromAnchorPos.x) {
                if (
                  columns[x] === fromAnchorPos.x &&
                  rows[y] >= Math.min(fromEntry.y, fromAnchorPos.y) &&
                  rows[y] <= Math.max(fromEntry.y, fromAnchorPos.y)
                ) {
                  matrix[y][x] = 1
                }
              } else if (fromEntry.y === fromAnchorPos.y) {
                if (
                  columns[x] >= Math.min(fromEntry.x, fromAnchorPos.x) &&
                  columns[x] <= Math.max(fromEntry.x, fromAnchorPos.x) &&
                  rows[y] === fromAnchorPos.y
                ) {
                  matrix[y][x] = 1
                }
              }

              if (toEntry.x === toAnchorPos.x) {
                if (
                  columns[x] === toAnchorPos.x &&
                  rows[y] >= Math.min(toEntry.y, toAnchorPos.y) &&
                  rows[y] <= Math.max(toEntry.y, toAnchorPos.y)
                ) {
                  matrix[y][x] = 1
                }
              } else if (toEntry.y === toAnchorPos.y) {
                if (
                  columns[x] >= Math.min(toEntry.x, toAnchorPos.x) &&
                  columns[x] <= Math.max(toEntry.x, toAnchorPos.x) &&
                  rows[y] === toAnchorPos.y
                ) {
                  matrix[y][x] = 1
                }
              }
            }
          }
          
          if (matrixStart && matrixEnd) {
            // 演算法使用
            const way = aStar({
              from: matrixStart,
              to: matrixEnd,
              matrix,
              maxCost: 2
            })

            // 畫線
            this.group.add(
              new Konva.Line({
                name: 'link-line',
                // 用於刪除連線線
                groupId: fromGroup.id(),
                pointId: fromPoint.id,
                pairId: pair.id,
                //
                points: _.flatten(
                  way.map((o) => [
                    this.render.toStageValue(columns[o.x]),
                    this.render.toStageValue(rows[o.y])
                  ])
                ),
                stroke: 'red',
                strokeWidth: 2
              })
            )
          }
        }
      }
    }
    
    // 略
  }

關於程式碼裡提到的“動態縮短連線點及其出入口的距離”,上面程式碼裡的“groupDistance”變數,由於我們人為定義了連線點的出入口,出入口距離連線點是存在一些距離的,當兩個節點距離太近的時候,其實就是兩個出入口在某一方向上挨在一起,導致演算法認為“無路可走”,無法繪製連線線了:

image

因此,當兩個節點距離太近的時候,動態縮小這個距離:
image

這裡定義了,動態的距離範圍在 6px ~ 背景網格大小 之間,取決於兩個節點之間的最短距離。

  getGroupPairDistance(groupArea1: Area, groupArea2: Area): number {
    const xs = [groupArea1.x1, groupArea1.x2, groupArea2.x1, groupArea2.x2]
    const maxX = Math.max(...xs)
    const minX = Math.min(...xs)
    const dx = maxX - minX - (groupArea1.x2 - groupArea1.x1 + (groupArea2.x2 - groupArea2.x1))

    const ys = [groupArea1.y1, groupArea1.y2, groupArea2.y1, groupArea2.y2]
    const maxY = Math.max(...ys)
    const minY = Math.min(...ys)
    const dy = maxY - minY - (groupArea1.y2 - groupArea1.y1 + (groupArea2.y2 - groupArea2.y1))
    //
    return this.render.toBoardValue(
      Math.min(this.render.bgSize, Math.max(dx < 6 ? 6 : dx, dy < 6 ? 6 : dy) * 0.5)
    )
  }

另外,程式碼裡計算 不可透過區域 的“groupDistance - 2”,減去2個畫素點原因是,人為與外層區域留了點空隙,距離縮小至動態範圍內,兩節點總有空間用於計算資料模型。

下面,逐個說明一下工具方法:

其實就是,所有錨點佔用的最小矩形區域

  // 元素(連線點們)最小區域(絕對值)
  getGroupLinkArea(group?: Konva.Group): Area {
    let area: Area = {
      x1: 0,
      y1: 0,
      x2: 0,
      y2: 0
    }

    if (group) {
      // stage 狀態
      const stageState = this.render.getStageState()

      const anchors = group.find('.link-anchor')

      const positions = anchors.map((o) => o.absolutePosition())

      area = {
        x1: Math.min(...positions.map((o) => o.x)) - stageState.x,
        y1: Math.min(...positions.map((o) => o.y)) - stageState.y,
        x2: Math.max(...positions.map((o) => o.x)) - stageState.x,
        y2: Math.max(...positions.map((o) => o.y)) - stageState.y
      }
    }

    return area
  }

其實就是在傳入區域基礎上,增加內邊距(目前 gap 也就是 groupDistance):

  // 連線不可透過區域
  getGroupForbiddenArea(groupArea: Area, gap: number): Area {
    const area: Area = {
      x1: groupArea.x1 - gap,
      y1: groupArea.y1 - gap,
      x2: groupArea.x2 + gap,
      y2: groupArea.y2 + gap
    }

    return area
  }

同上

  // 連線透過區域
  getGroupAccessArea(groupArea: Area, gap: number): Area {
    const area: Area = {
      x1: groupArea.x1 - gap,
      y1: groupArea.y1 - gap,
      x2: groupArea.x2 + gap,
      y2: groupArea.y2 + gap
    }

    return area
  }

兩個區域佔用的最小矩形區域

  // 兩區域擴充套件
  getGroupPairArea(groupArea1: Area, groupArea2: Area): Area {
    const area: Area = {
      x1: Math.min(groupArea1.x1, groupArea2.x1),
      y1: Math.min(groupArea1.y1, groupArea2.y1),
      x2: Math.max(groupArea1.x2, groupArea2.x2),
      y2: Math.max(groupArea1.y2, groupArea2.y2)
    }

    return area
  }

透過元素最小區域和錨點,得出該錨點的出入口

  // 連線出入口
  getEntry(anchor: Konva.Node, groupLinkArea: Area, gap: number): Konva.Vector2d {
    // stage 狀態
    const stageState = this.render.getStageState()

    let entry: Konva.Vector2d = {
      x: 0,
      y: 0
    }

    const fromPos = anchor.absolutePosition()

    if (fromPos.x - stageState.x === groupLinkArea.x1) {
      entry = {
        x: fromPos.x - gap - stageState.x,
        y: fromPos.y - stageState.y
      }
    } else if (fromPos.x - stageState.x === groupLinkArea.x2) {
      entry = {
        x: fromPos.x + gap - stageState.x,
        y: fromPos.y - stageState.y
      }
    } else if (fromPos.y - stageState.y === groupLinkArea.y1) {
      entry = {
        x: fromPos.x - stageState.x,
        y: fromPos.y - gap - stageState.y
      }
    } else if (fromPos.y - stageState.y === groupLinkArea.y2) {
      entry = {
        x: fromPos.x - stageState.x,
        y: fromPos.y + gap - stageState.y
      }
    }

    return entry
  }

到此,折線繪製的主要邏輯就完成了。

已知缺陷

從 Issue 中得知,當節點進行說 transform rotate 旋轉的時候,對齊就會出問題。相應的,繪製連線線(折線)的場景也有類似的問題,大家多多支援,後面抽空研究處理一下(-_-)。。。

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章