block by veltman 14006cc042f5dff5a6e1ddf041afbae6

Streamgraph label positions

Full Screen

Picking best label positions in a streamgraph along the same lines as this stacked area chart example.

If a label doesn’t fit in the top or bottom series, it tries to place it in the adjacent empty space.

index.html

<!DOCTYPE html>
<meta charset="utf-8">
<style>
  text {
    font: 14px sans-serif;
    fill: #222;
  }

  .area text {
    font-size: 20px;
    text-anchor: middle;
  }

  .hidden {
    display: none;
  }
</style>
<svg width="960" height="500"></svg>
<script src="//d3js.org/d3.v4.min.js"></script>
<script>

var margin = { top: 10, right: 0, bottom: 10, left: 0 },
    width = 960 - margin.left - margin.right,
    height = 500 - margin.top - margin.bottom,
    random = d3.randomNormal(0, 3),
    turtles = ["Leonardo", "Donatello", "Raphael", "Michelangelo"],
    colors = ["#ef9a9a", "#9fa8da", "#ffe082", "#80cbc4"];

var svg = d3.select("svg").append("g")
    .attr("transform", "translate(" + margin.left + " " + margin.top + ")");

var x = d3.scaleLinear().range([0, width]),
    y = d3.scaleLinear().range([height, 0]);

var series = svg.selectAll(".area")
  .data(turtles)
  .enter()
  .append("g")
  .attr("class", "area");

series.append("path")
  .attr("fill", (d, i) => colors[i]);

series.append("text")
  .attr("dy", 5)
  .text(d => d);

var stack = d3.stack().keys(turtles)
  .order(d3.stackOrderInsideOut)
  .offset(d3.stackOffsetWiggle);

var line = d3.line()
  .curve(d3.curveMonotoneX);

randomize();

function randomize() {

  var data = [];

  // Random-ish walk
  for (var i = 0; i < 40; i++) {
    data[i] = {};
    turtles.forEach(function(turtle){
      data[i][turtle] = Math.max(0, random() + (i ? data[i - 1][turtle] : 10));
    });
  }

  var stacked = stack(data);

  x.domain([0, data.length - 1]);
  y.domain([
    d3.min(stacked.map(d => d3.min(d.map(f => f[0])))),
    d3.max(stacked.map(d => d3.max(d.map(f => f[1]))))
  ]);

  series.data(stacked)
    .select("path")
    .attr("d", getPath);

  stacked.forEach(function(d, i){
    if (d[0][1] === d3.max(stacked.map(f => f[0][1]))) {
      d.top = true;
    }
    if (d[0][0] === d3.min(stacked.map(f => f[0][0]))) {
      d.bottom = true;
    }
  });

  series.select("text")
    .classed("hidden", false)
    .datum(getBestLabel)
    .classed("hidden", d => !d)
    .filter(d => d)
    .attr("x", d => d[0])
    .attr("y", d => d[1]);

  setTimeout(randomize, 750);

}

function getPath(area) {
  var top = area.map((f, j) => [x(j), y(f[1])]),
      bottom = area.map((f, j) => [x(j), y(f[0])]).reverse();

  return line(top) + line(bottom).replace("M", "L") + "Z";
}

function getBestLabel(points) {
  var bbox = this.getBBox(),
      numValues = Math.ceil(x.invert(bbox.width + 20)),
      finder = findSpace(points, bbox, numValues);

  // Try to fit it inside, otherwise try to fit it above or below
  return finder() ||
    (points.top && finder(y.range()[1])) ||
    (points.bottom && finder(null, y.range()[0]));
}

function findSpace(points, bbox, numValues) {

  return function(top, bottom) {
    var bestRange = -Infinity,
      bestPoint,
      set,
      floor,
      ceiling,
      textY;

    // Could do this in linear time ¯\_(ツ)_/¯
    for (var i = 1; i < points.length - numValues - 1; i++) {
      set = points.slice(i, i + numValues);

      if (bottom != null) {
        floor = bottom;
        ceiling = d3.max(set, d => y(d[0]));
      } else if (top != null) {
        floor = d3.min(set, d => y(d[1]));
        ceiling = top;
      } else {
        floor = d3.min(set, d => y(d[0]));
        ceiling = d3.max(set, d => y(d[1]));
      }

      if (floor - ceiling > bbox.height + 20 && floor - ceiling > bestRange) {
        bestRange = floor - ceiling;
        if (bottom != null) {
          textY = ceiling + bbox.height / 2 + 10;
        } else if (top != null) {
          textY = floor - bbox.height / 2 - 10;
        } else {
          textY = (floor + ceiling) / 2;
        }
        bestPoint = [
          x(i + (numValues - 1) / 2),
          textY
        ];
      }
    }

    return bestPoint;
  };
}

</script>