Cobra Developers
Blog, Engineering

A force-directed graph on canvas in 200 lines, no library

The short answer

A force-directed graph needs three forces and two safety measures. Every pair of nodes repels, every link acts as a spring pulling its two ends toward a rest length, and a weak pull keeps everything near the centre. A speed cap stops nodes from being flung away, and a cooling factor brings the layout to rest.

Halfway down our homepage there is a diagram that moves. Squares are our projects, circles are the technologies they run on, and a line joins each project to everything in its stack. You can drag any node and the rest of the web follows.

We did not lay it out by hand, and we did not load a charting library. The positions come from a small physics simulation that runs in your browser, in one file of under 200 lines drawing to a 2D canvas. The data is the same projects.json the rest of the site uses.

Force layouts have a reputation for being fiddly. They are, but the fiddly part is tuning five or six numbers, not the code. Here is ours.

How does a force-directed graph layout work?

A force-directed layout treats the graph as a physical system. Nodes push each other apart like charged particles, links pull connected nodes together like springs, and the simulation steps forward until the forces balance. Connected nodes end up close, unrelated nodes drift apart, and clusters appear without anyone placing them.

Our graph has two kinds of node. A project is a square of fixed size. A technology is a circle whose radius grows with the number of projects that use it:

for (const p of data) add({ id: 'p:' + p.slug, label: p.name, project: p, r: 7, deg: p.tech.length });
for (const [t, c] of Object.entries(count)) add({ id: 't:' + t, label: t, r: 2.5 + Math.sqrt(c) * 2.1, deg: c });
for (const p of data) for (const t of p.tech) links.push({ a: byId.get('p:' + p.slug), b: byId.get('t:' + t) });

The square root keeps the most used technology from swallowing the picture. Each node also gets a position, a velocity, and a set of its neighbours, which the drawing code uses later to highlight connections.

How do you make nodes repel each other?

Repulsion is a loop over every pair of nodes that pushes the two apart with a force that falls off with the square of the distance between them. Close nodes are shoved hard and distant nodes barely notice each other. Each pair is visited once and the push is applied equally in opposite directions.

for (let i = 0; i < nodes.length; i++) {
  const a = nodes[i];
  for (let j = i + 1; j < nodes.length; j++) {
    const b = nodes[j];
    let dx = a.x - b.x, dy = a.y - b.y;
    let d2 = dx * dx + dy * dy;
    if (d2 < 1) { dx = Math.random() - 0.5; dy = Math.random() - 0.5; d2 = 1; }
    if (d2 > 90000 * k * k) continue;
    const f = rep / d2, d = Math.sqrt(d2);
    dx = (dx / d) * f; dy = (dy / d) * f;
    a.vx += dx; a.vy += dy; b.vx -= dx; b.vy -= dy;
  }
}

Two lines in there are defensive. If two nodes sit on exactly the same point, there is no direction to push them in, so we pick a random one. And pairs that are far apart are skipped, because their push would be tiny and the square root is not free.

This is the brute force approach. It checks every pair, so the work grows with the square of the node count. Our graph has under a hundred nodes, so that is a few thousand cheap pair checks per step. Graphs with thousands of nodes need a spatial structure such as a quadtree to approximate distant groups, and at that point a library is the sensible choice.

How do springs and centering hold the graph together?

Springs pull each pair of linked nodes toward a fixed rest length: stretched links pull inward and compressed links push outward, in proportion to how far off they are. A separate weak force pulls every node toward the centre, which keeps unconnected groups from drifting out of view.

for (const l of links) {
  const dx = l.b.x - l.a.x, dy = l.b.y - l.a.y;
  const d = Math.hypot(dx, dy) || 1;
  const f = (d - rest) * spring;
  const fx = (dx / d) * f, fy = (dy / d) * f;
  l.a.vx += fx; l.a.vy += fy; l.b.vx -= fx; l.b.vy -= fy;
}
const sx = 0.011 * (H / W), sy = 0.016;
for (const n of nodes) { n.vx -= n.x * sx; n.vy -= n.y * sy; /* ... */ }

The centering pull is weaker sideways than vertically, and weaker still on a wide canvas. That makes the layout settle into a wide oval that fills a landscape box instead of a circle with empty margins.

Centering looks optional until you have a node with no shared links. Planet Chase is ours: it is built on Canvas 2D, vanilla JavaScript, and procedural textures, and no other project lists any of those. Springs cannot hold that little group to the rest. Only the centering pull and the canvas bounds keep it on screen. SignalDesk is the opposite case, tied into the main cluster through Python and the Claude API, which several other projects share.

Why do nodes fly off the canvas, and how do you stop it?

Nodes fly off because repulsion grows without limit as two nodes get close, and a simulation takes discrete steps. One step with a huge force produces a huge velocity, and the node lands far away before any spring can respond. The fix is damping, a cap on speed, and hard bounds.

This was our main tuning mistake. Every node starts on a small ring so the graph appears to unfold, which means the first frames have nodes only a few pixels apart. With our repulsion constant at its reference scale, two nodes four pixels apart push each other at more than two hundred pixels per step. Early versions threw nodes straight into the walls.

n.vx *= 0.8; n.vy *= 0.8;
const sp = Math.hypot(n.vx, n.vy);
if (sp > 14) { n.vx *= 14 / sp; n.vy *= 14 / sp; }      // nothing gets flung into a wall
n.x += n.vx * heat; n.y += n.vy * heat;
const mx = W / 2 - 14, my = H / 2 - 14;
n.x = Math.max(-mx, Math.min(mx, n.x)); n.y = Math.max(-my, Math.min(my, n.y));

Each line does one job. Multiplying velocity by 0.8 bleeds off energy every step. The cap means no node moves more than 14 pixels in a step, however hard it was pushed. The clamp keeps positions inside the canvas with a margin.

The other lesson was scale. One set of pixel constants cannot suit both a laptop and a phone, so the forces are derived from the canvas size: k is the square root of the canvas area divided by a reference value, and both the repulsion strength and the spring rest length are multiplied by it.

How do you make the simulation settle and stop?

A simulation settles when you multiply every movement by a "heat" value that decays a little each step. Early on, nodes move freely and find a rough arrangement. As heat falls, movements shrink until the layout is still, and at that point you stop requesting animation frames altogether.

heat = Math.max(0.02, heat * 0.992);
// in the frame loop
step(); step();
draw();
if (heat > 0.03 || drag) raf = requestAnimationFrame(loop);

Here is the order of work in each step:

  1. Apply repulsion between every pair of nodes.
  2. Apply the spring force along every link.
  3. Pull each node toward the centre.
  4. Damp the velocity and cap the speed.
  5. Move the node, scaled by heat, and clamp it to the canvas.
  6. Cool the heat slightly.

A graph that never stops is a battery drain, so ours stops. Dragging a node raises the heat again so the neighbours can rearrange, and resizing the window resets it. The loop only runs while the graph is on screen, using an IntersectionObserver. For visitors who prefer reduced motion, we run 400 steps without drawing and show the finished layout as a still.

How do you detect which node the pointer is on?

Hit testing on a canvas means doing the geometry yourself, because a canvas has no elements to receive events. Convert the pointer position into the simulation's coordinates, then find the nearest node within a tolerance. Comparing squared distances avoids a square root for every node.

const at = (e) => {
  const r = canvas.getBoundingClientRect();
  const x = e.clientX - r.left - W / 2, y = e.clientY - r.top - H / 2;
  let best = null, bd = 22 * 22;
  for (const n of nodes) { const d = (n.x - x) ** 2 + (n.y - y) ** 2; if (d < bd) { bd = d; best = n; } }
  return { x, y, n: best };
};

The 22 pixel tolerance is larger than any node, on purpose. Small targets are hard to hit, and nearest-wins resolves overlaps. On touch screens we do not start a drag at all, so a finger on the graph still scrolls the page. A tap pins a node and shows its connections, and a second tap on a pinned project opens it.

A canvas is invisible to screen readers, so the same data is available under the graph as a plain list of buttons. If you like this kind of thing, the hero at the top of the page is covered in our WebGPU shader walkthrough, and the blog's underline animation in animating a snake with canvas.

Frequently asked questions

When should you use a library instead of writing this yourself?

Use a library when the graph is large, when you need zooming and panning, or when you need layouts other than force-directed. Writing it yourself makes sense for a small, fixed-purpose graph where you want full control of the look and no extra download.

Should a graph like this use canvas or SVG?

SVG gives you real elements, so hover, focus, and styling come from the browser, and it suits small graphs. Canvas redraws everything each frame with no element overhead, which holds up better as node counts and animation increase. We chose canvas and wrote our own hit testing.

How do you keep canvas text sharp on high-density screens?

Size the canvas backing store by the device pixel ratio and scale the drawing context to match, so one unit in your code is still one CSS pixel. We cap the ratio at two, because beyond that the extra pixels cost drawing time for no visible gain.

How does a canvas pick up the colours of the page?

A canvas does not inherit CSS, so the script reads its colours from custom properties on the container element. Change the properties in the stylesheet and the graph follows without touching the script.

If you have data that would make more sense as something people can pull on, tell us about it.

Who wrote this

The Cobra Developers team. We are a software studio in Arizona that builds and runs its own products and builds websites, web apps, and automation for clients. This post draws on our work on SignalDesk, Planet Chase. Everything here comes from projects we shipped; see all 30 of them.

guest@cobra: ~