从事前端开发工作有接近五年时间了,平常都是看看各种大佬的文章学习学习,这也是第一次发文章。日常在开发中也有很多可以分享的内容,但由于各种原因(懒癌患者)没有去总结,所以现在也想尝试把自己开发中遇到的问题,解决问题的方法总结一下,加深自己的印象,也给有需要的朋友提供一点帮助。
背景介绍
回归正题,需求背景是因为公司开发了一款基于点阵笔的作业产品,当学生使用点阵笔在特定的卡纸上书写时,可以采集到学生的笔迹以及作答轨迹信息,并把这些信息展示出来,做出一个笔迹回放功能,前端画图嘛,最先想到的肯定是canvas了,安排一波。
后端数据
先来瞅一瞅后端给到的数据,主要就是笔迹点的数组,如下:
{
"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绘图,判断笔画,然后使用直线连接点,绘制页面。实现效果如下:
这个时候大家就会发现一个问题,就是现在绘制的点与点之间是直线,看起来很不协调,这个时候就需要用到我们这篇文章的主题——贝塞尔曲线了。
贝塞尔曲线
我们先简单介绍一下贝塞尔曲线哈......此处省略10000字,哈哈,感兴趣的朋友可以去详细了解一下,这里就不赘述了,有用过PS的朋友应该能理解,PS中的钢笔工具就是使用的贝塞尔曲线,这里就简单给大家放张图:
其实我们不需要了解贝塞尔曲线的具体原理是什么,因为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作为结束点,会导致最后一个点会短一截,效果如下图:
现在看的话是不是圆滑了很多,接近手写的效果了
实现笔迹回放
静态的页面画好了,那下一步就是要实现类似视频播放的动态效果了,具体效果如下:
播放,暂停,进度条,可拖拽进度条修改播放进度,倍速...,再加个弹幕是不是就是一个视频播放器了。
// 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,拿到改改样式应该就可以用。