/* Copyright (c) 2006-2010 by OpenLayers Contributors (see authors.txt for 
 * full list of contributors). Published under the Clear BSD license.  
 * See http://svn.openlayers.org/trunk/openlayers/license.txt for the
 * full text of the license. */


/**
 * @requires OpenLayers/Layer/HTTPRequest.js
 * @requires OpenLayers/Console.js
 */

/**
 * Class: OpenLayers.Layer.Grid
 * Base class for layers that use a lattice of tiles.  Create a new grid
 * layer with the <OpenLayers.Layer.Grid> constructor.
 *
 * Inherits from:
 *  - <OpenLayers.Layer.HTTPRequest>
 */
OpenLayers.Layer.Grid = OpenLayers.Class(OpenLayers.Layer.HTTPRequest, {

	/**
	 * APIProperty: tileSize
	 * {<OpenLayers.Size>}
	 */
	tileSize: null,

	/**
	 * Property: grid
	 * {Array(Array(<OpenLayers.Tile>))} This is an array of rows, each row is
	 *     an array of tiles.
	 */
	grid: null,

	/**
	 * APIProperty: singleTile
	 * {Boolean} Moves the layer into single-tile mode, meaning that one tile
	 *     will be loaded. The tile's size will be determined by the 'ratio'
	 *     property. When the tile is dragged such that it does not cover the
	 *     entire viewport, it is reloaded.
	 */
	singleTile: false,

	/** APIProperty: ratio
	 *  {Float} Used only when in single-tile mode, this specifies the
	 *          ratio of the size of the single tile to the size of the map.
	 */
	ratio: 1.5,

	/**
	 * APIProperty: buffer
	 * {Integer} Used only when in gridded mode, this specifies the number of
	 *           extra rows and colums of tiles on each side which will
	 *           surround the minimum grid tiles to cover the map.
	 */
	buffer: 2,

	/**
	 * APIProperty: numLoadingTiles
	 * {Integer} How many tiles are still loading?
	 */
	numLoadingTiles: 0,

	/**
	 * Constructor: OpenLayers.Layer.Grid
	 * Create a new grid layer
	 *
	 * Parameters:
	 * name - {String}
	 * url - {String}
	 * params - {Object}
	 * options - {Object} Hashtable of extra options to tag onto the layer
	 */
	initialize: function (name, url, params, options) {
		OpenLayers.Layer.HTTPRequest.prototype.initialize.apply(this,
			arguments);

		//grid layers will trigger 'tileloaded' when each new tile is
		// loaded, as a means of progress update to listeners.
		// listeners can access 'numLoadingTiles' if they wish to keep track
		// of the loading progress
		//
		this.events.addEventType("tileloaded");

		this.grid = [];
	},

	/**
	 * APIMethod: destroy
	 * Deconstruct the layer and clear the grid.
	 */
	destroy: function () {
		this.clearGrid();
		this.grid = null;
		this.tileSize = null;
		OpenLayers.Layer.HTTPRequest.prototype.destroy.apply(this, arguments);
	},

	/**
	 * Method: clearGrid
	 * Go through and remove all tiles from the grid, calling
	 *    destroy() on each of them to kill circular references
	 */
	clearGrid: function () {
		if (this.grid) {
			for (var iRow = 0, len = this.grid.length; iRow < len; iRow++) {
				var row = this.grid[iRow];
				for (var iCol = 0, clen = row.length; iCol < clen; iCol++) {
					var tile = row[iCol];
					this.removeTileMonitoringHooks(tile);
					tile.destroy();
				}
			}
			this.grid = [];
		}
	},

	/**
	 * APIMethod: clone
	 * Create a clone of this layer
	 *
	 * Parameters:
	 * obj - {Object} Is this ever used?
	 *
	 * Returns:
	 * {<OpenLayers.Layer.Grid>} An exact clone of this OpenLayers.Layer.Grid
	 */
	clone: function (obj) {

		if (obj == null) {
			obj = new OpenLayers.Layer.Grid(this.name,
				this.url,
				this.params,
				this.getOptions());
		}

		//get all additions from superclasses
		obj = OpenLayers.Layer.HTTPRequest.prototype.clone.apply(this, [obj]);

		// copy/set any non-init, non-simple values here
		if (this.tileSize != null) {
			obj.tileSize = this.tileSize.clone();
		}

		// we do not want to copy reference to grid, so we make a new array
		obj.grid = [];

		return obj;
	},

	/**
	 * Method: moveTo
	 * This function is called whenever the map is moved. All the moving
	 * of actual 'tiles' is done by the map, but moveTo's role is to accept
	 * a bounds and make sure the data that that bounds requires is pre-loaded.
	 *
	 * Parameters:
	 * bounds - {<OpenLayers.Bounds>}
	 * zoomChanged - {Boolean}
	 * dragging - {Boolean}
	 */
	moveTo: function (bounds, zoomChanged, dragging) {
		OpenLayers.Layer.HTTPRequest.prototype.moveTo.apply(this, arguments);

		bounds = bounds || this.map.getExtent();

		if (bounds != null) {

			// if grid is empty or zoom has changed, we *must* re-tile
			var forceReTile = !this.grid.length || zoomChanged;

			// total bounds of the tiles
			var tilesBounds = this.getTilesBounds();

			if (this.singleTile) {

				// We want to redraw whenever even the slightest part of the
				//  current bounds is not contained by our tile.
				//  (thus, we do not specify partial -- its default is false)
				if (forceReTile ||
					(!dragging && !tilesBounds.containsBounds(bounds))) {
					this.initSingleTile(bounds);
				}
			} else {

				// if the bounds have changed such that they are not even
				//  *partially* contained by our tiles (IE user has
				//  programmatically panned to the other side of the earth)
				//  then we want to reTile (thus, partial true).
				//
				if (forceReTile || !tilesBounds.containsBounds(bounds, true)) {
					this.initGriddedTiles(bounds);
				} else {
					//we might have to shift our buffer tiles
					this.moveGriddedTiles(bounds);
				}
			}
		}
	},

	/**
	 * APIMethod: setTileSize
	 * Check if we are in singleTile mode and if so, set the size as a ratio
	 *     of the map size (as specified by the layer's 'ratio' property).
	 *
	 * Parameters:
	 * size - {<OpenLayers.Size>}
	 */
	setTileSize: function (size) {
		if (this.singleTile) {
			size = this.map.getSize();
			size.h = parseInt(size.h * this.ratio);
			size.w = parseInt(size.w * this.ratio);
		}
		OpenLayers.Layer.HTTPRequest.prototype.setTileSize.apply(this, [size]);
	},

	/**
	 * Method: getGridBounds
	 * Deprecated. This function will be removed in 3.0. Please use
	 *     getTilesBounds() instead.
	 *
	 * Returns:
	 * {<OpenLayers.Bounds>} A Bounds object representing the bounds of all the
	 * currently loaded tiles (including those partially or not at all seen
	 * onscreen)
	 */
	getGridBounds: function () {
		var msg = "The getGridBounds() function is deprecated. It will be " +
			"removed in 3.0. Please use getTilesBounds() instead.";
		OpenLayers.Console.warn(msg);
		return this.getTilesBounds();
	},

	/**
	 * APIMethod: getTilesBounds
	 * Return the bounds of the tile grid.
	 *
	 * Returns:
	 * {<OpenLayers.Bounds>} A Bounds object representing the bounds of all the
	 *     currently loaded tiles (including those partially or not at all seen
	 *     onscreen).
	 */
	getTilesBounds: function () {
		var bounds = null;

		if (this.grid.length) {
			var bottom = this.grid.length - 1;
			var bottomLeftTile = this.grid[bottom][0];

			var right = this.grid[0].length - 1;
			var topRightTile = this.grid[0][right];

			bounds = new OpenLayers.Bounds(bottomLeftTile.bounds.left,
				bottomLeftTile.bounds.bottom,
				topRightTile.bounds.right,
				topRightTile.bounds.top);

		}
		return bounds;
	},

	/**
	 * Method: initSingleTile
	 *
	 * Parameters:
	 * bounds - {<OpenLayers.Bounds>}
	 */
	initSingleTile: function (bounds) {

		//determine new tile bounds
		var center = bounds.getCenterLonLat();
		var tileWidth = bounds.getWidth() * this.ratio;
		var tileHeight = bounds.getHeight() * this.ratio;

		var tileBounds =
			new OpenLayers.Bounds(center.lon - (tileWidth / 2),
				center.lat - (tileHeight / 2),
				center.lon + (tileWidth / 2),
				center.lat + (tileHeight / 2));

		var ul = new OpenLayers.LonLat(tileBounds.left, tileBounds.top);
		var px = this.map.getLayerPxFromLonLat(ul);

		if (!this.grid.length) {
			this.grid[0] = [];
		}

		var tile = this.grid[0][0];
		if (!tile) {
			tile = this.addTile(tileBounds, px);

			this.addTileMonitoringHooks(tile);
			tile.draw();
			this.grid[0][0] = tile;
		} else {
			tile.moveTo(tileBounds, px);
		}

		//remove all but our single tile
		this.removeExcessTiles(1, 1);
	},

	/**
	 * Method: calculateGridLayout
	 * Generate parameters for the grid layout. This
	 *
	 * Parameters:
	 * bounds - {<OpenLayers.Bound>}
	 * extent - {<OpenLayers.Bounds>}
	 * resolution - {Number}
	 *
	 * Returns:
	 * Object containing properties tilelon, tilelat, tileoffsetlat,
	 * tileoffsetlat, tileoffsetx, tileoffsety
	 */
	calculateGridLayout: function (bounds, extent, resolution) {
		var tilelon = resolution * this.tileSize.w;
		var tilelat = resolution * this.tileSize.h;

		var offsetlon = bounds.left - extent.left;
		var tilecol = Math.floor(offsetlon / tilelon) - this.buffer;
		var tilecolremain = offsetlon / tilelon - tilecol;
		var tileoffsetx = -tilecolremain * this.tileSize.w;
		var tileoffsetlon = extent.left + tilecol * tilelon;

		var offsetlat = bounds.top - (extent.bottom + tilelat);
		var tilerow = Math.ceil(offsetlat / tilelat) + this.buffer;
		var tilerowremain = tilerow - offsetlat / tilelat;
		var tileoffsety = -tilerowremain * this.tileSize.h;
		var tileoffsetlat = extent.bottom + tilerow * tilelat;

		return {
			tilelon: tilelon, tilelat: tilelat,
			tileoffsetlon: tileoffsetlon, tileoffsetlat: tileoffsetlat,
			tileoffsetx: tileoffsetx, tileoffsety: tileoffsety
		};

	},

	/**
	 * Method: initGriddedTiles
	 *
	 * Parameters:
	 * bounds - {<OpenLayers.Bounds>}
	 */
	initGriddedTiles: function (bounds) {

		// work out mininum number of rows and columns; this is the number of
		// tiles required to cover the viewport plus at least one for panning

		var viewSize = this.map.getSize();
		var minRows = Math.ceil(viewSize.h / this.tileSize.h) +
			Math.max(1, 2 * this.buffer);
		var minCols = Math.ceil(viewSize.w / this.tileSize.w) +
			Math.max(1, 2 * this.buffer);

		var extent = this.getMaxExtent();
		var resolution = this.map.getResolution();

		var tileLayout = this.calculateGridLayout(bounds, extent, resolution);

		var tileoffsetx = Math.round(tileLayout.tileoffsetx); // heaven help us
		var tileoffsety = Math.round(tileLayout.tileoffsety);

		var tileoffsetlon = tileLayout.tileoffsetlon;
		var tileoffsetlat = tileLayout.tileoffsetlat;

		var tilelon = tileLayout.tilelon;
		var tilelat = tileLayout.tilelat;

		this.origin = new OpenLayers.Pixel(tileoffsetx, tileoffsety);

		var startX = tileoffsetx;
		var startLon = tileoffsetlon;

		var rowidx = 0;

		var layerContainerDivLeft = parseInt(this.map.layerContainerDiv.style.left);
		var layerContainerDivTop = parseInt(this.map.layerContainerDiv.style.top);


		do {
			var row = this.grid[rowidx++];
			if (!row) {
				row = [];
				this.grid.push(row);
			}

			tileoffsetlon = startLon;
			tileoffsetx = startX;
			var colidx = 0;

			do {
				var tileBounds =
					new OpenLayers.Bounds(tileoffsetlon,
						tileoffsetlat,
						tileoffsetlon + tilelon,
						tileoffsetlat + tilelat);

				var x = tileoffsetx;
				x -= layerContainerDivLeft;

				var y = tileoffsety;
				y -= layerContainerDivTop;

				var px = new OpenLayers.Pixel(x, y);
				var tile = row[colidx++];
				if (!tile) {
					tile = this.addTile(tileBounds, px);
					this.addTileMonitoringHooks(tile);
					row.push(tile);
				} else {
					tile.moveTo(tileBounds, px, false);
				}

				tileoffsetlon += tilelon;
				tileoffsetx += this.tileSize.w;
			} while ((tileoffsetlon <= bounds.right + tilelon * this.buffer)
			|| colidx < minCols);

			tileoffsetlat -= tilelat;
			tileoffsety += this.tileSize.h;
		} while ((tileoffsetlat >= bounds.bottom - tilelat * this.buffer)
		|| rowidx < minRows);

		//shave off exceess rows and colums
		this.removeExcessTiles(rowidx, colidx);

		//now actually draw the tiles
		this.spiralTileLoad();
	},

	/**
	 * Method: getMaxExtent
	 * Get this layer's maximum extent. (Implemented as a getter for
	 *     potential specific implementations in sub-classes.)
	 *
	 * Returns:
	 * {OpenLayers.Bounds}
	 */
	getMaxExtent: function () {
		return this.maxExtent;
	},

	/**
	 * Method: spiralTileLoad
	 *   Starts at the top right corner of the grid and proceeds in a spiral
	 *    towards the center, adding tiles one at a time to the beginning of a
	 *    queue.
	 *
	 *   Once all the grid's tiles have been added to the queue, we go back
	 *    and iterate through the queue (thus reversing the spiral order from
	 *    outside-in to inside-out), calling draw() on each tile.
	 */
	spiralTileLoad: function () {
		var tileQueue = [];

		var directions = ["right", "down", "left", "up"];

		var iRow = 0;
		var iCell = -1;
		var direction = OpenLayers.Util.indexOf(directions, "right");
		var directionsTried = 0;

		while (directionsTried < directions.length) {

			var testRow = iRow;
			var testCell = iCell;

			switch (directions[direction]) {
				case "right":
					testCell++;
					break;
				case "down":
					testRow++;
					break;
				case "left":
					testCell--;
					break;
				case "up":
					testRow--;
					break;
			}

			// if the test grid coordinates are within the bounds of the
			//  grid, get a reference to the tile.
			var tile = null;
			if ((testRow < this.grid.length) && (testRow >= 0) &&
				(testCell < this.grid[0].length) && (testCell >= 0)) {
				tile = this.grid[testRow][testCell];
			}

			if ((tile != null) && (!tile.queued)) {
				//add tile to beginning of queue, mark it as queued.
				tileQueue.unshift(tile);
				tile.queued = true;

				//restart the directions counter and take on the new coords
				directionsTried = 0;
				iRow = testRow;
				iCell = testCell;
			} else {
				//need to try to load a tile in a different direction
				direction = (direction + 1) % 4;
				directionsTried++;
			}
		}

		// now we go through and draw the tiles in forward order
		for (var i = 0, len = tileQueue.length; i < len; i++) {
			var tile = tileQueue[i];
			tile.draw();
			//mark tile as unqueued for the next time (since tiles are reused)
			tile.queued = false;
		}
	},

	/**
	 * APIMethod: addTile
	 * Gives subclasses of Grid the opportunity to create an
	 * OpenLayer.Tile of their choosing. The implementer should initialize
	 * the new tile and take whatever steps necessary to display it.
	 *
	 * Parameters
	 * bounds - {<OpenLayers.Bounds>}
	 * position - {<OpenLayers.Pixel>}
	 *
	 * Returns:
	 * {<OpenLayers.Tile>} The added OpenLayers.Tile
	 */
	addTile: function (bounds, position) {
		// Should be implemented by subclasses
	},

	/**
	 * Method: addTileMonitoringHooks
	 * This function takes a tile as input and adds the appropriate hooks to
	 *     the tile so that the layer can keep track of the loading tiles.
	 *
	 * Parameters:
	 * tile - {<OpenLayers.Tile>}
	 */
	addTileMonitoringHooks: function (tile) {

		tile.onLoadStart = function () {
			//if that was first tile then trigger a 'loadstart' on the layer
			if (this.numLoadingTiles == 0) {
				this.events.triggerEvent("loadstart");
			}
			this.numLoadingTiles++;
		};
		tile.events.register("loadstart", this, tile.onLoadStart);

		tile.onLoadEnd = function () {
			this.numLoadingTiles--;
			this.events.triggerEvent("tileloaded");
			//if that was the last tile, then trigger a 'loadend' on the layer
			if (this.numLoadingTiles == 0) {
				this.events.triggerEvent("loadend");
			}
		};
		tile.events.register("loadend", this, tile.onLoadEnd);
		tile.events.register("unload", this, tile.onLoadEnd);
	},

	/**
	 * Method: removeTileMonitoringHooks
	 * This function takes a tile as input and removes the tile hooks
	 *     that were added in addTileMonitoringHooks()
	 *
	 * Parameters:
	 * tile - {<OpenLayers.Tile>}
	 */
	removeTileMonitoringHooks: function (tile) {
		tile.unload();
		tile.events.un({
			"loadstart": tile.onLoadStart,
			"loadend": tile.onLoadEnd,
			"unload": tile.onLoadEnd,
			scope: this
		});
	},

	/**
	 * Method: moveGriddedTiles
	 *
	 * Parameters:
	 * bounds - {<OpenLayers.Bounds>}
	 */
	moveGriddedTiles: function (bounds) {
		var buffer = this.buffer || 1;
		while (true) {
			var tlLayer = this.grid[0][0].position;
			var tlViewPort =
				this.map.getViewPortPxFromLayerPx(tlLayer);
			if (tlViewPort.x > -this.tileSize.w * (buffer - 1)) {
				this.shiftColumn(true);
			} else if (tlViewPort.x < -this.tileSize.w * buffer) {
				this.shiftColumn(false);
			} else if (tlViewPort.y > -this.tileSize.h * (buffer - 1)) {
				this.shiftRow(true);
			} else if (tlViewPort.y < -this.tileSize.h * buffer) {
				this.shiftRow(false);
			} else {
				break;
			}
		}
	},

	/**
	 * Method: shiftRow
	 * Shifty grid work
	 *
	 * Parameters:
	 * prepend - {Boolean} if true, prepend to beginning.
	 *                          if false, then append to end
	 */
	shiftRow: function (prepend) {
		var modelRowIndex = (prepend) ? 0 : (this.grid.length - 1);
		var grid = this.grid;
		var modelRow = grid[modelRowIndex];

		var resolution = this.map.getResolution();
		var deltaY = (prepend) ? -this.tileSize.h : this.tileSize.h;
		var deltaLat = resolution * -deltaY;

		var row = (prepend) ? grid.pop() : grid.shift();

		for (var i = 0, len = modelRow.length; i < len; i++) {
			var modelTile = modelRow[i];
			var bounds = modelTile.bounds.clone();
			var position = modelTile.position.clone();
			bounds.bottom = bounds.bottom + deltaLat;
			bounds.top = bounds.top + deltaLat;
			position.y = position.y + deltaY;
			row[i].moveTo(bounds, position);
		}

		if (prepend) {
			grid.unshift(row);
		} else {
			grid.push(row);
		}
	},

	/**
	 * Method: shiftColumn
	 * Shift grid work in the other dimension
	 *
	 * Parameters:
	 * prepend - {Boolean} if true, prepend to beginning.
	 *                          if false, then append to end
	 */
	shiftColumn: function (prepend) {
		var deltaX = (prepend) ? -this.tileSize.w : this.tileSize.w;
		var resolution = this.map.getResolution();
		var deltaLon = resolution * deltaX;

		for (var i = 0, len = this.grid.length; i < len; i++) {
			var row = this.grid[i];
			var modelTileIndex = (prepend) ? 0 : (row.length - 1);
			var modelTile = row[modelTileIndex];

			var bounds = modelTile.bounds.clone();
			var position = modelTile.position.clone();
			bounds.left = bounds.left + deltaLon;
			bounds.right = bounds.right + deltaLon;
			position.x = position.x + deltaX;

			var tile = prepend ? this.grid[i].pop() : this.grid[i].shift();
			tile.moveTo(bounds, position);
			if (prepend) {
				row.unshift(tile);
			} else {
				row.push(tile);
			}
		}
	},

	/**
	 * Method: removeExcessTiles
	 * When the size of the map or the buffer changes, we may need to
	 *     remove some excess rows and columns.
	 *
	 * Parameters:
	 * rows - {Integer} Maximum number of rows we want our grid to have.
	 * colums - {Integer} Maximum number of columns we want our grid to have.
	 */
	removeExcessTiles: function (rows, columns) {

		// remove extra rows
		while (this.grid.length > rows) {
			var row = this.grid.pop();
			for (var i = 0, l = row.length; i < l; i++) {
				var tile = row[i];
				this.removeTileMonitoringHooks(tile);
				tile.destroy();
			}
		}

		// remove extra columns
		while (this.grid[0].length > columns) {
			for (var i = 0, l = this.grid.length; i < l; i++) {
				var row = this.grid[i];
				var tile = row.pop();
				this.removeTileMonitoringHooks(tile);
				tile.destroy();
			}
		}
	},

	/**
	 * Method: onMapResize
	 * For singleTile layers, this will set a new tile size according to the
	 * dimensions of the map pane.
	 */
	onMapResize: function () {
		if (this.singleTile) {
			this.clearGrid();
			this.setTileSize();
		}
	},

	/**
	 * APIMethod: getTileBounds
	 * Returns The tile bounds for a layer given a pixel location.
	 *
	 * Parameters:
	 * viewPortPx - {<OpenLayers.Pixel>} The location in the viewport.
	 *
	 * Returns:
	 * {<OpenLayers.Bounds>} Bounds of the tile at the given pixel location.
	 */
	getTileBounds: function (viewPortPx) {
		var maxExtent = this.maxExtent;
		var resolution = this.getResolution();
		var tileMapWidth = resolution * this.tileSize.w;
		var tileMapHeight = resolution * this.tileSize.h;
		var mapPoint = this.getLonLatFromViewPortPx(viewPortPx);
		var tileLeft = maxExtent.left + (tileMapWidth *
			Math.floor((mapPoint.lon -
				maxExtent.left) /
				tileMapWidth));
		var tileBottom = maxExtent.bottom + (tileMapHeight *
			Math.floor((mapPoint.lat -
				maxExtent.bottom) /
				tileMapHeight));
		return new OpenLayers.Bounds(tileLeft, tileBottom,
			tileLeft + tileMapWidth,
			tileBottom + tileMapHeight);
	},

	CLASS_NAME: "OpenLayers.Layer.Grid"
});
