Stephen
Stephen

Reputation: 1183

D3.js - How to add the general update pattern to a stacked bar chart

I have a project where I try to create a stacked bar chart. One of the requirements of this is that the chart should be able to update. I have used the general update pattern to accomplish this. But I cannot get the code to work.

When the chart updates the new generated chart enters from the bottom. So I guess this means that the code never executes update part of the pattern and always thinks the updated data is new data.

I have added 3 different arrays of data to the snippet to give a good example of the problem I am facing. The first two arrays contain the same key and should update the chart. The last array contains completely different keys and here the chart should enter in stead of updating.

I have also noticed that when the new data is added to the chart. The old data is never deleted. So the exit state of the update pattern also does not get triggered.

I know there should be a key defined in the data function of the update pattern.data(stackData, d => {return d.key;}). But I cannot seem to grasp what key I should enter here.

I have deleted as much code as needed from my original code in order to get it to work for the snippet. All the code that is included inside the snippet is needed to get it to work.

this.width = 400;
this.height = 200;
var margin = {
  top: 20,
  right: 20,
  bottom: 30,
  left: 40
}

this.index = 0;

this.svg = d3
  .select(".canvas")
  .classed("svg-container", true)
  .append("svg")
  .attr("class", "chart")
  .attr(
    "viewBox",
    `0 0 ${this.width} ${this.height}`
  )
  .attr("preserveAspectRatio", "xMinYMin meet")
  .classed("svg-content-responsive", true)
  .append("g");

const scale = [0, 1200];

// set the scales
this.xScale = d3
  .scaleBand()
  .range([0, width])
  .padding(0.3);

this.yScale = d3.scaleLinear().range([this.height, 0]);

this.svg.append("g").attr("class", "bars");

const update = data => {
  const scale = [0, 1200];

  // Update scales.
  this.xScale.domain(data.map(d => d.key));
  this.yScale.domain([scale[0], scale[1]]);

  const subgroups = ["home", "work", "public"];

  var color = d3
    .scaleOrdinal()
    .domain(subgroups)
    .range(["#206BF3", "#171D2C", "#8B0000"]);

  var stackData = d3.stack().keys(subgroups)(data);

  // Set up transition.
  const dur = 1000;
  const t = d3.transition().duration(dur);

  this.svg
    .append("g")
    .selectAll("g")
    .data(stackData, d => {
      return d.key;
    })
    .join(
      enter => {
        enter
          .append("g")
          .attr("fill", d => color(d.key))
          .selectAll("rect")
          .data(d => {
            return d;
          })
          .join(
            enter => {
              enter
                .append("rect")
                .attr("class", "bar")
                .attr("x", d => {
                  return this.xScale(d.data.key);
                })
                .attr("y", () => {
                  return this.yScale(0);
                })
                .attr("height", () => {
                  return this.height - this.yScale(0);
                })
                .attr("width", this.xScale.bandwidth())
                .transition(t)
                .delay((d, i) => i * 20)
                .attr("y", d => {
                  return this.yScale(d[1]);
                })
                .attr("height", d => {
                  return this.yScale(d[0]) - this.yScale(d[1]);
                });
            },

            update => {
              update
                .transition(t)
                .delay((d, i) => i * 20)
                .attr("x", d => this.xScale(d.key))
                .attr("y", d => {
                  return this.yScale(d[1]);
                })
                .attr("height", d => {
                  return this.yScale(d[0]) - this.yScale(d[1]);
                });
            },

            exit => {
              exit
                .transition()
                .duration(dur / 2)
                .style("fill-opacity", 0)
                .remove();
            }
          );
      },

      update => {
        update
          .transition(t)
          .delay((d, i) => i * 20)
          .attr("x", d => this.xScale(d.key))
          .attr("y", d => {
            return this.yScale(d[1]);
          })
          .attr("height", d => {
            return this.yScale(d[0]) - this.yScale(d[1]);
          });
      },

      exit => {
        exit
          .transition()
          .duration(dur / 2)
          .style("fill-opacity", 0)
          .remove();
      }
    );
};

const data = [
  [{
      key: "Jan",
      home: 371,
      work: 335,
      public: 300
    },
    {
      key: "Feb",
      home: 343,
      work: 437,
      public: 228
    },
    {
      key: "Mrt",
      home: 359,
      work: 261,
      public: 202
    },
    {
      key: "Apr",
      home: 274,
      work: 217,
      public: 482
    },
    {
      key: "Mei",
      home: 442,
      work: 314,
      public: 477
    },
    {
      key: "Jun",
      home: 464,
      work: 261,
      public: 278
    },
    {
      key: "Jul",
      home: 343,
      work: 244,
      public: 396
    },
    {
      key: "Aug",
      home: 231,
      work: 406,
      public: 338
    },
    {
      key: "Sep",
      home: 380,
      work: 382,
      public: 366
    },
    {
      key: "Okt",
      home: 391,
      work: 408,
      public: 455
    },
    {
      key: "Nov",
      home: 419,
      work: 261,
      public: 226
    },
    {
      key: "Dec",
      home: 217,
      work: 453,
      public: 335
    }
  ],
  [{
      key: "Jan",
      home: 282,
      work: 363,
      public: 379
    },
    {
      key: "Feb",
      home: 428,
      work: 355,
      public: 216
    },
    {
      key: "Mrt",
      home: 217,
      work: 493,
      public: 280
    },
    {
      key: "Apr",
      home: 304,
      work: 283,
      public: 454
    },
    {
      key: "Mei",
      home: 397,
      work: 406,
      public: 289
    },
    {
      key: "Jun",
      home: 242,
      work: 239,
      public: 232
    },
    {
      key: "Jul",
      home: 327,
      work: 453,
      public: 264
    },
    {
      key: "Aug",
      home: 242,
      work: 240,
      public: 414
    },
    {
      key: "Sep",
      home: 495,
      work: 382,
      public: 368
    },
    {
      key: "Okt",
      home: 285,
      work: 471,
      public: 364
    },
    {
      key: "Nov",
      home: 315,
      work: 421,
      public: 482
    },
    {
      key: "Dec",
      home: 214,
      work: 284,
      public: 297
    }
  ],
  [{
      key: "1",
      home: 282,
      work: 363,
      public: 379
    },
    {
      key: "2",
      home: 232,
      work: 432,
      public: 324
    },
    {
      key: "3",
      home: 324,
      work: 124,
      public: 432
    },
    {
      key: "4",
      home: 425,
      work: 353,
      public: 532
    }
  ]
];

update(data[this.index]);

const swap = document.querySelector(".swap");
swap.addEventListener("click", () => {
  if (this.index < 2) this.index += 1;
  else this.index = 0;
  update(data[this.index]);
});
<button class="swap">swap</button>
<div class="canvas"></div>
<script src="https://d3js.org/d3.v6.js"></script>

Upvotes: 2

Views: 814

Answers (3)

Steve
Steve

Reputation: 10886

There are at least a few things going on here. As pointed out by Michael Rovinsky, you're appending a new g group for every update that houses all of your bars. Since it's a new group, it has no data bound to it, which will make new bars for every call.

Keeping track of groups

Instead, you should make the group only once, and maybe assign it to a variable to make it a little clearer to work with. I'll use the this.svg.append("g").attr("class", "bars"); group since it doesn't seem to be used for anything else:

var bars = this.svg.append("g").attr("class", "bars");

Then in your update function, change:

this.svg
  .append("g")
  .selectAll("g")
  // ...

to

bars
  .selectAll("g")

Returning values for the join selection

Your invocations of join aren't quite standard when you don't return the enter, update, and exit selections. When you have an arrow function with a body like:

enter => {
  enter.append('g') //...
}

it doesn't actually return the enter selection, it should be either

enter => {
  return enter.append('g') //...
}

or

enter => enter.append('g') //...

While you don't technically need to return the enter or update selections, it's the intended way to use them, and does cause problems when you try to use the combined enter and update selections later, which we will later do.

Understanding the update process

The way you implement your nested joining suggests that you're not quite understanding how joining works. When you enter and append new elements, those elements don't have any data bound to them, so when you use a subsequent join, the nested update and exit functions won't ever run.

You'll also notice that you're repeating a lot of the same code in both enter and update.

The more standard way of doing what I think you intend to do, is to add new bars if necessary, and then to use the new bars and old bars (which is returned by join and update them with the bar segments.

It'll look something like this:

bars.selectAll('g').data(data).join(
    enter => enter.append('g'),
    update => update, // don't do anything with only the update selection
    exit => exit.remove()
  ) // now we're selecting all of the enter and update g's
   .selectAll('rect')
   .data(d => d).join(
      enter => enter.append('rect'),
      update => update,
      exit => exit.remove()
   ) // now we're selecting all of the enter and update rects
     .transition(t)
     // ...

Setting Keys

If you look at the format of the data you can see that stackData is always an array of length 3 that has an array for each entry. Those nested arrays vary based on the number of columns and it's at that level that the keys should be defined:

    //... group selection
    .selectAll('rect')
    .data(d => d, d => d.data.key)
    //...

Fixing Bugs

Finally there's bit of code when you're updating the bar location .attr("x", d => this.xScale(d.key)) which should use d.data.key instead.

All together

I consolidated most of the redundant enter and update code, and removed some extraneous code. All together, it looks like:

this.width = 400;
this.height = 200;
var margin = {
  top: 20,
  right: 20,
  bottom: 30,
  left: 40
}

this.index = 0;

this.svg = d3
  .select(".canvas")
  .classed("svg-container", true)
  .append("svg")
  .attr("class", "chart")
  .attr(
    "viewBox",
    `0 0 ${this.width} ${this.height}`
  )
  .attr("preserveAspectRatio", "xMinYMin meet")
  .classed("svg-content-responsive", true)
  .append("g");

const scale = [0, 1200];

// set the scales
this.xScale = d3
  .scaleBand()
  .range([0, width])
  .padding(0.3);

this.yScale = d3.scaleLinear().range([this.height, 0]);

var bars = this.svg.append("g").attr("class", "bars");

const update = data => {
  const scale = [0, 1200];

  // Update scales.
  this.xScale.domain(data.map(d => d.key));
  this.yScale.domain([scale[0], scale[1]]);

  const subgroups = ["home", "work", "public"];

  var color = d3
    .scaleOrdinal()
    .domain(subgroups)
    .range(["#206BF3", "#171D2C", "#8B0000"]);

  var stackData = d3.stack().keys(subgroups)(data);

  // Set up transition.
  const dur = 1000;
  const t = d3.transition().duration(dur);

  bars
    .selectAll("g")
    .data(stackData)
    .join(
      enter => enter
        .append("g")
        .attr("fill", d => color(d.key)),

      null, // no update function

      exit => {
        exit
          .transition()
          .duration(dur / 2)
          .style("fill-opacity", 0)
          .remove();
      }
    ).selectAll("rect")
      .data(d => d, d => d.data.key)
        .join(
            enter => enter
                .append("rect")
                .attr("class", "bar")
                .attr("x", d => {
                  return this.xScale(d.data.key);
                })
                .attr("y", () => {
                  return this.yScale(0);
                })
                .attr("height", () => {
                  return this.height - this.yScale(0);
                })
                .attr("width", this.xScale.bandwidth())
            ,
            null,
            exit => {
              exit
                .transition()
                .duration(dur / 2)
                .style("fill-opacity", 0)
                .remove();
            }
          )
          .transition(t)
          .delay((d, i) => i * 20)
          .attr("x", d => this.xScale(d.data.key))
          .attr("y", d => {
            return this.yScale(d[1]);
          })
          .attr("width", this.xScale.bandwidth())
          .attr("height", d => {
            return this.yScale(d[0]) - this.yScale(d[1]);
          });
};

const data = [
  [{
      key: "Jan",
      home: 371,
      work: 335,
      public: 300
    },
    {
      key: "Feb",
      home: 343,
      work: 437,
      public: 228
    },
    {
      key: "Mrt",
      home: 359,
      work: 261,
      public: 202
    },
    {
      key: "Apr",
      home: 274,
      work: 217,
      public: 482
    },
    {
      key: "Mei",
      home: 442,
      work: 314,
      public: 477
    },
    {
      key: "Jun",
      home: 464,
      work: 261,
      public: 278
    },
    {
      key: "Jul",
      home: 343,
      work: 244,
      public: 396
    },
    {
      key: "Aug",
      home: 231,
      work: 406,
      public: 338
    },
    {
      key: "Sep",
      home: 380,
      work: 382,
      public: 366
    },
    {
      key: "Okt",
      home: 391,
      work: 408,
      public: 455
    },
    {
      key: "Nov",
      home: 419,
      work: 261,
      public: 226
    },
    {
      key: "Dec",
      home: 217,
      work: 453,
      public: 335
    }
  ],
  [{
      key: "Jan",
      home: 282,
      work: 363,
      public: 379
    },
    {
      key: "Feb",
      home: 428,
      work: 355,
      public: 216
    },
    {
      key: "Mrt",
      home: 217,
      work: 493,
      public: 280
    },
    {
      key: "Apr",
      home: 304,
      work: 283,
      public: 454
    },
    {
      key: "Mei",
      home: 397,
      work: 406,
      public: 289
    },
    {
      key: "Jun",
      home: 242,
      work: 239,
      public: 232
    },
    {
      key: "Jul",
      home: 327,
      work: 453,
      public: 264
    },
    {
      key: "Aug",
      home: 242,
      work: 240,
      public: 414
    },
    {
      key: "Sep",
      home: 495,
      work: 382,
      public: 368
    },
    {
      key: "Okt",
      home: 285,
      work: 471,
      public: 364
    },
    {
      key: "Nov",
      home: 315,
      work: 421,
      public: 482
    },
    {
      key: "Dec",
      home: 214,
      work: 284,
      public: 297
    }
  ],
  [{
      key: "1",
      home: 282,
      work: 363,
      public: 379
    },
    {
      key: "2",
      home: 232,
      work: 432,
      public: 324
    },
    {
      key: "3",
      home: 324,
      work: 124,
      public: 432
    },
    {
      key: "4",
      home: 425,
      work: 353,
      public: 532
    }
  ]
];

update(data[this.index]);

const swap = document.querySelector(".swap");
swap.addEventListener("click", () => {
  if (this.index < 2) this.index += 1;
  else this.index = 0;
  update(data[this.index]);
});
<button class="swap">swap</button>
<div class="canvas"></div>
<script src="https://d3js.org/d3.v6.js"></script>

Upvotes: 6

Michael Rovinsky
Michael Rovinsky

Reputation: 7210

Exit will not work because you add a "g" container on each update. Just add the following line:

this.svg.selectAll("g").transition().duration(dur).style("opacity",0).remove();

before you call

this.svg.append("g").selectAll("g").data(stackData...

You can see the solution working in a fiddle

Upvotes: 0

Michael Rovinsky
Michael Rovinsky

Reputation: 7210

The stateData you pass to .selectAll('g').data(stackData) is an array of arrays and does not contain keys. You need to enclose every array into an object and specify unique key:

correctStackData = stackData.map((item, index) => ({data: item, key: index}));

Upvotes: 0

Related Questions