Familienstammbäume mit ReactFlow visualisieren
Ein Familienstammbaum ist ein Graph. Die Daten bestehen aus Knoten – Personen –, die durch typisierte Kanten verbunden sind: Eltern-Kind-, Ehepartner- und Geschwisterbeziehungen. Bei der Visualisierung geht es darum, diesen Graphen in unterschiedlichen Maßstäben verständlich anzuordnen: von einer Ansicht mit drei Generationen bis zu einer weit zurückreichenden Abstammungslinie mit vielen Generationen und Hunderten von Personen. ReactFlow stellt die Infrastruktur für Rendering und Interaktion bereit. Die interessanten Entscheidungen betreffen die benutzerdefinierten Knotentypen, den Dagre-Layoutalgorithmus, die Gestaltung der Kanten für die verschiedenen Beziehungstypen und die Optimierungsstrategien, die bei wachsenden Stammbäumen nötig werden.
Dieser Beitrag behandelt unsere Implementierungsentscheidungen, die damit verbundenen Kompromisse und die Stellen, an denen uns die Abstraktionen von ReactFlow sowohl geholfen als auch eingeschränkt haben.
Warum ReactFlow
Als Alternative haben wir D3 ernsthaft in Betracht gezogen. Die Baumlayoutalgorithmen von D3 sind ausgereift, gut dokumentiert und werden seit Jahren für genealogische Visualisierungen verwendet. Das Problem: D3 erfordert direkte Eingriffe in das DOM. Wenn die interaktiven Elemente als React-Komponenten umgesetzt werden sollen, gerät das mit dem Abgleichmodell von React in Konflikt. Hybride Ansätze funktionieren, sind aber umständlich zu warten.
ReactFlow bleibt vollständig im Komponentenmodell von React. Knoten und Kanten sind einfache JavaScript-Objekte im Zustand der Anwendung. Die Bibliothek übernimmt das Rendering (HTML-Knoten, SVG-Kanten), das Verschieben und Zoomen der Ansicht sowie Interaktionsereignisse. Benutzerdefinierte Knotentypen sind normale React-Komponenten. So lassen sich interaktive Inhalte – Fotoplatzhalter, aufklappbare Detailbereiche und Aktionsschaltflächen – problemlos in jeden Knoten einbetten, ohne das React-Modell zu verlassen.
Dafür macht ReactFlow mehr Vorgaben als D3. Das Datenmodell für Knoten und Kanten hat eine festgelegte Struktur, das Layout wird extern berechnet (ReactFlow enthält keinen Layoutalgorithmus), und bei sehr vielen Knoten muss die Leistung gezielter optimiert werden als bei einem reinen D3-Ansatz. Für ein Projekt mit einigen Hundert Knoten sind diese Kompromisse vertretbar.
Benutzerdefinierte Knotentypen: Personenkarten
Das System für benutzerdefinierte Knoten gehört zu den Stärken von ReactFlow. Ein benutzerdefinierter Knotentyp ist eine React-Komponente, die die Knotendaten als Props erhält und beliebige Inhalte rendert. Die einzige Voraussetzung: Sie muss an den Verbindungspunkten, an denen Kanten ansetzen, die Handle-Komponenten rendern.
Die Komponente PersonNode rendert eine Karte mit dem Namen der Person, dem Geburtsjahr, gegebenenfalls dem Sterbejahr und einem Fotoplatzhalter, der bei fehlendem Foto die Initialen anzeigt. Die Karte hat einen dezenten Rahmen, dessen Farbe sich je nach Generationstiefe ändert – eine visuelle Orientierungshilfe in einem tiefen Stammbaum. Lebende und verstorbene Personen werden unterschiedlich dargestellt.
Eine Gestaltungsentscheidung hat die Übersichtlichkeit deutlich verbessert: Der Knoten verwendet für jeden Kantentyp separate Handles: oben und unten für Eltern-Kind-Kanten sowie links und rechts für Ehepartnerkanten. Eltern-Kind-Kanten verbinden die Unterseite mit der Oberseite, sodass der Baum durchgehend vertikal verläuft. Ehepartnerkanten verbinden die Seiten auf derselben Generationsebene. Diese Trennung der Kantentypen nach Handle-Position macht die Richtung der Beziehungen sofort erkennbar.
Die Knotenkomponente verarbeitet auch den „eingeklappten“ Zustand tiefer Teilbäume. Ist ein Teilbaum eingeklappt, zeigt der Knoten ein Abzeichen mit der Anzahl der ausgeblendeten Nachkommen. Ein Klick darauf klappt den Teilbaum auf. Dieses Interaktionsmodell ist entscheidend für die Nutzbarkeit großer Stammbäume: Eine Ansicht mit vierhundert Knoten ist unübersichtlich. Eine Ansicht, in der der größte Teil des Baums eingeklappt ist und Zweige bei Bedarf geöffnet werden, lässt sich dagegen gut erkunden.
Dagre-Layout für hierarchische Positionierung
ReactFlow berechnet keine Knotenpositionen. Sie geben die Positionen als Teil der Knotendaten vor; die Bibliothek rendert die Knoten an diesen Stellen. Für einen Familienstammbaum würde eine manuelle Berechnung bedeuten, einen hierarchischen Layoutalgorithmus neu zu implementieren. Die übliche Wahl ist Dagre, eine Layoutbibliothek für gerichtete azyklische Graphen, die übersichtliche hierarchische Layouts mit möglichst wenigen Kantenkreuzungen erzeugt.
Die Dagre-Integration liegt außerhalb des Rendering-Zyklus von ReactFlow. Wenn sich Knoten- oder Kantendaten ändern, läuft eine Layoutfunktion synchron, weist jedem Knoten eine Position zu und gibt das Array mit den positionierten Knoten zurück, das ReactFlow rendert. Die Funktion übernimmt die ursprünglichen Knoten und Kanten, erstellt daraus einen Dagre-Graphen, berechnet das Layout und liest die berechneten x- und y-Werte aus.
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 } };
});
}
Der Dagre-Parameter rankdir steuert die Layoutrichtung. „TB“ (top-to-bottom, von oben nach unten) ist die naheliegende Wahl für einen Familienstammbaum, in dem die Zeit über die Generationen hinweg nach unten verläuft. ranksep steuert den vertikalen Abstand zwischen Generationen; nodesep den horizontalen Abstand zwischen Geschwistern. Diese Werte müssen an die verwendeten Knotengrößen angepasst werden.
Die Einschränkung von Dagre bei Familienstammbäumen liegt darin, dass es den Graphen als DAG behandelt. Ehepartnerkanten – ungerichtete Verbindungen zwischen zwei Knoten auf derselben Ebene – passen nicht ohne Weiteres in dieses Modell. Der Ausweg: Das Dagre-Layout wird nur mit Eltern-Kind-Kanten berechnet, um Generationsebenen und horizontale Positionen festzulegen. Anschließend werden Ehepartnerkanten separat als gerade Linien auf derselben y-Koordinate gezeichnet. Die vereinfachte Funktion oben berücksichtigt bei der Layoutberechnung alle übergebenen Kanten. Filtern Sie die Kantenliste daher vor dem Aufruf auf Eltern-Kind-Kanten. Dagre allein garantiert außerdem nicht, dass Ehepartner nebeneinander oder auf derselben Ebene landen – ein Ehepartner ohne erfasste Eltern und ohne Kinder im sichtbaren Stammbaum hat überhaupt keine Eltern-Kind-Kante. Deshalb müssen die Positionen der Ehepartner nach der Layoutberechnung in einem zusätzlichen Schritt angepasst werden, um das gewünschte Bild zu erhalten: Ehepartner stehen nebeneinander auf derselben Ebene, verbunden durch eine horizontale Kante.
Kantengestaltung für Beziehungstypen
ReactFlow unterstützt benutzerdefinierte Kantenkomponenten genauso wie benutzerdefinierte Knotenkomponenten. Jeder Beziehungstyp – Eltern-Kind-, Ehepartner-, Adoptiv- und Stiefbeziehung – erhält einen eigenen Kantenstil. Eltern-Kind-Kanten sind durchgezogene Linien. Ehepartnerkanten sind dünne horizontale Verbindungslinien. Adoptivbeziehungen werden gestrichelt, Stiefbeziehungen gepunktet dargestellt.
Die Kantenkomponente erhält die Positionen des Quell- und Zielknotens als Props und rendert einen SVG-Pfad. Bei geraden Kanten ist das einfach. Für gekrümmte Kanten zwischen unterschiedlich positionierten Knoten stellt ReactFlow Hilfsfunktionen bereit (getStraightPath, getBezierPath, getSmoothStepPath), die aus den Endpunktkoordinaten die passende SVG-Pfadzeichenfolge erzeugen.
Benutzerdefinierte Kantenkomponenten ermöglichen unter anderem eine Beschriftung, die beim Überfahren mit dem Mauszeiger erscheint und die Beziehung vollständig beschreibt. Das hilft bei komplexen Familienstrukturen, in denen der visuelle Stil allein mehrdeutig sein kann. Beim Überfahren einer Kante zwischen zwei Knoten erscheint etwa „Adoptivelternteil – 1987“ oder „Verheiratet 1975, geschieden 1989.“ Die Beschriftung wird als foreignObject im SVG gerendert. Dadurch lassen sich für den Tooltip normales HTML und CSS verwenden.
Leistung bei großen Stammbäumen
ReactFlow verarbeitet mittelgroße Stammbäume mit bis zu einigen Hundert Knoten ohne besondere Optimierung. Darüber hinaus werden einige Maßnahmen erforderlich.
ReactFlow kann nur die Knoten und Kanten innerhalb des aktuellen sichtbaren Bereichs rendern. Diese Funktion ist standardmäßig deaktiviert und wird mit der Prop onlyRenderVisibleElements aktiviert; danach löst sie den Großteil der Leistungsprobleme großer Stammbäume, sofern die Knotenkomponenten selbst nicht aufwendig zu rendern sind. Ein PersonNode, der bei jedem Rendern externe Daten abruft oder aufwendige Berechnungen ausführt, verursacht auch dann Ruckeln, wenn Knoten außerhalb des sichtbaren Bereichs ausgeblendet werden. Halten Sie Knotenkomponenten frei von Seiteneffekten und schnell.
Die zweite Maßnahme ist die Memoisierung der Knoten- und Kantenarrays. Da ReactFlow neu rendert, sobald sich die Referenz eines dieser Arrays ändert, erzeugen bei jedem Rendern der übergeordneten Komponente neu angelegte Arrays unnötige Arbeit. Wenn Sie die Layoutberechnung in useMemo kapseln und von den Rohdaten statt von abgeleitetem Zustand abhängig machen, werden die aufwendige Berechnung und das zugehörige erneute Rendern nur ausgeführt, wenn sich die zugrunde liegenden Daten tatsächlich ändern.
Die dritte Maßnahme ist das im Abschnitt über Knotentypen beschriebene Ein- und Aufklappen. Ein Stammbaum mit 400 Knoten, von denen jeweils nur 40 angezeigt werden, hat eine ähnliche Leistung wie ein Baum mit 40 Knoten. Der Zustand der eingeklappten Teilbäume wird auf Visualisierungsebene im React-Zustand verwaltet, nicht im zugrunde liegenden Datenmodell. Beim Einklappen werden die betreffenden Elemente aus den an ReactFlow übergebenen Knoten- und Kantenarrays herausgefiltert; beim Aufklappen werden sie wieder hinzugefügt. Das Dagre-Layout wird mit der gefilterten Menge neu berechnet.
Überlegungen zum Interaktionsdesign
Neben Layout und Rendering muss auch das Interaktionsmodell einer Stammbaumvisualisierung sorgfältig gestaltet werden. ReactFlow bringt das Verschieben und Zoomen der Ansicht mit, doch die anfängliche Ansicht und Zoomstufe beeinflussen die Bedienbarkeit erheblich. Ein Aufruf von fitView() nach dem Rendern des Layouts stellt sicher, dass der Stammbaum beim Laden sichtbar und passend skaliert ist.
Für die Navigation zwischen Personen – eine Person anklicken, um sie im sichtbaren Bereich zu zentrieren, oder Kanten zu benachbarten Generationen folgen – sind die ReactFlow-Instanzmethoden setCenter() und fitBounds() erforderlich. Sie sind über den Hook useReactFlow() zugänglich und sollten mit flüssiger Animation aufgerufen werden, damit die räumliche Orientierung erhalten bleibt.
Suchen und Filtern – eine bestimmte Person anhand ihres Namens finden und zu ihrer Position im Stammbaum springen – gehört zu den üblichen Anforderungen an größere Familienstammbäume. Unsere Implementierung hebt den gefundenen Knoten mit einer pulsierenden Markierung hervor, nachdem die Ansicht auf ihn zentriert wurde. Dazu erhält der Knoten kurzzeitig eine CSS-Animationsklasse, die anschließend wieder entfernt wird. Die ReactFlow-Methode updateNode ermöglicht dies, ohne das gesamte Baumlayout neu zu berechnen.
Weiterführende Beiträge
- Closure Tables zur Berechnung von Verwandtschaftsbeziehungen – die Datenbankschicht, mit der sich die Frage „Wie sind diese beiden Personen verwandt?“ in einer einzigen Abfrage beantworten lässt; sie ergänzt die Visualisierungsschicht mit ReactFlow
- Flaggen-Widget: Warum ISO-Ländercodes besser sind als sprachabhängige Namen – ein weiteres Projekt, in dem stabile Kennungen für Entitäten (Ländercodes statt Namen) eine ganze Klasse schwer erkennbarer Fehler verhindert haben