dojo.require("dojo.io.cookie");

/**************************************************************************************************
 * Map object for displaying data Layers. Manages all viewport operations, including the zoom state
 * and panning events. No data is shown by default. Layers are added by calling the addLayer( Layer )
 * public method.
 *
 * The constructor uses the supplied innerDiv or creates one.
 * outerDivArg - the div inside which the map exists
 * widthArg - the pixel width of the outerDiv
 * heightArg - the pixel height of the outerDiv
 * innerDivArg - an optional innerDiv which will contain the Layers and be scrolled as a viewport.
 *               If one is not supplied, a default innerDiv is created.
 */
function Map( outerDivArg, widthArg, heightArg, innerDivArg ) {

  // Store the args
  var outerDiv = outerDivArg;
  this.innerDiv = (innerDivArg != null) ? innerDivArg : document.createElement("div");
  var width = widthArg;
  var height = heightArg;

  // Create a data structure for managing layers and their order
  var layers = new Array();

  // Create a LoadingManager to synchronize the asynchronous loading of layers' tiles
  this.tileManager = new TileManager();

  // Initialize the upper-left coordinates of the data to display and zoom state from previous state
  this.dataLeft = 0;
  this.dataTop = 0;
  this.zoom = 0;
  var cookiesEnabled = dojo.io.cookie.isSupported();
  if( cookiesEnabled ) {
    var dataLeft = dojo.io.cookie.getCookie( "left" );
    var dataTop = dojo.io.cookie.getCookie( "top" );
    if( dataLeft != null && dataTop != null ) {
      this.dataLeft = dataLeft;
      this.dataTop = dataTop;
      this.innerDiv.style.left = dataLeft;
      this.innerDiv.style.top = dataTop;
    }
    var zoom = dojo.io.cookie.getCookie( "zoom" );
    if( zoom != null ) {
      this.zoom = zoom;
    }
  }

  // dragging event controls for the map divs
  this.panEnabled = true;
  var dragging = false;
  var top;
  var left;
  var dragStartTop;
  var dragStartLeft;

  // wire up the mouse listeners to do dragging
  dojo.event.connect(outerDiv, "onmousedown", this, "startMove");
  dojo.event.connect(outerDiv, "onmousemove", this, "processMove");
  dojo.event.connect(outerDiv, "onmouseup", this, "stopMove");
  dojo.event.connect(outerDiv, "onmouseout", this, "stopMove");

  /**
   * Adds a new Layer to the top of the layer stack.
   *
   * layer - A Layer object
   */
  this.addLayer = function( layer ) {
    if( layer instanceof Layer ) {
      // Add the layer to the data structure
      layers[ layers.length ] = layer;
      layers[ layer.getName() ] = layer;
      // TODO: Check for duplicate Layer names before overwriting

      // set the viewport size and drawing order on the Layer and load its images
      layer.setSize(width, height);
      layer.zOrder = layers.length;
      layer.map = this;
      layer.tileManager = this.tileManager;

      // set the initial visibility based on the previous cookie state, if any
      if( cookiesEnabled ) {
        var value = dojo.io.cookie.getCookie( layer.getName() );
        if( value != null ) {
          layer.visible = (value == "true") ? true : false;
        }
      }
    }
  }

  /**
   * Returns the array of layers in this map.
   *
   * returns: an Array containing Layer objects
   */
  this.getLayers = function() {
    return layers;
  }

  /**
   * Returns the Layer with the given name, if one exists in this map. Otherwise, returns null
   * name - the String which was supplied as the Layer's name when it was created
   *
   * returns: the Layer containing that name, if found
   */
  this.getLayer = function( name ) {
    return layers[ name ];
  }

  /**
   * Toggles layers on or off, depending on the state of the button which triggered the event. If
   * the button is a member of a radio button group, all of the layers are updated. This function
   * depends on:
   * 1) The name of the layer acted upon by each button to be set as the button's "value" property
   * 2) The layer to be publicly visible from this scope.
   * If cookies are enabled, they are set to remember the visible states of the layers affected.
   */
  this.toggleLayer = function( evt ) {
    if( evt.currentTarget.type == "radio" ) {
      var items = evt.currentTarget.form[evt.currentTarget.name];
      for( var i = 0; i < items.length; i++ ) {
        layers[ items[i].value ].setVisible(items[i].checked);
        if( cookiesEnabled ) {
          dojo.io.cookie.setCookie( items[i].value, items[i].checked );
        }
      }
    } else {
      layers[evt.currentTarget.value].setVisible(evt.currentTarget.checked);
      if( cookiesEnabled ) {
        dojo.io.cookie.setCookie( evt.currentTarget.value, evt.currentTarget.checked );
      }
    }
  }

  /*************************
   * Dragging Event Handlers
   *************************/

  /**
   * Handles an onmousepressed event in the map by storing the initial mouse position,
   * setting the "dragging" flag, and setting a suitable cursor.
   *
   * event - the dojo event which triggered this method call
   */
  this.startMove = function( event ) {
    if( dragging ) {
      return this.processMove( event );
    }
    if( ! this.panEnabled ) {
      return;
    }
    dojo.event.browser.stopEvent(event);
    dragStartLeft = "" + event.clientX;
    dragStartTop = "" + event.clientY;
    this.innerDiv.style.cursor = "move";

    top = stripPx(this.innerDiv.style.top);
    left = stripPx(this.innerDiv.style.left);

    dragging = true;
    return false;
  }

  /**
   * Handles an onmousedragged event in the map by moving the innerDiv by the distance between
   * the current mouse position and the original mouse position (at the start of the drag). This
   * could be done on-the-fly during dragging but doesn't seem worth the performance hit.
   *
   * event - the dojo event which triggered this method call
   */
  this.processMove = function( event ) {
    if( ! this.panEnabled ) {
      return;
    }
    if( dragging ) {
      dojo.event.browser.stopEvent(event);
      this.innerDiv.style.top = parseFloat(top) + (event.clientY - dragStartTop) + "px";
      this.innerDiv.style.left = parseFloat(left) + (event.clientX - dragStartLeft) + "px";
    }
    return false;
  }

  /**
   * Handles an onmousereleased event in the map by unsetting the dragging flag and asking the
   * layers to load any new tiles they might need for the current viewport or future drags.
   *
   * event - the dojo event which triggered this method call
   */
  this.stopMove = function( event ) {
    if( ! this.panEnabled ) {
      return;
    }
    dojo.event.browser.stopEvent(event);
    this.innerDiv.style.cursor = "";
    this.dataLeft = this.innerDiv.style.left;
    this.dataTop = this.innerDiv.style.top;
    dojo.io.cookie.setCookie( "left", this.dataLeft );
    dojo.io.cookie.setCookie( "top", this.dataTop );
    dragging = false;
    this.updateLayers();
    return false;
  }

  /**
   * Zooms to the given zoom level. If the zoom level is different from the current zoom level, also
   * recenters the inner div so that the point at the center of the screen remains so in the new zoom.
   *
   * level - an integer zoom level, where 0 is the furthest zoom out
   */
  this.zoomTo = function( level ) {

    // Calculate where the innerDiv should be offset so that the center of the current zoom
    // becomes the center of the new zoom
    if( typeof this.zoom != "undefined" && level != 0 && this.zoom != level ) {
      var factor = Math.pow(2, (level - this.zoom));
      var newLeft = ( - stripPx(this.innerDiv.style.left) + width / 2 ) * factor;
      var newTop = ( - stripPx(this.innerDiv.style.top) + height / 2 ) * factor;
      this.zoomToAndCenter(level, newLeft, newTop);
    } else {
      this.zoomToAndCenter(level, width / 2, height / 2);
    }
  }

  /**
   * Zooms to the given zoom level and recenters the inner div so that the center of the screen is
   * at the x,y coordinates given. If no x or y coordinate is given, the inner div is placed at 0,0.
   *
   * level - an integer zoom level, where 0 is the furthest zoom out
   * xOfCenter - the x coordinate of the center of the new zoom in the units of the new zoom
   * yOfCenter - the y coordinate of the center of the new zoom in the units of the new zoom
   */
  this.zoomToAndCenter = function( level, xOfCenter, yOfCenter ) {
    // set the zoom and new data center
    this.zoom = level;
    dojo.io.cookie.setCookie( "zoom", this.zoom );
    this.dataLeft = (xOfCenter) ? ( -xOfCenter + width / 2 ) + "px": "0px";
    this.dataTop = (yOfCenter) ? ( -yOfCenter + height / 2 ) + "px": "0px";
    // register an event to pan to the new location once data are loaded
    this.tileManager.addListener(this);
    // redraw all of the layers
    this.updateLayers();
  }

  this.loadingComplete = function() {
    this.innerDiv.style.left = this.dataLeft;
    this.innerDiv.style.top = this.dataTop;
    this.tileManager.removeListener(this);
  }

  this.updateLayers = function() {
    for( var i = 0; i < layers.length; i++ ) {
      layers[i].updateTiles();
      layers[i].cleanTiles();
    }
    this.tileManager.requestsComplete();
  }
}

/**************************************************************************************************
 * A Layer is an object which renders tiles from a URL source in a Map object's innerDiv. The basic
 * constructor initializes the object with critical information.
 *
 * nameArg - a String used to refer to this layer. This should be unique in a map.
 * urlArg - the url of the directory in which to look for image tiles. The tiles should be located
 *          in subdirectories as described below:
 *          urlArg/[time/][altitude/]nameArg/zoomLevel/nameArg:[ttime][haltitude][zzoom]xxcoordyycoord.png
 * such as: http://weather.aero/data/200608141536/10000/ICING/2/ICING:t200608141536h10000z2x2y3.png
 * tileWidth - the integer pixel width of each image tile, constant for all zoom levels
 * tileHeight - the integer pixel height of each image tile, constant for all zoom levels
 * width - the total pixel width of all available data as represented at zoom level 0
 * height - the total pixel height of all available data as represented at zoom level 0
 */
function Layer( nameArg, urlArg, tileWidth, tileHeight, /* at z0 */ width, /* at z0 */ height ) {
  var name = nameArg;
  var url = urlArg;
  this.tiles = new Object();
  this.caches = true;
  // by default
  var viewportWidth;
  var viewportHeight;
  this.tileWidth = tileWidth;
  this.tileHeight = tileHeight;
  this.width = width;
  this.height = height;
  this.preloadFactor = 0;
  this.startX;
  this.startY;
  this.endX;
  this.endY;
  this.visible = true;
  this.zOrder = 0;
  this.map;
  this.tileManager;

  // Create a self-reference for use by private functions
  var self = this;

  /**
   * Gets the name of this layer. This is necessary because we don't want to expose the name attribute
   * as a public property which could be changed, thereby breaking the map's management of layers.
   */
  this.getName = function() {
    return name;
  }

  /**
   * Sets the viewport size for the layer. This is needed to calculate the indecies of the tiles.
   * This function should only be called by the Map during execution of its addLayer function.
   *
   * width - the width of the viewport in which this layer will be drawn
   * height - the height of the viewport in which this layer will be drawn
   */
  this.setSize = function( width, height ) {
    if( !viewportWidth && !viewportHeight ) {
      viewportWidth = width;
      viewportHeight = height;
    }
  }

  /**
   * Calculates which tiles should be showing, creates them, and removes unused tiles. This method
   * should only be called when the map has been panned such that different tiles might be needed.
   * During animation, the createTiles() function can be called directly to avoid recalculating the
   * needed tiles and cleaning up.
   */
  this.updateTiles = function() {
    if( this.visible ) {
      // check which tiles should be visible in the inner div because of location
      this.calcNeededTiles();

      // and create any needed tiles which we don't already have
      this.createTiles();
    }
  }

  /**
   * Figures out which tiles are needed to completely fill the inner div's viewport. The range of
   * tiles needed are stored in the member variables startX, startY, endX, and endY. This method
   * checks that we not expect tiles beyond the range of the data, as specified by the constructor's
   * width and height specifications.
   */
  this.calcNeededTiles = function() {
    var mapX = stripPx(this.map.dataLeft);
    var mapY = stripPx(this.map.dataTop);

    this.startX =
    Math.abs(Math.floor((mapX + this.tileWidth * this.preloadFactor) / this.tileWidth)) - 1;
    this.startY =
    Math.abs(Math.floor((mapY + this.tileHeight * this.preloadFactor) / this.tileHeight)) - 1;

    this.endX = Math.abs(Math.floor((mapX - this.tileWidth * this.preloadFactor - viewportWidth) /
                                    this.tileWidth)) - 1;
    this.endY = Math.abs(Math.floor((mapY - this.tileHeight * this.preloadFactor - viewportHeight) /
                                    this.tileHeight)) - 1;

    // Ignore tiles less than 0 or greater than the tile at maxWidth/maxHeight;
    if( this.startX < 0 ) {
      this.startX = 0;
    }
    if( this.startY < 0 ) {
      this.startY = 0;
    }
    var maxX = Math.floor((width * Math.pow(2, this.map.zoom) +
                           this.tileWidth * this.preloadFactor) / this.tileWidth) - 1;
    var maxY = Math.floor((height * Math.pow(2, this.map.zoom) +
                           this.tileWidth * this.preloadFactor) / this.tileWidth) - 1;
    if( this.endX > maxX ) {
      this.endX = maxX;
    }
    if( this.endY > maxY ) {
      this.endY = maxY;
    }
  }

  /**
   * Creates tiles. Needed tiles are identified by generating their source URLs from the location url,
   * if applicable, the current time and/or altitude, and the layer name and zoom.
   *
   * Once the tile source url has been derived, the tile is requested from the TileManager. The
   * TileManager is responsible for retreiving the tile from cache or loading it asynchronously from
   * its source url AND will ensure that repainting only occurs once all needed tiles are available.
   *
   * If there is not already a DOM image object in the Map's innerDiv into which to draw the tile
   * (based on the tile's location, not necessarily its data), an Image object is created and placed
   * within the Map's innerDiv at this Layer's zIndex and at the appropriate x and y offset.
   */
  this.createTiles = function() {

    // Build up the directory name and filename
    var srcDir = url;
    var srcFile = name + ":";
    var time;
    if( this.timeHandler ) {
      time = this.timeHandler.getTime();
      if( typeof time == "undefined" ) {
        // hide any old content in the tiles
        for( var tile in this.tiles ) {
          this.tiles[ tile ].style.visibility = "hidden";
        }
        return;
      }
      srcDir += time.original + "/";
      srcFile += "t" + time.original;
    }
    var altitude;
    if( this.altitudeHandler ) {
      altitude = this.altitudeHandler.altitude;
      if( typeof altitude == "undefined" ) {
        // hide any old content in the tiles
        for( var tile in this.tiles ) {
          this.tiles[ tile ].style.visibility = "hidden";
        }
        return;
      }
      srcDir += altitude + "/";
      srcFile += "h" + this.altitudeHandler.altitude;
    }
    srcDir += name + "/" + this.map.zoom + "/";
    srcFile += "z" + this.map.zoom;

    // Iterate over each of the individual tiles for this layer
    for( var xIndex = this.startX; xIndex <= this.endX; xIndex++ ) {
      for( var yIndex = this.startY; yIndex <= this.endY; yIndex++ ) {

        // If we don't have a DOM IMG object into which to display the tile, create one
        if( ! this.tiles[ xIndex + "," + yIndex ] ) {
          var img = document.createElement("img");
          img.id = name + ":" + xIndex + "," + yIndex;
          img.className = "tile";
          img.style.position = "absolute";
          img.style.left = xIndex * this.tileWidth - xIndex + "px"; // -1 pixel for each tile to avoid grey lines in high res displays
          img.style.top = yIndex * this.tileHeight + "px";
          img.style.zIndex = this.zOrder;
          img.style.visibility = "hidden";
          this.map.innerDiv.appendChild(img);
          this.tiles[ xIndex + "," + yIndex ] = img;
        }

        // Get the url of the image we are supposed to show
        var src = srcDir + srcFile + "x" + xIndex + "y" + yIndex + ".png";
        // If it is not already showing, ask the Tile Manager to retrieve it
        if( this.tiles[ xIndex + "," + yIndex ].src != src ) {
          this.tileManager.load(new TileDesc(this, src, xIndex, yIndex, this.map.zoom, time, altitude));
        } else {
          this.tiles[ xIndex + "," + yIndex ].style.visibility = "visible";
        }
      }
    }
  }

  /**
   * Removes any placeholder tiles which are not within the needed range. This is a memory-enhancing
   * function which is handy, but not required. As a result, it is only called after a completed
   * drag of the inner div.
   */
  this.cleanTiles = function() {
    for( var tileId in this.tiles ) {
      var index = tileId.split(",");
      if( index[0] < this.startX || index[0] > this.endX || index[1] < this.startY ||
          index[1] > this.endY ) {
        this.map.innerDiv.removeChild(this.tiles[ tileId ]);
        delete this.tiles[ tileId ];
      }
    }
  }
}
/**
 * Class method, shared by all instances of Layer objects, which sets this Layer's visibility.
 * A Layer is made invisible by setting all of its DOM Image tiles' visibilities to "hidden."
 * A Layer is made visible by triggering a a recalculation and re-retreival of needed tiles.
 *
 * visible - a boolean state indicating whether or not to show this Layer's tiles
 */
Layer.prototype.setVisible = function( visible ) {
  if( this.visible != visible ) {
    this.visible = visible;
    var state;
    state = ( visible ) ? "visible" : "hidden";
    for( var tile in this.tiles ) {
      this.tiles[ tile ].style.visibility = state;
    }
    if( visible ) {
      this.updateTiles();
      this.tileManager.requestsComplete();
    }
  }
}

/**************************************************************************************************
 * TimeHandler is an object for handling layers' time-based behavior. Any layers which need to be
 * synchronized can share the TimeHandler prototype's time attribute. Layers are not required to have
 * a TimeHandler if they display static data.
 * Any layers which need to have specialized time handling (such as snapping to the nearest
 * available time, etc) can override the prototype's getTime() function to perform this operation.
 */
function TimeHandler( layer ) {
  layer.timeHandler = this;
  this.getTime = function() {
    return TimeHandler.prototype.time;
  }
}
TimeHandler.prototype.time;

/**************************************************************************************************
 * AltitudeHandler is an Object for handling layers' altitude behavior. Layers are not required to
 * have an AltitudeHandler.
 */
function AltitudeHandler( layer ) {
  layer.altitudeHandler = this;
}
AltitudeHandler.altitude = 0;

/**************************************************************************************************
 * Datastructure Object for uniquely identifying an image.
 *
 * Required args are:
 *  layer     - the layer to which this tile belongs
 *  src       - the url from which to load the image
 *  x         - the x index where to place this tile
 *  y         - the y index where to place this tile
 *  z         - the zoom level to which this tile corresponds
 * Optional args are:
 *  t         - the time at which the image is valid
 *  h         - the altitude for which this image is valid
 *
 * In all cases, a unique id is generated and stored as the "id" property
 */
function TileDesc( layer, src, x, y, z, t, h ) {
  this.layer = layer;
  this.src = src;
  this.x = x;
  this.y = y;
  this.z = z;
  this.id = layer.getName() + ":" + z + "_" + x + "," + y;
  if( t ) {
    this.t = t;
    this.id += "_" + t;
  }
  if( h ) {
    this.h = h;
    this.id += "_" + t;
  }
}

/**************************************************************************************************
 * TileManager manages the asynchronous loading and caching of tiles for all of the layers. There
 * should be a single TileManager for each map. Specifically, it:
 * - Ensures that the map tiles are only redrawn once all anticipated tiles are available.
 * - Maintains a fixed-size, FILO cache of previously loaded tiles for faster animation.
 * - Notifies interested listeners once all images are loaded, so they can update GUI, etc.
 */
dojo.require("dojo.collections.ArrayList");

function TileManager() {
  // var cache = new Array();
  var MAX_CACHE_SIZE = 300;
  var cache = new dojo.collections.ArrayList();
  var timeout;
  this.loading = false;
  var numPending = 0;
  var pending = new dojo.collections.ArrayList();
  var loaded = new Array();
  var listeners = new dojo.collections.ArrayList();

  /**
   * Adds a listener to the list of listeners which will be notified once all tiles have loaded and
   * are displayed. The listeners must implement a method called loadingComplete().
   *
   * listener - an object which has a loadingComplete method
   */
  this.addListener = function( listener ) {
    listeners.add(listener);
  }

  /**
   * Removes the given object from the list of listeners which will be notified once all tiles have
   * loaded and are displayed. If the object is not a listener, does nothing.
   *
   * listener - an object which was previously registered as a listener
   */
  this.removeListener = function( listener ) {
    listeners.remove(listener);
  }

  /**
   * Determines whether or not this TileManager is still waiting for tiles to load and returns a
   * boolean true or false.
   *
   * returns true if the TileManager is still waiting for tiles, false otherwise.
   */
  this.isLoading = function() {
    return (this.loading || loaded.length > 0 );
  }

  /**
   * Loads the image described by the TileDesc. The image is either retrieved from the cache or loaded
   * asynchronously in a an off-line image object.
   *
   * tileDesc - a TileDesc object containing all of the metadata about the image
   */
  this.load = function( tileDesc ) {

    this.loading = true;

    // Check for the tile in the cache
    var tile = cache[ tileDesc.id ];
    if( tile ) {
      if( tile.loaded ) {
        loaded[ loaded.length ] = tileDesc.id;
      }
      return;
    }

    // No matching tile exists
    if( cache.count < MAX_CACHE_SIZE ) {
      // There is more room in the cache, create a new preload image
      tile = cache[ tileDesc.id ] = document.createElement('img');
      // BEWARE! A bug in Safari prevents the onload event from being properly initialized if the
      // image was created using "new Image()" instead of "createElement('img')"
      cache.add(tile);
      dojo.event.connect(tile, "onload", this, "imageLoaded");
    } else {
      // Get the first tile from the cache (longest created), and add it to the end of the cache queue
      tile = cache.item(0);
      cache.removeAt(0);
      cache.add(tile);
      cache[ tileDesc.id ] = tile;
      delete cache[ tile.tileDesc.id ];
    }

    // Set some info on the tile for later retrieval and load it
    tile.id = tileDesc.id;
    tile.tileDesc = tileDesc;
    if( ! tile.loading ) {
      tile.loading = true;
      numPending++;
    }
    tile.src = tileDesc.src;
    pending.add(tileDesc.src);
    if( ! timeout ) {
      timeout = dojo.lang.setTimeout(this, this.timedOut, 10000);
    }
  }

  /**
   * This method is registered as the "onload" handler for loading images. It is called when an
   * image has completed its loading. This method then flags the image as having been loaded. If
   * the image was the last image pending, this method triggers a call to the update method to make
   * all of the new tiles visible in the map.
   *
   * evt - the onload event which triggered this method call. Contains a reference to the image
   */
  this.imageLoaded = function( evt ) {
    // Check that this is a valid preloading image and decrement the numberRemaining count
    var tile = evt.currentTarget;
    if( tile.loading ) {
      tile.loading = false;
      tile.loaded = true;
      loaded[ loaded.length ] = tile.tileDesc.id;
      pending.remove(tile.tileDesc.src);
      numPending--;
    } else {
      dojo.debug("DANGER! tile: " + tile.tileDesc.id + " loaded without being asked to?!");
    }

    // If this was the last tile remaining, clear the timeout and update all images in the layers
    if( numPending == 0 && !this.loading ) {
      this.update();
    }
  }

  /**
   * Updates the map when all images have been loaded. Copies the loaded images to their target tiles,
   * clears the loaded cache, resets the pending counter, and notifies any listeners that the loading
   * is complete.
   */
  this.update = function() {
    if( timeout ) {
      clearTimeout(timeout);
    }
    pending.clear();

    var tileDesc, imgObject;
    for( var i = 0; i < loaded.length; i++ ) {
      tileDesc = cache[ loaded[i] ].tileDesc;
      imgObject = tileDesc.layer.tiles[ tileDesc.x + "," + tileDesc.y ];
      if( !imgObject ) { continue; }
      imgObject.src = cache[ loaded[i] ].src;
      imgObject.style.visibility = tileDesc.layer.visible ? "visible" : "hidden";
    }

    // Clear the loading array
    loaded = new Array();

    // Notify any listeners
    for( var i = 0; i < listeners.count; i++ ) {
      listeners.item(i).loadingComplete();
    }
  }

  /**
   * Notifies this TileManager that no more tiles will be requested and that it can redraw when all
   * tiles which have been requested so far have been loaded. This is necessary because of the
   * asynchronous loading of images; the TileManager waits for all *known* requested tiles to be
   * loaded before redrawing, but it cannot know if there are more tiles which have yet to be requested.
   */
  this.requestsComplete = function() {
    this.loading = false;
    if( numPending == 0 ) {
      this.update();
    }
  }

  /**
   * Failsafe method to catch any tiles which fail to load. Without this method, the TileManager
   * would wait forever for the last tile(s)'s imageLoaded event(s). The timeout gives the user a
   * chance to quit without waiting for the last tile(s).
   */
  this.timedOut = function() {
    clearTimeout(timeout);
    if( this.isLoading() ) {
      var message = "Loading is taking longer than expected.";
      if( pending.count > 0 ) {
        message += "\nStuck waiting for " + pending.count + " tiles:"
        for( var i = 0; i < pending.count; i++ ) {
          message += "\n" + pending.item[i];
        }
      }
      message += "\nClick 'OK' to continue loading or 'Cancel' to quit.";
      if( confirm(message) ) {
        timeout = dojo.lang.setTimeout(this, this.timedOut, 10000);
      }
    }
  }
}

/**************************************************************************************************
 * Animation object for controlling animation. Includes forward, backward, and sweep animation, step
 * and stop. Allows skipping of frames and supports a minimum delay between frames. Redraws the map
 * in a way that is optimized for performance. Loads expected times from a remote file, if desired,
 * and checks for new times on a recurring interval. The single constructor's arguments are:
 *
 * mapObjArg - the Map on which to animate (Required)
 * targetArg - a function reference to execute after each timestep's tiles have been displayed (Optional)
 * delay - an integer millisecond minimum delay between frames (Optional, defaults to 1000)
 * timesUrl - the url from which to load the JSON times array (Optional, no times loaded if not given)
 * timesDelay - the integer millisecond delay between checks to the timesUrl (Optional, defaults to 30000)
 */
function Animation( mapObjArg, targetArg, delay, timesUrl, timesDelay ) {
  var mapObj = mapObjArg;

  // times over which to animate, including the range and interval
  this.times;
  this.currTimeIndex,this.startTimeIndex,this.endTimeIndex;
  this.interval = 1;

  // the method to call for each time
  if( targetArg != null ) {
    var target = targetArg;
  }

  // animation state variables
  this.fwd = true;
  this.sweep = false;
  this.loop = true;

  // animation synchronization variables
  var timeout;
  this.delay = ( delay != null ) ? delay : 500;
  var dwellTime = 0;
  var expired
  mapObj.tileManager.addListener(this);

  // self-reference for JS bug in private methods
  var self = this;

  /*
   * Privileged animation state methods
   */
  this.play = function() {
    this.cycle();
  }

  this.stop = function() {
    if( timeout ) {
      clearTimeout(timeout);
    }
  }

  this.next = function() {
    if( this.currTimeIndex == this.endTimeIndex ) {
      if( dwellTime > 0 ) {
        dwellTime = 0;
      } else {
        dwellTime = Math.pow( this.delay, 0.5 ) * 100 ;
        return;
      }
      if( this.sweep ) {
        this.fwd = false;
        this.currTimeIndex -= this.interval;
      } else if( this.loop ) {
        this.currTimeIndex = this.startTimeIndex;
      } else {
        this.stop();
      }
    } else {
      this.currTimeIndex += this.interval;
    }
    update();
  }

  this.previous = function() {
    if( this.currTimeIndex == this.startTimeIndex ) {
      if( dwellTime > 0 ) {
        dwellTime = 0;
      } else {
        dwellTime = Math.pow( this.delay, 0.5 ) * 100 ;
        return;
      }
      if( this.sweep ) {
        this.fwd = true;
        this.currTimeIndex += this.interval;
      } else if( this.loop ) {
        this.currTimeIndex = this.endTimeIndex;
      } else {
        this.stop();
      }
    } else {
      this.currTimeIndex -= this.interval;
    }
    update();
  }

  this.cycle = function() {
    if( mapObj.tileManager.isLoading() ) {
      expired = true;
    } else {
      if( timeout ) {
        clearTimeout(timeout);
      }
      this.fwd ? this.next() : this.previous();
      timeout = dojo.lang.setTimeout(this, this.cycle, ( dwellTime > 0) ? dwellTime : this.delay );
    }
  }

  /**
   * Private method which updates all of the layers' TimeHandlers to reflect the current time and
   * asks the layers to redraw their tiles.
   */
  function update() {
    var value = self.times[ self.currTimeIndex ];
    TimeHandler.prototype.time = value;
    updateLayers();
  }

  /**
   * Sets the minimum animation delay. Frames may be delayed longer if layer images take longer to
   * load. This method may be called safely during an animation.
   *
   * delay - the optimal number of milliseconds to pause between animation frames
   */
  this.setDelay = function( delay ) {
      this.delay = delay;
  }

  /**
   * Should be called by the LoadingManager once all tiles have been loaded and the layers have
   * refreshed. At that time, this function notifies the target so it can update the GUI or do
   * whatever else it wants. If the Animation controller is blocked because the minimum delay between
   * frames has been exceeded, the next timestep is immediately launched.
   */
  this.loadingComplete = function() {
    if( target && this.times ) {
      target(this.times[ this.currTimeIndex]);
    }
    if( expired ) {
      expired = false;
      this.cycle();
    }
  }

  /**
   * Loads the available times from the given URL. This is done asynchronously. If the times are
   * identical to the existing times, nothing happens. If the times are different, the animation is
   * temporarily stopped and restarted in the same mode it was in. The user should not notice any
   * disruption of animation.
   */
  this.loadTimes = function() {
    var bindArgs = {
      // Reference to be used by the event handlers
      anim:          this,
      // URL from which to load (required)
      url:           this.timesUrl,
      // Expected mimetype of the data (required)
      mimetype:      "text/javascript",
      // Error Handler - provides alert message
      error:         function( type, errObj ) {
        window.alert("Could not load data.\nApplication disabled. Try again later.");
        return;
      },
      /**
       * Successful Load Handler - processes the returned data to extract it's times and determine
       * whether the times are different than the currently loaded times. If so, updates the times
       * and updates the layers.
       *
       * type - the type of data returned
       * data - the data itself: a JSON object containing a comma-separated list of time strings
       * evt - the event which triggered this call
       */
      load:      function( type, data, evt ) {
        if( data == null ) {
          window.alert("No data available.\nApplication disabled. Try again later.");
          return;
        }
        if( anim.times == null || anim.currTimeIndex == null ) {
          // The animator doesn't currently have times. Use the ones we just loaded.
          anim.times = buildTimesFromData( data );
          anim.currTimeIndex = anim.times.length - 1;
          TimeHandler.prototype.time = anim.times[ anim.currTimeIndex ];
        } else {
          // The animator already has times. See if these are different.
          var tempTimes = data;
          // for efficiency, we assume that if the index of the animation start time and the
          // index of the now time are the same in both arrays, the arrays are identical.
          // This is safe for the the kind of "forward rolling/scrubbing" data we use.
          var startIndex = anim.startTimeIndex;
          var startTime = anim.times[ startIndex ].original;
          var nowIndex = anim.nowTimeIndex;
          var nowTime = anim.times[ nowIndex ].original;
          if( tempTimes.length > nowIndex && tempTimes[startIndex] == startTime &&
              tempTimes[nowIndex] == nowTime ) {
            return;
          }

          // If we got here, the time arrays are different. Map the old time index to the new time index
          var currTime = anim.times[ anim.currTimeIndex ].original;
          var tempCurrentTimeIndex;
          for( var i = 0; i < tempTimes.length; i++ ) {
            if( typeof tempTimes[i] != 'undefined' ) {
              if( tempTimes[i] == currentTime ) {
                tempCurrTimeIndex = i;
                break;
              } else {
                tempCurrentTimeIndex = i;
              }
            }
          }

          // Update with the new array and set the time
          anim.times = buildTimesFromData( tempTimes );
          anim.currTimeIndex = tempCurrTimeIndex;
          TimeHandler.prototype.time = anim.times[ anim.currTimeIndex ];
        }
        anim.updateLayersForNewTime();

        // schedule a future call to myself
        dojo.lang.setTimeout(anim, "loadTimes", anim.timesDelay);

        function buildTimesFromData( tempTimes ) {
          var newTimes = new Array();
          for( var i = 0; i < tempTimes.length; i++ ) {
            if( typeof tempTimes[i] == 'undefined' ) {
              continue;
            }
            var tempTime = tempTimes[i].toString();
            //            newTimes[i] = new Date( tempTime.substring(0, 4), tempTime.substring(4, 6), tempTime.substring(6, 8), tempTime.substring(8, 10), tempTime.substring(10, 12), 0 );
            newTimes[i] = new Date(Date.UTC(tempTime.substring(0, 4),
                parseInt( tempTime.substring(4, 6), 10 )-1, tempTime.substring(6, 8),
                tempTime.substring(8, 10), tempTime.substring(10, 12), 0));
            newTimes[i].original = tempTimes[i];
          }
          return newTimes;
        }
      }
    };

    // dispatch the request
    var requestObj = dojo.io.bind(bindArgs);
  }
  // Constructor-scope statements which kick off the initial call to loadData, defined above. Starts
  // by storing the url argument from which to load the times, if provided.
  if( timesUrl != null ) {
    this.timesUrl = timesUrl;
    this.timesDelay = ( timesDelay != null ) ? timesDelay : 30000;
    mapObj.updateLayers();
    this.loadTimes();
  }

  /**
   * Updates all of the time-handling layers. Used by the animation cycle and the new times loading.
   */
  var updateLayers = function() {
    var layers = mapObj.getLayers();
    for( var i = 0; i < layers.length; i++ ) {
      if( layers[i].timeHandler && layers[i].visible ) {
        layers[i].createTiles();
      }
    }
    // Tell the tile manager it can redraw whenever it has all of its images loaded
    mapObj.tileManager.requestsComplete();
  }

  /**
   * Updates all of the time-handling layers after a new times array was loaded. This is a separate
   * function call from updateLayers, so that we can wire up events to adjust the times array and the
   * timehandlers of the layers BEFORE they all update.
   */
  this.updateLayersForNewTime = function() {
    updateLayers();
  }
}

/**************************************************************************************************
 * Global Utility functions used by more than one object
 */

/**
 * Strips off the "px" in the value of a length style attribute and returns it, so it can be used.
 *
 * value - a String containing a number and a "px"
 */
function stripPx( value ) {
  if( value == "" ) return 0;
  // return parseFloat(value.substring(0, value.length - 2));
  return parseFloat( value );
}
