大家好,我是南宫,最近我刚完成了一个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领取。