Invertir la transformación: de la pantalla al mundo
Convertir coordenadas de puntero a coordenadas de la escena con la matriz inversa, y resolver de una vez el hit testing en una vista con zoom y desplazamiento.
Las tres etapas
Entre el dedo del usuario y una coordenada de tu escena hay tres conversiones encadenadas, y confundirlas es el origen de la mayoría de los desajustes.
De la ventana al elemento. El evento da clientX y clientY, relativos al área visible del navegador. Restando la posición del canvas se obtienen coordenadas relativas al elemento, en píxeles CSS.
Del elemento al búfer. El búfer puede tener más píxeles que la caja del elemento. El factor es canvas.width / rect.width.
Del búfer al mundo. La matriz del contexto convirtió coordenadas del mundo en coordenadas del búfer al dibujar; para volver hay que aplicar su inversa.
function aMundo(canvas, ctx, evento) { const r = canvas.getBoundingClientRect(); // 1. ventana -> elemento (pixeles CSS) const cssX = evento.clientX - r.left; const cssY = evento.clientY - r.top; // 2. elemento -> buffer const bufX = cssX * (canvas.width / r.width); const bufY = cssY * (canvas.height / r.height); // 3. buffer -> mundo const inv = ctx.getTransform().invertSelf(); const p = inv.transformPoint(new DOMPoint(bufX, bufY)); return { x: p.x, y: p.y };}Esa función es correcta con cualquier combinación de densidad, zoom, desplazamiento, rotación, sesgo y transformaciones CSS en los ancestros. La razón es que getBoundingClientRect ya devuelve el rectángulo transformado, y que la matriz del contexto contiene todo lo demás.
El detalle que casi nadie contempla
Hay una condición que la función anterior asume y que hay que garantizar: la matriz que lees tiene que ser la misma con la que dibujaste.
Si el evento llega en cualquier momento y el contexto ha quedado con la matriz de la última capa dibujada —por ejemplo, la de la interfaz sin zoom— la conversión será incorrecta. El síntoma es un hit testing que funciona a veces.
La solución robusta es no leer la matriz del contexto, sino mantener la matriz de la vista como dato y usarla explícitamente:
class Vista { constructor() { this.x = 0; this.y = 0; this.zoom = 1; this.rot = 0; } /** Reconstruye la matriz desde parametros escalares: sin acumular error. */ matriz(dpr = 1) { return new DOMMatrix() .scaleSelf(dpr, dpr) .translateSelf(this.x, this.y) .rotateSelf((this.rot * 180) / Math.PI) // DOMMatrix usa GRADOS .scaleSelf(this.zoom, this.zoom); } aplicar(ctx, dpr) { ctx.setTransform(this.matriz(dpr)); } aMundo(canvas, clientX, clientY, dpr) { const r = canvas.getBoundingClientRect(); const bx = (clientX - r.left) * (canvas.width / r.width); const by = (clientY - r.top) * (canvas.height / r.height); const p = this.matriz(dpr).inverse().transformPoint(new DOMPoint(bx, by)); return { x: p.x, y: p.y }; } aPantalla(x, y, dpr) { const p = this.matriz(dpr).transformPoint(new DOMPoint(x, y)); return { x: p.x, y: p.y }; }}Con la vista como dato, la conversión no depende del estado del contexto en ese instante, y por tanto funciona desde un manejador de eventos, desde una prueba unitaria o desde un worker.
Un ejemplo completo
Este ejemplo funciona pegado tal cual: un lienzo con zoom por rueda, desplazamiento arrastrando y selección de figuras, con la conversión correcta en todos los casos.
<!doctype html><meta charset="utf-8"><style> html, body { margin: 0; height: 100%; background: #1e1e2e; } .caja { position: relative; width: 100%; height: 100vh; } canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }</style><div class="caja"><canvas id="c">Lienzo con zoom.</canvas></div><script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const vista = { x: 0, y: 0, zoom: 1 }; const figuras = Array.from({ length: 120 }, (_, i) => ({ id: i, x: (i % 12) * 90 - 500, y: Math.floor(i / 12) * 90 - 400, w: 64, h: 64, color: ['#89b4fa', '#a6e3a1', '#f9e2af', '#f38ba8', '#cba6f7'][i % 5], })); let seleccion = null; function matriz(dpr) { return new DOMMatrix() .scaleSelf(dpr, dpr) .translateSelf(vista.x, vista.y) .scaleSelf(vista.zoom, vista.zoom); } function aMundo(clientX, clientY) { const dpr = window.devicePixelRatio || 1; const r = canvas.getBoundingClientRect(); const bx = (clientX - r.left) * (canvas.width / r.width); const by = (clientY - r.top) * (canvas.height / r.height); const p = matriz(dpr).inverse().transformPoint(new DOMPoint(bx, by)); return { x: p.x, y: p.y }; } function pintar() { const dpr = window.devicePixelRatio || 1; const r = canvas.getBoundingClientRect(); const w = Math.round(r.width * dpr), h = Math.round(r.height * dpr); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, w, h); ctx.setTransform(matriz(dpr)); for (const f of figuras) { ctx.fillStyle = f.color; ctx.fillRect(f.x, f.y, f.w, f.h); if (f.id === seleccion) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 3 / vista.zoom; // grosor constante en pantalla ctx.strokeRect(f.x - 2, f.y - 2, f.w + 4, f.h + 4); } } // Capa de interfaz: sin zoom, matriz solo con la densidad ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#cdd6f4'; ctx.font = '14px system-ui'; ctx.fillText(`zoom ${vista.zoom.toFixed(2)}× · seleccion ${seleccion ?? '-'}`, 14, 24); } canvas.addEventListener('wheel', e => { e.preventDefault(); const antes = aMundo(e.clientX, e.clientY); vista.zoom *= e.deltaY < 0 ? 1.12 : 1 / 1.12; vista.zoom = Math.min(20, Math.max(0.15, vista.zoom)); const despues = aMundo(e.clientX, e.clientY); // Corregir el desplazamiento para que el punto bajo el cursor no se mueva vista.x += (despues.x - antes.x) * vista.zoom; vista.y += (despues.y - antes.y) * vista.zoom; pintar(); }, { passive: false }); let arrastre = null; canvas.addEventListener('pointerdown', e => { canvas.setPointerCapture(e.pointerId); const m = aMundo(e.clientX, e.clientY); const hit = [...figuras].reverse().find(f => m.x >= f.x && m.x <= f.x + f.w && m.y >= f.y && m.y <= f.y + f.h); seleccion = hit ? hit.id : null; arrastre = { x: e.clientX, y: e.clientY }; pintar(); }); canvas.addEventListener('pointermove', e => { if (!arrastre) return; vista.x += e.clientX - arrastre.x; vista.y += e.clientY - arrastre.y; arrastre = { x: e.clientX, y: e.clientY }; pintar(); }); canvas.addEventListener('pointerup', () => { arrastre = null; }); new ResizeObserver(pintar).observe(canvas); pintar();</script>Dos detalles del ejemplo que valen su peso. El primero, el lineWidth = 3 / vista.zoom: dividir por el zoom mantiene el grosor constante en pantalla, que es lo que quieres para adornos de interfaz dibujados en el espacio del mundo. El segundo, la corrección del zoom con dos conversiones: convertir el punto del cursor a mundo antes y después de cambiar el zoom, y ajustar el desplazamiento por la diferencia, es la forma más simple y robusta de conseguir que el zoom se centre en el cursor, y funciona sea cual sea la transformación.