El problema del hit testing: un elemento y ningún objetivo
Entender por qué el canvas no tiene eventos por objeto, convertir la posición del puntero al espacio correcto, y elegir entre las tres técnicas de detección con un criterio.
Un elemento, dos números
El canvas es una hoja de píxeles sin estructura. El navegador no sabe que ahí dentro hay figuras, porque nunca hubo figuras: hubo llamadas que dejaron color, según el modelo inmediato que ya conoces por la lección del modo inmediato. De esa amnesia se deriva todo lo demás: no hay target, no hay :hover, no hay cursor: pointer por objeto, no hay foco, no hay orden de tabulación y no hay burbujeo.
Lo único que llega es un evento de puntero sobre el elemento con coordenadas en píxeles de CSS relativas a la ventana. Convertirlas a algo utilizable exige tres pasos, y el tercero se olvida siempre.
function puntoDelEvento(canvas, evento) { const caja = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; // 1. De la ventana a la caja del elemento const xCss = evento.clientX - caja.left; const yCss = evento.clientY - caja.top; // 2. De la caja CSS al bufer, por si el CSS lo estira const escalaX = canvas.width / caja.width; const escalaY = canvas.height / caja.height; return { x: xCss * escalaX, y: yCss * escalaY, dpr };}El paso dos merece atención. canvas.width es el tamaño del búfer y caja.width es el tamaño en pantalla; si tu código dimensiona el canvas con el patrón correcto de redimensionado, la razón entre ambos es exactamente el devicePixelRatio, pero en cuanto alguien pone un width: 100% en el CSS y no reacciona al cambio, deja de serlo. Calcular la escala en lugar de asumir la densidad hace que la función funcione en los dos mundos.
Queda el tercer paso, que es el que corresponde a la vista: si tu escena tiene zoom o desplazamiento, ese punto todavía está en coordenadas de pantalla y hay que llevarlo al espacio del mundo con la matriz inversa, tal y como se trata en invertir la transformación. Salta ese paso y el hit testing funcionará perfectamente hasta que alguien haga zoom.
offsetX y offsetY existen y ahorran el primer paso, pero son relativos a la caja de padding del elemento y no tienen en cuenta la escala del búfer. Sirven para un canvas de tamaño fijo sin CSS de por medio; en cuanto hay densidad de pantalla, mienten.
Las tres técnicas
Hay exactamente tres maneras de responder a la pregunta, y la elección no es de gusto: depende del número de objetos y de la complejidad de sus formas.
La consulta geométrica pregunta al motor si el punto está dentro de una ruta. Es exacta, no cuesta memoria y es la respuesta correcta en la inmensa mayoría de los casos.
El índice espacial no responde la pregunta: reduce el número de candidatos antes de preguntar. Es un complemento de la anterior, no una alternativa.
El canvas de color oculto repinta la escena en un búfer paralelo dando a cada objeto un color único, y lee el píxel bajo el puntero. Responde en tiempo constante independientemente del número de objetos, y a cambio consume memoria, obliga a dibujar dos veces y paga el coste de leer del canvas.
Hay una cuarta que no aparece en el diagrama porque no es una técnica sino un error frecuente: calcular la geometría a mano. Comparar contra un rectángulo es razonable; comparar contra un polígono rotado con esquinas redondeadas es reimplementar mal lo que el motor ya hace bien.
La consulta va en el fotograma, no en el evento
El error de arquitectura más común de esta parte no es elegir mal la técnica: es ejecutarla en el sitio equivocado. La versión que todo el mundo escribe primero hace el trabajo dentro del manejador del evento.
// Lo que no hay que hacercanvas.addEventListener('pointermove', e => { const p = puntoDelEvento(canvas, e); escena.resaltado = buscarObjeto(p.x, p.y); dibujar(); // un redibujado por evento});Un ratón moderno emite eventos de movimiento más deprisa que la pantalla se refresca, y un lápiz digital muchísimo más deprisa. Ese código hace una búsqueda y un redibujado completo por cada uno, y el trabajo extra no produce ni un píxel adicional en pantalla porque el compositor solo muestra un fotograma cada vez.
La forma correcta guarda la posición y hace el trabajo una vez por fotograma.
let punteroPendiente = null;canvas.addEventListener('pointermove', e => { punteroPendiente = puntoDelEvento(canvas, e); // solo guardar});canvas.addEventListener('pointerleave', () => { punteroPendiente = { x: -1, y: -1 };});function marco() { if (punteroPendiente) { const encontrado = buscarObjeto(punteroPendiente.x, punteroPendiente.y); if (encontrado !== escena.resaltado) { escena.resaltado = encontrado; escena.sucia = true; canvas.style.cursor = encontrado ? 'pointer' : 'default'; } punteroPendiente = null; } if (escena.sucia) { dibujar(); escena.sucia = false; } requestAnimationFrame(marco);}requestAnimationFrame(marco);Fíjate en el detalle que hace que esto valga la pena: la comparación encontrado !== escena.resaltado. Sin ella redibujarías en cada movimiento aunque el objeto bajo el puntero sea el mismo, que es el caso el noventa y nueve por ciento del tiempo.
Lo que hay que sintetizar
Ninguno de los eventos que da el DOM por objeto existe aquí, y todos son reconstruibles a partir de dos primitivas: qué objeto hay bajo el puntero ahora, y cuál había antes.
| Evento del DOM | Cómo se sintetiza en canvas |
|---|---|
pointerenter | pointerleave |
Comparar el objeto actual con el del fotograma anterior |
click |
Mismo objeto en pointerdown y en pointerup, sin arrastre entre medias |
dblclick |
Dos clics sobre el mismo objeto en menos de unos 300 ms |
| Arrastre | pointerdown sobre un objeto más movimiento por encima de un umbral |
contextmenu |
Escuchar el evento del elemento y resolver el objetivo igual que un clic |
| Foco y tabulación | No es sintetizable con el puntero: exige elementos reales |
Las cinco primeras son mecánicas y se resuelven con una máquina de estados pequeña. La última no tiene solución dentro del canvas, y es el punto donde empieza el problema de accesibilidad que cierra este nivel.