isPointInPath e isPointInStroke: la consulta exacta
Usar el propio motor de rasterizado para responder si un punto cae dentro de una figura, con el espacio de coordenadas correcto, tolerancia para el trazo y descarte previo.
El navegador ya sabe decidir si un punto está dentro de una forma: lo hace millones de veces por segundo para rellenarla. isPointInPath expone esa maquinaria, y con ella el hit testing deja de ser geometría propia y pasa a ser una pregunta al mismo código que dibuja. La consecuencia es que la respuesta coincide siempre con lo que se ve, incluso en curvas, agujeros y esquinas, que es justo donde una implementación a mano se equivoca.
- Usar las cuatro firmas de
isPointInPathcon la regla de relleno correcta. - Saber en qué espacio se interpreta el punto y en cuál la ruta, y actuar en consecuencia.
- Añadir tolerancia con
isPointInStrokey el grosor de línea. - Descartar candidatos con una caja envolvente antes de consultar.
Las cuatro firmas y el espacio del punto
ctx.isPointInPath(x, y);
ctx.isPointInPath(x, y, 'evenodd');
ctx.isPointInPath(ruta, x, y);
ctx.isPointInPath(ruta, x, y, 'evenodd');
Sin argumento de ruta, la consulta va contra la ruta actual del contexto, la que estás construyendo con moveTo y compañía. Con un Path2D va contra ese objeto, sin tocar la ruta actual, que es lo que lo convierte en la herramienta práctica según lo visto en Path2D como objeto.
El argumento de regla de relleno acepta nonzero, que es el valor por defecto, y evenodd. Y aquí hay una obligación que se incumple mucho: si dibujas con una regla, tienes que consultar con la misma. Una dona dibujada con evenodd tiene un agujero real; consultada con nonzero no lo tiene, y el usuario podrá pinchar en el centro vacío.
Queda el punto delicado, y es el que produce los fallos más desconcertantes de todo el tema. La especificación es precisa al respecto: las coordenadas que pasas se interpretan en el espacio del canvas sin la transformación aplicada, mientras que la ruta sí pasa por la matriz vigente. Dicho de otro modo: el punto va en píxeles del búfer y la geometría en unidades de usuario.
La consecuencia inmediata afecta a todo canvas nítido en pantalla densa. Si tu contexto tiene puesta la matriz de densidad, las coordenadas lógicas del ratón no valen: hay que multiplicarlas.
const dpr = window.devicePixelRatio || 1;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // como en todo el resto del codigo
const ruta = new Path2D();
ruta.arc(120, 90, 40, 0, Math.PI * 2); // unidades logicas
ctx.fill(ruta);
// xLogico e yLogico vienen del evento en pixeles CSS
ctx.isPointInPath(ruta, xLogico, yLogico); // FALSO en pantalla retina
ctx.isPointInPath(ruta, xLogico * dpr, yLogico * dpr); // correcto
El fallo tiene una firma inconfundible: funciona en un monitor externo y no funciona en el portátil, o al revés. Como la escala falla por el mismo factor en las dos direcciones, el objeto sigue respondiendo pero desplazado, y en un canvas grande el desplazamiento es enorme.
La regla operativa que evita el problema entero es tener una única función de conversión, la que produce coordenadas de búfer, y usarla en todas las consultas. Si además tu escena tiene cámara, la ruta se consulta con la misma matriz con la que se dibujó:
function objetoEn(ctx, escena, xBufer, yBufer) {
for (let i = escena.objetos.length - 1; i >= 0; i--) { // de delante hacia atras
const o = escena.objetos[i];
ctx.setTransform(escena.matriz); // exactamente la del dibujo
ctx.translate(o.x, o.y);
ctx.rotate(o.angulo);
if (ctx.isPointInPath(o.ruta, xBufer, yBufer, o.regla)) {
ctx.setTransform(escena.matriz);
return o;
}
}
ctx.setTransform(escena.matriz);
return null;
}
Dos detalles del bucle. Se recorre al revés porque el último objeto dibujado es el que está encima, y es el que el usuario cree estar tocando. Y la región de recorte no interviene: isPointInPath responde sobre la ruta, no sobre lo que se ve. Si has recortado la escena, un objeto oculto por el recorte seguirá respondiendo que sí, y hay que descartarlo comprobando el rectángulo visible aparte.
La tolerancia: isPointInStroke y el grosor prestado
isPointInStroke responde si el punto cae dentro de la banda que dibujaría stroke(), usando los parámetros de trazo vigentes: el grosor sobre todo, y también la forma de los extremos y de las uniones.
ctx.lineWidth = 2;
ctx.isPointInStroke(rutaLinea, x, y); // solo si aciertas en dos pixeles
Acertar en una línea de dos píxeles con el ratón es difícil y con el dedo, imposible. La solución idiomática es prestarle a la consulta un grosor mayor que el del dibujo:
function tocaLinea(ctx, ruta, x, y, tolerancia = 10) {
const previo = ctx.lineWidth;
ctx.lineWidth = Math.max(previo, tolerancia);
const dentro = ctx.isPointInStroke(ruta, x, y);
ctx.lineWidth = previo;
return dentro;
}
Ese grosor prestado es la manera correcta de dar tolerancia a cualquier figura, no solo a las líneas. Una figura rellena con borde de contacto se consulta con las dos preguntas unidas:
const alcanzada = ctx.isPointInPath(ruta, x, y) || tocaLinea(ctx, ruta, x, y, 8);
Y la tolerancia debería depender del dispositivo de entrada, que el propio evento te dice: evento.pointerType vale 'mouse', 'pen' o 'touch'. Ocho píxeles para el ratón y veinte para el dedo es un punto de partida sensato.
const tolerancias = { mouse: 8, pen: 6, touch: 20 };
const t = (tolerancias[evento.pointerType] ?? 10) * dpr; // recuerda: espacio del bufer
isPointInPath parece gratis porque una consulta tarda menos de un microsegundo, y esa intuición se rompe de golpe. El coste real no está en la comprobación geométrica sino en el cambio de matriz que la precede: cada setTransform seguido de translate y rotate obliga al motor a recomponer la transformación de la ruta antes de poder responder, y ese trabajo es el mismo que hace al dibujar. Con mil objetos estás pagando, en cada movimiento del puntero, un porcentaje sustancial de lo que cuesta dibujar la escena entera. Si además cometes el error de consultar en el manejador del evento en vez de en el fotograma, lo multiplicas por la frecuencia del ratón y tienes una aplicación que va perfectamente hasta que mueves el cursor. Hay tres remedios y se aplican en este orden. Primero, la caja envolvente, que descarta el noventa y nueve por ciento de los candidatos con cuatro comparaciones de números y sin tocar el contexto; es la optimización de mayor retorno de todo el tema y ocupa seis líneas. Segundo, no cambiar la matriz: si en lugar de transformar el contexto llevas el punto al espacio local del objeto con la matriz inversa, la consulta se hace con la matriz identidad y el motor no recompone nada. Es más código y es notablemente más rápido. Tercero, un índice espacial, que solo hace falta cuando ya has hecho lo anterior y sigues teniendo demasiados candidatos por consulta. Y hay una trampa de medición que conviene conocer: si perfilas el hit testing verás el tiempo repartido entre setTransform y isPointInPath de una manera que cambia entre navegadores, porque unos hacen el trabajo al establecer la matriz y otros al consultar. Mide el bloque entero, no las llamadas por separado, o llegarás a conclusiones opuestas según el navegador que tengas abierto.
Descartar antes de preguntar
La caja envolvente de un objeto es un rectángulo en coordenadas del mundo que lo contiene entero. Comprobar si el punto está dentro son cuatro comparaciones, y evita la consulta cara en casi todos los casos.
Path2D no sabe calcular su propia caja, así que hay dos caminos. Si construyes la ruta tú, acumulas los extremos mientras la construyes. Si la ruta viene de una cadena de datos SVG, el propio SVG la calcula:
function cajaDeD(d) {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden';
svg.appendChild(p);
document.body.appendChild(svg);
const b = p.getBBox(); // exige estar en el documento
svg.remove();
return { x: b.x, y: b.y, ancho: b.width, alto: b.height };
}
Esa caja se calcula una vez, al construir el objeto, y se guarda con él. Después el filtro es trivial, teniendo en cuenta el radio de tolerancia y la posición del objeto:
function cajaContiene(o, x, y, margen = 0) {
return x >= o.x + o.caja.x - margen &&
x <= o.x + o.caja.x + o.caja.ancho + margen &&
y >= o.y + o.caja.y - margen &&
y <= o.y + o.caja.y + o.caja.alto + margen;
}
Con rotación, la caja del objeto rotado es mayor que la original; la forma barata y segura es usar el radio circunscrito, que no depende del ángulo:
o.radio = Math.hypot(o.caja.ancho, o.caja.alto) / 2; // una vez
const cerca = Math.hypot(x - o.centroX, y - o.centroY) <= o.radio + margen;
Es un filtro más laxo que la caja, y sigue descartando la inmensa mayoría de los candidatos por una raíz cuadrada.
Un ejemplo completo
Todo junto, pegable y funcionando: tres figuras, resaltado al pasar por encima, selección al pinchar, densidad correcta y descarte previo.
<canvas id="c" style="width:480px;height:300px;display:block"></canvas>
<script type="module">
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
function dimensionar() {
const caja = canvas.getBoundingClientRect();
canvas.width = Math.round(caja.width * dpr);
canvas.height = Math.round(caja.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
sucia = true;
}
const estrella = new Path2D();
for (let i = 0; i < 10; i++) {
const r = i % 2 ? 16 : 36;
const a = -Math.PI / 2 + (i * Math.PI) / 5;
const x = r * Math.cos(a), y = r * Math.sin(a);
i === 0 ? estrella.moveTo(x, y) : estrella.lineTo(x, y);
}
estrella.closePath();
const cuadrado = new Path2D();
cuadrado.roundRect(-34, -34, 68, 68, 10);
const anillo = new Path2D();
anillo.arc(0, 0, 38, 0, Math.PI * 2);
anillo.moveTo(20, 0);
anillo.arc(0, 0, 20, 0, Math.PI * 2);
const objetos = [
{ x: 90, y: 150, ruta: cuadrado, regla: 'nonzero', radio: 50, color: '#89b4fa' },
{ x: 240, y: 150, ruta: estrella, regla: 'nonzero', radio: 40, color: '#a6e3a1' },
{ x: 390, y: 150, ruta: anillo, regla: 'evenodd', radio: 42, color: '#f9e2af' },
];
let resaltado = null, seleccionado = null, pendiente = null, sucia = true;
function buscar(xBufer, yBufer) {
for (let i = objetos.length - 1; i >= 0; i--) {
const o = objetos[i];
if (Math.hypot(xBufer / dpr - o.x, yBufer / dpr - o.y) > o.radio) continue;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.translate(o.x, o.y);
if (ctx.isPointInPath(o.ruta, xBufer, yBufer, o.regla)) {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return o;
}
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return null;
}
function puntoDelEvento(e) {
const caja = canvas.getBoundingClientRect();
return {
x: (e.clientX - caja.left) * (canvas.width / caja.width),
y: (e.clientY - caja.top) * (canvas.height / caja.height),
};
}
canvas.addEventListener('pointermove', e => { pendiente = puntoDelEvento(e); });
canvas.addEventListener('pointerleave', () => { pendiente = { x: -1, y: -1 }; });
canvas.addEventListener('pointerdown', e => {
const p = puntoDelEvento(e);
seleccionado = buscar(p.x, p.y);
sucia = true;
});
function dibujar() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
for (const o of objetos) {
ctx.save();
ctx.translate(o.x, o.y);
ctx.fillStyle = o.color;
ctx.globalAlpha = o === resaltado ? 1 : 0.75;
ctx.fill(o.ruta, o.regla);
if (o === seleccionado) {
ctx.globalAlpha = 1;
ctx.strokeStyle = '#cdd6f4';
ctx.lineWidth = 3;
ctx.stroke(o.ruta);
}
ctx.restore();
}
}
function marco() {
if (pendiente) {
const encontrado = buscar(pendiente.x, pendiente.y);
if (encontrado !== resaltado) {
resaltado = encontrado;
canvas.style.cursor = encontrado ? 'pointer' : 'default';
sucia = true;
}
pendiente = null;
}
if (sucia) { dibujar(); sucia = false; }
requestAnimationFrame(marco);
}
new ResizeObserver(dimensionar).observe(canvas);
dimensionar();
requestAnimationFrame(marco);
</script>
El anillo es el que demuestra por qué la regla de relleno importa: está dibujado y consultado con evenodd, y el agujero central no responde. Cambia las dos apariciones a nonzero y el agujero se rellena y pasa a ser tocable, que es exactamente la coherencia que quieres.