JS利用原生canvas实现图形标注功能
作者:一枕槐安
由于工作需要,项目中要求实现一个功能—在视频或者图片上进行图形标注,支持矩形、多边形、线段、圆形、折线,已绘制的图形可以进行缩放,移动。
完整功能源代码在这个仓库,感兴趣的可以clone下来跑一下。
接下来我将实现一个dmeo来展示其中最简单的图形—矩形的创建。
初始化页面
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <style> body { margin: 0; padding: 0; height: 100vh; display: flex; align-items: center; justify-content: center; } canvas { border: 1px solid saddlebrown; background-color: beige; } </style> </head> <body> <canvas id="canvasIdChart" class="canvas">您的浏览器不支持canvas1元素</canvas> <script src="./drawClass.js"></script> <script> const chart = new Chart('canvasIdChart') </script> </body> </html>
该页面只有一个canvas,而功能的实现在drawClass里面,这里我实现了一个Chart类,需要传入一个canvas的id,表示后续的绘制功能都是在这个canvas上实现的。
创建基础类Chart
首先梳理下要实现一个图形标注需要涉及到什么事件
用户在canvas上按下鼠标,说明用户想要开始画一个矩形进行标注了,所以canvas上需要绑定一个onmousedown
事件。相应的绘制过程也涉及到了onmousemove
事件,而用户松开鼠标表示绘制已完成,即需要onmouseup
事件。同时为了兼容这样一种情况—用户在绘制过程中鼠标移出了canvas的范围(此时的鼠标事件都是绑定在canvas上的),这种情况无法触发canvas的onmouseup
事件,所以为canvas加上onmouseout
,鼠标移出默认代表绘制完成。
初始化代码如下
class Chart { constructor(canvasId) { this.cvs = document.getElementById(canvasId) this.ctx = this.cvs.getContext('2d') this.shapes = [] // 保存图形数据的数组 this.init() // 初始化canvas得宽高 this.bindEvent() // 为canvas绑定鼠标事件 this.draw() // canvas的绘制 this.isClickDown = false // 当前鼠标是否按下 this.currentShape = null // 当前选中的图形 } init() { const w = 1000, h = 600 this.cvs.width = w this.cvs.height = h } bindEvent() { this.cvs.onmousedown = (e) => { this.isClickDown = true // 鼠标按下的时候创建其他鼠标事件 that.cvs.onmousemove = function (e){} that.cvs.onmouseup = function (e){} that.cvs.onmouseout = function (e){} } } draw() {} }
为了后续拓展,我将各种图形封装成一个个类,如矩形封装成class **Rectangle
。这样做的好处是我们只需要规定这样的图形类内部都有某些属性和方法给Chart中的draw调用就行,比如我们可以把绘制矩形的实现写在Rectangle
类的draw函数上,而在Chart中的draw上只需调用new Rectangle().draw()
即可。要注意的是,draw方法是随着mousemove而要不断的刷新canvas重新绘制的,此时最好使用requestAnimationFrame
方法让浏览在合适的时机调用draw。此时Chart的draw方法如下:**
class Chart { .... draw() { requestAnimationFrame(this.draw.bind(this)) this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height) // 首先清空canvas // 将shapes里面的图形重新绘制 for (const s of this.shapes) { s.draw() } } }
实现Rectangle类
首先看一下矩形需要的几点要素
- 填充的颜色
- 矩形的初始位置(即鼠标按下的位置)
- 矩形的终点位置(即鼠标抬起的位置,也是鼠标移动的最后位置)
- 矩形需要绘制在哪里(此时需要绘制在canvas上)
- 矩形的初始位置可能会大于终点位置(鼠标按下后往左上角绘制),此时可用class语法中的get方法获取minX、maxX、minY、maxY
- 因为同类几何图形的实例可能会有很多,所以每个实例需要一个id
初始代码如下:
/** * 生成唯一ID * @param {Number} length 生成id的长度 * @returns */ const genID = (length = 3) => { return Number(Math.random().toString().substr(3, length) + Date.now()).toString(36) } class Rectangle { constructor(el,color, startX, startY) { this.el = el // 保存需要绘制的载体 this.shapeType = 'RECT' // 当前几何图形的类别 this.color = color // 填充的颜色 // 初始化起点,默认起点和终点一样 this.startX = startX this.startY = startY this.endX = startX this.endY = startY this.action = 'CREATE' // 当前的操作类型 this.id = genID() // 当前实例的唯一id } get minX() { return Math.min(this.startX, this.endX) } get maxX() { return Math.max(this.startX, this.endX) } get minY() { return Math.min(this.startY, this.endY) } get maxY() { return Math.max(this.startY, this.endY) } draw() {} }
实现Rectangle类的draw函数 幸运的是canvas得API中已经有了绘制矩形的方法,不用我们一条线一条线的自己画了。该方法是 canvas.rect(startX,startY,width,height)
四个参数分别是矩形的起点横坐标和纵坐标,矩形的宽高,这也是为什么需要minX和minY。代码如下:
class Rectangle { ... draw() { this.el.beginPath() // 画笔起始点 this.el.rect( this.minX, this.minY, (this.maxX - this.minX), (this.maxY - this.minY) ) this.el.fillStyle = this.color // 填充颜色 this.el.fill() this.el.strokeStyle = '#fff' // 边框的颜色 this.el.lineCap = 'square' this.el.lineWidth = 3 // 边框粗细 this.el.stroke() // 画笔终点 } }
判断鼠标是否点击在矩形内部,用来判断是新建还是拖动矩形。 判断一个坐标点是否落在矩形的内部很好判断,就是通过minX、minY、maxX、maxY和传入的x、y对比就可以了
class Rectangle { ... isInside(x, y) { return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY } }
实现鼠标事件
在鼠标事件中,获取鼠标的坐标点属性有好几种,例如clientX/screenX/offsetX/pageX等,如需分别这几个属性的不同,可参考此篇文章,目前我用的是offsetX,因为这个属性是获取相对于事件对象的位置,此时事件对象就是canvas。
onmousedown事件 在mousedown事件我们需要做两件事
判断鼠标落点是否在已有矩形的内部,如果有就是当前矩形的拖拽事件,如果不是则是新建矩形。 判断是否落在集合图形的内部前面我们在几何图形类中已经约定好了一个isInside
方法,此时只需遍历shapes
数组,依次调用每一项的isInside
方法就好。
... // 遍历数组 getShapes(x, y) { for (let i = this.shapes.length - 1; i >= 0; i--) { const s = this.shapes[i] if (s.isInside(x, y)) { return s } } return null }
如果返回的是null,则进入新建逻辑。新建一个矩形的操作很简单,就是创建一个Rectangle的实例,new Rectangle(...)
并把new出来的实例添加进shapes数组中。并且此时的onmousemove事件也很简单,只需把实例中的endX、endY修改成当前move的坐标点。
// 新建 const shape = new Rectangle(that.ctx, '#f00', clickX, clickY) that.shapes.push(shape) that.cvs.onmousemove = function (e) { shape.endX = e.offsetX shape.endY = e.offsetY }
此时如果返回的不是null,说明找到了当前鼠标点击的图形,执行的是拖拽逻辑。而拖拽逻辑需要计算矩形被拖拽的偏移量,如下图所示:
此时该矩形的四个坐标都要进行相应的同步修改,并且还要判断是否移动超出了边界。
完整代码如下所示:
class Chart{ ... bindEvent() { const that = this this.cvs.onmousedown = function (e) { this.isClickDown = true const [clickX, clickY] = [e.offsetX, e.offsetY] const shape = that.getShapes(clickX, clickY) if (shape) { // 如果找到图形,说明是拖拽 shape.action = 'MOVE' const { startX, startY, endX, endY } = shape that.cvs.onmousemove = function (e) { const disX = e.offsetX - clickX const disY = e.offsetY - clickY const newStartX = startX + disX const newEndX = endX + disX const newStartY = startY + disY const newEndY = endY + disY // 判断是否超出边界(矩形) if (newStartX < 0 || newEndX > that.cvs.width || newStartY < 0 || newEndY > that.cvs.height) { return } shape.startX = newStartX shape.endX = newEndX shape.startY = newStartY shape.endY = newEndY } } else { // 没找到,则是新建图形 const shape = new Rectangle(that.ctx, '#f00', clickX, clickY) that.shapes.push(shape) that.cvs.onmousemove = function (e) { shape.endX = e.offsetX shape.endY = e.offsetY } } } getShapes(x, y) { for (let i = this.shapes.length - 1; i >= 0; i--) { const s = this.shapes[i] if (s.isInside(x, y)) { return s } } return null } }
鼠标按下初始化onmousemove和onmouseup等事件。 onmousemove、onmouseup和onmouseout事件其实很简单,只需要取消鼠标移动事件和鼠标抬起事件即可
that.cvs.onmouseup = function () { this.isClickDown = false that.cvs.onmousemove = null that.cvs.onmouseup = null } that.cvs.onmouseout = function () { this.isClickDown = false that.cvs.onmousemove = null that.cvs.onmouseup = null }
最终代码
此时,一个矩形的简单绘制和拖拽就完成了,后续的缩放需要的还可以单独开一篇文章讲讲。按照这种方式,其他几何图形我们可以新建相对应地类就行了,拓展起来就很方便了。
完整代码
// drawClass.js /** * 生成唯一ID * @param {Number} length 生成id的长度 * @returns */ const genID = (length = 3) => { return Number(Math.random().toString().substr(3, length) + Date.now()).toString(36) } class Chart { constructor(canvasId) { this.cvs = document.getElementById(canvasId) this.ctx = this.cvs.getContext('2d') this.shapes = [] // 保存图形数据的数组 this.init() // 初始化canvas得宽高 this.bindEvent() // 为canvas绑定鼠标事件 this.draw() // canvas的绘制 this.isClickDown = false // 当前鼠标是否按下 this.isPolygon = false this.currentShape = null // 当前选中的图形 } init() { const w = 1000, h = 600 this.cvs.width = w this.cvs.height = h } bindEvent() { const that = this this.cvs.onmousedown = function (e) { this.isClickDown = true const [clickX, clickY] = [e.offsetX, e.offsetY] const shape = that.getShapes(clickX, clickY) if (shape) { // 如果找到图形,说明是拖拽 shape.action = 'MOVE' const { startX, startY, endX, endY } = shape that.cvs.onmousemove = function (e) { const disX = e.offsetX - clickX const disY = e.offsetY - clickY const newStartX = startX + disX const newEndX = endX + disX const newStartY = startY + disY const newEndY = endY + disY // 判断是否超出边界(矩形) if (newStartX < 0 || newEndX > that.cvs.width || newStartY < 0 || newEndY > that.cvs.height) { return } shape.startX = newStartX shape.endX = newEndX shape.startY = newStartY shape.endY = newEndY } } else { // 没找到,则是新建图形 const shape = new Rectangle(that.ctx, '#f00', clickX, clickY) that.shapes.push(shape) that.cvs.onmousemove = function (e) { shape.endX = e.offsetX shape.endY = e.offsetY const radius = Math.sqrt( Math.pow(e.offsetX - clickX, 2) + Math.pow(e.offsetY - clickY, 2) ) shape.radius = radius } } that.cvs.onmouseup = function () { this.isClickDown = false that.cvs.onmousemove = null that.cvs.onmouseup = null } that.cvs.onmouseout = function () { this.isClickDown = false that.cvs.onmousemove = null that.cvs.onmouseup = null } } } getShapes(x, y) { for (let i = this.shapes.length - 1; i >= 0; i--) { const s = this.shapes[i] if (s.isInside(x, y)) { return s } } return null } draw() { requestAnimationFrame(this.draw.bind(this)) this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height) // 首先清空canvas // 将shapes里面的图形重新绘制 for (const s of this.shapes) { s.draw() } } } class Rectangle { constructor(el,color, startX, startY) { this.el = el // 保存需要绘制的载体 this.shapeType = 'RECT' // 当前几何图形的类别 this.color = color // 填充的颜色 // 初始化起点,默认起点和终点一样 this.startX = startX this.startY = startY this.endX = startX this.endY = startY this.action = 'CREATE' // 当前的操作类型 this.id = genID() // 当前实例的唯一id } get minX() { return Math.min(this.startX, this.endX) } get maxX() { return Math.max(this.startX, this.endX) } get minY() { return Math.min(this.startY, this.endY) } get maxY() { return Math.max(this.startY, this.endY) } draw() { this.el.beginPath() // 画笔起始点 this.el.rect( this.minX, this.minY, (this.maxX - this.minX), (this.maxY - this.minY) ) this.el.fillStyle = this.color // 填充颜色 this.el.fill() this.el.strokeStyle = '#fff' // 边框的颜色 this.el.lineCap = 'square' this.el.lineWidth = 3 // 边框粗细 this.el.stroke() //画笔的终点 } isInside(x, y) { return x >= this.minX && x <= this.maxX && y >= this.minY && y <= this.maxY } }
到此这篇关于JS利用原生canvas实现图形标注功能的文章就介绍到这了,更多相关JS canvas图形标注内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!