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.

Dynamic Animated Tile Layer in Bing Maps

I attempted to create an animated weather map in Bing Maps by using a custom tilelayer that pointed to a set of dynamically-generated animated GIF image tiles. Note that the tilesource itself never changed – it was the tile images which the tilelayer pointed to that were animated, rather than the tilesource itself. There are a number of limitations with this approach, since there is no way in the client application to control the animation – it just repeats in a fixed loop and, because each tile is loaded and animated independently, there is a risk for the animations frames to become out-of-sync.

In order to have control over the animation, we don’t want the frames to be hard-baked into an animated GIF, but rather we want to be able to refresh a tile layer programmatically, updating the underlying image source based on some client event or after a specified time interval. This is what I’ll look at it this post.

Updating the Tile Source of an Existing Tile Layer?

To change the properties of an existing tile layer, you use the setOptions() method of the tile layer, passing in the same TileLayerOptions structure as you do when constructing a new layer:

tilelayer.setOptions(tileLayerOptions);

The problem is that, although the TileLayerOptions object defines a tilesource property that can be passed to the setOptions() method (and there is nothing in the documentation to suggest otherwise), it seems that you can’t change the tilesource of an existing tilelayer – any tilesource specified in the TileLayerOptions supplied to setOptions() is simply ignored. You can verify this quite easily using the following code (which also demonstrates the undocumented getVisible() method of the TileLayer class):

// Declare a set of tile layer options
var tileLayerOptions = {
  mercator: new Microsoft.Maps.TileSource({
    uriConstructor: 'http://mapsys.info/wp-content/uploads/2011/05/25b2588fcbtile1.jpg.jpg'
  }),
  opacity: 0.1,
  visible: false,
  zIndex: 20
}
// Declare another set of tile layer options
var tileLayerOptions2 = {
  mercator: new Microsoft.Maps.TileSource({
    uriConstructor: 'http://mapsys.info/wp-content/uploads/2011/05/940347c14ftile2.jpg.jpg'
  }),
  opacity: 0.2,
  visible: true,
  zIndex: 25
};
// Create a tile layer based on the first options
var tilelayer = new Microsoft.Maps.TileLayer(tileLayerOptions);
// Change the options of the tile layer
tilelayer.setOptions(tileLayerOptions2);
// Check the current options of the tile layer
alert(
  'TileSource: ' + tilelayer.getTileSource('mercator').getUriConstructor() + 'n' +
  'Opacity: ' + tilelayer.getOpacity() + 'n' +
  'Visible: ' + tilelayer.getVisible() + 'n' +
  'ZIndex: ' + tilelayer.getZIndex()
);

The results of the previous code listing will demonstrate that the opacity, visibility, and z-index properties of the tilelayer have all been updated to reflect the changed options passed to the setOptions() method, but the tilesource property is still the original, unchanged rand_tile1.jpg file.

So, if the tilesource can only be defined at the point the tile layer is first created, we can’t animate an existing tilelayer. Instead, we have to create a new tile layer every time we want to display a new frame in the animation.

Approach #1 : Dropping and recreating a Tile Layer

A first approach at animating between frames might simply drop the existing tilelayer and then immediately create a new tilelayer in its place, advancing the source image to the next frame of the animation, something like this:

var frames = ['frame1.png', 'frame2.png', 'frame3.png', 'frame4.png'];
for(var i = 0; i < frames.length; i++) {
  DisplayFrame(frames[i]);
}

function DisplayFrame(frame) {
  // Remove the existing frame
  map.entities.clear();

  // Create a tilelayer showing the next frame
  var tilelayer = new Microsoft.Maps.TileLayer({
    mercator: new Microsoft.Maps.TileSource({
      uriConstructor: frame
    })
  });

  // Display the new frame on the map
  map.entities.push(tilelayer);
}

The problem here is that, because the current frame is removed before the next frame is generated, there will be a gap and/or probably some fairly horrible flickering between frames.

Approach #2 : Cycling through an array of tilelayers

You might be tempted to go to the other extreme and preload all of the frames into separate tilelayers before starting the animation. Suppose you had a 30 frame animation – you could create 30 different tile layers when the map first loads, with the tile source of each layer pointing to the tile images for each individual frames of the animation. Once all the layers had loaded, you could selectively show only one layer (visible: true) while hiding the others (by setting visible: false) to create a smooth animation between the frames. Something like this:

// Add tilelayers for each frame in the animation
var frames = ['frame1.png', 'frame2.png', 'frame3.png', 'frame4.png'];
for (var i = 0; i < frames.length; i++) {
  var tilelayer = new Microsoft.Maps.TileLayer({
    mercator: new Microsoft.Maps.TileSource({
      uriConstructor: frames[i]
    }),
    visible: false
  });
  map.entities.push(tilelayer);
}

// Make chosen frame visible
function DisplayFrame(frame) {
  for (var i = 0; i < map.entities.getLength(); i++) {
    if (i == frame) {
      map.entities.get(i).setOptions({ visible: true });
    }
    else {
      map.entities.get(i).setOptions({ visible: false });
    }
  }
}

The problem with this second approach is that each new tile layer leads to many additional elements being placed in the DOM, many more image tiles being simultaneously downloaded, and having that many tile layers created at the same time would almost certainly choke the browser (as confirmed in a recent post on the MSDN forums, in which 10 simultaneous tile layers ceased a network).

Approach #3 : Buffering frames onto a queue of tile layers

The solution I went for was to try to create a “happy medium” somewhere in the middle of the previous two approaches – by creating a buffer of tile layers that queued up the next one or two frames of animation in advance, but didn’t preload all the frames.  Assuming the animation speed was kept relatively constant, I should be able to animate the frames that had been queued more smoothly than simply dropping and recreating a single layer, yet without requiring the resources to preload every frame in a separate layer.

To create my queue, rather than simply create an array of tilelayers, I decided to use aMicrosoft.Maps.EntityCollection structure. The first tilelayer in the entity collection would be set to visible: true and represent the frame of animation currently shown on the map. Subsequent visible:false tilelayers would be added for the immediate upcoming frames and pushed onto the end of the collection. When I wanted to display the next frame, I’d remove the first element from the collection and shuffle any other frames down the queue, setting the visible:true property on the new lowest frame. So my queue structure looked a bit like this:

var frames = ['frame1.png', 'frame2.png', 'frame3.png', 'frame4.png'];
var currentFrame = 2;
// Create the tile queue
var tileQueue = new Microsoft.Maps.EntityCollection();
// Add the current tile as the first (visible) entity in the queue
var currentTileLayer = new Microsoft.Maps.TileLayer({
mercator: new Microsoft.Maps.TileSource({
uriConstructor: frames[i]
}),
visible: true
});
map.entities.push(currentTileLayer);
// Queue up the next few tile layers
for (var i = 1; i < 3; i++) {
var nextTileLayer = new Microsoft.Maps.TileLayer({
mercator: new Microsoft.Maps.TileSource({
uriConstructor: frames[currentFrame + i]
}),
visible: false
});
map.entities.push(tilelayer);
}

Obviously, preloading subsequent frames onto the queue implies that the frames of animation will generally be being played in consistent, sequential order and I’m able to predict what the frame displayed in 2 or 3 frames time will be, in order to add it to the queue. If the user decided to jump to arbitrary points along the timeline, I wouldn’t have been able to queue the necessary frame in advance, and you’d get much the same behaviour from the queue as you would with a single tilelayer. Still, I decided that, for the most part, the queue would probably offer a benefit for the general use case of “playing” an animation.

I hoped that, by using the EntityCollection object rather than a generic array, I’d be able to make use of some of the Bing Maps events and methods to help with my animation handling, but more on that in a minute.

Wait for it… Wait for it…

The javascript setInterval() method repeatedly calls a specified function at a chosen interval in time, specified in milliseconds. So to automatically animate through my queue of frames my first thought was to use setInterval(x) to schedule a function to execute every x milliseconds, which would remove the current tile layer from the queue (by tileQueue.removeAt(0); ), and immediately display the next element from the queue in its place. I’d also create and push a new tilelayer onto the end of the queue, to maintain a minimum number of ‘lookahead’ frames – ready-created tilelayers that were ready to display.

However, simply because the tilelayers pointing at the next frames of animation had been created and added onto the entitycollection, it doesn’t necessarily mean that the tile images for that frame had finished downloading. If you set a fast frame rate, it’s perfectly possible that the function called by setInterval would churn through the tilelayers in the queue faster than they were ready to display. Although you’d still get some benefit from the fact that the tiles had been requested slightly earlier than they were required to be displayed, it would be nice to only advance to the next frame if we knew it was fully ready to be displayed.

In order to find out if the next frame in the queue was ready, I thought I could make use of the tiledownloadcomplete event, as described at http://msdn.microsoft.com/en-us/library/gg427609.aspx. My idea was that, rather than simply wait for a predefined time to have elapsed before setInterval() would display the next frame, I would also monitor the tiledownloadcomplete event to know that the images for the next frame were completely ready. That would mean that animating between frames should be seamless, requiring nothing more than to swap the opacity of the current foreground tilelayer and its replacement. This sounded good on paper but, after considerable time spent wrangling this into a working solution, I encountered a number of practical problems with the implementation:

  • Firstly, the tiledownloadcomplete event only fires on the map object itself, not on an individual tilelayer. Therefore, when trying to queue up more than frame in advance, I couldn’t separately identify that the next frame’s tiles had finished downloading, only that all tiles queued had finished downloading. This means that I’d have to limit my buffer to only look one frame ahead at any one time. Then, when the event fires, I’d know that both the current frame being displayed and the next frame were fully loaded. I could then safely start queuing the next tilelayer in the animation.
  • The second problem, which was more significant, is that the AJAX map control only downloads tiles that are actually visible on the map. Objectively speaking, this sounds like a perfectly sensible design decision – why would you want your browser to unnecessarily download image tiles for a tilelayer that you couldn’t even see? However, this presented a problem for the implementation of my “background” tilelayers – setting the properties of the queued tilelayers to either visible:false or opacity:0 meant that the tiles weren’t downloaded, and consequently the tiledownloadcomplete event never fired. To get round this, I set the opacity of my queued tilelayers to be nearly transparent (opacity: 0.01) and then, when they were to be displayed, turned the opacity of the new current frame to full opacity: 1.0.
  • The final, killer, problem with relying on the tiledownloadcomplete event is that, as its name suggests, it is a single event that fires once at the point that tiles required to display the current map image have finished downloading. However, I was trying to use it as a indicator that the tiles for a given frame of animation were ready to display at any given point in time. “Finished downloading” is not the same as “Ready to display”. This problem surfaced when I started to test looped animations – my queued frames would run beautifully through the first cycle, only changing when the next frame was fully ready to be rendered, as designed. Then, on the second loop, the tiledownloadevent would never fire, so my animation never advanced to the next  frame. This was, of course, because the tiles for the next frame in the queue had already been downloaded and cached, so were not being requested again when each tile was shown for the second time. I could have perhaps solved this problem by appending an incremental string onto the end of each tile request so that, when requested on each successive occasion, each tile would have a slightly different URL. However, to do so would negate the possibly of caching altogether (caching is, for the most part, a good thing of course – and there would be no reason for me not to cache tiles that were being re-used again and again on a short animation cycle).

Reluctantly, I couldn’t find a way to make tiledownloadcomplete work for my purposes to reliably tell me that the next frame in my queue was ready, so I decided to go back to Plan A and make do with simply waiting a chosen amount of time before changing frames, in the hope that the time spent on the queue will have provided enough opportunity for the needed tile images to be fully downloaded.