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.
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 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.