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.
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:
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:
alphaempieza en 1 y baja hastaalphaMin, que por defecto es 0,001. Es la temperatura: multiplica el efecto de las fuerzas.alphaDecaycontrola la velocidad del enfriamiento. Su valor por defecto está calculado para que la simulación se detenga en unas 300 iteraciones.velocityDecayes 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 temporizadorfor (let i = 0; i < 300; i++) sim.tick(); // 300 pasos de golpedibujar(); // un solo dibujado, con la disposicion finalstop 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.