前端使用 Konva 實現視覺化設計器(19)- 連線線 - 直線、折線

xachary發表於2024-08-01

本章響應小夥伴的反饋,除了演算法自動畫連線線(仍需最佳化完善),實現了可以手動繪製直線、折線連線線功能。

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

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

github原始碼

gitee原始碼

示例地址

模式切換

image

前置工作

連線線 模式種類

// src/Render/types.ts
export enum LinkType {
  'auto' = 'auto',
  'straight' = 'straight', // 直線
  'manual' = 'manual' // 手動折線
}

連線線 模式狀態

// src/Render/draws/LinkDraw.ts
​
// 連線線(臨時)
export interface LinkDrawState {
  // 略
  linkType: Types.LinkType // 連線線型別
  linkManualing: boolean // 是否 正在操作拐點
}

連線線 模式切換方法

// src/Render/draws/LinkDraw.ts
​
  /**
   * 修改當前連線線型別
   * @param linkType Types.LinkType
   */
  changeLinkType(linkType: Types.LinkType) {
    this.state.linkType = linkType
    this.render.config?.on?.linkTypeChange?.(this.state.linkType)
  }

連線線 模式切換按鈕

<!-- src/App.vue -->
​
<button @click="onLinkTypeChange(Types.LinkType.auto)"
        :disabled="currentLinkType === Types.LinkType.auto">連線線:自動</button>
<button @click="onLinkTypeChange(Types.LinkType.straight)"
        :disabled="currentLinkType === Types.LinkType.straight">連線線:直線</button>
<button @click="onLinkTypeChange(Types.LinkType.manual)"
        :disabled="currentLinkType === Types.LinkType.manual">連線線:手動</button>

連線線 模式切換事件

// src/App.vue
const currentLinkType = ref(Types.LinkType.auto)
​
function onLinkTypeChange(linkType: Types.LinkType) {
  (render?.draws[Draws.LinkDraw.name] as Draws.LinkDraw).changeLinkType(linkType)
}

當前 連線對(pair) 記錄當前 連線線 模式

// src/Render/draws/LinkDraw.ts
​
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略
  override draw() {
    // 略
  
    // 連線點
    for (const point of points) {
      // 略
    
      // 非 選擇中
      if (group && !group.getAttr('selected')) {
        // 略
        const anchor = this.render.layer.findOne(`#${point.id}`)
​
        if (anchor) {
          // 略
          circle.on('mouseup', () => {
            if (this.state.linkingLine) {
              // 略
              
              // 不同連線點
              if (line.circle.id() !== circle.id()) {
                // 略
                if (toGroup) {
                  // 略
                  if (fromPoint) {
                    // 略
                    if (toPoint) {
                      if (Array.isArray(fromPoint.pairs)) {
                        fromPoint.pairs = [
                          ...fromPoint.pairs,
                          {
                            // 略
                            
                            linkType: this.state.linkType // 記錄 連線線 型別
                          }
                        ]
                      }
                      // 略
                    }
                  }
                }
              }
              // 略
            }
          })
          // 略
        }
      }
    }
  }
}

直線

image

繪製直線相對簡單,透過判斷 連線對(pair)記錄的 連線線 模式,從起點繪製一條 Line 到終點即可:

// src/Render/draws/LinkDraw.ts
​
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略
  override draw() {
    // 略
  
    // 連線線
    for (const pair of pairs) {
        if (pair.linkType === Types.LinkType.manual) {
          // 略,手動折線
        } else if (pair.linkType === Types.LinkType.straight) {
          // 直線
​
          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)
​
            const linkLine = new Konva.Line({
              name: 'link-line',
              // 用於刪除連線線
              groupId: fromGroup.id(),
              pointId: fromPoint.id,
              pairId: pair.id,
              linkType: pair.linkType,
​
              points: _.flatten([
                [
                  this.render.toStageValue(fromAnchorPos.x),
                  this.render.toStageValue(fromAnchorPos.y)
                ],
                [this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
              ]),
              stroke: 'red',
              strokeWidth: 2
            })
​
            this.group.add(linkLine)
          }
        } else {
          // 略,原演算法畫連線線邏輯
        }
    }
  }
}

折線

image

繪製折線,先人為定義 3 種“點”: 1、連線點,就是原來就有的。 2、拐點(待拐),藍色的,從未拖動過的,一旦拖動,會新增拐點記錄。 3、拐點(已拐),綠色的,已經拖動過的,依然可以拖動,但不會新增拐點記錄。

image

請留意下方程式碼的註釋,關鍵:

  • fromGroup 會記錄 拐點 manualPoints。
  • 連線線 的繪製是從 起點 -> 拐點(們)-> 終點(linkPoints)。
  • 拐點正在拖動時,繪製臨時的虛線 Line。
  • 分別處理 拐點(待拐)和 拐點(已拐)兩種情況。

處理 拐點(待拐)和 拐點(已拐)主要區別是:

  • 處理 拐點(待拐),遍歷 linkPoints 的時候,是成對遍歷的。
  • 處理 拐點(已拐),遍歷 linkPoints 的時候,是跳過 起點 和 終點 的。
  • 拖動 拐點(待拐),會新增拐點記錄。
  • 拖動 拐點(已拐),不會新增拐點記錄。
// src/Render/draws/LinkDraw.ts
​
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
  // 略
  override draw() {
    // 略
  
    // 連線線
    for (const pair of pairs) {
        if (pair.linkType === Types.LinkType.manual) {
          // 手動折線
​
          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)
​
            // 拐點(已拐)記錄
            const manualPoints: Array<{ x: number; y: number }> = Array.isArray(
              fromGroup.getAttr('manualPoints')
            )
              ? fromGroup.getAttr('manualPoints')
              : []
​
            // 連線點 + 拐點
            const linkPoints = [
              [
                this.render.toStageValue(fromAnchorPos.x),
                this.render.toStageValue(fromAnchorPos.y)
              ],
              ...manualPoints.map((o) => [o.x, o.y]),
              [this.render.toStageValue(toAnchorPos.x), this.render.toStageValue(toAnchorPos.y)]
            ]
​
            // 連線線
            const linkLine = new Konva.Line({
              name: 'link-line',
              // 用於刪除連線線
              groupId: fromGroup.id(),
              pointId: fromPoint.id,
              pairId: pair.id,
              linkType: pair.linkType,
​
              points: _.flatten(linkPoints),
              stroke: 'red',
              strokeWidth: 2
            })
​
            this.group.add(linkLine)
​
            // 正在拖動效果
            const manualingLine = new Konva.Line({
              stroke: '#ff0000',
              strokeWidth: 2,
              points: [],
              dash: [4, 4]
            })
            this.group.add(manualingLine)
​
            // 拐點
​
            // 拐點(待拐)
            for (let i = 0; i < linkPoints.length - 1; i++) {
              const circle = new Konva.Circle({
                id: nanoid(),
                pairId: pair.id,
                x: (linkPoints[i][0] + linkPoints[i + 1][0]) / 2,
                y: (linkPoints[i][1] + linkPoints[i + 1][1]) / 2,
                radius: this.render.toStageValue(this.render.bgSize / 2),
                stroke: 'rgba(0,0,255,0.1)',
                strokeWidth: this.render.toStageValue(1),
                name: 'link-manual-point',
                // opacity: 0,
                linkManualIndex: i // 當前拐點位置
              })
​
              // hover 效果
              circle.on('mouseenter', () => {
                circle.stroke('rgba(0,0,255,0.8)')
                document.body.style.cursor = 'pointer'
              })
              circle.on('mouseleave', () => {
                if (!circle.attrs.dragStart) {
                  circle.stroke('rgba(0,0,255,0.1)')
                  document.body.style.cursor = 'default'
                }
              })
​
              // 拐點操作
              circle.on('mousedown', () => {
                const pos = circle.getAbsolutePosition()
​
                // 記錄操作開始狀態
                circle.setAttrs({
                  // 開始座標
                  dragStartX: pos.x,
                  dragStartY: pos.y,
                  // 正在操作
                  dragStart: true
                })
​
                // 標記狀態 - 正在操作拐點
                this.state.linkManualing = true
              })
              this.render.stage.on('mousemove', () => {
                if (circle.attrs.dragStart) {
                  // 正在操作
                  const pos = this.render.stage.getPointerPosition()
                  if (pos) {
                    // 磁貼
                    const { pos: transformerPos } = this.render.attractTool.attract({
                      x: pos.x,
                      y: pos.y,
                      width: 1,
                      height: 1
                    })
​
                    // 移動拐點
                    circle.setAbsolutePosition(transformerPos)
​
                    // 正在拖動效果
                    const tempPoints = [...linkPoints]
                    tempPoints.splice(circle.attrs.linkManualIndex + 1, 0, [
                      this.render.toStageValue(transformerPos.x - stageState.x),
                      this.render.toStageValue(transformerPos.y - stageState.y)
                    ])
                    manualingLine.points(_.flatten(tempPoints))
                  }
                }
              })
              circle.on('mouseup', () => {
                const pos = circle.getAbsolutePosition()
​
                if (
                  Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
                  Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
                ) {
                  // 操作移動距離達到閾值
​
                  // stage 狀態
                  const stageState = this.render.getStageState()
​
                  // 記錄(插入)拐點
                  manualPoints.splice(circle.attrs.linkManualIndex, 0, {
                    x: this.render.toStageValue(pos.x - stageState.x),
                    y: this.render.toStageValue(pos.y - stageState.y)
                  })
                  fromGroup.setAttr('manualPoints', manualPoints)
                }
​
                // 操作結束
                circle.setAttrs({
                  dragStart: false
                })
​
                // state 操作結束
                this.state.linkManualing = false
​
                // 銷燬
                circle.destroy()
                manualingLine.destroy()
​
                // 更新歷史
                this.render.updateHistory()
​
                // 重繪
                this.render.redraw()
              })
​
              this.group.add(circle)
            }
​
            // 拐點(已拐)
            for (let i = 1; i < linkPoints.length - 1; i++) {
              const circle = new Konva.Circle({
                id: nanoid(),
                pairId: pair.id,
                x: linkPoints[i][0],
                y: linkPoints[i][1],
                radius: this.render.toStageValue(this.render.bgSize / 2),
                stroke: 'rgba(0,100,0,0.1)',
                strokeWidth: this.render.toStageValue(1),
                name: 'link-manual-point',
                // opacity: 0,
                linkManualIndex: i // 當前拐點位置
              })
​
              // hover 效果
              circle.on('mouseenter', () => {
                circle.stroke('rgba(0,100,0,1)')
                document.body.style.cursor = 'pointer'
              })
              circle.on('mouseleave', () => {
                if (!circle.attrs.dragStart) {
                  circle.stroke('rgba(0,100,0,0.1)')
                  document.body.style.cursor = 'default'
                }
              })
​
              // 拐點操作
              circle.on('mousedown', () => {
                const pos = circle.getAbsolutePosition()
​
                // 記錄操作開始狀態
                circle.setAttrs({
                  dragStartX: pos.x,
                  dragStartY: pos.y,
                  dragStart: true
                })
​
                // 標記狀態 - 正在操作拐點
                this.state.linkManualing = true
              })
              this.render.stage.on('mousemove', () => {
                if (circle.attrs.dragStart) {
                  // 正在操作
                  const pos = this.render.stage.getPointerPosition()
                  if (pos) {
                    // 磁貼
                    const { pos: transformerPos } = this.render.attractTool.attract({
                      x: pos.x,
                      y: pos.y,
                      width: 1,
                      height: 1
                    })
​
                    // 移動拐點
                    circle.setAbsolutePosition(transformerPos)
​
                    // 正在拖動效果
                    const tempPoints = [...linkPoints]
                    tempPoints[circle.attrs.linkManualIndex] = [
                      this.render.toStageValue(transformerPos.x - stageState.x),
                      this.render.toStageValue(transformerPos.y - stageState.y)
                    ]
                    manualingLine.points(_.flatten(tempPoints))
                  }
                }
              })
              circle.on('mouseup', () => {
                const pos = circle.getAbsolutePosition()
​
                if (
                  Math.abs(pos.x - circle.attrs.dragStartX) > this.option.size ||
                  Math.abs(pos.y - circle.attrs.dragStartY) > this.option.size
                ) {
                  // 操作移動距離達到閾值
​
                  // stage 狀態
                  const stageState = this.render.getStageState()
​
                  // 記錄(更新)拐點
                  manualPoints[circle.attrs.linkManualIndex - 1] = {
                    x: this.render.toStageValue(pos.x - stageState.x),
                    y: this.render.toStageValue(pos.y - stageState.y)
                  }
                  fromGroup.setAttr('manualPoints', manualPoints)
                }
​
                // 操作結束
                circle.setAttrs({
                  dragStart: false
                })
​
                // state 操作結束
                this.state.linkManualing = false
​
                // 銷燬
                circle.destroy()
                manualingLine.destroy()
​
                // 更新歷史
                this.render.updateHistory()
​
                // 重繪
                this.render.redraw()
              })
​
              this.group.add(circle)
            }
          }
        } else if (pair.linkType === Types.LinkType.straight) {
          // 略,直線
        } else {
          // 略,原演算法畫連線線邏輯
        }
    }
  }
}

最後,關於 linkManualing 狀態,會用在 2 個地方,避免和其它互動產生衝突:

// src/Render/handlers/DragHandlers.ts

// 略

export class DragHandlers implements Types.Handler {
  // 略  
  handlers = {
    stage: {
      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        // 拐點操作中,防止異常拖動
        if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
          // 略
        }
      },
      // 略
    }
  }
}
// src/Render/tools/LinkTool.ts

// 略
export class LinkTool {
  // 略

  pointsVisible(visible: boolean, group?: Konva.Group) {
    // 略

    // 拐點操作中,此處不重繪
    if (!(this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state.linkManualing) {
      // 重繪
      this.render.redraw()
    }
  }
  // 略
}

Done!

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章