需求背景
工作项目中,需要展示这样一个场景:给一张图片,一组坐标点(可围成闭合区域),展示出来区域高亮,其余部分透明或减弱展示。
实现思路
在 canvas 画布上,用 ctx.drawImage()
把图片放到 canvas 中,接着,通过 ctx.moveTo()
、ctx.lineTo()
等画出‘圆环’路径,然后 ctx.fill()
一个半透明的白色。
关键代码
// 这里要注意图片的加载是异步,所以需要放到 load
事件中执行画图操作;也可以在画区域遮罩之后设置一下 ctx.globalCompositeOperation
为 destination-over
改一下叠加图层的规则,保持在上面,这样异步图片也不会挡住先画的图形了。
const canvas = document.querySelector('.canvas');
const ctx = canvas.getContext('2d');
canvas.width = 400;
canvas.height = 250;
// 通过坐标点画遮罩
function drawMarkerByPoints (points, fillColor = 'rgba(255, 255, 255, 0.7)') {}
// 坐标点
const points = []
const img = new Image();
img.src = 'https://imageUrl';
img.onload = () => {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
drawMarkerByPoints(points);
}
我们看一下画区域遮罩的代码:先围着整个画布,走出来外圈路径,然后再根据坐标点,走出来内圈路径,之后就storke()
、fill()
function drawMarkerByPoints (points, fillColor = 'rgba(255, 255, 255, 0.7)') {
if (!points.length) return;
const { width, height } = canvas;
ctx.beginPath();
// 顺时针走满整个画布
ctx.moveTo(0, 0);
ctx.lineTo(width, 0);
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.lineTo(0, 0);
// points 是二维数组, 如:[[{x: 0.1, y: 0.1}, {x: 0.1, y: 0.7}, {x: 0.7, y: 0.7}, {x: 0.7, 0.1}]]
points.forEach((pointsItem, index) => {
// 画出多边形
ctx.moveTo(pointsItem[0].x * width, pointsItem[0].y * height);
for (let i = 1; i < pointsItem.length; i++) {
ctx.lineTo(pointsItem[i].x * width, pointsItem[i].y * height);
}
ctx.lineTo(pointsItem[0].x * width, pointsItem[0].y * height);
});
ctx.closePath();
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = fillColor;
ctx.fill();
}
理想与现实总有那么点差距
上面的思路没问题,跑demo也正常展示,但是到实际项目中用的时候,这个遮罩高亮区域总是‘随机’出现,有时出来有时不出来,这可就令人费解了。有规律还好,就怕随机的。如下图,高亮区域没出来,整个canvas 都被fill
了。
但 debug 久了,无意中发现了“规律”:一旦 points
连成区域是顺时针的,高亮遮罩区域就没有出来,反之 points
坐标点连成区域顺序是逆时针,就可以展示出来高亮的遮罩区域了!
可以在画内圈路径的时候用这个验证一下:
pointsItem = [...pointsItem].reverse();
在边挠头边查资料之后,bug 定位到了 fill
方法上:
fill
可以指定两种填充算法规则:'nonzero', 'evenodd',默认是 'nonzero'
本来看到这种,都是跳过,懒得去理解非零绕组原则、奇偶绕组原则,因为一眼没看懂。
于是找来相关文章了解一下 fill
的这两个规则,参考这篇文章
在了解了这两个规则之后,我终于知道为什么会随机出现了,咱这个需求的点的来源,是用户点击绘制的,用户可能顺时针也可以逆时针绘制区域,也就是点的顺逆时针是随机的,所以也就导致了上面代码,在绘制的时候,走外圈路径永远是顺时针,而内圈路径可能是顺时针也可能是逆时针,根据非零原则,都是顺时针的时候,中间区域也会被 fill
了,逆时针就不会。
解决方案有二
1. 内圈顺时针判断
既然知道了非零原则下的表现,那么我们只要保证内圈路径在画的时候,时针顺序跟外圈路径是相反的就行,外圈我们固定顺时针,那画内圈路径就逆时针。
...
points.forEach((pointsItem, index) => {
// 判断是否顺时针
if (isClockwise(pointsItem)) {
pointsItem = [...pointsItem].reverse();
}
// 画出多边形
...
});
// 已知多个点,判断顺时针方向
function isClockwise (points) {
let sum = 0;
for (let i = 0; i < points.length; i++) {
const p1 = points[i];
const p2 = points[(i + 1) % points.length];
sum += (p2.x - p1.x) * (p2.y + p1.y);
}
return sum <= 0;
}
顺时针的判断,涉及到一些数学知识,Github Copilot 生成的(真香),没太理解,不过,确实解决了我的问题。
2. 奇偶绕组原则
第二个原则,跟画圈路径的顺逆时针没关系,只跟区域内射出线相交的路径数量有关,奇数就fill
, 偶数就不fill
,那在上述需求场景中,中间高亮区域射出线,跟路径相交的数量永远是2,为偶数,不填充(既高亮),那么指定奇偶绕组原则来解决问题更简单。
...
ctx.fill('evenodd');
...
总结
- 实践是检验真理的唯一标准,没有踩坑,就不会去了解这些填充规则。
- 为什么会有两个方案,是因为我好学喜欢研究么,不是,而是我急着解决问题,只了解了非零原则就跑去debug了,如果我耐心点,就会发现奇偶原则解决起来更简单。