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.
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
Weaken the push
In the first example, change
strength(-200)tostrength(-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
Cool faster
Add
.alphaDecay(0.05)before.stop(). The first line of the log becomesticks until it cools: 135: the layout is done in fewer steps, with less time to settle. - 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.
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
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 516Each case runs in its own simulation over a fresh copy of the data. Positions are rounded to whole pixels.
Preview
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.063Break 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.
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 usesrequestAnimationFrame. In a background tab the timer pauses. If you need positions at once (server rendering, tests),stop()and calltick()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. - ▸
forceManyBodyis 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. - ▸
forceCollideworks 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
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
**
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, andforceX/forceYpull every node gently towards a line. The simulation writesx,y,vxandvyonto your node objects. - 3
**
alphais the temperature.** It starts at 1 and shrinks byalphaDecay(0.0228) each tick, likealpha *= 0.9772. Forces move nodes in proportion to alpha, so the picture settles. When alpha falls belowalphaMin(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 runsimulation.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
**
forceLinkrewrites your links.** Before,link.sourceis the text"S03". After the force is added, it is the node object. If theidfunction is missing, D3 looks up the text as an index and throwsnode 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
Forces do not know the box. Without a clamp, nodes can leave it.
forceX/forceYpull 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
Dragging pins a node. Setting
node.fxandnode.fyfixes it at that spot, and the others move around it. Callingsimulation.alphaTarget(0.3)keeps the simulation warm while the pointer is down. On release, setfxandfyback tonullandalphaTarget(0), and the network settles again.d3.drag()calls these in its handlers. - 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
What does each of forceLink, forceManyBody, forceCenter and forceCollide do?
What is alpha, and when does a simulation stop?
Why would you call stop() and tick() yourself?
What can you do when a network is a hairball?
Practice
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.
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.