Canvas 2D Web Apps/Animations

This chapter explains how to use keyframe animations with cui2d. Specifically, it shows how to change numeric variables according to a predefined array of keyframes. If the changing variables are used in the process function as coordinates, colors, etc., the resulting graphics will appear to be animated. Of course, other ways of implementing animations with the canvas element exist, but the presented approach has some advantages and is extremely flexible.

The Example edit

The example of this chapter (which is available online; also as downloadable version) extends the example of the chapter on responsive buttons. Thus, the following sections only discuss the animation-specific parts of the code; see the chapter on responsive buttons and previous chapters for discussions of other parts.

In the example, three buttons are used to start three different animations. If clicked again, the first button just restarts the animation (even if it is already playing). The second button doesn't restart the animation if it is already playing. Lastly, the third button is inactive while its animation is playing and this state is also visually communicated by drawing it semitransparently, which achieves the effect of “graying out.”

<!DOCTYPE HTML>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
    <meta name="viewport"
      content="width=device-width, initial-scale=1.0, user-scalable=no">

    <script src="cui2d.js"></script>

    <script>
      function init() {
        // get images
        imageNormalButton.src = "normal.png";
        imageNormalButton.onload = cuiRepaint;
        imageFocusedButton.src = "selected.png";
        imageFocusedButton.onload = cuiRepaint;
        imagePressedButton.src = "depressed.png";
        imagePressedButton.onload = cuiRepaint;
        imageAlien.src = "alien_smiley.png";
        imageAlien.onload = cuiRepaint;

        // set defaults for all pages
        cuiBackgroundFillStyle = "#000000";
        cuiDefaultFont = "bold 20px Helvetica, sans-serif";
        cuiDefaultFillStyle = "#FFFFFF";

        // initialize cui2d and start with myPage
        cuiInit(myPage);
      }

      // create images for the buttons
      var imageNormalButton = new Image();
      var imageFocusedButton = new Image();
      var imagePressedButton = new Image();
      var imageAlien = new Image();

      // create buttons
      var button0 = new cuiButton();
      var button1 = new cuiButton();
      var button2 = new cuiButton();

      // create new arrays of keyframes (arrays of cuiKeyframe Objects)
      var widthAndHeightKeys = [ // keyframes for width and height
        {time : 0.00,          out : -1, values : [125, 158]}, // start fast
        {time : 0.25, in :  1, out :  1, values : [100, 190]}, // turn smoothly
        {time : 0.80, in :  1, out :  1, values : [150, 126]}, // turn smoothly
        {time : 1.50, in :  0,           values : [125, 158]}  // end slowly
      ];
      var yKeys = [ // keyframes for y coordinate
        {time : 0.00,          out : -3, values : [200]}, // start extra fast
        {time : 0.50, in :  1, out :  1, values : [ 50]}, // turn smoothly
        {time : 1.00, in : -3,           values : [200]}  // end extra fast
      ];
      var xAndAngleKeys = [ // keyframes for x coordinate and angle (in degrees)
        new cuiKeyframe(0.00, 0, 0, [190,   0]), // start slowly
        new cuiKeyframe(1.00, 1, 1, [90,  -20]), // turn smoothly
        new cuiKeyframe(2.00, 1, 1, [290, +20]), // turn smoothly
        new cuiKeyframe(3.00, 0, 0, [190,   0])  // end slowly
      ];

      // create new animations
      var widthAndHeightAnimation = new cuiAnimation();
      var yAnimation = new cuiAnimation();
      var xAndAngleAnimation = new cuiAnimation();

      // create a new page of size 400x300 and attach myPageProcess
      var myPage = new cuiPage(400, 300, myPageProcess);

      // a function to repaint the canvas and return false (if null == event)
      // or to process user events (if null != event) and return true
      // if the event has been processed
      function myPageProcess(event) {

        // draw animated image
        if (null == event) {
          var xAndAngle = [190, 0];
          if (xAndAngleAnimation.isPlaying()) {
            xAndAngle = xAndAngleAnimation.animateValues();
          }
          var x = xAndAngle[0];
          var angle = xAndAngle[1];

          var y = 200;
          if (yAnimation.isPlaying()) {
            y = yAnimation.animateValues()[0];
          }

          var widthAndHeight = [125, 158];
          if (widthAndHeightAnimation.isPlaying()) {
            widthAndHeight = widthAndHeightAnimation.animateValues();
          }
          var width = widthAndHeight[0];
          var height = widthAndHeight[1];

          cuiContext.save(); // save current coordinate transformation

          // read the following three lines backwards, starting with the last
          cuiContext.translate(x, y); // translate pivot point back to original position
          cuiContext.rotate(angle * Math.PI / 180.0); // rotate around pivot point
          cuiContext.translate(-x, -y); // translate pivot point to origin

          cuiContext.drawImage(imageAlien, x - width/2, y - height/2, width, height);
          cuiContext.restore(); // restore previous coordinate transformation
        }

        // draw and react to buttons

        if (button0.process(event, 40, 50, 90, 50, "wobble",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            widthAndHeightAnimation.play(widthAndHeightKeys, 0.6, false);
              // restart animation even if playing
          }
          return true;
        }

        if (button1.process(event, 150, 50, 80, 50, "jump",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked() && !yAnimation.isPlaying()) {
            // only restart when not playing
            yAnimation.play(yKeys, 1.0, false);
          }
          return true;
        }

        if (!xAndAngleAnimation.isPlaying()) { // usual button
          if (button2.process(event, 250, 50, 80, 50, "rock",
            imageNormalButton, imageFocusedButton, imagePressedButton)) {
            if (button2.isClicked()) {
              xAndAngleAnimation.play(xAndAngleKeys, 0.5, false);
            }
            return true;
          }
        }
        else { // inactive button while animating
          if (null == event) {
            cuiContext.save(); // save current global alpha (and all context settings)
            cuiContext.globalAlpha = 0.2; // use semitransparent rendering
            button2.process(event, 250, 50, 80, 50, "rock",
              imageNormalButton, imageNormalButton, imageNormalButton);
            cuiContext.restore(); // restore previous global alpha
          }
        }

        if (null == event) {
          // draw background
          cuiContext.fillStyle = "#804000";
          cuiContext.fillRect(0, 0, this.width, this.height);
        }

        return false; // event should be further processed
      }
    </script>
  </head>

  <body bgcolor="#000000" onload="init()"
    style="-webkit-user-drag:none; -webkit-user-select:none; ">
    <span style="color:white;">A canvas element cannot be displayed.</span>
  </body>
</html>

Defining Animations edit

The example defines three keyframe animations for animating a single image: widthAndHeightAnimation animates the width and height of the image to create a wobbling effect; yAnimation is used to create an animated jump by changing the y coordinate of the image; and xAndAngleAnimation is used to change the x coordinate and a rotation angle of the image to create a rocking motion:

      // create new animations
      var widthAndHeightAnimation = new cuiAnimation();
      var yAnimation = new cuiAnimation();
      var xAndAngleAnimation = new cuiAnimation();

Additionally, three keyframe arrays (one for each animation) are defined by these lines:

      // create new arrays of keyframes (arrays of cuiKeyframe Objects)
      var widthAndHeightKeys = [ // keyframes for width and height
        {time : 0.00,          out : -1, values : [125, 158]}, // start fast
        {time : 0.25, in :  1, out :  1, values : [100, 190]}, // turn smoothly
        {time : 0.80, in :  1, out :  1, values : [150, 126]}, // turn smoothly
        {time : 1.50, in :  0,           values : [125, 158]}  // end slowly
      ];
      var yKeys = [ // keyframes for y coordinate
        {time : 0.00,          out : -3, values : [200]}, // start extra fast
        {time : 0.50, in :  1, out :  1, values : [ 50]}, // turn smoothly
        {time : 1.00, in : -3,           values : [200]}  // end extra fast
      ];
      var xAndAngleKeys = [ // keyframes for x coordinate and angle (in degrees)
        new cuiKeyframe(0.00, 0, 0, [190,   0]), // start slowly
        new cuiKeyframe(1.00, 1, 1, [90,  -20]), // turn smoothly
        new cuiKeyframe(2.00, 1, 1, [290, +20]), // turn smoothly
        new cuiKeyframe(3.00, 0, 0, [190,   0])  // end slowly
      ];

Each array is defined with the syntax [ 0th keyframe , 1st keyframe , ... ]. The first two arrays define the individual keyframes as objects with the properties time, in, out, and values. However, the in property of the 0th keyframe and the out property of the last keyframe is not required. time is the time in seconds of the keyframe after the start of the animation. Note that keyframes have to be specified in ascending order of their times. in and out define the velocities of change (i.e. the tangents or slopes) with which values are changing right before and right after each keyframe. The most important choices are:

  • 0 for zero velocity, i.e. slow in/out (also known as ease in/out)
  • 1 for the velocity defined by a smooth Catmull-Rom spline (but it is only smooth if in and out of one keyframe are the same)
  • -1 for a constant velocity to the neighboring keyframe (also known as linear interpolation, but it will only be linear interpolation if the corresponding tangent of the neighboring keyframe is also specified by a -1)

For faster or slower velocities, these numbers can be scaled; i.e. a value of 0.5 specifies half the velocity that the Catmull-Rom spline would use. A value of -3 specifies three times the velocity that a linear interpolation would use. In between the keyframes, a cubic Hermite spline is applied. (Catmull-Rom splines and linear interpolation are both special cases of the cubic Hermite spline.)

The values property is an array of the actual data values at the keyframe. Their actual meaning (whether they are coordinates, color components, or sizes etc.) depends on how the animated values are actually used later on. In this example, yKeys specifies keyframes only for the y coordinate of an image; therefore, the values properties of the keyframes are arrays of just one number, e.g. [200] for an array with one coordinate of value 200 pixels. xAndAngleKeys animate an x coordinate and a rotation angle at the same time; therefore, the values properties are arrays of two elements. For example, [90, -20] specifies an array of two elements, where the first one will be interpreted as an x coordinate of value 90 pixels and the second as an rotation angle of value -20 degrees.

The third array of keyframes xAndAngleKeys uses the constructor cuiKeyframe(time, in, out, values) to construct objects with the properties time, in, out, and values. It is up to you, which way of initializing keyframes you prefer.

Starting Animations edit

After animations have been defined as discussed in the previous section, they can be played (when processing events) with the function play(keyframes, stretch, isLooping).

This function sets the properties keyframes, stretch and isLooping with the specified arguments. In the example, the keyframes arguments are widthAndHeightKeys, xAndAngleKeys, and yKeys for the three animations. The second argument (which determines the stretch property) is a factor to make the animation longer than defined by the keyframes (with a factor greater than 1), or shorter (with a factor less than 1). This is useful in order to change the overall speed of an animation without changing the time coordinates of all keyframes. The third argument allows to play the animation in an endless loop. (Which can be stopped with the function stopLooping()).

In the example, the first button just restarts the animation widthAndHeightAnimation whenever the button is clicked:

        if (button0.process(event, 40, 50, 90, 50, "wobble",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button0.isClicked()) {
            widthAndHeightAnimation.play(widthAndHeightKeys, 0.6, false); 
              // restart animation even if playing
          }
          return true;
        }

The second button checks whether the animation is not playing (with isPlaying) before restarting the animation:

        if (button1.process(event, 150, 50, 80, 50, "jump",
          imageNormalButton, imageFocusedButton, imagePressedButton)) {
          if (button1.isClicked() && !yAnimation.isPlaying()) { 
            // only restart when not playing 
            yAnimation.play(yKeys, 1.0, false);
          }
          return true;
        }

Thus, the animation cannot be restarted while it is playing. In a sense, this makes the button inactive while the animation is playing. To communicate inactive buttons, many GUIs “gray out” these buttons. The third button achieves a similar effect by using semitransparent rendering and only drawing the image imageNormalButton:

        if (!xAndAngleAnimation.isPlaying()) { // usual button 
          if (button2.process(event, 250, 50, 80, 50, "rock",
            imageNormalButton, imageFocusedButton, imagePressedButton)) {
            if (button2.isClicked()) {
              xAndAngleAnimation.play(xAndAngleKeys, 0.5, false);
            }
            return true;
          }
        }
        else { // inactive button while animating
          if (null == event) {
            cuiContext.save(); // save current global alpha (and all context settings)
            cuiContext.globalAlpha = 0.2; // use semitransparent rendering
            button2.process(event, 250, 50, 80, 50, "rock",
              imageNormalButton, imageNormalButton, imageNormalButton);
            cuiContext.restore(); // restore previous global alpha
          }
        }

cuiContext.save() saves the current global alpha (which controls the opaqueness of the draw commands), cuiContext.globalAlpha = 0.2; sets the global alpha to a rather low value, i.e. the opaqueness is strongly reduced, i.e. the following images are rendered almost transparently. After the button is drawn with process(), the global alpha is restored again with cuiContext.restore();. Note that process() is only called if event is null, which just means that the button is inactive and doesn't react to events.

Animating Values edit

The values of the keyframes of an animation are animated (i.e. interpolated for the current time) with the function animateValues(). The result of animateValues() is an array of values just like the ones that are specified in the array of keyframes but with the interpolated values for the current time. In the example, this is used this way:

        // draw animated image 
        if (null == event) {
          var xAndAngle = [190, 0];
          if (xAndAngleAnimation.isPlaying()) {
            xAndAngle = xAndAngleAnimation.animateValues();
          } 
          var x = xAndAngle[0];
          var angle = xAndAngle[1];

          var y = 200;
          if (yAnimation.isPlaying()) {
            y = yAnimation.animateValues()[0];
          }

          var widthAndHeight = [125, 158];
          if (widthAndHeightAnimation.isPlaying()) {
            widthAndHeight = widthAndHeightAnimation.animateValues();
          }
          var width = widthAndHeight[0];
          var height = widthAndHeight[1];
          ...

Each call to animateValues() returns an array of the current values of animated values. From these arrays, individual elements are extracted and assigned to certain variables (here: x, angle, y, width, and height). These variables are then used to draw the animated image. Note that by simply redrawing the canvas in every frame of the animation, complex animations are much easier to implement than if we had to individually modify all animated attributes of all animated objects.

The actual drawing of the animated image in the example is defined next:

          ...
          cuiContext.save(); // save current coordinate transformation

          // read the following three lines backwards, starting with the last
          cuiContext.translate(x, y); // translate pivot point back to original position
          cuiContext.rotate(angle * Math.PI / 180.0); // rotate around pivot point
          cuiContext.translate(-x, -y); // translate pivot point to origin

          cuiContext.drawImage(imageAlien, x - width/2, y - height/2, width, height);
          cuiContext.restore(); // restore previous coordinate transformation
        }

The most important line (and the only line that actually draws something) is this:

          cuiContext.drawImage(imageAlien, x - width/2, y - height/2, width, height);

It draws the image imageAlien centered at coordinates x and y at a size of width × height. (The coordinates x - width/2 and y - height/2 specify the top, left corner.) Since the values of x, y, width, and height are all varying with time, this covers most of the animation. The rest of the code is only required to handle the rotation by angle.

To this end, we apply the line cuiContext.rotate(angle * Math.PI / 180.0); which tells cuiContext to rotate all following drawings by angle. (The multiplication with Math.PI / 180.0 transforms a value in degrees to radians.) However, this rotation is always around the origin of the coordinate system. Thus, we have to translate (i.e. move) the pivot point of the rotation, which is at coordinates x and y in this example, to the origin of the coordinate system (i.e. to coordinates x=0 and y=0) in order to rotate around it. This is achieved with the line cuiContext.translate(-x, -y);. After the rotation, we have to move the pivot point back to its original position with cuiContext.translate(x, y);. If you look at the code, you'll see that these translations actually have to be specified in the reverse order. (If you want to read the transformations in the order they are specified, you have to think about how the coordinate system is transformed: cuiContext.translate(x, y); moves the origin of the coordinate system to our pivot point, then we rotate around the origin (at the new position of our pivot point), and then we move the origin back to its original position.)

Since we don't want this rotation to affect any further drawing commands, we have to restore the standard transformation again. The best way to do this, is to first save the current settings (i.e. state) of the context (which includes the transformation but also the fill color, font settings, etc.) with cuiContext.save(); and once we are done with drawing, these settings can be restored with cuiContext.restore(); .

This completes the discussion of how an application programmer would use the animation system of the example. The next section discusses, how it is implement in cui2d.js.

Implementing the Animation System edit

First, the constructors simply set the properties of the cuiKeyframe and cuiAnimation objects:

/**
 * @class cuiKeyframe
 * @classdesc A keyframe defines an array of numeric values at a certain time with tangents for the interpolation 
 * right before and right after that time. Instead of using the constructor, objects can also be initialized
 * with "{time : ..., in : ..., out : ..., values : [..., ...]}"
 * (See {@link cuiAnimation}.)
 * 
 * @desc Create a new cuiKeyframe.
 * @param {number} time - The time of the keyframe (in seconds relative to the start of the animation). 
 * @param {number} inTangent - Number specifying the tangent before the keyframe; -1: linear interpolation,  
 * 0: horizontal tangent, 1: cubic Hermite, others: scaled slope of +1/-1 cases. 
 * @param {number} outTangent - Number specifying the tangent after the keyframe; -1: linear interpolation, 
 * 0: horizontal tangent, 1: cubic Hermite, others: scaled slope of +1/-1 cases. 
 * @param {number[]} values - An array of numbers; all keyframes of one animation should have 
 * values arrays of the same size.
 */
function cuiKeyframe(time, inTangent, outTangent, values) {
  /** 
   * The time of the keyframe (in seconds relative to the start of the animation). 
   * @member {number} cuiKeyframe.time 
   */
  this.time = time;
  /** 
   * Number specifying the tangent before the keyframe; -1: linear interpolation,  
   * 0: horizontal tangent, 1: cubic Hermite, others: scaled slope of +1/-1 cases. 
   * @member {number} cuiKeyframe.in 
   */
  this.in = inTangent;
  /** 
   * Number specifying the tangent after the keyframe; -1: linear interpolation,  
   * 0: horizontal tangent, 1: cubic Hermite, others: scaled slope of +1/-1 cases. 
   * @member {number} cuiKeyframe.out 
   */
  this.out = outTangent;
  /** 
   * An array of numbers; all keyframes of one animation should have 
   * values arrays of the same size.
   * @member {number[]} cuiKeyframe.values 
   */
  this.values = values;
}

/**
 * @class cuiAnimation
 * @classdesc Animations allow to animate (i.e. interpolate) numbers specified by keyframes.
 * (See {@link cuiKeyframe}.)
 *
 * @desc Create a new cuiAnimation.
 */
function cuiAnimation() {
  this.keyframes = null;
  this.stretch = 1.0;
  this.start = 0;
  this.end = 0;
  this.isLooping = false;
}

As mentioned, the function play() starts an animation and stopLooping() stops the looping:

/** 
 * Play an animation. 
 * @param {cuiKeyframe[]} keyframes - An array of keyframe objects. (Object initialization with 
 * something like var keys = [{time : ..., in : ..., out : ..., values : [..., ...]}, {...}, ...];
 * is encouraged.) (See {@link cuiKeyframe}.)
 * @param {number} stretch - A scale factor for the times in the keyframes; 
 * one way of usage: start designing keyframe times with stretch = 1 and 
 * adjust the overall timing at the end by adjusting stretch;
 * another way of usage: define all times of keyframes between 0 and 1 (as in CSS transitions) 
 * and then set stretch to the length of the animation in seconds.
 * @param {boolean} isLooping - Whether to repeat the animation endlessly.
 */
cuiAnimation.prototype.play = function(keyframes, stretch, isLooping) {
  this.keyframes = keyframes;
  this.stretch = stretch;
  this.isLooping = isLooping;
  this.start = (new Date()).getTime();
  this.end = this.start +
    1000.0 * this.keyframes[this.keyframes.length - 1].time * this.stretch;
  if (this.end > cuiAnimationsEnd) { // new maximum end?
    cuiAnimationsEnd = this.end;
  }
  cuiRepaint();
}

/**
 * Stop looping the animation.
 */
cuiAnimation.prototype.stopLooping = function() {
  this.isLooping = false;
}

Basically, play() just sets the properties start and end. Both are times in milliseconds since January 1st, 1970. The values are based on the current time (for start) and additionally the time property of the last keyframe scaled by stretch (and converted from seconds after the start of the animation to milliseconds after January 1, 1970). Furthermore, the global variable cuiAnimationsEnd might be set if necessary. cuiAnimationsEnd specifies the time (in milliseconds after January 1, 1970) when all animations are over. Thus, it only needs to be updated if the current animation is supposed to stop after all other animations. Lastly, cuiRepaint() is called to request a redraw as soon as possible.

We also define a helper function isPlaying() that returns true or false to specify whether a specific animation is currently playing (see above for examples how to use it):

/** 
 * Determine whether the animation is currently playing. 
 * @returns {boolean} True if the animation is currently playing, false otherwise.
 */
cuiAnimation.prototype.isPlaying = function() {
  if (!this.isLooping) {
    return ((new Date()).getTime() < this.end);
  }
  else {
    return (this.end > 0);
  }
}

In order to continuously repaint the canvas, the render loop is checking whether any animation is playing:

// Render loop of cui2d, which calls cuiProcess(null) if needed. 
function cuiRenderLoop() {
  var now = (new Date()).getTime();
  if (cuiAnimationsEnd < now ) { // all animations over?
    if (cuiAnimationsArePlaying) {  
      cuiRepaint();
      // repaint one more time since the rendering might differ
      // after the animations have stopped
    }
    cuiAnimationsArePlaying = false; 
  }
  else {
    cuiAnimationsArePlaying = true;
  }

  if (cuiCanvasNeedsRepaint || cuiAnimationsArePlaying) {
    cuiProcess(null);
  }
  window.setTimeout("cuiRenderLoop()", cuiAnimationStep); // call myself again
    // using setTimeout allows to easily change cuiAnimationStep dynamically
}

It checks whether any animation is playing by comparing cuiAnimationsEnd with the current time and updates the global variable cuiAnimationsArePlaying accordingly. Note that cuiRepaint() is called when the animations just stopped. This is required because the canvas might change after an animation has stopped. If cuiAnimationsArePlaying is true, a new frame is rendered by calling cuiProcess(null).

Apart from the actual computation of the animated values (which is discussed next), this completes the description of the animation system. Note that the system allows for restarting animations that are already playing, it allows for any number of animations playing at the same time, it allows for the animation of an unlimited number of values with a single array of keyframes, and it allows to employ arbitrary cubic Hermite splines for the animation of values while making it easy to specify slow in/out motions (by setting the in and out properties of specific keyframes to 0), Catmull-Rom splines (by setting all in and out properties to 1) and linear interpolation (by setting them all to -1). The last feature is implemented by the interpolation of values, which is discussed next.

The values of the keyframes in an animation are animated (i.e. interpolated for the current time) by the function animateValues(). As mentioned, the result of animateValues() is an array of values just like the ones that are specified in the array of keyframes but with the interpolated values for the current time. The implementation of animateValues() basically evaluates a cubic Hermite spline for every element of the values array with scaled slopes from Catmull-Rom splines or from linear interpolation (depending on the sign of the in and out properties). Unfortunately, the evaluation of splines is somewhat tedious and the mixing of Catmull-Rom splines with linear interpolation doesn't make it easier; thus, we are not going to discuss this code in detail.

However, there is an important feature at the start of the function: it first checks whether the animation hasn't started yet or whether the start and end time don't make sense (which is the case after they are both initialized to 0). In this case, the values array of the 0th keyframe is returned. Then it checks whether the animation is already over. In that case, the values array of the last keyframe is returned. This feature makes it possible to call animateValues() even if the animation is not playing and this is actually exploited in the example of the three animations described above.

/** 
 * Compute an array of interpolated values based on the keyframes and the current time. 
 * Returns the values array of the 0th keyframe if the animation hasn't started yet 
 * and the values array of the last keyframe if it has finished playing. 
 * This makes it possible to use animateValues even after the animation has stopped. 
 * (See {@link cuiKeyframe}.)
 * @returns {number[]} An array of interpolated values.
 */
cuiAnimation.prototype.animateValues = function() { 
  var now = (new Date()).getTime();
  if (now < this.start || this.end <= this.start) { // animation not started?
    return this.keyframes[0].values; 
  }
  if (now > this.end) { // current loop of animation already over?
    if (!this.isLooping) {
      return this.keyframes[this.keyframes.length - 1].values; 
    }
    // restart the animation
    var length = 1000.0 * this.keyframes[this.keyframes.length - 1].time * this.stretch;
    this.start = this.start + Math.floor((now - this.start) / length) * length;
    this.end = this.start + length;
    if (this.end > cuiAnimationsEnd) { // new maximum end?
      cuiAnimationsEnd = this.end;
    }
  }

  // determine index iTo of keyframe after(!) current time t
  var iTo = 0;
  var ut = 0.001 * (now - this.start) / this.stretch;
    // unstretched time relative to animation start in seconds
  while (iTo < this.keyframes.length &&
    this.keyframes[iTo].time < ut) {
    iTo = iTo + 1;
  }
  var iFrom = iTo - 1; // index of keyframe before t
  if (iTo == 0) {
    return this.keyframes[0].values;
  }
  if (iTo >= this.keyframes.length) {
    return this.keyframes[this.keyframes.length - 1].values;
  }
  // interpolate each value
  var newValues = this.keyframes[iFrom].values.slice(0);
  var t0 = this.keyframes[iFrom].time;
  var t1 = this.keyframes[iTo].time;
  var t = (ut - t0) / (t1 - t0)
  var tt = t * t;
  var ttt = tt * t;
  for (var iValue = 0; iValue < newValues.length; iValue++) {
    // compute values for cubic Hermite spline with out/in determining
    // the velocity: out/in = -1: linear, out/in = 0: slow (i.e. 0),
    // out/in = 1: smooth (Catmull-Rom spline).
    // The magnitude of in/out changes the velocity accordingly.
    var p0, p1, m0, m1;
    p0 = this.keyframes[iFrom].values[iValue];
    p1 = this.keyframes[iTo].values[iValue];
    // compute out slope m0 at iFrom
    if (this.keyframes[iFrom].out < 0.0) { // linear
      m0 = (p1 - p0) / (t1 - t0) * (-this.keyframes[iFrom].out);
    }
    else if (iFrom > 0) { // smooth, not in first interval
      m0 = (p1 - this.keyframes[iFrom - 1].values[iValue]) /
        (t1 - this.keyframes[iFrom - 1].time) *
        this.keyframes[iFrom].out;
    }
    else { // smooth, in first interval
      m0 = (p1 - p0) / (t1 - t0) * this.keyframes[iFrom].out;
    } 
    // compute in slope m1 at iTo
    if (this.keyframes[iTo].in < 0.0) { // linear
      m1 = (p1 - p0) / (t1 - t0) * (-this.keyframes[iTo].in);
    }
    else if (iTo < this.keyframes.length - 1) { // smooth, not last interval
      m1 = (this.keyframes[iTo + 1].values[iValue] - p0) /
        (this.keyframes[iTo + 1].time - t0) *
        this.keyframes[iTo].in;
    }
    else { // smooth, in last interval
      m1 = (p1 - p0) / (t1 - t0) * this.keyframes[iTo].in;
    } 
    // cubic Hermite curve interpolation
    newValues[iValue] =  (2.0*ttt-3.0*tt+1.0) * p0 +
      (ttt-2.0*tt+t)*(t1-t0) * m0 +
      (-2.0*ttt+3.0*tt) * p1 +
      (ttt-tt) * (t1-t0) * m1;
  }
  return newValues;
}


< Canvas 2D Web Apps

Unless stated otherwise, all example source code on this page is granted to the public domain.