wandres.dev
VISUALIZACIÓN DE DATOS II · D3 y el modelo de datos

Los generadores de forma

d3.line, d3.area, d3.arc y d3.pie: qué devuelven exactamente, el convenio de ángulos que no es el del lienzo, el catálogo de curvas y cuáles mienten sobre tus datos.

⏱ 20 min

Un generador de forma de D3 es una función configurable que convierte un array de datos en una cadena para el atributo d. No dibuja nada, no toca el DOM y no sabe si su resultado acabará en un SVG o en un lienzo: eso lo decides tú, y con una sola llamada más lo redirige al contexto 2D. Son el módulo con mejor relación entre lo que ahorran y lo que cuesta aprenderlos.

🎯 Al terminar esta lección sabrás
  • Generar líneas y áreas con d3.line y d3.area, incluidos los huecos.
  • Usar d3.arc y d3.pie conociendo su convenio de ángulos.
  • Elegir una curva de interpolación con criterio y descartar las que distorsionan.
  • Redirigir un generador a un lienzo 2D con context y a un Path2D.

Líneas y áreas

import { line, area, curveMonotoneX } from 'd3-shape';

const linea = line()
  .x(d => x(d.fecha))
  .y(d => y(d.valor))
  .defined(d => d.valor != null)
  .curve(curveMonotoneX);

const d = linea(datos);   // "M12,300L34,280L56,291..."
ruta.setAttribute('d', d);

Lo que hay que ver en esas seis líneas: el generador no conoce tus datos ni tus escalas. Recibe dos funciones de acceso que traducen un elemento de tu array a coordenadas de pantalla, y las escalas son las del nivel anterior. El generador solo se ocupa de emitir los comandos en el orden correcto.

defined es la que más se agradece y menos se conoce. Marca qué puntos son válidos, y rompe la ruta en subrutas en los tramos que no lo son. Una serie temporal con huecos dibujada sin defined produce una línea recta que atraviesa el hueco, es decir, inventa datos que no existen. Con defined, el hueco se ve como lo que es.

// Sin defined: una recta larga uniendo el 3 de marzo con el 19 de marzo.
// Con defined: dos tramos separados, y el lector ve que faltan datos.

Un truco que multiplica el valor de defined: dibuja dos veces la misma serie, una con una línea tenue sin defined y otra encima con la línea normal y defined. El lector ve la continuidad aproximada y, a la vez, distingue qué tramos son datos y cuáles son interpolación visual.

area es la misma idea con dos fronteras:

const banda = area()
  .x(d => x(d.fecha))
  .y0(d => y(d.min))
  .y1(d => y(d.max))
  .curve(curveMonotoneX);

Con y0 fijo en la línea base tienes el área bajo la curva; con y0 e y1 variables tienes una banda de confianza o un rango de mínimo y máximo, que es de las representaciones más útiles y menos usadas. Y area.lineY1() devuelve un generador de línea con la configuración de la frontera superior, para dibujar el borde del área sin duplicar la configuración.

Arcos y sectores

d3.arc dibuja un anillo o un sector, y su convenio de ángulos no es el del lienzo 2D ni el de la trigonometría estándar:

  • El ángulo cero apunta a las doce en punto, no a las tres.
  • Los ángulos positivos avanzan en el sentido de las agujas del reloj.
  • Se miden en radianes.
import { arc, pie } from 'd3-shape';

const generador = arc()
  .innerRadius(60)        // 0 para un sector macizo, mayor que 0 para un anillo
  .outerRadius(120)
  .padAngle(0.02)         // separacion entre sectores, en radianes
  .cornerRadius(3);

const sectores = pie()
  .value(d => d.total)
  .sort(null)             // conserva el orden del array
  (datos);

// sectores[0] = { data, value, index, startAngle, endAngle, padAngle }
const d = generador(sectores[0]);

Fíjate en la separación de responsabilidades: pie no dibuja nada. Toma un array de datos y devuelve otro array con los ángulos calculados. arc toma uno de esos objetos y devuelve la cadena. Puedes usar pie para calcular ángulos y dibujarlos con tu propio código, o usar arc con ángulos que hayas calculado tú.

sort(null) merece un comentario porque el comportamiento por defecto sorprende: sin él, pie reordena los sectores de mayor a menor. Eso suele ser lo que quieres en un gráfico aislado y es exactamente lo que no quieres cuando dos gráficos contiguos tienen que compararse, o cuando las categorías tienen un orden intrínseco.

arc tiene además un método que ahorra un cálculo tedioso:

const [cx, cy] = generador.centroid(sectores[0]);   // centro del sector
etiqueta.setAttribute('transform', `translate(${cx},${cy})`);

Y d3.stack completa la familia para las series apiladas. Convierte un array de objetos en un array de series, cada una con sus pares de límites inferior y superior ya acumulados:

import { stack, stackOffsetExpand, stackOrderInsideOut } from 'd3-shape';

const apilado = stack()
  .keys(['norte', 'sur', 'este'])
  .offset(stackOffsetExpand)      // normaliza cada columna a 1: apilado al 100%
  .order(stackOrderInsideOut)     // orden que minimiza el zigzag
  (datos);

Los desplazamientos disponibles cambian por completo el significado del gráfico: el de por defecto apila desde cero, stackOffsetExpand normaliza a proporciones, y stackOffsetWiggle produce el gráfico de corriente centrado. No son estilos: son tres preguntas distintas sobre los mismos datos.

Las curvas, y cuáles mienten

curve decide cómo se unen los puntos, y el catálogo es amplio. La clasificación que importa no es por aspecto, es por si la curva pasa por los datos.

Curva Pasa por los puntos Se sale del rango
curveLinear No
curveStep, curveStepBefore, curveStepAfter No
curveMonotoneX, curveMonotoneY No
curveNatural
curveCatmullRom
curveCardinal
curveBumpX, curveBumpY No
curveBasis No No

curveBasis es una B-spline cúbica: es aproximante, no interpolante. La curva se acerca a los puntos de control sin pasar por ellos. Dibujada sobre datos reales, lo que ves no son tus datos: es una curva suavizada que pasa cerca. Para una decoración es perfecta. Para una serie de la que alguien va a leer valores, es incorrecta.

Las que se salen del rango son un problema distinto y más traicionero. curveCatmullRom y curveCardinal pasan por todos los puntos y sobrepasan entre ellos: si dos valores consecutivos suben mucho y luego bajan, la curva se pasa de largo antes de volver. En una serie de valores no negativos, eso significa que la curva puede bajar de cero y dibujar un tramo por debajo del eje donde tu dato nunca fue negativo.

curveMonotoneX existe exactamente para eso: garantiza que la curva es monótona en cada tramo entre puntos consecutivos, es decir, no sobrepasa. Si vas a suavizar una serie temporal, es la respuesta por defecto.

Y hay una pregunta anterior a todas: ¿por qué estás suavizando? Una curva suave afirma que entre dos mediciones el fenómeno evolucionó de forma continua y suave. Para una temperatura muestreada cada hora, la afirmación es razonable. Para un recuento diario de incidencias, es falsa: entre el lunes y el martes no hubo ningún valor intermedio, y suavizar sugiere que sí. Ahí lo correcto es curveLinear, o incluso curveStepAfter, que es la única que representa honestamente un valor que se mantiene durante un intervalo.

El puente al lienzo

Todos los generadores aceptan un contexto, y ese detalle es lo que los convierte en herramienta de canvas y no solo de SVG.

const ctx = lienzo.getContext('2d');

const linea = line()
  .x(d => x(d.fecha))
  .y(d => y(d.valor))
  .context(ctx);          // ahora emite llamadas al contexto, no una cadena

ctx.beginPath();
linea(datos);             // llama a ctx.moveTo, ctx.lineTo, ctx.bezierCurveTo...
ctx.strokeStyle = '#89b4fa';
ctx.lineWidth = 2;
ctx.stroke();

Con context, el generador deja de devolver una cadena y emite directamente las llamadas del contexto 2D. La misma configuración, el mismo código de escalas, el mismo array de datos, y el resultado va a un lienzo. Es la pieza que hace posible el enfoque híbrido sin duplicar el código de geometría.

Y hay una tercera salida que combina lo mejor de las dos:

const p = new Path2D(linea(datos));   // sin context: devuelve la cadena
ctx.stroke(p);
ctx.isPointInStroke(p, ratonX, ratonY);

Generar la cadena y construir un Path2D con ella te da un objeto reutilizable, que se puede redibujar sin recalcular la geometría y contra el que se puede hacer hit testing. Cuesta un poco más que context en la primera pasada y mucho menos en las siguientes.

El generador construye la cadena con la precisión que le pidas, y esa cifra decide el peso del SVG

Un generador de D3 emite las coordenadas tal cual las devuelven tus funciones de acceso. Si tu escala devuelve 137.28571428571428, eso es exactamente lo que acaba en el atributo d: diecisiete caracteres para expresar una posición que en pantalla no se distingue de 137.3.

Con mil puntos y dos coordenadas por punto, la diferencia entre precisión completa y un decimal es del orden de veinte mil caracteres, y esto se acumula: el SVG generado pesa el triple sin que ningún píxel cambie. Es el mismo problema que se trata en precisión decimal y peso, pero aquí lo genera tu propio código en tiempo de ejecución, así que ningún optimizador va a arreglártelo después.

La solución no es redondear la cadena resultante con una expresión regular, que es lo que hace la mayoría y es frágil. Es redondear en la función de acceso, que es donde ya tienes el número:

const r1 = v => Math.round(v * 10) / 10;
const linea = line().x(d => r1(x(d.fecha))).y(d => r1(y(d.valor)));

Y hay una consecuencia de segundo orden que casi nadie ve venir: redondear a un decimal introduce colinealidad. Tres puntos que antes formaban un ángulo minúsculo pasan a estar alineados, y eso permite que un optimizador posterior elimine el punto del medio. Es decir, redondear no solo acorta cada número: reduce el número de puntos. En series densas ese efecto de segundo orden pesa más que el primero.

El límite inferior de la precisión útil sale del viewBox, no del gusto. Si tu área de dibujo tiene 800 unidades de ancho y se muestra a 800 píxeles, un decimal es una décima de píxel, que ya es invisible. Si el mismo SVG se puede ampliar al 400 por ciento, un decimal se convierte en cuatro décimas de píxel y empieza a notarse en las diagonales. Dos decimales cubren cualquier caso razonable, y tres son siempre desperdicio.

Nota que esto no aplica al lienzo. Con context, no hay cadena: los números van directos a lineTo como dobles, no cuestan nada y redondearlos solo empeora la calidad del antialiasing. La optimización es exclusiva del camino que produce texto.

⚔️ Reto práctico

Dibuja la misma serie de valores no negativos con curveCatmullRom, curveMonotoneX y curveLinear, con la línea base del eje bien visible. Busca un tramo donde la primera cruce por debajo del cero y mide cuánto. Después enséñale las tres a alguien y pregúntale cuál es el valor mínimo de la serie: la respuesta cambiará entre las tres versiones con los mismos datos.