Animated transitions

Finish up our bar chart component by animating its state transitions.

Transcript

Alright, well we're nearing the end of our D3 series! In the last few videos, we made our bar charts dynamic so that they can re-render correctly. In this video, all that hard work will pay off, because I'm about to show you just how easy D3 makes it to add animated transitions to our visualizations. Let's look at some code.

Right now in our app, when we click a category, our authors table re-renders but it does it immediately. Let's switch over to our bar chart component. We'll come to top and start by importing the d3-transition module:

import 'd3-transition';

Now this module adds a .transition method to all of our D3 selections. So we can come down here to where we work with our enter and update selection, and right before we start performing our operations, we'll just call .transition():

barsEnter.merge(barsUpdate)
  .transition()

Let's save this, come back - and just like that, all of our bars are smoothly transitioning between their new widths and heights, and even the colors are smoothly transitioning between the darker tall ones, and lighter small ones. Pretty cool right?

I just want to point out that while it felt like it took a long time to get to this point, this is really the benefit, and if you take time to code your D3 visualizations according to best practices like we did, with making that renderChart method purely declarative, then you really get a lot of goodies like this for free.

Now, what this method is actually doing is turning our selection into a transition. Transitions have many of the same methods that selections have, which is why we can call these methods like .attr on them.

Now of course this transition can be customized in terms of its duration and its easing function, but for now we'll stick the default.

Alright, so the heights are working really well. But look what happens when I change the category to Literature. This bar on the end, it just kinda disappears. And we want to make it smoothly fade out.

Let's come back to our code, and we'll come down here to our rules for exiting bars. We'll just add a transition, and before we remove the bars, we want to animate the opacity to 0.

barsExit
  .transition()
  .attr('opacity', 0)
  .remove();

And now when we click on Literature, we can see that bar nicely fade out, and we can see it even more pronounced if we choose a single post. All those bars nicely fade out.

One of the great things about transitions is that they wait for animations to complete, so this will finish animating the opacity, and then remove the elements from the DOM making sure that the animation runs in its entirely.

We can actually see this by slowing down this transition to two seconds:

.transition().duration(2000)

Now when we choose literature, we can see it slowly fading out, or a post - all of those nicely fade out.

The last part is to make sure that they slowly fade in, because right now if I click this, we can see they pop back in, and we don't want that.

Let's remove our duration, come back up to when we define the entering rules, and right after we append these rects, let's just give them an initial value for their opacity of 0.

let barsEnter = barsUpdate.enter()
  .append('rect')
  .attr('opacity', 0);

So now our bars will enter with an opacity of 0, and then in the transition, they'll smoothly go to their final values because they're set right here.

Let's save this, and try it out. Fade out, and fade in. Looks great!

Now, adding animations has actually exposed a small visual bug. It's been there the whole time, it just wasn't apparent.

If I select this post right here, the Authors table filters down to a single author, and we can see it's that left-most bar that's growing and shrinking. But if we look at the data, the selected post has an author of Montana, which is shown right here; but if I unselect this bar, this left-most author is actually Edison. Montana is way over here.

So why is the left-most bar growing, instead of this bar that corresponds to Montana? We can actually also see this if we come over and directly select an author. It's always the left-most bar that grows.

The problem is with our data join, right here. Right now, we're just joining our array of data to our selection of rectangles. This code is telling D3 to match the first element in our data array with the first rectangle, the second element to the second rectangle, and so on.

So, when come here and filter down our dataset to one author, the dataset becomes a 1-element array and D3's just joining that first element to the first bar. That bar's properties are changing so it's animated, and the rest are removed.

But we don't actually want to match by index, we want to match our data elements by the author, which is coming from the label property on our dataset.

D3 lets us easily do this by passing a second parameter to the data join, which is called a key function. This is a function that takes each data element as a parameter, and returns a unique identifier, which in our case is the label property.

.data(data, data => data.label)

And now D3 knows to match the data elements to our bars by label.

Let's save this, come back, and now when we select this post, we see that the bar corresponding to Montana is actually the one that grows. Same with all these other ones, and if we select one of these authors, it's the correct bar that actually grows.

Ok, we've got one last touch to wrap up this chart nicely. When we select an Author bar, we see that this tooltip kind of jumps around. And this is because our tooltip comes from our tethered modal dialog, which is not aware of our D3 transition.

If we had written our tooltip using D3, we could have just animated it alongside the rest of our bar animations. But we're already using modal-dialog, I like it, it gives us a lot of flexibility when it comes to what we want to render, so let's stick with it and make it work alongside our transition.

Now, let's come down here to the bottom of our transition code. Our modal dialog uses the Tether global to control its positioning. This global exposes a .position method that we can call whenever we want to force Tether to recalculate the position of our modal.

Now Tether is a global, so I'll come up here and define is as a global to make ESLint happy. Now our strategy will be to call .position() whenever our transition is running. So when our transition starts, we'll start calling .position() as often as we can; and then as soon as our transition finishes, we'll stop calling it.

Fortunately, D3's transitions give us an .on method that we can use to execute arbitrary code during different lifecycle events of the transition. Transitions have a start event, and they also have an end and interrupt event. Each one of these events takes a function whose parameters are data and index, and these functions would be executed for each element in our data array.

Let's grab this code and move it to start, and we actually want to continuously execute this as long as our transition is running. To do this we'll make a new function right here, called updateTether, which is actually going to use requestAnimationFrame to recursively call itself. Now, once our transition starts, we actually want to kick this function off - right here we're just defining it - but we can force it to immediately run by wrapping this in an expression, and immediately invoking it.

Finally, remember that I said this function will get run for each element in our data array. So, if our chart gets a data array with seven different elements in it, we'll run this code seven different times. But we only want to actually run this code once, when the transition is kicked off. We can use this index property here, and only run this code if the index is 0.

.on('start', (data, index) => {
  if (index === 0) {
    (function updateTether() {
      Tether.position();
      requestAnimationFrame(updateTether);
    })();
  }
})

Now let's save this, and give it a shot. Nice, now when we click on these bars, the tooltip smoothly transitions along with the bars.

Now we actually kicked this code off when we started a transition, but we haven't canceled it. To do that we'll use a new variable, we'll call it rafId. This is going to be a unique id returned by requestAnimationFrame, which we can use in this end and interrupt event function to cancel. So we'll call cancelAnimationFrame, pass in the rafId, and again we only want to run this once per transition, so we'll only run this if the index is 0.

letrafId;
...
.on('end interrupt', (data, index) => {
  if (index === 0) {
    cancelAnimationFrame(rafId);
  }
})

Let's save this, and now everything seems to be working great.

Ok, it's time for the grand reveal! So far we're only passing dynamic data to our Authors chart, so let's come back to our posts controller, and here are the other two properties that are powering our charts, and we'll change their static model property to the dynamic posts array.

postsByCategory: groupBy('posts', 'category')
...
commentsData: Ember.computed.map('posts', function(post) {

Save this, and now anytime we interact with any of these charts, we get everything updating! So we can choose a category, see the distribution across all the authors, and see the comment count for just those posts. We can choose an author and a category, and this data table is in sync, this all works with the forward and back button, and the refresh as well, because all of our charts are declarative. We have URLs which we can use to share all programming articles by Marlen with our colleagues. Everything is transitioning smoothly, and it looks really sharp!

Alright - give yourself a pat on the back, because we just finished coding our animated bar charts! Animations are easily my favorite part of D3 and I really encourage you to go check out some community visualizations and get inspired. After all - Ember gives us all this power to do stateful UI on the client - so, why not take advantage of it?

In our next and last video, we'll finish out the series with some closing thoughts.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.