Part 6 · The Mindmap Renderer: What Hand-Rolling SVG Taught Me (and Why v2 Uses React Flow)
Part of a series on building Gemma CogniVault. Previously: Getting reliable JSON out of a local LLM.
All abbreviations are fully explained in the appendix at the bottom of the page.
CogniVault’s Study Hub has four modes. Three of them — Quiz, Workshop, Flashcards — are list-shaped. The fourth, Mindmap, isn’t. It’s a tree of concepts radiating from a central topic, and I wanted it to be:
- Visually clean enough that the user actually wants to look at it.
- Interactive: pan, zoom, explore.
- Exportable to PNG and PDF with high fidelity.
This post is the honest version of how that renderer got built — twice. Version one was hand-rolled SVG with no graph library, and it shipped. Version two, the one in the codebase today, is built on @xyflow/react (React Flow) and a dagre auto-layout. I think both decisions were correct at the time they were made, and the journey between them taught me more about build-vs-buy than either version alone.
Round one: hand-rolling it
My first instinct, like everyone else’s, was to reach for a library on day one. I resisted, for reasons that were sound: the default styling would need full customisation anyway, the layout I wanted was simple, export would need extra dependencies regardless, and the bundle cost wasn’t nothing. For the small trees Gemma generates, SVG alone looked sufficient — it pans and zooms with the viewBox attribute, draws arbitrary shapes, serialises to a string, and rasterises cleanly.
So v1 was pure SVG. And the core of it really was small.
Radial layout in 40 lines
A radial layout places the root at the centre and arranges children in concentric rings:
type Node = { id: string; label: string; children: Node[] };
type Placed = Node & { x: number; y: number; angle: number };
function layout(root: Node, radiusStep = 180): Placed[] {
const placed: Placed[] = [];
function place(
node: Node,
depth: number,
fromAngle: number,
toAngle: number,
) {
if (depth === 0) {
placed.push({ ...node, x: 0, y: 0, angle: 0 });
} else {
const angle = (fromAngle + toAngle) / 2;
placed.push({
...node,
x: depth * radiusStep * Math.cos(angle),
y: depth * radiusStep * Math.sin(angle),
angle,
});
}
const slice = (toAngle - fromAngle) / Math.max(node.children.length, 1);
node.children.forEach((child, i) =>
place(
child,
depth + 1,
fromAngle + i * slice,
fromAngle + (i + 1) * slice,
),
);
}
place(root, 0, 0, 2 * Math.PI);
return placed;
}
Each level inherits an angular slice from its parent and subdivides it among its children. Pan and zoom were pure viewBox arithmetic — no transform matrices, no event library, just numbers. Edges were quadratic Bézier curves pulled toward the centre. It looked good, it was fast, and the whole renderer fit comfortably in one component.
The export trick that’s still worth knowing
To export an SVG to PNG with zero dependencies, the browser does all the work:
SVG DOM ─► XMLSerializer ─► string ─► <img> ─► <canvas> ─► PNG blob
Serialise the SVG to a string, load it into an Image, draw that image onto a scaled <canvas>, and ask the canvas for a PNG. Fonts, anti-aliasing, currentColor — the browser resolves all of it natively. If your visual is an SVG element, this is still the cleanest export pipeline there is, and I’d use it again without hesitation.
One v1 detail survived to the present day completely unchanged: the save flow. Instead of the classic “straight to the Downloads folder” experience, exports go through the File System Access API (showSaveFilePicker) where the browser supports it, with an anchor-tag download fallback for Firefox and Safari. A real “Save As…” dialog, no Electron required. That helper (lib/saveBlob.ts) now serves the quiz and workshop exports too.
The requirements that broke v1
Then the feature met its users (well — met me, using it seriously while studying), and three requirements emerged that the elegant hand-rolled version handled badly:
“Let me move that node.” A generated layout is a starting point; a useful mindmap is one you rearrange to match how you think. v1’s nodes were fixed in their computed positions. Adding drag meant building hit-testing, drag state, and position persistence from scratch — exactly the unglamorous interaction machinery that graph libraries exist to provide.
Text wanted to be HTML. SVG
<text>doesn’t wrap. Long concept labels needed manual line-breaking, measuring, and truncation — a constant fight. HTML nodes (real<div>s with CSS) wrap, ellipsize, and theme for free.Radial wasn’t the best reading layout after all. For the wide-and-shallow trees Gemma actually generates, a left-to-right or top-down tree (the kind a layout engine like dagre computes) reads better than rings. And once layouts became switchable, “auto-layout plus remembered manual tweaks” became the natural model.
I could have built all of that on the SVG foundation. But look at the list: viewport management, node dragging, HTML nodes inside a graph canvas, pluggable layouts. That is precisely React Flow’s feature set. In v1, the library would have been a wrapper around things I didn’t need. By v2, my requirements had grown into exactly the things it does well.
So I changed my mind.
Round two: React Flow + dagre
Today’s renderer (frontend/src/components/study/mindmaps/):
@xyflow/react(React Flow) provides the canvas: native pan/zoom, draggable nodes, a minimap-style controls cluster, and dark-mode support viacolorMode.- dagre computes the automatic layout, with a user-facing toggle between left-to-right and top-down. The tree-to-graph conversion is a small pure function.
- Custom HTML nodes carry the design system: the root gets a gradient, themes get a tint, leaves stay subtle — and text wraps like text should.
- Dragged positions persist. Moving a node fires a save; reopening the map restores your arrangement. A “Reset layout” button clears the saved positions and returns to the dagre auto-layout. The layout choice and positions live with the mindmap in SQLite.
- Export adapted to the new reality. The nodes are HTML now, so the v1 SVG-serialisation trick no longer applies. PNG export uses
html-to-imageover the React Flow viewport, framed to the node bounds regardless of current zoom; PDF embeds that PNG via a lazy-loadedjsPDF; Markdown export is a zero-dependency recursive walk of the tree. Yes — v2 uses the exact library (html-to-image) I was proud of avoiding in v1. The requirements changed; the trade-off changed with them.
What the journey actually taught me
I went back and forth on how to write this post, because v1’s story (“look how little code you need!”) is more flattering. But the two-version truth is the more useful lesson:
Hand-rolling first was still right. v1 shipped in a weekend, taught me the problem’s real shape (layout, viewport, export are separate concerns), and cost nothing to throw away because it was small. If I’d started with React Flow, I’d have configured a library before understanding the problem.
Libraries earn their place when your requirements converge on their feature set — not before. The moment “drag nodes and remember where I put them” became a requirement, the build-vs-buy maths flipped completely.
Some pieces outlive the rewrite. The save-dialog helper, the Markdown walk, the instinct to frame exports to content bounds — all carried over. Rewrites are rarely total.
The browser’s native pipelines are worth knowing even when you end up not using them. SVG → canvas → PNG is still the best zero-dependency export trick in frontend development. It just stops applying the day your nodes become HTML.
Takeaway
“Build or buy” is a function of requirements — and requirements move. Build while the problem is small and you’re still learning its shape. Buy when your feature list starts reading like the library’s README. And when you switch, write down why, so the next person (or the next you) knows it wasn’t indecision. It was the plan growing up.
Appendix: Abbreviations in this post
| Abbreviation | Full form | Meaning |
|---|---|---|
| SVG | Scalable Vector Graphics | The browser’s built-in vector drawing format — v1’s entire foundation |
| PNG | Portable Network Graphics | The raster image format exports produce |
| Portable Document Format | The print-ready export, built by embedding the PNG | |
| DOM | Document Object Model | The browser’s live representation of the page — what html-to-image rasterises in v2 |
| HTML / CSS | HyperText Markup Language / Cascading Style Sheets | What v2’s nodes are made of — and why their text wraps for free |
| API | Application Programming Interface | As in the File System Access API, which provides real “Save As…” dialogs |
| UI / UX | User Interface / User Experience | The drag-a-node requirement that triggered the rewrite |
| JSON | JavaScript Object Notation | The tree structure Gemma generates for each mindmap (see the previous post) |
| SQLite | (SQL = Structured Query Language) | The single-file database where layout choices and node positions persist |
Next up: Gamifying learning — 25 badges, idle-gap sessions, and a 90-day heatmap.
