Saltar al contenido

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.

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.

JS
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 hermanostreemap()  .size([ancho, alto])  .padding(2)  .tile(treemapSquarify)  .round(true)(raiz);                  // escribe x0, y0, x1, y1 en cada nodofor (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:

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

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

JS
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 temporizadorfor (let i = 0; i < 300; i++) sim.tick();   // 300 pasos de golpedibujar();                       // 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.