Saltar al contenido

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.

El generador de línea

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

JS
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:

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

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

JS
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:

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

JS
// Mal: el redondeo entra en el calculoconst puntos = datos.map(d => ({  x: r(escalaX(d.fecha)),  y: r(escalaY(d.valor)),}));// Bien: se calcula en doble precision y se redondea al escribirconst 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:

JS
// Eliminar puntos que caen en el mismo pixelfunction 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.