Home > Software design >  How to dynamically render horizontal stacked bar charts in D3
How to dynamically render horizontal stacked bar charts in D3

Time:12-31

I am going to make this a bit verbose for two reasons:

  1. To prove that I have put effort into trying to solve the problem I am presenting, because there is too much ephemeral stuff on the internet
  2. D3 is awesome because it is complex, it is difficult to understand fully. Recent major versions have emphasised the use of data joining, intended to simplify the general update pattern.

Therefore this might be a bit of a write-up, but I am genuinely looking to understand the answer to my problem so please bear with me.

Context

I want to create a dynamic stacked horizontal bar chart that visualises the different stages of D3's stack means each group of constituent bars are drawn together.

In the example above the data binding is not by month, but by fruits.

Final state of stacked horizontal chart

The final state is fine but dynamic (transitioned) data changes will be hard.

This is an area of some complexity. I unashamedly confess I don't understand Bostock's use of stacks, taken from his above mentioned example:

// Compute a nested array of series where each series is [[x1, x2], [x1, x2],
// [x1, x2], …] representing the x-extent of each stacked rect. In addition,
// each tuple has an i (index) property so that we can refer back to the
// original data point (data[i]). This code assumes that there is only one
// data point for a given unique y- and z-value.
const series = d3.stack()
  .keys(zDomain)
  .value(([, I], z) => X[I.get(z)])
  .order(order)
  .offset(offset)
(d3.rollup(I, ([i]) => i, i => Y[i], i => Z[i]))
.map(s => s.map(d => Object.assign(d, {i: d.data[1].get(s.key)})));

Nested data joins

Perhaps someone can shine further light on the above. Perhaps I am not understanding something about stacks (very likely ;). Perhaps if the chart were vertical and not horizontal things would be easier? Don't know.

I decided to abandon D3 Stacks and instead turn to data joining nested data, kind of going back to basics.

Repeated readings of Bostock's almost decade old post on the Enter every time

Thank you for reading and happy new year :))

-* Unfortunately I can not post links to JSFiddle as the most recent D3 version supported is version 5 and I am (perhaps unnecessarily) using the most recent version, 7. Bostock has started using a platform that allows experimentation called Observable but I find to be confusing.

CodePudding user response:

Here is an example that uses the fruits dataset. The chart is animated so that the bars for one fruit are revealed at a time. I do this by giving the bars for each fruit a different transition delay.

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <script src="https://d3js.org/d3.v7.js"></script>
</head>

<body>
  <div id="chart"></div>

  <script>
    // set up

    const margin = { top: 10, right: 10, bottom: 20, left: 40 };

    const width = 300 - margin.left - margin.right;
    const height = 200 - margin.top - margin.bottom;

    const svg = d3.select('#chart')
      .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})`);

    // data

    const data = [
      {month: "Jan", apples: 3840, bananas: 1920, cherries: 960, dates: 400},
      {month: "Feb", apples: 1600, bananas: 1440, cherries: 960, dates: 400},
      {month: "March", apples:  640, bananas:  960, cherries: 640, dates: 400},
      {month: "Apr", apples:  3120, bananas:  1480, cherries: 640, dates: 400}
    ];

    const fruit = Object.keys(data[0]).filter(d => d != "month");
    const months = data.map(d => d.month);

    const stackedData = d3.stack()
        .keys(fruit)(data);

    const xMax = d3.max(stackedData[stackedData.length - 1], d => d[1]);

    // scales


    const x = d3.scaleLinear()
        .domain([0, xMax]).nice()
        .range([0, width]);

    const y = d3.scaleBand()
        .domain(months)
        .range([0, height])
        .padding(0.25);

    const color = d3.scaleOrdinal()
        .domain(fruit)
        .range(d3.schemeTableau10);

    // axes

    const xAxis = d3.axisBottom(x).ticks(5, '~s');
    const yAxis = d3.axisLeft(y);

    svg.append('g')
        .attr('transform', `translate(0,${height})`)
        .call(xAxis)
        .call(g => g.select('.domain').remove());

    svg.append("g")
        .call(yAxis)
        .call(g => g.select('.domain').remove());

    // draw bars

    // create one group for each fruit
    const layers = svg.append('g')
      .selectAll('g')
      .data(stackedData)
      .join('g')
        .attr('fill', d => color(d.key));

    // transition for bars
    const duration = 1000;
    const t = d3.transition()
        .duration(duration)
        .ease(d3.easeLinear);

    layers.each(function(_, i) {
      // this refers to the group for a given fruit
      d3.select(this)
        .selectAll('rect')
        .data(d => d)
        .join('rect')
          .attr('x', d => x(d[0]))
          .attr('y', d => y(d.data.month))
          .attr('height', y.bandwidth())
        .transition(t)
          // i is the index of this fruit.
          // this will give the bars for each fruit a different delay
          // so that the fruits will be revealed one at a time.
          // using .each() instead of a normal data join is needed
          // so that we have access to what fruit each bar belongs to.
          .delay(i * duration)
          .attr('width', d => x(d[1]) - x(d[0]));
    });


  </script>
</body>

</html>

  • Related