Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~26 min· topic 5 of 7

Topic 13.5

Force-Directed Networks

In one line

A network is things (nodes) joined by connections (links). A force simulation places the nodes by pretending they are charged balls joined by springs, and lets them settle. You decide how many steps to run, and how to keep it readable when there are too many links.

Think of it like this

Beads on strings dropped on a table. Every bead pushes the others away a little. Every string pulls its two beads together. Give them a moment to wobble and they come to rest with joined beads close and unrelated beads apart. Nobody placed them: the pushes and pulls did.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

node
A thing in a network: here, a station.
link
A connection between two nodes: here, a route, with the number of trips.
force
A rule that pushes or pulls nodes each tick: links pull, many-body pushes, centre holds, collide separates.
tick
One step of the simulation: all forces move the nodes a little.
alpha
The simulation's temperature, from 1 down to alphaMin. Forces scale with it, so the layout cools and settles.
pin
Fix a node at a place by setting fx and fy. Set them to null to let it move again.
hairball
A network drawn with so many links that nothing can be read.

Step by step

01Look at the simulation, step by step

The widget below shows a force layout one tick at a time. Press step a few times and watch the nodes move: first fast and far, then slower as alpha falls. Play it at a slow speed to see the groups form.

02Build the Lumen route network

The first example gives forceSimulation the 25 stations and 60 routes, with four forces and a gentle forceX and forceY. It calls .stop() and ticks until alpha is below alphaMin: 300 ticks. It then prints the three busiest hubs (Quay 18 routes, Lantern 13, Library 13) at their final places.

The links show before and after: the text "S03" becomes the node object.

Build the Lumen route networkdiagram
Rendering diagram…

03Pin a node, as dragging does

Dragging sets fx and fy on a node and raises alphaTarget, so the others keep moving. On release both go back to null and alphaTarget to 0. The second example does it by code: Quay held at (50, 50), then let go.

04Break it on purpose

The second example prints what goes wrong: a link to a missing station throws node not found: S99; without forceCollide 12 pairs of circles overlap; a station with no routes drifts 429 pixels away unless forceX and forceY hold it (159 pixels). It also thins the network by trips and pins a node as a drag would.

Try it yourself

  1. 1

    Weaken the push

    In the first example, change strength(-200) to strength(-20). The hubs move closer to the middle (Quay at 252.1, 142.1) and the whole network shrinks: x now spans 147 to 371 instead of 4 to 516.

  2. 2

    Cool faster

    Add .alphaDecay(0.05) before .stop(). The first line of the log becomes ticks until it cools: 135: the layout is done in fewer steps, with less time to settle.

  3. 3

    Shorten the springs

    In the second example, change every .distance(40) to .distance(20). Without the collide force, 23 pairs of circles overlap instead of 12. The collide force still gets it to 0.

Code & diagrams

Try it: interactive

Step through a force layout

Press step, then play. Watch the nodes move far at first and little at the end. Use the speed control to slow it down.

The Lumen route network: 25 stations, 60 routes, 300 fixed ticks New tab

Five stations have no route among the top 60 and float at the edges, held in view by forceX and forceY. The picture is the same on every run because the ticks are run by the code.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

before: a link's source is string S03
after: a link's source is object S03 | alpha 1.00
ticks until it cools: 300 | alpha now 0.0010 | 1 - alphaDecay = 0.9772
Quay     | 18 routes | x,y 242.7, 161.3
Lantern  | 13 routes | x,y 277.1, 127.0
Library  | 13 routes | x,y 234.3, 221.5
inside the box: true | x from 4 to 516
When a network goes wrong: a missing station, overlap, lonely nodes, hairballs, dragging New tab

Each case runs in its own simulation over a fresh copy of the data. Positions are rounded to whole pixels.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

a link to a missing station: node not found: S99
no collide force  | pairs of 7 px circles that overlap: 12
forceCollide(7)   | pairs of 7 px circles that overlap: 0
all routes: 60 routes, 20 stations | 60+ trips: 17 routes, 10 stations | 70+ trips: 5 routes, 6 stations | 80+ trips: 2 routes, 3 stations
no x/y force      | the lonely station is 429 px from the centre
forceX and forceY | the lonely station is 159 px from the centre
dragged: Quay is at 50 50 | alpha 0.25 (held up by alphaTarget 0.3)
released: Quay is at 116, 108 | alpha 0.063

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the id function

In the first example, delete .id((d) => d.id) from the forceLink line.

terminal
$ Console
── what you'll see ──
before: a link's source is string S03
Uncaught Error: node not found: S03

Myth vs fact

Myth

The place of a node in a force layout means something.

Fact

Only the closeness of connected nodes does. The same data starts from a different seed or different forces and lands somewhere else. Never read a direction (north, left) into it.

Myth

A force layout is always the right chart for connections.

Fact

For more than a few dozen links it turns into a hairball. A matrix, a chord diagram or a filtered list often answers the question better.

Myth

The simulation runs until you stop it.

Fact

It cools by itself when alpha falls below alphaMin (300 ticks by default). Dragging, or restart(), warms it again.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    By default the simulation ticks on d3.timer, which uses requestAnimationFrame. In a background tab the timer pauses. If you need positions at once (server rendering, tests), stop() and call tick() in a loop, as the examples do. The positions are deterministic because D3 starts nodes on a spiral and uses its own seeded random number generator.

  • ▸

    forceManyBody is the costly force: it uses a Barnes-Hut approximation (theta, default 0.9), so about O(n log n) per tick. For a few thousand nodes, move the simulation into a Web Worker (Topic 12.5) and send positions back, or draw with canvas.

  • ▸

    forceCollide works on circles and can be made stronger with .iterations(n). Strength 1 and one iteration are fine for sparse data. If you also tie the radius to a value, update the force when the data changes.

  • ▸

    Accessibility: a force diagram is not readable by a screen reader. Provide the connections as a list or a table (Topic 15.3): each station with its routes and trips.

Remember this

  1. 1

    The problem this solves: Arjun's question is "which stations are joined by the busiest routes?". Position carries no meaning here: Quay has no x and y. A table of 60 routes hides the groups. A network chart finds a place for every node so that connected nodes sit near each other, and groups show up by themselves.

  2. 2

    **d3.forceSimulation(nodes) moves the nodes with forces.** Each force is a small rule added with .force(name, force). forceLink(links).id((d) => d.id) pulls the two ends of each link together, forceManyBody().strength(-80) makes every node push every other away (negative = push), forceCenter(x, y) keeps the average in the middle, forceCollide(r) stops circles overlapping, and forceX / forceY pull every node gently towards a line. The simulation writes x, y, vx and vy onto your node objects.

  3. 3

    **alpha is the temperature.** It starts at 1 and shrinks by alphaDecay (0.0228) each tick, like alpha *= 0.9772. Forces move nodes in proportion to alpha, so the picture settles. When alpha falls below alphaMin (0.001), the simulation has "cooled" and stops: that takes 300 ticks with the defaults. By default D3 ticks on its own timer, so positions change as you watch. For a fixed picture, call .stop() and run simulation.tick() yourself a fixed number of times: the result is the same on every run, which matters for tests and for a chart that must not wobble.

  4. 4

    **forceLink rewrites your links.** Before, link.source is the text "S03". After the force is added, it is the node object. If the id function is missing, D3 looks up the text as an index and throws node not found: S03. A link that names a station not in the list throws the same error with that name. A simulation also changes your data, so give each run its own copy.

  5. 5

    Forces do not know the box. Without a clamp, nodes can leave it. forceX / forceY pull everything gently back. A node with no links (five of Lumen's 25 stations have no route among the 60 busiest) is pushed away by every other node, and drifts a long way without them. Clamp positions after each tick when the box matters (Math.max(r, Math.min(W - r, x))).

  6. 6

    Dragging pins a node. Setting node.fx and node.fy fixes it at that spot, and the others move around it. Calling simulation.alphaTarget(0.3) keeps the simulation warm while the pointer is down. On release, set fx and fy back to null and alphaTarget(0), and the network settles again. d3.drag() calls these in its handlers.

  7. 7

    Hairballs. With many links, a force layout becomes a ball where nothing can be read. The honest fixes are: show only the strongest links (a threshold: 17 routes are 60+ trips and touch 10 stations, 5 routes are 70+ trips), group nodes into clusters, label only the hubs, or choose a different chart (a matrix, Topic 13.6). Make sure the layout is not the story: positions of a force layout are not geography, and a node near the middle is not necessarily more important.

Explain it without notes

01

What does each of forceLink, forceManyBody, forceCenter and forceCollide do?

02

What is alpha, and when does a simulation stop?

03

Why would you call stop() and tick() yourself?

04

What can you do when a network is a hairball?

Practice

01

Add a slider-free version of the threshold: a function strongest(links, min) that keeps links with at least min trips and returns the nodes they use. Then run the simulation on that part only.

02

Make nodes draggable: add d3.drag() to the node groups so that dragging sets fx and fy, and release clears them.

Trade-offs

  • ↔

    A force layout needs no coordinates and reveals groups, but gives different pictures for small changes. A matrix is stable and exact but less intuitive.

  • ↔

    Running ticks in advance gives a fixed picture. Letting them run live looks lively and allows dragging, but costs frames and cannot be screenshotted reliably.

Done when you can

  • I can name the main forces and say what each does.

  • I can run a simulation for a fixed number of ticks and get the same positions every time.

  • I can explain alpha, alphaMin and alphaTarget.

  • I can handle a link to a missing node, nodes with no links and nodes leaving the box.

  • I can pin a node while it is dragged, and thin a network that is a hairball.