Biblioteca de visualização de grafos: como resolvemos o dilema Canvas vs HTML no Gravity UI

Olá! Meu nome é Andrey, sou desenvolvedor de interfaces na equipe de User Experience de serviços de infraestrutura do Yandex. Desenvolvemos o Gravity UI — uma design system open source e uma biblioteca de componentes React, usada por dezenas de produtos dentro e fora da empresa. Hoje vou contar como nos deparamos com a tarefa de visualizar grafos complexos, por que as soluções existentes não nos atenderam e como, no fim, surgiu o @gravity‑ui/graph — uma biblioteca que decidimos abrir para a comunidade.

Esta história começou com um problema prático: precisávamos renderizar grafos com 10.000+ elementos e componentes interativos. No Yandex há muitos projetos em que os usuários criam pipelines complexos de processamento de dados — de processos ETL simples até machine learning. Quando esses pipelines são criados programaticamente, a quantidade de blocos pode chegar a dezenas de milhares.

As soluções existentes não nos atendiam:

  • Bibliotecas HTML/SVG têm boa aparência e são convenientes no desenvolvimento, mas começam a travar já com centenas de elementos.
  • Soluções em Canvas dão conta da performance, mas exigem uma quantidade enorme de código para criar elementos de UI complexos.

Desenhar um botão com cantos arredondados e gradiente em Canvas não é difícil. Porém, os problemas aparecem quando é preciso criar controles complexos próprios ou uma marcação/layout — será necessário escrever dezenas de linhas de comandos de desenho de baixo nível. Cada elemento de interface precisa ser programado do zero — do tratamento de cliques às animações. E nós precisávamos de componentes de UI completos: botões, selects, campos de entrada, drag‑and‑drop.

Decidimos não escolher entre Canvas e HTML, e sim usar o melhor de ambas as tecnologias. A ideia era simples: alternar automaticamente entre os modos dependendo do quão perto o usuário está visualizando o grafo.

Full screen image

De onde veio a demanda

Nirvana e seus grafos

No Yandex temos o serviço Nirvana para criar e executar grafos de processamento de dados (nós escrevemos sobre ele lá em 2018). O serviço é grande, popular e existe há muito tempo.

Parte dos usuários cria grafos manualmente — usam o mouse, adicionam blocos, conectam nós. Com esses grafos não há problemas: não há muitos blocos e tudo funciona muito bem. Mas há projetos que criam grafos programaticamente. E aí começam as dificuldades: eles podem colocar em um único grafo até 10.000 operações. E fica assim:

Full screen image
E assim:

image
image
image
image
image

Grafos assim, uma combinação comum de HTML + SVG simplesmente não aguenta. O navegador começa a travar, a memória vaza, o usuário sofre. Tentamos resolver o problema de forma direta: otimizar a renderização de HTML, mas cedo ou tarde esbarrávamos em limites físicos — o DOM simplesmente não foi pensado para milhares de elementos interativos flutuantes visíveis ao mesmo tempo.

Precisávamos de outra solução, e no navegador só nos restava o Canvas. Só ele pode garantir a performance necessária.

A primeira ideia foi encontrar uma solução pronta. Era 2017–2018, e nós revisamos as bibliotecas populares para Canvas ou renderização de grafos, mas todas esbarravam no mesmo problema: ou você usa Canvas e elementos primitivos, ou usa HTML/SVG e sacrifica a performance.

E se não escolhermos?

Level of Details: inspiração do GameDev

No GameDev e na cartografia existe um conceito muito bom — Level of Details (LOD). Essa técnica nasceu de uma necessidade — como mostrar um mundo enorme sem matar a performance?

A ideia é simples: um mesmo objeto pode ter vários níveis de detalhamento dependendo de quão perto ele é observado. Em jogos, isso é especialmente visível:

  • Ao longe, você vê montanhas — polígonos simples com textura básica.
  • Ao se aproximar — surgem detalhes: grama, pedras, sombras.
  • Mais perto ainda — dá para ver folhas individuais nas árvores.

Ninguém renderiza milhões de polígonos de grama quando o jogador está no topo da montanha olhando para longe.

Em mapas, o princípio é o mesmo — cada nível de zoom tem seu conjunto de dados e seu nível de detalhamento:

  • Zoom de continente — aparecem apenas países.
  • Aproximando da cidade — surgem ruas e bairros.
  • Mais perto — números de casas, cafés, pontos de ônibus.

Entendemos: o usuário não precisa de botões interativos em um zoom amplo de um grafo com 10.000 blocos — ele nem vai vê-los, nem conseguirá trabalhar com eles.

Mais ainda, tentar renderizar 10.000 elementos HTML simultaneamente vai congelar o navegador. Mas quando o usuário dá zoom em uma área específica, a quantidade de blocos visíveis cai drasticamente — de 10.000 para, digamos, 50. É aí que sobram recursos para componentes HTML com rica interatividade.

Três níveis do nosso esquema Level of Details

Minimalistic (escala 0,1–0,3) — Canvas com primitivos simples

Nesse modo, o usuário vê a arquitetura geral do sistema: onde ficam os principais grupos de blocos e como eles se conectam. Cada bloco é um retângulo simples com codificação básica por cores. Sem textos, botões ou ícones detalhados. Em compensação, é possível renderizar confortavelmente milhares de elementos. Nesse nível, o usuário escolhe uma área para estudar em detalhe.

Full screen image

Schematic (escala 0,3–0,7) — Canvas com detalhes

Surgem os nomes dos blocos, ícones de estado e âncoras para conexões. O texto é renderizado via Canvas API — é rápido, mas as possibilidades de estilização são limitadas. As ligações entre os blocos ficam mais informativas: dá para mostrar direção do fluxo de dados, status da conexão. É um modo de transição, no qual a performance do Canvas se combina com uma informatividade básica.

Full screen image

Detailed (escala 0,7+) — HTML com interatividade completa

Aqui os blocos se transformam em componentes de interface completos: com botões de controle, campos de parâmetros, barras de progresso, selects. Dá para usar quaisquer recursos de HTML/CSS e conectar bibliotecas de UI. Nesse modo, normalmente cabem no viewport não mais que 20–50 blocos, o que é confortável para trabalho detalhado.

Full screen image

E se calcularmos o FPS para escolher o nível de detalhamento?

Tivemos abordagens para escolher a detalhação com base no FPS. Mas descobrimos que essa abordagem cria instabilidade — quando a performance cresce, o sistema muda para um modo mais detalhado, o que reduz o FPS e pode provocar a troca de volta — e assim por diante.

Como chegamos à solução

Ok, LOD é ótimo. Mas a implementação exige Canvas por causa da performance, e isso traz uma nova dor de cabeça. Desenhar no Canvas não é tão difícil — os problemas aparecem quando precisamos fazer interatividade.

Problema: como entender onde o usuário clicou?

Em HTML, tudo é simples: clicou no botão — o evento chega diretamente ao elemento. No Canvas é mais complicado: clicou no canvas — e daí? Precisamos descobrir por conta própria em qual elemento o usuário clicou.

Basicamente existem três abordagens:

  • Pixel Testing (color picking),
  • Geometric approach (varredura simples de todos os elementos),
  • Spatial Indexing (índice espacial).

Pixel Testing (color picking)

A ideia é simples: criamos um segundo canvas invisível, copiamos a cena para lá, mas pintamos cada elemento com uma cor única, que será tratada como o ID do objeto. Ao clicar, lemos a cor do pixel sob o cursor do mouse via getImageData e assim obtemos o ID do elemento.

Prós

Contras

  • Implementa-se em algumas dezenas de linhas

  • Não exige estruturas de dados adicionais

  • O anti-aliasing do Canvas mistura cores — um clique na borda da forma pode retornar um ID “inválido”

  • Desativar anti‑aliasing em 2D‑Canvas não é possível

  • O segundo canvas duplica a memória e dobra o passe de renderização

Para cenas pequenas, o método serve, mas com 10.000+ elementos a taxa de erros se torna inaceitável — deixamos o Pixel Testing de lado.

Geometric approach (varredura simples de todos os elementos)

A ideia é simples: percorremos todos os elementos e verificamos se o ponto do clique está dentro do elemento.

Prós

Contras

  • Implementa-se em algumas dezenas de linhas

  • Não exige estruturas de dados adicionais

  • Funciona muito lentamente com um grande número de elementos

  • Não serve para cenas grandes

Spatial Indexing

Uma evolução da abordagem geométrica. Na abordagem geométrica, nós esbarrávamos na quantidade de elementos. Algoritmos de índice espacial tentam agrupar elementos próximos, usando principalmente árvores, o que permite reduzir a complexidade para log n.

Existem muitos algoritmos de índice espacial; escolhemos a estrutura de dados R‑Tree na forma da biblioteca rbush.

R‑Tree — como o nome sugere, é uma árvore em que cada objeto é colocado dentro de um retângulo de tamanho mínimo (MBR), e então esses retângulos são agrupados em retângulos maiores. Assim forma-se uma árvore em que cada retângulo contém outros retângulos.

Full screen image

Imagem da Wikipédia R‑tree

Para buscar no RTree, precisamos descer pela árvore (para dentro do retângulo) até chegar ao elemento específico. O caminho é escolhido verificando a interseção do retângulo de busca com o MBR. Todos os ramos cujas bounding boxes nem tocam o retângulo de busca são descartados imediatamente — por isso a profundidade do percurso normalmente se limita a 3–5 níveis, e a própria busca leva microssegundos mesmo com dezenas de milhares de elementos.

Essa opção funciona, embora seja mais lenta (O (log n) no melhor caso e O (n) no pior) do que o pixel testing, mas é mais precisa e exige menos memória.

Modelo de eventos

Com base no RTree, agora podemos construir nosso modelo de eventos. Quando o usuário clica, inicia-se um hit-test: formamos um retângulo de 1×1 pixel nas coordenadas do cursor e buscamos sua interseção no R‑Tree. Ao obter o elemento em que esse retângulo cai, delegamos o evento a esse elemento. Se o elemento não interromper o evento, então o evento é passado ao seu pai e assim até a raiz. O comportamento desse modelo se parece com o comportamento do modelo de eventos do navegador ao qual estamos acostumados. Os eventos podem ser interceptados, prevenidos ou ter a propagação interrompida.

Como mencionei, no hit-test formamos um retângulo de 1×1 pixel, o que significa que podemos formar um retângulo de qualquer tamanho. E isso vai nos ajudar a fazer mais uma otimização muito importante — Spatial Culling.

Spatial Culling

Spatial Culling é uma técnica de otimização de renderização, voltada a não desenhar o que não é visível. Por exemplo, para não desenhar objetos que estão fora do espaço da câmera ou que estão bloqueados por outros elementos da cena. Como nosso grafo é desenhado em 2D, basta não desenhar apenas os objetos que estão fora da área visível da câmera (viewport).

Como funciona:

  • a cada movimento ou zoom da câmera, formamos um retângulo igual ao viewport atual;
  • buscamos sua interseção no R‑Tree;
  • o resultado é uma lista de elementos que realmente estão visíveis;
  • renderizamos apenas eles; todo o resto é ignorado.

Esse truque torna a performance quase independente do número total de elementos: se couberem 40 blocos no frame, a biblioteca desenhará exatamente 40, e não dezenas de milhares escondidos fora da tela. Em escalas distantes, um grande número de elementos cai no viewport, então desenhamos primitivos leves em Canvas; ao aproximar a câmera, a quantidade de elementos diminui e os recursos liberados permitem mudar para o modo HTML com detalhamento completo.

Juntando tudo, fica um esquema simples:

  • Canvas é responsável pela velocidade,
  • HTML — pela interatividade,
  • R‑Tree e Spatial Culling combinam tudo discretamente em um sistema único, permitindo entender rapidamente quais elementos podem ser desenhados na camada HTML.

Enquanto a câmera se move, um viewport pequeno pede ao R‑Tree apenas os objetos que realmente estão no frame. Essa abordagem nos permite desenhar grafos realmente grandes, ou pelo menos ter uma reserva de performance até que o usuário restrinja o viewport.

No fim, no núcleo a biblioteca contém:

  • modo Canvas com primitivos simples;
  • modo HTML com detalhamento completo;
  • R‑Tree e Spatial Culling para otimização de performance;
  • um modelo de eventos familiar.

Mas para produção isso não é suficiente; é preciso ter a capacidade de estender a biblioteca e customizá-la para as suas necessidades.

Personalização

A biblioteca oferece duas formas complementares de extensão e alteração de comportamento:

  • Sobrescrever componentes base. Alteramos a lógica de Block, Anchor, Connection padrão.
  • Estender via camadas (Layers). Adicionamos funcionalidades essencialmente novas acima/abaixo da cena existente.

Sobrescrever componentes

Quando é preciso modificar a aparência ou o comportamento de elementos já existentes, herdamos da classe base e sobrescrevemos os métodos principais. Depois, registramos o componente com um nome próprio.

Customização de blocos

Por exemplo, se você precisa criar um grafo com barras de progresso nos blocos — digamos, para mostrar o status de execução de tarefas em um pipeline — você pode customizar facilmente os blocos padrão:

import { CanvasBlock } from "@gravity‑ui/graph";

class ProgressBlock extends CanvasBlock {
  // Forma base do bloco com cantos arredondados
  public override renderBody(ctx: CanvasRenderingContext2D): void {
    ctx.fillStyle = "#ddd";
    ctx.beginPath();
    ctx.roundRect(this.state.x, this.state.y, this.state.width, this.state.height, 12);
    ctx.fill();
    ctx.closePath();
  }

  public renderSchematicView(ctx: CanvasRenderingContext2D): void {
    const progress = this.state.meta?.progress || 0;

    // Desenhamos a base do bloco
    this.renderBody(ctx);

    // Barra de progresso com indicação por cores
    const progressWidth = (this.state.width - 20) * (progress / 100);
    ctx.fillStyle = progress < 50 ? "#ff6b6b" : progress < 80 ? "#feca57" : "#48cae4";
    ctx.fillRect(this.state.x + 10, this.state.y + this.state.height - 15, progressWidth, 8);

    // Borda da barra de progresso
    ctx.strokeStyle = "#ddd";
    ctx.lineWidth = 1;
    ctx.strokeRect(this.state.x + 10, this.state.y + this.state.height - 15, this.state.width - 20, 8);

    // Texto com percentuais e nome
    ctx.fillStyle = "#2d3436";
    ctx.font = "12px Arial";
    ctx.textAlign = "center";
    ctx.fillText(`${Math.round(progress)}%`, this.state.x + this.state.width / 2, this.state.y + 20);
    ctx.fillText(this.state.name, this.state.x + this.state.width / 2, this.state.y + 40);
  }
}

Customização de conexões

Da mesma forma, se você precisa mudar o comportamento e a aparência das ligações — por exemplo, mostrar a intensidade do fluxo de dados entre blocos — você pode criar uma conexão customizada:

import { BlockConnection } from "@gravity-ui/graph";

class DataFlowConnection extends BlockConnection {
  public override style(ctx: CanvasRenderingContext2D) {
    // Obtemos dados do fluxo a partir dos blocos conectados
    const sourceBlock = this.sourceBlock;
    const targetBlock = this.targetBlock;

    const sourceProgress = sourceBlock?.state.meta?.progress || 0;
    const targetProgress = targetBlock?.state.meta?.progress || 0;

    // Calculamos a intensidade do fluxo com base no progresso dos blocos
    const flowRate = Math.min(sourceProgress, targetProgress);
    const isActive = flowRate > 10; // Fluxo está ativo quando o progresso > 10%

    if (isActive) {
      // Fluxo ativo -- linha verde grossa
      ctx.strokeStyle = "#00b894";
      ctx.lineWidth = Math.max(2, Math.min(6, flowRate / 20));
    } else {
      // Fluxo inativo -- linha cinza tracejada
      ctx.strokeStyle = "#ddd";
      ctx.lineWidth = this.context.camera.getCameraScale();
      ctx.setLineDash([5, 5]);
    }

    return { type: "stroke" };
  }
}

Uso de componentes customizados

Registramos os componentes criados nas configurações do grafo:

const customGraph = new Graph({
  blocks: [
    {
      id: "task1",
      is: "progress",
      x: 100,
      y: 100,
      width: 200,
      height: 80,
      name: "Data Processing",
      meta: { progress: 75 },
    },
    {
      id: "task2",
      is: "progress",
      x: 400,
      y: 100,
      width: 200,
      height: 80,
      name: "Analysis",
      meta: { progress: 30 },
    },
    {
      id: "task3",
      is: "progress",
      x: 700,
      y: 100,
      width: 200,
      height: 80,
      name: "Output",
      meta: { progress: 5 },
    },
  ],
  connections: [
    { sourceBlockId: "task1", targetBlockId: "task2" },
    { sourceBlockId: "task2", targetBlockId: "task3" },
  ],
  settings: {
    // Registramos os blocos customizados
    blockComponents: {
      'progress': ProgressBlock,
    },
    // Registramos a conexão customizada para todas as ligações
    connection: DataFlowConnection,
    useBezierConnections: true,
  },
});

customGraph.setEntities({
  blocks: [
    {
    is: 'progress',
    id: '1',
    name: "progress block',
    x: 10, 
    y: 10, 
    width: 10, 
    height: 10,
    anchors: [],
    selected: false,
    }
  ]
})

customGraph.start();

Resultado

No resultado, obtemos um grafo em que:

  • os blocos mostram o progresso atual com indicação por cores;
  • as conexões visualizam o fluxo de dados: fluxos ativos — verdes e grossos; inativos — cinzas e tracejados;
  • ao dar zoom, os blocos alternam automaticamente para o modo HTML com interatividade completa.

Extensão via camadas

Camadas são elementos Canvas ou HTML adicionais que são inseridos no “espaço” do grafo. Na prática, cada camada é um canal de renderização separado, que pode conter seu próprio canvas para gráficos rápidos ou um container HTML para elementos interativos complexos.

Aliás, é exatamente via camadas que funciona a integração React da nossa biblioteca: componentes React são renderizados na camada HTML via React Portal.

Arquitetura de camadas

Camadas são mais uma solução-chave para o dilema Canvas vs HTML. Elas sincronizam as posições dos elementos Canvas e HTML, garantindo sua sobreposição correta. Isso permite alternar Canvas e HTML sem rupturas, permanecendo em um único espaço. O grafo consiste em camadas independentes sobrepostas umas às outras:

Full screen image

As camadas podem trabalhar em dois sistemas de coordenadas:

  • Vinculadas ao grafo (transformByCameraPosition: true):

    • elementos se movem junto com a câmera,
    • blocos, conexões, elementos do grafo.
  • Fixas na tela (transformByCameraPosition: false):

    • permanecem no lugar ao panoramizar,
    • toolbars, legendas, controles de UI.

Como funciona a integração React

A camada com integração React é bem ilustrativa para demonstrar o que são camadas. Primeiro, vamos olhar para um componente que destaca a lista de blocos que estão na área visível da câmera. Para isso, precisamos assinar mudanças da câmera e, após cada mudança, checar a interseção do viewport da câmera com o hitbox dos elementos.

import { Graph } from "@gravity-ui/graph";

const BlocksList = ({ graph, renderBlock }: { graph: Graph, renderBlock: (graph: Graph, block: TBlock) => React.JSX.Element }) => {
  const [blocks, setBlocks] = useState([]);

  const updateVisibleList = useCallback(() => {
    const cameraState = graph.cameraService.getCameraState();
    const CAMERA_VIEWPORT_TRESHOLD = 0.5;
    const x = -cameraState.relativeX - cameraState.relativeWidth * CAMERA_VIEWPORT_TRESHOLD;
    const y = -cameraState.relativeY - cameraState.relativeHeight * CAMERA_VIEWPORT_TRESHOLD;
    const width = -cameraState.relativeX + cameraState.relativeWidth * (1 + CAMERA_VIEWPORT_TRESHOLD) - x;
    const height = -cameraState.relativeY + cameraState.relativeHeight * (1 + CAMERA_VIEWPORT_TRESHOLD) - y;
    
    const blocks = graph
      .getElementsOverRect(
        {
          x,
          y,
          width,
          height,
        }, // define a área na qual a lista de blocos será buscada
        [CanvasBlock] // define os tipos de elementos que serão buscados na área visível da câmera
      ).map((component) => component.connectedState); // Obtemos a lista de modelos de blocos

      setBlocks(blocks);
  });

    useGraphEvent(graph, "camera-change", ({ scale }) => {
      if (scale >= 0.7) {
        // Se a escala for maior que 0.7, atualizamos a lista de blocos
        updateVisibleList()
        return;
      }
      setBlocks([]);
    });

    return blocks.map(block => <React.Fragment key={block.id}>{renderBlock(graphObject, block)}</React.Fragment>)
}

Agora vamos ver a descrição da própria camada, que usará esse componente.

import { Layer } from '@gravity-ui/graph';

class ReactLayer extends Layer {
  constructor(props: TReactLayerProps) {
    super({
      html: {
        zIndex: 3, // elevamos a camada acima das demais
        classNames: ["no-user-select"], // adicionamos uma classe para desativar seleção de texto
        transformByCameraPosition: true, // camada vinculada à câmera — agora ela se moverá junto com a câmera
      },
      ...props,
    });
  }

  public renderPortal(renderBlock: <T extends TBlock>(block: T) => React.JSX.Element) {
    if (!this.getHTML()) {
      return null;
    }

    const htmlLayer = this.getHTML() as HTMLDivElement;

    return createPortal(
      React.createElement(BlocksList, {
        graph: this.context.graph,
        renderBlock: renderBlock,
      }),
      htmlLayer,
    );
  }
}

Agora podemos usar essa camada na nossa aplicação.

import { Flex } from "@gravity-ui/uikit";

const graph = useMemo(() => new Graph());
const containerRef = useRef<HTMLDivElement>();

useEffect(() => {
    if (containerRef.current) {
      graph.attach(containerRef.current);
    }

    return () => {
      graph.detach();
    };
  }, [graph, containerRef]);


const reactLayer = useLayer(graph, ReactLayer, {});

const renderBlock = useCallback((graph, block) => <Block graph={graph} block={block}>{block.name}</Block>)

  return (
    <div>
      <div style={{ position: "absolute", overflow: "hidden", width: "100%", height: "100%" }} ref={containerRef}>
        {graph && reactLayer && reactLayer.renderPortal(renderBlock)}
      </div>
    </div>
  );

No geral, é tudo bem simples. Nada do que foi descrito acima precisa ser escrito por você — já está tudo implementado e pronto para uso.

Nossa biblioteca de grafos: quais as vantagens e como usar

Quando começamos a trabalhar na biblioteca, a questão principal era: como fazer com que o desenvolvedor não precisasse escolher entre performance e conveniência de desenvolvimento? A resposta acabou sendo automatizar essa escolha.

Vantagens

Performance + conveniência

@gravity‑ui/graph alterna automaticamente entre Canvas e HTML dependendo da escala. Isso significa que você obtém:

  • 60 FPS estáveis em grafos com milhares de elementos.
  • A possibilidade de usar componentes HTML completos com rica interatividade na visualização detalhada.
  • Um modelo de eventos único independentemente do método de renderização — click, mouseenter funcionam da mesma forma no Canvas e no HTML.

Compatibilidade com bibliotecas de UI

Uma das principais vantagens é a compatibilidade com quaisquer bibliotecas de UI. Se sua equipe usa:

  • Gravity UI,
  • Material‑UI,
  • Ant Design,
  • componentes customizados.

… então você não precisa abrir mão delas! Ao aumentar a escala, o grafo alterna automaticamente para o modo HTML, onde Button, Select, DatePicker no tema de cores desejado funcionam exatamente como em uma aplicação React comum.

Framework agnostic

Embora tenhamos implementado o renderer HTML base usando React, tentamos desenvolver a biblioteca de modo que ela permaneça framework‑agnostic. Isso significa que, se necessário, você pode implementar com relativa facilidade uma camada de integração com o seu framework favorito.

Existem alternativas?

Hoje o mercado tem muitas soluções para desenhar grafos, desde soluções pagas como yFiles, JointJS, até soluções open source como Foblex Flow, baklavajs, jsPlumb. Mas, para comparação, consideramos @antv/g6React Flow como as ferramentas mais populares. Cada uma tem suas características.

React Flow é uma boa biblioteca voltada para construir interfaces node‑based. Ela tem recursos muito amplos, mas devido ao uso de svg e html tem uma performance relativamente modesta. A biblioteca é boa quando há certeza de que os grafos não vão passar de 100–200 blocos.

Por sua vez, o @antv/g6 tem um monte de recursos; ele suporta Canvas e, em particular, WebGL. Comparar diretamente @antv/g6 e @gravity‑ui/graph talvez não faça sentido: eles são mais voltados para construção de grafos e diagramas — mas UI node‑based também é suportado. Então o antv/g6 serve se, além de uma interface node‑based, você também precisa desenhar gráficos/diagramas.

Embora a biblioteca @antv/g6 saiba tanto canvas/webgl quanto html/svg, o controle das regras de alternância vai ter que ser feito manualmente — e é preciso fazer isso corretamente. Em performance, ela é muito mais rápida do que React Flow, mas ainda assim há questões. Embora eles afirmem que há suporte a WebGL, se você olhar o stress test, dá para notar que com 60k nós a biblioteca não consegue garantir dinâmica — em um MacBook M3 a renderização de um frame levou 4 segundos. Para comparação, nosso stress test com 111k nós e 109k conexões no mesmo Macbook M3: a renderização da cena do grafo inteiro leva ~60ms, o que dá ~15–20FPS. Não é muito, mas com Spatial Culling é possível limitar o viewport e assim melhorar a responsividade. Embora os mantenedores tenham declarado que querem alcançar renderização de 100k nós a 30 FPS, ao que tudo indica eles ainda não conseguiram.

Mais um ponto em que @gravity‑ui/graph vence é o tamanho do bundle.

Bundle size Minified

Bundle size Minified + Gzipped

@antv/g6 bundlephobia

1.1 MB

324.5 kB

react flow bundlephobia

181.2 kB

56.4 kB

@gravity-ui/graph bundlephobia

2.2 kB

672 B

Embora as duas bibliotecas sejam bastante fortes em performance ou em facilidade de integração, @gravity‑ui/graph tem uma série de vantagens — ela consegue garantir performance em grafos realmente grandes e, ao mesmo tempo, manter o UI/UX para o usuário e simplificar o desenvolvimento.

Planos para o futuro

Já agora a biblioteca tem uma boa reserva de performance para a maioria das tarefas, então no curto prazo vamos dar mais atenção ao desenvolvimento do ecossistema ao redor da biblioteca — desenvolver camadas (plugins), integrações para outras bibliotecas e frameworks (Angular/Vue/Svelte, …etc), adicionar suporte a dispositivos touch, adaptação para navegadores móveis e, no geral, melhorar UX/DX.

Experimente e junte-se a nós

No repositório você encontrará uma biblioteca totalmente funcional:

  • Núcleo em Canvas + R‑Tree (≈ 30K linhas de código),
  • integração React,
  • Storybook com exemplos.

Você pode instalar a biblioteca com uma única linha:

npm install @gravity-ui/graph


Por bastante tempo, a biblioteca que hoje se chama @gravity‑ui/graph foi uma ferramenta interna dentro do Nirvana, e a abordagem escolhida se mostrou muito eficaz. Agora queremos compartilhar nossos desenvolvimentos e ajudar desenvolvedores de fora a desenhar seus grafos de forma mais simples, rápida e performática.

Queremos padronizar abordagens para exibição de grafos complexos na comunidade open source — há equipes demais reinventando a roda ou sofrendo com ferramentas inadequadas.

Por isso, é muito importante para nós coletar seu feedback — projetos diferentes trazem edge cases diferentes, que ajudam a evoluir a biblioteca. Isso vai nos ajudar a aprimorá-la e a crescer mais rápido o ecossistema Gravity UI.

Biblioteca de visualização de grafos: como resolvemos o dilema Canvas vs HTML no Gravity UI

Sign in to save this post