MISCELLANEOUS TECHNICAL ARTICLES BY Dr A R COLLINS

JavaScript Animation

Object movement

JavaScript makes webpage content dynamic, allowing user interaction and animation. The principle of JavaScript animation is rather simple; webpage elements, most often images, have their style or position within the page altered by a script. Repeating this process at sufficiently short intervals has the effect of simulating smooth motion of the element about the page.

Each of the elements comprising a webpage is defined by an object (a data storage structure) forming part of the page's Document Object Model (DOM). The elements rendered on the screen are all rectangles defined by their width and height, their position is set by the coordinates of the element's upper left corner. To manipulate these values, a reference to the element's DOM object must be generated. The simplest way to do this is to make sure the element has an 'id' attribute and then use the document.getElementById method.

The image of the red ball on the right had id="ball" and the JavaScript to reference its DOM object and change its left and top style values is as follows:

red ball

Run

function moveBall()
{
  var ballObj = document.getElementById("ball");

  ballObj.style.left = "70px";
  ballObj.style.top = "80px";
}

Click the "Run" button to try the code.

Continuing movement

To get repetitive movement, the window.setTimeout method can be used. This method takes a reference to a function and a time delay (in milliseconds) as parameters. setTimeout waits for the delay time and calls the function. The drawing having been done, the setTimout can be called again and so on.

In this example the stepBall2() function is called every 100msec. The global variable s stores the number of steps the ball has taken along its animation path. The function stepBall2() calculates the new values for the left and top coordinates of the ball for the current step number, and then calls moveBall2() to actually make the image of the ball move. The step number, s is incremented ready for the next call to stepBall() by the setInterval timer. Finally a check is made to stop and reset the animation after 13 steps.

red ball

Run

function moveDomObj(id, left, top)
{
  const domObj = document.getElementById(id);

  domObj.style.left = left+"px";
  domObj.style.top = top+"px";
}

var timer2 = null;
var s = 0;

function stepBall2()
{
  const x = 50+4*s;
  const y = 35+1.6*s*s;   // a parabolic path y=x*x
  moveDomObj("ball2", x, y);
  s++;
  if (s<14)
  {
    timer2 = setTimeout(stepBall2, 100);
  }
  else
  {
    s = 0;     // so we can do it again
  }
}

function startBall2()
{
  timer2 = setTimeout(stepBall2, 100);
}

Click the "Run" button to run the function 'startBall2()'.

Using window.requestAnimationFrame

The setTimeout utility is not ideal for generating smooth continuous animation, the operating system has so many tasks competing for attention timeout servicing can be delayed and the assumption that the frames are equally spaced breaks down and at worst timeouts may stack up so that the animation jitters. To address the requirements of animation, the window method requestAnimationFrame has been implemented by all the major browsers.

requestAnimationFrame (rAF) attempts to draw the frame at a rate of about 60 frames/sec, matching the screen refresh rate. In so doing, it avoids wasting CPU time switching among competing interrupts, allowing more efficient use of the hardware resources. The major difference to code using rAF rather than timeout is rAF frame rate should not be assumed a priori. rAF will pass the callback a time value (in msec) representing the time relative to an arbitrary start time, this can be used to calculate the elapsed time since the last frame and the amount of movement set accordingly.

Circular Motion using requestAnimationFrame

Here is a simple example of continuous motion using the requestAnimationFrame method. The image of the red ball is moved in circular orbit. The Start button makes an initial call to rAF and then, the 'circularPath' callback moves the ball and then calls rAF again, providing the 'playing' flag is still true. The Pause button simply sets the 'playing' flag to false, halting the animation.

The only subtlety in the code is the manipulation of the 'currTime' variable which saves the time at which the currently displayed frame was drawn. If resuming from a pause, currTime is updated with the Date.now() function so there is no discontinuity in the motion due to the time lost while paused.

red ball

Start

Pause

Here is all the source code of the circular motion example.

var cm_tline = null;

function Orbit(objId)
{
  const elem = document.getElementById(objId),
        radius = 80,
        va = 3,             // angular velocity, 3 radians / sec
        cx = 120,           // coordinates of orbit center
        cy = 120;
  let savTime = 0,       // time when last frame was drawn
      ang = 0,
      playing = false;

  function circularPath(){
    let currTime = Date.now(),
        dt = currTime - savTime;      // time since last frame

    ang += va*dt/1000;                // angle moved at constant angular velocity
    if (ang > 2*Math.PI){              // wraparound for angle
      ang -= 2*Math.PI;
    }
    const x = cx + radius * Math.cos(ang);  // calculate coords of ball
    const y = cy + radius * Math.sin(ang);
    elem.style.left = x + "px";       // move the element
    elem.style.top = y + "px";

    savTime = currTime;               // save the time this frame is drawn
    if (playing){
      requestAnimationFrame(circularPath);
    }
  }

  this.play = function(){
    playing = true;
    savTime = Date.now();             // reset to avoid a jump in angle
    requestAnimationFrame(circularPath);
  };

  this.pause = function(){
    playing = false;                  // stop after next frame
  };
}

Timeline JavaScript library

For more complex animations where multiple objects are animated and may interact, a timeline is still required to coordinate the animations.

Each Animator object takes a user defined function drawFn function, to render the object in each frame along the timeline and a pathFn function that generates new properties such as position, color etc of the animated objects for each frame.

The pathFn and drawFn are called in the scope of the Animator object so they have access to currState and nextState.

The basic timeline functionality is provided by the small 'Timeline' JavaScript library. Each Timeline object has play(), stop(), pause() and step() methods which control a single Animator object or an array of Animator objects.

The current version of Timeline is 4v00, and the source code is available at Timeline-4v00.js.

Animation example using Timeline

In this animation, the pathFn simulates freefall under gravity with reflection off the boundary walls. The ball is given randomized initial trajectory after each time its stops.

blue ball

Stop

Step

Pause

Play

The path function in this example is the bouncingPath. This pathFn function requires a knowledge of the previous state to calculate the next. For this type of pathFn Animator provides the 'currState' and 'nextState' objects as properties. All the newly calculated values that 'pathFn' creates can be saved in this.nextState object. After each animated object is drawn the 'currState' and 'nextState' objects are swapped so that the 'as drawn' properties will be available to the pathFn function in 'this.currState' when it is called for the next frame. The currState object should be treated as read-only, read its properties, calculate new values and save them in 'nextState' properties, then apply the changes to the properties of the object to be drawn.

Optional parameters can be passed to the Animator constructor as properties of the 'options' argument. The options object is passed to the drawFn, and pathFn functions in the 'this.options' property.

Here is the code for the bouncing ball example which relies on the currState, nextState objects.

  var bb_tline;

  function animDemo(elemId)
  {
    const elem = document.getElementById(elemId),
          elementWidth = elem.offsetWidth,
          elementHeight = elem.offsetHeight,
          leftWall = 0,     // containing box walls
          rightWall = elem.parentNode.clientWidth,
          topWall = 0,
          bottomWall = elem.parentNode.clientHeight;
    const data = {startX: 120,
                  startY: 75,
                  startVel: 1,
                  coeff: 0.90,       // percentage bounce height
                  friction: 0.98};   // rolling friction loss coefficient

    function bouncingPath(time, options) 
    {
      const reflect = -1,
            px_mm = 2.0,      // 90dpi = 90px/25.4mm = 3.5 (slow it to 2)
            gravity = 0.0098 * px_mm; // gravity =9.8m/s/s =0.0098mm/ms/ms =0.0098*px/ms/ms

      // 'this' refers to the Animator object
      if (time == 0)   // generate random start condition for each reset
      {
        // restart at at a random angle
        const startAngle = -(90 * Math.random() + 45);  // shoot upward
        // speed m/s = speed mm/ms = speed mm/ms * px/mm = speed * px_mm
        const vel = options.startVel*px_mm;

        this.nextState.x = options.startX;  // start position (120, 75)
        this.nextState.y = options.startY;
        this.nextState.vx = vel * Math.cos(startAngle * Math.PI / 180);
        this.nextState.vy = vel * Math.sin(startAngle * Math.PI / 180);
        // return the object to be animated
        return;
      }
      // calculate the new position and velocity
      const timeInt = time - this.currState.time;   // time since last draw
      let yVel = this.currState.vy + gravity * timeInt;    // accelerating due to gravity
      let xVel = this.currState.vx;                    // constant
      let x = this.currState.x + xVel*timeInt;
      let y = this.currState.y + yVel*timeInt + 0.5*gravity * timeInt * timeInt;
       // now check for hitting the walls
      if (x > rightWall - elementWidth)
      {
        x = rightWall - elementWidth;
        xVel *= reflect*options.coeff;    // lossy reflection next step
      }
      if (x < leftWall)
      {
        x = leftWall;
        xVel *= reflect*options.coeff;    // lossy reflection next step
      }
      if (y < topWall)
      {
        y = topWall;
        yVel *= reflect*options.coeff;    // lossy reflection next step
      }
      if (y > bottomWall - elementHeight)
      {
        y = bottomWall - elementHeight;
        // calc velocity at the floor   (v^2 = u^2 + 2*g*s)
        let s = bottomWall - elementHeight - this.currState.y;     // pre bounce
        let u = this.currState.vy;
        yVel = Math.sqrt(u*u + 2*gravity*s);
        yVel *= reflect*options.coeff;  // lossy reflection next step
        xVel *= options.friction;
      }
      this.nextState.x = x;
      this.nextState.y = y;
      this.nextState.vx = xVel;
      this.nextState.vy = yVel;
    }

    function moveElem()
    {
      elem.style.left = this.nextState.x + "px";
      elem.style.top = this.nextState.y + "px";
    }

    const anim = new Animator(moveElem, bouncingPath, data);
    bb_tline = new Timeline(anim, 3000, false);
  }