前端使用 Konva 實現視覺化設計器(10)- 對齊線

xachary發表於2024-05-11

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

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

github原始碼

gitee原始碼

示例地址

不知不覺來到第 10 章了,感覺接近尾聲了。。。

對齊線

先看效果:
image

這裡互動有兩個部分:
1、節點之間的對齊線
2、對齊磁貼

多選的情況下,效果是一樣的:
image

主要邏輯會放在控制“選擇”的程式碼檔案裡:
src\Render\handlers\SelectionHandlers.ts
這裡需要一些輔助都定義:

interface SortItem {
  id?: number // 有 id 就是其他節點,否則就是 選擇目標
  value: number // 左、垂直中、右的 x 座標值; 上、水平中、下的 y 座標值;
}

type SortItemPair = [SortItem, SortItem]

嘗試畫個圖說明一下上面的含義:

這裡以縱向(基於 x 座標值)為例:

image
這裡的 x1~x9,就是 SortItem,橫向(基於 y 座標值)同理,特別地,如果是正在拖動的目標節點,會把該節點的 _id 記錄在 SortItem 以示區分。

會存在一個處理,把一個方向上的所有 x 座標進行從小到大的排序,然後一雙一雙的遍歷,需要符合以下條件“必須分別屬於相鄰的兩個節點”的 SortItem 對,也就是 SortItemPair。

在查詢所有 SortItemPair 的同時,只會更新並記錄節點距離最短的那些 SortItemPair(可能會存在多個)。

核心邏輯程式碼:

  // 磁吸邏輯
  attract = (newPos: Konva.Vector2d) => {
    // 對齊線清除
    this.alignLinesClear()

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

    const width = this.render.transformer.width()
    const height = this.render.transformer.height()

    let newPosX = newPos.x
    let newPosY = newPos.y

    let isAttract = false

    let pairX: SortItemPair | null = null
    let pairY: SortItemPair | null = null

    // 對齊線 磁吸邏輯
    if (this.render.config.attractNode) {
      // 橫向所有需要判斷對齊的 x 座標
      const sortX: Array<SortItem> = []
      // 縱向向所有需要判斷對齊的 y 座標
      const sortY: Array<SortItem> = []

      // 選擇目標所有的對齊 x
      sortX.push(
        {
          value: this.render.toStageValue(newPos.x - stageState.x) // 左
        },
        {
          value: this.render.toStageValue(newPos.x - stageState.x + width / 2) // 垂直中
        },
        {
          value: this.render.toStageValue(newPos.x - stageState.x + width) // 右
        }
      )

      // 選擇目標所有的對齊 y
      sortY.push(
        {
          value: this.render.toStageValue(newPos.y - stageState.y) // 上
        },
        {
          value: this.render.toStageValue(newPos.y - stageState.y + height / 2) // 水平中
        },
        {
          value: this.render.toStageValue(newPos.y - stageState.y + height) // 下
        }
      )

      // 拖動目標
      const targetIds = this.render.selectionTool.selectingNodes.map((o) => o._id)
      // 除拖動目標的其他
      const otherNodes = this.render.layer.getChildren((node) => !targetIds.includes(node._id))

      // 其他節點所有的 x / y 座標
      for (const node of otherNodes) {
        // x
        sortX.push(
          {
            id: node._id,
            value: node.x() // 左
          },
          {
            id: node._id,
            value: node.x() + node.width() / 2 // 垂直中
          },
          {
            id: node._id,
            value: node.x() + node.width() // 右
          }
        )
        // y
        sortY.push(
          {
            id: node._id,
            value: node.y() // 上
          },
          {
            id: node._id,
            value: node.y() + node.height() / 2 // 水平中
          },
          {
            id: node._id,
            value: node.y() + node.height() // 下
          }
        )
      }

      // 排序
      sortX.sort((a, b) => a.value - b.value)
      sortY.sort((a, b) => a.value - b.value)

      // x 最短距離
      let XMin = Infinity
      // x 最短距離的【對】(多個)
      let pairXMin: Array<SortItemPair> = []

      // y 最短距離
      let YMin = Infinity
      // y 最短距離的【對】(多個)
      let pairYMin: Array<SortItemPair> = []

      // 一對對比較距離,記錄最短距離的【對】
      // 必須是 選擇目標 與 其他節點 成【對】
      // 可能有多個這樣的【對】

      for (let i = 0; i < sortX.length - 1; i++) {
        // 相鄰兩個點,必須為 目標節點 + 非目標節點
        if (
          (sortX[i].id === void 0 && sortX[i + 1].id !== void 0) ||
          (sortX[i].id !== void 0 && sortX[i + 1].id === void 0)
        ) {
          // 相鄰兩個點的 x 距離
          const offset = Math.abs(sortX[i].value - sortX[i + 1].value)
          if (offset < XMin) {
            // 更新 x 最短距離 記錄
            XMin = offset
            // 更新 x 最短距離的【對】 記錄
            pairXMin = [[sortX[i], sortX[i + 1]]]
          } else if (offset === XMin) {
            // 存在多個 x 最短距離
            pairXMin.push([sortX[i], sortX[i + 1]])
          }
        }
      }

      for (let i = 0; i < sortY.length - 1; i++) {
        // 相鄰兩個點,必須為 目標節點 + 非目標節點
        if (
          (sortY[i].id === void 0 && sortY[i + 1].id !== void 0) ||
          (sortY[i].id !== void 0 && sortY[i + 1].id === void 0)
        ) {
          // 相鄰兩個點的 y 距離
          const offset = Math.abs(sortY[i].value - sortY[i + 1].value)
          if (offset < YMin) {
            // 更新 y 最短距離 記錄
            YMin = offset
            // 更新 y 最短距離的【對】 記錄
            pairYMin = [[sortY[i], sortY[i + 1]]]
          } else if (offset === YMin) {
            // 存在多個 y 最短距離
            pairYMin.push([sortY[i], sortY[i + 1]])
          }
        }
      }

      // 取第一【對】,用於判斷距離是否在閾值內

      if (pairXMin[0]) {
        if (Math.abs(pairXMin[0][0].value - pairXMin[0][1].value) < this.render.bgSize / 2) {
          pairX = pairXMin[0]
        }
      }

      if (pairYMin[0]) {
        if (Math.abs(pairYMin[0][0].value - pairYMin[0][1].value) < this.render.bgSize / 2) {
          pairY = pairYMin[0]
        }
      }

      // 優先對齊節點

      // 存在 1或多個 x 最短距離 滿足閾值
      if (pairX?.length === 2) {
        for (const pair of pairXMin) {
          // 【對】裡的那個非目標節點
          const other = pair.find((o) => o.id !== void 0)
          if (other) {
            // x 對齊線
            const line = new Konva.Line({
              points: _.flatten([
                [other.value, this.render.toStageValue(-stageState.y)],
                [other.value, this.render.toStageValue(this.render.stage.height() - stageState.y)]
              ]),
              stroke: 'blue',
              strokeWidth: this.render.toStageValue(1),
              dash: [4, 4],
              listening: false
            })
            this.alignLines.push(line)
            this.render.layerCover.add(line)
          }
        }
        // 磁貼第一個【對】
        const target = pairX.find((o) => o.id === void 0)
        const other = pairX.find((o) => o.id !== void 0)
        if (target && other) {
          // 磁鐵座標值
          newPosX = newPosX - this.render.toBoardValue(target.value - other.value)
          isAttract = true
        }
      }

      // 存在 1或多個 y 最短距離 滿足閾值
      if (pairY?.length === 2) {
        for (const pair of pairYMin) {
          // 【對】裡的那個非目標節點
          const other = pair.find((o) => o.id !== void 0)
          if (other) {
            // y 對齊線
            const line = new Konva.Line({
              points: _.flatten([
                [this.render.toStageValue(-stageState.x), other.value],
                [this.render.toStageValue(this.render.stage.width() - stageState.x), other.value]
              ]),
              stroke: 'blue',
              strokeWidth: this.render.toStageValue(1),
              dash: [4, 4],
              listening: false
            })
            this.alignLines.push(line)
            this.render.layerCover.add(line)
          }
        }
        // 磁貼第一個【對】
        const target = pairY.find((o) => o.id === void 0)
        const other = pairY.find((o) => o.id !== void 0)
        if (target && other) {
          // 磁鐵座標值
          newPosY = newPosY - this.render.toBoardValue(target.value - other.value)
          isAttract = true
        }
      }
    }

雖然程式碼比較冗長,不過邏輯相對還是比較清晰,找到滿足條件(小於閾值,足夠近,這裡閾值為背景網格的一半大小)的 SortItemPair,就可以根據記錄的座標值大小,定義並繪製相應的線條(新增到 layerCover 中),記錄在某個變數中:

  // 對齊線
  alignLines: Konva.Line[] = []

  // 對齊線清除
  alignLinesClear() {
    for (const line of this.alignLines) {
      line.remove()
    }
    this.alignLines = []
  }

在適合的時候,執行 alignLinesClear 清空失效的對齊線即可。

接下來,計劃實現下面這些功能:

  • 連線線
  • 等等。。。

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章