Saltar al contenido

Pensar en espacios locales, no en coordenadas absolutas

Adoptar el modelo mental que hace manejable el dibujo complejo: cada elemento se dibuja en su propio origen y la jerarquía se resuelve componiendo transformaciones.

El olor

Este código funciona y es una señal de alarma:

JS
function pintarReloj(ctx, cx, cy, r, hora, minuto) {  ctx.beginPath();  ctx.arc(cx, cy, r, 0, Math.PI * 2);  ctx.stroke();  for (let i = 0; i < 12; i++) {    const a = (i * Math.PI) / 6 - Math.PI / 2;    ctx.beginPath();    ctx.moveTo(cx + Math.cos(a) * r * 0.85, cy + Math.sin(a) * r * 0.85);    ctx.lineTo(cx + Math.cos(a) * r * 0.95, cy + Math.sin(a) * r * 0.95);    ctx.stroke();  }  const ah = ((hora % 12) / 12) * Math.PI * 2 - Math.PI / 2;  ctx.beginPath();  ctx.moveTo(cx, cy);  ctx.lineTo(cx + Math.cos(ah) * r * 0.5, cy + Math.sin(ah) * r * 0.5);  ctx.stroke();  // ... y asi con cada elemento}

Los síntomas están a la vista: cx y cy aparecen en cada línea, Math.cos y Math.sin se repiten en cada elemento, y cualquier cambio —girar el reloj entero, ponerlo en perspectiva, escalarlo— exige tocar todas las fórmulas.

La misma figura, pensada en espacios locales:

JS
function pintarReloj(ctx, r, hora, minuto) {  // Todo se dibuja como si el reloj estuviera en el origen, radio r  ctx.beginPath();  ctx.arc(0, 0, r, 0, Math.PI * 2);  ctx.stroke();  for (let i = 0; i < 12; i++) {    ctx.save();    ctx.rotate((i * Math.PI) / 6);    ctx.beginPath();    ctx.moveTo(0, -r * 0.85);    ctx.lineTo(0, -r * 0.95);    ctx.stroke();    ctx.restore();  }  aguja(ctx, ((hora % 12) + minuto / 60) / 12, r * 0.5, 5);  aguja(ctx, minuto / 60, r * 0.8, 3);}function aguja(ctx, fraccion, largo, grosor) {  ctx.save();  ctx.rotate(fraccion * Math.PI * 2);  ctx.lineWidth = grosor;  ctx.beginPath();  ctx.moveTo(0, grosor * 2);  ctx.lineTo(0, -largo);  ctx.stroke();  ctx.restore();}// Colocarlo es responsabilidad de quien llamactx.save();ctx.translate(200, 150);pintarReloj(ctx, 90, 10, 25);ctx.restore();

Ha desaparecido toda la trigonometría. Cada marca se dibuja en la misma posición —arriba, en el eje vertical— y lo que cambia es el sistema en el que se dibuja. Las agujas usan una función común que no sabe nada de relojes. Y la posición del reloj entero no aparece dentro de la función: es una decisión del llamador.

Jerarquías

El patrón se generaliza a árboles de elementos, que es como funcionan los sistemas gráficos retenidos. Cada nodo tiene una transformación local y sus hijos se dibujan dentro de ella.

JS
const escena = {  transform: { x: 260, y: 180, rot: 0, escala: 1 },  dibujar: pintarBrazo,  hijos: [    {      transform: { x: 0, y: -70, rot: 0.4, escala: 1 },      dibujar: pintarBrazo,      hijos: [        { transform: { x: 0, y: -60, rot: -0.7, escala: 0.9 },          dibujar: pintarPinza, hijos: [] },      ],    },  ],};function recorrer(ctx, nodo) {  ctx.save();  const t = nodo.transform;  ctx.translate(t.x, t.y);  ctx.rotate(t.rot);  ctx.scale(t.escala, t.escala);  nodo.dibujar(ctx);  for (const h of nodo.hijos) recorrer(ctx, h);  ctx.restore();}

Doce líneas y tienes un grafo de escena funcional con transformaciones heredadas. Mover el nodo raíz mueve todo el brazo; girar un nodo intermedio gira su segmento y todo lo que cuelga de él. Eso es exactamente lo que hace un motor gráfico retenido, y no ha hecho falta ninguna librería.

Añadir una capacidad más es trivial: la opacidad heredada se consigue multiplicando globalAlpha, porque también forma parte del estado que la pila guarda.

JS
ctx.globalAlpha *= nodo.opacidad ?? 1;

El caso de las etiquetas que no deben girar

Hay una excepción recurrente al patrón: elementos que deben estar posicionados por la jerarquía pero no orientados por ella. Las etiquetas de texto son el caso típico: quieres que sigan al nodo pero que se lean horizontales aunque el nodo esté girado.

La solución es anular la parte de rotación de la matriz justo antes de dibujar la etiqueta, conservando la traslación:

JS
function etiquetaSinGirar(ctx, texto) {  const m = ctx.getTransform();  ctx.save();  // Conservar solo la traslacion y la escala de la densidad  ctx.setTransform(1, 0, 0, 1, m.e, m.f);  ctx.font = '13px system-ui';  ctx.textAlign = 'center';  ctx.fillStyle = '#cdd6f4';  ctx.fillText(texto, 0, -4);  ctx.restore();}

Ese fragmento lee la matriz vigente, se queda con e y f —la traslación acumulada, que ya incluye toda la jerarquía— y descarta el resto. La etiqueta acaba en el sitio correcto sin heredar ni rotación ni escala.

Una variante conserva la escala de la densidad de pantalla, que sí interesa:

JS
const dpr = window.devicePixelRatio || 1;ctx.setTransform(dpr, 0, 0, dpr, m.e, m.f);

El olor, catalogado

Estas son las señales de que a un fragmento de canvas le faltan espacios locales.

Parámetros de posición en funciones de dibujo. Si una función recibe x e y y los suma a todo, ese trabajo lo debería hacer una traslación.

Trigonometría repetida. Si Math.cos y Math.sin aparecen más de una vez por función, hay una rotación que no se hizo.

Fórmulas de composición. Expresiones como cx + (px - cx) * escala son escalados alrededor de un punto hechos a mano.

Cambios de diseño costosos. Si mover un grupo de elementos exige tocar diez fórmulas, el grupo no existe como espacio.

Imposibilidad de reutilizar. Si no puedes dibujar la misma figura dos veces en dos sitios sin duplicar código, la figura está atada a su posición.