利用原生canvas实现图形标注功能

利用原生canvas实现图形标注功能

由于工作需要,项目中要求实现一个功能---在视频或者图片上进行图形标注,支持矩形、多边形、线段、圆形、折线,已绘制的图形可以进行缩放,移动。

完整功能源代码在这个仓库,感兴趣的可以clone下来跑一下。

接下来我将实现一个dmeo来展示其中最简单的图形---矩形的创建。

初始化页面

html 复制代码
<!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,鼠标移出默认代表绘制完成。

  • 初始化代码如下

    js 复制代码
    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方法如下:**

    js 复制代码
    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类

  • 首先看一下矩形需要的几点要素

    1. 填充的颜色
    2. 矩形的初始位置(即鼠标按下的位置)
    3. 矩形的终点位置(即鼠标抬起的位置,也是鼠标移动的最后位置)
    4. 矩形需要绘制在哪里(此时需要绘制在canvas上)
    5. 矩形的初始位置可能会大于终点位置(鼠标按下后往左上角绘制),此时可用class语法中的get方法获取minX、maxX、minY、maxY
    6. 因为同类几何图形的实例可能会有很多,所以每个实例需要一个id

    初始代码如下:

    js 复制代码
    /**
     * 生成唯一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。代码如下:

    js 复制代码
    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对比就可以了

    js 复制代码
    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事件我们需要做两件事

    1. 判断鼠标落点是否在已有矩形的内部,如果有就是当前矩形的拖拽事件,如果不是则是新建矩形。 判断是否落在集合图形的内部前面我们在几何图形类中已经约定好了一个isInside方法,此时只需遍历shapes数组,依次调用每一项的isInside 方法就好。

      js 复制代码
      ...
      // 遍历数组
      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的坐标点。

      js 复制代码
      // 新建
      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,说明找到了当前鼠标点击的图形,执行的是拖拽逻辑。而拖拽逻辑需要计算矩形被拖拽的偏移量,如下图所示:

      此时该矩形的四个坐标都要进行相应的同步修改,并且还要判断是否移动超出了边界。

      完整代码如下所示:

      js 复制代码
      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
        }
      }
    2. 鼠标按下初始化onmousemove和onmouseup等事件。 onmousemove、onmouseup和onmouseout事件其实很简单,只需要取消鼠标移动事件和鼠标抬起事件即可

    jsx 复制代码
        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
        }

最终代码

此时,一个矩形的简单绘制和拖拽就完成了,后续的缩放需要的还可以单独开一篇文章讲讲。按照这种方式,其他几何图形我们可以新建相对应地类就行了,拓展起来就很方便了。

完整代码

js 复制代码
// 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
  }
}

希望各位大佬点点赞收藏一下谢谢啦!👋👋👋👋

相关推荐
一路向前的月光42 分钟前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   43 分钟前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web1 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
Jiaberrr2 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
安冬的码畜日常4 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ4 小时前
html+css+js实现step进度条效果
javascript·css·html
john_hjy4 小时前
11. 异步编程
运维·服务器·javascript
风清扬_jd5 小时前
Chromium 中JavaScript Fetch API接口c++代码实现(二)
javascript·c++·chrome
yanlele5 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范