Tutorial · Graph Theory · Canvas 2D · JavaScript
📅 July 2026 ⏱ ≈ 25 min 🎯 Intermediate

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)
  }
}
Choosing restLength and k: a larger restLength spreads the whole graph out; a larger k makes edges "stiffer" and the layout converges faster but can overshoot and oscillate. Typical starting values: restLength = 60–100px, k = 0.02–0.1.

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
    }
  }
}
This is the O(N²) bottleneck: the double loop here is the single most expensive part of the simulation — see section 8 for the Barnes-Hut fix that makes graphs of thousands of nodes practical.

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;
  }
}
Convergence check: sum the total kinetic energy (Σ vx² + vy²) each frame — once it drops below a small threshold, the layout has settled and you can stop calling 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
    });
  }
}
When to bother: below ~300 nodes the plain O(N²) loop is simpler and fast enough at 60fps. Beyond that, Barnes-Hut (θ ≈ 0.7–0.9) keeps the layout interactive up into the low thousands of nodes — the same tree structure used for gravitational N-body simulation applies here almost unchanged, because both problems are "every particle repels every other particle" at their core.

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.