What I built
Obsidian has a graph view that shows connections between notes as dots and lines. I wanted something similar on this blog.
Here's what I aimed for:
- A local graph: not every document, but the ones within 1–3 hops of the document you're currently reading
- Free interaction: drag nodes, zoom and pan, click to navigate to that document
- A design that fits this blog: not the usual "blue dot graph," but an "observatory" feel blended into the ink panel and mono labels
Below is the build process, step by step, with the concepts explained. Each step reads as background → the situation → what I did → what I took away, so you can dip into just the parts you need.
1. Making the data to draw (BFS)
Background (concepts)
- Graph: a data structure of dots (nodes) and the lines connecting them (edges or links). Here a node is one document and a link is a relationship between two documents.
- Relations: documents on this blog point at each other through
[[other-document]]wikilinks in the body or arelatedfield in frontmatter. Those connections are pre-indexed into aRelationIndex. - BFS (breadth-first search): a way of exploring outward one layer at a time from a starting point. Perfect for "documents 1, 2, and 3 hops from the current one." That hop count is called depth.
- Serialization: to hand server-built data to the browser it has to be plain JSON, not functions or classes. Converting it into that shape is serialization.
The situation
The relationship data was already all in RelationIndex. The problem was that a graph view doesn't want "all relations" — it wants a small subgraph centered on the current document.
If you're reading document A, you need the documents directly linked to A (depth 1), then the ones linked to those (depth 2), and so on, up to three hops.
What I did
I added getLocalGraph(index, root, maxDepth) in src/lib/content/graph.ts. It's just BFS:
- put the starting document in the queue at depth 0
- expand one layer at a time, adding any unseen neighbor as a node and recording its depth
- stop at
maxDepth(3 by default)
Fetching neighbors is handled by getNeighbors(doc), newly added to RelationIndex. It treats both "documents I point at" and "documents that point at me" (backlinks) as neighbors. The same link in reverse is deduplicated so it's drawn once, and private/draft documents are excluded.
The result comes out as plain JSON:
{
nodes: [{ id, title, type, href, depth }],
links: [{ source, target, kind }],
}
Shipping depth on every node is the point. The server computes up to depth 3 in one pass, and the "depth 1–3 slider" in the browser only filters on that value. Moving the slider never asks the server again, so it responds instantly.
This logic is invisible and easy to get wrong, so I attached six unit tests — depth limits, merged duplicate links, excluded private documents, and so on.
What I took away
- If a client-side interaction (a slider filter) operates on a subset of server data, have the server send the maximum range once and let the client filter. It's far smoother than re-requesting on every interaction.
- The less visible a data transformation is, the more it needs tests. When the graph draws wrong, you immediately know whether the problem is the data or the drawing.
2. Drawing the graph (force simulation)
Background (concepts)
- Force-directed layout: repeatedly applying "virtual physics" where nodes push each other apart and linked nodes pull together, until a natural arrangement emerges. It's why the Obsidian graph looks alive.
- canvas: an HTML element for drawing pixel by pixel. A graph where dozens of nodes move every frame belongs on canvas.
- SSR (server-side rendering): Next.js pre-builds pages into HTML on the server. But canvas and
windowonly exist in the browser, so running them on the server throws. - Dynamic import: pulling a component out of server rendering and loading it only in the browser. In Next.js you pass
ssr: falsetonext/dynamic.
The situation
Implementing a force simulation myself would be more trouble than it's worth. react-force-graph-2d already exists and is good, so I used it — while dodging the SSR problem above.
What I did
I made a client component, document-graph-view.tsx, and loaded the graph library inside it with next/dynamic(..., { ssr: false }). Now the server never touches the component and canvas is drawn only in the browser. The heavy library loads only on detail pages, so other pages take no performance hit.
There was one trap. Using zoomToFit — which fits the graph to the viewport once the simulation settles — requires a ref to the library instance, and next/dynamic doesn't forward that ref.
So I added one more thin wrapper, force-graph-canvas.tsx, that owns the ref directly. It calls zoomToFit exactly once from the onEngineStop callback (the moment the simulation halts).
What I took away
- Wrap libraries that depend on canvas or
windowinnext/dynamic(..., { ssr: false })in Next.js. - When you need such a library's imperative API (things you have to command directly, like
zoomToFit), wrapping it in a thin client component that owns the ref is the clean way.
3. Turning depth into "orbits" (a custom force)
Background (concepts)
- d3-force: the force engine
react-force-graphuses internally. It combines forces like link attraction and node repulsion (charge). You can add your own force to it. - alpha: the simulation's "energy" value. It starts high and cools toward 0 as the layout settles. Custom forces scale their strength by alpha.
- Pinned node: a node that ignores forces and stays at fixed coordinates. Give it
fx,fyand it stays put.
The situation
The default layout looked fine but showed nothing about depth. A document one hop away and one three hops away landed wherever. I wanted "how far from the current document" to be readable at a glance.
What I did
The idea is simple: turn depth into distance from the center. Depth 1 sits on an 88px orbit, depth 2 on 176px, and so on.
I wrote a custom force called radialByDepth. On every tick it takes each node's current radius, compares it to the target radius for that node's depth, and nudges velocity toward closing the gap. The current document is pinned at the origin with fx: 0, fy: 0 so every orbit revolves around it.
I also tuned the built-in forces so mine could win — weakening link attraction and charge so the orbital force drives the layout. The result is nodes settling near concentric rings by depth.
What I took away
- Layout itself can carry information. Here "distance from the center = how closely related" is encoded as orbits.
- When the library's default forces fight your custom one, you don't get the picture you want. Turning the defaults down is part of making yours work.
4. Not another generic graph — an "observatory" (design)
Background (concepts)
- Design tokens: managing colors, spacing, and type as named CSS variables like
--signaland--border. Change the theme and everything follows. - canvas can't read CSS variables: HTML elements can use
var(--signal), but anything drawn on canvas needs a real color string passed in from JavaScript. - AI slop: designs that look "cranked out by AI" because gradients, glows, and emoji are switched on everywhere. Avoiding that was the goal.
The situation
The default state was a few blue dots on a black background — a bit dull. I wanted it to feel like an astronomical instrument display, matching this blog's visual language (1px ink borders, mono uppercase labels, paper grain).
What I did
- Panel: reused the existing
.section-ink(ink background) and.paper-grain(paper noise) utilities. No new styles invented; just layered onto the existing tone. - Drawing the orbits: in
onRenderFramePre(a callback that draws before the nodes each frame) I drew dashed circles per depth plus crosshairs directly on canvas. The orbits from step 3 became visible. - Current-document marker: I started with reticle-style crosshair ticks, but they were too much. Settled on a dot plus a simple ring. The accent color (orange) is used only here.
- Color: since the panel is always ink-colored, the graph palette is fixed regardless of theme. Each document type (til/knowledge/blog/portfolio) gets its own color.
- HUD and legend: mono uppercase instrument labels like
LOCAL GRAPHtop-left and23 NODES · D2top-right. The legend shows only the types actually present in the graph.
What I took away
- Spend boldness in one place. Put the weight on a single signature (the orbits) and keep the rest (color, labels) quiet, and the intent reads clearly without being flashy.
- If you have a design system, dig through existing tokens and utilities first for new components. Reuse is what keeps the tone consistent.
5. Making stars twinkle in the background (CSS animation)
Background (concepts)
- Compositor-friendly animation: the only properties a browser can handle cheaply on the GPU without recalculating layout are
transformandopacity. Animate only those and low-end devices stay smooth. - Hydration: the process where React attaches events to server-rendered HTML to bring it to life. If the server's screen and the browser's screen differ, you get warnings or errors. So anything random that changes each time must stay out of server rendering.
The situation
The observatory panel's background felt empty. The idea came up: make it twinkle like stars, with randomized blur for depth.
What I did
I made 42 stars, and handled them in CSS rather than drawing in JavaScript. Each star is a small <span> with position, size, blur, animation period, and start delay randomized into inline styles. The twinkle itself is a CSS @keyframes that only moves opacity between 0.1 and 0.8. Since blur differs per star, some look sharp and others hazy, which creates depth.
I dodged the hydration problem this way: star coordinates are generated exactly once on mount with useMemo, and rendered only after the viewport measurement finishes (browser only). The server draws no stars at all, so the two screens can't disagree.
And when prefers-reduced-motion is on (the user asked for less motion), the animation turns off and settles at a soft fixed brightness.
What I took away
- For small repeating motion, CSS
@keyframesis cheaper and steadier than JavaScript — as long as you stick toopacityandtransform. - Keep random values out of server rendering. Generate them in the browser or hydration breaks.
- Always include a
prefers-reduced-motionpath for motion.
6. Viewing it large (Fullscreen API)
Background (concepts)
- Fullscreen API: a standard browser feature that blows a specific element up to fill the screen.
element.requestFullscreen()turns it on,document.exitFullscreen()turns it off. No need to build a modal. - useSyncExternalStore: a hook that lets React safely subscribe to state living outside React — here, "are we fullscreen right now?"
The situation
The panel was small, so a busy graph felt cramped. It needed a way to go big.
What I did
Instead of building a modal I used the native Fullscreen API directly. Pressing the button calls requestFullscreen() on the graph container. Fullscreen state is tracked by subscribing to the fullscreenchange event via useSyncExternalStore, and the container height switches to 100vh accordingly. When the size changes the wrapper calls zoomToFit again to refit the graph. Stars, orbits, and interactions all come along unchanged.
What I took away
- Don't rebuild what the browser already does. One native Fullscreen API call saved writing modal code, an overlay, and scroll locking.
Wrapping up
It was one small widget, but the lessons were dense.
Data was split so the server computes the maximum range and the client only filters. The heavy canvas library was wrapped with a dynamic import plus a ref wrapper. Layout encoded depth as orbits via a custom force. The design avoided the generic-graph look by spending boldness on a single signature. Stars twinkle cheaply through CSS opacity animation, and fullscreen came free from a native API.
One principle runs through all of it:
Look for what already exists (the relation index, design tokens, browser APIs) and build only the minimum that's missing. That's what keeps the code small and the result coherent.