前端使用 Konva 实现可视化设计器(21)- 绘制图形(椭圆)

本章开始补充一些基础的图形绘制,比如绘制:直线、曲线、圆/椭形、矩形。这一章主要分享一下本示例是如何开始绘制一个图形的,并以绘制圆/椭形为实现目标。

请大家动动小手,给我一个免费的 Star 吧~

大家如果发现了 Bug,欢迎来提 Issue 哟~

github源码

gitee源码

示例地址

接下来主要说说:

  • UI
  • Graph(图形)
  • canvas2svg 打补丁
  • 拐点旋转修复

UI - 图形绘制类型切换

先找几个图标,增加按钮,分别代表绘制图形:直线、曲线、圆/椭形、矩形:

选中图形类型后,即可通过拖动绘制图形(绘制完成后,清空选择):

定义图形类型:

javascript 复制代码
// src/Render/types.ts	

/**
 * 图形类型
 */
export enum GraphType {
  Line = 'Line', // 直线
  Curve = 'Curve', // 曲线
  Rect = 'Rect', // 矩形
  Circle = 'Circle' // 圆/椭圆形
}

在 Render 中记录当前图形类型,并提供修改方法与事件:

javascript 复制代码
// src/Render/index.ts	

// 略

  // 画图类型
  graphType: Types.GraphType | undefined = undefined

// 略

  // 改变画图类型
  changeGraphType(type?: Types.GraphType) {
    this.graphType = type
    this.emit('graph-type-change', this.graphType)
  }

工具栏按钮通讯:

javascript 复制代码
// src/components/main-header/index.vue	

// 略

const emit = defineEmits([/* 略 */, 'update:graphType'])

const props = withDefaults(defineProps<{
    // 略
    graphType?: Types.GraphType
}>(), {
    // 略
});

// 略

watch(() => props.render, () => {
    if (props.render) {
        // 略

        props.render?.on('graph-type-change', (value) => {
            emit('update:graphType', value)
        })
    }

}, {
    immediate: true
})

// 略

function onGraph(type: Types.GraphType) {
    emit('update:graphType', props.graphType === type ? undefined : type)

以上就是绘制图形的工具栏入口。

Graph - 图形定义及其相关实现

相关代码文件:

1、src/Render/graphs/BaseGraph.ts - 抽象类:定义通用属性、逻辑、外部接口定义。

2、src/Render/graphs/Circle.ts 继承 BaseGraph - 构造 圆/椭形 ;处理创建部分交互信息;关键逻辑的实现。

3、src/Render/handlers/GraphHandlers.ts - 收集图形创建所需交互信息,接着交给 Circle 静态处理方法处理。

4、src/Render/draws/GraphDraw.ts - 绘制图形、调整点 - 绘制 调整点 的锚点;收集并处理交互信息,接着并交给 Circle 静态处理方法处理。

BaseGraph 抽象类

javascript 复制代码
// src/Render/graphs/BaseGraph.ts

// 略

/**
 * 图形类
 * 实例主要用于新建图形时,含新建同时的大小拖动。
 * 静态方法主要用于新建之后,通过 调整点 调整的逻辑定义
 */
export abstract class BaseGraph {
  /**
   * 更新 图形 的 调整点 的 锚点位置
   * @param width 图形 的 宽度
   * @param height 图形 的 高度
   * @param rotate 图形 的 旋转角度
   * @param anchorShadows 图形 的 调整点 的 锚点
   */
  static updateAnchorShadows(
    width: number,
    height: number,
    rotate: number,
    anchorShadows: Konva.Circle[]
  ) {
    console.log('请实现 updateAnchorShadows', width, height, anchorShadows)
  }

  /**
   * 更新 图形 的 连接点 的 锚点位置
   * @param width 图形 的 宽度
   * @param height 图形 的 高度
   * @param rotate 图形 的 旋转角度
   * @param anchors 图形 的 调整点 的 锚点
   */
  static updateLinkAnchorShadows(
    width: number,
    height: number,
    rotate: number,
    linkAnchorShadows: Konva.Circle[]
  ) {
    console.log('请实现 updateLinkAnchorShadows', width, height, linkAnchorShadows)
  }

  /**
   * 生成 调整点
   * @param render 渲染实例
   * @param graph 图形
   * @param anchor 调整点 定义
   * @param anchorShadow 调整点 锚点
   * @param adjustingId 正在操作的 调整点 id
   * @returns
   */
  static createAnchorShape(
    render: Render,
    graph: Konva.Group,
    anchor: Types.GraphAnchor,
    anchorShadow: Konva.Circle,
    adjustType: string,
    adjustGroupId: string
  ): Konva.Shape {
    console.log('请实现 createAnchorShape', render, graph, anchor, anchorShadow, adjustingId, adjustGroupId)
    return new Konva.Shape()
  }

  /**
   * 调整 图形
   * @param render 渲染实例
   * @param graph 图形
   * @param graphSnap 图形 的 备份
   * @param rect 当前 调整点
   * @param rects 所有 调整点
   * @param startPoint 鼠标按下位置
   * @param endPoint 鼠标拖动位置
   */
  static adjust(
    render: Render,
    graph: Konva.Group,
    graphSnap: Konva.Group,
    rect: Types.GraphAnchorShape,
    rects: Types.GraphAnchorShape[],
    startPoint: Konva.Vector2d,
    endPoint: Konva.Vector2d
  ) {
    console.log('请实现 updateAnchorShadows', render, graph, rect, startPoint, endPoint)
  }
  //
  protected render: Render
  group: Konva.Group
  id: string // 就是 group 的id
  /**
   * 鼠标按下位置
   */
  protected dropPoint: Konva.Vector2d = { x: 0, y: 0 }
  /**
   * 调整点 定义
   */
  protected anchors: Types.GraphAnchor[] = []
  /**
   * 调整点 的 锚点
   */
  protected anchorShadows: Konva.Circle[] = []

  /**
   * 调整点 定义
   */
  protected linkAnchors: Types.LinkDrawPoint[] = []
  /**
   * 连接点 的 锚点
   */
  protected linkAnchorShadows: Konva.Circle[] = []

  constructor(
    render: Render,
    dropPoint: Konva.Vector2d,
    config: {
      anchors: Types.GraphAnchor[]
      linkAnchors: Types.AssetInfoPoint[]
    }
  ) {
    this.render = render
    this.dropPoint = dropPoint

    this.id = nanoid()

    this.group = new Konva.Group({
      id: this.id,
      name: 'asset',
      assetType: Types.AssetType.Graph
    })

    // 调整点 定义
    this.anchors = config.anchors.map((o) => ({
      ...o,
      // 补充信息
      name: 'anchor',
      groupId: this.group.id()
    }))

    // 记录在 group 中
    this.group.setAttr('anchors', this.anchors)

    // 新建 调整点 的 锚点
    for (const anchor of this.anchors) {
      const circle = new Konva.Circle({
        adjustType: anchor.adjustType,
        name: anchor.name,
        radius: 0
        // radius: this.render.toStageValue(1),
        // fill: 'red'
      })
      this.anchorShadows.push(circle)
      this.group.add(circle)
    }

    // 连接点 定义
    this.linkAnchors = config.linkAnchors.map(
      (o) =>
        ({
          ...o,
          id: nanoid(),
          groupId: this.group.id(),
          visible: false,
          pairs: [],
          direction: o.direction,
          alias: o.alias
        }) as Types.LinkDrawPoint
    )

    // 连接点信息
    this.group.setAttrs({
      points: this.linkAnchors
    })
    // 新建 连接点 的 锚点
    for (const point of this.linkAnchors) {
      const circle = 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,
        alias: point.alias
      })
      this.linkAnchorShadows.push(circle)
      this.group.add(circle)
    }

    this.group.on('mouseenter', () => {
      // 显示 连接点
      this.render.linkTool.pointsVisible(true, this.group)
    })
    this.group.on('mouseleave', () => {
      // 隐藏 连接点
      this.render.linkTool.pointsVisible(false, this.group)
      // 隐藏 hover 框
      this.group.findOne('#hoverRect')?.visible(false)
    })

    this.render.layer.add(this.group)

    this.render.redraw()
  }

  /**
   * 调整进行时
   * @param point 鼠标位置 相对位置
   */
  abstract drawMove(point: Konva.Vector2d): void

  /**
   * 调整结束
   */
  abstract drawEnd(): void
}

这里的:

  • 静态方法,相当定义了绘制图形必要的工具方法,具体实现交给具体的图形类定义;
  • 接着是绘制图形必要的属性及其初始化;
  • 最后,抽象方法约束了图形实例必要的方法。

绘制 圆/椭形

图形是可以调整的,这里 圆/椭形 拥有 8 个 调整点:

还要考虑图形被旋转后,依然能合理调整:

调整本身也是支持磁贴的:

图形也支持 连接点:

图形类 - Circle

javascript 复制代码
// src/Render/graphs/Circle.ts

// 略

/**
 * 图形 圆/椭圆
 */
export class Circle extends BaseGraph {
  // 实现:更新 图形 的 调整点 的 锚点位置
  static override updateAnchorShadows(
    width: number,
    height: number,
    rotate: number,
    anchorShadows: Konva.Circle[]
  ): void {
    for (const shadow of anchorShadows) {
      switch (shadow.attrs.id) {
        case 'top':
          shadow.position({
            x: width / 2,
            y: 0
          })
          break
        case 'bottom':
          shadow.position({
            x: width / 2,
            y: height
          })
          break
        case 'left':
          shadow.position({
            x: 0,
            y: height / 2
          })
          break
        case 'right':
          shadow.position({
            x: width,
            y: height / 2
          })
          break
        case 'top-left':
          shadow.position({
            x: 0,
            y: 0
          })
          break
        case 'top-right':
          shadow.position({
            x: width,
            y: 0
          })
          break
        case 'bottom-left':
          shadow.position({
            x: 0,
            y: height
          })
          break
        case 'bottom-right':
          shadow.position({
            x: width,
            y: height
          })
          break
      }
    }
  }
  // 实现:更新 图形 的 连接点 的 锚点位置
  static override updateLinkAnchorShadows(
    width: number,
    height: number,
    rotate: number,
    linkAnchorShadows: Konva.Circle[]
  ): void {
    for (const shadow of linkAnchorShadows) {
      switch (shadow.attrs.alias) {
        case 'top':
          shadow.position({
            x: width / 2,
            y: 0
          })
          break
        case 'bottom':
          shadow.position({
            x: width / 2,
            y: height
          })
          break
        case 'left':
          shadow.position({
            x: 0,
            y: height / 2
          })
          break
        case 'right':
          shadow.position({
            x: width,
            y: height / 2
          })
          break
        case 'center':
          shadow.position({
            x: width / 2,
            y: height / 2
          })
          break
      }
    }
  }
  // 实现:生成 调整点
  static createAnchorShape(
    render: Types.Render,
    graph: Konva.Group,
    anchor: Types.GraphAnchor,
    anchorShadow: Konva.Circle,
    adjustType: string,
    adjustGroupId: string
  ): Konva.Shape {
    // stage 状态
    const stageState = render.getStageState()

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

    const offset = render.pointSize + 5

    const shape = new Konva.Line({
      name: 'anchor',
      anchor: anchor,
      //
      // stroke: colorMap[anchor.id] ?? 'rgba(0,0,255,0.2)',
      stroke:
        adjustType === anchor.adjustType && graph.id() === adjustGroupId
          ? 'rgba(0,0,255,0.8)'
          : 'rgba(0,0,255,0.2)',
      strokeWidth: render.toStageValue(2),
      // 位置
      x,
      y,
      // 路径
      points:
        {
          'top-left': _.flatten([
            [-offset, offset / 2],
            [-offset, -offset],
            [offset / 2, -offset]
          ]),
          top: _.flatten([
            [-offset, -offset],
            [offset, -offset]
          ]),
          'top-right': _.flatten([
            [-offset / 2, -offset],
            [offset, -offset],
            [offset, offset / 2]
          ]),
          right: _.flatten([
            [offset, -offset],
            [offset, offset]
          ]),
          'bottom-right': _.flatten([
            [-offset / 2, offset],
            [offset, offset],
            [offset, -offset / 2]
          ]),
          bottom: _.flatten([
            [-offset, offset],
            [offset, offset]
          ]),
          'bottom-left': _.flatten([
            [-offset, -offset / 2],
            [-offset, offset],
            [offset / 2, offset]
          ]),
          left: _.flatten([
            [-offset, -offset],
            [-offset, offset]
          ])
        }[anchor.id] ?? [],
      // 旋转角度
      rotation: graph.getAbsoluteRotation()
    })

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

    return shape
  }
  // 实现:调整 图形
  static override adjust(
    render: Types.Render,
    graph: Konva.Group,
    graphSnap: Konva.Group,
    shapeRecord: Types.GraphAnchorShape,
    shapeRecords: Types.GraphAnchorShape[],
    startPoint: Konva.Vector2d,
    endPoint: Konva.Vector2d
  ) {
    // 目标 圆/椭圆
    const circle = graph.findOne('.graph') as Konva.Ellipse
    // 镜像
    const circleSnap = graphSnap.findOne('.graph') as Konva.Ellipse

    // 调整点 锚点
    const anchors = (graph.find('.anchor') ?? []) as Konva.Circle[]

    // 连接点 锚点
    const linkAnchors = (graph.find('.link-anchor') ?? []) as Konva.Circle[]

    const { shape: adjustShape } = shapeRecord

    if (circle && circleSnap) {
      let [graphWidth, graphHeight] = [graph.width(), graph.height()]
      const [graphRotation, anchorId, ex, ey] = [
        Math.round(graph.rotation()),
        adjustShape.attrs.anchor?.id,
        endPoint.x,
        endPoint.y
      ]

      let anchorShadow: Konva.Circle | undefined, anchorShadowAcross: Konva.Circle | undefined

      switch (anchorId) {
        case 'top':
          {
            anchorShadow = graphSnap.findOne(`#top`)
            anchorShadowAcross = graphSnap.findOne(`#bottom`)
          }
          break
        case 'bottom':
          {
            anchorShadow = graphSnap.findOne(`#bottom`)
            anchorShadowAcross = graphSnap.findOne(`#top`)
          }
          break
        case 'left':
          {
            anchorShadow = graphSnap.findOne(`#left`)
            anchorShadowAcross = graphSnap.findOne(`#right`)
          }
          break
        case 'right':
          {
            anchorShadow = graphSnap.findOne(`#right`)
            anchorShadowAcross = graphSnap.findOne(`#left`)
          }
          break
        case 'top-left':
          {
            anchorShadow = graphSnap.findOne(`#top-left`)
            anchorShadowAcross = graphSnap.findOne(`#bottom-right`)
          }
          break
        case 'top-right':
          {
            anchorShadow = graphSnap.findOne(`#top-right`)
            anchorShadowAcross = graphSnap.findOne(`#bottom-left`)
          }
          break
        case 'bottom-left':
          {
            anchorShadow = graphSnap.findOne(`#bottom-left`)
            anchorShadowAcross = graphSnap.findOne(`#top-right`)
          }
          break
        case 'bottom-right':
          {
            anchorShadow = graphSnap.findOne(`#bottom-right`)
            anchorShadowAcross = graphSnap.findOne(`#top-left`)
          }
          break
      }

      if (anchorShadow && anchorShadowAcross) {
        const { x: sx, y: sy } = anchorShadow.getAbsolutePosition()
        const { x: ax, y: ay } = anchorShadowAcross.getAbsolutePosition()

        // anchorShadow:它是当前操作的 调整点 锚点
        // anchorShadowAcross:它是当前操作的 调整点 反方向对面的 锚点

        // 调整大小
        {
           // 略
           // 计算比较复杂,不一定是最优方案,详情请看工程代码。
           // 基本逻辑:
           // 1、通过鼠标移动,计算当前鼠标位置、当前操作的 调整点 锚点 位置(原位置) 分别与 anchorShadowAcross(原位置)的距离;
           // 2、 保持 anchorShadowAcross 位置固定,通过上面两距离的变化比例,计算最新的宽高大小;
           // 3、期间要约束不同角度不同方向的宽高处理,有的只改变宽、有的只改变高、有的同时改变宽和高。
        }

        // 调整位置
        {
          // 略
          // 计算比较复杂,不一定是最优方案,详情请看工程代码。
          // 基本逻辑:
          // 利用三角函数,通过最新的宽高,调整图形的坐标。
        }
      }

      // 更新 圆/椭圆 大小
      circle.x(graphWidth / 2)
      circle.radiusX(graphWidth / 2)
      circle.y(graphHeight / 2)
      circle.radiusY(graphHeight / 2)

      // 更新 调整点 的 锚点 位置
      Circle.updateAnchorShadows(graphWidth, graphHeight, graphRotation, anchors)

      // 更新 图形 的 连接点 的 锚点位置
      Circle.updateLinkAnchorShadows(graphWidth, graphHeight, graphRotation, linkAnchors)

      // stage 状态
      const stageState = render.getStageState()

      // 更新 调整点 位置
      for (const anchor of anchors) {
        for (const { shape } of shapeRecords) {
          if (shape.attrs.anchor?.adjustType === anchor.attrs.adjustType) {
            const anchorShadow = graph.findOne(`#${anchor.attrs.id}`)
            if (anchorShadow) {
              shape.position({
                x: render.toStageValue(anchorShadow.getAbsolutePosition().x - stageState.x),
                y: render.toStageValue(anchorShadow.getAbsolutePosition().y - stageState.y)
              })
              shape.rotation(graph.getAbsoluteRotation())
            }
          }
        }
      }
    }
  }
  /**
   * 默认图形大小
   */
  static size = 100
  /**
   * 圆/椭圆 对应的 Konva 实例
   */
  private circle: Konva.Ellipse

  constructor(render: Types.Render, dropPoint: Konva.Vector2d) {
    super(render, dropPoint, {
      // 定义了 8 个 调整点
      anchors: [
        { adjustType: 'top' },
        { adjustType: 'bottom' },
        { adjustType: 'left' },
        { adjustType: 'right' },
        { adjustType: 'top-left' },
        { adjustType: 'top-right' },
        { adjustType: 'bottom-left' },
        { adjustType: 'bottom-right' }
      ].map((o) => ({
        adjustType: o.adjustType, // 调整点 类型定义
        type: Types.GraphType.Circle // 记录所属 图形
      })),
      linkAnchors: [
        { x: 0, y: 0, alias: 'top', direction: 'top' },
        { x: 0, y: 0, alias: 'bottom', direction: 'bottom' },
        { x: 0, y: 0, alias: 'left', direction: 'left' },
        { x: 0, y: 0, alias: 'right', direction: 'right' },
        { x: 0, y: 0, alias: 'center' }
      ] as Types.AssetInfoPoint[]
    })

    // 新建 圆/椭圆
    this.circle = new Konva.Ellipse({
      name: 'graph',
      x: 0,
      y: 0,
      radiusX: 0,
      radiusY: 0,
      stroke: 'black',
      strokeWidth: 1
    })

    // 加入
    this.group.add(this.circle)
    // 鼠标按下位置 作为起点
    this.group.position(this.dropPoint)
  }

  // 实现:拖动进行时
  override drawMove(point: Konva.Vector2d): void {
    // 鼠标拖动偏移量
    let offsetX = point.x - this.dropPoint.x,
      offsetY = point.y - this.dropPoint.y

    // 确保不翻转
    if (offsetX < 1) {
      offsetX = 1
    }
    if (offsetY < 1) {
      offsetY = 1
    }

    // 半径
    const radiusX = offsetX / 2,
      radiusY = offsetY / 2

    // 圆/椭圆 位置大小
    this.circle.x(radiusX)
    this.circle.y(radiusY)
    this.circle.radiusX(radiusX)
    this.circle.radiusY(radiusY)

    // group 大小
    this.group.size({
      width: offsetX,
      height: offsetY
    })

    // 更新 图形 的 调整点 的 锚点位置
    Circle.updateAnchorShadows(offsetX, offsetY, 1, this.anchorShadows)

    // 更新 图形 的 连接点 的 锚点位置
    Circle.updateLinkAnchorShadows(offsetX, offsetY, 1, this.linkAnchorShadows)

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

  // 实现:拖动结束
  override drawEnd(): void {
    if (this.circle.radiusX() <= 1 && this.circle.radiusY() <= 1) {
      // 加入只点击,无拖动

      // 默认大小
      const width = Circle.size,
        height = width

      const radiusX = Circle.size / 2,
        radiusY = radiusX

      // 圆/椭圆 位置大小
      this.circle.x(radiusX)
      this.circle.y(radiusY)
      this.circle.radiusX(radiusX - this.circle.strokeWidth())
      this.circle.radiusY(radiusY - this.circle.strokeWidth())

      // group 大小
      this.group.size({
        width,
        height
      })

      // 更新 图形 的 调整点 的 锚点位置
      Circle.updateAnchorShadows(width, height, 1, this.anchorShadows)

      // 更新 图形 的 连接点 的 锚点位置
      Circle.updateLinkAnchorShadows(width, height, 1, this.linkAnchorShadows)

      // 对齐线清除
      this.render.attractTool.alignLinesClear()

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

GraphHandlers

javascript 复制代码
// src/Render/handlers/GraphHandlers.ts	

// 略

export class GraphHandlers implements Types.Handler {
  // 略

  /**
   * 新建图形中
   */
  graphing = false

  /**
   * 当前新建图形类型
   */
  currentGraph: Graphs.BaseGraph | undefined

  /**
   * 获取鼠标位置,并处理为 相对大小
   * @param attract 含磁贴计算
   * @returns
   */
  getStagePoint(attract = false) {
    const pos = this.render.stage.getPointerPosition()
    if (pos) {
      const stageState = this.render.getStageState()
      if (attract) {
        // 磁贴
        const { pos: transformerPos } = this.render.attractTool.attractPoint(pos)
        return {
          x: this.render.toStageValue(transformerPos.x - stageState.x),
          y: this.render.toStageValue(transformerPos.y - stageState.y)
        }
      } else {
        return {
          x: this.render.toStageValue(pos.x - stageState.x),
          y: this.render.toStageValue(pos.y - stageState.y)
        }
      }
    }
    return null
  }

  handlers = {
    stage: {
      mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
        if (this.render.graphType) {
          // 选中图形类型,开始

          if (e.target === this.render.stage) {
            this.graphing = true

            this.render.selectionTool.selectingClear()

            const point = this.getStagePoint()
            if (point) {
              if (this.render.graphType === Types.GraphType.Circle) {
                // 新建 圆/椭圆 实例
                this.currentGraph = new Graphs.Circle(this.render, point)
              }
            }
          }
        }
      },
      mousemove: () => {
        if (this.graphing) {
          if (this.currentGraph) {
            const pos = this.getStagePoint(true)
            if (pos) {
              // 新建并马上调整图形
              this.currentGraph.drawMove(pos)
            }
          }
        }
      },
      mouseup: () => {
        if (this.graphing) {
          if (this.currentGraph) {
            // 调整结束
            this.currentGraph.drawEnd()
          }

          // 调整结束
          this.graphing = false

          // 清空图形类型选择
          this.render.changeGraphType()

          // 对齐线清除
          this.render.attractTool.alignLinesClear()

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

GraphDraw

javascript 复制代码
// src/Render/draws/GraphDraw.ts	

// 略

export interface GraphDrawState {
  /**
   * 调整中
   */
  adjusting: boolean

  /**
   * 调整中 id
   */
  adjustType: string
}

// 略

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

  state: GraphDrawState = {
    adjusting: false,
    adjustType: ''
  }

  /**
   * 鼠标按下 调整点 位置
   */
  startPoint: Konva.Vector2d = { x: 0, y: 0 }

  /**
   * 图形 group 镜像
   */
  graphSnap: Konva.Group | undefined

  constructor(render: Types.Render, layer: Konva.Layer, option: GraphDrawOption) {
    super(render, layer)

    this.option = option

    this.group.name(this.constructor.name)
  }

  /**
   * 获取鼠标位置,并处理为 相对大小
   * @param attract 含磁贴计算
   * @returns
   */
  getStagePoint(attract = false) {
    const pos = this.render.stage.getPointerPosition()
    if (pos) {
      const stageState = this.render.getStageState()
      if (attract) {
        // 磁贴
        const { pos: transformerPos } = this.render.attractTool.attractPoint(pos)
        return {
          x: this.render.toStageValue(transformerPos.x - stageState.x),
          y: this.render.toStageValue(transformerPos.y - stageState.y)
        }
      } else {
        return {
          x: this.render.toStageValue(pos.x - stageState.x),
          y: this.render.toStageValue(pos.y - stageState.y)
        }
      }
    }
    return null
  }

  // 调整 预处理、定位静态方法
  adjusts(
    shapeDetailList: {
      graph: Konva.Group
      shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[]
    }[]
  ) {
    for (const { shapeRecords, graph } of shapeDetailList) {
      for (const { shape } of shapeRecords) {
        shape.setAttr('adjusting', false)
      }
      for (const shapeRecord of shapeRecords) {
        const { shape } = shapeRecord
        // 鼠标按下
        shape.on('mousedown', () => {
          this.state.adjusting = true
          this.state.adjustType = shape.attrs.anchor?.adjustType
          this.state.adjustGroupId = graph.id()

          shape.setAttr('adjusting', true)

          const pos = this.getStagePoint()
          if (pos) {
            this.startPoint = pos

            // 图形 group 镜像,用于计算位置、大小的偏移
            this.graphSnap = graph.clone()
          }
        })

        // 调整中
        this.render.stage.on('mousemove', () => {
          if (this.state.adjusting && this.graphSnap) {
            if (shape.attrs.anchor?.type === Types.GraphType.Circle) {
              // 调整 圆/椭圆 图形
              if (shape.attrs.adjusting) {
                const pos = this.getStagePoint(true)
                if (pos) {
                  // 使用 圆/椭圆 静态处理方法
                  Graphs.Circle.adjust(
                    this.render,
                    graph,
                    this.graphSnap,
                    shapeRecord,
                    shapeRecords,
                    this.startPoint,
                    pos
                  )

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

        // 调整结束
        this.render.stage.on('mouseup', () => {
          this.state.adjusting = false
          this.state.adjustType = ''
          this.state.adjustGroupId = ''

          // 恢复显示所有 调整点
          for (const { shape } of shapeRecords) {
            shape.opacity(1)
            shape.setAttr('adjusting', false)
            shape.stroke('rgba(0,0,255,0.2)')
            document.body.style.cursor = 'default'
          }

          // 销毁 镜像
          this.graphSnap?.destroy()

          // 对齐线清除
          this.render.attractTool.alignLinesClear()
        })

        this.group.add(shape)
      }
    }
  }

  override draw() {
    this.clear()

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

    const shapeDetailList: {
      graph: Konva.Group
      shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[]
    }[] = []

    for (const graph of graphs) {
      // 非选中状态才显示 调整点
      if (!graph.attrs.selected) {
        const anchors = (graph.attrs.anchors ?? []) as Types.GraphAnchor[]
        const shapeRecords: { shape: Konva.Shape; anchorShadow: Konva.Circle }[] = []

        // 根据 调整点 信息,创建
        for (const anchor of anchors) {
          // 调整点 的显示 依赖其隐藏的 锚点 位置、大小等信息
          const anchorShadow = graph.findOne(`#${anchor.id}`) as Konva.Circle
          if (anchorShadow) {
            const shape = Graphs.Circle.createAnchorShape(
              this.render,
              graph,
              anchor,
              anchorShadow,
              this.state.adjustingId,
              this.state.adjustGroupId
            )

            shapeRecords.push({ shape, anchorShadow })
          }
        }

        shapeDetailList.push({
          graph,
          shapeRecords
        })
      }
    }

    this.adjusts(shapeDetailList)
  }
}

稍显臃肿,后面慢慢优化吧 -_-

canvas2svg 打补丁

上面已经实现了绘制图形(圆/椭形),但是导出 svg 的时候报错了。经过错误定位以及源码阅读,发现:

1、当 Konva.Group 包含 Konva.Ellipse 的时候,无法导出 svg 文件

2、对 Konva.Ellipse 调整如 radiusX、radiusY 属性时,无法正确输出 path 路径

1、canvas2svg 尝试给 g 节点赋予 path 属性,导致异常报错。

现通过 hack __applyCurrentDefaultPath 方法,增加处理 nodeName === 'g' 的场景

2、查看 Konva.Ellipse.prototype._sceneFunc 方法源码,Konva 绘制 Ellipse 是通过 canvas 的 arc + scale 实现的,对应代码注释 A。

实际效果,无法仿照 canvas 的平均 scale,会出现 stroke 粗细不一。

因此,尝试通过识别 scale 修改 path 特征,修复此问题。

javascript 复制代码
// src/Render/tools/ImportExportTool.ts	

C2S.prototype.__applyCurrentDefaultPath = function () {
  // 补丁:修复以下问题:
  // 1、当 Konva.Group 包含 Konva.Ellipse 的时候,无法导出 svg 文件
  // 2、对 Konva.Ellipse 调整如 radiusX、radiusY 属性时,无法正确输出 path 路径
  //
  // PS:
  // 1、canvas2svg 尝试给 g 节点赋予 path 属性,导致异常报错。
  // 现通过 hack __applyCurrentDefaultPath 方法,增加处理 nodeName === 'g' 的场景
  //
  // 2、查看 Konva.Ellipse.prototype._sceneFunc 方法源码,
  // Konva 绘制 Ellipse 是通过 canvas 的 arc + scale 实现的,对应代码注释 A,
  // 实际效果,无法仿照 canvas 的平均 scale,会出现 stroke 粗细不一。
  // 因此,尝试通过识别 scale 修改 path 特征,修复此问题。
  //
  // (以上 hack 仅针对示例绘制 图形 时的特征进行处理,并未深入研究 canvas2svg 为何会进入错误的逻辑)

  if (this.__currentElement.nodeName === 'g') {
    const g = this.__currentElement.querySelector('g')
    if (g) {
      // 注释 A
      // const d = this.__currentDefaultPath
      // const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGElement
      // path.setAttribute('d', d)
      // path.setAttribute('fill', 'none')
      // g.append(path)

      const scale = g.getAttribute('transform')
      if (scale) {
        const match = scale.match(/scale\(([^),]+),([^)]+)\)/)
        if (match) {
          const [sx, sy] = [parseFloat(match[1]), parseFloat(match[2])]
          let d = this.__currentDefaultPath
          const reg = /A ([^ ]+) ([^ ]+) /
          const match2 = d.match(reg)
          if (match2) {
            const [rx, ry] = [parseFloat(match2[1]), parseFloat(match2[2])]
            d = d.replace(reg, `A ${rx * sx} ${ry * sy} `)
            const path = document.createElementNS(
              'http://www.w3.org/2000/svg',
              'path'
            ) as SVGElement
            path.setAttribute('d', d)
            path.setAttribute('fill', 'none')
            this.__currentElement.append(path)
          }
        }
      } else {
        const d = this.__currentDefaultPath
        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') as SVGElement
        path.setAttribute('d', d)
        path.setAttribute('fill', 'none')
        this.__currentElement.append(path)
      }
    }

    console.warn(
      '[Hacked] Attempted to apply path command to node ' + this.__currentElement.nodeName
    )
    return
  }

  // 原逻辑
  if (this.__currentElement.nodeName === 'path') {
    const d = this.__currentDefaultPath
    this.__currentElement.setAttribute('d', d)
  } else {
    throw new Error('Attempted to apply path command to node ' + this.__currentElement.nodeName)
  }
}

以上 hack 仅针对示例绘制 图形 时的特征进行处理,并未深入研究 canvas2svg 为何会进入错误的逻辑

拐点旋转修复

测试发现,连接线 的 拐点 并没有能跟随旋转角度调整坐标,因此补充一个修复:

javascript 复制代码
// src/Render/handlers/SelectionHandlers.ts	

// 略

  /**
   * 矩阵变换:坐标系中的一个点,围绕着另外一个点进行旋转
   * -  -   -        - -   -   - -
   * |x`|   |cos -sin| |x-a|   |a|
   * |  | = |        | |   | +
   * |y`|   |sin  cos| |y-b|   |b|
   * -  -   -        - -   -   - -
   * @param x 目标节点坐标 x
   * @param y 目标节点坐标 y
   * @param centerX 围绕的点坐标 x
   * @param centerY 围绕的点坐标 y
   * @param angle 旋转角度
   * @returns
   */
  rotatePoint(x: number, y: number, centerX: number, centerY: number, angle: number) {
    // 将角度转换为弧度
    const radians = (angle * Math.PI) / 180

    // 计算旋转后的坐标
    const newX = Math.cos(radians) * (x - centerX) - Math.sin(radians) * (y - centerY) + centerX
    const newY = Math.sin(radians) * (x - centerX) + Math.cos(radians) * (y - centerY) + centerY

    return { x: newX, y: newY }
  }

  lastRotation = 0


// 略
  handlers = {
// 略
    transformer: {
      transform: () => {
        // 旋转时,拐点也要跟着动
        const back = this.render.transformer.findOne('.back')

        if (back) {
          // stage 状态
          const stageState = this.render.getStageState()

          const { x, y, width, height } = back.getClientRect()
          const rotation = back.getAbsoluteRotation() - this.lastRotation
          const centerX = x + width / 2
          const centerY = y + height / 2

          const groups = this.render.transformer.nodes()

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

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

          for (const pair of pairs) {
            const fromGroup = groups.find((o) => o.id() === pair.from.groupId)
            const toGroup = groups.find((o) => o.id() === pair.to.groupId)
            // 必须成对移动才记录
            if (fromGroup && toGroup) {
              // 移动
              if (fromGroup.attrs.manualPointsMap && fromGroup.attrs.manualPointsMapBefore) {
                let manualPoints = fromGroup.attrs.manualPointsMap[pair.id]
                const manualPointsBefore = fromGroup.attrs.manualPointsMapBefore[pair.id]
                if (Array.isArray(manualPoints) && Array.isArray(manualPointsBefore)) {
                  manualPoints = manualPointsBefore.map((o: Types.ManualPoint) => {
                    const { x, y } = this.rotatePoint(
                      this.render.toBoardValue(o.x) + stageState.x,
                      this.render.toBoardValue(o.y) + stageState.y,
                      centerX,
                      centerY,
                      rotation
                    )

                    return {
                      x: this.render.toStageValue(x - stageState.x),
                      y: this.render.toStageValue(y - stageState.y)
                    }
                  })

                  fromGroup.setAttr('manualPointsMap', {
                    ...fromGroup.attrs.manualPointsMap,
                    [pair.id]: manualPoints
                  })
                }
              }
            }
          }
        }

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

Thanks watching~

More Stars please!勾勾手指~

源码

gitee源码

示例地址

相关推荐
德育处主任13 小时前
p5.js 3D 形状 "预制工厂"——buildGeometry ()
前端·javascript·canvas
德育处主任2 天前
p5.js 3D盒子的基础用法
前端·数据可视化·canvas
掘金安东尼3 天前
2分钟创建一个“不依赖任何外部库”的粒子动画背景
前端·面试·canvas
百万蹄蹄向前冲3 天前
让AI写2D格斗游戏,坏了我成测试了
前端·canvas·trae
用户2519162427116 天前
Canvas之画图板
前端·javascript·canvas
FogLetter9 天前
玩转Canvas:从静态图像到动态动画的奇妙之旅
前端·canvas
用户2519162427119 天前
Canvas之贪吃蛇
前端·javascript·canvas
用户25191624271110 天前
Canvas之粒子烟花
前端·javascript·canvas
普兰店拉马努金10 天前
【Canvas与文字】生存与生活
生活·canvas·文字·生存
敲敲敲敲暴你脑袋12 天前
用canvas绘制兰伯特投影地图
typescript·数据可视化·canvas