封装canvas选择区域的组件

大家好,我是南宫,最近我刚完成了一个canvas相关组件的封装。我个人其实很怕canvas和地图,就感觉这里有很复杂的操作,搞不懂,所以这次封装完了以后,决定写一篇博客来记录。

首先我先简单介绍一下这个组件的功能,然后分布介绍思路并列出代码,最后如果有需要的小伙伴,可以联系我获取完整代码。

一、背景和组件的功能

背景:这一次我在做一个新的项目,里面有一个新增编辑的弹窗,弹窗里面有一个字段需要选择区域,我当场就蒙了,然后去找了组长,主要是想问这个项目里面有没有已经写好的现成的组件能直接用的,结果得到了否定的答案,只能我来封装一个了。

然后又经过核对需求,我明白了,我的这个组件不需要支持用鼠标在canvas里面选点画框,而是通过传入的坐标把框渲染上去就好了,但是还需要实现点击选择其中一个区域的功能,也就是点击图片上的一个点,判断是否在框里面,是的话就认为是选中了这个框

我做的效果如下:(图片是什么不重要,框的位置在哪里不重要,这些都是使用组件的时候传进去的)

如图所示,里面现在有两个区域,我当前选择的是区域2。区域2的标记是高亮状态。

二、思路拆解

首先是初始化,初始化canvas,getContext得到上下文对象(废话)。然后drawImage画出图片到画布上,然后则是根据点的坐标画出多个框。(这一步我直接用了组长发给我的画框代码,我没写这个,我只是阅读了以后知道了要传什么参数,然后补充了填充颜色)

(这里我意识到一个问题,我的画面大小是根据父组件实际上的空间来决定的,但是要绘制的图片并不是只有这么大,说明看起来的坐标和实际坐标不一致,坐标发生了缩放。)

第二步是标记出每个区域的名字。这一步是我自己做的。就是找到区域里最高的一个点,在上面绘制一个矩形,矩形里面写上“区域X”。有了这个标记,才能选择区域。

第三步,实现点击选择区域。思路是点击canvas以后,就去获取当前点击位置相对于canvas的坐标(注意缩放),然后挨个判断这个坐标是否在每一个区域里面。(判断点是否在区域里面的方法我是用了这个博客里面的代码,非常感谢原作者 https://www.cnblogs.com/tracyjfly/p/15891591.html )

第四步,获取到所在区域以后的操作。如果当前没有选择过区域,那么直接再绘制一个高亮的区域标记就可以了如果当前已经选择过区域了,而且现在选的不是之前的那个区域,那就不能直接再绘制高亮了,而是要去掉其他标识的高亮才可以

三、每一步的具体实现

① 初始化:

第一步的代码我看过了,大概意思是接收一个data对象,然后获取指定属性的值来作为图片地址,drawImage绘制到canvas中。然后获取data中准备好的坐标数组(每一个元素表示一个框),遍历每一个框的每一个点的坐标,用moveTo和lineTo绘制框

开始新区域的前后记得用beginPath和closePath关闭区域,最后stroke和fill这个区域。

(这一步简单说说,具体看代码就好,下面这个是关键代码,省略了初始化canvas的部分)

if (data.monitorAnalysisRuleList && data.monitorAnalysisRuleList.length > 0) {
  // 多框 布控框
  data.monitorAnalysisRuleList.forEach((e, index) => {
    ctx.strokeStyle = '#f00' //线条颜色
    ctx.fillStyle = 'rgba(255, 0, 0, 0.2)' // 填充颜色
    ctx.lineWidth = 5 //线条粗细
    ctx.beginPath() //绘图开始
    let point = ''
    point = JSON.parse(e.polygon)
    for (let i in point) {
      if (i == 0) {
        ctx.moveTo(point[i][0], point[i][1]) //设置路径起点坐标
      } else {
        ctx.lineTo(point[i][0], point[i][1])
      }
    }
    ctx.closePath()
    ctx.stroke()
    // 补充填充
    ctx.fill()
  })
}

效果是这样的(这里的坐标是我乱写的,画出来成这样了)

② 标记出区域的名字:

为了标记的位置比较正常,所以我让标记出现在区域最高的点的上方,所以是选择y值最小的点

找到这个点以后,稍微移动一下就是绘制标记的地方了。

区域的名字出现在矩形的上方,所以要先画矩形,而矩形的宽度则是比文字更高。这里就需要先设置文字的内容和字体样式,然后测量文字宽度了

然后给文字宽度稍微加一点,作为矩形的宽度,然后微调位置,绘制矩形。(stroke和fill)然后再绘制文字fillText。

/**
  * 绘制区域标签的方法
  * @param {*} ctx 画框的上下文对象
  * @param {*} point 一个框的点坐标集合
  * @param {*} index 当前画的是第几个框
  * @param {*} isActive 是否高亮
  */
drawAreaTag(ctx, point, index, isActive) {
  // 找到最高点,也就是y值最小的点,在这个点的上方写区域的名字
  let minY = point[0][1]
  let minYIndex = 0
  for (let i in point) {
    if (point[i][1] < minY) {
      minY = point[i][1]
      minYIndex = i
    }
  }
  const text = `区域${index + 1}`
  // 设置字体样式和大小
  ctx.font = '20px Arial'
  // 测量文字的宽度
  const textWidth = ctx.measureText(text).width
  const textHeight = 28 // 文字大小为16px,上下留出8px的间距
  const rectWidth = textWidth + 16 // 比文字所占区域稍微宽一些
  const rectHeight = textHeight + 12 // 比文字所占区域稍微宽一些
  // 绘制一个白色矩形,高亮状态为蓝色
  if (isActive) {
    ctx.fillStyle = '#0C84FF'
    ctx.strokeStyle = '#0C84FF'
  } else {
    ctx.fillStyle = 'white'
    ctx.strokeStyle = 'black'
  }
  ctx.lineWidth = 2
  ctx.fillRect(point[minYIndex][0] - 8, point[minYIndex][1] - 55, rectWidth, rectHeight) // 位置可以根据需要调整
  ctx.strokeRect(point[minYIndex][0] - 8, point[minYIndex][1] - 55, rectWidth, rectHeight) // 位置可以根据需要调整
  // 设置文本颜色,高亮状态为白色
  if (isActive) {
    ctx.fillStyle = 'white'
  } else {
    ctx.fillStyle = 'black'
  }
  ctx.fillText(text, point[minYIndex][0], point[minYIndex][1] - 28)
},

我画的矩形默认状态是白底黑边黑字,高亮状态是蓝底蓝边白字。可以根据你的需求修改颜色。

效果如下图:

③ 点击选择区域

这一步算是主流程了吧。

点击选择区域,然后获取到当前点击的位置在canvas中的坐标

具体方法比较绕(知道意思的话可跳过)——点击以后获取点击位置相对于窗口左上角的距离,然后得到canvas左上角相对于窗口左上角的距离,相减得到点击位置相对于canvas的像素位置;计算canvas外观的宽高除以图片具体的宽高,得到一个缩放的比例;最后用相对于canvas的像素位置除以缩放比例,得到相对于canvas当前坐标的坐标。

然后遍历框的数组,用这个坐标来判断是否在每一个框里面,匹配到第一个框就停止,并且得到当前框的下标,匹配不到则是返回-1。

// 点击canvas,获取坐标,转成相对于canvas实际大小的坐标,判断在哪个区域里面,并且进行处理
chooseArea(e) {
  console.log('你好,点击canvas', e.clientX, e.clientY)
  // 点击屏幕的坐标
  const point = { x: e.clientX, y: e.clientY }
  // 获取canvas左上角的坐标
  const canvas = this.$refs.canvas
  const rect = canvas.getBoundingClientRect()
  // console.log('你好,canvas左上角的坐标', rect.left, rect.top)
  const x = point.x - rect.left
  const y = point.y - rect.top
  // console.log('你好,点击的位置相对于canvas', x, y)
  // 计算缩放比例
  const scaleX = this.width / canvas.width
  const scaleY = this.height / canvas.height
  // 得到当前的点相对于canvas实际大小的坐标
  const canvasX = x / scaleX
  const canvasY = y / scaleY
  const pointObj = { x: canvasX, y: canvasY }
  // 比对每一个区域,看看是否在里面
  const area = this.findArea(pointObj)
  console.log('我点击的位置在哪个区域里呢', area)
},
// 把点的坐标转换成对象的格式
convertArray(arr) {
  let result = []
  for (let i = 0; i < arr.length; i++) {
    result.push({ x: arr[i][0], y: arr[i][1] })
  }
  return result
},
//判断点是否在多边形范围内(点坐标,多边形的点坐标集合)
queryPtInPolygon(point, polygon) {
  var p1, p2, p3, p4

  p1 = point
  p2 = { x: 1000000000000, y: point.y }

  var count = 0
  //对每条边都和射线作对比
  for (var i = 0; i < polygon.length - 1; i++) {
    p3 = polygon[i]

    p4 = polygon[i + 1]
    if (checkCross(p1, p2, p3, p4) == true) {
      count++
    }
  }
  p3 = polygon[polygon.length - 1]

  p4 = polygon[0]
  if (checkCross(p1, p2, p3, p4) == true) {
    count++
  }

  return count % 2 == 0 ? false : true

  //判断两条线段是否相交
  function checkCross(p1, p2, p3, p4) {
    var v1 = { x: p1.x - p3.x, y: p1.y - p3.y },
      v2 = { x: p2.x - p3.x, y: p2.y - p3.y },
      v3 = { x: p4.x - p3.x, y: p4.y - p3.y },
      v = crossMul(v1, v3) * crossMul(v2, v3)

    v1 = { x: p3.x - p1.x, y: p3.y - p1.y }
    v2 = { x: p4.x - p1.x, y: p4.y - p1.y }

    v3 = { x: p2.x - p1.x, y: p2.y - p1.y }
    return v <= 0 && crossMul(v1, v3) * crossMul(v2, v3) <= 0 ? true : false
  }

  //计算向量叉乘
  function crossMul(v1, v2) {
    return v1.x * v2.y - v1.y * v2.x
  }
},
// 判断当前的点是否在任意一个区域内(
findArea(pointObj) {
  let flag = -1
  for (let index = 0; index < this.data.monitorAnalysisRuleList.length; index++) {
    const polygon = this.convertArray(JSON.parse(this.data.monitorAnalysisRuleList[index].polygon))
    // console.log(`区域${index + 1}的坐标`, polygon)
    var pts = this.queryPtInPolygon(pointObj, polygon)
    // console.log(`请问当前的点击位置是否在区域${index + 1}里面`, pts)
    if (pts == true) {
      flag = index
      break
    }
  }
  return flag
},

④ 获取到区域以后的操作

这一步属于主流程的一部分,但是为了避免主流程太多内容,我把这一部分写到另一个方法里面了。

先判断是否已经选择过区域了,如果否就直接绘制一个高亮的区域标记,覆盖在原来的地方即可。

如果是,那就初始化canvas,重新画过(绘制图片,绘制框,绘制区域标记这些)

// 找到区域后的处理(area是比对到的点击位置所在的区域下标)
handleDrawArea(area) {
  // 大于-1就认为有在区域里面,记录一下当前选择了区域几
  if (area > -1) {
    // 如果是第一次选择,直接记录
    if (this.currentAreaIndex == -1) {
      this.currentAreaIndex = area
      const pointList = JSON.parse(this.data.monitorAnalysisRuleList[this.currentAreaIndex].polygon)
      this.drawAreaTag(this.ctx, pointList, this.currentAreaIndex, true)
    } else {
      // 判断是否跟当前选择的area相同,是就不做处理
      if (this.currentAreaIndex == area) {
        // console.log('你好,当前的选择的区域是第几个', this.currentAreaIndex)
        return
      } else {
        // 否就赋值,并且重新画过
        this.currentAreaIndex = area
        // console.log('你好,当前的选择的区域是第几个', this.currentAreaIndex)
        // 需要重新画过(控制高亮的逻辑写到初始化canvas方法里了)
        this.setDrawImage(this.data, 0, 'canvas', false, 'sceneUrl')
      }
    }
  }
},

这个时候就可以实现点击单选区域了。

四、优化

到这里还没完,为了让这个成为一个可以使用的组件,我明确了这个组件的输入和输出(输入包括data对象(坐标和图片地址等信息),输出则是当前选择的框的坐标集合(根据当前选择的是第几个,就返回第几个的坐标集合给父组件))

这个时候还有一个地方要优化的——初始化canvas的时候有一个clearRect的步骤,会清空整个画布,然后再绘制,所以点击选择区域的时候会有白光一闪的效果

我查了资料以后,决定用一个临时canvas优化掉这个问题。

经过一番纠结和思考以后,我的想法如下:

准备一个临时canvas,不显示出来,仅仅是用来存放内容。

临时canvas不需要每一步都跟着画,只需要初始化的时候绘制图片和不带高亮的区域标记即可。

当主要的canvas点击以后需要重新绘制的时候,就不需要清空画布了,直接覆盖一层临时canvas的内容,然后再绘制一个高亮标记就可以了。

(以下是处理部分的修改,另外要写一个初始化临时canvas的函数,初始化的时候也调用一下)

// 找到区域后的处理(area是比对到的点击位置所在的区域下标)
handleDrawArea(area) {
  // 大于-1就认为有在区域里面,记录一下当前选择了区域几
  if (area > -1) {
    // 如果是第一次选择,直接记录
    if (this.currentAreaIndex == -1) {
      this.currentAreaIndex = area
      const pointList = JSON.parse(this.data.monitorAnalysisRuleList[this.currentAreaIndex].polygon)
      this.drawAreaTag(this.ctx, pointList, this.currentAreaIndex, true)
    } else {
      // 判断是否跟当前选择的area相同,是就不做处理
      if (this.currentAreaIndex == area) {
        // console.log('你好,当前的选择的区域是第几个', this.currentAreaIndex)
        return
      } else {
        // 否就赋值,并且重新画过
        this.currentAreaIndex = area
        // console.log('你好,当前的选择的区域是第几个', this.currentAreaIndex)
        // 需要重新画过(控制高亮的逻辑写到初始化canvas方法里了)
        // 如果有临时canvas
        if (this.tempUrl) {
          // 用临时canvas的的结果初始化canvas
          this.ctx.drawImage(this.tempCanvas, 0, 0)
          // 计算是要在哪里绘制标记
          const pointList = JSON.parse(this.data.monitorAnalysisRuleList[this.currentAreaIndex].polygon)
          // 绘制高亮的标记
          this.drawAreaTag(this.ctx, pointList, this.currentAreaIndex, true)
        } else {
          this.setDrawImage(this.data, 0, 'canvas', false, 'sceneUrl')
        }
      }
    }
  }
},

这样操作下来,就没有白光的问题了,效果丝滑,本人亲测。

如果需要完整代码,联系本人QQ:807026100领取。

你可能感兴趣的:(vue.js,前端,javascript,canva可画)