What I built
Obsidian has a graph view that shows the connections between notes as dots and lines. I wanted something similar on this blog. Here were the goals:
- Local graph: not the whole corpus, but the documents within 1–3 hops centered on the document you're currently reading.
- Free interaction: drag nodes, zoom and pan, and click to jump to that document.
- A design that fits the project: not the usual "blue-dot graph," but something woven into this blog's visual language (ink panel + mono labels) — an "observatory" feel.
Below is the build process, step by step, with the concepts explained. Each step reads self-contained in the order background → the situation → the core work → takeaways.
1. Building the data to draw (BFS)
Background (concepts)
- Graph: a data structure of dots (nodes) connected by lines (edges / links). Here a node is one document and a link is a relationship between two documents.
- Relation: documents on this blog point at each other through wikilinks like
[[other-doc]]in the body or arelatedfield in the frontmatter. These connections are pre-indexed in the project'sRelationIndex. - BFS (Breadth-First Search): a way to explore that spreads outward one layer at a time from a starting point. It's a perfect fit for finding "documents 1, 2, or 3 hops away from the current one." That "how many hops away" is called depth.
- Serialization: to pass server-built data to the client (browser), it must be plain JSON — not functions or classes. Reshaping data into that passable form is serialization.
The situation
The relationship data already lived in RelationIndex. The catch was that the graph view needs not the "whole relation set" but a small subgraph centered on the current document. If you're reading document A, you want documents directly linked to A (depth 1), then documents linked to those (depth 2), and so on — up to 3 hops.
The core work
I added getLocalGraph(index, root, maxDepth) to src/lib/content/graph.ts. It's BFS itself:
- Put the start document into the queue at depth 0.
- Increase depth one layer at a time; for each document, pull its neighbors, and if a neighbor is unseen, add it as a node — recording its depth.
- Stop at
maxDepth(default 3).
Pulling neighbors is handled by getNeighbors(doc), which I added to RelationIndex. It treats both "outgoing links (documents I point to)" and "incoming links (documents that point to me — backlinks)" as neighbors. The same link in the opposite direction is drawn only once (deduplicated), and private/draft documents are excluded.
The result comes out as plain JSON:
Shipping depth on each node is the key. The server computes down to depth 3 in one shot, and the "1–3 depth slider" only filters on that value in the browser. Moving the slider doesn't re-request the server, so it responds instantly.
Because this logic is invisible and easy to get wrong, I attached 6 unit tests for getLocalGraph: whether the depth limit holds, whether duplicate links merge, whether private documents are dropped, and so on.
Takeaways
- When a client-side action (slider filter) is a subset of the server data, let the server hand over the maximum range once and have the client just filter. It's far smoother than re-requesting on every interaction.
- The more invisible a data transform is, the more it needs tests. When the graph looks wrong, you can immediately tell whether the cause 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, to find a natural arrangement. It's why the Obsidian graph looks alive.
- canvas: an HTML element for drawing pixel by pixel in the browser. A graph where dozens of nodes move every frame is a good fit for canvas.
- SSR (Server-Side Rendering): Next.js pre-renders pages to HTML on the server. But
canvasandwindowonly exist in the browser, so running them on the server throws. - Dynamic import: loading a component only in the browser, excluding it from server rendering. In Next.js you pass
ssr: falsetonext/dynamic.
The situation
Implementing a force simulation by hand is more trouble than it's worth. A well-made library, react-force-graph-2d, already exists. I used it — while avoiding the SSR problem above.
The core work
I made a client component document-graph-view.tsx and loaded the graph library inside it with next/dynamic(..., { ssr: false }). That way the server never touches the component and canvas is drawn only in the browser. Since the heavy graph library loads only on the detail page, other pages' performance is unaffected.
There was one trap. To use zoomToFit (auto-fitting the graph to the container after the simulation settles), you need a ref to the library instance — but next/dynamic doesn't forward that ref. So I added a thin wrapper component, force-graph-canvas.tsx, that owns the ref itself. It calls zoomToFit exactly once in the onEngineStop callback (the moment the simulation halts).
Takeaways
- Wrap libraries that depend on
canvasorwindowwithnext/dynamic(..., { ssr: false })in Next.js. - If you need such a library's imperative API (a feature you must command directly, e.g.
zoomToFit), wrapping it in a separate thin client wrapper that owns the ref is the clean approach.
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 custom force. - alpha: the simulation's "energy." It starts large and cools toward 0 as the layout stabilizes. A custom force scales its strength by this alpha.
- Pinned node: a node fixed at a specific coordinate, ignoring forces. Giving a node
fx,fy(fixed x/y) locks it in place.
The situation
The default layout is pretty but showed no depth information. A document 1 hop away and one 3 hops away both landed anywhere. I wanted "how far from the current document" to be readable at a glance.
The core work
The idea is simple: make depth the distance from the center. Depth 1 is an 88px-radius orbit, depth 2 is 176px, and so on.
I wrote a custom force function radialByDepth. On every simulation tick, for each node it takes the difference between "the current radius" and "the target radius the node's depth should sit at," and nudges the velocity to shrink that gap. The current document is pinned at the origin with fx: 0, fy: 0, so all orbits revolve around it.
To let this orbital force win, I tuned the default forces too — weakening link attraction and node repulsion (charge) so my orbital force drives the layout. As a result, nodes settle near the depth-based concentric rings.
Takeaways
- The layout itself can carry information. Here I encoded "distance from center = how near or far the relevance is" as orbits.
- If a library's default forces fight your custom force, you won't get the picture you want. To let your force show, you often have to lower the default forces' strength.
4. Not the usual graph, but an "observatory" (design)
Background (concepts)
- Design token: managing values like color, spacing, and font as named CSS variables such as
--signal,--border. Change the theme and only these variables need to swap; the whole thing follows. - canvas can't read CSS variables: HTML elements can use
var(--signal), but drawings on canvas need actual color strings passed directly in JavaScript. - AI slop: designs that switch on gradients, glows, and emoji everywhere, looking "hastily AI-made." Avoiding that was the goal.
The situation
The default was a few blue dots on a black background — dull. I wanted an "astronomical-instrument screen" feel that matches this blog's visual language (1px ink borders, mono uppercase labels, paper grain).
The core work
- Panel: I reused the project's existing
.section-ink(ink background) +.paper-grain(paper noise) utilities. No new styles invented — layered onto the existing tone. - Drawing orbits: in
onRenderFramePre(a callback that draws before the nodes each frame) I drew dotted rings per depth and a crosshair directly on canvas. The orbits from step 3 become visible. - Current-document marker: I started with reticle-like crosshair ticks, but it was too much, so I trimmed it to a dot + a simple ring. The accent color (orange) is used on this alone.
- Color: since the panel is always ink-colored, I kept the graph palette as fixed values independent of the theme. Each document type (til/knowledge/blog/portfolio) has its own color.
- HUD / legend: I overlaid mono uppercase instrument labels like
LOCAL GRAPHat top-left and23 NODES · D2at top-right. The legend shows only the types that actually exist in the graph.
Takeaways
- Spend your boldness in one place. Put the weight on one signature ("the orbits") and keep the rest (colors, labels) quiet, and the intent stays clear without being flashy.
- When you have a design system, reach for existing tokens and utilities first, even for a new component. Reuse preserves tonal consistency.
5. Making stars twinkle in the background (CSS animation)
Background (concepts)
- Compositor-friendly animation: the only properties the browser can handle cheaply on the GPU without recomputing layout are
transformandopacity. Animate only these and it won't stutter even on low-end devices. - Hydration: the process where React attaches events to the server-built HTML to make it come alive. If the server-built screen and the browser-built screen differ, you get warnings/errors. So anything that changes each time, like random values, must not go into server rendering.
The situation
The observatory panel's background felt empty. The request: "make it twinkle like stars, and add random blur for a sense of depth."
The core work
I made 42 stars, but processed them with CSS instead of drawing them in JavaScript. Each star is a small <span>, with position, size, blur, animation duration, and start delay randomized per star in inline styles. The twinkle itself is a CSS @keyframes that only sweeps opacity between 0.1 and 0.8. Because blur differs per star, some are crisp and some are hazy, creating depth.
To avoid the hydration problem, star coordinates are generated once at mount with useMemo, and rendered only after the size measurement finishes (browser-only). The server draws no stars, so there's no mismatch between server and browser screens. And under prefers-reduced-motion (the user's "reduce motion" preference), the animation is turned off and left at a soft fixed brightness.
Takeaways
- Repeated subtle motion is cheaper and more stable with CSS
@keyframesthan JavaScript — as long as you animate onlyopacity/transform. - Don't put random values into server rendering; generate them only in the browser. Otherwise hydration breaks.
- Always add a
prefers-reduced-motionfallback for motion.
6. Seeing it big with fullscreen (Fullscreen API)
Background (concepts)
- Fullscreen API: a standard browser feature that blows a specific element up to the whole screen. Turn it on with
element.requestFullscreen()and off withdocument.exitFullscreen(). No need to build a modal yourself. - useSyncExternalStore: a React hook that lets React safely subscribe to state living outside React (here, "are we in fullscreen now").
The situation
The panel was small, so it felt cramped with many nodes. I needed a way to blow it up to fullscreen.
The core work
Instead of building a new modal, I used the native browser 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 fit the graph to the new size. The star background, orbits, and interactions all come along.
Takeaways
- Don't rebuild what the browser already does. One native Fullscreen API saved me from writing all the modal code, the overlay, and the scroll lock.
Wrapping up
It was a small widget, but the lessons were dense. I split the data so the server computes the maximum range and the client only filters, and wrapped the heavy canvas library with dynamic import + a ref wrapper. I encoded depth into orbits with a custom force, and dodged the usual graph with a design principle of putting weight on a single signature. The stars twinkle cheaply via CSS opacity animation, and fullscreen came free with a native API.
One principle runs through all of it: find and use what already exists (the relation index, the design tokens, the browser APIs) first, and build only what's missing, minimally. That both cuts code and keeps the result consistent.