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.
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 codigoconst ruta = new Path2D();ruta.arc(120, 90, 40, 0, Math.PI * 2); // unidades logicasctx.fill(ruta);// xLogico e yLogico vienen del evento en pixeles CSSctx.isPointInPath(ruta, xLogico, yLogico); // FALSO en pantalla retinactx.isPointInPath(ruta, xLogico * dpr, yLogico * dpr); // correctoEl 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 pixelesAcertar 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 buferDescartar 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 vezconst 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.