wandres.dev
NIVEL DIOS · Síntesis del gráfico 2D

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.

⏱ 20 min

Un gráfico terminado y un gráfico en producción se diferencian en cosas que no aparecen en ninguna maqueta: qué pasa cuando no hay datos, cuando hay uno solo, cuando el usuario tiene el sistema en alemán, cuando la pestaña lleva dos días abierta y cuando alguien filtra hasta dejar el conjunto vacío. Ninguna de esas situaciones es rara, y todas llegan la primera semana.

🎯 Al terminar esta lección sabrás
  • Diseñar los cinco estados de un gráfico además del normal.
  • Tratar los conjuntos de datos degenerados sin caso especial disperso.
  • Internacionalizar números, fechas y dirección del texto.
  • Evitar las cuatro fugas de memoria típicas y medir el gráfico en campo.

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.

El caso de un solo dato es el que más veces rompe un gráfico en producción, y llega siempre por la misma puerta

De los nueve casos degenerados de la tabla, hay uno que aparece en absolutamente todos los proyectos y que casi nadie prueba: el conjunto con un solo elemento.

Llega por una puerta concreta y predecible: el usuario filtra. Selecciona un producto, un día, una región, y de golpe la serie tiene un punto. Y entonces:

La escala se degenera. Mínimo y máximo coinciden, la división por cero produce NaN, y todas las coordenadas son NaN. El SVG con d="MNaN NaN" no lanza ninguna excepción: simplemente no dibuja. El lienzo tampoco. No hay error en consola. El usuario ve un gráfico vacío y tú no ves nada.

El eje se queda sin marcas. Con dominio de amplitud cero, el algoritmo de marcas bonitas no tiene nada que devolver. Un eje sin marcas y sin etiquetas parece un fallo de carga.

La línea no se dibuja. Un path con un solo M y ningún comando más no pinta nada, porque una subruta de un solo punto no se traza. El usuario filtra a un día y la gráfica de líneas desaparece por completo, aunque el dato exista y esté ahí.

El área apilada se colapsa. Con un punto, no hay polígono.

Las cuatro defensas, y hay que ponerlas todas porque cada una tapa un agujero distinto:

Uno: la escala trata el dominio degenerado. Devolver el centro del rango cuando d1 === d0, en la escala y no en cada uso. Es una línea y elimina el NaN de raíz.

Dos: el dominio tiene una amplitud mínima. Si el dominio calculado tiene amplitud cero, ábrelo artificialmente: [v - 1, v + 1], o [0, v * 2] si el cero tiene sentido. Ahora hay marcas y hay eje.

Tres: con menos de dos puntos, cambia de representación. Una serie de un punto no es una línea: es un punto. Dibuja el marcador aunque la línea no exista, y considera enseñar el valor como número grande, que es lo que un dato aislado pide.

if (datos.length === 1) {
  ctx.beginPath();
  ctx.arc(x(datos[0].t), y(datos[0].v), 4, 0, Math.PI * 2);
  ctx.fill();
}

Cuatro: prueba con cero, uno y dos elementos. Son tres casos de prueba de tres líneas cada uno, y cubren la clase de fallo que más veces llega a producción de todo este artículo. Si de todo lo que has leído aquí solo vas a hacer una cosa, que sea esta.

Y la observación que explica por qué se escapa siempre: los datos de desarrollo nunca tienen un solo elemento. El conjunto de ejemplo tiene treinta puntos bonitos. El caso de un punto solo existe en producción, solo lo alcanza un usuario filtrando, y no genera ningún error que llegue a tu sistema de registro. Es invisible desde dentro por construcción, y esa es exactamente la definición de un fallo caro.

⚔️ Reto práctico

Escribe tres pruebas para tu gráfico principal con cero, uno y dos elementos, y ejecútalas antes de leer el resto de esta frase. Si alguna produce coordenadas NaN o un elemento vacío, acabas de encontrar un fallo que tus usuarios ya han visto y del que nunca te han informado, porque desde su lado parece que la aplicación está cargando.