canvas使用贝塞尔曲线,实现笔迹回放

2,184 阅读7分钟

从事前端开发工作有接近五年时间了,平常都是看看各种大佬的文章学习学习,这也是第一次发文章。日常在开发中也有很多可以分享的内容,但由于各种原因(懒癌患者)没有去总结,所以现在也想尝试把自己开发中遇到的问题,解决问题的方法总结一下,加深自己的印象,也给有需要的朋友提供一点帮助。 1920_1080_20100321013632497336.jpg

背景介绍

回归正题,需求背景是因为公司开发了一款基于点阵笔的作业产品,当学生使用点阵笔在特定的卡纸上书写时,可以采集到学生的笔迹以及作答轨迹信息,并把这些信息展示出来,做出一个笔迹回放功能,前端画图嘛,最先想到的肯定是canvas了,安排一波。

bfdf1ccf2be1b85d35a0e3bba2fc736f (1).gif

后端数据

先来瞅一瞅后端给到的数据,主要就是笔迹点的数组,如下:

{
  "position": {
      "startX": 75.0,
      "startY": 350.0,
      "width": 841.0,
      "height": 985.0
  },
  "dots": [
      {
          "x": 422.7,
          "y": 498.3,
          "strokeId": "3645fa56-56c6-49a1-9c27-73277d3c2d69"
      }, {
          "x": 427.6,
          "y": 499.0,
          "strokeId": "3645fa56-56c6-49a1-9c27-73277d3c2d69"
      }, {
          "x": 434.5,
          "y": 512.9,
          "strokeId": "383ad5cc-d30e-4069-b8ea-95938d4ae511"
      }, {
          "x": 434.0,
          "y": 513.7,
          "strokeId": "383ad5cc-d30e-4069-b8ea-95938d4ae511"
      }, {
          "x": 437.6,
          "y": 533.1,
          "strokeId": "383ad5cc-d30e-4069-b8ea-95938d4ae511"
      }
  ],
  "smallTopicTitle": "1"   
}

绘制静态页面

这是一页数据里的第一题的笔迹数据,实际上的点很多,目前只是截取部分看一下数据结构,position中包含的是题目在页面的信息,dots是点的数组,其中strokeId是笔画id(笔迹是由笔画构成,笔画是由点连接构成)。那就把所有笔迹画一下看看:

   // canvas进行轨迹绘制
    ChirographyEvent () {
      // 调用绘制页面
      let canvas = document.getElementById('canvas')
      var ctx = canvas.getContext('2d')
      // 绘制圆角
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'
      // 线条粗细 斜接长度
      ctx.lineWidth = 2
      // 清空画布
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      // 调用绘画方法
      this.LoopDrawEvent(ctx, 0) // 下方调用
    }
    // 绘制单个点事件
    LoopDrawEvent (ctx, index) {
      const presentPoint = this.dataShowList[index] // 当前点数据
      const previousPoint = this.dataShowList[index - 1]
        ? this.dataShowList[index - 1]
        : {
          x: 0,
          y: 0,
          strokeId: -1
        }  // 上一个点的数据
      // 判断起点跟终点是否是同一笔
      if (presentPoint.strokeId === previousPoint.strokeId) {
        ctx.beginPath()
        ctx.moveTo(this.startXX, this.startYY) // 线条起始点
        ctx.lineTo(presentPoint.x, presentPoint.y) // 线条终点
        //设置新的起点
        this.startXX = presentPoint.x
        this.startYY = presentPoint.x
        ctx.stroke()
      } else {
        this.startXX = presentPoint.x
        this.startYY = presentPoint.y
      }
      // 判断index不超过list长度
      index = index + 1
      if (index > this.dataShowList.length) {
        index = this.dataShowList.length
      }
      // 嵌套循环
      if (index < this.dataShowList.length + 1) {
        this.LoopDrawEvent(ctx, index)
      } else {
        // 判断结束,状态重置
        clearTimeout(this.timeSetTimeOut)
      }
    }

上面代码就不具体解释了,就是简单的canvas绘图,判断笔画,然后使用直线连接点,绘制页面。实现效果如下:

temp.png 这个时候大家就会发现一个问题,就是现在绘制的点与点之间是直线,看起来很不协调,这个时候就需要用到我们这篇文章的主题——贝塞尔曲线了。

贝塞尔曲线

我们先简单介绍一下贝塞尔曲线哈......此处省略10000字,哈哈,感兴趣的朋友可以去详细了解一下,这里就不赘述了,有用过PS的朋友应该能理解,PS中的钢笔工具就是使用的贝塞尔曲线,这里就简单给大家放张图:

1467997-20201012151707502-1854765346.gif 其实我们不需要了解贝塞尔曲线的具体原理是什么,因为canvas已经给你封装好了贝塞尔曲线的方法:

// cpx,cpy是控制点,可对照上图2中的P1点
// x,y是结束点,可对照上图2中的P2点
context.quadraticCurveTo(cpx,cpy,x,y) // 创建二次贝塞尔曲线

// cp1x,cp1y是控制点1,可对照上图3中的P1点
// cp2x,cp2y是控制点2,可对照上图3中的P2点
// x,y是结束点,可对照上图3中的P3点
context.bezierCurveTo(cp1x,cp1y,cp2x,cp2y,x,y) // 创建三次贝塞尔曲线

绘制三阶贝塞尔曲线

这里就不介绍二阶贝塞尔曲线了,直接上三阶。上面也说到canvas提供了三阶贝塞尔曲线的方法,但是现在就有个疑问了,两个点之间绘制线条,起点知道,终点知道,那两个控制点怎么办?

   LoopDrawEvent (ctx, index) {
        const presentPoint = this.dataShowList[index]
        const previousPoint = this.dataShowList[index - 1]
          ? this.dataShowList[index - 1]
          : {
            x: 0,
            y: 0,
            strokeId: -1
          }

        // 判断是最后一个点
        if (index === this.dataShowList.length) {
          ctx.beginPath()
          ctx.moveTo(this.startXX, this.startYY) // 线条起始点
          ctx.lineTo(previousPoint.x, previousPoint.y) // 线条终点
          ctx.stroke()
          console.log('判断是最后一个点', previousPoint)
          return
        }
        
        // 判断起点跟终点是否是同一笔
        if (presentPoint.strokeId === previousPoint.strokeId) {
          ctx.beginPath()
          // 当前点
          const startX = presentPoint.x
          const startY = presentPoint.y
          // 上一点
          let controlX = previousPoint.x
          let controlY = previousPoint.y
          ctx.moveTo(this.startXX, this.startYY) // 线条起始点
          
          // 计算两点之间的x,y的差值
          const offsetX = startX - controlX
          const offsetY = startY - controlY
          
          // 绘制三阶贝塞尔曲线
          ctx.bezierCurveTo(controlX, controlY, (offsetX / 3 + controlX), (offsetY / 3 + controlY), (offsetX * 2 / 3 + controlX), (offsetY * 2 / 3 + controlY))
          this.startXX = offsetX * 2 / 3 + controlX
          this.startYY = offsetY * 2 / 3 + controlY
          ctx.stroke()
        } else {
          this.startXX = presentPoint.x
          this.startYY = presentPoint.y
        }

        index = index + 1
        if (index > this.dataShowList.length) {
          index = this.dataShowList.length
        }

        // 嵌套循环
        if (index < this.dataShowList.length + 1) {
          this.LoopDrawEvent(ctx, index)
        } else {
          clearTimeout(this.timeSetTimeOut)
        }
    }

控制点的计算就是上面的代码改造里面了,bezierCurveTo方法,使用上一个点作为控制点1,使用两点之间1/3位置的点作为控制点2,使用两点之间2/3位置的点作为结束点;最后一个点需要单独处理,因为用两点的2/3作为结束点,会导致最后一个点会短一截,效果如下图:

temp.png 现在看的话是不是圆滑了很多,接近手写的效果了

实现笔迹回放

静态的页面画好了,那下一步就是要实现类似视频播放的动态效果了,具体效果如下:

temp1.png 播放,暂停,进度条,可拖拽进度条修改播放进度,倍速...,再加个弹幕是不是就是一个视频播放器了。

    // canvas进行轨迹绘制
    // 只在第一次调用
    ChirographyEvent (type) {
      // 第一次调用绘制页面
      let canvas = document.getElementById('canvas')
      var ctx = canvas.getContext('2d')
      // 绘制圆角
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'
      // 线条粗细 斜接长度
      ctx.lineWidth = 2
      // 判断点击的是播放案件
      if (type === 'play') {
        this.LoopDrawEvent(ctx, this.playedIndex)
        return false
      }
      // 初始化数据
      this.isPlay = true // 播放状态
      this.isFinish = false // 结束状态
      this.playedIndex = 0
      this.sliderNum = 0 // 进度条

      // 清空画布
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      // 调用绘画方法
      this.LoopDrawEvent(ctx, 0)
    },
    
    // canvas进行轨迹绘制,拖拽事件更新
    ChirographyUpdataEvent (myIndex) {
      // 第一次调用绘制页面
      let canvas = document.getElementById('canvas')
      var ctx = canvas.getContext('2d')
      // 绘制圆角
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'
      // 线条粗细 斜接长度
      ctx.lineWidth = 2

      // 清空画布
      ctx.clearRect(0, 0, canvas.width, canvas.height)
      // 调用绘画方法
      for (let index = 0; index < myIndex; index += 1) {
        const presentPoint = this.dataShowList[index]

        const previousPoint = this.dataShowList[index - 1]
          ? this.dataShowList[index - 1]
          : {
            x: 0,
            y: 0,
            strokeId: -1
          }
        if (presentPoint.strokeId === previousPoint.strokeId) {
          ctx.beginPath()
          const startX = presentPoint.x
          const startY = presentPoint.y
          let controlX = previousPoint.x
          let controlY = previousPoint.y
          ctx.moveTo(this.startXX, this.startYY) // 线条起始点
          // 判断贝塞尔曲线的控制点
          // ctx.lineTo(controlX, controlY) // 线条终点
          const offsetX = startX - controlX
          const offsetY = startY - controlY
          ctx.bezierCurveTo(controlX, controlY, (offsetX / 3 + controlX), (offsetY / 3 + controlY), (offsetX * 2 / 3 + controlX), (offsetY * 2 / 3 + controlY))
          this.startXX = offsetX * 2 / 3 + controlX
          this.startYY = offsetY * 2 / 3 + controlY
          ctx.stroke()
        } else {
          this.startXX = presentPoint.x
          this.startYY = presentPoint.y
        }
      }
    },

    // 画笔回放倍速修改
    TimeEactEvent () {
      this.isDoubleSpeed = !this.isDoubleSpeed
      if (this.isDoubleSpeed) {
        this.timeEact = this.defaultTimeEact / 2
      } else {
        this.timeEact = this.defaultTimeEact
      }
    },

    // 播放 暂停 事件
    playEvent () {
      this.isPlay = !this.isPlay
      if (this.isPlay) {
        // 判断是否结束,结束重新播放
        if (this.isFinish) {
          this.ChirographyEvent()
        } else {
          this.ChirographyEvent('play')
        }
      }
    },

    // 进度条拖拽事件
    slideChangeEvent (value) {
      this.isPlay = false // 播放中止
      this.playedIndex = Math.floor(value / 100 * this.allIndex)
      this.playedTime = this.timeChange(this.playedIndex * this.defaultTimeEact)
      // 直接渲染当前index之前点的数据
      this.ChirographyUpdataEvent(this.playedIndex)
    },

    // 退出播放事件
    closeCanvasEvent () {
      clearTimeout(this.timeSetTimeOut)
      this.$emit('closeCanvasEvent')
    },

    // 绘制单个点事件
    LoopDrawEvent (ctx, index) {
      this.timeSetTimeOut = setTimeout(() => {
        // 判断是否中止播放
        if (!this.isPlay) {
          clearTimeout(this.timeSetTimeOut)
          return
        }
        // 绘制线
        // 判断起点跟终点是否是同一笔
        const presentPoint = this.dataShowList[index]

        const previousPoint = this.dataShowList[index - 1]
          ? this.dataShowList[index - 1]
          : {
            x: 0,
            y: 0,
            strokeId: -1
          }

        // 判断是最后一个点
        if (index === this.dataShowList.length) {
          ctx.beginPath()
          ctx.moveTo(this.startXX, this.startYY) // 线条起始点
          ctx.lineTo(previousPoint.x, previousPoint.y) // 线条终点
          ctx.stroke()
          console.log('判断是最后一个点', previousPoint)
          return
        }
        if (presentPoint.strokeId === previousPoint.strokeId) {
          ctx.beginPath()
          const startX = presentPoint.x
          const startY = presentPoint.y
          let controlX = previousPoint.x
          let controlY = previousPoint.y

          ctx.moveTo(this.startXX, this.startYY) // 线条起始点

          // 判断贝塞尔曲线的控制点
          const offsetX = startX - controlX
          const offsetY = startY - controlY
          ctx.bezierCurveTo(controlX, controlY, (offsetX / 3 + controlX), (offsetY / 3 + controlY), (offsetX * 2 / 3 + controlX), (offsetY * 2 / 3 + controlY))
          this.startXX = offsetX * 2 / 3 + controlX
          this.startYY = offsetY * 2 / 3 + controlY
          ctx.stroke()
        } else {
          this.startXX = presentPoint.x
          this.startYY = presentPoint.y
        }
        // 获取已播放时间
        // 判断时长不能大于总长度
        index = index + 1
        if (index > this.dataShowList.length) {
          index = this.dataShowList.length
        }

        // 计算进度
        this.playedIndex = index
        this.sliderNum = Math.floor(this.playedIndex / this.allIndex * 100) // 进度条
        this.playedTime = this.timeChange(index * this.defaultTimeEact)

        // 嵌套循环
        if (index < this.dataShowList.length + 1) {
          this.LoopDrawEvent(ctx, index)
        } else {
          // 判断结束,状态重置
          console.log('判断结束,状态重置')
          this.isPlay = false // 播放状态
          this.isFinish = true // 结束状态
          clearTimeout(this.timeSetTimeOut)
        }
      }, this.timeEact)
    }

以上粘贴的只是部分代码,实现逻辑其实也比较简单:

  • 时长:通过点的数量乘以固定时长,计算出一个总时间;
  • 播放:使用setTimeout进行延时绘制,index判断播放进度;
  • 暂停:通过clearTimeout进行延时中止;
  • 倍数:通过修改setTimeout的延时时间;
  • 拖拽进度:判断当前拖拽的index,重新绘制页面

结束

以上就是一个笔迹回放的主要功能了,主体就是三阶贝塞尔曲线,以及个播放功能。第一次写也写得比较简单仓促,有什么需要完善或者讨论的欢迎大家留言。

Demo:code.juejin.cn/api/raw/733… 源码没有了,只能简单写一个demo,拿到改改样式应该就可以用。