Points of Interest: D3 Force Layout to Place Labels on Interactive Charts

PlaceIQ > Blog  > Points of Interest: D3 Force Layout to Place Labels on Interactive Charts

Points of Interest: D3 Force Layout to Place Labels on Interactive Charts

By Will Dickerson, Data Visualization Intern

 

At PlaceIQ, we love to make interactive visualizations for our clients, salespeople, data scientists and engineers. In this blog post, we’ll demonstrate how we use a D3 force layout to make great looking legends on interactive line charts. For this demonstration, we’ll be plotting the population of US states over time since 1910. The goal is a plot that looks like this:

 

But never like this:

 

 

Build an interactive chart in three steps

 

In order to demonstrate the true benefit of the force layout for interactive charts, we need an interactive chart! Let’s build one in three easy steps. Our data is in an array called states which holds objects like this:

 

{
  name: California,
  show: false,
  currentPopulation: 37253956,
  history: Array(11),
}

with the history property containing objects like this:

{
  year: 1910,
  population: 2377549
}

 

To build an interactive chart, we will:

 

  1. Create drawing areas for the user input and the chart
  2. Add the user input area, which contains some logic for assigning colors to states and updating the chart on input
  3. Draw the chart

 

1. Create drawing areas

// Define margins, dimensions, and some line colors
const margin = {top: 40, right: 120, bottom: 30, left: 40};
const width = 800 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const colors = ['red', 'green', 'limegreen', 'blue', 'skyblue', 'gray', 'magenta'];

// Define the scales and tell D3 how to draw the line
const x = d3.scaleLinear().domain([1910, 2010]).range([0, width]);     
const y = d3.scaleLinear().domain([0, 40000000]).range([height, 0]);
const line = d3.line().x(d => x(d.year)).y(d => y(d.population));

// Create areas for the chart and user input
const chart = d3.select('body')
  .append('svg')
  .attr('width', width + margin.left + margin.right)
  .attr('height', height + margin.top + margin.bottom)
  .append('g')
  .attr('transform','translate('+ margin.left+','+margin.top+')');
const userInput = d3.select('body')
  .append('div')
  .style('width', width + margin.left + margin.right)
  .style('height', '14em')
  .style('display', 'flex')
  .style('flex-flow', 'column wrap');  

// Add the axes and a title
const xAxis = d3.axisBottom(x).tickFormat(d3.format('.4'));
const yAxis = d3.axisLeft(y).tickFormat(d3.format('.2s'));
chart.append('g').call(yAxis); 
chart.append('g').attr('transform', 'translate(0,' + height + ')').call(xAxis);
chart.append('text').html('State Population Over Time').attr('x', 200);

2. Add the user input area

function drawUserInput() {
  userInput.selectAll('checkboxes')
    .data(states).enter()
    .append('label')
    .style('width', '10em')
    .html(d => d.name)
    .append('input')
    .attr('type', 'checkbox')
    .on('change', d => {
      d.show = !d.show;
      if (!d.color) {
        const color = colors.shift();
        d.color = color;
        colors.push(color);
      }
      drawChart();
    });
}

 

Here, we append a checkbox for each state in our states array. The change event for each checkbox toggles the show property of the state, assigns the state a color, and draws the chart.

 

3. Draw the chart

function drawChart() {  
  const visibleStates = states.filter(s => s.show);
  const lines = chart.selectAll('.population-line').data(visibleStates, d => d.name);
  lines.exit().remove();
  lines.enter().append('path')
    .attr('class', 'population-line')
    .attr('fill', 'none')
    .attr('stroke', d => d.color)
    .attr('stroke-width', 2)
    .datum(d => d.history)
    .attr('d', line);
  drawLegend();
}

Here, we filter for visible states based on the show property. For each visible state, we append a path element to the chart with the appropriate properties.

 

Our array of states is stored in a JSON file called state-populations.json, so we can grab the data and kick things off like this:

 

let states = [];
d3.json('state-populations.json', d => {
  states = d;
  drawUserInput();
  drawChart();  
});

At this point, we’ve got an interactive chart that looks something like this:

 

 

We’re missing an important chart component, so let’s go ahead and add a legend. First, we’ll add the state’s name just to the right of its line.

 

function drawLegend() {
  const visibleStates = states.filter(s => s.show);
  const labelHeight = 14;

  const legendItems = chart.selectAll('.legend-item').data(visibleStates, d => d.name);
  legendItems.exit().remove();
  legendItems.enter().append('text')
    .attr('class', 'legend-item')
    .html(d => d.name)
    .attr('fill', d => d.color)
    .attr('font-size', labelHeight)
    .attr('alignment-baseline', 'middle')
    .attr('x', width)
    .attr('dx', '.5em')
    .attr('y', d => y(d.currentPopulation));    
}

 

We append a text element for every visible state, with the appropriate properties. Notice we use the state’s current population to find the y-coordinate, which places the label next to its line. We just call drawLegend() at the end of our drawChart function, and this is what we get:

 

 

 

 

We’re on the right track, but depending on which states are selected, the labels might overlap. Read on to see how we can do better.

 

Using a force layout to place legend labels

 

One way to fix this is to place the labels in a list in the corner of the chart. However, even though the labels are color-coded, the data becomes difficult to interpret as the user adds more lines. What we’d really like to do here is just give some of these labels a little nudge. If you’re reading this blog post, you may have concluded (as we did) that this is not a trivial problem to solve programmatically. The solution presented below is an example of iterative relaxation, and we’ll implement it with a D3 force layout.

 

When we use a force layout, we first need to define a set of nodes. Think of a node as having three distinct properties: x, y, and size. The force layout works by executing a simulation that applies forces to each node, changing their x and y properties at each iteration. We only want our nodes to move up and down, so we’ll keep the x property constant.

 

Prevent overlapping labels

 

Go ahead and scrap what we have in our drawLegend function because we’ll replace it with the next few snippets of code. First, we need to define our nodes:

 

const visibleStates = states.filter(s => s.show);
const labelHeight = 14;

const labels = visibleStates.map(s => {
  return {
    fx: 0,
    targetY: y(s.currentPopulation)
  };
});

Each visible state defines a node for our force layout. We’re calling the array of nodes labels, because that’s what our nodes represent. The fx property (as in, “fixed x”), is recognized by D3 and keeps the x value of the nodes constant. The targetY property is for our convenience; we’ll use it to tell D3 where the label should go.

 

Now we set up our force simulation:

 

const force = d3.forceSimulation()
  .nodes(labels)
  .force('collide', d3.forceCollide(labelHeight / 2))
  .force('y', d3.forceY(d => d.targetY).strength(1))    
  .stop();

Here, we indicate that our nodes are in the labels array and define two forces to act on them. .force() takes two arguments: the first is a name–any string of our choosing, and the second is a function that defines a force. D3 gives us several built in force functions. d3.forceCollide() moves the nodes away from each other to prevent overlapping. We pass it the radius of the node, which in our case is just half of the label’s height. d3.forceY() tries to place each node in a target y position. We pass it a function which is called for each node to find the target y position. The default behavior of d3.forceSimulation() is to begin the simulation immediately and iterate it at a human-viewable speed. We don’t want this behavior, so we include .stop().

 

Now, we can execute our simulation:

 

for (let i = 0; i < 300; i++) force.tick();

This is a simple approach of iterating the simulation 300 times. Generally, the more iteration, the better the placement of nodes.

At this point, each element in the labels array has a new property, y. This is the y value calculated by the force simulation. We need to assign these ‘y’ values to our visible states, but there’s one caveat to deal with: during the simulation, sometimes two nodes will “jump over” one another. Although the order of the labels array should match the order of the visibleStates array, that isn’t necessarily true. To deal with this possibility, we’ll sort labels by y and visibleStates by currentPopulation before assigning the y values to the visible states.

 

labels.sort((a, b) => a.y - b.y);
visibleStates.sort((a, b) => b.currentPopulation - a.currentPopulation);
visibleStates.forEach((state, i) => state.y = labels[i].y);

The remainder of our drawLegend() function is basically what we had before, but now we use the new y property. Also, because a state’s ‘y’ property might change depending on which other states are visible, we include an update action in addition to the enter and exit actions.

 

const legendItems = chart.selectAll('.legend-item').data(visibleStates, d => d.name);
legendItems.exit().remove();
legendItems.attr('y', d => d.y);
legendItems.enter().append('text')
  .attr('class', 'legend-item')
  .html(d => d.name)
  .attr('fill', d => d.color)
  .attr('font-size', labelHeight)
  .attr('alignment-baseline', 'middle')
  .attr('x', width)
  .attr('dx', '.5em')
  .attr('y', d => d.y);

 

Let’s return to our example:

 

 

Things are looking good, but let’s test an extreme case:

 

 

We’re getting there, but in some cases, the legend might run off the chart area.

 

Enforcing boundaries with a custom force

 

Our D3 force simulation doesn’t know that we want to contain our nodes within any boundaries. The D3 api reference describes six force functions built in to D3, but none of them address keeping the nodes within bounds. No big deal–we’ll make our own! Let’s modify the block of code where we set up the simulation:

const forceClamp = (min, max) => {
  let nodes;
  const force = () => {
    nodes.forEach(n => {
      if (n.y > max) n.y = max;
      if (n.y < min) n.y = min;
    });
  };
  force.initialize = (_) => nodes = _;
  return force;
}

const force = d3.forceSimulation()
  .nodes(labels)
  .force('collide', d3.forceCollide(labelHeight / 2))
  .force('y', d3.forceY(d => d.targetY).strength(1))    
  .force('clamp', forceClamp(0, height))
  .stop();

We created forceClamp, a function analogous to d3.forceY or d3.forceCollide. There are two important things to notice here:
1. forceClamp returns a function. We named it force, but we could have named it anything. When forceClamp is bound to the simulation, this function is called at every iteration.
2. We defined force.initialize as another function. When forceClamp is bound to the simulation, force.initialize is called just once, being passed the nodes that were previously defined. In this case, force.initialize simply assigns the array of nodes to a variable that can be accessed by force.

 

Read more about how force functions work here, or take a look at one of the built in force function for another example.

 

How well does our custom force function work? Even in extreme cases, our improved force layout does a nice job of keeping the labels in bounds and preventing collisions.

 

 

Conclusions

 

When placing labels on a chart, a D3 force layout can prevent them from overlapping or running out of bounds. Here are a few more things to keep in mind:

 

  • If a user continues to add data, eventually the labels have no choice but to collide
  • Be careful that your labels aren’t out of order! We performed two sorting operations to handle this
  • In our example, if a user selects more than seven states, some line colors are repeated. We prefer this to using more colors, which become difficult to differentiate
  • See the live demo here: https://bl.ocks.org/wdickerson/bd654e61f536dcef3736f41e0ad87786