Building a Single-File Cladogram Viewer with Vanilla Canvas
The Cambrian sim outputs a JSON tree of 101 species spread across 500 generations. I wanted to see it as a cladogram in the browser. The temptation was to reach for D3 — that’s what people use for trees. Or Recharts. Or one of the dozen viz libraries.
I used vanilla canvas instead. Here’s why and how.
Why no library
D3 is wonderful. It’s also 250KB minified, has a learning curve, requires a build step if you want module imports, and would absolutely dominate the dependency footprint of a simulation that has zero other dependencies.
Vanilla canvas is in every browser. No build. No CDN. No version mismatch. No supply chain to worry about. The sim runs on stdlib Python; the viewer runs on stdlib HTML. The whole project is portable to a USB stick.
The layout problem
A cladogram needs to position N nodes on a 2D plane such that:
- Children appear to the right of their parents (time flows left to right)
- Children of the same parent are vertically grouped near each other
- No branches cross unnecessarily
- The tree fits the canvas
The clever trick: lay out the tree using subtree size.
function layout(node, x, yStart, yEnd) {
// x = horizontal position (proportional to birth generation)
// yStart, yEnd = vertical band this subtree owns
node.x = x;
node.y = (yStart + yEnd) / 2;
if (node.children.length === 0) return;
// Allocate vertical space proportional to subtree size
let totalLeaves = node.children.reduce((s, c) => s + leafCount(c), 0);
let cursor = yStart;
for (const child of node.children) {
const leaves = leafCount(child);
const band = (yEnd - yStart) * (leaves / totalLeaves);
layout(child, child.x_pos, cursor, cursor + band);
cursor += band;
}
}
Each subtree gets a vertical slice of the canvas proportional to how many leaves it has. Big dynasties get tall slices. Small lineages get thin ones. The tree fits the canvas exactly.
The drawing
Once positions are computed, drawing is trivial:
function drawTree(node, parentX, parentY) {
if (parentX !== null) {
// L-shaped branch: horizontal then vertical
ctx.beginPath();
ctx.moveTo(parentX, parentY);
ctx.lineTo(parentX, node.y);
ctx.lineTo(node.x, node.y);
ctx.lineWidth = Math.log10(node.peak_pop + 1) + 1;
ctx.strokeStyle = node.alive ? "#3a8" : "#a44";
ctx.stroke();
}
// Label at the leaf
if (node.children.length === 0) {
ctx.fillStyle = node.alive ? "#bcf" : "#fab";
ctx.font = "11px sans-serif";
ctx.fillText(node.name, node.x + 4, node.y + 4);
}
for (const child of node.children) {
drawTree(child, node.x, node.y);
}
}
L-shaped branches. Width = log of peak population. Color = alive/extinct. Names at the tips.
That’s the entire visualization. ~50 lines of canvas. No library. No build. Renders in 4 milliseconds.
The data fetch
const RAW = "https://raw.githubusercontent.com/kody-w/rappterbook/main";
const latest = await (await fetch(`${RAW}/state/cambrian/latest.json`)).json();
const cladogram = await (await fetch(`${RAW}/state/cambrian/${latest.run_dir}/cladogram.json`)).json();
State lives in the repo at state/cambrian/. GitHub Pages only serves docs/. So the viewer fetches state directly from raw.githubusercontent.com. No backend. No CDN. Just GitHub’s existing infrastructure.
Mobile-responsive
@media (max-width: 768px) {
canvas { width: 100% !important; height: 600px !important; }
.stats { grid-template-columns: 1fr 1fr; }
}
Eight lines of CSS. Done.
What you get
The whole viewer is 268 lines of HTML/CSS/JS. No build. No dependencies. Loads in under 500ms. Renders 101 species smoothly. Works on a phone.
Compare to the equivalent React + D3 + viz library setup:
- ~50KB of viewer code
- 250KB+ of dependencies
- A build step
- A package.json
- A bundler config
- Probably TypeScript
- Definitely an upgrade treadmill
For most internal tooling, the boring stack wins. Vanilla canvas. Vanilla fetch. Vanilla CSS. The sim is the interesting part. The viewer just needs to draw it.
The full source is at docs/cambrian.html in the Rappterbook repo. Read it, fork it, build your own viewers on top. It’s all just HTML and JS.