Where can I find a good label placement algorithm for line charts?

Viewed 138

I am hoping to automatically place series labels close to their lines on a line chart, as in the following example from The Economist. I am using D3, but would be happy with good solutions in other languages that I could translate to JavaScript or draw inspiration from.

A line chart with nicely-placed labels

I've found some good solutions that place the labels to the right of the lines, for example using force-directed layout. I've also attempted my own algorithm to do a similar thing. This often works well, but causes problem when the rightmost points of several series are close together. I feel like we could get quite far with rules like "if a series has several consecutive values that are above all the other series on the plot, put the label above these values" - like Britain or the US in the chart above.

I'd really appreciate any pointers to implementations that do this kind of thing!

1 Answers

Below is a simple D3 snippet that attempts to mimic the Economist example by looking for points in a series that are outliers per the inter-quartile range for all the series at that point. Further, by checking if the highest outlier is more outlying than the lowest outlier then you can decide the best point for the label.

E.g. For a line chart you are going to have a set of series e.g.

[
  {
    "id": "A",
    "values": [{"index": 1, "value": 10}, ... {"index": n, "value": 35}]
  },
  {
    "id": "B",
    "values": [{"index": 1, "value": 20}, ... {"index": n, "value": 200}]
  },
  ... etc
]

So you can compute the high and low points of the IQR for each index (using d3.quantile(arr, x) where x is 0.25 or 0.75:

  const iqrLows = d3.range(valueCount).map((n, i) => {
    const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
    return d3.quantile(values, 0.25);
  });
  const iqrHighs = d3.range(valueCount).map((n, i) => {
    const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
    return d3.quantile(values, 0.75);
  });

Then, for each series, look for points that either below the lows, or above the highs. Then, if the highest outlier is bigger than the lowest outlier, you can place the label above that point. Or if the lowest outlier is bigger than the highest outlier, place the label below, for that point.

  series.forEach(s => {
    const compareLows = s.values.map((k, i) => k.value < iqrLows[i] ? iqrLows[i] - k.value : 0);
    const compareHighs = s.values.map((k, i) => k.value > iqrHighs[i] ? k.value - iqrHighs[i] : 0);
    if (d3.max(compareHighs) > d3.max(compareLows)) {
      s.xLabelIndex = d3.maxIndex(compareHighs) + 1;
      s.xLabelValue = s.values[d3.maxIndex(compareHighs)];
      s.xLabelOrient = "above";
    } else {
      s.xLabelIndex = d3.maxIndex(compareLows) + 1; 
      s.xLabelValue = s.values[d3.maxIndex(compareLows)];
      s.xLabelOrient = "below";
    }
  });

The way I've generated randomized data for the example below makes for hits and misses. But hopefully gives you some inspiration.

document.getElementById("generate")
  .addEventListener("click", generateChart);
  
generateChart();

function generateChart() {

  // clear chart
  d3.select("#chart")
    .selectAll("*")
    .remove();
    
  // random data
  const data = sampleData();
  
  // chart init
  const margin = 30;
  const width = 300;
  const height = 120;

  const svg = d3.select("#chart")
    .append("svg")
    .attr("width", width + margin + margin)
    .attr("height", height + margin + margin);

  const gChart = svg.append("g")
    .attr("transform", `translate(${margin},${margin})`);

  const color = d3.scaleOrdinal(d3.schemeCategory10);

  // scales and axes
  const xMax = d3.max(data[0].values, d => d.index + 1);
  const yMax = d3.max(data, series => {
    return d3.max(series.values, d => d.value * 1.2);
  });
  const xScale = d3.scaleLinear()
    .range([0, width])
    .domain([1, xMax]);
  const yScale = d3.scaleLinear()
    .range([height, 0])
    .domain([0, yMax]);

  gChart.append("g")
    .call(d3.axisLeft().scale(yScale)); 

  gChart.append("g")
    .attr("transform", `translate(0,${height})`)
    .call(d3.axisBottom().scale(xScale));

  // draw lines
  const line = d3.line()
    .curve(d3.curveCardinal)
    .x(d => xScale(d.index))
    .y(d => yScale(d.value));

  const lines = gChart.selectAll(".series")
    .data(data)
    .enter()
    .append("g");

  lines.append("path")
    .attr("class", "series")
    .attr("d", d => line(d.values))
    .attr("stroke", d => color(d.id))

  // add labels
  const labels = gChart.selectAll(".labels")
    .data(data)
    .enter()
    .append("g");
    
  // x and y refer to iqr dependent code in sampleData()
  labels.append("text")
    .attr("class", "labels")
    .attr("x", d => xScale(d.xLabelIndex))
    .attr("y", d => yScale(d.xLabelValue.value) + (d.xLabelOrient === "above" ? -15 : 10))
    //.attr("dy", ".35em")
    .attr("fill", d => color(d.id))
    .text(d => d.id)
}
    
function sampleData() {
  const labels = [
    "Amsterdam", "Barcelona", 
    "Copenhagen", "Dublin", 
    "Edinburgh", "Frankfurt"
  ]; // 6 example series
  const seriesCount = d3.randomInt(2, labels.length + 1)(); // e.g. 2-6 lines
  const sampleLabels = labels.slice(0, seriesCount);
  const valueCount = d3.randomInt(12, 25)(); // e.g. 1-2 years
  const series = sampleLabels.map(label => {
    return {
      "id": label,
      "values": d3.range(valueCount).map((n, i) => {
        return {
          "index": i + 1,
          "value": d3.randomIrwinHall(5)() * (i ** 0.5)
        }
      })
    }
  });
  
  // look at points that are max outside box per series  
  const iqrLows = d3.range(valueCount).map((n, i) => {
    const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
    return d3.quantile(values, 0.25);
  });
  const iqrHighs = d3.range(valueCount).map((n, i) => {
    const values = sampleLabels.map((label, i2) => series[i2].values[i]).map(o => o.value);
    return d3.quantile(values, 0.75);
  });
  series.forEach(s => {
    const compareLows = s.values.map((k, i) => k.value < iqrLows[i] ? iqrLows[i] - k.value : 0);
    const compareHighs = s.values.map((k, i) => k.value > iqrHighs[i] ? k.value - iqrHighs[i] : 0);
    if (d3.max(compareHighs) > d3.max(compareLows)) {
      s.xLabelIndex = d3.maxIndex(compareHighs) + 1;
      s.xLabelValue = s.values[d3.maxIndex(compareHighs)];
      s.xLabelOrient = "above";
    } else {
      s.xLabelIndex = d3.maxIndex(compareLows) + 1; 
      s.xLabelValue = s.values[d3.maxIndex(compareLows)];
      s.xLabelOrient = "below";
    }
  });
  //console.log(series);
  return series;
}
.series {
  fill: none;
  stroke-width: 2px;
}

.labels {
  text-anchor: middle;
  font: 10px sans-serif;
  font-weight: bold;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.7.0/d3.min.js"></script>
<button id="generate">Generate</button>
<div id="chart"></div>

I've gone with d3.randomIrwinHall and a subtle growth term with a square root of the index. This kind of skews the label placement rightward. Obviously very much of the success of this approach depends on the type of data you are visualizing.

This approach isn't going to work where a series has no points outside the IQR (i.e. where compareLows and compareHighs are all 0s). For this case, some choice needs to be made. Perhaps adding the label at the very end of the series per your approach ?

Related