Dynamic Animated Tile Layers in Bing Maps (AJAX v7) 2

 

I considered various approaches that could be used to animate between tilelayers containing image tiles representing different frames of an animation. The approach I decided on was to buffer tilelayers onto an EntityCollection “queue”. The first entity in the collection would be the tilelayer of the currently displayed frame, and subsequent elements would be pushed onto the end to allow them to be pre-buffered by the time they were to be displayed on the map.

In this post, I’ll look at creating the various methods used to add and transition through the frames in the queue.

Variables

To start with, I defined some variables. The following public properties could be set to change the behaviour of the animation:

  • frames: the URL of the individual frames used to define the animation. This can be supplied in one of two ways:
    • As an array of image URLs, specified in frame order, e.g.:var frames = [“http://mapsys.info/wp-content/uploads/2011/05/9f93ac1bbeadkey.png.png”, “http://mapsys.info/wp-content/uploads/2011/05/5e69b74bbbadkey.png.png”, “http:///www.example.com/frame3/{quadkey}.png”];
    • As a single URL in which the supplied {frame} placeholder would be replaced with the frame number of the requested tile. e.g. var frames = “http://mapsys.info/wp-content/uploads/2011/05/0cf8dc8575adkey.png.png”;
  • loopbehaviour: What should happen when the animation continues beyond the last frame? I defined three possible options:
    • ‘loop’: animation loops back to the first frame and continues playing
    • ‘stop’: animation stops on the last frame
    • ‘bounce’: direction of animation changes and continues to play
  • framerate: The interval (in milliseconds) at which frames should be animated
  • opacity: The opacity at which the tiles should be displayed
  • lookAhead: The number of frames of animation that should be loaded in advance onto the tile queue.

And I also created the following internal variables to help with the mechanics:

  • intervalId: the intervalId assigned by setInterval() – used to start and cancel the animation
  • frame: The integer index used to keep track of the currently displayed frame
  • direction: The current direction of animation: 1 forwards, or –1 backwards

Functions

The basic public methods of my animated tile class would be straightforward, as follows:

  this.play = function() {
    _direction = 1;
    _play();
  }
  this.playbackwards = function() {
    _direction = -1;
    _play();
  }
  this.goToFrame = function(n) {
    _goToFrame(n);
  }
  this.stop = function() {
    _stop();
  }
  this.reset = function() {
    _reset();
  }

And the corresponding private methods that they called would be, for the most part, straightforward as well:

  /* Play the animation in the current direction */
  function _play() {
    if (_intervalId == "") {
      _intervalId = setInterval(_nextFrame, _options.framerate);
    }
  }

  /* Reset the animation back to the first frame */
  function _reset() {
    _animatedTileLayer.clear();
    _frame = 0;
    _redraw();
  }

  /* Stop the animation if currently playing */
  function _stop() {
    if (_intervalId != "") {
      clearInterval(_intervalId);
      _intervalId = "";
    }
  }

  /* Jump to the specified frame index */
  function _goToFrame(n) {
    if (n > 0 && (n < _frames.length || _frames.length == 1)) {
      _animatedTileLayer.clear();
      _frame = n;
      _redraw();
    }
  }

The two functions that actually do the grunt work are _nextFrame(), which is the method called repeatedly by setInterval() when the animation is playing, and _redraw(), which is the method that actually deals with the tilelayers on the queue. Here’s _nextFrame(), which is responsible for determining the next frame to queue up in the currently playing animation, taking account of specified behaviour when the last frame is reached:

function _nextFrame() {

  // Increment (or decrement) the frame counter based on animation direction
  _frame += _direction;

  // Test if requested frame lies outside specified array of frames
  if (_frames.length > 1 && (_frame >= _frames.length || _frame < 0)) {

    // Varies depending on desired loop behaviour
    switch (_options.loopbehaviour) {

      // Loop (default) the animation from the other end
      case 'loop':
        _frame = _frames.length - (_direction * _frame);
        break;

      // Stop the animation
      case 'stop':
        _stop();
        _frame -= _direction;
        break;

      // Continue by reversing direction of animation
      case 'bounce':
       _direction *= -1;
       _frame = _frame + (2 * _direction);
       break;
      }
    }
    // Push the next frame onto the queue
    _redraw();
  }

And here’s version 1 of _redraw(), which removes the currently displayed frame of animation, displays (at full opacity) the next frame on the queue, and ensures that the queue maintains the specified number of tilelayers to preload in advance:

  function _redraw() {

    // Retrieve the URI of the next frame
    var uri = "";
    if (_frames.length > 1) { // Specified array of frames
      uri = _frames[_frame];
    }
    else {
      uri = _frames[0].replace('{frame}', _frame); // Single URL with {frame} placeholder
    }

    // Create a new tilelayer for the requested frame
    // Visibility must be set to true and the tilelayer must have non-zero opacity
    // in order for tiles to be requested
    var tileOptions = {
      mercator: new Microsoft.Maps.TileSource({ uriConstructor: uri }),
      opacity: 0.01,
      visible: true,
      zIndex: 25
    };

    // Add the tilelayer onto the end of the tile queue
    var tileLayer = new Microsoft.Maps.TileLayer(tileOptions);
    _animatedTileLayer.push(tileLayer);

    // If there is only one frame of animation in the queue, make it visible
    if (_animatedTileLayer.getLength() == 1) {
      _animatedTileLayer.get(0).setOptions({ opacity: _options.opacity });
    }
    // Ensure the tile queue maintains specified length
    else while (_animatedTileLayer.getLength() > _options.lookAhead + 1) {

      // Set the opacity of the next frame to full
      _animatedTileLayer.get(1).setOptions({ opacity: _options.opacity });

      // Remove the currently displayed frame
      _animatedTileLayer.removeAt(0);
    }
  }

First Impressions…

Testing out my library for the first time, I was:
a.) surprised to find that it did actually work!, but at the same time…
b.) disappointed that there was a really annoying flicker between each frame change, even though the tiles for the next frame had been fully cached via the tile queue.

Even though my _redraw() function was setting the opacity to full on the next tile layer to be shown (i.e. the tilelayer at position 1 in my entitycollection) before removing the current tilelayer (the tilelayer at position 0), I found that the API was not firing these actions synchronously. Investigating some more, I found that somebody else had already noticed the same problem and had proposed a workaround – rather than use the setOptions() method of the API, I could set the opacity of the layers directly through editing the styles attached to the DOM element.

This approach is a bit risky because, as the other commentator notes, there doesn’t seem to be a reliable way to identify any particular tile layer (or other Bing Map entity) through the DOM – the Bing Maps v7 elements are remarkably lacking in useful things like unique IDs or Classes, so you can only target them by their position. If the animated tile layer is the first entity to be added to the map, I could seem to target it reliably using Map.getModeLayer().children[0].children[1] but I couldn’t guarantee this would always work. Hence, I decided to introduce a new option – to either run the animation in “safe mode” (which used only supported methods of the API) or “dangerous mode”, which achieved a smoother result by transitioning between frames directly through the DOM, but might break at any moment (exciting, huh?).

The required modification to the _redraw() method is as follows:

...
    // If there is more than one frame
    else while (_animatedTileLayer.getLength() > _options.lookAhead + 1) {

      // Display the next frame depending on the mode selected
      switch (_options.mode) {

        case 'safe':
          // Set the opacity using the API method - incurs slight delay
          _animatedTileLayer.get(1).setOptions({ opacity: _options.opacity });
          break;

        case 'dangerous':
          // Can reduce flicker by setting opacity directly through the DOM

          _map.getModeLayer().children[0].children[1].style.opacity = _options.opacity;
          break;
      }
...

Now that I’ve got the basic code working, I’m planning to see what I can do to improve performance.