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.
La diferencia entre un canvas mantenible y uno que nadie quiere tocar rara vez está en la API: está en si el código calcula coordenadas absolutas o compone espacios locales. El primero acumula sumas y senos por todas partes y se rompe con cada cambio de diseño. El segundo describe cada pieza en su propio sistema y deja que las matrices hagan el resto, que es exactamente para lo que existen.
- Reescribir código con coordenadas absolutas como composición de espacios locales.
- Modelar una jerarquía de elementos con transformaciones anidadas.
- Aislar cada función de dibujo de la posición y la orientación de su llamador.
- Reconocer el olor a código que indica que faltan espacios locales.
El olor
Este código funciona y es una señal de alarma:
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:
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 llama
ctx.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.
El patrón funciona porque todas las funciones respetan la misma convención: dibujo centrado en el origen, orientado hacia arriba, en las unidades que me pasen. En cuanto una función rompe esa convención —porque acepta un parámetro de posición “por comodidad”, o porque dibuja desde la esquina en lugar de desde el centro— la composición deja de funcionar de forma predecible y el llamador tiene que recordar cuál es cuál. Ese es el momento en que el código empieza a degradarse, y ocurre siempre por la misma razón: alguien añade un parámetro x, y a una función porque en ese momento le venía bien. Merece la pena escribir la convención como norma del proyecto y aplicarla sin excepciones, con tres reglas concretas. Primera: ninguna función de dibujo recibe posición absoluta. Si necesita ir en un sitio, el llamador la traslada. Segunda: el origen local de cada elemento es su punto de anclaje natural —el centro para lo radial, la esquina superior izquierda para lo rectangular, la línea base para el texto— y ese punto se documenta en el nombre o en un comentario. Tercera: ninguna función de dibujo deja el contexto modificado. El beneficio no es estético: es que cada función se vuelve componible y probable de forma aislada, y que operaciones como “dibuja esta escena entera en miniatura en la esquina” o “exporta esta pieza como icono” pasan a ser dos líneas de transformación en lugar de una reescritura. El coste es un save/restore por función, que en el nivel de elementos compuestos es despreciable.
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.
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.
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:
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:
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.
Coge el reloj de esta lección y añade, sin tocar ninguna de las funciones existentes, un segundo reloj más pequeño dentro del primero, en la posición de las seis, que marque una zona horaria distinta. Si te sale en tres líneas, el patrón está bien aplicado; si necesitas modificar pintarReloj, hay un espacio local que falta por extraer.