wandres.dev
SVG GENERADO · Construir gráficos por código

Generar rutas a partir de datos

Construir la cadena d desde un array: el patrón de generador, el manejo de los huecos, la precisión que hay que aplicar, y el coste de concatenar mal.

⏱ 17 min

Convertir un array de datos en una cadena d es la operación central de cualquier gráfico generado, y tiene tres decisiones que se toman una vez y se pagan siempre: qué hacer con los valores que faltan, con cuántos decimales escribir, y cómo construir la cadena sin generar basura. Las tres tienen respuestas claras y ninguna es la primera que se le ocurre a nadie.

🎯 Al terminar esta lección sabrás
  • Escribir un generador de línea y uno de área a partir de datos.
  • Tratar los huecos de una serie sin inventar valores.
  • Aplicar la precisión decimal en el punto correcto de la tubería.
  • Construir la cadena de forma eficiente y saber por qué importa.

El generador de línea

El caso básico: un array de puntos ya proyectados a coordenadas de pantalla.

const r = (n, d = 2) => Number(n.toFixed(d));

function linea(puntos) {
  if (puntos.length === 0) return '';
  let d = `M${r(puntos[0].x)} ${r(puntos[0].y)}`;
  for (let i = 1; i < puntos.length; i++) {
    d += `L${r(puntos[i].x)} ${r(puntos[i].y)}`;
  }
  return d;
}

Con la repetición implícita de comandos se puede acortar todavía más, porque una serie de L consecutivos no necesita repetir la letra:

function lineaCompacta(puntos) {
  if (puntos.length === 0) return '';
  const cabeza = `M${r(puntos[0].x)} ${r(puntos[0].y)}`;
  if (puntos.length === 1) return cabeza;
  const resto = puntos.slice(1)
    .map(p => `${r(p.x)} ${r(p.y)}`)
    .join(' ');
  return `${cabeza}L${resto}`;
}

Un solo L seguido de todos los pares. En una serie de mil puntos eso son 999 caracteres menos, aproximadamente un 5 por ciento de la cadena. Es una micro-optimización que conviene tener escrita porque no cuesta nada.

Los huecos

Una serie real tiene valores ausentes: un sensor que no midió, un mes sin datos, un valor nulo. Hay tres tratamientos y solo uno es honesto.

Tratarlos como cero. Mal. Un dato ausente no es un cero, y dibujarlo así miente sobre los datos.

Interpolar entre los vecinos. Mal para un gráfico de datos, porque inventa información. Aceptable solo si el gráfico declara explícitamente que interpola.

Romper la línea. Correcto. El hueco se ve como un hueco.

function lineaConHuecos(puntos, definido = p => p.y != null && Number.isFinite(p.y)) {
  const tramos = [];
  let actual = [];
  for (const p of puntos) {
    if (definido(p)) actual.push(p);
    else if (actual.length) { tramos.push(actual); actual = []; }
  }
  if (actual.length) tramos.push(actual);
  return tramos.map(lineaCompacta).join('');
}

Cada tramo continuo es una subruta con su propio M. La concatenación de subrutas produce una sola d, un solo elemento, y visualmente una línea con huecos.

El predicado definido como parámetro es la parte que hace que esto sea reutilizable: qué cuenta como ausente depende de los datos. Un null, un NaN, un cero que en ese dominio significa «sin medida». Que lo decida quien conoce los datos.

Un detalle sobre un tramo de un solo punto: produce una d con solo un M, que no dibuja nada. Si quieres que los puntos aislados se vean, hay que dibujarlos aparte como marcadores, y ese es el comportamiento correcto: un punto aislado no es una línea.

El generador de área

Un área es la línea, más el recorrido de vuelta por la línea base, más el cierre.

function area(puntos, y0) {
  if (puntos.length === 0) return '';
  const arriba = puntos.map(p => `${r(p.x)} ${r(p.y)}`).join(' ');
  const abajo = [...puntos].reverse()
    .map(p => `${r(p.x)} ${r(y0)}`).join(' ');
  return `M${arriba}L${abajo}Z`;
}

Nótese que el M inicial aprovecha la repetición implícita: el primer par es el moveto y los siguientes son lineto automáticos. El recorrido de vuelta va invertido para que el polígono no se cruce.

Para un área entre dos series (una banda de confianza, un rango de mínimo y máximo), la línea base es otra serie:

function banda(puntos) {
  // cada punto tiene x, y0, y1
  const arriba = puntos.map(p => `${r(p.x)} ${r(p.y1)}`).join(' ');
  const abajo = [...puntos].reverse()
    .map(p => `${r(p.x)} ${r(p.y0)}`).join(' ');
  return `M${arriba}L${abajo}Z`;
}

Dónde aplicar el redondeo

Esta es la decisión que produce errores acumulados si se toma mal. El redondeo se aplica solo al escribir la cadena, nunca antes.

// Mal: el redondeo entra en el calculo
const puntos = datos.map(d => ({
  x: r(escalaX(d.fecha)),
  y: r(escalaY(d.valor)),
}));

// Bien: se calcula en doble precision y se redondea al escribir
const puntos = datos.map(d => ({
  x: escalaX(d.fecha),
  y: escalaY(d.valor),
}));
const d = lineaCompacta(puntos);  // el redondeo esta dentro

La diferencia importa cuando los puntos redondeados alimentan un cálculo posterior: la interpolación de una curva suave, el cálculo de un área, la detección del punto más cercano al cursor. Con valores redondeados, cada uno de esos cálculos arrastra el error.

Y como se vio en el nivel 25, hay un caso donde el redondeo agresivo cambia la topología: dos puntos consecutivos que colapsan al mismo valor. En una serie densa (mil puntos en 800 píxeles), eso ocurre constantemente y es beneficioso: los puntos que caen en el mismo píxel no aportan nada. Pero hay que hacerlo a propósito, no como efecto secundario:

// Eliminar puntos que caen en el mismo pixel
function reducir(puntos, umbral = 0.5) {
  const out = [puntos[0]];
  for (let i = 1; i < puntos.length; i++) {
    const u = out[out.length - 1];
    if (Math.abs(puntos[i].x - u.x) >= umbral ||
        Math.abs(puntos[i].y - u.y) >= umbral) {
      out.push(puntos[i]);
    }
  }
  return out;
}

Con series de decenas de miles de puntos esa reducción es la diferencia entre una d de 400 kB y una de 20 kB, y visualmente son idénticas.

La reducción por umbral se come los picos, y para una serie de datos eso es una mentira

El filtro por distancia mínima del código anterior tiene un defecto grave que se ve enseguida en datos reales: elimina los extremos.

Si una serie de mil puntos tiene un pico agudo de un solo punto, y ese punto está a menos de medio píxel en horizontal de su vecino, el filtro lo descarta. El pico desaparece del gráfico. Y un pico es exactamente lo que alguien está buscando cuando mira una serie temporal.

Peor: el efecto es sistemático. Cuanto más denso es el muestreo, más probable es que los extremos caigan cerca de sus vecinos, así que cuantos más datos tengas, más picos pierdes. Un gráfico que se ve suave puede estar ocultando toda su variabilidad.

La técnica correcta para reducir una serie densa preservando la forma se llama agregación por cubo con extremos: se divide el eje horizontal en tantos cubos como píxeles haya, y de cada cubo se conservan cuatro puntos, el primero, el último, el mínimo y el máximo, en su orden original de aparición.

function porPixel(puntos, anchoPx) {
  if (puntos.length <= anchoPx * 2) return puntos;
  const x0 = puntos[0].x, x1 = puntos.at(-1).x;
  const cubos = new Array(anchoPx);
  for (const p of puntos) {
    const i = Math.min(anchoPx - 1,
      Math.floor(((p.x - x0) / (x1 - x0)) * anchoPx));
    const c = cubos[i] ?? (cubos[i] = { pri: p, ult: p, min: p, max: p });
    c.ult = p;
    if (p.y < c.min.y) c.min = p;
    if (p.y > c.max.y) c.max = p;
  }
  return cubos.filter(Boolean).flatMap(c =>
    [c.pri, c.min, c.max, c.ult]
      .filter((v, i, a) => a.indexOf(v) === i)
      .sort((a, b) => a.x - b.x));
}

El resultado tiene como mucho cuatro puntos por píxel, y conserva todos los extremos visibles. Es lo que hacen las bibliotecas de series temporales serias, y es la diferencia entre un gráfico rápido y un gráfico rápido que dice la verdad.

Existen además algoritmos más sofisticados, como el de agregación por triángulos de área máxima, que eligen el punto que más contribuye a la forma. Dan resultados más bonitos y no garantizan conservar los extremos. Para datos donde el máximo importa, el de cubos con extremos es el correcto.

⚔️ Reto práctico

Toma una serie de cincuenta mil puntos con varios picos aislados y redúcela con los dos métodos: filtro por distancia y agregación por cubos. Dibuja las tres versiones superpuestas (original, filtrada, agregada) y cuenta cuántos de los picos sobreviven en cada una. Mide también el tamaño de la cadena d resultante en los tres casos.