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

Las disposiciones: jerarquías y fuerzas

d3-hierarchy y d3-force calculan coordenadas y no dibujan nada: el patrón de anotación, los algoritmos de teselado, la simulación física y cómo ejecutarla sin animar.

⏱ 20 min

Una disposición de D3 no produce gráficos: produce números. Toma una estructura de datos y le añade coordenadas, y a partir de ahí el dibujo es tuyo, en SVG o en lienzo, con el generador que quieras. Esa separación es la que hace que un treemap y una simulación de fuerzas, que no se parecen en nada, se usen exactamente igual.

🎯 Al terminar esta lección sabrás
  • Construir una jerarquía y aplicarle un teselado con d3-hierarchy.
  • Elegir el algoritmo de teselado según si el treemap se anima o no.
  • Configurar una simulación de fuerzas y explicar el papel de alpha.
  • Ejecutar una simulación sin animarla para obtener la disposición final.

El patrón de anotación

Las dos familias comparten una idea que conviene ver antes que los detalles: la disposición muta los nodos añadiéndoles propiedades de posición, y el render las lee.

import { hierarchy, treemap, treemapSquarify } from 'd3-hierarchy';

const raiz = hierarchy(datos)          // envuelve el arbol en nodos de D3
  .sum(d => d.valor)                   // calcula el valor acumulado de cada rama
  .sort((a, b) => b.value - a.value);  // ordena los hermanos

treemap()
  .size([ancho, alto])
  .padding(2)
  .tile(treemapSquarify)
  .round(true)(raiz);                  // escribe x0, y0, x1, y1 en cada nodo

for (const nodo of raiz.leaves()) {
  ctx.fillStyle = color(nodo.parent.data.nombre);
  ctx.fillRect(nodo.x0, nodo.y0, nodo.x1 - nodo.x0, nodo.y1 - nodo.y0);
}

Ese bucle final es todo lo que hay de dibujo, y podría igual de bien crear rect de SVG con el ayudante de createElementNS y el namespace. La disposición no tiene ninguna opinión.

sum es la llamada que más se olvida y sin la cual el treemap sale vacío: recorre el árbol de abajo arriba acumulando el valor de cada rama, y el teselado reparte el espacio proporcionalmente a esos acumulados. sort decide el orden de los hermanos, y en un treemap eso no es cosmética: el algoritmo de teselado coloca primero los grandes, así que el orden decide la forma del resultado.

Si tus datos son una tabla plana con una columna de identificador y otra de padre, stratify construye el árbol:

import { stratify } from 'd3-hierarchy';

const raiz = stratify()
  .id(d => d.codigo)
  .parentId(d => d.codigoPadre)(filas);

Las disposiciones jerárquicas que devuelven un rectángulo por nodo (treemap, partition) escriben x0, y0, x1, y1. Las que devuelven un punto (tree, cluster) escriben x e y. pack escribe x, y y r. Saber cuál escribe qué te ahorra la mitad de las visitas a la documentación.

Una nota sobre tree: implementa el algoritmo de árbol ordenado de Reingold y Tilford con la mejora de tiempo lineal de Buchheim y otros, que es el que produce esos árboles compactos y simétricos que uno esperaría dibujar a mano y que un algoritmo ingenuo no consigue. Y una advertencia de coordenadas: en d3.tree, la x es la posición a lo largo del nivel y la y es la profundidad. Para un árbol que crece hacia abajo hay que dibujarlo tal cual; para uno que crece hacia la derecha hay que intercambiarlas. Casi todo el mundo se equivoca la primera vez.

Los teselados y la estabilidad

treemap acepta distintos algoritmos de teselado, y la elección tiene consecuencias reales.

treemapSquarify es el de por defecto y viene del trabajo de Bruls, Huizing y van Wijk del año 2000. Su objetivo es que la relación de aspecto de cada rectángulo se acerque a uno, es decir, que salgan cuadrados en lugar de tiras. Importa porque el ojo compara mal áreas de rectángulos muy alargados: dos rectángulos de la misma área, uno cuadrado y otro diez veces más largo que alto, se perciben como muy distintos.

treemapBinary parte recursivamente el conjunto en dos mitades de valor parecido. Da rectángulos algo peores que el anterior y conserva el orden del array, lo que sirve cuando el orden significa algo (por ejemplo, si las categorías son temporales).

treemapSlice y treemapDice cortan siempre en la misma dirección. Producen tiras y solo tienen sentido como piezas de otras disposiciones.

treemapResquarify es el que casi nadie conoce y el que resuelve un problema serio. Cuando un treemap se actualiza con datos nuevos, treemapSquarify puede reorganizar por completo la disposición: un cambio del cinco por ciento en un valor reordena todos los rectángulos y el usuario pierde de vista el que estaba mirando. treemapResquarify conserva la disposición anterior y solo ajusta los tamaños dentro de ella, a costa de relaciones de aspecto peores.

La regla: treemap estático, treemapSquarify; treemap que se actualiza o se anima, treemapResquarify. Es la misma preocupación por la constancia de objeto que motivaba la función de clave del join, aplicada a la geometría en lugar de a los nodos.

La simulación de fuerzas

d3-force es una simulación física por integración de Verlet de velocidades. Los nodos tienen posición y velocidad, las fuerzas modifican las velocidades, y en cada paso las posiciones avanzan.

import { forceSimulation, forceManyBody, forceLink,
         forceCenter, forceCollide } from 'd3-force';

const sim = forceSimulation(nodos)
  .force('carga', forceManyBody().strength(-120))
  .force('enlace', forceLink(enlaces).id(d => d.id).distance(40))
  .force('centro', forceCenter(ancho / 2, alto / 2))
  .force('colision', forceCollide().radius(d => d.r + 2))
  .on('tick', dibujar);

Las cuatro fuerzas de esa configuración son las que aparecen en el noventa por ciento de los grafos: repulsión entre todos los nodos, atracción por los enlaces, atracción hacia un centro, y evitación de solapamientos.

Los parámetros que gobiernan el enfriamiento son tres y hay que entenderlos porque son la diferencia entre una simulación que converge y una que tiembla para siempre:

  • alpha empieza en 1 y baja hasta alphaMin, que por defecto es 0,001. Es la temperatura: multiplica el efecto de las fuerzas.
  • alphaDecay controla la velocidad del enfriamiento. Su valor por defecto está calculado para que la simulación se detenga en unas 300 iteraciones.
  • velocityDecay es la fricción, 0,4 por defecto. Valores altos frenan antes y pueden dejar la disposición sin converger; valores bajos producen rebotes.

forceManyBody es la fuerza cara: calcular la repulsión entre todos los pares es cuadrático. D3 usa una aproximación de Barnes y Hut sobre un quadtree, que agrupa los nodos lejanos y los trata como una masa única. El parámetro theta controla ese umbral de agrupación: subirlo acelera y degrada la precisión.

Y la técnica que cambia el rendimiento por completo: ejecutar la simulación sin animarla.

const sim = forceSimulation(nodos)
  .force('carga', forceManyBody().strength(-120))
  .force('enlace', forceLink(enlaces).id(d => d.id))
  .force('centro', forceCenter(ancho / 2, alto / 2))
  .stop();                       // no arranca el temporizador

for (let i = 0; i < 300; i++) sim.tick();   // 300 pasos de golpe

dibujar();                       // un solo dibujado, con la disposicion final

stop impide que se registre el temporizador y tick avanza la simulación de forma síncrona sin emitir eventos. Trescientas iteraciones de un grafo mediano tardan unas decenas de milisegundos, y en lugar de trescientos redibujados haces uno.

Cuándo tiene sentido cada modo: la animación del enfriamiento sirve si el usuario va a arrastrar nodos y quiere ver cómo reacciona el grafo. Si el grafo es solo para mirarlo, la versión síncrona es estrictamente mejor: no gasta trescientos fotogramas y no obliga al lector a esperar a que deje de moverse para poder leerlo. Y si el grafo es grande, ejecutar los tick en un worker y transferir las posiciones al hilo principal es directo, porque d3-force no toca el DOM.

Dos detalles más que ahorran horas: nodo.fx y nodo.fy fijan un nodo en una posición y la simulación deja de moverlo, que es como se implementa el arrastre; y si necesitas que dos ejecuciones produzcan exactamente la misma disposición, hay que fijar las posiciones iniciales de los nodos, porque la disposición depende de dónde empiezan.

El grafo de fuerzas es el gráfico más seductor y el menos informativo, y hay medidas que lo respaldan

Un grafo dirigido por fuerzas es hipnótico: se mueve, se organiza solo, parece revelar estructura. Y en la inmensa mayoría de los casos, no revela nada.

Las posiciones no significan nada. En un gráfico de dispersión, la posición codifica dos variables. En un grafo de fuerzas, la posición es el resultado de un equilibrio de fuerzas arbitrarias que tú has elegido. Que dos nodos estén cerca puede significar que están conectados, o que sus vecinos los empujaron ahí. Estás usando el canal perceptual más preciso que existe, la posición, para codificar nada.

Los ejes tampoco. No hay ejes. Un lector entrenado que se pregunte «qué mide el eje horizontal» no encuentra respuesta, y con razón.

Se convierte en una madeja rápidamente. En cuanto el número de aristas supera unas tres veces el de nodos, los cruces dominan y la imagen deja de tener estructura visible. Es el resultado, no la excepción.

Hay evidencia experimental sobre la alternativa. En un trabajo de Ghoniem, Fekete y Castagliola presentado en InfoVis en 2004 se compararon representaciones de nodos y enlaces frente a matrices de adyacencia en siete tareas de lectura de grafos, y el resultado fue que la matriz gana en casi todas las tareas para grafos de tamaño medio y grande, con una única excepción clara: encontrar un camino entre dos nodos, donde el diagrama de nodos y enlaces es claramente superior.

Eso da un criterio operativo:

Si la tarea es seguir caminos o cadenas, usa el grafo. Es su terreno y no hay sustituto.

Si la tarea es cualquier otra (encontrar el nodo más conectado, ver si dos nodos están conectados, detectar grupos, comparar densidades), la matriz de adyacencia ordenada gana, y además escala mucho mejor: una matriz de quinientos nodos se lee, una madeja de quinientos nodos no.

Si el grafo es en realidad un árbol, y muchos lo son, usa una disposición jerárquica. d3.tree produce algo legible y determinista donde forceSimulation produce una nube.

Y si aun así vas a hacer el grafo de fuerzas, hay dos decisiones que lo mejoran de verdad: filtra las aristas por peso hasta que la estructura sea visible, y fija los nodos importantes en posiciones con significado, de modo que al menos parte de la posición codifique algo. Un grafo con veinte nodos y las aristas fuertes es útil; el mismo con quinientos nodos y todas las aristas es un adorno.

⚔️ Reto práctico

Coge un grafo de al menos doscientos nodos y dibújalo dos veces: con forceSimulation y como matriz de adyacencia ordenada por grado. Después plantéate cuatro preguntas concretas sobre los datos (cuál es el nodo más conectado, hay algún grupo aislado, están conectados A y B, hay camino de A a C) y responde a las cuatro con cada representación, cronometrándote. El reparto de victorias se va a parecer mucho al del experimento.