§ The Unreasonable Effectiveness Of Declarative Programming

§ Declarative animations

I show off minanim.js , a tiny, 100LoC, yet feature-complete library for building animations declaratively, and why someone would want to do things this way. Enjoy!

§ A complex animation

The blue circle's animation is quite complex. It consists of multiple stages. (1) The circle grows in size. (2) It continues to grow in size at a faster rate, as it shoots off to the right. (3) It pauses. (4) It moves to the middle. (5) It pauses again. (6) It shrinks to nothing. All of this is captured by a single object anim_circle (written using minanim.js) which declares what the animation is doing:
01: 
02: // cx = location | cr = radius
03: let anim_circle = anim_const("cx", 100)
04:     .seq(anim_const("cr", 0))
05:     // (1) grow in size.
06:     .seq(anim_interpolated(ease_cubic, "cr", /*val=*/10, /*time=*/3))
07:     // (2) go to right while growing.
08:     .seq(anim_interpolated(ease_cubic, "cx", /*val=*/300, /*time=*/1)
09:         .par(anim_interpolated(ease_cubic, "cr", 70, 1)))
10:     // (3) pause.
11:     .seq(anim_delay(/*time=*/3))
12:     // (4) come back to the left.
13:     .seq(anim_interpolated(ease_cubic, "cx", 100, 1))
14:     // (5) pause again.
15:     .seq(anim_delay(/*time=*/2))
16:     // (6) shrink to nothing.
17:     .seq(anim_interpolated(ease_cubic, "cr", 0, 1));
18: 
The entire animation is built out of one primitive and three combinators:
  1. anim_const(name, val) to set a constant value val to name name.
  2. anim_interpolated(ease, name, val, time) to change to a named value with name name to value val in duration time.
  3. anim1.seq(anim2) to run anim2 once anim1 has completed.
  4. anim1.par(anim2) to run anim2 in parallel with anim1.
  5. anim_delay(time) to do nothing for time time.

§ What is anim_circle, really?

anim_circle is a function, which can be invoked as val = anim_circle(t). It returns an object val. val.cx and val.cr have values as the animation dictates. That's it. It does not modify the DOM. It does not edit the circle tag. Given a time t0, it computes cx and cr at time t0. Keep it simple, stupid!
Here is a plot of the values of val.cx and val.cr for different values of t. This plotting code calls anim_circle at different times to plot the results. The function anim_circle is these plots, since it doesn't compute anything else.
Fancy ways of saying that anim_circle doesn't change anything else is to say that it is side-effect-free, or refrentially transparent.

§ Playing with this Webpage: edit anim_circle in the browser!

The code that's been copied onto your clipboard is:
01: 
02: // cx = location | cr = radius
03: anim_circle = anim_const("cx", 100)
04:     .seq(anim_const("cr", 0))
05:     // (1) grow in size.
06:     .seq(anim_interpolated(ease_cubic, "cr", /*val=*/10, /*time=*/3))
07:     // (2) go to right while growing.
08:     .seq(anim_interpolated(ease_cubic, "cx", /*val=*/300, /*time=*/1)
09:         .par(anim_interpolated(ease_cubic, "cr", 70, 1)))
10:     // (3) shrink to nothing.
11:     .seq(anim_interpolated(ease_cubic, "cr", 0, 1)); plot()
12: 
You can explore different definitions anim_circles. Feel free to play around. Try evaluating anim_circle(0), anim_circle(anim_circle.duration), anim_circle(anim_circle.duration/2.0) in the console to get a feel for what anim_circle returns.

§ Declarative ⇒ Pure

As hinted above, since our specification of the animation was entirely declarative, it can't really "do anything else" like manipulate the DOM. This gives us fantastic debugging and editing capabilities. As it's "just" a mathematical function:
1: 
2: anim_circle: (t:Time) -> (cx: float, cr: float)
3: 
We can easily swap it (by pasting the code above), poke it (by calling anim_circle(0.5)), and in general deal with is as a unit of thought. It has no unpleasant interactions with the rest of the world.

§ Purity ⇒ Time Travel

Due to this purity, we also get time-travel-debugging. The slider is hooked up to anim_circle, and displays the circle as dictated by anim_circle(t_slider). t_slider is received from the slider.
Drag the slider to move through the animation!

§ Declarative ⇒ Composition: staggering animations

Our framework is composable, because we can build larger objects from smaller objects in a natural way. As an example, a staggered animation is a nice way to make the entry of multiple objects feel less monotonous.

§ Step 1

The code to achieve this creates a list of animations called as which has the animations of the ball rising up. Each element as[i] has the animation of the ball rising up for the same amount of time. This is visualized here:
1: 
2: *as[0]===*
3: *as[1]===*
4: *as[2]===*
5: *as[3]===*
6: *as[4]===*
7: 

§ Step 2

Next, each element as[i] is modified by creating a new animation xs[i]. xs[i] runs as[i] after a delay of delta*i. We then compose all the xs[i] in parallel to create a single animation x. This animation has the balls rising from the bottom in a staggered fashion.
1: 
2:   | |xs[0] = -delay=0-*as[0]===*
3:   |P|xs[1] = -delay=1------*as[1]===*
4: x=|A|xs[2] = -delay=2-----------*as[2]===*
5:   |R|xs[3] = -delay=3----------------*as[3]===*
6:   | |xs[4] = -delay=4--------------------*as[4]===*
7: 

§ Step 3

Next, we similarly create an array of animations called bs which has animations of the balls disappearing. These are staggered as before. This is shown here:
1: 
2:   | |ys[0] = -delay=0-*bs[0]===*
3:   |P|ys[1] = -delay=1------*bs[1]===*
4: y=|A|ys[2] = -delay=2-----------*bs[2]===*
5:   |R|ys[3] = -delay=3----------------*bs[3]===*
6:   | |ys[4] = -delay=4--------------------*bs[4]===*
7: 

§ Step 4

Finally, we compose x and y, such that y is staggered relative to x by some delay. This allows the first few balls to start disappearing while new balls continue entering.
01: 
02:      |  | |xs[0] = -delay=0-*as[0]===*
03:      |  |P|xs[1] = -delay=1------*as[1]===*
04:      |x=|A|xs[2] = -delay=2-----------*as[2]===*
05:      |  |R|xs[3] = -delay=3----------------*as[3]===*
06:      |  | |xs[4] = -delay=4--------------------*as[4]===*
07:      |                 | |ys[0] = -delay=0-*bs[0]===*
08:      |                 |P|ys[1] = -delay=1------*bs[1]===*
09: anim=|---delay-------y=|A|ys[2] = -delay=2-----------*bs[2]===*
10:      |                 |R|ys[3] = -delay=3----------------*bs[3]===*
11:      |                 | |ys[4] = -delay=4--------------------*bs[4]===*
12: 
Drag the slider to move through the animation!

§ Reflection

Notice that the final animation network is quite complex. It's hopeless to build it "manually". In code, we write special helpers called anim_stagger that allow us to stagger animation, and then use it, along with .seq() and .par() to build the full animation:
1: 
2: const anim = anim_parallel_list(anim_circles_start)
3:     .seq(anim_stagger([anim_stagger(anim_circles_enter, STAGGER),
4:                        anim_stagger(anim_circles_leave, STAGGER)], 300));
5: 
This describes the complicated network:
01: 
02:      |  | |xs[0] = -delay=0-*as[0]===*
03:      |  |P|xs[1] = -delay=1------*as[1]===*
04:      |x=|A|xs[2] = -delay=2-----------*as[2]===*
05:      |  |R|xs[3] = -delay=3----------------*as[3]===*
06:      |  | |xs[4] = -delay=4--------------------*as[4]===*
07:      |             | |bs[0] = -delay=0-*as[0]===*
08:      |             |P|bs[1] = -delay=1------*as[1]===*
09: anim=|---delay---y=|A|bs[2] = -delay=2-----------*as[2]===*
10:      |             |R|bs[3] = -delay=3----------------*as[3]===*
11:      |             | |bs[4] = -delay=4--------------------*as[4]===*
12: 

§ Declarative ⇒ Debuggable

As hinted above, since our specification of the animation was entirely declarative, it can't really "do anything else" like manipulate the DOM. This gives us fantastic debugging and editing capabilities. As it's "just" a mathematical function:
1: 
2: anim_circle: (t:Time) -> (cx: float, cr: float)
3: 
so we can play with it on the console, edit it interactively, and plot it. It's behaviour can be studied on a piece of paper, since it's entirely decoupled from the real world.

§ The power of easing

So far, we have been using the same easing parameter everywhere: easing_cubic. This parameter is a way to warp time. We only tell the library what the final value is supposed to be. It's our library's job to figure out how to get from the current value to the final value. However, there are many ways to get from the initial value to the final value. We could: There are many easing functions. Indeed, infinitely many, since we can write any function we want. A quick example of the three mentioned above, with a slide to notice the difference:
Drag the slider to move through the animation!

§ Code for easing:

01: 
02: const interpolators = [ease_cubic, ease_linear, ease_out_back]
03: for(var i = 0; i < NINTERPOLATORS; ++i) {
04:     ...
05:     anim_circles_start.push(anim_const("cx" + i, 200));
06:     anim_circles_enter.push(anim_interpolated(interpolators[i], "cx" + i, 300, 200));
07: }
08: ...
09: const anim = anim_parallel_list(anim_circles_start).seq(anim_parallel_list(anim_circles_enter));
10: 
11: 

§ minanim.js versus the world

Both d3.js and anime.js are libraries that intertwine computing with animation. On the other hand, our implementation describes only how values change. It's up to us to render this using SVG/canvas/what-have-you. Building a layer like anime.js on top of this is not hard. On the other hand, using anime.js purely is impossible.

§ Code Walkthrough / API documentation

The entire "library", which is written very defensively and sprinkled with asserts fits in exactly 100 lines of code . It can be golfed further at the expense of either asserts, clarity, or by adding some higher-order functions that factor out some common work. I was loath to do any of these. So here's the full source code, explained as we go on.
01: 
02: // t, tstart: number. out: object
03: function assert_precondition(t, out, tstart) {
04:     console.assert(typeof(t) === "number");
05:     if (out === undefined) { out = {}; }
06:     console.assert(typeof(out) === "object");
07:     if (tstart === undefined) { tstart = 0; }
08:     else { console.assert(typeof(tstart) === "number"); }
09:     console.assert(t >= tstart);
10:     return [out, tstart];
11: }
12: 
01: 
02: // duration: number
03: function anim_delay(duration) {
04:     console.assert(typeof(duration) === "number");
05:     let f = function(t, out, tstart) { 
06:         [out, tstart] = assert_precondition(t, out, tstart); return out;
07:     }
08:     f.duration = duration;
09:     f.par = ((g) => anim_parallel(f, g));
10:     f.seq = ((g) => anim_sequence(f, g));
11:     return f;
12: }
13: 
01: 
02: // field: string. v: number
03: function anim_const(field, v) {
04:     let f = function(t, out, tstart) {
05:         [out, tstart] = assert_precondition(t, out, tstart); out[field] = v; return out;
06:     };
07:     f.duration = 0;
08:     f.par = ((g) => anim_parallel(f, g));
09:     f.seq = ((g) => anim_sequence(f, g));
10:     return f;
11: }
12: 
01: 
02: // vstart, vend: number. tlin: number, 0 <= tlin <= 1
03: function ease_linear(vstart, tlin, vend) { return (1.0 - tlin) * vstart + tlin * vend; }
04: 
05: function ease_cubic(vstart, tlin, vend) {
06:     const cube = (1 - tlin)*(1-tlin)*(1-tlin); return cube * vstart + (1 - cube) * vend;
07: }
08: function ease_out_back(vstart, tlin, vend) {
09:     const c1 = 1.70158; const c3 = c1 + 1; const t = 1 + c3 * pow(x - 1, 3) + c1 * pow(x - 1, 2);
10:     return (1-t) * vstart + t*vend;
11: }
12: 
01: 
02: // fease: easing function.
03: // field: string. vend: number. duration: number >= 0
04: function anim_interpolated(fease, field, vend, duration) {
05:     let f =  function(t, out, tstart) {
06:         [out, tstart] = assert_precondition(t, out, tstart);
07:         if (t < tstart + duration && duration !== 0) {
08:             const tlin = (t - tstart) /duration;
09:             console.assert(tlin >= 0);
10:             console.assert(tlin <= 1);
11:             const vstart = out[field];
12:             out[field] = fease(vstart, tlin, vend);
13:         } else { out[field] = vend; }
14:         return out;
15:     };
16:     f.duration = duration;
17:     f.par = ((g) => anim_parallel(f, g));
18:     f.seq = ((g) => anim_sequence(f, g));
19:     return f;
20: }
21: 
01: 
02: // anim1, anim2: anim
03: function anim_sequence(anim1, anim2) {
04:     const duration = anim1.duration + anim2.duration;
05:     let f =  function(t, out, tstart) {
06:         [out, tstart] = assert_precondition(t, out, tstart);
07:         anim1(t, out, tstart);
08:         if (t >= tstart + anim1.duration) { anim2(t, out, tstart + anim1.duration); }
09:         return out;
10:     }
11:     f.duration = duration;
12:     f.par = ((g) => anim_parallel(f, g));
13:     f.seq = ((g) => anim_sequence(f, g));
14:     return f;
15: }
16: 
01: 
02: // anim1, anim2: anim
03: function anim_parallel(anim1, anim2) {
04:     const duration = Math.max(anim1.duration, anim2.duration);
05:     let f =  function(t, out, tstart) {
06:         [out, tstart] = assert_precondition(t, out, tstart);
07:         if (t >= tstart) { anim1(t, out, tstart); anim2(t, out, tstart); }
08:         return out;
09:     }
10:     f.duration = duration;
11:     f.par = ((g) => anim_parallel(f, g));
12:     f.seq = ((g) => anim_sequence(f, g));
13:     return f;
14: }
15: 
1: 
2: // xs: list[animation]
3: function anim_parallel_list(xs) {
4:     var x = xs[0]; for(var i = 1; i < xs.length; ++i) { x = x.par(xs[i]); }
5:     return x;
6: }
7: 
01: 
02: // xs: list[animation]. delta: duration
03: function anim_stagger(xs, delta) {
04:     console.assert(typeof(delta) == "number");
05:     var ys = [];
06:     for(var i = 0; i < xs.length; ++i) {
07:         ys.push(anim_delay(delta*i).seq(xs[i]));
08:     }
09:     var y = ys[0];
10:     for(var i = 1; i < ys.length; ++i) {
11:         y = y.par(ys[i]);
12:     }
13:     return y;
14: }
15: 

§ Conclusion

We saw how to write a tiny, declarative, composable animation library that does one thing: compose functions that manipulate values over time, and does it well. If you like this content, check out the repo at bollu/mathemagic