What is this?
An interactive graph that displays all content of this portfolio as interconnected nodes — inspired by the graph view in Obsidian and a collaboration with the Wossidlo Archive. Blog posts and projects become dots, shared tags and explicit links become edges. Instead of a linear list, a visual map of relationships emerges.
The graph is not a static image. The nodes move physically: dragging a node affects its neighbors, the neighbors’ neighbors, and so on — with decreasing force. After a few frames, everything comes to rest.
Why a Custom Physics Engine?
Cytoscape.js includes force-directed layouts (cose, cose-bilkent), but they run once and then position statically. I wanted nodes that react to interaction — not just during initial loading, but continuously.
The solution is a custom requestAnimationFrame loop with three forces:
- Spring forces between connected nodes — pull them toward an ideal distance
- Repulsion between all nodes — prevents overlap
- Gravity toward the center — keeps the graph centered
The damping (0.35) ensures that motion decays quickly. A micro-velocity cutoff at 0.01 pixels per frame prevents permanent jittering — a problem that was only solved after several iterations.
Lazy Loading
Cytoscape.js is ~144 KB (gzip). It must not slow down the initial page load. The solution is a two-stage pattern:
graph-shell.ts(~15 lines, always loaded): Responds to Escape and close buttons, imports the main logic only on demandknowledge-graph.ts(lazy viaimport()): Only loaded on the first click of the graph button — including Cytoscape
The graph button in the toolbar triggers import("./knowledge-graph").then(m => m.openGraph()). On the first call, the library and data are loaded in parallel; on subsequent opens, everything is cached.
Data at Build Time
The graph data does not come from a runtime API. A static endpoint (/api/graph-data.json) is generated at build time from the content collections:
- Nodes: Every blog post and project becomes a node with title, description, tags, URL, and date
- Tag edges: For every pair of content items sharing at least one tag, an edge is automatically created
- relatedTo edges: Explicit links from the frontmatter (
relatedTo: ["projects/once"]) create additional edges
The result is a JSON file that is simply fetched via fetch() at runtime — no server process, no database access.
Initial Positioning
The nodes do not start randomly but on a Fermat spiral with the golden angle. A seed-based jitter provides organic variation without the distribution looking different every time. The spiral distributes nodes evenly across the area — the physics simulation then pushes them into their equilibrium positions.
Interaction
- Click on a node opens a sidebar with title, type, description, tags, and date. The rest of the graph dims; only neighboring nodes remain visible.
- Double-click navigates directly to the page.
- Drag moves the node and its neighbors physically.
- Tag filter via the filter button (funnel icon next to the toolbar): matching nodes are highlighted, the rest is dimmed. Multiple tags can be selected simultaneously.
Theme Reactivity
The graph reacts live to theme and color changes. A MutationObserver on <html> watches for changes to data-theme and style (accent color). On every change, the Cytoscape stylesheet is rebuilt — node colors, edge colors, and text colors adapt immediately.
What I Learned
- Force-directed layouts and physics simulations are different things. A layout computes positions once. A simulation runs continuously and reacts to interaction. For a lively feel, you need the latter.
- Micro-velocity cutoffs are essential. Without them, spring forces and repulsion create permanent tiny movements that never come to rest. A threshold of 0.01 px/frame solves the problem elegantly.
- Lazy loading with the shell pattern keeps the initial bundle small without compromising UX. The lightweight shell responds immediately to close/escape, while the heavy logic loads asynchronously.
- Fermat spirals are excellent for the initial distribution of graph nodes. They avoid the “perfect circle” look of a concentric layout and the randomness of a random distribution.