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

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

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

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

github源码

gitee源码

示例地址

接下来主要说说:

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

UI - 图形绘制类型切换

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

image

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

image

定义图形类型:

// src/Render/types.ts	

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

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

// src/Render/index.ts	

// 略

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

// 略

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

工具栏按钮通讯:

// 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 抽象类

// 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 个 调整点:

image

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

image

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

image

图形也支持 连接点:

image

图形类 - Circle

// 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

// 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

// 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 特征,修复此问题。

// 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 为何会进入错误的逻辑

拐点旋转修复

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

image

// 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源码

示例地址

posted @ 2024-08-20 18:24  xachary  阅读(316)  评论(0编辑  收藏  举报