基于 fabric.js 实现矩形阵列和环形阵列

616 阅读12分钟

前言

因为目前有时间了,所以在整理一下自己这几年写过的一些东西的相关文档,准备把一些东西改一下发出来,有的内容可能并不复杂,甚至有点浅显,但是也是对自己这几年的一些复盘和总结了

原创文章,全文唯一
如果内容有帮助请不要吝啬您的点赞收藏哦
有转载需求的请跟我确认

什么是矩形阵列和环形阵列

矩形阵列

简单来说矩形阵列就是将当前选中的图形根据设定的参数变成一个矩阵阵列的需求

动画.gif

实现思路

矩形阵列实际上来说是个简单的需求,本质上我们只需要根据原来图案的宽高以及对应需要的列宽和行宽来生成相应的图案就行了,所以在这个功能上不会进行过多的描述

常规参数

行数、列数、行宽、列宽

generateMatrixArray(params: IMatrixArrayParams = { ...defaultMatrixArrayParams }) {
  if (!this.canGenerate || !this.selection) {
    return
  }
  this.cleanMatrixArray()
  const _cloneIds: fabric.ActiveSelection[] = []
  const _copyData = this.canvas.getActiveObject()
  const _width = _copyData?.getScaledWidth() || 0
  const _height = _copyData?.getScaledHeight() || 0
  for (let i = 0; i < params.lineNum; i++) {
    for (let j = 0; j < params.columnNum; j++) {
      if (i === 0 && j === 0) {
        continue
      }
      _copyData?.clone((newObj: fabric.ActiveSelection) => {
        newObj.set({
          left: newObj.left! + (params.columnSpacing + _width) * j,
          top: newObj.top! + (params.lineSpacing + _height) * i
        })
        setObjectChar(newObj, this.canvas)
        this.canvas.add(newObj)
        _cloneIds.push(newObj)
      }, this.canvas.extraProps)
    }
  }
  this.canvas.requestRenderAll()
  this.cloneIds = _cloneIds
}

注意还要保存一个 clone 出来的元素队列,方便在取消生成的时候进行清除

 private clean() {
    this.cloneIds.forEach((obj) => {
      this.canvas.remove(obj)
    })
    this.cloneIds = []
  }
  

优化

但是如果仅仅是这样做的话,其实我们会发现,在元素变换特别多且频繁的时候会有卡顿的感觉,这是因为我们在不断地清除和绘制图像,这是一个性能消耗非常大的操作,所以我们就需要在图像变更时,未被更改到的图形不需要进行清除或者操作,以此来减少消耗,例如我从 2 行 2列,新增到 2行 3列,那原来 2行2列的元素就不需要进行操作和处理

我们可以在进行操作的时候保存一个上次操作的二维数组进行对比即可

并且我们需要尽量避免删除和绘制的操作,所以在改变边距的时候也尽量自己进行计算处理

generateMatrixArray(params: IMatrixArrayParams = { ...defaultMatrixArrayParams }) {
  if (!this.canGenerate || !this.selection) {
    return
  }
  this.canvas.renderOnAddRemove = false
  const spacingHasChange =
    this.lastMatrixParams.columnSpacing !== params.columnSpacing ||
    this.lastMatrixParams.lineSpacing !== params.lineSpacing

  const _cloneIds: fabric.Object[] = []
  const lastMatrixArray: fabric.Object[][] = []
  const _copyData = this.canvas.getActiveObject()
  const _width = _copyData?.getScaledWidth() || 0
  const _height = _copyData?.getScaledHeight() || 0

  for (let i = 0; i < params.lineNum; i++) {
    lastMatrixArray.push([])
    for (let j = 0; j < params.columnNum; j++) {
      if (i === 0 && j === 0) {
        _copyData && lastMatrixArray[i].push(_copyData)
        continue
      }

      if (!this.lastMatrixArray[i]?.[j]) {
        _copyData?.clone((newObj: fabric.Object) => {
          newObj.set({
            left: newObj.left! + (params.columnSpacing + _width) * j,
            top: newObj.top! + (params.lineSpacing + _height) * i
          })
          setObjectChar(newObj, this.canvas)
          this.canvas.add(newObj)
          _cloneIds.push(newObj)
          lastMatrixArray[i].push(newObj)
        }, this.canvas.extraProps)
      } else {
        if (spacingHasChange) {
          const item = this.lastMatrixArray[i]?.[j]
          if (params.columnSpacing !== this.lastMatrixParams.columnSpacing) {
            if (j !== 0) {
              this.lastMatrixArray[i]?.[j].set({
                left:
                  (item.left || 0) +
                  (params.columnSpacing - this.lastMatrixParams.columnSpacing) * j
              })
            }
          }
          if (params.lineSpacing !== this.lastMatrixParams.lineSpacing) {
            if (i !== 0) {
              this.lastMatrixArray[i]?.[j].set({
                top:
                  (item.top || 0) + (params.lineSpacing - this.lastMatrixParams.lineSpacing) * i
              })
            }
          }
        }
        _cloneIds.push(this.lastMatrixArray[i]?.[j])
        lastMatrixArray[i].push(this.lastMatrixArray[i]?.[j])
      }
    }
  }

  this.lastMatrixArray.forEach((item, index) => {
    item.forEach((item2, index2) => {
      if (index >= params.lineNum || index2 >= params.columnNum) {
        this.canvas.remove(item2)
      }
    })
  })
  this.canvas.renderAll()
  this.cloneIds = _cloneIds
  this.lastMatrixArray = lastMatrixArray
  this.lastMatrixParams = { ...params }
}

环形阵列

跟矩形阵列差不多,也是将当前选中给的图形,根据预设的参数来生成相关的阵列
环形阵列.gif

实现思路

环形阵列的实现对比于矩形阵列来说就复杂了一些
首先是环形阵列需要的参数相对矩形阵列更多,并且参数间会有相关的一些联动需求

参数

阵列中心点 X坐标:默认为当前所选择的对象/组合的中心点的X轴
阵列中心点 Y坐标:默认为当前所选择的对象/组合的中心点的Y轴
起始角度:环形矩阵的开始角度,一般默认是 0
终点角度:环形矩阵结束那个图案的角度
间隔角度:环形矩阵中每个图案的间隔角度
复制数量:环形矩阵中图案的数量 - 初始图案
是否自旋转:若勾选,则是按照间隔角度进行递增旋转,即后一个图形会按照上一个图形旋转对应的度数



起点角度、终点角度、间隔监督和复制数量会互相发生联动影响,修改了其中一个数值,其他的字段也会联动发生变化:
(1)若修改起点角度:终点角度会发生联动变化,变化公式为:
终点角度=间隔角度复制数量+起点角度;
(2)若修改终点角度:间隔角度会发生联动变化,变化公式为:
间隔角度=(终点角度-起点角度)/复制数量;
(3)若修改间隔角度:终点角度会发生联动变化,变化公式为:
终点角度=间隔角度
复制数量+起点角度;
(4)若修改复制数量:间隔角度会发生联动变化,变化公式为:
间隔角度=(终点角度-起点角度)/复制数量;


场景考虑

所以我们在实现的时候需要考虑的情况比较多:

1.阵型中心的x、y坐标变化时,我们的环形阵列该如何变更


关于这点,我们参考xtool的交互,我们可以发现它在阵型中心的x、y变化时不会改变初始图案的位置,而是根据初始图案的位置来调整阵列的半径大小,这点的交互与啄木鸟又有所不同环形阵列坐标变更.gif

2.起始角度变更时我们需要怎么调整生成的图形的位置

我们可以发现 xtool 中 起始角度的变更也不会影响到原图案的位置,这点与啄木鸟也不同,在起始角度变更时,xtool中的环形阵列会根据起始坐标来渲染复制出来的图形 29e0f167-9fa5-4476-9e90-6846c7dd9966.gif

3.终点角度变更时我们要如何调整生成图形的位置

我们可以看到终点角度的变更并不会影响到起点角度的变动,而是会不断更改间隔角度的大小环形阵列坐标终点坐标变更.gif 当终点坐标使间隔角度小于一定程度的时候就会让图案重叠到一起 image.png

4.间隔角度变更时我们需要如何调整图形的生成位置
我们可以看到在 xtool中 间隔角度的变化会影响到终点角度的变化
环形阵列坐标间隔坐标变更.gif
当然,上面所考虑到的都是在自旋转情况下的变动,如果是非自旋转的情况下又有所不同,因为很多变更实际上和自旋的情况下差不多,只不过是图案本身并不需要进行旋转,所以我这里就不一一细说了,只是简单的给个动图描述一下
非自旋转情况下的变更.gif

实现方案

理论分析完毕,下面开始实践,因为参数联动的变动修改比较简单,所以这里就不做过多描述了,我们只专注图形的变动
首先,我们可以将问题分为两种情况:自旋转和非自旋转,在这两种不同的选择下我们的处理方式也是有所不同
我们先处理最自旋转和非自旋转都需要处理的公共逻辑,也就阵列会根据坐标、起始角度、终点角度、变更角度、复制数量等情况变更的逻辑


前置知识

首先,我们在做这个需求之前需要知道一些前置知识

在二维平面几何中,当我们想要在一个圆上均匀分布多个点时,我们需要知道每个点相对于圆心的角度,并据此计算出它们在圆上的具体位置。

圆的参数方程通常用来描述圆上任意一点的坐标(x, y)与圆心坐标(cx, cy)和该点与圆心连线与x轴正方向的夹角θ(通常以弧度为单位)之间的关系。

圆的参数方程如下:
x = cx + r * cos(θ)
y = cy + r * sin(θ)


其中,(cx, cy) 是圆心的坐标,r 是圆的半径,θ 是从圆心到圆上某点的连线与x轴正方向的夹角(弧度制)。 在这个情况下,我们可以用Math.cos(θ) 和 Math.sin(θ) 计算该角度对应的余弦值和正弦值。这两个值决定了在给定角度下,从圆心出发的射线与x轴和y轴的交点到圆心的距离比例。通过乘以圆的半径r,我们得到了圆上该角度对应的点的x和y坐标。

在 Fabric.js 中,由于我们想要将多个对象(如矩形、圆形等)放置在一个环形阵列上,我们需要知道每个对象相对于圆心的位置。通过循环遍历每个对象并递增角度θ(根据间隔角度angleStep),我们可以使用上述公式计算出每个对象应该放置的(x, y)坐标

具体实现

因为我们的第一个图案的元素是不会变动的,所以我的的处理需要考虑这一点

1.计算每个复制出来的图形的中心落点

为了求解点x3的坐标,我们可以使用极坐标转换和三角函数。首先,我们需要知道x1和x2之间的角度(我们可以假设它或者通过计算得到),以及x2到x3的角度(这个是已知的)。然后,我们可以使用圆的半径(即x1到x2的距离,也是x2到x3的距离)和这些角度来找到x3的坐标。

假设x1的坐标为(x1, y1),x2(圆心)的坐标为(x2, y2),x2到x3的角度为angleToX3(以弧度为单位,如果给出的是角度,则需要转换为弧度),圆的半径为r(即x1到x2的距离)。

所以我们可以得出求坐标的方法:

private calculateTargetPoint(params: {
    xAxis: number
    yAxis: number
    originX: number
    originY: number
    angle: number
  }) {
    const { xAxis, originX, yAxis, originY, angle } = params
    // 将角度从度转换为弧度
    const angleInRadians = angle * (Math.PI / 180)

    // 计算x1到x2的距离(圆的半径)
    const r = Math.sqrt(Math.pow(xAxis - originX, 2) + Math.pow(yAxis - originY, 2))

    // 使用极坐标转换来找到x3的坐标
    const x3 = xAxis + r * Math.cos(angleInRadians)
    const y3 = yAxis + r * Math.sin(angleInRadians)

    return { x: x3, y: y3 } // 返回x3的坐标
  }


由于圆上的点有两个可能的位置(一个在圆的一侧,另一个在另一侧),可能需要处理这种情况,具体取决于如何定义angleToX3。上面的代码只给出了一个解。

如果需要另一个解,可以通过添加或减去Math.PI(180度)来找到它。

这时候我们复制8个的情况下能得到的效果是这样的

image.png

这个明显与我们的需求不符:

  1. 没有考虑到初始图案的情况
  2. 复制出来的图形的旋转角度不符合期望

2.如何解决复制出来的图形的旋转角度问题

解决这个问题其实很简单,我们在上面看到了如何去计算复制出来的图形的中心落点,那么我们现在需要去做的是,让当前图形的旋转角度与计算图形落点时的角度相同则可以解决

const angle = params.startAngle + (i + 1) * params.intervalAngle


image.png

3.考虑初始图案的问题,Y轴位置变更时,复制出来的图案旋转角度不符合 xtool

image.png

xtool 的实现比较鸡贼,他的第一个图案实际上是和源图案进行了重叠, 实际上源图案跟第一个图案是不变的,变的只有这个环形矩阵的中心点位置和大小

并且 xtool 的交互在 y 轴有变更时,图形的旋转方式也会有所变更,但是第一个图形还是不变的,但是我们目前的实现是y轴变更时复制出来的第一个元素的偏移角度有所变更

image.png
对于第一个问题,第一个元素和源图案重叠的问题,我们首先是计算偏移角度的时候不用 + 1

const angle = params.startAngle + i * params.intervalAngle

然后第二个问题就是中心点y轴变更时,没有跟xtool一样对图形的角度有所偏移


我的效果

xtool的效果

明显发现,我们的效果里的第一个元素的位置发生了偏移,也就是说我们的x,y的计算其实是有问题的,并且我们复制出来的图案的偏转角度也是有问题的

经过仔细排查,原来是计算目标点的方法出了问题

  private calculateTargetPoint(params: {
    xAxis: number
    yAxis: number
    originX: number
    originY: number
    angle: number
  }) {
    const { xAxis, yAxis, originX, originY, angle } = params

    // 将角度从度转换为弧度
    const angleInRadians = angle * (Math.PI / 180)

    // 计算从圆心到(originX, originY)的向量
    const dx = originX - xAxis
    const dy = originY - yAxis

    // 旋转向量
    const x3 = xAxis + dx * Math.cos(angleInRadians) - dy * Math.sin(angleInRadians)
    const y3 = yAxis + dx * Math.sin(angleInRadians) + dy * Math.cos(angleInRadians)

    return { x: x3, y: y3 } // 返回x3的坐标
  }

(originX, originY)是圆上的一个点,而(xAxis, yAxis)是圆心

在这种情况下,要找到与(originX, originY)关于圆心(xAxis, yAxis)成angle角度的点(x3, y3),应该首先计算从圆心到(originX, originY)的向量,然后旋转这个向量angle角度。但是,由于(originX, originY)已经在圆上,并且(xAxis, yAxis)是圆心,因此实际上只需要将(originX, originY)绕圆心(xAxis, yAxis)旋转angle角度即可。

在这个修改后的方法中,首先计算了从圆心(xAxis, yAxis)到点(originX, originY)的向量(dx, dy)。
然后,用标准的二维旋转公式来旋转这个向量angle角度。
最后,将旋转后的向量加回到圆心(xAxis, yAxis)上,从而得到新的点(x3, y3)。
现在,无论angle如何变化,只要(originX, originY)保持在圆上(即它到(xAxis, yAxis)的距离保持不变),x3的坐标就会根据angle的变化而正确地变化。


优化

我们可以发现这个操作其实和上面的矩形阵列一样,在图形过多时,变化的卡顿感其实非常高
但是这个的变更就没办法像上面一样可以做一些减少卡顿的操作了
所以这里我们就简单给他加个防抖算了
出来的效果跟啄木鸟的也差不多