El patrón de componente de gráfico
La arquitectura que separa datos, escalas, marcas y render: el convenio de márgenes, el redimensionado con ResizeObserver, y el contrato que hace que un gráfico sea reutilizable.
Un gráfico generado deja de ser un script de trescientas líneas y pasa a ser un componente el día que separas cuatro responsabilidades: los datos, la proyección a coordenadas, la construcción de las marcas y la escritura en el DOM. Esa separación no es una preferencia de estilo: es lo que permite cambiar de renderizador, testar la geometría sin navegador, y reutilizar el mismo gráfico en tres tamaños distintos.
- Aplicar el convenio de márgenes y explicar qué problema resuelve.
- Separar el cálculo de la geometría de la escritura en el DOM.
- Redimensionar un gráfico con
ResizeObserversin acumular trabajo. - Definir el contrato de un componente de gráfico reutilizable.
El convenio de márgenes
Un gráfico tiene un área de dibujo y unos márgenes para los ejes y las etiquetas. El convenio estándar es sencillo y hay que seguirlo porque todo el mundo lo sigue:
function medidas(anchoTotal, altoTotal, margen = { arriba: 16, dcha: 16, abajo: 32, izda: 48 }) {
return {
margen,
ancho: Math.max(0, anchoTotal - margen.izda - margen.dcha),
alto: Math.max(0, altoTotal - margen.arriba - margen.abajo),
};
}
Y en el marcado, un grupo desplazado que establece el origen del área de dibujo:
const g = el('g', { transform: `translate(${m.izda},${m.arriba})` });
A partir de ahí, todas las coordenadas del interior son relativas al área de dibujo, con el origen en su esquina superior izquierda. Las escalas van de 0 a ancho y de alto a 0. Los ejes se dibujan en grupos hermanos, también desplazados.
Lo que resuelve: sin ese convenio, cada cálculo de posición tiene que sumar el margen, y ese + margen.izda repetido treinta veces es donde se cuelan los errores. Con el desplazamiento en un grupo, se escribe una vez.
El Math.max(0, ...) no es paranoia: durante el montaje de un componente, el contenedor puede medir cero, y un ancho negativo produce escalas invertidas y d con NaN que rompen el renderizado en silencio.
Separar el cálculo del render
La estructura que funciona tiene tres capas y una frontera clara.
Capa uno: la especificación. Una función pura que toma datos y dimensiones y devuelve una descripción de lo que hay que dibujar, en coordenadas ya calculadas. No toca el DOM.
function especificar(datos, { ancho, alto }) {
const maxY = Math.max(...datos.map(d => d.valor));
const paso = ancho / datos.length;
const relleno = 0.2;
const barras = datos.map((d, i) => ({
clave: d.etiqueta,
x: i * paso + (paso * relleno) / 2,
y: alto - (d.valor / maxY) * alto,
ancho: paso * (1 - relleno),
alto: (d.valor / maxY) * alto,
valor: d.valor,
etiqueta: d.etiqueta,
}));
const marcas = [0, 0.25, 0.5, 0.75, 1].map(f => ({
y: alto - f * alto,
texto: Math.round(f * maxY).toLocaleString('es-ES'),
}));
return { barras, marcas, ancho, alto };
}
Es una función pura: mismos argumentos, mismo resultado. Se puede testar sin navegador, sin DOM y sin renderizador. Se pueden comprobar los valores exactos de las coordenadas en una prueba unitaria.
Capa dos: el render. Toma la especificación y escribe en el DOM.
function renderizar(raiz, spec, m) {
raiz.replaceChildren();
raiz.setAttribute('viewBox',
`0 0 ${spec.ancho + m.izda + m.dcha} ${spec.alto + m.arriba + m.abajo}`);
const ejes = el('g', { transform: `translate(${m.izda},${m.arriba})` },
spec.marcas.flatMap(t => [
el('line', { x1: 0, y1: t.y, x2: spec.ancho, y2: t.y,
stroke: '#313244', 'shape-rendering': 'crispEdges' }),
el('text', { x: -8, y: t.y, dy: '0.32em', 'text-anchor': 'end',
'font-size': 11, fill: '#a6adc8' }, [t.texto]),
]));
const marcas = el('g', { transform: `translate(${m.izda},${m.arriba})` },
spec.barras.map(b =>
el('rect', { x: b.x, y: b.y, width: b.ancho, height: b.alto,
fill: '#89b4fa', rx: 2,
'data-clave': b.clave })));
raiz.append(ejes, marcas);
}
Capa tres: el ciclo de vida. Monta, escucha el redimensionado, desmonta.
La frontera entre las capas uno y dos es lo importante: la capa uno no sabe que existe SVG. Se puede escribir un renderizador a lienzo que consuma la misma especificación, y ese es exactamente el enfoque híbrido del nivel 30.
El redimensionado
Un gráfico tiene que reaccionar al tamaño de su contenedor, y hacerlo mal es la causa habitual de que un panel con gráficos se arrastre.
function montar(contenedor, datos, opciones = {}) {
const m = opciones.margen ?? { arriba: 16, dcha: 16, abajo: 32, izda: 48 };
const svg = el('svg', {
role: 'img',
preserveAspectRatio: 'xMidYMid meet',
style: 'width:100%;height:100%;display:block',
});
contenedor.appendChild(svg);
let pendiente = null;
function dibujar(anchoTotal, altoTotal) {
const dim = medidas(anchoTotal, altoTotal, m);
if (dim.ancho <= 0 || dim.alto <= 0) return;
renderizar(svg, especificar(datos, dim), m);
}
const ro = new ResizeObserver(entradas => {
const e = entradas[0];
// contentBoxSize evita leer el layout de nuevo
const w = e.contentBoxSize?.[0]?.inlineSize ?? e.contentRect.width;
const h = e.contentBoxSize?.[0]?.blockSize ?? e.contentRect.height;
cancelAnimationFrame(pendiente);
pendiente = requestAnimationFrame(() => dibujar(w, h));
});
ro.observe(contenedor);
return {
actualizar(nuevos) {
datos = nuevos;
const { width, height } = contenedor.getBoundingClientRect();
dibujar(width, height);
},
destruir() {
ro.disconnect();
cancelAnimationFrame(pendiente);
svg.remove();
},
};
}
Cuatro decisiones que hay que copiar.
ResizeObserver y no el evento de redimensionado de la ventana. El contenedor puede cambiar de tamaño sin que la ventana cambie: un panel plegable, una barra lateral, un cambio de contenido. El evento de ventana se pierde esos casos y además se dispara cuando no hace falta.
Las dimensiones se leen de la entrada del observador, no con getBoundingClientRect. La entrada ya trae la medida, y leerla otra vez fuerza un cálculo de layout síncrono dentro de una devolución de llamada que se ejecuta justo después del layout: es el patrón que produce el aviso de bucle de redimensionado.
El redibujado se difiere a un fotograma. Sin eso, un arrastre del divisor de un panel dispara decenas de redibujados por segundo, cada uno reconstruyendo el DOM entero.
Hay un destruir. Un ResizeObserver que nadie desconecta mantiene vivo el contenedor y todo lo que cuelgue de él. Es una fuga de memoria clásica en aplicaciones de una sola página.
El mensaje «ResizeObserver loop completed with undelivered notifications» aparece en la consola de casi todos los proyectos con gráficos, se ignora, y significa algo concreto: el navegador ha tenido que abandonar la entrega de notificaciones porque el layout no se estabilizaba.
El mecanismo: el observador se ejecuta después del layout y antes del pintado. Si dentro de la devolución de llamada cambias algo que afecta al layout, el navegador recalcula y vuelve a notificar. Si eso ocurre repetidamente en el mismo fotograma, el navegador corta y pospone las notificaciones restantes al fotograma siguiente. El resultado visible es un parpadeo o un salto de tamaño, y en el peor caso una oscilación permanente entre dos tamaños.
Cómo se genera con un gráfico. Escribes el viewBox y el contenido del SVG dentro de la devolución de llamada. Si el SVG tiene width: 100% y height: auto, su alto depende de la proporción del viewBox, así que cambiar el viewBox cambia el alto, lo que cambia el tamaño del contenedor, lo que dispara el observador otra vez. Bucle.
Las tres defensas:
Uno: el SVG tiene dimensiones que no dependen de su contenido. width: 100%; height: 100% con un contenedor de altura definida, o un aspect-ratio fijo en CSS. Nunca height: auto en un SVG cuyo viewBox cambia.
Dos: diferir a requestAnimationFrame, como en el código. Rompe el ciclo porque el trabajo ocurre fuera de la fase de entrega de notificaciones.
Tres: comparar antes de escribir. Si las dimensiones no han cambiado respecto al último dibujado, no hacer nada. Elimina el redibujado espurio que dispara el propio observador.
Y una nota sobre el ruido: ese mensaje llega a los sistemas de registro de errores del cliente y llena el panel de miles de entradas inútiles. La mayoría de los equipos acaba filtrándolo. Es mejor arreglarlo: casi siempre significa que un gráfico está redibujándose dos veces por fotograma.
El contrato del componente
Lo que un componente de gráfico debe exponer, ni más ni menos:
montar(contenedor, datos, opciones) -> { actualizar(datos), destruir() }
Y lo que no debe hacer: leer del DOM global, escribir estilos globales, depender de un identificador fijo, asumir un tamaño, o registrar escuchadores en window sin quitarlos.
Con ese contrato, envolverlo para cualquier framework es de diez líneas, y probarlo sin framework es directo.
Refactoriza un gráfico existente para separar la función especificar pura del renderizado, y escribe una prueba unitaria que verifique las coordenadas exactas de tres barras sin abrir un navegador. Después escribe un segundo renderizador que consuma la misma especificación y dibuje en un lienzo 2D. Si el segundo te sale en menos de cuarenta líneas, la separación está bien hecha.