Hrafn Malmquist
Hrafn Malmquist

Reputation: 482

How to dynamically render horizontal stacked bar charts in D3

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.

Context

I want to create a dynamic stacked horizontal bar chart that visualises the different stages of single transferable voting processes (in local elections in Scotland to be precise).

D3 Stacks

This is very much akin to Bostock's Stacked Bar Chart, Horizontal showing the population of US states broken down into age groups.

Not surprisingly Bostock uses D3's stack generator which I was disappointed to discover organises SVG rectangles into groupings (svg g elements) across the Y axis. Take the following experiment which is based on Bostock's Stacked Bar Chart, Horizontal example:

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}
];

The above data is pivoted using the same method as in Bostock's example and passed to StackedBarChart() which I have added a transition to resulting in the following being rendered:

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 general update pattern are helpful along with reading about the newish join method, intended to further abstract the general update pattern.-*

The following code is an adaptation of the documentation nested data join example (just make sure you load the d3 library somewhere):

    const svg = d3.select("body")
        .append("svg");

    function doD3() {
        svg
            .selectAll("g")
            .data(generate, (d) => d[0].id.split("_")[0])
            .join("g")
            .attr("transform", (d, i) => "translate(0, "  + i * 30 + ")")
            .selectAll("text")
            .data(d => d)
            .join(enter => enter
                    .append("text").attr("fill", "green"),
                update => update,
                exit => exit.remove())
            .attr("x", (d, i) => i * 50)
            .text(d => d.value);
    }

    function generateRow(rowId, cols) {
        let row = [];

        for(let i = 0; i < cols; i++) {
            row.push({"id": rowId + "_" + i, "value": highRandom()});
        }

        return row;

    }

    const lowRandom = d3.randomInt(3, 15);
    const highRandom = d3.randomInt(1e2, 1e5);

    function generate() {
        const cols = lowRandom();
        const rows = lowRandom();

        let matrix = [];

        for(let i = 0; i < rows; i++) {
            matrix.push(generateRow(i, cols));
        }

        return matrix;


    }

    window.setInterval(doD3, 2000);

Primitive perhaps but intended to demonstrate successful data binding with a key function. The generate and generateRow functions together generate random matrices of objects with an id and value.

This is called every two seconds. All nodes (texts) are rendered on enter. What am I not getting right?

Enter every time

-* Unfortunately I cannot post links to JS Fiddle 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.

Upvotes: 0

Views: 3076

Answers (1)

Dan
Dan

Reputation: 1571

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>

Upvotes: 4

Related Questions