背景
今天在写代码的时候遇到一个场景,在一个封闭图形顶点已知的情况下判断点击时是否点击在图形内部。可能在算法端有不少解决方案,但从一个前端的角度交互实现,第一反应没有很好的手段,于是借鉴封闭图形的围成线段与点之间的关系,通过射线与线段相交的点位数量来判断是否点击的位置是否在图形内。(如果在图形内部,给予高亮反馈)
图形绘制
由于项目场景和图片识别相关,前端获取的数据是二维数组下的像素点,类似以下结构:
js
const vertexs = [
[100, 200],
[150, 300],
[240, 300],
// ...
]
其中数组中的数据表示在x,y轴像素坐标。首先利用canvas将其绘制在页面上, 由于技术架构当时选用的React,这边也先用React的语法糖表述
jsx
import React from 'react';
let canvas, ctx;
export default class Index extends React.Component() {
componentDidMount():void {
// 初始化画布信息
canvas = document.getElementById('containerCanvas');
ctx = canvas.getContext('2d');
// 绘制封闭图形
this.drawEnclosedGraph();
}
drawEnclosedGraph = () => {
// 这里的vertexs是顶点数据的来源
const { vertexs = [] } = this.props;
// 封闭图形最少需要三个顶点坐标
if(Array.isArray(vertexs) && vertexs.length > 2) {
ctx.beginPath();
ctx.moveTo(vertexs[0][0], vertexs[0][1]);
for(let i = 1; i < vertexs.length; i++) {
ctx.lineTo(vertexs[i][0], vertexs[i][1]);
}
ctx.closePath();
ctx.lineWidth = 2;
ctx.strokeStyle = 'orange';
ctx.stroke();
} else {
console.error('err');
}
}
render() {
// 画布的宽高在我的实际场景下是涉及顶点信息识别地图的尺寸,需比例尺与底图的处理转化,与本文想叙述的关系不大,先设为1000/800
return <canvas id="containerCanvas" width={1000} height={800}/>
}
}
通过上述 drawEnclosedGraph
函数可以将顶点坐标 vertexs
下的数据渲染在画布中
事件监听
通过点击事件的监听,来获取点击的坐标位置 x,y值,因为我这边的图形操作较多,在实现上做了类似事件委托的方式去触发这个click事件,方式是在canvas的上层添加了一个蒙层用于触发点击事件,获取点击位置后将x,y值向该canvas组件传递的方式,代码如下
js
clickMask = (e) => {
const getAbsLeft = (obj) => {
let l = obj.offsetLeft;
while(obj.offsetParent != null) {
obj = obj.offsetParent;
l += obj.offsetLeft;
}
return l;
}
const getAbsTop = (obj) => {
let top = obj.offsetTop;
while(obj.offsetParent != null) {
obj = obj.offsetParent;
top += obj.offsetTop;
}
return top;
}
const getAbsScrollD = (obj) => {
let scrollToTop = obj.scrollTop;
let scrollToLeft = obj.scrollLeft;
while(obj.parentElement != null) {
obj = obj.parentElement;
scrollToTop += obj.scrollTop;
scrollToLeft += obj.scrollLeft;
}
return {
scrollToTop,
scrollToLeft,
};
}
const maskDiv = e.target;
// 由于页面元素排序,或者滚动条滚动会对点击位置产生影响,这边通过上述函数作出补偿,保证点击位置不受滚动条影响
const scrollD = getAbsScrollD(maskDiv);
const divToTop = getAbsTop(maskDiv);
const divToLeft = getAbsLeft(maskDiv);
const divScrollTop = scrollD['scrollToTop'] || 0;
const divScrollLeft = scrollD['scrollToLeft'] || 0;
const mouseX = e.clientX;
const mouseY = e.clientY;
const clickInMapX = mouseX - divToLeft + divScrollLeft;
// 这里的800是画布高度,如果场景中是变量,也可以通过clientHeight去获取container高度
const clickInMapY = 800 - (mouseY - divToTop) - divScrollTop;
this.isClickGraph({
x: clickInMapX,
y: clickInMapY
});
}
点击结果判断
通过成功获取点击的XY值后,我们可以开始判断点击位置是否在图形内部,这边主要用的方式是将相邻顶点坐标转化成一元一次函数的表述方式,再通过判断点击的位置向右延伸的射线与所有线段相交的数量是否为奇数来判断点是否点击位置在图形内部,代码如下:
js
const transVertexs2Lines = () => {
const { vertexs = [] } = this.props;
if(Array.isArray(vertexs) && vertexs.length > 2) {
const lines = vertexs.map((curP, idx) => {
const nextP = (idx === vertexs.length - 1) ? vertexs[0] : vertexs[idx + 1];
const x1 = curP[0],
y1 = curP[1],
x2 = nextP[0],
y2 = nextP[1];
const lineRange = {
yMax: Math.max(y1, y2),
xMax: Math.max(x1, x2),
yMin: Math.min(y1, y2),
xMin: Math.min(x1, x2),
};
if(x1 === x2) {
return {
k: 'empty',
b: 'empty',
...lineRange
}
} else if(y1 === y2) {
return {
k: 0,
b: y1,
...lineRange,
}
} else {
const k = ((y1 - y2) / (x1 - x2)).toFixed(2);
const b = y1 - (k * x1);
return {
k,
b,
...lineRange,
}
}
})
this.setState({
linesInfo: lines
})
} else {
console.error('err');
}
}
const isClickGraph = (clickLocation = {x: 0, y: 0}) => {
const { linesInfo = [] } = this.state;
let interSectionNum = 0;
Array.isArray(linesInfo) && linesInfo.forEach(eachLine => {
const {
k,b,
xMin,
xMax,
yMin,
yMax,
} = eachLine;
if(k === 0) {
// 无交点
} else if(k === 'empty') {
if(x < xMin && y < yMax && y > yMin) {
interSectionNum++;
}
} else {
if(y > yMin && y < yMax) {
const secX = ((y - b) / k).toFixed(2);
if(secX > x) {
interSectionNum++;
}
}
}
})
if(interSectionNum > 0 && interSectionNum % 2 === 1) {
return true;
} else {
return false;
}
}
通过上述代码中的 isClickGraph
函数可以判断出点击位置是否在图形内部,为了方便理解,下图简单解释了该算法的丑陋图片
当相交点位为奇数个时,为在图形内部。好了,希望这篇文章能对你在遇到该类场景时有思路上的启发,作者小菜轻喷。