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.
Dibujar es ir del mundo a la pantalla; interactuar es ir de la pantalla al mundo. La segunda dirección exige invertir la matriz, y es donde se atasca la mayoría del código de canvas interactivo: se resuelve a base de dividir por el zoom y restar el desplazamiento, funciona mientras no haya rotación, y se rompe el día que alguien añade una. La matriz inversa lo resuelve completamente y en una línea.
- Convertir coordenadas de un evento de puntero a coordenadas del mundo con la matriz inversa.
- Explicar por qué la cadena de conversión tiene tres etapas y qué hace cada una.
- Manejar los casos degenerados de una matriz no invertible.
- Escribir la utilidad de conversión que sirve para cualquier vista, con o sin rotación.
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.
Una matriz con determinante cero no tiene inversa, y en el canvas eso ocurre por dos vías perfectamente alcanzables. La primera es un scale(0, ...): un zoom que llega a cero por un cálculo mal acotado, o una animación de escala que arranca en cero. La segunda es más traicionera: una acumulación de escalados muy pequeños que hace que el determinante caiga por debajo de la precisión de la coma flotante, aunque nominalmente no sea cero. Un usuario que hace zoom hacia fuera durante un rato en una vista que multiplica la escala en cada rueda llega ahí antes de lo que parece. Lo que pasa entonces es que inverse() devuelve una matriz cuyos componentes son todos NaN —no lanza ninguna excepción— y a partir de ese momento todas las coordenadas convertidas son NaN. El canvas, fiel a su costumbre, ignora silenciosamente cualquier operación de dibujo con coordenadas no finitas, así que el resultado es un lienzo que deja de responder y deja de pintar sin un solo mensaje en consola. Hay dos defensas y conviene aplicar las dos. La primera es acotar el zoom con un mínimo y un máximo explícitos, y hacerlo en el sitio donde se asigna, no en el que se usa. La segunda es comprobar el resultado de la inversión antes de usarlo, lo que cuesta una línea: if (!Number.isFinite(p.x)) return null. Y hay una tercera defensa estructural que ya conoces: si la vista se guarda como parámetros escalares y la matriz se reconstruye desde cero, el determinante nunca se degrada por acumulación y solo hay que preocuparse de los límites.