Force-Directed Graph Layout in Canvas 2D From Scratch
Every "pretty" graph visualization you've seen — GitHub's dependency graphs, Obsidian's note graph, Gephi's network maps — is drawn using the same core idea: treat edges as springs and nodes as charged particles that repel each other, then let physics settle the layout. This is the spring-embedder / force-directed algorithm, and it takes well under 150 lines of Canvas 2D JavaScript to build from scratch.
1. Graph Data Model
The whole layout engine needs just two arrays: a list of nodes (each carrying a position and velocity) and a list of edges (pairs of node indices). No adjacency matrix is needed — an edge list is enough for both the spring forces and the rendering pass.
class GraphLayout {
constructor(nodeCount, edgeList, width, height) {
this.width = width;
this.height = height;
// Start nodes at random positions near the center
this.nodes = Array.from({ length: nodeCount }, () => ({
x: width / 2 + (Math.random() - 0.5) * 100,
y: height / 2 + (Math.random() - 0.5) * 100,
vx: 0, vy: 0,
fixed: false // true while the user is dragging this node
}));
this.edges = edgeList; // [[i, j], [i, k], ...]
}
}
// Example: a small graph, 6 nodes, 7 edges
const layout = new GraphLayout(6, [
[0,1],[0,2],[1,2],[1,3],
[2,4],[3,4],[3,5]
], 800, 600);
2. Spring Forces on Edges (Hooke's Law)
Each edge is treated as a spring with a natural rest length L₀. If the two connected nodes are farther apart than L₀ the spring pulls them together; if closer, it pushes them apart — exactly Hooke's law, F = −k·(d − L₀):
function applySpringForces(nodes, edges, restLength = 80, k = 0.05) {
for (const [i, j] of edges) {
const a = nodes[i], b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.max(Math.hypot(dx, dy), 0.01);
const force = k * (dist - restLength); // Hooke's law: F = -k(d - L0)
const fx = (dx / dist) * force, fy = (dy / dist) * force;
a.vx += fx; a.vy += fy; // pull a toward b
b.vx -= fx; b.vy -= fy; // pull b toward a (Newton's 3rd law)
}
}
3. Repulsion Between All Nodes (Coulomb's Law)
Springs alone would let unconnected nodes collapse on top of each other. A repulsive force between every pair of nodes (not just connected ones), inversely proportional to squared distance — exactly Coulomb's law for like charges — pushes the graph apart into a readable layout:
function applyRepulsion(nodes, strength = 4000) {
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
const dx = b.x - a.x, dy = b.y - a.y;
const distSq = Math.max(dx*dx + dy*dy, 25); // min distance clamp
const dist = Math.sqrt(distSq);
const force = strength / distSq; // Coulomb's law: F = k / r²
const fx = (dx / dist) * force, fy = (dy / dist) * force;
a.vx -= fx; a.vy -= fy; // push a away from b
b.vx += fx; b.vy += fy; // push b away from a
}
}
}
4. Centering Force and Damping
Without a centering pull, the mutually repelling nodes drift forever outward with no reason to stop anywhere in particular. A weak force toward the canvas center keeps the whole layout anchored, and velocity damping (friction) is what actually lets the simulation settle into a stable resting layout instead of oscillating forever:
function applyCenteringAndDamping(nodes, cx, cy, centerStrength = 0.01, damping = 0.85) {
for (const n of nodes) {
if (n.fixed) continue; // don't fight the user's mouse drag
n.vx += (cx - n.x) * centerStrength;
n.vy += (cy - n.y) * centerStrength;
n.vx *= damping; // friction: bleeds off kinetic energy each frame
n.vy *= damping;
}
}
A damping factor close to 1.0 (e.g. 0.95) lets the layout swing and settle slowly and smoothly; a lower value (e.g. 0.7) converges faster but can look abrupt. Most implementations also gradually increase damping over time ("simulated annealing") to lock the final layout in place.
5. Integration Loop
Each animation frame, apply every force in sequence, then integrate velocity into position with simple semi-implicit Euler — the exact same integrator used in every particle simulation:
function step(layout) {
applySpringForces(layout.nodes, layout.edges);
applyRepulsion(layout.nodes);
applyCenteringAndDamping(layout.nodes, layout.width / 2, layout.height / 2);
for (const n of layout.nodes) {
if (n.fixed) continue;
n.x += n.vx;
n.y += n.vy;
}
}
step()
every frame, saving CPU on static graphs.
6. Rendering Edges and Nodes
Rendering is the simplest part: clear the canvas, draw every edge as a line, then draw every node as a filled circle on top:
function render(ctx, layout) {
ctx.clearRect(0, 0, layout.width, layout.height);
// Edges first, so nodes render on top
ctx.strokeStyle = 'rgba(148,163,184,0.5)';
ctx.lineWidth = 1.5;
for (const [i, j] of layout.edges) {
const a = layout.nodes[i], b = layout.nodes[j];
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
// Nodes on top
ctx.fillStyle = '#38bdf8';
for (const n of layout.nodes) {
ctx.beginPath();
ctx.arc(n.x, n.y, 8, 0, Math.PI * 2);
ctx.fill();
}
}
// Main loop
function animate() {
step(layout);
render(ctx, layout);
requestAnimationFrame(animate);
}
animate();
7. Interactive Dragging
Dragging a node makes the layout feel alive and is essential for
exploring dense graphs. On mousedown, find the closest
node under the cursor and mark it fixed so the physics
loop skips it; on mousemove, snap that node's position
directly to the mouse; on mouseup, release it back into
the simulation:
let draggedNode = null;
canvas.addEventListener('mousedown', (e) => {
const { x, y } = getCanvasCoords(e);
let closest = null, closestDist = 15; // 15px hit radius
for (const n of layout.nodes) {
const d = Math.hypot(n.x - x, n.y - y);
if (d < closestDist) { closest = n; closestDist = d; }
}
if (closest) { closest.fixed = true; draggedNode = closest; }
});
canvas.addEventListener('mousemove', (e) => {
if (!draggedNode) return;
const { x, y } = getCanvasCoords(e);
draggedNode.x = x; draggedNode.y = y;
draggedNode.vx = draggedNode.vy = 0;
});
addEventListener('mouseup', () => {
if (draggedNode) draggedNode.fixed = false;
draggedNode = null;
});
Releasing a dragged node back into an already-settled layout gives it fresh velocity-zero conditions, so it gently re-equilibrates with its neighbours rather than snapping or flying off — a small but important detail for a layout that feels physically grounded.
8. Scaling Up: Barnes-Hut for O(N log N) Repulsion
The naive repulsion loop in section 3 is O(N²) per frame — fine for a few hundred nodes, but it grinds to a halt well before a few thousand. Exactly as in N-body gravity, a quadtree-based Barnes-Hut approximation groups distant clusters of nodes into a single "virtual charge" at their combined center of mass, cutting the cost to O(N log N):
// Sketch: build a quadtree each frame, then for each node,
// walk the tree and treat any sufficiently distant/small
// quadrant as one point mass at its center-of-mass instead
// of visiting every node inside it individually.
function applyRepulsionBarnesHut(nodes, quadtree, theta = 0.8) {
for (const n of nodes) {
quadtree.visit((quad, x0, y0, x1, y1) => {
const size = x1 - x0;
const dx = quad.comX - n.x, dy = quad.comY - n.y;
const dist = Math.hypot(dx, dy);
if (size / dist < theta) {
applyForceFrom(n, quad.comX, quad.comY, quad.mass);
return true; // stop descending — treat as one mass
}
return false; // too close/large — descend into children
});
}
}
Frequently Asked Questions
What will I learn in this tutorial?
Build a force-directed graph layout engine from scratch in Canvas 2D: spring forces on edges, Coulomb repulsion between nodes, centering gravity, velocity damping, and interactive drag-and-drop rendering.
What topics are covered in this tutorial?
This tutorial covers: Graph Data Model, Spring Forces on Edges (Hooke's Law), Repulsion Between All Nodes (Coulomb's Law), Centering Force and Damping, Integration Loop, Rendering Edges and Nodes, Interactive Dragging, Scaling Up: Barnes-Hut for O(N log N) Repulsion.
How long does this tutorial take?
This tutorial takes approximately 25 minutes to complete.
What prerequisites do I need before starting?
This is a Intermediate-level tutorial — no special preparation beyond basic JavaScript is assumed.