El gráfico que aguanta producción
Los estados que nadie diseña, los datos degenerados que llegan siempre, la internacionalización, las actualizaciones en vivo, las fugas de memoria y las tres pruebas que sí detectan algo.
Los estados que nadie diseña
Un gráfico tiene seis estados y las maquetas suelen enseñar uno.
Cargando. Ni un espacio en blanco ni un giro sobre fondo vacío: un esqueleto con las dimensiones finales. Si el gráfico ocupa 360 píxeles de alto cuando llega, el esqueleto tiene que ocupar 360, o la página salta cuando cargan los datos. La reserva del espacio se hace con aspect-ratio o con una altura fija en el contenedor, nunca dependiendo del contenido.
Vacío. No hay datos que mostrar. Un gráfico con los ejes dibujados y nada dentro es peor que un mensaje, porque el usuario se pregunta si está cargando. Un mensaje que diga qué falta y qué puede hacer (quitar un filtro, ampliar el rango de fechas) es lo correcto.
Error. No se pudieron obtener los datos. Distinto de vacío, y hay que distinguirlos: «no hay ventas este mes» y «no hemos podido consultar las ventas» llevan a acciones opuestas.
Parcial. Llegaron algunos datos y otros no, o la serie tiene huecos. Aquí la respuesta es dibujar lo que hay y marcar lo que falta, con el defined de los generadores o con una banda sombreada. Rellenar los huecos con ceros es falsear los datos; interpolarlos en silencio, también.
Obsoleto. Los datos son de hace veinte minutos porque la actualización falló. Un gráfico que muestra datos viejos sin decirlo es una fuente activa de decisiones equivocadas. Basta con una marca de tiempo visible y un cambio de tono cuando supera un umbral.
Normal. El único que se diseña.
Los datos degenerados
Llegan siempre, y tratarlos con condicionales repartidos por el código es lo que hace que un gráfico se vuelva inmantenible. Trátalos en la capa que corresponde.
| Caso | Qué rompe | Dónde se trata |
|---|---|---|
| Array vacío | Dominio infinito, NaN |
Estado vacío, antes de calcular |
| Un solo dato | Dominio degenerado, división por cero | En la escala, devolviendo el centro |
| Todos los valores iguales | Lo mismo | En la escala |
| Todos cero | Eje sin marcas útiles | Dominio mínimo forzado a [0, 1] |
| Un valor negativo inesperado | Barras hacia arriba desde el cero | Decisión explícita: recortar o representar |
| Un atípico enorme | Todo lo demás aplastado | Recorte a percentil, con aviso |
| Valores nulos intercalados | La línea atraviesa el hueco | defined en el generador |
| Fechas fuera de orden | La ruta se cruza consigo misma | Ordenar en la capa de transformación |
| Categorías duplicadas | La escala de banda pierde una | Agregar antes, o error explícito |
La última merece atención porque no produce ningún síntoma: una escala de banda construida con un dominio que tiene la misma categoría dos veces mapea las dos al mismo sitio, así que una barra se dibuja encima de otra y el gráfico enseña un dato de menos sin avisar. Merece una comprobación explícita en desarrollo.
Internacionalización y temas
Números y fechas salen de Intl, siempre. El separador decimal, el de millares, el orden de día y mes, el nombre del mes: todo cambia por idioma, y escribirlo a mano produce un gráfico que solo es correcto en un sitio.
const nf = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });const df = new Intl.DateTimeFormat(locale, { month: 'short', year: 'numeric' });Y un detalle que se olvida: el ancho del texto cambia con el idioma. «May» mide la mitad que «Dezember». Un margen izquierdo calculado con las etiquetas en inglés se queda corto en alemán, y las etiquetas se salen. El margen tiene que salir de medir o estimar las etiquetas reales, no de una constante.
La zona horaria hay que fijarla explícitamente. Date opera en la del navegador. Si los datos vienen agregados por día en UTC y el usuario está en otra zona, los puntos se desplazan y los totales del gráfico no cuadran con los de la tabla de al lado. Decide en qué zona se representa y pásala a Intl.DateTimeFormat.
El texto de derecha a izquierda invierte la lectura del gráfico. En árabe o hebreo, un eje temporal que avanza hacia la derecha se lee al revés de lo natural. Lo mínimo es poner las etiquetas del eje vertical al lado correcto y respetar la dirección del texto; invertir el eje temporal es una decisión de producto que hay que consultar y no dar por hecha.
El tema. Un SVG puede tomar sus colores de variables CSS y cambiar solo. Un lienzo no: hay que escuchar el cambio y redibujar.
const oscuro = matchMedia('(prefers-color-scheme: dark)');oscuro.addEventListener('change', redibujar);Y si tu tema es una clase en la raíz del documento y no una preferencia del sistema, la escucha es un MutationObserver sobre esa clase, o mejor, un evento que emita tu propio gestor de temas.
Actualizaciones en vivo, fugas y pruebas
El gráfico que se actualiza cada segundo tiene tres reglas propias:
- No recalcules el dominio en cada actualización salvo que quieras que el eje baile. Recalcúlalo cuando un dato se salga, y entonces con un margen para que no vuelva a pasar inmediatamente.
- Ventana los datos. Un gráfico que acumula puntos desde que se abrió la pestaña acaba con un millón. Recorta a la ventana visible y suelta el resto.
- Agrupa las actualizaciones en un fotograma. Cinco mensajes por segundo no son cinco redibujados si los acumulas.
Las cuatro fugas de memoria de un gráfico, todas con la misma forma: algo registrado que nadie da de baja.
function montar(contenedor) { const ro = new ResizeObserver(...); // 1 const mq = matchMedia(...); // 2 let raf = null; // 3 const bitmaps = []; // 4 return { destruir() { ro.disconnect(); mq.removeEventListener('change', manejar); cancelAnimationFrame(raf); for (const b of bitmaps) b.close(); // ImageBitmap no lo libera el GC a tiempo contenedor.replaceChildren(); }, };}La cuarta es la menos conocida: un ImageBitmap retiene memoria de gráficos que el recolector de basura no libera con la prontitud que debería, y close() la suelta de inmediato. En un gráfico que genera texturas o miniaturas, se nota.
Y las tres pruebas que sí detectan algo, en orden de valor por minuto invertido:
Uno: pruebas unitarias de la especificación. La función pura recibe datos conocidos y devuelve coordenadas. Comprueba los extremos, el redondeo del dominio, el número de marcas, y los nueve casos degenerados de la tabla. Corre en milisegundos, no necesita navegador, y atrapa la mayoría de las regresiones geométricas.
Dos: comparación visual. Renderiza a imagen y compara con una referencia aprobada. Es la única que detecta el descuadre de medio píxel entre capas y los problemas de fuente. Su coste es el mantenimiento de las referencias, y hay que asumir que habrá que aprobar cambios a menudo.
Tres: una comprobación de accesibilidad automática. Detecta el contraste insuficiente, el nombre accesible ausente y el elemento interactivo sin foco. No detecta si la tabla alternativa es útil, que hay que mirarlo a mano una vez.
Y una medida en campo que casi nadie pone y que vale mucho: el tiempo de renderizado del gráfico, con el número de marcas.
const t0 = performance.now();renderizar(spec);telemetria('grafico.render', { ms: performance.now() - t0, marcas: spec.barras.length });Con esa pareja de números en producción sabes exactamente dónde está tu umbral real, en los dispositivos reales de tus usuarios, y dejas de discutirlo.