ease()-y as Math.PI: 1,200,000ms of Fun with D3’s Animated Transitions

Scott Murray, @alignedleft, alignedleft.com

Scott Murray, @alignedleft, alignedleft.com

This is a presentation I gave at the Eyeo Festival in Minneapolis on June 11, 2014, adapted for the web. The talk was entirely live-coded in the JavaScript console, an experience I’ve tried to recreate here.

I recommend viewing this in Chrome, with the developer tools open. Click the next button to step through the presentation. Or, of course you can retype any of the code directly into the console yourself. Click any code block to execute it (but note that running them out of the intended order may produce unexpected results).

Next >

Hi! D3 is great for working with data, but today I’m just going to talk about transitions. The first and most important thing to know is that D3 is just manipulating the DOM. That is, D3 doesn’t “draw” anything to the screen; it simply creates new DOM elements. It also doesn’t “move” anything from one place to another; it just interpolates position values (or any other values) over time.

Let me show you what I mean. First, every action in D3 begins with a selection. Think of this as the subject of your sentence. We have to know what to act on before we can act on it.

d3.select("#intro")

With this code, we select an existing div with the ID of intro.

OK, nothing changed. We need to add a verb to our sentence. Let’s take that selection and add an inline CSS property to it.

d3.select("#intro").style("background-color", "red")

After running that code, open the DOM inspector and note that #intro now has a background color applied to it.

We can set any other CSS property using the same technique. Here we change the font:

d3.select("#intro").style("font-family", "Helvetica")

Then we change the font color:

d3.select("#intro").style("color", "blue")

You can store references to selections in variables, to spare yourself from typing d3.select() over and over. Here we store this selection in intro:

intro = d3.select("#intro")

Now we can reference intro just using the variable name. Here I’m going to get rid of the intro using a custom function I wrote that fades it out.

intro.each(hide)

Great. Now take a second and find the SVG element in the DOM inspector. Note that it contains a bunch of circles. Each circle has a cy attribute that specifies its vertical position. Let’s center them all vertically.

d3.selectAll("circle").attr("cy", "50%")

Now check the DOM inspector again and notice that all the cy values have been set to 50%. This is what I mean when I say D3 doesn’t draw anything directly; we just use it to manipulate values in the DOM, and the browser re-renders everything accordingly.

Let’s move all the circles to the bottom:

d3.selectAll("circle").attr("cy", "100%")

All changes so far have been instantaneous. To change values gradually, over time we use a transition. It is ridiculously easy to do this; just add the transition() method before changing any values:

d3.selectAll("circle").transition().attr("cy", "50%")

Kind of amazing, right? But that was so, so fast! The default transition duration (speed) is 250ms, or one quarter second. We can add a duration() to slow things down a bit.

d3.selectAll("circle")
	.transition()
	.duration(2000)
	.attr("cy", "0%")

Much better! I’ve also indented the code here so it’s easier to read.

Let’s center them again.

d3.selectAll("circle")
	.transition()
	.duration(2000)
	.attr("cy", "50%")

Transitions aren’t just for position values; we can use them for color, too.

d3.selectAll("circle")
	.transition()
	.duration(1000)
	.attr("fill", "red")

One more time:

d3.selectAll("circle")
	.transition()
	.duration(1000)
	.attr("fill", "lime")

And again:

d3.selectAll("circle")
	.transition()
	.duration(1000)
	.attr("fill", "aqua")

Just for fun, let’s make all the circles bigger by transitioning the radius value.

d3.selectAll("circle")
	.transition()
	.duration(1000)
	.attr("r", "5%")

And then we’ll set the sizes back to normal, and reposition the circles up top at the same time:

d3.selectAll("circle")
	.transition()
	.duration(2000)
	.attr("r", "1%")
	.attr("cy", "0%")

Notice how you can string together as many of these attribute statements as you like, and they all transition together.

If you don’t want a transition to begin immediately, you can add delay() and specify the time to wait in milliseconds.

This waits two seconds before initating a three-second transition.

d3.selectAll("circle")
	.transition()
	.delay(2000)
	.duration(3000)
	.attr("cy", "75%")

OK, here’s the really fun stuff. So far, all of our circles have moved together, as a group. But often you’ll want to have the transitions staggered, so each element begins moving or changing at a slightly different time. We call this a per-element delay. To do this, we replace the static delay value with an anonymous function. This function takes two values, which are automatically passed in by D3. (Thanks, buddy!) Ignore d for now; the important one is i, which represents the index value of each circle. The index of the first circle is 0, the second is 1, and so on.

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 100;
	})
	.duration(3000)
	.attr("cy", "50%")

WHOOOOAAA what was that?!?!

Each circle made the same transition, but they began at different times. The first one began immediately, and each subsequent transition kicked off with an additional 100ms (tenth of a second) delay. Nice!

Of course, we can use per-element delays with color changes as well.

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(250)
	.attr("fill", "steelblue")

And again:

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(250)
	.attr("fill", "#CCEE33")

And again:

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(250)
	.attr("fill", "rgb(255, 100, 33)")

Notice that I can use named colors, hex colors, or RGB colors, and D3 calculates the interpolations for me. IT IS JUST THAT GOOD.

Let’s use the per-element delay technique with radii changes.

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 20;
	})
	.duration(500)
	.attr("r", "10%")

OK, now just to be awesome, let’s incorporate that technique of using an anonymous function to calculate a different radius value for each circle.

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 20;
	})
	.duration(500)
	.attr("r", function(d, i) {
		return (Math.random() * 7) + "%";
	})

That chose some random numbers, but we could also take the sine of the index value to make a smooth wave.

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 20;
	})
	.duration(500)
	.attr("r", function(d, i) {
		return ((Math.sin(i / 5) + 1.1) * 3) + "%";
	})

Pretty cool, but it’s time for a reset.

d3.selectAll("circle")
	.transition()
	.duration(500)
	.attr("r", "1%")

Check the DOM inspector and notice that inside the SVG element, I placed an empty rect. I’ll set the fill on that rectangle so you can see it.

d3.select("rect")
	.transition()
	.duration(500)
	.attr("fill", "hsl(360, 100%, 50%)")

I’m using an HSL color here, so by dialing the hue value up and down (between 0 and 360 degrees), we easily get a different color.

Here’s a randomly selected hue:

d3.select("rect")
	.transition()
	.duration(500)
	.attr("fill", "hsl(" + (Math.random() * 360) + ",100%,50%)")

Go ahead and click that code block again; each time you click it, a new color is selected and applied.

Now what if we could loop this recoloring, so after completing the transition to one color, another transition would immediately begin toward another color, and so on forever? Yes, of course we can do that, by wrapping this transition code inside a function that, when done, calls itself!

var recolor = function() {
	d3.select("rect")
		.transition()
		.duration(3000)
		.attr("fill", "hsl(" + (Math.random() * 360) + ",100%,50%)")
		.each("end", recolor);
};

That final line, each() tells D3 to perform some action as soon as the initial transition has ended. In this case, we are using that space to call recolor(), the very same function that has just run. It’s like a JavaScript snake eating it’s own tail!

But nothing’s happened visually yet. Having defined the function, we need to actually call the function just once to kick things off:

recolor()

Our trippy background in place, let’s revisit the circles. Try this per-element delay, moving the circles to the bottom of the image:

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(3000)
	.attr("cy", "100%")

And again, but moving them back up to the top:

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(3000)
	.attr("cy", "0%")

Now what if we did the same thing, but after the move to the bottom is complete, we call a new function that returns the circles to the top, automatically? Note that this just requires adding each() and including the appropriate “move back to the top” code.

//Move to bottom
d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(3000)
	.attr("cy", "100%")
	.each("end", function() {

		//Move to top
		d3.select(this)
			.transition()
			.delay(function(d, i) {
				return i * 50;
			})
			.duration(3000)
			.attr("cy", "0%");

	})

That snake metaphor is getting more appopriate by the minute! (I am allowed to make really bad jokes here, as I doubt anyone has actually made it this far. If you are still with me, I apologize.)

One new thing above is d3.select(this), which selects the thing that called each(). In this case, this refers to whichever circle just finished its initial transition.

I think you can see where I’m headed with this. With some minor changes, we can wrap all this in a function, and then have that function call itself once it’s done. Then we will have a recursive and infintely gratuitous use of this totally mindblowing technology!

Let’s first define our snake, er, wave function:

wave = function() {

	//Move to bottom
	d3.select(this)
		.transition()
		.duration(3000)
		.attr("cy", "0%")
		.each("end", function() {

			//Move to top
			d3.select(this)
				.transition()
				.delay(function(d, i) {
					return i * 50;
				})
				.duration(3000)
				.attr("cy", "100%")
				.each("end", wave);

		});

};

We’ve defined it, now let’s kick things off with an initial transition, after which the wave function is called:

d3.selectAll("circle")
	.transition()
	.delay(function(d, i) {
		return i * 50;
	})
	.duration(3000)
	.attr("cy", "100%")
	.each("end", wave)

Thank you for suffering through this! If you want to learn lots more about D3 and how to use its transitions in an actually useful way, check out my book!

d3.select("#end").each(show)