May the force (simulation) be with you

One of the cooler layouts provided by D3 (in the d3-force package) is force simulation. This is actually really useful, as it allows us to position elements on the screen using simulated physical properties. This can be really useful when the positioning of an element doesn't have anything to do with its data, such as in a network diagram, where the linkages between items is more important than when they are on screen.

We will use the network dataset from the last example to create a network of connections in the show. Go to main.js, comment out the last example, and add this line:

westerosChart.init('force', 'data/stormofswords.csv');

Go back to chapter7/index and add a new function enclosure:

westerosChart.force = function Force(_data) { 
const nodes = uniques(
_data.map(d => d.Target)
.concat(_data.map(d => d.Source)),
d => d)
.map(d =>
({ id: d, total: _data.filter(e => e.Source === d).length }));

fixateColors(nodes, 'id');
}

We create an array of unique nodes of the format:

{id: <character-name>, total: <total linkages>}

There isn't much more in terms of data in the dataset, and it's hard to join it to another dataset because it lacks character surnames. If we had more data we wanted to add (for instance, we created a lookup table for each node ID in the network dataset and then mapped that to a value in the other dataset), we could do it at this stage and attach it to each node. In this instance, we get a count of the number of linkages each node has, which we'll use to size our nodes.

We also then fixate our color scale and create an array of links connecting all the nodes together, with the weight depicting the strength of the relationship. We'll use this weight to define the thickness of the lines connecting each node. Continuing to add to westerosChart.force:

const links = _data.map(d => 
({ source: d.Source, target: d.Target, value: d.Weight }));

We will first append all the nodes and links, then set up the simulation to move stuff around the screen. Add the following to westerosChart.force beneath the last line:

  const link = this.container.append('g').attr('class', 'links') 
.selectAll('line')
.data(links)
.enter()
.append('line')
.attr('stroke', d => color(d.source))
.attr('stroke-width', d => Math.sqrt(d.value));

This puts a line on the screen linking each node. We take the square root of the value to get a value for the link thickness, and color the link so that it reflects the connection's source. Just beneath, add the following code:

  const radius = d3.scaleLinear().domain(
d3.extent(nodes, d => d.total)).range([4, 20]);
const node = this.container.append('g').attr('class', 'nodes')
.selectAll('circle')
.data(nodes)
.enter()
.append('circle')
.attr('r', radius)
.attr('fill', d => color(d.id))
.call(d3.drag()
.on('start', dragstart)
.on('drag', dragging)
.on('end', dragend));

We define a scale to size the radii, linearly scaling nodes between a radius of 4 and 20. We then add a bunch of circles to the screen and a bunch of event callbacks that we'll write in a little bit.

Put the tooltip on the nodes:

node.call(tooltip(d => d.id, this.container));

Let's finally set up the simulation. This is really basic, and d3-force has a lot of properties we can tweak, but this will suffice for now:

const sim = d3.forceSimulation() 
.force('link', d3.forceLink().id(d => d.id).distance(200))
.force('charge', d3.forceManyBody())
.force('center',
d3.forceCenter(this.innerWidth / 2, this.innerHeight / 2));

This sets up the force simulation layout, with three forces: link, charge and center.

  • The link force depicts the tension caused by a vector linking two bodies. This can be used to do things such as add elasticity to links, or use them to pull nodes closer together. We set the distance at 200 so that the nodes are a decent distance away from each other. If we wanted to rely more on the other forces, we wouldn't set distance because it's pretty powerful here.
  • The charge force means that items on the screen have a simulated electric charge, using a many body simulation to calculate this. This is really useful, because it means we can cause elements to either attract or repulse each other, the latter of which is an effective way of ensuring that all data points are on screen and not overlapping each other. We instantiate the simulation with the default options here, so there's a slight negative charge causing nodes to repel each other a bit.
  • Lastly, the center force causes an attraction to a central point as defined by the values in the constructor.

Next, add this:

  sim.nodes(nodes).on('tick', ticked); 
sim.force('link').links(links);

This adds the nodes and links to the simulation. 

.nodes() sets the following properties on each object passed to it:

  • index: the node's zero-based index
  • x, y: the current x- and y-position of the node
  • vx, vy: the current v- and y-velocity of the node

You can also set the fx and fy to ensure the node is at a fixed position. We use this in our drag event callbacks to be able to move a node.

We're not done yet. We need to write all the callbacks we defined earlier. Let's start with what happens when a simulation tick occurs. Add the following, still inside of the Force() function:

function ticked() { 
link.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);

node.attr('cx', d => d.x)
.attr('cy', d => d.y);
}

The force simulation just updates the values on the data; it doesn't actual cause them to be re-rendered. We have to do that ourselves, by setting the specific properties we need on the link and node elements.

Just after that, add the following three functions to define how the nodes should interact with the mouse events:

function dragstart(d) { 
if (!d3.event.active) sim.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}

function dragging(d) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}

function dragend(d) {
if (!d3.event.active) sim.alphaTarget(0);
d.fx = null;
d.fy = null;
}

We set the fixed position properties to the x- and y-coordinates of the mouse event. After we stop dragging, we set that to null so that it springs back to where it was.

Click on save, and you should have this awesomeness:

There's a lot more to d3-force than what we've gone through here. Check out the documentation for more on all the different styles of forces and values you can set.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset