ReactFlow para visualização de árvores genealógicas
Uma árvore genealógica é um grafo. Os dados são um conjunto de nós — pessoas — conectados por arestas que representam tipos de parentesco: entre pais e filhos, cônjuges ou irmãos. O problema de visualização é como dispor esse grafo de forma inteligível em diferentes escalas, de uma visão de três gerações a uma linhagem extensa abrangendo muitas gerações e centenas de indivíduos. O ReactFlow fornece a infraestrutura de renderização e interação. As decisões interessantes estão nos tipos de nó personalizados, no algoritmo de layout Dagre, no estilo das arestas para tipos de relacionamento e nas estratégias de desempenho que se tornam necessárias conforme as árvores crescem.
Este artigo aborda as decisões de implementação que tomamos, as vantagens e limitações envolvidas e os pontos em que as abstrações do ReactFlow ajudaram ou restringiram o que podíamos construir.
Por que ReactFlow
A alternativa que consideramos seriamente foi o D3. Os algoritmos de layout de árvore do D3 são maduros, bem documentados e têm sido usados para visualização genealógica há anos. O problema é que o D3 requer o gerenciamento direto do DOM, o que conflita com o modelo de reconciliação do React se você quiser componentes React para os elementos interativos. As abordagens híbridas funcionam, mas são difíceis de manter.
O ReactFlow mantém tudo no modelo de componentes do React. Nós e arestas são objetos JavaScript simples no estado. A biblioteca lida com renderização (nós HTML e arestas SVG), deslocamento e zoom, e eventos de interação. Os tipos de nó personalizados são apenas componentes React. Isso facilita incorporar conteúdo interativo rico em cada nó — espaços reservados para fotos, seções de detalhes expansíveis, botões de ação — sem sair do modelo do React.
Em contrapartida, o ReactFlow impõe mais convenções do que o D3. O modelo de dados de nó e aresta tem uma forma específica, o layout é tratado externamente (o ReactFlow não inclui um algoritmo de layout) e o desempenho com quantidades muito grandes de nós requer gerenciamento mais explícito do que uma abordagem D3 pura. Para um projeto na faixa de algumas centenas de nós, esse equilíbrio entre vantagens e limitações é favorável.
Tipos de nós personalizados: cartões de pessoas
O sistema de nós personalizados do ReactFlow é um de seus pontos mais fortes. Um tipo de nó personalizado é um componente React que recebe os dados do nó como props e renderiza o que você quiser. O único requisito é que ele renderize os componentes Handle nos pontos de conexão onde as arestas se conectam.
O componente PersonNode renderiza um cartão com o nome da pessoa, ano de nascimento, ano de morte (se aplicável) e um espaço reservado para foto que mostra as iniciais da pessoa quando nenhuma foto está disponível. O cartão tem uma borda sutil que muda de cor com base no nível da geração — uma pista visual que ajuda a orientar quem navega por uma árvore com muitas gerações. Pessoas vivas e falecidas usam tratamentos visuais distintos.
Uma decisão de design que melhorou significativamente a clareza: o nó usa pontos de conexão separados para cada tipo de aresta: no topo e na parte inferior para as arestas pai-filho, e nas laterais esquerda e direita para as arestas de cônjuge. As arestas pai-filho ligam a parte inferior do nó de origem à parte superior do nó de destino, então a árvore flui verticalmente com uma direção consistente. As arestas de cônjuge ligam as laterais dos nós no mesmo nível de geração. Essa separação dos tipos de aresta pela posição dos pontos de conexão torna a direcionalidade dos relacionamentos imediatamente legível.
O componente de nó também lida com o estado “recolhido” para subárvores profundas. Quando uma subárvore é recolhida, o nó renderiza um indicador mostrando o número de descendentes ocultos. Clicar no indicador expande a subárvore. Esse modelo de interação é essencial para tornar árvores grandes utilizáveis — uma visualização com todos os quatrocentos nós expostos é ilegível, mas uma visão onde a maior parte da árvore está recolhida e você expande os ramos sob demanda é navegável.
Layout Dagre para posicionamento hierárquico
O ReactFlow não calcula as posições dos nós. Você fornece posições como parte dos dados do nó, e a biblioteca os renderiza nas posições indicadas. Para uma árvore genealógica, calcular essas posições manualmente exigiria reimplementar um algoritmo de layout hierárquico. A escolha padrão é o Dagre, uma biblioteca de layout para grafos acíclicos direcionados que produz layouts hierárquicos limpos com cruzamentos mínimos.
A integração do Dagre fica fora do ciclo de renderização do ReactFlow. Quando os dados de nós ou arestas mudam, uma função de layout é executada de forma síncrona, atribui posições a cada nó e retorna o array de nós posicionados que o ReactFlow renderiza. A função recebe os nós e arestas brutos, constrói um grafo Dagre, executa o layout e lê os valores x e y calculados.
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 } };
});
}
O parâmetro rankdir do Dagre controla a direção do layout. “TB” (de cima para baixo) é a escolha natural para uma árvore genealógica onde o tempo flui para baixo através das gerações. ranksep controla o espaçamento vertical entre gerações; nodesep controla o espaçamento horizontal entre irmãos. Esses valores precisam de ajuste para os tamanhos específicos de nó que você está usando.
A limitação do Dagre para árvores genealógicas é que ele trata o grafo como um DAG. As arestas de cônjuge — que conectam dois nós no mesmo nível e são não direcionadas — não são arestas DAG naturais. A solução alternativa é executar o layout Dagre usando apenas arestas pai-filho para estabelecer níveis de geração e posições horizontais, em seguida desenhar as arestas de cônjuge separadamente como linhas retas na mesma coordenada y. A função simplificada acima calcula o layout de todas as arestas que recebe, portanto filtre a lista para incluir apenas arestas pai-filho antes de chamá-la. O Dagre sozinho também não garante que os cônjuges fiquem lado a lado ou no mesmo nível — um cônjuge sem pais registrados e sem filhos na árvore visível não tem nenhuma aresta pai-filho. Por isso, as posições dos cônjuges precisam de uma etapa de ajuste após o layout para obter o resultado desejado: cônjuges aparecem lado a lado no mesmo nível com uma aresta horizontal entre eles.
Estilo das arestas por tipo de relacionamento
O ReactFlow suporta componentes de aresta personalizados da mesma forma que os componentes de nó personalizados. Cada tipo de relacionamento — pai-filho, cônjuge, adotivo, com padrasto ou madrasta — usa um estilo de aresta distinto. As arestas pai-filho são linhas contínuas. As arestas de cônjuge são conectores horizontais finos. Os relacionamentos adotivos usam uma linha tracejada. Os relacionamentos com padrasto ou madrasta usam uma linha pontilhada.
O componente de aresta recebe as posições dos nós de origem e destino como props e renderiza um caminho SVG. Para arestas retas, isso é trivial. Para arestas curvas entre nós em posições diferentes, o ReactFlow fornece um conjunto de funções auxiliares de caminho (getStraightPath, getBezierPath, getSmoothStepPath) que geram a string de caminho SVG correta para as coordenadas das extremidades fornecidas.
Uma interação que os componentes de aresta personalizados habilitam é um rótulo de aresta que aparece ao passar o mouse, mostrando a descrição completa do relacionamento. Isso é útil para estruturas familiares complexas onde o estilo visual por si só pode ser ambíguo — passar o mouse em uma aresta entre dois nós mostra “Pai adotivo — 1987” ou “Casados em 1975, divorciados em 1989.” O rótulo é renderizado como um foreignObject no SVG, o que permite usar HTML e CSS padrão para o estilo da dica de contexto.
Desempenho com árvores grandes
O ReactFlow lida com árvores de tamanho moderado — até algumas centenas de nós — sem otimização específica. Acima desse tamanho, algumas estratégias se tornam necessárias.
O ReactFlow pode renderizar apenas os nós e as arestas dentro da área de visualização atual. Esse recurso é desabilitado por padrão e é habilitado com a prop onlyRenderVisibleElements; uma vez habilitado, resolve a maior parte dos problemas de desempenho para árvores grandes, desde que os próprios componentes de nó não sejam caros para renderizar. Um PersonNode que aciona buscas de dados externos ou cálculos pesados em cada renderização causará instabilidade independentemente da exclusão de nós fora da área visível. Mantenha os componentes de nó puros e rápidos.
A segunda estratégia é memoizar os arrays de nós e arestas. Como o ReactFlow renderiza novamente sempre que os arrays de nós ou arestas mudam por referência, passar arrays recém-construídos em cada renderização do componente pai causa trabalho desnecessário. Envolver o cálculo de layout em useMemo com uma dependência nos dados brutos — não no estado derivado — garante que o cálculo de layout custoso e a nova renderização associada ocorram apenas quando os dados subjacentes realmente mudam.
A terceira estratégia é a interação de recolhimento e expansão descrita na seção de tipo de nó. Uma árvore com 400 nós que exibe apenas 40 a qualquer momento tem desempenho semelhante ao de uma árvore de 40 nós. O estado de recolhimento é gerenciado no estado do React no nível da visualização, não no modelo de dados subjacente. Ao recolher uma subárvore, ela é excluída dos arrays de nós e arestas passados para o ReactFlow; ao expandi-la, ela é incluída novamente. O layout Dagre é executado novamente com o conjunto filtrado.
Considerações de design de interação
Além da mecânica de layout e renderização, o modelo de interação para uma visualização de árvore genealógica requer reflexão cuidadosa. O deslocamento e o zoom são fornecidos pelo ReactFlow por padrão, mas a visão inicial e o nível de zoom importam significativamente para a usabilidade. Chamar fitView() após a renderização do layout garante que a árvore seja visível e adequadamente dimensionada no carregamento.
A navegação entre indivíduos — clicar em uma pessoa para centralizá-la na área de visualização, seguir arestas para gerações adjacentes — requer os métodos de instância do ReactFlow setCenter() e fitBounds(). Esses são acessados via o hook useReactFlow() e devem ser chamados com animação suave habilitada para dar ao usuário uma sensação de continuidade espacial.
Busca e filtro — encontrar uma pessoa específica pelo nome e ir até sua posição na árvore — é um requisito comum para qualquer árvore genealógica não trivial. A implementação faz o nó correspondente pulsar com destaque após centralizar a área de visualização nele. Isso é feito adicionando uma classe de animação CSS ao nó correspondente por uma curta duração, depois removendo-a. O método updateNode do ReactFlow lida com isso sem acionar um novo cálculo completo do layout da árvore.
Artigos relacionados
- Tabelas de fechamento para cálculo de relacionamentos — a camada de banco de dados que permite responder à pergunta “qual é o parentesco entre essas duas pessoas?” em uma única consulta, complementando a camada de visualização do ReactFlow
- Bandeiras em widgets: por que códigos ISO de país superam nomes dependentes da localidade — outro projeto onde identificadores estáveis para entidades (códigos de país em vez de nomes) evitaram uma classe de bugs sutis