前端使用 Konva 實現視覺化設計器(22)- 繪製圖形(矩形、直線、折線)

xachary發表於2024-09-10

本章分享一下如何使用 Konva 繪製基礎圖形:矩形、直線、折線,希望大家繼續關注和支援哈!

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

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

github原始碼

gitee原始碼

示例地址

矩形

先上效果!

image
image

實現方式基本和《前端使用 Konva 實現視覺化設計器(21)- 繪製圖形(橢圓)》是一致的,主要區別矩形的大小和橢圓形的大小設定方式不一樣,特別是矩形無需設定 offset。其它就不再贅述了哈。

直線、折線

先上效果!

image
image

簡單描述一下上面的互動:

首先,繪製一條直線,淡出畫一條直線還是比較簡單的,根據記錄滑鼠按下的位置和滑鼠釋放的位置,就很容易得到 Konva.Line 的 points 應該設定的值了。

然後,沿用繪製 橢圓形、矩形 的思路,它只有特定的 2 個“調整點”,分別代表 起點 和 終點。

// src/Render/graphs/Line.ts

// 略

/**
 * 直線、折線
 */
export class Line extends BaseGraph {
  // 略

  constructor(render: Types.Render, dropPoint: Konva.Vector2d) {
    super(render, dropPoint, {
      type: Types.GraphType.Line,
      // 定義了 2 個 調整點
      anchors: [{ adjustType: 'start' }, { adjustType: 'end' }].map((o) => ({
        adjustType: o.adjustType // 調整點 型別定義
      })),
      linkAnchors: [
        { x: 0, y: 0, alias: 'start' },
        { x: 0, y: 0, alias: 'end' }
      ] as Types.AssetInfoPoint[]
    })

    // 新建 直線、折線
    this.line = new Konva.Line({
      name: 'graph',
      x: 0,
      y: 0,
      stroke: 'black',
      strokeWidth: 1,
      hitStrokeWidth: render.toStageValue(5)
    })

    // 給予 1 畫素,防止匯出圖片 toDataURL 失敗
    this.group.size({
      width: 1,
      height: 1
    })

    // 加入
    this.group.add(this.line)
    // 滑鼠按下位置 作為起點
    this.group.position(this.dropPoint)
  }

  // 實現:拖動進行時
  override drawMove(point: Konva.Vector2d): void {
    // 滑鼠拖動偏移量
    const offsetX = point.x - this.dropPoint.x,
      offsetY = point.y - this.dropPoint.y

    // 起點、終點
    const linkPoints = [
      [this.line.x(), this.line.y()],
      [this.line.x() + offsetX, this.line.y() + offsetY]
    ]

    // 直線、折線 路徑
    this.line.points(_.flatten(linkPoints))

    // 更新 圖形 的 調整點 的 錨點位置
    Line.updateAnchorShadows(this.group, this.anchorShadows, this.line)

    // 更新 圖形 的 連線點 的 錨點位置
    Line.updateLinkAnchorShadows(this.group, this.linkAnchorShadows, this.line)

    // 重繪
    this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
  }

  // 實現:拖動結束
  override drawEnd(): void {
    if (this.line.width() <= 1 && this.line.height() <= 1) {
      // 加入只點選,無拖動

      // 預設大小
      const width = Line.size,
        height = width

      // 起點、終點
      const linkPoints = [
        [this.line.x(), this.line.y()],
        [this.line.x() + width, this.line.y() + height]
      ]

      // 直線、折線 位置大小
      this.line.points(_.flatten(linkPoints))
    }

    // 更新 調整點(拐點)
    Line.updateAnchor(this.render, this.group)

    // 更新 圖形 的 調整點 的 錨點位置
    Line.updateAnchorShadows(this.group, this.anchorShadows, this.line)

    // 更新 圖形 的 連線點 的 錨點位置
    Line.updateLinkAnchorShadows(this.group, this.linkAnchorShadows, this.line)

    // 對齊線清除
    this.render.attractTool.alignLinesClear()

    // 更新歷史
    this.render.updateHistory()

    // 重繪
    this.render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
  }

  // 略
}

調整點,可以改變 直線、折線 的 起點、終點。

// 略

/**
 * 直線、折線
 */
export class Line extends BaseGraph {
  // 實現:更新 圖形 的 調整點 的 錨點位置
  static override updateAnchorShadows(
    graph: Konva.Group,
    anchorShadows: Konva.Circle[],
    shape?: Konva.Line
  ): void {
    if (shape) {
      const points = shape.points()
      //
      for (const shadow of anchorShadows) {
        switch (shadow.attrs.adjustType) {
          case 'start':
            shadow.position({
              x: points[0],
              y: points[1]
            })
            break
          case 'end':
            shadow.position({
              x: points[points.length - 2],
              y: points[points.length - 1]
            })
            break
        }
      }
    }
  }
  
  // 略

  // 實現:生成 調整點
  static override createAnchorShapes(
    render: Types.Render,
    graph: Konva.Group,
    anchorAndShadows: {
      anchor: Types.GraphAnchor
      anchorShadow: Konva.Circle
      shape?: Konva.Shape
    }[],
    adjustAnchor?: Types.GraphAnchor
  ): {
    anchorAndShadows: {
      anchor: Types.GraphAnchor
      anchorShadow: Konva.Circle
      shape?: Konva.Shape | undefined
    }[]
  } {
    // stage 狀態
    const stageState = render.getStageState()

    const graphShape = graph.findOne('.graph') as Konva.Line

    if (graphShape) {
      const points = graphShape.points()

      for (const anchorAndShadow of anchorAndShadows) {
        let rotate = 0
        const { anchor, anchorShadow } = anchorAndShadow

        const x = render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
          y = render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)

        if (anchor.adjustType === 'manual') {
          // 略
        } else {
          if (anchor.adjustType === 'start') {
            rotate = Line.calculateAngle(points[2] - points[0], points[3] - points[1])
          } else if (anchor.adjustType === 'end') {
            rotate = Line.calculateAngle(
              points[points.length - 2] - points[points.length - 4],
              points[points.length - 1] - points[points.length - 3]
            )
          }

          const cos = Math.cos((rotate * Math.PI) / 180)
          const sin = Math.sin((rotate * Math.PI) / 180)

          const offset = render.toStageValue(render.pointSize + 5)

          const offsetX = offset * sin
          const offsetY = offset * cos

          const anchorShape = new Konva.Circle({
            name: 'anchor',
            anchor: anchor,
            //
            fill:
              adjustAnchor?.adjustType === anchor.adjustType && adjustAnchor?.groupId === graph.id()
                ? 'rgba(0,0,255,0.8)'
                : 'rgba(0,0,255,0.2)',
            radius: render.toStageValue(3),
            strokeWidth: 0,
            // 位置
            x: x,
            y: y,
            offsetX:
              anchor.adjustType === 'start' ? offsetX : anchor.adjustType === 'end' ? -offsetX : 0,
            offsetY:
              anchor.adjustType === 'start' ? offsetY : anchor.adjustType === 'end' ? -offsetY : 0,
            // 旋轉角度
            rotation: graph.getAbsoluteRotation()
          })

          anchorShape.on('mouseenter', () => {
            anchorShape.fill('rgba(0,0,255,0.8)')
            document.body.style.cursor = 'move'
          })
          anchorShape.on('mouseleave', () => {
            anchorShape.fill(
              anchorShape.attrs.adjusting ? 'rgba(0,0,255,0.8)' : 'rgba(0,0,255,0.2)'
            )
            document.body.style.cursor = anchorShape.attrs.adjusting ? 'move' : 'default'
          })

          anchorAndShadow.shape = anchorShape
        }
      }
    }

    return { anchorAndShadows }
  }

  // 略

  // 實現:調整 圖形
  static override adjust(
    render: Types.Render,
    graph: Konva.Group,
    graphSnap: Konva.Group,
    adjustShape: Konva.Shape,
    anchorAndShadows: {
      anchor: Types.GraphAnchor
      anchorShadow: Konva.Circle
      shape?: Konva.Shape | undefined
    }[],
    startPoint: Konva.Vector2d,
    endPoint: Konva.Vector2d
  ) {
    // 目標 直線、折線
    const line = graph.findOne('.graph') as Konva.Line
    // 映象
    const lineSnap = graphSnap.findOne('.graph') as Konva.Line

    // 調整點 錨點
    const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]
    // 映象
    const anchorsSnap = (graphSnap.find('.anchor') ?? []) as Konva.Circle[]

    // 連線點 錨點
    const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]

    if (line && lineSnap) {
      // stage 狀態
      const stageState = render.getStageState()

      {
        const [graphRotation, adjustType, ex, ey] = [
          Math.round(graph.rotation()),
          adjustShape.attrs.anchor?.adjustType,
          endPoint.x,
          endPoint.y
        ]

        const { x: cx, y: cy, width: cw, height: ch } = graphSnap.getClientRect()

        const { x, y } = graph.position()

        const [centerX, centerY] = [cx + cw / 2, cy + ch / 2]

        const { x: sx, y: sy } = Line.rotatePoint(ex, ey, centerX, centerY, -graphRotation)
        const { x: rx, y: ry } = Line.rotatePoint(x, y, centerX, centerY, -graphRotation)

        const points = line.points()
        const manualPoints = (line.attrs.manualPoints ?? []) as Types.LineManualPoint[]

        if (adjustType === 'manual') {
          // 略
        } else {
          const anchor = anchors.find((o) => o.attrs.adjustType === adjustType)
          const anchorShadow = anchorsSnap.find((o) => o.attrs.adjustType === adjustType)

          if (anchor && anchorShadow) {
            {
              const linkPoints = [
                [points[0], points[1]],
                ...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
                [points[points.length - 2], points[points.length - 1]]
              ]

              switch (adjustType) {
                case 'start':
                  {
                    linkPoints[0] = [sx - rx, sy - ry]
                    line.points(_.flatten(linkPoints))
                  }
                  break
                case 'end':
                  {
                    linkPoints[linkPoints.length - 1] = [sx - rx, sy - ry]
                    line.points(_.flatten(linkPoints))
                  }
                  break
              }
            }
          }
        }
      }

      // 更新 調整點(拐點)
      Line.updateAnchor(render, graph)

      // 更新 調整點 的 錨點 位置
      Line.updateAnchorShadows(graph, anchors, line)

      // 更新 圖形 的 連線點 的 錨點位置
      Line.updateLinkAnchorShadows(graph, linkAnchors, line)

      // 更新 調整點 位置
      for (const anchor of anchors) {
        for (const { shape } of anchorAndShadows) {
          if (shape) {
            if (shape.attrs.anchor?.adjustType === anchor.attrs.adjustType) {
              const anchorShadow = graph
                .find(`.anchor`)
                .find((o) => o.attrs.adjustType === anchor.attrs.adjustType)

              if (anchorShadow) {
                shape.position({
                  x: render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
                  y: render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
                })
                shape.rotation(graph.getAbsoluteRotation())
              }
            }
          }
        }
      }

      // 重繪
      render.redraw([Draws.GraphDraw.name, Draws.LinkDraw.name, Draws.PreviewDraw.name])
    }
  }

  // 略
}

折線

相比繪製 橢圓形、矩形 比較不一樣的地方在於,橢圓形、矩形 的“調整點”是固定的,而繪製 折線 不一樣,沒調整一個新的拐點,就會新增 2 個新調整點,整體互動與 手動連線線 類似。

image

// src/Render/draws/GraphDraw.ts

// 略

export interface GraphDrawState {
  // 略

  /**
   * 調整中 調整點
   */
  adjustAnchor?: Types.GraphAnchor

  /**
   * 滑鼠按下 調整點 位置
   */
  startPointCurrent: Konva.Vector2d

  /**
   * 圖形 group
   */
  graphCurrent?: Konva.Group

  /**
   * 圖形 group 映象,用於計算位置、大小的偏移
   */
  graphCurrentSnap?: Konva.Group
}

// 略

export class GraphDraw extends Types.BaseDraw implements Types.Draw {
  // 略

  state: GraphDrawState = {
    adjusting: false,
    adjustGroupId: '',
    startPointCurrent: { x: 0, y: 0 }
  }

  // 略

  override draw() {
    this.clear()
    // 所有圖形
    const graphs = this.render.layer
      .find('.asset')
      .filter((o) => o.attrs.assetType === Types.AssetType.Graph) as Konva.Group[]

    for (const graph of graphs) {
      // 非選中狀態才顯示 調整點
      if (!graph.attrs.selected) {
        // 略

        for (const anchorAndShadow of anchorAndShadows) {
          const { shape } = anchorAndShadow

          if (shape) {
            // 滑鼠按下
            shape.on('mousedown', () => {
              const pos = this.getStagePoint()
              if (pos) {
                this.state.adjusting = true
                this.state.adjustAnchor = shape.attrs.anchor
                this.state.adjustGroupId = graph.id()

                this.state.startPointCurrent = pos

                this.state.graphCurrent = graph
                this.state.graphCurrentSnap = graph.clone()

                shape.setAttr('adjusting', true)

                if (this.state.adjustAnchor) {
                  switch (shape.attrs.anchor?.type) {
                    case Types.GraphType.Line:
                      // 使用 直線、折線 靜態處理方法
                      Graphs.Line.adjustStart(this.render, graph, this.state.adjustAnchor, pos)
                      break
                  }
                }
              }
            })

            // 略

            // 調整結束
            this.render.stage.on('mouseup', () => {
              // 略
              
              this.state.adjusting = false
              this.state.adjustAnchor = undefined
              this.state.adjustGroupId = ''

              // 恢復顯示所有 調整點
              for (const { shape } of anchorAndShadows) {
                if (shape) {
                  shape.opacity(1)
                  shape.setAttr('adjusting', false)
                  if (shape.attrs.anchor?.type === Types.GraphType.Line) {
                    if (shape.attrs.anchor.adjusted) {
                      shape.fill('rgba(0,0,0,0.4)')
                    } else {
                      shape.fill('rgba(0,0,255,0.2)')
                    }
                  } else {
                    shape.stroke('rgba(0,0,255,0.2)')
                  }
                }

                // 略
              }

              // 略
            })

            // 略
          }
        }
      }
    }
  }
}

上面除了需要更多的狀態記錄 調整 資訊,還需要定義 Line 特有的 adjustStart 方法:

// src/Render/graphs/Line.ts

// 略

/**
 * 直線、折線
 */
export class Line extends BaseGraph {
  // 略

  /**
   * 調整之前
   */
  static adjustStart(
    render: Types.Render,
    graph: Konva.Group,
    adjustAnchor: Types.GraphAnchor & { manualIndex?: number; adjusted?: boolean },
    endPoint: Konva.Vector2d
  ) {
    const { x: gx, y: gy } = graph.position()

    const shape = graph.findOne('.graph') as Konva.Line

    if (shape && typeof adjustAnchor.manualIndex === 'number') {
      const manualPoints = (shape.attrs.manualPoints ?? []) as Types.LineManualPoint[]
      if (adjustAnchor.adjusted) {
        //
      } else {
        manualPoints.push({
          x: endPoint.x - gx,
          y: endPoint.y - gy,
          index: adjustAnchor.manualIndex
        })
        shape.setAttr('manualPoints', manualPoints)
      }

      // 更新 調整點(拐點)
      Line.updateAnchor(render, graph)
    }
  }
}

// 略

動態的調整點,會記錄在 line 的 attrs 中 manualPoints,每次首次調整一處 拐點,就會新增一個 新 拐點,主要應用在:

// 略

/**
 * 直線、折線
 */
export class Line extends BaseGraph {
  // 略

  // 實現:調整 圖形
  static override adjust(
    render: Types.Render,
    graph: Konva.Group,
    graphSnap: Konva.Group,
    adjustShape: Konva.Shape,
    anchorAndShadows: {
      anchor: Types.GraphAnchor
      anchorShadow: Konva.Circle
      shape?: Konva.Shape | undefined
    }[],
    startPoint: Konva.Vector2d,
    endPoint: Konva.Vector2d
  ) {
    // 目標 直線、折線
    const line = graph.findOne('.graph') as Konva.Line
    // 映象
    const lineSnap = graphSnap.findOne('.graph') as Konva.Line

    // 調整點 錨點
    const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]
    // 映象
    const anchorsSnap = (graphSnap.find('.anchor') ?? []) as Konva.Circle[]

    // 連線點 錨點
    const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]

    if (line && lineSnap) {
      // stage 狀態
      const stageState = render.getStageState()

      {
        const [graphRotation, adjustType, ex, ey] = [
          Math.round(graph.rotation()),
          adjustShape.attrs.anchor?.adjustType,
          endPoint.x,
          endPoint.y
        ]

        const { x: cx, y: cy, width: cw, height: ch } = graphSnap.getClientRect()

        const { x, y } = graph.position()

        const [centerX, centerY] = [cx + cw / 2, cy + ch / 2]

        const { x: sx, y: sy } = Line.rotatePoint(ex, ey, centerX, centerY, -graphRotation)
        const { x: rx, y: ry } = Line.rotatePoint(x, y, centerX, centerY, -graphRotation)

        const points = line.points()
        const manualPoints = (line.attrs.manualPoints ?? []) as Types.LineManualPoint[]

        if (adjustType === 'manual') {
          if (adjustShape.attrs.anchor?.manualIndex !== void 0) {
            const index = adjustShape.attrs.anchor?.adjusted
              ? adjustShape.attrs.anchor?.manualIndex
              : adjustShape.attrs.anchor?.manualIndex + 1

            const manualPointIndex = manualPoints.findIndex((o) => o.index === index)

            if (manualPointIndex > -1) {
              manualPoints[manualPointIndex].x = sx - rx
              manualPoints[manualPointIndex].y = sy - ry
            }

            const linkPoints = [
              [points[0], points[1]],
              ...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
              [points[points.length - 2], points[points.length - 1]]
            ]

            line.setAttr('manualPoints', manualPoints)

            line.points(_.flatten(linkPoints))

            //
            const adjustAnchorShadow = anchors.find(
              (o) => o.attrs.adjustType === 'manual' && o.attrs.manualIndex === index
            )
            if (adjustAnchorShadow) {
              adjustAnchorShadow.position({
                x: sx - rx,
                y: sy - ry
              })
            }
          }
        } else {
          // 略
        }
      }

      // 略
    }
  }

  // 略

  /**
   * 更新 調整點(拐點)
   * @param render
   * @param graph
   */
  static updateAnchor(render: Types.Render, graph: Konva.Group) {
    const anchors = graph.attrs.anchors ?? []
    const anchorShadows = graph.find('.anchor') ?? []

    const shape = graph.findOne('.graph') as Konva.Line

    if (shape) {
      // 已拐
      let manualPoints = (shape.attrs.manualPoints ?? []) as Types.LineManualPoint[]
      const points = shape.points()

      // 調整點 + 拐點
      const linkPoints = [
        [points[0], points[1]],
        ...manualPoints.sort((a, b) => a.index - b.index).map((o) => [o.x, o.y]),
        [points[points.length - 2], points[points.length - 1]]
      ]

      // 清空 調整點(拐點),保留 start end
      anchors.splice(2)
      const shadows = anchorShadows.splice(2)
      for (const shadow of shadows) {
        shadow.remove()
        shadow.destroy()
      }

      manualPoints = []

      for (let i = linkPoints.length - 1; i > 0; i--) {
        linkPoints.splice(i, 0, [])
      }

      // 調整點(拐點)
      for (let i = 1; i < linkPoints.length - 1; i++) {
        const anchor = {
          type: graph.attrs.graphType,
          adjustType: 'manual',
          //
          name: 'anchor',
          groupId: graph.id(),
          //
          manualIndex: i,
          adjusted: false
        }

        if (linkPoints[i].length === 0) {
          anchor.adjusted = false

          // 新增
          const prev = linkPoints[i - 1]
          const next = linkPoints[i + 1]

          const circle = new Konva.Circle({
            adjustType: anchor.adjustType,
            anchorType: anchor.type,
            name: anchor.name,
            manualIndex: anchor.manualIndex,
            radius: 0,
            // radius: render.toStageValue(2),
            // fill: 'red',
            //
            x: (prev[0] + next[0]) / 2,
            y: (prev[1] + next[1]) / 2,
            anchor
          })

          graph.add(circle)
        } else {
          anchor.adjusted = true

          // 已拐
          const circle = new Konva.Circle({
            adjustType: anchor.adjustType,
            anchorType: anchor.type,
            name: anchor.name,
            manualIndex: anchor.manualIndex,
            adjusted: true,
            radius: 0,
            // radius: render.toStageValue(2),
            // fill: 'red',
            //
            x: linkPoints[i][0],
            y: linkPoints[i][1],
            anchor
          })

          graph.add(circle)

          manualPoints.push({
            x: linkPoints[i][0],
            y: linkPoints[i][1],
            index: anchor.manualIndex
          })
        }

        anchors.push(anchor)
      }

      shape.setAttr('manualPoints', manualPoints)

      graph.setAttr('anchors', anchors)
    }
  }

  // 略
}

上面簡單的說,就是處理 manualPoints 的演算法,負責控制新增拐點,然後把“點”們插入到 起點、終點 之間,最後處理成 Konva.Line 的 points 的值。

順帶一說。區分 起點、終點 和 拐點 是透過 attrs 中的 adjustType 欄位;區分 拐點 是否已經操作過 是透過 attrs 中的 adjusted 欄位;拐點是存在明確的順序的,會記錄在 attrs 的 manualIndex 欄位中。

個人覺得,目前,繪製圖形的 程式碼結構 和 變數命名 容易產生歧義,後面儘量抽出時間重構一下,大家支援支援 👇!

Thanks watching~

More Stars please!勾勾手指~

原始碼

gitee原始碼

示例地址

相關文章