前端使用 Konva 實現視覺化設計器(15)- 自定義連線點、連線最佳化

xachary發表於2024-06-14

前面,本示例實現了折線連線線,簡述了實現的思路和原理,也已知了一些缺陷。本章將處理一些缺陷的同時,實現支援連線點的自定義,一個節點可以定義多個連線點,最終可以滿足類似圖元接線的效果。

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

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

github原始碼

gitee原始碼

示例地址

一些調整

  • 把示例素材從 src 轉移至 public 目錄,拖入畫布的素材改為非同步載入
  • 移除部分示例素材
  • 一些開發過程中的測試用例可以線上載入

此前有些朋友說匯入、匯出有異常,估計是線上版本和線下版本的構建示例素材的檔案 hash 字尾不一樣,跨環境匯入、匯出無法載入圖片導致的。現在調整後就應該正常了。

自定義連線點

先說明一下定義:

// src/Render/types.ts

export interface AssetInfoPoint {
  x: number
  y: number
  direction?: 'top' | 'bottom' | 'left' | 'right' // 人為定義連線點屬於元素的什麼方向
}

export interface AssetInfo {
  url: string
  points?: Array<AssetInfoPoint>
}
// src/Render/draws/LinkDraw.ts

// 連線點
export interface LinkDrawPoint {
  id: string
  groupId: string
  visible: boolean
  pairs: LinkDrawPair[]
  x: number
  y: number
  direction?: 'top' | 'bottom' | 'left' | 'right' // 人為定義連線點屬於元素的什麼方向
}

一個素材除了原來的 url 資訊外,增加了一個 points 的連線點陣列,每個 point 除了記錄了它的相對於素材的位置 x、y,還有方向的定義,目的是說明該連線點出入口方向,例如:

image

做這個定義的原因是,連線方向不可以預知,是與圖元的含義有關。
不設定 direction 的話,就代表連線線可以從上下左右4個方向進出,如:

image

最佳實踐應該另外實現一個連線點定義工具(也許後面有機會實現一個),多多支援~

// src/App.vue

// 從 public 載入靜態資源 + 自定義連線點
const assetsModules: Array<Types.AssetInfo> = [
  { "url": "./img/svg/ARRESTER_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/ARRESTER_2.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/ARRESTER_2_1.svg", points: [{ x: 101, y: 1, direction: 'top' }, { x: 101, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/BREAKER_CLOSE.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
  { "url": "./img/svg/BREAKER_OPEN.svg", points: [{ x: 100, y: 1, direction: 'top' }, { x: 100, y: 199, direction: 'bottom' }] },
  // 略
 ]

素材拖入之前,需要攜帶 points 資訊:

// src/App.vue

function onDragstart(e: GlobalEventHandlersEventMap['dragstart'], item: Types.AssetInfo) {
  if (e.dataTransfer) {
    e.dataTransfer.setData('src', item.url)
    e.dataTransfer.setData('points', JSON.stringify(item.points)) // 傳遞連線點資訊
    e.dataTransfer.setData('type', item.url.match(/([^./]+)\.([^./]+)$/)?.[2] ?? '')
  }
}

拖入之後,需要解析 points 資訊:

// src/Render/handlers/DragOutsideHandlers.ts

      drop: (e: GlobalEventHandlersEventMap['drop']) => {
        const src = e.dataTransfer?.getData('src')

        // 接收連線點資訊
        let morePoints: Types.AssetInfoPoint[] = []
        const morePointsTxt = e.dataTransfer?.getData('points') ?? '[]'

        try {
          morePoints = JSON.parse(morePointsTxt)
        } catch (e) {
          console.error(e)
        }

        // 略

              // 預設連線點
              let points: Types.AssetInfoPoint[] = [
                // 左
                { x: 0, y: group.height() / 2, direction: 'left' },
                // 右
                {
                  x: group.width(),
                  y: group.height() / 2,
                  direction: 'right'
                },
                // 上
                { x: group.width() / 2, y: 0, direction: 'top' },
                // 下
                {
                  x: group.width() / 2,
                  y: group.height(),
                  direction: 'bottom'
                }
              ]

              // 自定義連線點 覆蓋 預設連線點
              if (Array.isArray(morePoints) && morePoints.length > 0) {
                points = morePoints
              }

              // 連線點資訊
              group.setAttrs({
                points: points.map(
                  (o) =>
                    ({
                      ...o,
                      id: nanoid(),
                      groupId: group.id(),
                      visible: false,
                      pairs: [],
                      direction: o.direction // 補充資訊
                    }) as LinkDrawPoint
                )
              })

              // 連線點(錨點)
              for (const point of group.getAttr('points') ?? []) {
                group.add(
                  new Konva.Circle({
                    name: 'link-anchor',
                    id: point.id,
                    x: point.x,
                    y: point.y,
                    radius: this.render.toStageValue(1),
                    stroke: 'rgba(0,0,255,1)',
                    strokeWidth: this.render.toStageValue(2),
                    visible: false,
                    direction: point.direction // 補充資訊
                  })
                )
              }
              
              // 略
      }
        

如果沒有自定義連線點,這裡會給予之前一樣的 4 個預設連線點。

出入口修改

原來的邏輯就不能用了,需要重寫一個。目標是計算出:沿著當前連線點的方向 與 不可透過區域其中一邊的相交點,上圖:

image

關注的就是這個綠色點(出入口):

image

就算這個點,用的是三角函式:

image

這裡邊長稱為 offset,角度為 rotate,計算大概如下:

const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)

不同角度範圍,計算略有不同,是根據多次測試得出的,有興趣的朋友可以在最佳化精簡一下。

完整方法有點長,四個角直接賦值,其餘按不同角度範圍計算:

  // 連線出入口(原來第二個引數是 最小區域,先改為 不可透過區域)
  getEntry(anchor: Konva.Node, groupForbiddenArea: Area, gap: number): Konva.Vector2d {
    // stage 狀態
    const stageState = this.render.getStageState()

    const fromPos = anchor.absolutePosition()

    // 預設為 起點/終點 位置(無 direction 時的值)
    let x = fromPos.x - stageState.x,
      y = fromPos.y - stageState.y

    const direction = anchor.attrs.direction

    // 定義了 direction 的時候
    if (direction) {
      // 取整 連線點 錨點 旋轉角度(保留 1 位小數點)
      const rotate = Math.round(anchor.getAbsoluteRotation() * 10) / 10

      // 利用三角函式,計算按 direction 方向與 不可透過區域 的相交點位置(即出/入口 entry)
      if (rotate === -45) {
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        }
      } else if (rotate === 45) {
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        }
      } else if (rotate === 135) {
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        }
      } else if (rotate === -135) {
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = groupForbiddenArea.y1
        }
      } else if (rotate > -45 && rotate < 45) {
        const offset = gap * Math.tan((rotate * Math.PI) / 180)
        if (direction === 'top') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        } else if (direction === 'bottom') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'left') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'right') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        }
      } else if (rotate > 45 && rotate < 135) {
        const offset = gap * Math.atan(((90 - rotate) * Math.PI) / 180)
        if (direction === 'top') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'left') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y1
        } else if (direction === 'right') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y2
        }
      } else if ((rotate > 135 && rotate <= 180) || (rotate >= -180 && rotate < -135)) {
        const offset = gap * Math.tan((rotate * Math.PI) / 180)
        if (direction === 'top') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'bottom') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        } else if (direction === 'left') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'right') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        }
      } else if (rotate > -135 && rotate < -45) {
        const offset = gap * Math.atan(((90 + rotate) * Math.PI) / 180)
        if (direction === 'top') {
          x = groupForbiddenArea.x1
          y = fromPos.y - stageState.y - offset
        } else if (direction === 'bottom') {
          x = groupForbiddenArea.x2
          y = fromPos.y - stageState.y + offset
        } else if (direction === 'left') {
          x = fromPos.x - stageState.x - offset
          y = groupForbiddenArea.y2
        } else if (direction === 'right') {
          x = fromPos.x - stageState.x + offset
          y = groupForbiddenArea.y1
        }
      }
    }

    return { x, y } as Konva.Vector2d
  }

原來的演算法起點、終點 與 連線點一一對應,科室現在新的計算方法得出的出入口x、y座標與連線點不再總是存在同一方向一致(因為被旋轉),所以現在把演算法的起點、終點改為出入口對應:

              // 出口、入口 -> 演算法 起點、終點

              if (columns[x] === fromEntry.x && rows[y] === fromEntry.y) {
                matrix[y][x] = 1
                matrixStart = { x, y }
              }

              if (columns[x] === toEntry.x && rows[y] === toEntry.y) {
                matrix[y][x] = 1
                matrixEnd = { x, y }
              }

上面提到沒有定義 direction 的連線點可以從不同方向出入,所以會進行下面處理:

              // 沒有定義方向(給於十字可透過區域)
              // 如,從:
              // 1 1 1
              // 1 0 1
              // 1 1 1
              // 變成:
              // 1 0 1
              // 0 0 0
              // 1 0 1
              if (!fromAnchor.attrs.direction) {
                if (columns[x] === fromEntry.x || rows[y] === fromEntry.y) {
                  if (
                    x >= columnFromStart &&
                    x <= columnFromEnd &&
                    y >= rowFromStart &&
                    y <= rowFromEnd
                  ) {
                    matrix[y][x] = 1
                  }
                }
              }
              if (!toAnchor.attrs.direction) {
                if (columns[x] === toEntry.x || rows[y] === toEntry.y) {
                  if (x >= columnToStart && x <= columnToEnd && y >= rowToStart && y <= rowToEnd) {
                    matrix[y][x] = 1
                  }
                }
              }

最後在繪製連線的時候,補上連線點(起點、終點)即可:

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

測試一下

image

已知缺陷

從 Issue 中得知,當節點進行說 transform rotate 旋轉的時候,對齊就會出問題。大家多多支援,後面抽空研究處理一下(-_-)。。。

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章