wandres.dev
INTERACCIÓN EN CANVAS · Hit testing sin DOM

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.

⏱ 17 min

El navegador reparte los eventos entre elementos, y dentro de un canvas hay exactamente un elemento. Cuando el usuario pincha sobre el círculo azul que dibujaste, lo que recibes es un clic en un rectángulo de mil por seiscientos píxeles con dos números. Averiguar que esos dos números caen dentro del círculo, y no dentro del cuadrado que hay debajo, es un trabajo entero que en el DOM te regalan y aquí te toca escribir.

🎯 Al terminar esta lección sabrás
  • Explicar por qué el modelo de eventos del DOM no llega dentro del canvas.
  • Convertir la posición de un evento al espacio de coordenadas correcto en todos los casos.
  • Elegir entre consulta geométrica, índice espacial y canvas de color según la escena.
  • Hacer la consulta en el fotograma y no en el evento, y saber por qué importa.

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.

flowchart TB
punto[Punto convertido al espacio del bufer] --> cuantos[Cuantos objetos hay en la escena]
cuantos -->|Decenas o pocos cientos| geo[Recorrer la escena al reves y consultar con isPointInPath]
cuantos -->|Miles o decenas de miles| indice[Indice espacial que devuelve solo los candidatos cercanos]
indice --> geo
cuantos -->|Formas muy irregulares o pintadas con imagenes| color[Canvas de color oculto y lectura de un pixel]
geo --> caja[Descarte previo por caja envolvente]
caja --> resultado[Objeto bajo el puntero o ninguno]
color --> resultado
style punto fill:#89b4fa,color:#11111b
style cuantos fill:#cba6f7,color:#11111b
style geo fill:#a6e3a1,color:#11111b
style indice fill:#94e2d5,color:#11111b
style color fill:#f9e2af,color:#11111b
style caja fill:#94e2d5,color:#11111b
style resultado fill:#a6e3a1,color:#11111b

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 hacer
canvas.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.

El puntero no está donde crees: la latencia, los eventos agrupados y el dedo que tapa el objetivo

Tres asuntos que separan un hit testing correcto de uno que solo parece correcto, y que aparecen justo cuando el proyecto llega a manos reales. El primero es la agrupación de eventos. El navegador no te entrega todos los movimientos del puntero: agrupa los que ocurrieron entre dos fotogramas y te da el último, guardando los demás en evento.getCoalescedEvents(). Para resaltar un objeto eso da igual, y por eso el patrón de arriba está bien. Pero para dibujar un trazo a mano alzada es la diferencia entre una línea suave y una poligonal con esquinas, porque estás tirando los puntos intermedios. La regla es limpia: el hit testing usa la última posición; el dibujo de trazos itera sobre los eventos agrupados. Existe el simétrico, getPredictedEvents(), que devuelve posiciones extrapoladas para adelantarse a la latencia; sirve para pintar una punta provisional que se corrige después, y no debe usarse jamás para decidir sobre qué objeto se ha pinchado, porque es una predicción y a veces falla. El segundo es el tamaño del objetivo. Un punto matemático no tiene tolerancia, y un dedo cubre unos nueve milímetros. Un hit testing exacto sobre una línea de un píxel es inutilizable con el dedo y molesto con el ratón. La solución no es engordar los objetos sino consultar con un radio: probar el punto y, si falla, probar unos pocos desplazados alrededor, o directamente subir el grosor de trazo antes de la consulta. Y el orden importa: con tolerancia, varios objetos pueden responder que sí, y hay que quedarse con el que esté delante. El tercero es el más sutil. En una escena con transformación animada —un zoom con inercia, una cámara que sigue a algo— el fotograma que el usuario está viendo cuando pincha ya no es el estado actual de tu modelo, porque entre la composición de ese fotograma y la llegada del evento ha pasado el tiempo de latencia del sistema. En una animación rápida eso significa que el usuario pincha sobre lo que ve y tú resuelves contra lo que hay. La corrección profesional consiste en guardar la matriz con la que se dibujó cada fotograma junto a su marca de tiempo, y resolver la consulta con la matriz del fotograma cuyo tiempo esté más cerca de evento.timeStamp. Casi nadie lo hace, y es exactamente lo que explica esos clics que “fallan por poco” en las aplicaciones de mapas mientras la vista todavía se está moviendo.

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.