Getting started with D3.js force simulations
This article talks you through the decisions made developing 2 force simulations.
They look and behave very differently but the core logic is the same.
Step 1 — The Basics
Force simulations are very different to other d3 charts , so if this is your first time I’d recommend a quick look at Shirley Wu and her tip to watch Jim Vallandingham’s Abusing the Force.
Step 2 — The Data
All network charts have two key data elements, nodes (the circles) and links (the lines — sometimes called edges).
Nodes : nodes must have unique ids:
var nodes = [{"id":'f1'},{"id":'f2'} ...... {"id":'f99'}]
then you can add as many custom variables as you like:
{"type":'person',"id":'f99',"name":"Santa's Little Helper","age": 2,"sex":'m',"image": "santa.png"}
Links: Links must have a valid node id as a source and a target
var links = [{source:'f1',target:'f2'}]
These can be text or numbers. You can add other variables if you want.
Step 3— Basic Code Structure
Assuming you’ve drawn an svg with a stored width and height there are 5 key stages. (for more info on the update process, click here)
a) the simulation.
This is basic functionality.
center — pulls all nodes to the center
charge — nodes repel from each other which prevents overlap
link — specifies that id is the link variable
//define and stop the simulation
var simulation = d3.forceSimulation()
.force("center", d3.forceCenter(width/2, height/2))
.force("charge", d3.forceManyBody())
.force("link", d3.forceLink().id(function(d => d.id)simulation.stop()
b) define links group and set link properties (position comes later):
//define links group
var my_group = svg.selectAll(".link_group")
.data(links)//exit, remove
my_group.exit().remove()//enter
var enter = my_group.enter()
.append("g").attr("class","link_group")//append
enter.append("line").attr("class","link_line")//merge
my_group = my_group.merge(enter)my_group.select("link_line")
.attr("stroke", "orange")
c) nodes
define nodes group and set properties.
//define nodes group
var my_group = svg.selectAll(".node_group")
.data(nodes)//exit, remove
my_group.exit().remove()//enter
var enter = my_group.enter()
.append("g").attr("class","node_group")//append
enter.append("circle").attr("class","node_circle")//merge
my_group = my_group.merge(enter)my_group.select("node_circle")
.attr("fill", "orange")
.attr("r",10)
c) append the data to the simulation
simulation.nodes(nodes)
simulation.force("link").links(links)
c) and finally, define the tick functionality and restart:
simulation.on("tick",function(d){
//position links
d3.selectAll(".link_line")
.attr("x1", d => d.source.x)
.attr("x2", d => d.target.x)
.attr("y1", d => d.source.y)
.attr("y2", d => d.target.y)
//position nodes
d3.selectAll(".node_circle")
.attr("x", d => d.x)
.attr("y", d => d.y)
})simulation.alpha(1).restart()
Step 4 — Tweaking the simulation to fit
Now you’ve got the basics when a brief comes in you need to tailor the simulation to suit.
FAMILY TREE
A node for each family member
A link for each of their family links — parent or child.
Nodes cluster around a central smaller ‘family node’.
Here’s the simulation:
var simulation = d3.forceSimulation()
.force("x",d3.forceX(width/2).strength(0.4))
.force("y",d3.forceY(height/2).strength(0.6))
.force("charge",d3.forceManyBody().strength(-1000))
.force("link", d3.forceLink().id(d => d.id ))
.force("collide",d3.forceCollide().radius(d => d.r * 10))
x and y — pull nodes towards the centre with y stronger so nodes fill the landscape screen better.
charge — nodes repel from each other which prevents overlap (default strength is -30 so I’ve made this a bit stronger)
link — specify that id is the link variable
collide-specify a ‘repel radius’ of 10 x node radius — to prevent overlap and leave space for label
HIERARCHICAL TREE
Three tiers of hierarchical data — Title , Category and Sub-Category .
The client wanted the tiers to fan out in the appearance of a tree.
Node behaviour depended on the hierarchical tier.
Tier 1 — Title
nodes fixed to a set position around the ‘trunk’
Tier 2 — Category
here’s the simulation:
var simulation = d3.forceSimulation()
.force('x', d3.forceX().x(d => d.x_position))
.force('y', d3.forceY().y(d => d.y_position))
.force("charge",d3.forceManyBody())
.force("link", d3.forceLink().id(d =>
d.id).distance(20))
.force("collide",d3.forceCollide().radius(d => d.r*10))
x and y — pulls all nodes to the center (gentler than force centre)
charge — nodes repel from each other which prevents overlap
link — specify that id is the link variable — distance added to keep them close
collide-specify a ‘repel radius’ of 10 x node radius — to prevent overlap and leave space for labels
Tier 3
The simulation here is the same as the Tier 2 simulation
EXCEPT
x and y are the co-ordinates of the Tier 2 level node.
var popup_simulation = d3.forceSimulation()
.force('x', d3.forceX().x(my_x))
.force('y', d3.forceY().y(my_y))
.force("charge",d3.forceManyBody())
.force("link", d3.forceLink().id(d =>
d.id).distance(70))
.force("collide",d3.forceCollide().radius(d => d.r*10))
Step 5 — Adding and Optimising
Once you’ve got the core simulation running correctly you can start:
- adding other data specific elements — for example, images, fills, strokes, stroke width.
- altering the individual properties to optimise performance — more information here.
Step 6 — Conclusions
This is only a start.
A quick Google or Observable search will show you many different ways people have used force simulations to fit their specific needs.
This is a great tool for starters — recommended by Mike Bostock of course!