Graph-Visualisierungsbibliothek: Wie wir das Canvas vs. HTML-Dilemma gelöst haben

Hallo! Ich heiße Andrej und bin Frontend-Entwickler im User-Experience-Team der Infrastruktur-Services bei Yandex. Wir entwickeln Gravity UI — ein Open-Source-Designsystem und eine React-Komponentenbibliothek, die von Dutzenden Produkten innerhalb des Unternehmens und darüber hinaus genutzt wird. Heute erzähle ich, wie wir vor der Aufgabe standen, komplexe Graphen zu visualisieren, warum uns bestehende Lösungen nicht überzeugt haben und wie schließlich @gravity‑ui/graph entstand — eine Bibliothek, die wir für die Community geöffnet haben.

Diese Geschichte begann mit einem praktischen Problem: Wir mussten Graphen mit 10.000+ Elementen inklusive interaktiver Komponenten rendern. Bei Yandex gibt es viele Projekte, in denen Benutzer komplexe Datenverarbeitungs-Pipelines erstellen — von einfachen ETL-Prozessen bis hin zu Machine Learning. Wenn solche Pipelines programmatisch erzeugt werden, kann die Anzahl der Blöcke Zehntausende erreichen.

Bestehende Lösungen haben uns nicht zufrieden gestellt:

  • HTML/SVG-Bibliotheken sehen gut aus und sind bequem in der Entwicklung, werden aber schon bei Hunderten von Elementen langsam.
  • Canvas-Lösungen sind performant, erfordern aber extrem viel Code, um komplexe UI-Elemente zu bauen.

Einen Button mit abgerundeten Ecken und Gradient in Canvas zu zeichnen, ist nicht schwer. Probleme beginnen jedoch, wenn man eigene komplexe Controls oder Layouts erstellen muss — dann braucht man Dutzende Zeilen an Low-Level-Zeichenbefehlen. Jedes UI-Element muss man von Grund auf programmieren — von Klick-Handling bis zu Animationen. Wir brauchten aber vollwertige UI-Komponenten: Buttons, Selects, Eingabefelder, Drag-and-drop.

Wir wollten nicht zwischen Canvas und HTML wählen, sondern das Beste aus beiden Technologien nutzen. Die Idee war einfach: automatisch zwischen Modi umschalten — abhängig davon, wie nah der Nutzer auf den Graphen zoomt.

Full screen image

Woher die Aufgabe kam

Nirvana und ihre Graphen

Bei Yandex gibt es den Service Nirvana zum Erstellen und Ausführen von Datenverarbeitungs-Graphen (darüber haben wir bereits schon 2018 geschrieben). Der Service ist groß, beliebt und existiert schon lange.

Ein Teil der Nutzer erstellt Graphen manuell — mit der Maus, fügt Blöcke hinzu und verbindet sie. Solche Graphen sind unproblematisch: Es gibt nicht viele Blöcke, und alles funktioniert hervorragend. Aber es gibt Projekte, die Graphen programmatisch erzeugen. Und hier beginnen die Schwierigkeiten: Sie können bis zu 10.000 Operationen in einen einzigen Graphen packen. Das sieht dann so aus:

Full screen image
Und so:

image
image
image
image
image

Solche Graphen schafft eine gewöhnliche Kombination aus HTML + SVG einfach nicht. Der Browser wird langsam, Speicher läuft aus, der Nutzer leidet. Wir haben versucht, das Problem direkt zu lösen: HTML-Rendering zu optimieren, sind aber früher oder später an physische Grenzen gestoßen — das DOM ist schlicht nicht dafür ausgelegt, Tausende gleichzeitig sichtbare, frei schwebende interaktive Elemente zu verwalten.

Es braucht eine andere Lösung, und im Browser blieb uns nur Canvas. Nur damit lässt sich die erforderliche Performance erreichen.

Der erste Gedanke: eine fertige Lösung finden. Es war 2017–2018, und wir haben populäre Bibliotheken für Canvas oder Graph-Rendering durchforstet, aber alle stießen auf dasselbe Problem: Entweder Canvas mit primitiven Elementen, oder HTML/SVG und dafür Performance opfern.

Was, wenn man nicht wählen müsste?

Level of Details: Inspiration aus GameDev

In GameDev und Kartografie gibt es ein großartiges Konzept — Level of Details (LOD). Diese Technik entstand aus der Notwendigkeit: Wie zeigt man eine riesige Welt, ohne die Performance zu zerstören?

Die Idee ist simpel: Ein Objekt kann mehrere Detailstufen haben — abhängig davon, wie nah man hinschaut. In Spielen sieht man das besonders deutlich:

  • In der Ferne sieht man Berge — einfache Polygone mit Basistextur.
  • Kommt man näher, erscheinen Details: Gras, Steine, Schatten.
  • Noch näher sieht man einzelne Blätter an den Bäumen.

Niemand rendert Millionen Gras-Polygone, wenn der Spieler auf einem Berggipfel steht und in die Ferne blickt.

Bei Karten ist das Prinzip dasselbe — für jede Zoomstufe gibt es eigene Daten und eine eigene Detailtiefe:

  • Kontinent-Maßstab — man sieht nur Länder.
  • Zoomt man in eine Stadt, erscheinen Straßen und Bezirke.
  • Noch näher — Hausnummern, Cafés, Bushaltestellen.

Uns wurde klar: Der Nutzer braucht auf einer Gesamtansicht eines Graphen mit 10.000 Blöcken keine interaktiven Buttons — er sieht sie ohnehin nicht und kann damit nicht arbeiten.

Mehr noch: Der Versuch, 10.000 HTML-Elemente gleichzeitig zu rendern, friert den Browser ein. Wenn man aber in einen konkreten Bereich zoomt, fällt die Zahl der sichtbaren Blöcke drastisch — von 10.000 auf z. B. 50. Genau dann werden Ressourcen frei für HTML-Komponenten mit reichhaltiger Interaktivität.

Drei Ebenen unseres Level-of-Details-Schemas

Minimalistic (Zoom 0,1–0,3) — Canvas mit einfachen Primitive

In diesem Modus sieht der Nutzer die Gesamtarchitektur des Systems: wo die Hauptgruppen von Blöcken liegen und wie sie miteinander verbunden sind. Jeder Block ist ein einfaches Rechteck mit grundlegender Farbcodierung. Keine Texte, Buttons, detaillierten Icons. Dafür kann man komfortabel Tausende von Elementen rendern. Auf dieser Ebene wählt der Nutzer den Bereich für eine detaillierte Betrachtung.

Full screen image

Schematic (Zoom 0,3–0,7) — Canvas mit Details

Es erscheinen Blocknamen, Status-Icons, Anker für Verbindungen. Text wird mit dem Canvas-API gerendert — das ist schnell, aber die Styling-Möglichkeiten sind begrenzt. Die Verbindungen zwischen den Blöcken werden informativer: Man kann die Richtung des Datenflusses und den Verbindungsstatus zeigen. Das ist ein Übergangsmodus, in dem Canvas-Performance mit grundlegender Informationsdichte kombiniert wird.

Full screen image

Detailed (Zoom 0,7+) — HTML mit voller Interaktivität

Hier werden Blöcke zu vollwertigen UI-Komponenten: mit Steuerbuttons, Parameterfeldern, Progress-Bars, Selects. Man kann alle Möglichkeiten von HTML/CSS nutzen und UI-Bibliotheken anbinden. In diesem Modus passen in den Viewport üblicherweise nicht mehr als 20–50 Blöcke — komfortabel für detaillierte Arbeit.

Full screen image

Was wäre, wenn man FPS zur Wahl der Detailstufe nutzt?

Wir hatten Ansätze, die Detailstufe anhand der FPS zu wählen. Es stellte sich aber heraus, dass so ein Ansatz Instabilität erzeugt: Steigt die Performance, schaltet das System in einen detaillierteren Modus, was die FPS senkt und ein Zurückschalten auslösen kann — und so weiter im Kreis.

Wie wir zur Lösung gekommen sind

Gut, LOD ist großartig. Aber die Umsetzung erfordert Canvas für Performance — und das ist ein neues Kopfzerbrechen. Auf Canvas zu zeichnen ist nicht sehr schwierig — Probleme beginnen, wenn Interaktivität nötig ist.

Problem: Wie erkennt man, wohin der Nutzer geklickt hat?

In HTML ist es einfach: Auf einen Button geklickt — das Event landet direkt am Element. Bei Canvas ist es schwieriger: Auf die Zeichenfläche geklickt — und was dann? Man muss selbst herausfinden, auf welches Element der Nutzer geklickt hat.

Grundsätzlich gibt es drei Ansätze:

  • Pixel Testing (Color Picking),
  • Geometric approach (einfaches Durchlaufen aller Elemente),
  • Spatial Indexing (räumlicher Index).

Pixel Testing (Color Picking)

Die Idee ist simpel: Wir erstellen ein zweites unsichtbares Canvas, kopieren die Szene dorthin, aber füllen jedes Element mit einer eindeutigen Farbe, die als Objekt-ID gilt. Beim Klick lesen wir die Pixelfarbe unter dem Mauszeiger via getImageData aus und erhalten so die Element-ID.

Vorteile

Nachteile

  • In ein paar Dutzend Zeilen umsetzbar

  • Benötigt keine zusätzlichen Datenstrukturen

  • Canvas-Anti-Aliasing mischt Farben — ein Klick auf die Formgrenze kann eine “ungültige” ID liefern

  • Anti-Aliasing im 2D-Canvas lässt sich nicht deaktivieren

  • Das zweite Canvas verdoppelt den Speicherverbrauch und den Render-Pass

Für kleine Szenen ist die Methode ok, aber bei 10.000+ Elementen wird die Fehlerquote inakzeptabel — Pixel Testing legen wir beiseite.

Geometric approach (einfaches Durchlaufen aller Elemente)

Die Idee ist simpel: Wir gehen alle Elemente durch und prüfen, ob der Klickpunkt innerhalb eines Elements liegt.

Vorteile

Nachteile

  • In ein paar Dutzend Zeilen umsetzbar

  • Benötigt keine zusätzlichen Datenstrukturen

  • Sehr langsam bei großer Elementanzahl

  • Nicht geeignet für große Szenen

Spatial Indexing

Eine Weiterentwicklung des geometrischen Ansatzes. Dort waren wir an der Anzahl der Elemente gescheitert. Algorithmen für räumliche Indizes versuchen, nahe beieinander liegende Elemente zu gruppieren (meist über Bäume), wodurch die Komplexität auf log n reduziert werden kann.

Es gibt viele Spatial-Index-Algorithmen; wir haben die Datenstruktur R-Tree gewählt, konkret die Bibliothek rbush.

R-Tree ist, wie der Name andeutet, ein Baum, in dem jedes Objekt in ein minimal großes Bounding-Rectangle (MBR) gelegt wird; diese Rechtecke werden dann zu größeren Rechtecken gruppiert. So entsteht ein Baum, in dem jedes Rechteck andere Rechtecke enthält.

Full screen image

Bild aus Wikipedia R‑tree

Für die Suche im RTree müssen wir im Baum nach unten steigen (in die Tiefe des Rechtecks), bis wir beim konkreten Element ankommen. Der Pfad wird über die Schnittprüfung des Suchrechtecks mit den MBRs gewählt. Alle Äste, deren Bounding-Box das Suchrechteck nicht einmal berührt, werden sofort verworfen — deshalb ist die Traversierungstiefe meist auf 3–5 Ebenen begrenzt, und die Suche dauert selbst bei Zehntausenden Elementen nur Mikrosekunden.

Diese Variante ist zwar langsamer (O (log n) im besten Fall und O (n) im schlechtesten), als Pixel Testing, dafür aber genauer und weniger speicherhungrig.

Ereignismodell

Auf Basis des RTree können wir nun unser Ereignismodell aufbauen. Wenn der Nutzer klickt, starten wir einen Hit-Test: Wir bilden ein Rechteck der Größe 1×1 Pixel in Cursor-Koordinaten und suchen seine Schnittmenge im R-Tree. Haben wir das Element gefunden, in das dieses Rechteck fällt, delegieren wir das Event an dieses Element. Wenn das Element das Event nicht stoppt, wird es an den Parent weitergereicht — bis zur Root. Das Verhalten ähnelt dem uns vertrauten Browser-Eventmodell. Events können abgefangen, prevented oder die Bubbling-Phase gestoppt werden.

Wie bereits erwähnt, bilden wir beim Hit-Test ein Rechteck von 1×1 Pixel. Das bedeutet: Wir können ein Rechteck beliebiger Größe bilden. Das hilft uns bei einer weiteren sehr wichtigen Optimierung — Spatial Culling.

Spatial Culling

Spatial Culling ist eine Rendering-Optimierungstechnik mit dem Ziel, nichts zu zeichnen, was nicht sichtbar ist. Zum Beispiel zeichnet man keine Objekte, die außerhalb des Kameraraums liegen oder von anderen Elementen verdeckt werden. Da unser Graph in einem 2D-Raum gezeichnet wird, reicht es, nur jene Objekte nicht zu zeichnen, die außerhalb des Sichtbereichs der Kamera (Viewport) liegen.

So funktioniert es:

  • bei jeder Kamerabewegung oder jedem Zoom bilden wir ein Rechteck, das dem aktuellen Viewport entspricht;
  • wir suchen seine Schnittmenge im R-Tree;
  • das Ergebnis ist eine Liste der tatsächlich sichtbaren Elemente;
  • wir rendern nur diese — alles andere wird übersprungen.

Diese Technik macht die Performance fast unabhängig von der Gesamtanzahl der Elemente: Wenn 40 Blöcke im Bild sind, zeichnet die Bibliothek genau 40 — nicht Zehntausende, die außerhalb des Bildschirms liegen. Bei weiten Zoomstufen fallen viele Elemente in den Viewport, daher zeichnen wir leichte Canvas-Primitive; beim Heranzoomen sinkt die Elementanzahl und die frei werdenden Ressourcen erlauben den Wechsel in den HTML-Modus mit voller Detailtiefe.

Alles zusammen ergibt ein einfaches Schema:

  • Canvas steht für Geschwindigkeit,
  • HTML für Interaktivität,
  • R-Tree und Spatial Culling verbinden beides unauffällig zu einem System, das schnell bestimmen kann, welche Elemente auf der HTML-Ebene gezeichnet werden können.

Während sich die Kamera bewegt, fragt der kleine Viewport beim R-Tree nur jene Objekte ab, die sich tatsächlich im Bild befinden. Dieser Ansatz erlaubt uns, wirklich große Graphen zu zeichnen oder zumindest Performance-Reserven zu haben, bis der Nutzer den Viewport einschränkt.

Im Kern enthält die Bibliothek:

  • Canvas-Modus mit einfachen Primitiven;
  • HTML-Modus mit voller Detailtiefe;
  • R-Tree und Spatial Culling zur Performance-Optimierung;
  • ein gewohntes Ereignismodell.

Für Produktion reicht das jedoch nicht: Man braucht die Möglichkeit, die Bibliothek zu erweitern und an die eigenen Bedürfnisse anzupassen.

Anpassung

Die Bibliothek bietet zwei sich ergänzende Wege, Verhalten zu erweitern und zu verändern:

  • Überschreiben von Basiskomponenten. Wir ändern die Logik der Standard-Block-, Anchor- und Connection-Komponenten.
  • Erweiterung über Layer. Wir fügen grundsätzlich neue Funktionalität über/unter der bestehenden Szene hinzu.

Komponenten überschreiben

Wenn man das Aussehen oder Verhalten bereits vorhandener Elemente modifizieren muss, erbt man von der Basisklasse und überschreibt die Schlüsselmethoden. Danach registriert man die Komponente unter einem eigenen Namen.

Blöcke anpassen

Wenn Sie z. B. einen Graphen mit Progress-Bars auf den Blöcken erstellen müssen — etwa um den Ausführungsstatus von Tasks in einer Pipeline zu zeigen — können Sie die Standardblöcke leicht anpassen:

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

class ProgressBlock extends CanvasBlock {
  // Grundform des Blocks mit abgerundeten Ecken
  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;

    // Basis des Blocks zeichnen
    this.renderBody(ctx);

    // Progress-Bar mit Farbindikation
    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);

    // Rahmen der Progress-Bar
    ctx.strokeStyle = "#ddd";
    ctx.lineWidth = 1;
    ctx.strokeRect(this.state.x + 10, this.state.y + this.state.height - 15, this.state.width - 20, 8);

    // Text mit Prozenten und Name
    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);
  }
}

Verbindungen anpassen

Analog dazu: Wenn Sie Verhalten und Aussehen der Kanten ändern müssen — z. B. um die Intensität des Datenflusses zwischen Blöcken darzustellen — können Sie eine eigene Verbindung erstellen:

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

class DataFlowConnection extends BlockConnection {
  public override style(ctx: CanvasRenderingContext2D) {
    // Daten über den Fluss aus verbundenen Blöcken holen
    const sourceBlock = this.sourceBlock;
    const targetBlock = this.targetBlock;

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

    // Flussintensität auf Basis des Block-Fortschritts berechnen
    const flowRate = Math.min(sourceProgress, targetProgress);
    const isActive = flowRate > 10; // Fluss ist aktiv bei Fortschritt > 10%

    if (isActive) {
      // Aktiver Fluss -- dicke grüne Linie
      ctx.strokeStyle = "#00b894";
      ctx.lineWidth = Math.max(2, Math.min(6, flowRate / 20));
    } else {
      // Inaktiver Fluss -- gestrichelte graue Linie
      ctx.strokeStyle = "#ddd";
      ctx.lineWidth = this.context.camera.getCameraScale();
      ctx.setLineDash([5, 5]);
    }

    return { type: "stroke" };
  }
}

Nutzung eigener Komponenten

Wir registrieren die erstellten Komponenten in den Graph-Einstellungen:

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: {
    // Eigene Blöcke registrieren
    blockComponents: {
      'progress': ProgressBlock,
    },
    // Eigene Verbindung für alle Kanten registrieren
    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();

Ergebnis

Das Resultat ist ein Graph, in dem:

  • Blöcke den aktuellen Fortschritt mit Farbindikation anzeigen;
  • Verbindungen den Datenfluss visualisieren: aktive Flüsse sind grün und dick, inaktive grau und gestrichelt;
  • beim Zoomen Blöcke automatisch in den HTML-Modus mit voller Interaktivität wechseln.

Erweiterung über Layer

Layer sind zusätzliche Canvas- oder HTML-Elemente, die in den “Raum” des Graphen eingefügt werden. Im Grunde ist jeder Layer ein eigener Rendering-Kanal, der entweder ein eigenes Canvas für schnelle Grafik oder einen HTML-Container für komplexe interaktive Elemente enthalten kann.

Übrigens: Genau über Layer funktioniert die React-Integration unserer Bibliothek: React-Komponenten werden in den HTML-Layer via React Portal gerendert.

Layer-Architektur

Layer sind noch eine weitere Schlüssellösung für das Canvas-vs-HTML-Dilemma. Layer synchronisieren die Positionen von Canvas- und HTML-Elementen und sorgen für korrektes Überlagern. Dadurch kann man nahtlos zwischen Canvas und HTML wechseln und bleibt dabei im selben Koordinatenraum. Der Graph besteht aus unabhängigen Layern, die übereinander liegen:

Full screen image

Layer können in zwei Koordinatensystemen arbeiten:

  • An den Graph gebunden (transformByCameraPosition: true):

    • Elemente bewegen sich вместе mit der Kamera,
    • Blöcke, Verbindungen, Graph-Elemente.
  • Am Bildschirm fixiert (transformByCameraPosition: false):

    • bleiben beim Panning an Ort und Stelle,
    • Toolbars, Legenden, UI-Controls.

Wie die React-Integration aufgebaut ist

Ein Layer mit React-Integration ist ziemlich anschaulich, um zu zeigen, was Layer sind. Schauen wir uns zuerst eine Komponente an, die eine Liste von Blöcken hervorhebt, die sich im sichtbaren Bereich der Kamera befinden. Dazu müssen wir Kameraänderungen abonnieren und nach jeder Änderung eine Schnittprüfung zwischen dem Kamera-Viewport und den Hitboxen der Elemente durchführen.

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,
        }, // definiert den Bereich, in dem die Blockliste gesucht wird
        [CanvasBlock] // definiert die Elementtypen, die im Sichtbereich der Kamera gesucht werden
      ).map((component) => component.connectedState); // Liste der Block-Modelle holen

      setBlocks(blocks);
  });

    useGraphEvent(graph, "camera-change", ({ scale }) => {
      if (scale >= 0.7) {
        // Wenn der Zoom größer als 0.7 ist, aktualisieren wir die Blockliste
        updateVisibleList()
        return;
      }
      setBlocks([]);
    });

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

Jetzt schauen wir uns die Beschreibung des Layers selbst an, der diese Komponente verwenden wird.

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

class ReactLayer extends Layer {
  constructor(props: TReactLayerProps) {
    super({
      html: {
        zIndex: 3, // Layer über die anderen Layer anheben
        classNames: ["no-user-select"], // Klasse hinzufügen, um Textauswahl zu deaktivieren
        transformByCameraPosition: true, // Layer ist an die Kamera gebunden – bewegt sich also mit der Kamera
      },
      ...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,
    );
  }
}

Jetzt können wir diesen Layer in unserer Anwendung verwenden.

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>
  );

Insgesamt ist alles ziemlich einfach. Nichts von dem, was oben beschrieben wurde, müssen Sie selbst schreiben — alles ist bereits implementiert und einsatzbereit.

Unsere Graph-Bibliothek: Vorteile und Nutzung

Als wir mit der Bibliothek begonnen haben, war die Hauptfrage: Wie stellen wir sicher, dass der Entwickler nicht zwischen Performance und Entwicklerkomfort wählen muss? Die Antwort lag darin, diese Wahl zu automatisieren.

Vorteile

Performance + Komfort

@gravity‑ui/graph schaltet automatisch zwischen Canvas und HTML um — abhängig vom Zoom. Das bedeutet, Sie bekommen:

  • Stabile 60 FPS bei Graphen mit Tausenden von Elementen.
  • Die Möglichkeit, bei Detailansicht vollwertige HTML-Komponenten mit reichhaltiger Interaktivität zu verwenden.
  • Ein einheitliches Ereignismodell unabhängig vom Rendering — click, mouseenter funktionieren auf Canvas und in HTML gleich.

Kompatibilität mit UI-Bibliotheken

Einer der größten Vorteile ist die Kompatibilität mit beliebigen UI-Bibliotheken. Wenn Ihr Team nutzt:

  • Gravity UI,
  • Material‑UI,
  • Ant Design,
  • eigene Komponenten.

…, dann müssen Sie nicht darauf verzichten! Beim Hineinzoomen wechselt der Graph automatisch in den HTML-Modus, in dem gewohnte Button, Select, DatePicker in Ihrem gewünschten Farbschema genauso funktionieren wie in einer normalen React-Anwendung.

Framework-agnostisch

Obwohl wir den базalen HTML-Renderer mit React implementiert haben, haben wir die Bibliothek so entwickelt, dass sie framework-agnostisch bleibt. Das heißt: Bei Bedarf können Sie relativ einfach einen Layer mit Integration Ihres bevorzugten Frameworks umsetzen.

Gibt es Alternativen?

Auf dem Markt gibt es derzeit ziemlich viele Lösungen zum Zeichnen von Graphen — von kostenpflichtigen Lösungen wie yFiles und JointJS bis hin zu Open-Source-Lösungen wie Foblex Flow, baklavajs und jsPlumb. Für den Vergleich betrachten wir aber @antv/g6 und React Flow als die populärsten Tools. Jedes davon hat свои Besonderheiten.

React Flow ist eine gute Bibliothek, die auf den Aufbau node-basierter Interfaces ausgelegt ist. Sie hat sehr umfangreiche Möglichkeiten, aber aufgrund der Nutzung von SVG und HTML eine eher bescheidene Performance. Die Bibliothek ist gut, wenn man sicher ist, dass die Graphen 100–200 Blöcke nicht überschreiten.

@antv/g6 hingegen hat массу Funktionen, unterstützt Canvas und insbesondere WebGL. @antv/g6 und @gravity‑ui/graph kann man vermutlich nicht direkt vergleichen: Das Team ist stärker auf Graphen und Diagramme ausgerichtet, aber node-basiertes UI wird ebenfalls unterstützt. antv/g6 passt also, wenn Ihnen nicht nur node-basiertes UI wichtig ist, sondern auch das Zeichnen von Diagrammen.

Obwohl @antv/g6 sowohl canvas/webgl als auch html/svg beherrscht, muss man die Umschaltregeln selbst steuern — und zwar richtig. Performance-mäßig ist es deutlich schneller als React Flow, aber es bleiben dennoch Fragen. Obwohl WebGL-Support заявлено ist, sieht man in ihrem Stresstest, dass die Bibliothek bei 60k Nodes keine Dynamik liefern kann — auf einem MacBook M3 dauerte das Rendern eines Frames 4 Sekunden. Zum Vergleich: Unser Stresstest mit 111k Nodes und 109k Kanten auf demselben Macbook M3: Das Rendern der gesamten Graph-Szene dauert ~60ms, was ~15–20 FPS ergibt. Das ist nicht sehr viel, aber dank Spatial Culling kann man den Viewport begrenzen und so die Responsiveness verbessern. Obwohl die Maintainer angekündigt haben, 100k Nodes bei 30 FPS rendern zu wollen, scheint ihnen das bislang nicht gelungen zu sein.

Ein weiterer Punkt, in dem @gravity‑ui/graph gewinnt, ist die Bundle-Größe.

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

Obwohl beide Bibliotheken entweder in der Performance oder beim Integrationskomfort ziemlich stark sind, hat @gravity‑ui/graph mehrere Vorteile — die Bibliothek kann Performance auf wirklich großen Graphen liefern, dabei UI/UX für den Nutzer bewahren und die Entwicklung vereinfachen.

Pläne für die Zukunft

Schon сейчас hat die Bibliothek genügend Performance-Reserve für die meisten Aufgaben. Daher werden wir in nächster Zeit mehr Aufmerksamkeit auf den Ausbau des Ökosystems rund um die Bibliothek legen — Layer (Plugins) entwickeln, Integrationen für andere Bibliotheken und Frameworks (Angular/Vue/Svelte, …etc) erstellen, Support für Touch-Geräte hinzufügen, Anpassungen für mobile Browser vornehmen und insgesamt UX/DX verbessern.

Ausprobieren und mitmachen

Im Repository finden Sie eine vollständig funktionierende Bibliothek:

  • Core auf Canvas + R‑Tree (≈ 30K Codezeilen),
  • React-Integration,
  • Storybook mit Beispielen.

Installieren kann man die Bibliothek in einer Zeile:

npm install @gravity-ui/graph


Ziemlich lange war die Bibliothek, die heute @gravity‑ui/graph heißt, ein internes Tool innerhalb von Nirvana — und der gewählte Ansatz hat sich sehr bewährt. Jetzt möchten wir unsere Entwicklungen teilen und Entwicklern außerhalb helfen, ihre Graphen einfacher, schneller und performanter zu zeichnen.

Wir wollen Ansätze zur Darstellung komplexer Graphen in der Open-Source-Community standardisieren — zu viele Teams erfinden das Rad neu oder quälen sich mit ungeeigneten Tools.

Deshalb ist uns Ihr Feedback sehr wichtig: Unterschiedliche Projekte bringen unterschiedliche Edge-Cases, die helfen, die Bibliothek weiterzuentwickeln. Das wird uns helfen, die Bibliothek zu verbessern und das Gravity-UI-Ökosystem schneller auszubauen.

Graph-Visualisierungsbibliothek: Wie wir das Canvas vs. HTML-Dilemma gelöst haben

Sign in to save this post