D3.js - Network Circle Pack
Having just written an article on building 3 basic charts I thought I'd follow up with a complex one - the Network Circle Pack.
This is a combination of two much loved d3 charts - the Circle Pack and the Force Simulation.
There is a lot going on in the code and you can play about with customising it in Observable HQ. I'll concentrate here on talking through the key elements.
Underlying Data
The basic structure is nodes:
{id: 'c1', name:'Crisps', category:'Snacks',children:[
{id: 1, name: "Salt & Vinegar",value:431},
{id: 2, name: "Prawn Cocktail",value:363},
{id: 3, name: "Cheese & Onion",value:173},
{id: 4, name: "Roast Beef",value:83},
{id: 5, name: "Ready Salted",value:410}]
}
where each node has id, name, category and an array of children.
and links:
{"id": 1,"source": 39,"target": 20,"rating": 1}
The difference to a traditional Force Simulation data structure being that the link source and target refers to the id of the children not the parents.
Data Manipulation
So you start the same as a standard Circle Pack:
let pack_data = d3.pack()
.size([width*0.6, width*0.6])
.radius(d => radius_scale(d.data.value))
.padding(10)
(d3.hierarchy({name: 'root',children: nodes})
.sum(d => d.value)
.sort((a, b) => b.value - a.value));
but then you need to loop through the pack and set the pack_x and pack_y variables.
- parents - their position will be defined by the force simulation so we want to lose the d3.pack() positioning. All we need is the circle radius.
- children - we want to keep the d3.pack() positioning but subtract the d3.pack() positioning of the parent and add the parent's circle radius.
pack_data.children.forEach(function(d){
const my_x = d.x;
const my_y = d.y;
d.pack_x = d.r;
d.pack_y = d.r;
d.category = d.data.category;
for(let c in d.children){
d.children[c].pack_x = d.children[c].x - my_x + d.r;
d.children[c].pack_y = d.children[c].y - my_y + d.r;
d.children[c].category = d.data.category;
}
})
Sub Category Links
We don't need to do anything with the current links as they will be positioned by the force simulation but I wanted to add extra links for the nodes.
You'll find the code in the demo, I'm sure it could be improved.
Circle Positioning
You'll see that every circle and the other elements in the node group are first positioned with pack_x and pack_y.
my_group.select(".pack_circle")
.attr("id",d => "node_" + d.data.id)
.attr("cx", d => d.pack_x)
.attr("cy",d => d.pack_y)
.attr("r",d => d.r)
If you left it here they would all be on top of each other in the top left hand corner of the svg.
Force Simulation
I then set up a force simulation. I used ForceInaBox this time but you can customise to your needs. The important bit is the tick function where links and node elements are placed using these two functions.
function get_node_translate(d){
if( d.depth === 1){
return "translate(" + (d.x + margin) + "," + (d.y + (margin/2)) + ")";
} else {
return "translate(" + (d.parent.x + margin) + "," + (d.parent.y + (margin/2)) + ")";
}
}
function get_link_extra(d, type,coord){
return d.parent === undefined ? d[type].parent[coord] : d[type][coord]
}
The important thing is return the correct x and y co-ordinates defined by the force simulation.
- parents (d.depth === 1) this is simply d.x and d.y
- children (depth === 2) you need to reference the parent - d.parent.x and d.parent.y.
I'm really pleased with this one. It took a lot of thought to adapt it properly as well thinking through the design and the mouseover interactions.
The client I developed it for had large volumes of data so I added an extra zoom and pan layer as well as a panel for the information as the text elements were becoming too small to read.
Hope you like it.