tva
← Artículos

ReactFlow para visualizar árboles genealógicos

Un árbol genealógico es un grafo. Los datos son un conjunto de nodos —personas— conectados por aristas que representan tipos de relación: progenitor e hijo, cónyuges y hermanos. El problema de visualización consiste en distribuir ese grafo de forma comprensible a distintas escalas, desde una vista de tres generaciones hasta un linaje que abarca muchas generaciones y cientos de personas. ReactFlow proporciona la infraestructura de renderizado e interacción. Las decisiones interesantes están en los tipos de nodos personalizados, el algoritmo de distribución Dagre, los estilos de arista para cada tipo de relación y las estrategias de rendimiento necesarias a medida que crecen los árboles.

Este artículo explica las decisiones de implementación que tomamos, sus contrapartidas y los aspectos en los que las abstracciones de ReactFlow facilitaron y limitaron lo que podíamos construir.


Por qué reactflow

La alternativa que consideramos seriamente fue D3. Los algoritmos de distribución de árboles de D3 son maduros, están bien documentados y llevan años utilizándose para visualizar genealogías. El problema es que D3 exige gestionar el DOM directamente, lo que entra en conflicto con el modelo de reconciliación de React si se quieren usar componentes de React para los elementos interactivos. Los enfoques híbridos funcionan, pero son difíciles de mantener.

ReactFlow mantiene todo dentro del modelo de componentes de React. Los nodos y las aristas son objetos JavaScript simples almacenados en el estado. La biblioteca gestiona el renderizado (nodos HTML y aristas SVG), el desplazamiento, el zoom y los eventos de interacción. Los tipos de nodos personalizados son componentes de React. Esto permite incorporar fácilmente contenido interactivo enriquecido en cada nodo —espacios para fotos, secciones de detalles expandibles y botones de acción— sin salir del modelo de React.

La contrapartida es que ReactFlow impone más convenciones que D3. El modelo de datos de nodos y aristas tiene una estructura específica, la distribución se gestiona externamente (ReactFlow no incluye un algoritmo de distribución) y el rendimiento con cantidades muy elevadas de nodos requiere una gestión más explícita que un enfoque basado únicamente en D3. Para un proyecto de unos pocos cientos de nodos, estas contrapartidas son favorables.

Tipos de nodos personalizados: tarjetas de personas

El sistema de nodos personalizados de ReactFlow es una de sus mejores funcionalidades. Un tipo de nodo personalizado es un componente de React que recibe los datos del nodo como propiedades y renderiza el contenido que se desee. El único requisito es que renderice los componentes Handle en los puntos de conexión donde se unen las aristas.

El componente PersonNode renderiza una tarjeta con el nombre de la persona, el año de nacimiento, el año de fallecimiento (si corresponde) y un espacio para la foto que muestra las iniciales cuando no hay una disponible. La tarjeta tiene un borde discreto cuyo color cambia según la profundidad generacional, una señal visual que ayuda a orientarse en un árbol de muchas generaciones. Las personas vivas y las fallecidas se presentan con tratamientos visuales distintos.

Una decisión de diseño que mejoró considerablemente la claridad fue utilizar puntos de conexión separados para cada tipo de arista: arriba y abajo para las aristas entre progenitores e hijos, y a la izquierda y a la derecha para las aristas entre cónyuges. Las aristas entre progenitores e hijos conectan la parte inferior con la superior, de modo que el árbol avanza verticalmente en una dirección consistente. Las aristas entre cónyuges conectan los laterales al mismo nivel generacional. Separar los tipos de arista según la posición de sus puntos de conexión permite reconocer de inmediato la dirección de las relaciones.

El componente del nodo también gestiona el estado «contraído» de los subárboles profundos. Cuando se contrae un subárbol, el nodo muestra un indicador con el número de descendientes ocultos. Al hacer clic en él, se expande el subárbol. Este modelo de interacción es esencial para que los árboles grandes sean utilizables: una vista completa de cuatrocientos nodos resulta ilegible, pero una vista con la mayor parte del árbol contraída, cuyas ramas se expanden según se necesitan, permite navegar por él.

Distribución con dagre para el posicionamiento jerárquico

ReactFlow no calcula las posiciones de los nodos. Estas se proporcionan como parte de sus datos y la biblioteca los renderiza en las posiciones indicadas. En un árbol genealógico, calcularlas manualmente exigiría volver a implementar un algoritmo de distribución jerárquica. La opción habitual es Dagre, una biblioteca de distribución de grafos acíclicos dirigidos que genera distribuciones jerárquicas claras con un mínimo de cruces.

La integración con Dagre se ejecuta fuera del ciclo de renderizado de ReactFlow. Cuando cambian los datos de los nodos o las aristas, una función de distribución se ejecuta de forma síncrona, asigna posiciones a cada nodo y devuelve el arreglo de nodos posicionados que ReactFlow renderiza. La función recibe los nodos y las aristas sin procesar, construye un grafo de Dagre, ejecuta la distribución y recupera los valores calculados de x e y.

import dagre from '@dagrejs/dagre';

function layoutTree(nodes, edges, nodeWidth, nodeHeight) {
  const g = new dagre.graphlib.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({ rankdir: 'TB', ranksep: 80, nodesep: 40 });

  nodes.forEach(node => {
    g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
  });
  edges.forEach(edge => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  return nodes.map(node => {
    const { x, y } = g.node(node.id);
    return { ...node, position: { x: x - nodeWidth / 2, y: y - nodeHeight / 2 } };
  });
}

El parámetro rankdir de Dagre controla la dirección de la distribución. «TB» (de arriba abajo) es la opción natural para un árbol genealógico donde el tiempo avanza hacia abajo entre generaciones. ranksep controla el espaciado vertical entre generaciones; nodesep, el espaciado horizontal entre hermanos. Estos valores deben ajustarse a los tamaños de nodo utilizados.

La limitación de Dagre para los árboles genealógicos es que trata el grafo como un grafo acíclico dirigido (DAG). Las aristas entre cónyuges, que conectan dos nodos del mismo nivel y no tienen dirección, no encajan de forma natural en un DAG. La solución consiste en ejecutar la distribución de Dagre utilizando solo las aristas entre progenitores e hijos para establecer los niveles generacionales y las posiciones horizontales, y dibujar después las aristas entre cónyuges por separado como líneas rectas en la misma coordenada y. La función simplificada anterior distribuye todas las aristas que recibe, por lo que debe filtrar la lista para incluir solo las aristas entre progenitores e hijos antes de llamarla. Además, Dagre por sí solo no garantiza que los cónyuges queden uno al lado del otro ni en el mismo nivel: un cónyuge sin progenitores registrados ni hijos en el árbol visible no tiene ninguna arista entre progenitores e hijos. Por eso, es necesario ajustar las posiciones de los cónyuges después de la distribución para lograr el resultado previsto: los cónyuges aparecen uno al lado del otro, al mismo nivel, con una arista horizontal entre ellos.

Estilos de arista para los tipos de relación

ReactFlow admite componentes de arista personalizados del mismo modo que admite componentes de nodo personalizados. Cada tipo de relación —entre progenitores e hijos, cónyuges, por adopción o por afinidad— utiliza un estilo de arista distinto. Las aristas entre progenitores e hijos son líneas continuas. Las de los cónyuges son conectores horizontales finos. Las relaciones de adopción utilizan una línea de trazos. Las relaciones con padrastros o madrastras utilizan una línea de puntos.

El componente de arista recibe como propiedades las posiciones de los nodos de origen y destino y renderiza un trazado SVG. Para las aristas rectas, esto es sencillo. Para las aristas curvas entre nodos situados en posiciones distintas, ReactFlow proporciona funciones auxiliares de trazado (getStraightPath, getBezierPath y getSmoothStepPath) que generan la cadena de trazado SVG correcta para las coordenadas de los extremos.

Una interacción que permiten los componentes de arista personalizados es mostrar una etiqueta al pasar el cursor, con la descripción completa de la relación. Esto resulta útil en estructuras familiares complejas donde el estilo visual por sí solo puede ser ambiguo: al pasar el cursor sobre una arista entre dos nodos, se muestra «Progenitor adoptivo — 1987» o «Matrimonio en 1975, divorcio en 1989». La etiqueta se renderiza como un foreignObject dentro del SVG, lo que permite usar HTML y CSS estándar para el estilo de la información emergente.

Rendimiento con árboles grandes

ReactFlow gestiona árboles de tamaño moderado, de hasta unos pocos cientos de nodos, sin optimizaciones específicas. A partir de ese tamaño, algunas estrategias se vuelven necesarias.

ReactFlow puede renderizar solo los nodos y las aristas dentro del área visible actual. Esta función está desactivada por defecto y se activa con la prop onlyRenderVisibleElements; una vez activada, resuelve la mayor parte de los problemas de rendimiento de los árboles grandes, siempre que los componentes de nodo no sean costosos de renderizar. Un PersonNode que solicite datos externos o ejecute cálculos pesados en cada renderizado provocará tirones, aunque se omitan los nodos fuera del área visible. Mantenga los componentes de nodo puros y rápidos.

La segunda estrategia consiste en memoizar los arreglos de nodos y aristas. Como ReactFlow vuelve a renderizar cuando cambia la referencia de cualquiera de estos arreglos, pasar arreglos recién creados en cada renderizado del componente padre genera trabajo innecesario. Encapsular el cálculo de distribución en useMemo, con una dependencia de los datos sin procesar y no del estado derivado, garantiza que el cálculo costoso y el renderizado asociado solo se ejecuten cuando cambian los datos subyacentes.

La tercera estrategia es la interacción de contraer y expandir descrita en la sección sobre tipos de nodos. Un árbol de 400 nodos que solo muestra 40 en un momento dado tiene un rendimiento similar al de un árbol de 40 nodos. El estado de contracción se gestiona en el estado de React a nivel de visualización, no en el modelo de datos subyacente. Al contraer un subárbol, se excluye de los arreglos de nodos y aristas que se pasan a ReactFlow; al expandirlo, se añade de nuevo. La distribución de Dagre se vuelve a ejecutar con el conjunto filtrado.

Consideraciones de diseño de la interacción

Además de la mecánica de distribución y renderizado, el modelo de interacción de una visualización genealógica exige atención. ReactFlow proporciona desplazamiento y zoom de forma nativa, pero la vista inicial y el nivel de zoom son importantes para la facilidad de uso. Llamar a fitView() después de renderizar la distribución garantiza que el árbol sea visible y tenga una escala adecuada al cargarlo.

La navegación entre personas —hacer clic en una para centrarla en el área visible o seguir las aristas hasta las generaciones adyacentes— requiere los métodos de instancia de ReactFlow setCenter() y fitBounds(). Se accede a ellos mediante el hook useReactFlow() y deben invocarse con la animación suave activada para dar al usuario una sensación de continuidad espacial.

La búsqueda y el filtrado —encontrar a una persona por su nombre y saltar a su posición en el árbol— son requisitos habituales en cualquier árbol genealógico de cierta complejidad. La implementación resalta el nodo encontrado con una animación pulsante después de centrarlo en el área visible. Para ello, añade durante un breve período una clase de animación CSS al nodo correspondiente y después la elimina. El método updateNode de ReactFlow gestiona esta operación sin recalcular la distribución de todo el árbol.

Artículos relacionados

Artículos relacionados