Published: 13-08-2016
Note
D3 has recently changed tp Version 4. Learning how to use layouts and forces was fairly hard already without having limited examples and trying to convert V3 examples to V4. It seems the best way to learn it is just to forget about any V3 examples and focus on the principles.
Overview
To create a mindmap there are two options, the tree layout or a force graph layout. Forgetting about mindmaps for a bit, how do these layouts work? The tree layout uses a static calculation for each position of a node based on the data. The force layout uses a physics simulation to arrange the nodes based on physical properties (charge, gravity, fiction etc.). The most important thing to understand is how to get the data into the format required for a tree or a force graph. Here is some starting data, a basic recursive tree or hierarchy:
{
"name": "Root",
"children": [
{
"name": "Branch 1"
},
{
"name": "Branch 2",
"children": [
{
"name": "Branch 2.1"
},
{
"name": "Branch 2.2",
"children": [
{
"name": "Branch 2.2.1"
},
{
"name": "Branch 2.2.2"
}
]
}
]
},
{
"name": "Branch 3"
},
{
"name": "Branch 4",
"children": [
{
"name": "Branch 4.1"
},
{
"name": "Branch 4.2"
}
]
},
{
"name": "Branch 5"
}
]
}
Manipulating the raw hierarchy
To manipulate the data for a tree or force graph, use d3.hierarchy(). This helper can be used to flatten the hierarchy and generate links. Links are objects that contain the source and target nodes. They are used to draw the linking lines on the tree or force graph. Calling d3.hierarchy(data) returns a root node which has a bunch of useful methods attached. Below are a few methods commonly needed to produce tree and force graphs.
var data = {...}
var root = d3.hierarchy(data);
// Flatten the hierarchy
// Returns array of node objects
root.descendants();
// Get all the links between nodes
// Returns array of objects
// with source and target data
root.links();
Trees
A tree in D3 is just a static rendering of a hierarchy. To be able to render the hierarchy nodes and links they need to have some positional data.
Setup
d3.tree() is a helper for calculating this positional data. Essentially, you pass it your root node and it mutates it, adding in x and y coordinates for each node.. Using it looks like this:
var data = {...}
var root = d3.hierarchy(data)
var height = 600,
width = 600;
// Create a new tree layout with defaults settings
// Then change the tree size
var tree = d3.tree()
.size([width, height]);
// Mutate the root node object
// so it contains x and y positions
tree(root);
Rendering Nodes
Now that the data has x and y coordinates attached it can be rendered to the correct location. This is done by binding the data to SVG elements just like normal. Below is a stripped back example of this.
// Select nodes
var node = d3.selectAll(".node")
.data(root.descendants())
.enter()
.append("g")
.attr("transform", function(d) {
return "translate(" + d.x + "," + d.y + ")";
})
// Add node SVG circle
node.append("circle")
.attr("r", 2.5);
// Add node SVG text
node.append("text")
.text(function(d) {
return d.data.name
});
Rendering Nodes
The code selects all the elements with the class 'node' and binds the node data to them. If they don't exist (Enter selection) a g element is added and moved to the nodes x and y coordinates. A circle and some text is placed inside this element.
Rendering Links
Rendering the links follows an identical process
var link = d3.selectAll(".link")
.data(root.links())
.enter().append("line")
.attr("class", "link")
.attr("stroke-width", "2px")
.attr("stroke", "#ddd")
.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
The code selects all the elements with the class 'link' and binds the link data to them. If they don't exist (Enter selection) an SVG line element is added. The attributes are set to draw a line from the links source to its target. This draws all the links from the original hierarchy.
All Together
The tree doesn't look very pretty but it works. At a bit of css, rotate it around, use curved lines, and it starts to look a little better.
Force Simulations
The tree layout calculated a static position for each node. In D3 there is another way of calculating node positions, through Force Simulations.
What they do
Nodes can be modeled as particles in a physics simulation. This is what a Force simulation does. Any force can be applied to the nodes but common ones are charge, gravity and linking constraints. Position and velocity properties are added to the nodes object (as x, y, vx and vy). These properties are updated during every tick of the simulation. A tick is simply a single time step through the simulation. Simulating the nodes as particles makes it possible to do interesting visualisations of networks are hierarchies. See d3 or vida for examples.
Setup
Below is a basic setup with the forces that people tend to commonly use. It all starts with d3.forceSimulation().
var data = {};
// Convert data to D3 hierarchy
var root = d3.hierarchy(data);
// Setting up the simulation
var simulation = d3.forceSimulation()
.force("link", d3.forceLink()) // adds linking force (empty for now)
.force("charge", d3.forceManyBody()) // adds repelling charge, defaults to -30
.force("center", d3.forceCenter(width / 2, height / 2)) // adds force towards center
.on("tick", ticked); // called ticked for every simulation tick
// Add nodes to the simulation
simulation.nodes(root.descendants())
// Add link constraints to the simulation
simulation.force("link").links(root.links());
function ticked() {
// Update node and link SVG positions
}
Forces can be chained using the returned simulation object. The force is given a name and a function that generates the force. D3 comes with some default functions that can be used to setup forces.
Different Forces
A force is just a function that modifies the node position or velocity. For example, from the docs, here is a simple positioning force that moves nodes towards the origin (0, 0):
function force(alpha) {
for (var i = 0, n = nodes.length, node, k = alpha * 0.1; i < n; ++i) {
node = nodes[i];
node.vx -= node.x * k;
node.vy -= node.y * k;
}
}
Simulations normally combine several different forces. Each force is normally used to either represent some property in the data set or to add some generic layout control. D3 provides some built in force models:
- Centering - d3.forceCenter([x, y])
- Collision - d3.forceCollide([radius])
- Links - d3.forceLink([links])
- Many-Body - d3.forceManyBody()
- Positioning - d3.forceX([x]) / d3.forceY([y])
See the links to the docs for details. The docs list all the parameters that can be set for built in force models. Setting these parameters is done by chaining onto the model function as shown when adding the links in the force setup code snippets.
Rendering Nodes
Rendering the nodes is identical to that for a tree layout. The difference between between the tree and force layout is how the positions are calculated not how they are displayed. They may require different styling, but for now, that's not important.
Rendering Links
Same goes for the rendering of links. The code is identical to that for a tree layout.
All Together
Put everything together and you get a graph like the one below:
The nodes react to the applied forces until they reach an equilibrium. While this is very useful for visualising parameters in a data set, applied as different forces on each node. A mindmap is used to display data uniformly. There are no real parameters and the layout is based around equally spaced content. This makes controlling a mindmap using force harder when compared to a uniform tree layout.
A Basic Mindmap - With Trees
Mindmaps start from a root node and branch out on all sides. Currently the tree only goes in one direction. To create a 2-sided tree, two individual trees need to be drawn with the same overlapping root. This requires the data to be split. A really crude way to split the data is to create two new data object and copy half of the root's children to each. A better way would be to count how many total node ends there are, then try to split the children so that this number is as equal as possible for each new data object. Here is the crude version.
var data = {...}
var split_index = Math.round( data.children.length/2 )
// Left data
var data1 = {
"name": data.name,
"children": JSON.parse( JSON.stringify( data.children.slice( 0, split_index ) ) )
};
// Right data
var data2 = {
"name": data.name,
"children": JSON.parse( JSON.stringify( data.children.slice( split_index ) ) )
};
The code above uses JSON in a bit of hacky way to create deep copies of the hierarchy. Next is to render the two trees side by side. The left tree needs to be reversed in the x-axis. Both trees need to be shifted by half the SVG width to the right. To make the tree code reusable it's simply wrapped up in a function that can be called for each tree side. It looks a bit like this:
// Do data split
// Create d3 hierarchies
var right = d3.hierarchy(data1);
var left = d3.hierarchy(data2);
// draw single tree
function drawTree(root, pos) {
var SWITCH_CONST = 1;
if (pos === "left") {
SWITCH_CONST = -1;
}
// Rest of drawing function goes here
}
// Render both trees
drawTree(right, "right")
drawTree(left, "left")
The tree position (left or right) is passed in explicitly to the drawTree function. This sets a switching constant which is used to make the tree width negative if the position is set to left. Translating the entire tree so both root nodes start in the middle is handled by translating the main element. If the number of branches on either side are not even the root nodes y-position will move. It needs manually setting to half the SVG height. Below is an example of all these modifications.
var svg = d3.select("svg"),
width = +svg.attr("width"),
height = +svg.attr("height")
// Shift the entire tree by half it's width
var g = svg.append("g").attr("transform", "translate(" + width/2 + ",0)");
// Create new default tree layout
var tree = d3.tree()
// Set the size
// Remember the tree is rotated
// so the height is used as the width
// and the width as the height
.size([height, SWITCH_CONST*(width-150)/2]);
tree(root)
var nodes = root.descendants();
var links = root.links();
// Set both root nodes to be dead center vertically
nodes[0].x = height/2;
Put all of this together, and a very basic mindmap pops out.
This can now be used as a functional base for creating a small mind-mapping library. The styling needs to be addressed. Some extra functionality such as being able to hide nodes would also be useful for larger mindmaps. Creating a mindmap with the force layout is possible but a bit more complex and harder to control. So I will stick to trees for now.
References