Was ist das?
Ein interaktiver Graph, der alle Inhalte dieses Portfolios als vernetzte Nodes darstellt — inspiriert vom Graph View in Obsidian und einer Zusammenarbeit mit dem Wossidlo Archiv. Blog-Posts und Projekte werden zu Punkten, gemeinsame Tags und explizite Verlinkungen zu Kanten. Statt einer linearen Liste entsteht eine visuelle Landkarte der Zusammenhänge.
Der Graph ist kein statisches Bild. Die Nodes bewegen sich physikalisch: Ziehen an einem Knoten beeinflusst seine Nachbarn, die Nachbarn der Nachbarn, und so weiter — mit abnehmender Kraft. Nach wenigen Frames kommt alles zur Ruhe.
Warum ein eigener Physics-Engine?
Cytoscape.js bringt Force-Directed-Layouts mit (cose, cose-bilkent), aber die laufen einmalig und positionieren dann statisch. Ich wollte Nodes, die auf Interaktion reagieren — nicht nur beim initialen Laden, sondern dauerhaft.
Die Lösung ist eine eigene requestAnimationFrame-Schleife mit drei Kräften:
- Spring-Kräfte zwischen verbundenen Nodes — ziehen sie auf eine Idealdistanz
- Repulsion zwischen allen Nodes — verhindert Überlappung
- Gravitation zur Mitte — hält den Graphen zentriert
Die Dämpfung (0.35) sorgt dafür, dass Bewegung schnell abklingt. Ein Micro-Velocity-Cutoff bei 0.01 Pixel pro Frame verhindert permanentes Zittern — das war ein Problem, das erst nach mehreren Iterationen gelöst wurde.
Lazy Loading
Cytoscape.js ist ~144 KB (gzip). Das darf nicht den initialen Seitenaufbau belasten. Die Lösung ist ein zweistufiges Pattern:
graph-shell.ts(~15 Zeilen, immer geladen): Reagiert auf Escape und Close-Buttons, importiert die Hauptlogik nur bei Bedarfknowledge-graph.ts(lazy viaimport()): Wird erst beim ersten Klick auf den Graph-Button geladen — inklusive Cytoscape
Der Graph-Button in der Toolbar löst import("./knowledge-graph").then(m => m.openGraph()) aus. Beim ersten Aufruf werden Bibliothek und Daten parallel geladen, bei weiteren Öffnungen ist alles gecached.
Daten zur Build-Time
Die Graph-Daten kommen nicht von einer API zur Laufzeit. Ein statischer Endpoint (/api/graph-data.json) wird beim Build aus den Content Collections generiert:
- Nodes: Jeder Blog-Post und jedes Projekt wird ein Node mit Titel, Beschreibung, Tags, URL und Datum
- Tag-Edges: Für jedes Inhaltspaar mit mindestens einem gemeinsamen Tag entsteht automatisch eine Kante
- relatedTo-Edges: Explizite Verlinkungen aus dem Frontmatter (
relatedTo: ["projects/once"]) erzeugen zusätzliche Kanten
Das Ergebnis ist eine JSON-Datei, die zur Laufzeit nur noch per fetch() geladen wird — kein Server-Prozess, kein Datenbankzugriff.
Initiale Positionierung
Die Nodes starten nicht zufällig, sondern auf einer Fermat-Spirale mit goldenem Winkel. Ein Seed-basierter Jitter sorgt für organische Variation, ohne dass die Verteilung bei jedem Öffnen anders aussieht. Die Spirale verteilt die Nodes gleichmäßig über die Fläche — die Physics-Simulation schiebt sie dann in ihre Gleichgewichtspositionen.
Interaktion
- Klick auf einen Node öffnet eine Sidebar mit Titel, Typ, Beschreibung, Tags und Datum. Der Rest des Graphen dimmt, nur Nachbar-Nodes bleiben sichtbar.
- Doppelklick navigiert direkt zur Seite.
- Drag bewegt den Node und seine Nachbarn physikalisch mit.
- Tag-Filter über den Filter-Button (Trichter-Icon neben der Toolbar): Matching Nodes werden hervorgehoben, der Rest wird abgeblendet. Mehrere Tags gleichzeitig wählbar.
Theme-Reaktivität
Der Graph reagiert live auf Theme- und Farbwechsel. Ein MutationObserver auf <html> beobachtet Änderungen an data-theme und style (Akzentfarbe). Bei jeder Änderung wird das Cytoscape-Stylesheet neu gebaut — Node-Farben, Kantenfarben und Textfarben passen sich sofort an.
Was ich gelernt habe
- Force-Directed-Layouts und Physics-Simulationen sind verschiedene Dinge. Ein Layout berechnet Positionen einmalig. Eine Simulation läuft kontinuierlich und reagiert auf Interaktion. Für ein lebendiges Gefühl braucht man letzteres.
- Micro-Velocity-Cutoffs sind entscheidend. Ohne sie erzeugen Spring-Kräfte und Repulsion permanent winzige Bewegungen, die nie zur Ruhe kommen. Ein Schwellwert von 0.01 px/Frame löst das Problem elegant.
- Lazy Loading mit dem Shell-Pattern hält den initialen Bundle klein, ohne die UX zu beeinträchtigen. Die leichtgewichtige Shell reagiert sofort auf Close/Escape, während die schwere Logik asynchron nachlädt.
- Fermat-Spiralen eignen sich hervorragend für die initiale Verteilung von Graph-Nodes. Sie vermeiden den “perfekten Kreis”-Look eines konzentrischen Layouts und den Zufall einer Randomverteilung.