A brief introduction to animation
Bringing things to life, through space and time
This is it: our final unit! Since the beginning of our journey, we have come a long way from being able to just insert some p
elements in an HTML page, or draw some circles on an SVG canvas. We've seen many different ways of bringing these static, sitting elements to life, such as through the D3 data join pattern that allows us to create shapes through sets of data, or event listeners that enable us to do things with elements when we click a button or move the mouse across the screen. In this final unit, we will explore one last way to bring our elements to life, through animation — the process of moving elements through space and time across the screen.
In this final exploration, we will focus on a shallow introduction to animation in the world of web programming. In reality, this topic itself is very complex and deserving of an entire semester's worth of study! As a result, we will focus on limited patterns for animating shapes, through two different ways: one will be based on a pattern built into the D3 library, and the other will be a pattern that is built into the plain JavaScript language itself.
Regardless of which pattern we use, we will learn that animating SVG shapes is actually a very low-tech mechanism that gets its visual effect by taking advantage of human perception of apparent continuous motion.
Rapidly changing attributes in tiny increments
To begin our exploration of animation, examine the demonstration below. It features a simple circle that is drawn on an SVG canvas, along with a button that says "Click me to move the circle." Do as the button says — click it. What happens?
Unsurprisingly, the circle moves, and by the same amount of distance with each click of the button. All that is happening to make this work is that we are incrementing (or increasing) the value of the circle's "cx"
attribute by 50 pixels each time, and as a result, the circle's position on the screen gets updated with each increment. To confirm this for yourself, open up the Chrome web inspector, find the circle
element in the element structure, and examine its "cx"
attribute every time you click the button; you should see the value increasing by 50 with every button click.
let cx = 0; let circle = svg.append("circle") .attr("cx", cx) .attr("cy", height/2) .attr("r", 50) .attr("fill","#CC0000"); d3.select("button").on("click", function() { cx += 50; circle1.attr("cx", cx); });
Now imagine two variations on the above demonstration. First, imagine clicking the button very rapidly in succession; as you do so, the circle moves more quickly across the screen. Then, imagine clicking the button very rapidly while decreasing the distance the "cx"
attribute changes with each button click; as you do that, the circle will appear to move more slowly, because the distance traveled with each button click is decreased. In both variations, the apparent motion of the circle is achieved by the same mechanism, where the circle's "cx"
attribute is being changed by a certain amount over a specified duration of time. By manipulating these parameters of speed of clicking and the size of change in the attribute, we can achieve different effects of apparent motion.
This "apparent motion" is the foundation of animating SVG shapes. In fact, animation with SVG is really the same very simple mechanism: to animate a shape, we change one or more of its attributes by a certain amount over a fixed duration of time. That's it. The browser does the heavy lifting for us to figure out how a shape's attributes should be changed by what value over what period of time, and the resulting effect gives the impression of smooth, continuous motion. But that continuous motion is just an attribute changing in such small increments that are imperceptible to the human eye.
This basic tenet of SVG animation is built into the D3 library that we have been using all along to draw SVG shapes, giving us a very convenient way to animate any shape we draw. The pattern that makes this possible in D3 is called .transition()
, and its simplicity makes it extremely easy to animate any shape with very little code — all we need to supply to this pattern is what we want the shape to look like at the end of an animation, and D3 does all the rest for us. In contrast, achieving the same sort of effect without D3 but using plain JavaScript alone requires a bit more code and forces us to think about what needs to happen with every single step of an animation, from start to finish. In the sections that follow, we will see both patterns introduced. Most of the time, we can achieve any kind of animation we want to see with D3 alone, but other times, we'll need a bit more fine-tuned control over an animation with the help of plain JavaScript. There isn't a specific advantage to using one pattern over the other; most of the time, you'll need to decide which pattern works best for your needs. For our purposes, we'll need to start somewhere, and we will start with D3.
Animation with D3
The pattern for animating an SVG shape with D3 is very straightforward, requiring two main steps:
- Draw one or more shapes.
- Using the
.transition()
method, tell D3 what you want those shapes to look like at the end of an animation, over an animation time that you specify.
In terms of code, this pattern uses a method named .transition()
. We can attach this method to any shape (or selection of shapes), specify how long an animation should last, and indicate what the final shape should look like at the end of that animation in terms of final values for specific attributes of interest. The general pattern looks like the following:
SHAPE.transition() .duration(LENGTH OF ANIMATION) .delay(DELAY TIME BEFORE ANIMATION START) .attr(FINAL VALUE OF NAMED ATTRIBUTE) ...
The SHAPE must be a reference to an SVG shape, or a selection of multiple shapes (e.g., after using the svg.append()...
pattern). To that reference, we attach the .transition()
method, which instantiates a new animation (or "transition") on that selected shape. Note that nothing goes inside the parentheses for the .transition()
method. After that, we specify how long the animation of that shape should last using the .duration()
method; this time duration is specified in milliseconds, so for example, 1 second is 1000 milliseconds (and we would put a value of 1000 inside the parentheses to indicate this animation duration). Optionally, we can also specify an amount of time to delay the start of the animation, using the .delay()
method, which is also given in milliseconds. (By default, the delay time will be 0 seconds, meaning an instantly-starting animation, unless we specify an amount of time to wait before the animation starts.)
The last part of this pattern is a series of attribute specifications, using the .attr()
method. These lines indicate what the values of specified attributes should be by the end of the animation, and we can list one or more attributes to animate over time by listing them out one after another. For example, if we wanted to change a circle's radius from an initial value of 50 to a final value of 0 pixels over a 1-second animation, we would write the following code:
svg.append("circle") .attr("cx", 100) .attr("cy", 100) .attr("r", 50) .attr("fill", "red") .transition() .duration(1000) .attr("r", 0);
If we wanted to simultaneously change the circle's size and move it to a new "cx"
position over an animation of 1500 seconds, with a delay of 250 milliseconds before the animation starts, we would write the following code:
svg.append("circle") .attr("cx", 100) .attr("cy", 100) .attr("r", 50) .attr("fill", "red") .transition() .duration(1500) .delay(250) .attr("r", 0) .attr("cx", 250);
If we wanted to animate any other attribute changes over the same duration of time, we would add them to this chain of attributes, using the .attr()
method. There's no limit to how many attributes can be changed together in an animation.
In the following demonstration, a circle is initially drawn with a "cx"
attribute of 0, which places it at the left edge of the SVG canvas. When you click the button, an animation is triggered that moves the circle to the right edge of the SVG canvas, by transitioning the circle's "cx"
attribute to the width
of the canvas (thus positioning it at the right edge of the canvas) over a time period of 2 seconds, with a 0.5-second delay:
The code that accomplishes the above demonstration looks like the following:
circle.attr("cx", 0) .attr("cy", height/2) .attr("r", 50) .attr("fill", "#CC0000") .transition() .duration(2000) .delay(500) .ease(d3.easeLinear) .attr("cx",width);
You might notice in the above code that there is another line inserted into the pattern, with the .ease()
method. This method allows us to specify an easing function for the animation, which controls how the animation is distributed over time. Into the parentheses for this method, we place the name of a D3 easing function, such as d3.easeLinear
, which animates the specified attributes linearly (evenly) over time. To see a complete list of available easing functions, see the documentation for the d3-ease module.
The above animation moves just a single shape, but we can generalize this pattern to more than one shape. For example, in the following demonstration, 5 circle
elements are drawn with a data join and stored by reference in a variable named circles
. Then, the .transition()
pattern is added to that variable named circles
, which instantiates animations for all 5 circle
elements in that selection.
The code that accomplishes the above demonstration looks like the following:
let data = [];
for(let i = 0; i < 5; i++) {
let cx = 0;
let cy = 50 + i * (height/5);
data.push({x: cx, y: cy});
}
let circles = svg.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; })
.attr("r", 40)
.attr("fill", "#CC0000");
circles.transition()
.duration(2000)
.delay(function(d, i) { return i * 500; }
)
.attr("cx", width);
In the above demonstration, you may have noticed yet something else new: the animation of the circles is staggered, meaning they all don't get animated at the same time. This staggered effect is the result of using the .delay()
method, but instead of specifying a constant delay time that will be uniformly applied across all the circles in the selection, we can also specify an accessor function that can be used to compute a different delay time for each circle. In the code above, can you figure out what the .delay(function(d, i) { return i * 500; })
line is doing and how it works? (Remember that the i
in the function(d, i)
refers to the index number of each circle
element in the selection.)
In all of the above demonstrations, there's really nothing magical going on beyond rapidly changing the values of attributes! To confirm this for yourself, open the web inspector again, and find the shape elements for one of the above demonstrations. Click the button to trigger the corresponding animation, and watch what happens to the attributes of the shape. They're changing, rapidly and in small increments! Again, we don't see these incremental changes with the naked eye, and that doesn't matter — in the end, we still have apparent motion, which gives the illusion of continuous animation.
One thing to note about D3 animations is that if we want to animate a specific attribute for a shape, that attribute must be initialized with a starting value before we can animate it to a different value. For example, if we want a shape to fade away to an opacity
value of 0, we must first draw that shape with an opacity
attribute set to an initial value, even if that value is 1. This means we can't just animate any attribute using the .transition()
pattern; we need to make sure that the animation for that attribute has somewhere to start from.
Animation with setInterval()
The D3 pattern is simple and can be applied to any shape. But that simplicity in the pattern is actually hiding a more complex operation in the background. In fact, the D3 .transition()
pattern is just a simpler and more concise form of a more generalized pattern that exists in the plain JavaScript language, called setInterval()
. With the setInterval()
function in JavaScript, we can also animate any shape, but we need to be more explicit in our code about what exactly should happen over the course of an animation made with this pattern.
The setInterval()
pattern requires two pieces: a thing to do over and over again in succession, and a time period that should elapse between each doing of that thing. The pattern looks like this:
setInterval(function() { THING TO DO WITH EACH INTERVAL }, TIME PERIOD BETWEEN INTERVALS)
In this context, an interval is like a loop on repeat: something is going to happen, over and over again, separated in time by a certain interval of milliseconds. Importantly, this "loop" will go on forever, being triggered every same number of milliseconds, unless we tell it to stop. Note that the first argument to the setInterval()
pattern is a function; this function explains what should happen every single time the loop event gets triggered. The second argument is a time duration, in milliseconds, that tells the browser how long to wait between loop iterations. In other words, the time duration is the amount of time that separates each event from being triggered or fired.
This pattern may seem very bare-bones, but there is immense utility in that simplicity. While the D3 .transition()
pattern can only be used to animate attributes of elements over finite durations of time, the setInterval()
pattern can be used for much more than just animating attributes. But the tradeoff for that utility is that when we use this pattern, we have to be much more explicit about what should happen in an animation. This is because setInterval()
is not fundamentally about animation; it is purely for repeating a certain action over and over again, with time delays inserted between instances of those actions.
The following demonstration shows our original circle-movement animation, but this time accomplished with setInterval()
instead of D3's .transition()
:
The code required to create the above demonstration looks like this:
let circle = svg.append("circle") .attr("cx", 0) .attr("cy", height/2) .attr("r", 50) .attr("fill","#CC0000"); let cx = 0; setInterval(function() { cx += 10; circle.attr("cx", cx); }, 5);
In the code above, a circle
element is drawn with initial attributes. After that, a variable named cx
is created with an initial value of 0; this variable will shortly be used to update the circle's "cx"
attribute value. Then, setInterval()
is used, to perform two actions: first to increase the value of the variable named cx
by 10, and second to apply this updated value of the variable to the "cx"
attribute of the circle. These actions are repeated over and over again, spaced out by a time interval of 5 milliseconds, which is indicated as the second argument of the setInterval()
function.
But wait! This isn't completely accurate. Recall that setInterval()
runs forever, unless we tell it to stop. This means that the above code will actually cause the circle's "cx"
attribute to increase by 10 every 5 milliseconds, forever, meaning that it will just keep moving further and further off the edge of the SVG canvas. (There's no limit to how far off it will go!) If we want the circle to stop moving when we hit the right edge of the canvas, then we need to do a check inside the setInterval()
function to determine if the value of cx
has reached the width
of the canvas — and if so, then we need to stop the interval from running, using clearInterval()
. (The clearInterval()
function accepts one input argument: the name of an already-running setInterval()
that has been stored in a variable elsewhere in the code.) The real code required to make this work looks more like this:
let circle = svg.append("circle") .attr("cx", 0) .attr("cy", height/2) .attr("r", 50) .attr("fill","#CC0000"); let cx = 0; let animation = setInterval(function() { if(cx >= width) { clearInterval(animation); } else { cx += 10; circle.attr("cx", cx); } }, 5);
As you can see, this can get really complicated really quickly, even for simple kinds of animations like moving a circle from one side of an SVG canvas to another. When we use the D3 .transition()
pattern, we don't need to think about these things, because D3 handles all these computations for us. For this reason, for simpler animations, we'll tend to prefer the D3 pattern over setInterval()
; however, in more complex animations that we will see in demonstrations in class, we'll need to use some combination of both .transition()
and setInterval()
together.
Looping animations
In all the demonstrations so far, we've seen animations that have a starting point and a stopping point, which controls the movement of a shape over a finite duration of time. What if we wanted to loop an animation, to keep it running for an indefinite period of time? The following demonstration shows an example of this.
The above demonstration uses D3 .transition()
to change the "cx"
attribute of a circle. But instead of quitting the animation when the transition has ended, it repeats itself again — and again — to restart the animation from the beginning. The secret behind this is that we can trigger another animation, using .transition()
at the end of a previous animation, using the .on()
method. The code that makes this demonstration possible looks like this:
let circle = svg.append("circle") .attr("cx", -50) .attr("cy", height/2) .attr("r", 50) .attr("fill","#CC0000"); function runAnimation() { circle.attr("cx", -50); circle.transition() .duration(2000) .ease(d3.easeLinear) .attr("cx",width+50) .on("end", runAnimation); } runAnimation();
It's worth taking a moment to puzzle over the code you see above; there are a couple peculiar but extremely important things happening. First, we create a new circle
element, storing it in a variable named circle
. When this circle is first drawn, its "cx"
attribute is set to -50 — why is this? This is because the circle's radius is set to 50 pixels, and so by starting the circle's "cx"
attribute at -50, the circle is just barely off the left edge of the SVG canvas. (Do you understand why this is true?) After we initially draw the circle, we then use .transition()
to move the circle to a final "cx"
attribute of width+50, which places it just barely off the right edge of the SVG canvas (why?). But importantly, this .transition()
on the circle is wrapped inside of a function named runAnimation()
. The reason we are doing this is because we want to be able to call this same animation, again and again, at the end of each animation's runtime. We do this by attaching the .on("end", runAnimation)
line of code. The .on("end", ...)
part means "do something when this animation is done running," and the runAnimation
(a function we named ourselves!) is the thing we are telling the browser to do when that animation is done running. Conveniently, whenever we call runAnimation
, we trigger a new .transition()
on the same circle, and the end result of this recursive procedure is a continuous loop: every time the circle moves off the right edge of the SVG canvas, the circle is moved back to being off the ledge edge of the SVG canvas and transitioned again to the right side of the canvas, forever.
This is really tricky stuff! But this is the kind of code shenanigans that makes more complex kinds of animation possible.
Triggering animations with interaction
We now have (almost) all the pieces we need to understand what was happening in the final demonstration from the previous unit, on interactivity. Remember the following demonstration? (Move your cursor across the canvas to see what happens.)
It turns out that there's actually not much going on in this demonstration; it leverages a very simple D3 animation, one that gets triggered for every new circle that is drawn across the canvas with the mousemove
event. Recall that in that demonstration, we drew a new circle
element with every movement of the mouse, using a combination of an event listener to detect movement on the SVG canvas and the d3.pointer()
method, which enabled us to capture the location of the mouse at any point across that canvas, like this:
svg.on("mousemove", function(event) { let position = d3.pointer(event); let x = position[0]; let y = position[1]; svg.append("circle") .attr("cx", x) .attr("cy", y) .attr("r", 50) .attr("fill", "steelblue") .attr("opacity", 0.8); });
Every time we draw a new circle with the mousemove
event, we can also attach a transition to each newly-drawn circle element. If we initialize each circle with the given attributes, particularly noting that each circle's radius is initialized to 50 pixels, we can simply add the .transition()
pattern at the end of the code for drawing each circle, making the circle's radius decrease down to 0 over a fixed amount of time before being removed completely from the DOM:
svg.on("mousemove", function(event) { let position = d3.pointer(event); let x = position[0]; let y = position[1]; svg.append("circle") .attr("cx", x) .attr("cy", y) .attr("r", 50) .attr("fill", "steelblue") .attr("opacity", 0.8) .transition() .duration(1000) .attr("r", 0) .remove(); });
The above code yields the following outcome. How do we then add the color change into this demonstration?
The secret is in a special color scale that's being created by D3. In the code, there's actually another pattern embedded that creates a color palette we're able to exploit in our animation:
const colorScale = d3.scaleSequential(d3.interpolatePlasma) .domain([0,1]);
The d3.scaleSequential()
pattern is called a D3 scale function, which converts an input value in a domain of possible input values into a corresponding scaled value in an output range. In this particular usage of the pattern, we are passing d3.interpolatePlasma
into the parentheses, which is the name of a color palette that is written into the D3 library. We don't need to know the specifics of what this whole chunk of code is doing; instead, all we need to know is that the variable called colorScale
now behaves like a function, and if we pass into that function a value between 0 and 1, we'll get back from the function a different color in this specific color palette.
Once this color scale is constructed, we can exploit it with the mousemove
event. First, we create a variable named colorIndex
, which is initialized at a value of 0. Then, inside the mousemove event, we increment the value of colorIndex
by 0.05 every time the mouse cursor moves. We then pass this incremented value of colorIndex
into the colorScale()
function, and assign the resulting color to the "fill"
attribute of the newly-drawn circle
element. At a certain point, the value of colorIndex
will be greater than 1, and thus larger than the values accepted by colorScale
. To control for this, a simple if()
statement inside the mousemove
event checks to see if the value of colorIndex
is greater than 1, and if so, resets its value back down to 0, to restart the cycle. The end result is a cycling through colors in the color palette:
const colorScale = d3.scaleSequential(d3.interpolatePlasma) .domain([0,1]); let colorIndex = 0; svg.on("mousemove", function(event) { let position = d3.pointer(event); let x = position[0]; let y = position[1]; if(colorIndex > 1) { colorIndex = 0; } colorIndex += 0.05; svg.append("circle") .attr("cx", x) .attr("cy", y) .attr("r", 50) .attr("fill", colorScale(colorIndex)) .attr("opacity", 0.8) .transition() .duration(1000) .attr("r", 0) .remove(); });
Tweaks like these can take the D3 .transition()
pattern to new heights. But again, behind the scenes, even in the most apparently complex animations, a very simple thing is happening, and that is changing attributes of shapes. That's really it — if we can learn to wield that power correctly, then we can create any kind of animation, with a little bit of ingenuity.