How to Build a Force-Directed Graph with D3.js: From Theory to Code

This article explains the fundamentals of D3.js force-directed graph layouts, covering the underlying physics-inspired algorithms, node initialization, quad-tree collision detection, Barnes-Hut optimization, link forces, and simulation steps, and provides practical code examples and tips for handling common rendering issues.

ELab Team
ELab Team
ELab Team
How to Build a Force-Directed Graph with D3.js: From Theory to Code

Introduction

D3.js is a web‑standard based JavaScript visualization library that uses SVG, Canvas and HTML for data visualisation. Graph layout algorithms organise scattered node‑link data into clear, aesthetically pleasing structures, such as tree or DAG layouts.

Different graph layouts serve different scenarios; for example, a tree layout or a DAG layout (directed acyclic graph) is suitable for workflow representations.

In a concentric‑circle layout, nodes are sorted by degree, placing the highest‑degree node at the centre and decreasing outward, forming concentric circles.

A force‑directed layout reduces node overlap by simulating repulsive forces; the implementation is based on physical theory, modelling nodes as atoms that interact until reaching equilibrium, known as a force‑directed graph .

Approach

The system can be decomposed into two entities and one factor: nodes, links, and forces. The d3‑force implementation follows the classic Fruchterman‑Reingold algorithm: compute repulsive forces between nodes, attractive forces along links, derive node velocities, and use a simulated‑annealing decay to reach stability.

Demo link: https://jsfiddle.net/smallstars/h8y6e031/51/

d3‑force Principles

Node Processing

Initialize Nodes

Nodes are imported into D3 and pre‑processed with a given radius and angle to arrange them in a circle.

// d3-force/simulation.js
var initialRadius = 10,
    initialAngle = Math.PI * (3 - Math.sqrt(5));

function initializeNodes() {
    for (var i = 0, n = nodes.length, node; i < n; ++i) {
        node = nodes[i], node.index = i;
        if (node.fx != null) node.x = node.fx;
        if (node.fy != null) node.y = node.fy;
        // initial position
        if (isNaN(node.x) || isNaN(node.y)) {
            var radius = initialRadius * Math.sqrt(0.5 + i),
                angle = i * initialAngle;
            node.x = radius * Math.cos(angle);
            node.y = radius * Math.sin(angle);
        }
        // vx, vy are velocities
        if (isNaN(node.vx) || isNaN(node.vy)) {
            node.vx = node.vy = 0;
        }
    }
}

Build Quad‑Tree

Quad‑Tree (quad‑tree)

A quad‑tree facilitates collision detection by recursively subdividing 2‑D space into four quadrants, each node having up to four children.

When inserting nodes, four cases are considered: empty tree, empty quadrant, coincident coordinates without an array index (create array index and split), and coincident coordinates with an array index (directly attach).

// d3-force/collide.js
for (var k = 0; k < iterations; ++k) {
  // build quad‑tree
  tree = quadtree(nodes, x, y).visitAfter(prepare);
  for (i = 0; i < n; ++i) {
    node = nodes[i];
    ri = radii[node.index], ri2 = ri * ri;
    xi = node.x + node.vx;
    yi = node.y + node.vy;
    tree.visit(apply);
  }
}

// d3-quadtree/add.js
function add(tree, x, y, d) {
  if (isNaN(x) || isNaN(y)) return tree; // ignore invalid points
  var parent,
      node = tree._root,
      leaf = {data: d},
      x0 = tree._x0,
      y0 = tree._y0,
      x1 = tree._x1,
      y1 = tree._y1,
      xm, ym, xp, yp, right, bottom, i, j;

  // case1: empty tree
  if (!node) return tree._root = leaf, tree;

  // descend to leaf
  while (node.length) {
    if (right = x >= (xm = (x0 + x1) / 2)) x0 = xm; else x1 = xm;
    if (bottom = y >= (ym = (y0 + y1) / 2)) y0 = ym; else y1 = ym;
    if (parent = node, !(node = node[i = bottom << 1 | right])) return parent[i] = leaf, tree;
  }

  // case4: coincident point
  xp = +tree._x.call(null, node.data);
  yp = +tree._y.call(null, node.data);
  if (x === xp && y === yp) return leaf.next = node, parent ? parent[i] = leaf : tree._root = leaf, tree;

  // case3: split until separate quadrants
  do {
    parent = parent ? parent[i] = new Array(4) : tree._root = new Array(4);
    if (right = x >= (xm = (x0 + x1) / 2)) x0 = xm; else x1 = xm;
    if (bottom = y >= (ym = (y0 + y1) / 2)) y0 = ym; else y1 = ym;
  } while ((i = bottom << 1 | right) === (j = (yp >= ym) << 1 | (xp >= xm)));
  return parent[j] = node, parent[i] = leaf, tree;
}

Optimized Repulsion

The core repulsive force is the electrostatic charge force. Naïvely summing forces from all other nodes yields O(N²) complexity; using the Barnes‑Hut approximation reduces it to O(N log N).

Aggregate Forces

Barnes‑Hut groups distant nodes into a single quad, with accuracy controlled by the theta parameter, and the Velocity‑Verlet integrator updates velocities.

// d3-force/manyBody.js
var distanceMin2 = 1,
    distanceMax2 = Infinity,
    theta2 = 0.81;

function apply(quad, x1, _, x2) {
  if (!quad.value) return true;

  var x = quad.x - node.x,
      y = quad.y - node.y,
      w = x2 - x1,
      l = x * x + y * y;

  // Barnes‑Hut condition
  if (w * w / theta2 < l) {
    if (l < distanceMax2) {
      if (x === 0) x = jiggle(random), l += x * x;
      if (y === 0) y = jiggle(random), l += y * y;
      if (l < distanceMin2) l = Math.sqrt(distanceMin2 * l);
      node.vx += x * quad.value * alpha / l;
      node.vy += y * quad.value * alpha / l;
    }
    return true;
  }

  // If quad has a single node
  if (quad.length || l >= distanceMax2) return;

  // Exclude self‑interaction
  if (quad.data !== node || quad.next) {
    if (x === 0) x = jiggle(random), l += x * x;
    if (y === 0) y = jiggle(random), l += y * y;
    if (l < distanceMin2) l = Math.sqrt(distanceMin2 * l);
  }

  do if (quad.data !== node) {
    w = strengths[quad.data.index] * alpha / l;
    node.vx += x * w;
    node.vy += y * w;
  } while (quad = quad.next);
}

Link Forces

Links are initialised by computing node degrees and the proportion of each edge’s contribution to its source/target degree. The default link distance is 30 and strength is 1, reducing attraction for high‑degree nodes.

// d3-force/link.js
function force(alpha) {
  for (var k = 0, n = links.length; k < iterations; ++k) {
    for (var i = 0, link, source, target, x, y, l, b; i < n; ++i) {
      link = links[i], source = link.source, target = link.target;
      x = target.x + target.vx - source.x - source.vx || jiggle(random);
      y = target.y + target.vy - source.y - source.vy || jiggle(random);
      l = Math.sqrt(x * x + y * y);
      l = (l - distances[i]) / l * alpha * strengths[i];
      x *= l, y *= l;
      target.vx -= x * (b = bias[i]);
      target.vy -= y * b;
      source.vx += x * (b = 1 - b);
      source.vy += y * b;
    }
  }
}

Simulation Loop

The simulation repeatedly applies forces, decays the alpha parameter, and updates node positions until alpha falls below a threshold, achieving a stable layout.

// d3-force/simulation.js
var simulation,
    alpha = 1,
    alphaMin = 0.001,
    alphaDecay = 1 - Math.pow(alphaMin, 1 / 300),
    alphaTarget = 0,
    velocityDecay = 0.6,
    stepper = timer(step),
    event = dispatch("tick", "end");

function step() {
  tick();
  event.call("tick", simulation);
  if (alpha < alphaMin) {
    stepper.stop();
    event.call("end", simulation);
  }
}

function tick() {
  alpha += (alphaTarget - alpha) * alphaDecay;
  forces.each(function(force) { force(alpha); });
  for (i = 0; i < n; ++i) {
    node = nodes[i];
    if (node.fx == null) node.x += node.vx *= velocityDecay;
    else node.x = node.fx, node.vx = 0;
    if (node.fy == null) node.y += node.vy *= velocityDecay;
    else node.y = node.fy, node.vy = 0;
  }
}

Practical Example

Demo: React‑d3js Code: https://github.com/SmaIIstars/react-demo/blob/master/src/pages/d3-force/index.tsx

Common Issues

Complex SVG content – use foreignObject nodes to embed XML elements.

Too many iteration steps causing UI lag – employ React.memo and useCallback to minimise unnecessary calculations.

Node overlap – increase repulsive force and link length, though complete avoidance may be impossible in limited space.

Excessive nodes exceeding canvas – adapt canvas size to viewport and enable zoom/pan for full view.

References

GitHub – d3/d3-force: Force‑directed graph layout using velocity Verlet integration.

D3.js force‑directed graph theory documentation.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScriptsimulationD3.jsgraph layoutforce-directed graphBarnes-Hutquad-tree
ELab Team
Written by

ELab Team

Sharing fresh technical insights

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.