首页 > 技术文章 > 【译文】HTML5 Canvas的点击区域检测以及如何监听Canvas上各种图形的点击事件

hanshuai 2021-02-20 09:39 原文

原文地址:Hit Region Detection For HTML5 Canvas And How To Listen To Click Events On Canvas Shapes  作者:Anton Lavrenov

你是否需要一个在Canvas画布上的任意图形的点击事件监听(译者注:类似于任意一个DOM元素的点击监听事件)?但是Canvas没有此类监听器的API。你只能在整个Canvas画布上进行事件监听,而不是在画布上的任意一个元素。我将描述2种方法来解决这个问题。

注意!我将不会使用 addHitRegion API ,因为现在(作者写文章时间为2017年)这个api是不稳定的,并且并没有被完整的支持。但是你可以了解下。

让我们从简单的canvas画布图形开始。假设我们在一个页面上绘制了几个圆圈(circle)。

const canvas = document.getElementById('canvas');

const ctx = canvas.getContext('2d');

const circles = [
  {
    x: 40,
    y: 40,
    radius: 10,
    color: 'rgb(255,0,0)'
  },
  {
    x: 70,
    y: 70,
    radius: 10,
    color: 'rgb(0,255,0)'
  }
];

circles.forEach(circle => {
  ctx.beginPath();
  ctx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
  ctx.fillStyle = circle.color;
  ctx.fill();
});

效果如下:

现在我们只能简单在整个Canvas画布上监听点击事件:

canvas.addEventListener('click', () => {
   console.log('canvas click');
});

但是我们想监听其中人一个圆圈的点击事件。我们该怎么做?怎么检测到我们点击了其中的一个圆圈?

方式1 - 利用数学的力量

当拥有圆圈的坐标和尺寸(半径)信息,我们可以利用数学方式通过简单计算来检测在任意一个圆圈上的点击。我们所需要的就是获取到鼠标点击位置的坐标信息,并且跟所有的圆圈逐一进行相交检测:

function isIntersect(point, circle) {
  return Math.sqrt((point.x-circle.x) ** 2 + (point.y - circle.y) ** 2) < circle.radius;
}

canvas.addEventListener('click', (e) => {
  const pos = {
    x: e.clientX,
    y: e.clientY
  };
  circles.forEach(circle => {
    if (isIntersect(mousePoint, circle)) {
      alert('click on circle: ' + circle.id);
    }
  });
});

这种方式非常普遍,并在许多项目中广泛使用。你可以轻松找到更加复杂集合图形的数学函数,比如矩形,椭圆,多边形。。。

这种方式非常棒,当你的画布上没有大量的图形时,他可能是非常快的。

但是这种方式很难处理那些非常复杂的几何图形。比如说,你正在使用具有二次曲线的线。

方式2 - 模拟点击区域

点击区域的想法很简单 - 我们只需要获取点击区域的像素,并且找到拥有相同颜色的图形即可。

function hasSameColor(color, circle) {
  return circle.color === color;
}

canvas.addEventListener('click', (e) => {
  const mousePos = {
    x: e.clientX - canvas.offsetTop,
    y: e.clientY - canvas.offsetLeft
  };
  // get pixel under cursor
  const pixel = ctx.getImageData(mousePos.x, mousePos.y, 1, 1).data;

  // create rgb color for that pixel
  const color = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`;

  // find a circle with the same colour
  circles.forEach(circle => {
    if (hasSameColor(color, circle)) {
      alert('click on circle: ' + circle.id);
    }
  });
 });

但是这种方式可能无效,因为不同的图形可能拥有相同的颜色。为了避免这种问题,我们应该创建一个'点击图形'
canvas画布.它将跟主canvas画布拥有几乎相同的图形,并且每一个图形都拥有唯一的颜色。因此我们需要对每一个圆圈生成随机的颜色。

// colorsHash for saving references of all created circles
const colorsHash = {};

function getRandomColor() {
 const r = Math.round(Math.random() * 255);
 const g = Math.round(Math.random() * 255);
 const b = Math.round(Math.random() * 255);
 return `rgb(${r},${g},${b})`;
}



const circles = [{
  id: '1', x: 40, y: 40, radius: 10, color: 'rgb(255,0,0)'
}, {
  id: '2', x: 100, y: 70, radius: 10, color: 'rgb(0,255,0)'
}];

// generate unique colors
circles.forEach(circle => {
  // repeat until we find trully unique colour
  while(true) {
     const colorKey = getRandomColor();
     // if colours is unique
     if (!colorsHash[colorKey]) {
        // set color for hit canvas
        circle.colorKey = colorKey;
        // save reference 
        colorsHash[colorKey] = circle;
        return;
     }
  }
});

然后,我们需要绘制每个图形2次。第一次在主画布上(可见的),然后在'点击'画布上(不可见)。

circles.forEach(circle => {
  // draw on "scene" canvas first
  ctx.beginPath();
  ctx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
  ctx.fillStyle = circle.color;
  ctx.fill();
  
  // then draw on offscren "hit" canvas
  hitCtx.beginPath();
  hitCtx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
  hitCtx.fillStyle = circle.colorKey;
  hitCtx.fill();
});

现在当你点击主canvas时,你需要做的就是获取到你点击处的一个像素,然后在'点击'canvas上找到跟主cavnas同样位置的一像素的颜色。

实例代码如下:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const hitCanvas = document.createElement('canvas');
const hitCtx = hitCanvas.getContext('2d');

const colorsHash = {};

function getRandomColor() {
 const r = Math.round(Math.random() * 255);
 const g = Math.round(Math.random() * 255);
 const b = Math.round(Math.random() * 255);
 return `rgb(${r},${g},${b})`;
}



const circles = [{
  id: '1', x: 40, y: 40, radius: 10, color: 'rgb(255,0,0)'
}, {
  id: '2', x: 100, y: 70, radius: 10, color: 'rgb(0,255,0)'
}];

circles.forEach(circle => {
  while(true) {
     const colorKey = getRandomColor();
     if (!colorsHash[colorKey]) {
        circle.colorKey = colorKey;
        colorsHash[colorKey] = circle;
        return;
     }
  }
});

circles.forEach(circle => {
  ctx.beginPath();
  ctx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
  ctx.fillStyle = circle.color;
  ctx.fill();
  
  hitCtx.beginPath();
  hitCtx.arc(circle.x, circle.y, circle.radius, 0, 2 * Math.PI, false);
  hitCtx.fillStyle = circle.colorKey;
  hitCtx.fill();
});

function hasSameColor(color, shape) {
  return shape.color === color;
}

canvas.addEventListener('click', (e) => {
  const mousePos = {
    x: e.clientX - canvas.offsetLeft,
    y: e.clientY - canvas.offsetTop
  };
  const pixel = hitCtx.getImageData(mousePos.x, mousePos.y, 1, 1).data;
  const color = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`;
  const shape = colorsHash[color];
  if (shape) {
     alert('click on circle: ' + shape.id);
  }
});

那种方式更好?

这需要视情况而定。第二种方式最主要的瓶颈在于你需要绘制2次。因此性能可能下降2倍!但是我们可以简化hitCanvas的绘制。你可以跳过shadows或者strokes绘制,你可以简化很多图形,比如,用矩形来代替文本。简化绘制后的方式可能是非常快的。因为从canvas上去一像素和从一个颜色hash对象(colorsHash)中取值是非常快的操作。

它们能一起使用吗?

当然。一些canvas库就是结合了上边2种方式。它们以这种方式工作:

对于每一个图形,你必须计算简化后的矩形边界(x,y, width, height)。然后你使用第一种方式计算点击位置和边界矩形的相交来筛选相关的图形。然后,你可以绘制hitcanvas,并且用第二种方式来检测相交,从而得到更加准确的结果。

为什么不使用SVG?

因为有时候Canvas可以表现得更好,更适合您的高级任务。当然,这取决于任务。因此Canvas VS SVG不在本文讨论的范围内。当你使用canvas并且进行点击检测你必学使用一些东西,不是吗?

其他事件如何检测?比如mousemove,mouseenter等等?

您只需要在以上描述的方法中添加一些额外的代码。一旦你可以100%检测到鼠标下方的图形,你就可以模拟所有其他事件。

有什么好的开箱即用的解决方案么?

当然。只需要去google搜索 html5 canvas framework。但是我个人推荐 http://konvajs.github.io/.。我几乎忘记了,我是这个库的维护者。Konva只使用了第二种方式,支持所有我们经常在dom元素上的mouse 和touch事件(还有更多,比如drag和drop)。

推荐阅读