/**
 * Giant Scalable Image Viewer (GSIV) 1.0
 *
 * Generates a draggable and zoomable viewer for images that would
 * be otherwise too large for a browser window.  Examples would include
 * maps or high resolution document scans.
 *
 * Images must be precut into tiles, such as by the accompanying tilemaker.py
 * python library.
 *
 * <div class="viewer">
 *   <div class="well"><!-- --></div>
 *   <div class="surface"><!-- --></div>
 *   <div class="controls">
 *     <a href="#" class="zoomIn">+</a>
 *     <a href="#" class="zoomOut">-</a>
 *   </div>
 * </div>
 * 
 * The "well" node is where generated IMG elements are appended. It
 * should have the CSS rule "overflow: hidden", to occlude image tiles
 * that have scrolled out of view.
 * 
 * The "surface" node is the transparent mouse-responsive layer of the
 * image viewer, and should match the well in size.
 *
 * var viewerBean = new GSIV(element, 'tiles', 256, 3, 1);
 *
 * To disable the image toolbar in IE, be sure to add the following:
 * <meta http-equiv="imagetoolbar" content="no" />
 *
 * Copyright (c) 2005 Michal Migurski <mike-gsv@teczno.com>
 *                    Dan Allen <dan.allen@mojavelinux.com>
 * 
 * Redistribution and use in source form, with or without modification,
 * are permitted provided that the following conditions are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. The name of the author may not be used to endorse or promote products
 *    derived from this software without specific prior written permission.
 * 
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
 * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
 * IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
 * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
 * THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 * @author Michal Migurski <mike-gsv@teczno.com>
 * @author Dan Allen <dan.allen@mojavelinux.com>
 *
 * NOTE: if artifacts are appearing, then positions include half-pixels
 * TODO: additional jsdoc and package jsmin
 * TODO: Tile could be an object
 */
function GSIV(viewer, options) {

	// listeners that are notified on a move (pan) event
	this.viewerMovedListeners = [];
	// listeners that are notified on a zoom event
	this.viewerZoomedListeners = [];

	if (typeof viewer == 'string') {
		this.viewer = document.getElementById(viewer);
	}
	else {
		this.viewer = viewer;
	}

	if (typeof options == 'undefined') {
		options = {};
	}

	// BEGIN ExpressServer mods
	
	this.tileSize = (options.tileSize ? options.tileSize : GSIV.TILE_SIZE);

	this.tileUrlProvider = new GSIV.TileUrlProvider(
		options.tileBaseUri,
		options.catalog,
		options.imageLocation
	);

	this.imageWidth = (typeof options.initialZoom == 'undefined' ? -1 : parseInt(options.imageWidth));
	this.imageHeight = (typeof options.initialZoom == 'undefined' ? -1 : parseInt(options.imageHeight));
	
	this.maxZoomLevelForWidth = GSIV.log2(this.imageWidth / this.tileSize);
	this.maxZoomLevelForHeight = GSIV.log2(this.imageHeight / this.tileSize);
	this.maxZoomLevel = this.maxZoomLevelForWidth > this.maxZoomLevelForHeight ? Math.ceil(this.maxZoomLevelForWidth) : Math.ceil(this.maxZoomLevelForHeight);
	
	// END ExpressServer mods

	// assign and do some validation on the zoom levels to ensure sanity
	this.zoomLevel = (typeof options.initialZoom == 'undefined' ? -1 : parseInt(options.initialZoom));
	if (this.zoomLevel > this.maxZoomLevel) {
		this.zoomLevel = this.maxZoomLevel;
	}

	this.initialPan = (options.initialPan ? options.initialPan : GSIV.INITIAL_PAN);

	this.initialized = false;
	this.surface = null;
	this.well = null;
	this.width = 0;
	this.height = 0;
	this.top = 0;
	this.left = 0;
	this.x = 0;
	this.y = 0;
	this.border = -1;
	this.mark = { 'x' : 0, 'y' : 0 };
	this.pressed = false;
	this.tiles = [];
	this.cache = {};
	var blankTile = options.blankTile ? options.blankTile : GSIV.BLANK_TILE_IMAGE;
	var loadingTile = options.loadingTile ? options.loadingTile : GSIV.LOADING_TILE_IMAGE;
	this.cache['blank'] = new Image();
	this.cache['blank'].src = blankTile;
	if (blankTile != loadingTile) {
		this.cache['loading'] = new Image();
		this.cache['loading'].src = loadingTile;
	}
	else {
		this.cache['loading'] = this.cache['blank'];
	}

	// employed to throttle the number of redraws that
	// happen while the mouse is moving
	this.moveCount = 0;
	this.slideMonitor = 0;
	this.slideAcceleration = 0;

	// add to viewer registry
	GSIV.VIEWERS[GSIV.VIEWERS.length] = this;
}

// project specific variables
GSIV.PROJECT_NAME = 'GSIV';
GSIV.PROJECT_VERSION = '1.0.0';
GSIV.REVISION_FLAG = '';

// CSS definition settings
GSIV.SURFACE_STYLE_CLASS = 'surface';
GSIV.WELL_STYLE_CLASS = 'well';
GSIV.CONTROLS_STYLE_CLASS = 'controls'
GSIV.TILE_STYLE_CLASS = 'tile';

// language settings
GSIV.MSG_BEYOND_MIN_ZOOM = 'Cannot zoom out past the current level.';
GSIV.MSG_BEYOND_MAX_ZOOM = 'Cannot zoom in beyond the current level.';

// defaults if not provided as constructor options
GSIV.TILE_SIZE = 256;
GSIV.BLANK_TILE_IMAGE = 'blank.gif';
GSIV.LOADING_TILE_IMAGE = 'blank.gif';
GSIV.INITIAL_PAN = { 'x' : .5, 'y' : .5 };
GSIV.USE_LOADER_IMAGE = true;
GSIV.USE_SLIDE = true;
GSIV.USE_KEYBOARD = true;

// performance tuning variables
GSIV.MOVE_THROTTLE = 3;
GSIV.SLIDE_DELAY = 40;
GSIV.SLIDE_ACCELERATION_FACTOR = 5;

// the following are calculated settings
GSIV.DOM_ONLOAD = (navigator.userAgent.indexOf('KHTML') >= 0 ? false : true);
GSIV.GRAB_MOUSE_CURSOR = (navigator.userAgent.search(/KHTML|Opera/i) >= 0 ? 'pointer' : (document.attachEvent ? 'images/grab.cur' : '-moz-grab'));
GSIV.GRABBING_MOUSE_CURSOR = (navigator.userAgent.search(/KHTML|Opera/i) >= 0 ? 'move' : (document.attachEvent ? 'images/grabbing.cur' : '-moz-grabbing'));

// registry of all known viewers
GSIV.VIEWERS = [];

// utility functions
GSIV.isInstance = function(object, clazz) {
	while (object != null) {
		if (object == clazz.prototype) {
			return true;
		}

		object = object.__proto__;
	}

	return false;
};
// BEGIN ExpressServer mods
GSIV.log2 = function(x) {
	return Math.LOG2E * Math.log(x);
};
// END ExpressServer mods

GSIV.prototype = {

	/**
	 * Resize the viewer to fit snug inside the browser window (or frame),
	 * spacing it from the edges by the specified border.
	 *
	 * This method should be called prior to init()
	 * FIXME: option to hide viewer to prevent scrollbar interference
	 */
	fitToWindow : function(border) {
		if (typeof border != 'number' || border < 0) {
			border = 0;
		}

		this.border = border;
		var calcWidth = 0;
		var calcHeight = 0;
		if (window.innerWidth) {
			calcWidth = window.innerWidth;
			calcHeight = window.innerHeight;
		}
		else {
			calcWidth = (document.compatMode == 'CSS1Compat' ? document.documentElement.clientWidth : document.body.clientWidth);
			calcHeight = (document.compatMode == 'CSS1Compat' ? document.documentElement.clientHeight : document.body.clientHeight);
		}
		
		calcWidth = Math.max(calcWidth - 2 * border, 0);
		calcHeight = Math.max(calcHeight - 2 * border, 0);
		if (calcWidth % 2) {
			calcWidth--;
		}

		if (calcHeight % 2) {
			calcHeight--;
		}

		this.width = calcWidth;
		this.height = calcHeight;
		this.viewer.style.width = this.width + 'px';
		this.viewer.style.height = this.height + 'px';
		this.viewer.style.top = border + 'px';
		this.viewer.style.left = border + 'px';
	},

	init : function() {
		if (document.attachEvent) {
			document.body.ondragstart = function() { return false; }
		}
		
		if (this.width == 0 && this.height == 0) {
			this.width = this.viewer.offsetWidth;
			this.height = this.viewer.offsetHeight;
		}

		var fullSize = this.tileSize;
		// explicit set of zoom level
		if (this.zoomLevel >= 0 && this.zoomLevel <= this.maxZoomLevel) {
			fullSize = this.tileSize * Math.pow(2, this.zoomLevel);
			// BEGIN ExpressServer mods
			var divisor = Math.pow(2, (this.maxZoomLevel - this.zoomLevel));
			fullSize = Math.max((this.imageWidth / divisor), (this.imageHeight / divisor));
			// END ExpressServer mods
		}
		// calculate the zoom level based on what fits best in window
		else {
			this.zoomLevel = -1;
			fullSize = this.tileSize / 2;
			do {
				this.zoomLevel += 1;
				fullSize *= 2;
			} while (fullSize < Math.max(this.width, this.height));
		}

		// move top level up and to the left so that the image is centered
		this.x = Math.floor((fullSize - this.width) * -this.initialPan.x);
		this.y = Math.floor((fullSize - this.height) * -this.initialPan.y);

		// offset of viewer in the window
		for (var node = this.viewer; node; node = node.offsetParent) {
			this.top += node.offsetTop;
			this.left += node.offsetLeft;
		}

		for (var child = this.viewer.firstChild; child; child = child.nextSibling) {
			if (child.className == GSIV.SURFACE_STYLE_CLASS) {
				this.surface = child;
				child.backingBean = this;
			}
			else if (child.className == GSIV.WELL_STYLE_CLASS) {
				this.well = child;
				child.backingBean = this;
			}
			else if (child.className == GSIV.CONTROLS_STYLE_CLASS) {
				for (var control = child.firstChild; control; control = control.nextSibling) {
					if (control.className) {
						control.onclick = GSIV[control.className + 'Handler'];
					}
				}
			}
		}

		this.viewer.backingBean = this;
		this.surface.style.cursor = GSIV.GRAB_MOUSE_CURSOR;
		// BEGIN ExpressServer mods
		this.well.style.cursor = GSIV.GRAB_MOUSE_CURSOR;
		// END ExpressServer mods

		this.prepareTiles();
		this.initialized = true;
	},

	prepareTiles : function() {
		var rows = Math.ceil(this.height / this.tileSize) + 1;
		var cols = Math.ceil(this.width / this.tileSize) + 1;

		for (var c = 0; c < cols; c++) {
			var tileCol = [];

			for (var r = 0; r < rows; r++) {
				/**
				 * element is the DOM element associated with this tile
				 * posx/posy are the pixel offsets of the tile
				 * xIndex/yIndex are the index numbers of the tile segment
				 * qx/qy represents the quadrant location of the tile
				 */
				var tile = {
					'element' : null,
					'posx' : 0,
					'posy' : 0,
					'xIndex' : c,
					'yIndex' : r,
					'qx' : c,
					'qy' : r
				};

				tileCol.push(tile);
			}
		
			this.tiles.push(tileCol);
		}


		// BEGIN ExpressServer mods
		// NOTE: IE does not seem to always send mouse events to the top DIV.
		//       So, registering mouse events to both surface and well.
		this.surface.onmousedown = this.well.onmousedown = GSIV.mousePressedHandler;
		this.surface.onmouseup = this.surface.onmouseout = this.well.onmouseup = this.well.onmouseout = GSIV.mouseReleasedHandler;

		if (this.surface.addEventListener)
			this.surface.addEventListener("DOMMouseScroll", GSIV.scrollWheelZoomHandler, false); /* For mozilla. */
		else 
			window.onmousewheel=this.surface.onmousewheel=this.well.onmousewheel=GSIV.scrollWheelZoomHandler; /* IE. */

		this.surface.ondblclick = this.well.ondblclick = GSIV.doubleClickHandler;

		// END ExpressServer mods

		if (GSIV.USE_KEYBOARD) {
			// BEGIN ExpressServer mods
			document.onkeypress = window.onkeypress = GSIV.keyboardMoveHandler;
			document.onkeydown = window.onkeydown = GSIV.keyboardZoomHandler;
			// END ExpressServer mods
		}

		this.positionTiles();
	},

	/**
	 * Position the tiles based on the x, y coordinates of the
	 * viewer, taking into account the motion offsets, which
	 * are calculated by a motion event handler.
	 */
	positionTiles : function(motion, reset) {
		// default to no motion, just setup tiles
		if (typeof motion == 'undefined') {
			motion = { 'x' : 0, 'y' : 0 };
		}

		for (var c = 0; c < this.tiles.length; c++) {
			for (var r = 0; r < this.tiles[c].length; r++) {
				var tile = this.tiles[c][r];

				tile.posx = (tile.xIndex * this.tileSize) + this.x + motion.x;
				tile.posy = (tile.yIndex * this.tileSize) + this.y + motion.y;

				var visible = true;

				if (tile.posx > this.width) {
					// tile moved out of view to the right
					// consider the tile coming into view from the left
					do {
						tile.xIndex -= this.tiles.length;
						tile.posx = (tile.xIndex * this.tileSize) + this.x + motion.x;
					} while (tile.posx > this.width);

					if (tile.posx + this.tileSize < 0) {
						visible = false;
					}

				} else {
					// tile may have moved out of view from the left
					// if so, consider the tile coming into view from the right
					while (tile.posx < -this.tileSize) {
						tile.xIndex += this.tiles.length;
						tile.posx = (tile.xIndex * this.tileSize) + this.x + motion.x;
					}

					if (tile.posx > this.width) {
						visible = false;
					}
				}

				if (tile.posy > this.height) {
					// tile moved out of view to the bottom
					// consider the tile coming into view from the top
					do {
						tile.yIndex -= this.tiles[c].length;
						tile.posy = (tile.yIndex * this.tileSize) + this.y + motion.y;
					} while (tile.posy > this.height);

					if (tile.posy + this.tileSize < 0) {
						visible = false;
					}

				} else {
					// tile may have moved out of view to the top
					// if so, consider the tile coming into view from the bottom
					while (tile.posy < -this.tileSize) {
						tile.yIndex += this.tiles[c].length;
						tile.posy = (tile.yIndex * this.tileSize) + this.y + motion.y;
					}

					if (tile.posy > this.height) {
						visible = false;
					}
				}

				// initialize the image object for this quadrant
				if (!this.initialized) {
					this.assignTileImage(tile, true);
					tile.element.style.top = tile.posy + 'px';
					tile.element.style.left = tile.posx + 'px';
				}

				// display the image if visible
				if (visible) {
					this.assignTileImage(tile);
				}

				// seems to need this no matter what
				tile.element.style.top = tile.posy + 'px';
				tile.element.style.left = tile.posx + 'px';
			}
		}

		// reset the x, y coordinates of the viewer according to motion
		if (reset) {
			this.x += motion.x;
			this.y += motion.y;
		}
	},

	/**
	 * Determine the source image of the specified tile based
	 * on the zoom level and position of the tile.  If forceBlankImage
	 * is specified, the source should be automatically set to the
	 * null tile image.  This method will also setup an onload
	 * routine, delaying the appearance of the tile until it is fully
	 * loaded, if configured to do so.
	 */
	assignTileImage : function(tile, forceBlankImage) {
		var tileImgId, src;
		var useBlankImage = (forceBlankImage ? true : false);

		// check if image has been scrolled too far in any particular direction
		// and if so, use the null tile image
		if (!useBlankImage) {
			var left = tile.xIndex < 0;
			var high = tile.yIndex < 0;
			var right = tile.xIndex >= Math.pow(2, this.zoomLevel);
			var low = tile.yIndex >= Math.pow(2, this.zoomLevel);
			if (high || left || low || right) {
				useBlankImage = true;
			}
		}

		// BEGIN ExpressServer mods
		var imageInTile = this.anyPartOfImageInTile(tile.xIndex, tile.yIndex, this.maxZoomLevel, this.zoomLevel, this.tileSize, this.imageWidth, this.imageHeight);
		if (!imageInTile) {
			useBlankImage = true;
		}
		// END ExpressServer mods

		if (useBlankImage) {
			tileImgId = 'blank:' + tile.qx + ':' + tile.qy;
			src = this.cache['blank'].src;
		}
		else {
			// BEGIN ExpressServer mods
			tileImgId = src = this.computeExpressServerURL(tile.xIndex, tile.yIndex, this.maxZoomLevel, this.zoomLevel, this.tileSize, this.imageWidth, this.imageHeight);
			// END ExpressServer mods
		}

		// only remove tile if identity is changing
		if (tile.element != null &&
			tile.element.parentNode != null &&
			tile.element.relativeSrc != src) {
			this.well.removeChild(tile.element);
		}

		var tileImg = this.cache[tileImgId];
		// create cache if not exist
		if (tileImg == null) {
			tileImg = this.cache[tileImgId] = this.createPrototype(src);
		}

		if (useBlankImage || !GSIV.USE_LOADER_IMAGE || tileImg.complete || (tileImg.image && tileImg.image.complete)) {
			tileImg.onload = function() {};
			if (tileImg.image) {
				tileImg.image.onload = function() {};
			}

			if (tileImg.parentNode == null) {
				tile.element = this.well.appendChild(tileImg);
			}
		}
		else {
			var loadingImgId = 'loading:' + tile.qx + ':' + tile.qy;
			var loadingImg = this.cache[loadingImgId];
			if (loadingImg == null) {
				loadingImg = this.cache[loadingImgId] = this.createPrototype(this.cache['loading'].src);
			}

			loadingImg.targetSrc = tileImgId;

			var well = this.well;
			tile.element = well.appendChild(loadingImg);
			tileImg.onload = function() {
				// make sure our destination is still present
				if (loadingImg.parentNode && loadingImg.targetSrc == tileImgId) {
					tileImg.style.top = loadingImg.style.top;
					tileImg.style.left = loadingImg.style.left;
					well.replaceChild(tileImg, loadingImg);
					tile.element = tileImg;
				}

				tileImg.onload = function() {};
				return false;
			}

			// konqueror only recognizes the onload event on an Image
			// javascript object, so we must handle that case here
			if (!GSIV.DOM_ONLOAD) {
				tileImg.image = new Image();
				tileImg.image.onload = tileImg.onload;
				tileImg.image.src = tileImg.src;
			}
		}
	},

	createPrototype : function(src) {
		var img = document.createElement('img');
		img.src = src;
		img.relativeSrc = src;
		img.className = GSIV.TILE_STYLE_CLASS;
		// BEGIN ExpressServer mods
		//img.style.width = this.tileSize + 'px';
		//img.style.height = this.tileSize + 'px';
		// END ExpressServer mods
		return img;
	},

	addViewerMovedListener : function(listener) {
		this.viewerMovedListeners.push(listener);
	},

	addViewerZoomedListener : function(listener) {
		this.viewerZoomedListeners.push(listener);
	},

	/**
	 * Notify listeners of a zoom event on the viewer.
	 */
	notifyViewerZoomed : function() {
		var percentage = (100/(this.maxZoomLevel + 1)) * (this.zoomLevel + 1);
		for (var i = 0; i < this.viewerZoomedListeners.length; i++) {
			this.viewerZoomedListeners[i].viewerZoomed(
				new GSIV.ZoomEvent(this.x, this.y, this.zoomLevel, percentage)
			);
		}
	},

	/**
	 * Notify listeners of a move event on the viewer.
	 */
	notifyViewerMoved : function(coords) {
		if (typeof coords == 'undefined') {
			coords = { 'x' : 0, 'y' : 0 };
		}

		for (var i = 0; i < this.viewerMovedListeners.length; i++) {
			this.viewerMovedListeners[i].viewerMoved(
				new GSIV.MoveEvent(
					this.x + (coords.x - this.mark.x),
					this.y + (coords.y - this.mark.y)
				)
			);
		}
	},

	zoom : function(direction) {
		// ensure we are not zooming out of range
		if (this.zoomLevel + direction < 0) {
			// BEGIN ExpressServer mods
			//alert(GSIV.MSG_BEYOND_MIN_ZOOM);
			// END ExpressServer mods
			return;
		}
		else if (this.zoomLevel + direction > this.maxZoomLevel) {
			// BEGIN ExpressServer mods
			//alert(GSIV.MSG_BEYOND_MAX_ZOOM);
			// END ExpressServer mods
			return;
		}

		this.blank();

		var coords = { 'x' : Math.floor(this.width / 2), 'y' : Math.floor(this.height / 2) };

		var before = {
			'x' : (coords.x - this.x),
			'y' : (coords.y - this.y)
		};

		var after = {
			'x' : Math.floor(before.x * Math.pow(2, direction)),
			'y' : Math.floor(before.y * Math.pow(2, direction))
		};

		this.x = coords.x - after.x;
		this.y = coords.y - after.y;
		this.zoomLevel += direction;
		
		// BEGIN ExpressServer mods
		this.ensureImageInView();
		// END ExpressServer mods

		this.positionTiles();
		
		this.notifyViewerZoomed();
	},
	
	// BEGIN ExpressServer mods
	zoomAbsolute : function(newZoomLevel) {
		// ensure we are not zooming out of range
		if (newZoomLevel < 0) {
			alert(GSIV.MSG_BEYOND_MIN_ZOOM);
			return;
		}
		else if (newZoomLevel > this.maxZoomLevel) {
			alert(GSIV.MSG_BEYOND_MAX_ZOOM);
			return;
		}

		this.blank();

		var coords = { 'x' : Math.floor(this.width / 2), 'y' : Math.floor(this.height / 2) };

		var before = {
			'x' : (coords.x - this.x),
			'y' : (coords.y - this.y)
		};

		var direction = newZoomLevel - this.zoomLevel;

		var after = {
			'x' : Math.floor(before.x * Math.pow(2, direction)),
			'y' : Math.floor(before.y * Math.pow(2, direction))
		};

		this.x = coords.x - after.x;
		this.y = coords.y - after.y;
		this.zoomLevel = newZoomLevel;

		// BEGIN ExpressServer mods
		this.ensureImageInView();
		// END ExpressServer mods

		this.positionTiles();

		this.notifyViewerZoomed();
	},
	// END ExpressServer mods

	/** 
	 * Clear all the tiles from the well for a complete reinitialization of the
	 * viewer. At this point the viewer is not considered to be initialized.
	 */
	clear : function() {
		this.blank();
		this.initialized = false;
		this.tiles = [];
	},

	/**
	 * Remove all tiles from the well, which effectively "hides"
	 * them for a repaint.
	 */
	blank : function() {
		for (imgId in this.cache) {
			var img = this.cache[imgId];
			img.onload = function() {};
			if (img.image) {
				img.image.onload = function() {};
			}

			if (img.parentNode != null) {
				this.well.removeChild(img);
			}
		}
	},

	/**
	 * Method specifically for handling a mouse move event.  A direct
	 * movement of the viewer can be achieved by calling positionTiles() directly.
	 */
	moveViewer : function(coords) {
		this.positionTiles({ 'x' : (coords.x - this.mark.x), 'y' : (coords.y - this.mark.y) });
		this.notifyViewerMoved(coords);
	},

	/**
	 * Make the specified coords the new center of the image placement.
	 * This method is typically triggered as the result of a double-click
	 * event.  The calculation considers the distance between the center
	 * of the viewable area and the specified (viewer-relative) coordinates.
	 * If absolute is specified, treat the point as relative to the entire
	 * image, rather than only the viewable portion.
	 */
	recenter : function(coords, absolute) {
		if (absolute) {
			coords.x += this.x;
			coords.y += this.y;
		}

		var motion = {
			'x' : Math.floor((this.width / 2) - coords.x),
			'y' : Math.floor((this.height / 2) - coords.y)
		};

		if (motion.x == 0 && motion.y == 0) {
			return;
		}

		if (GSIV.USE_SLIDE) {
			var target = motion;
			var x, y;
			// handle special case of vertical movement
			if (target.x == 0) {
				x = 0;
				y = this.slideAcceleration;
			}
			else {
				var slope = Math.abs(target.y / target.x);
				x = Math.round(Math.pow(Math.pow(this.slideAcceleration, 2) / (1 + Math.pow(slope, 2)), .5));
				y = Math.round(slope * x);
			}
			
			motion = {
				'x' : Math.min(x, Math.abs(target.x)) * (target.x < 0 ? -1 : 1),
				'y' : Math.min(y, Math.abs(target.y)) * (target.y < 0 ? -1 : 1)
			}
		}

		this.positionTiles(motion, true);
		this.notifyViewerMoved();

		if (!GSIV.USE_SLIDE) {
			return;
		}

		var newcoords = {
			'x' : coords.x + motion.x,
			'y' : coords.y + motion.y
		};

		var self = this;
		// TODO: use an exponential growth rather than linear (should also depend on how far we are going)
		// FIXME: this could be optimized by calling positionTiles directly perhaps
		this.slideAcceleration += GSIV.SLIDE_ACCELERATION_FACTOR;
		this.slideMonitor = setTimeout(function() { self.recenter(newcoords); }, GSIV.SLIDE_DELAY );
	},

	resize : function() {
		// IE fires a premature resize event
		if (!this.initialized) {
			return;
		}

		this.viewer.style.display = 'none';
		this.clear();

		var before = {
			'x' : Math.floor(this.width / 2),
			'y' : Math.floor(this.height / 2)
		};

		if (this.border >= 0) {
			this.fitToWindow(this.border);
		}

		this.prepareTiles();

		var after = {
			'x' : Math.floor(this.width / 2),
			'y' : Math.floor(this.height / 2)
		};

		this.x += (after.x - before.x);
		this.y += (after.y - before.y);
		this.positionTiles();
		this.viewer.style.display = '';
		this.initialized = true;
		this.notifyViewerMoved();
	},

	/**
	 * Resolve the coordinates from this mouse event by subtracting the
	 * offset of the viewer in the browser window (or frame).  This does
	 * take into account the scroll offset of the page.
	 */
	resolveCoordinates : function(e) {
		return {
			'x' : (e.pageX || (e.clientX + (document.documentElement.scrollLeft || document.body.scrollLeft))) - this.left,
			'y' : (e.pageY || (e.clientY + (document.documentElement.scrollTop || document.body.scrollTop))) - this.top
		}
	},

	press : function(coords) {
		this.activate(true);
		this.mark = coords;
	},

	release : function(coords) {
		this.activate(false);
		var motion = {
			'x' : (coords.x - this.mark.x),
			'y' : (coords.y - this.mark.y)
		};

		this.x += motion.x;
		this.y += motion.y;
		this.mark = { 'x' : 0, 'y' : 0 };
	},

	/**
	 * Activate the viewer into motion depending on whether the mouse is pressed or
	 * not pressed.  This method localizes the changes that must be made to the
	 * layers.
	 */
	activate : function(pressed) {
		this.pressed = pressed;
		this.surface.style.cursor = this.well.style.cursor = (pressed ? GSIV.GRABBING_MOUSE_CURSOR : GSIV.GRAB_MOUSE_CURSOR);
		this.surface.onmousemove = this.well.onmousemove = (pressed ? GSIV.mouseMovedHandler : function() {});
	},

	/**
	 * Check whether the specified point exceeds the boundaries of
	 * the viewer's primary image.
	 */
	pointExceedsBoundaries : function(coords) {
		return (coords.x < this.x ||
			coords.y < this.y ||
			coords.x > (this.tileSize * Math.pow(2, this.zoomLevel) + this.x) ||
			coords.y > (this.tileSize * Math.pow(2, this.zoomLevel) + this.y));
	},

	// QUESTION: where is the best place for this method to be invoked?
	resetSlideMotion : function() {
		if (this.slideMonitor != 0) {
			clearTimeout(this.slideMonitor);
			this.slideMonitor = 0;
		}

		this.slideAcceleration = 0;
	},

	// BEGIN ExpressServer mods
	anyPartOfImageInTile : function(tileXIndex, tileYIndex, maxZoomLevel, zoomLevel, tileSize, imageWidth, imageHeight) {
		var widthInTile = this.thisDimensionImageInTile(tileXIndex, maxZoomLevel, zoomLevel, tileSize, imageWidth);
		var heightInTile = this.thisDimensionImageInTile(tileYIndex, maxZoomLevel, zoomLevel, tileSize, imageHeight);
		if (widthInTile && heightInTile) {
			return true;
		}
		return false;
	},
	
	thisDimensionImageInTile : function(tileIndex, maxZoomLevel, zoomLevel, tileSize, totalSize) {
		
		//alert('tileIndex= ' + tileIndex + ' maxZoomLevel= ' + maxZoomLevel + ' zoomLevel= ' + zoomLevel + ' tileSize= ' + tileSize + ' totalSize= ' + totalSize);
		
		var magnification = Math.pow(2, (maxZoomLevel - zoomLevel));
		var imageTileSize = tileSize * magnification;
		var startPixel =  tileIndex * imageTileSize;

		// if start is beyond image edge, then will be returning nothing
		if (startPixel >= totalSize) {
			return false;
		}
		return true;
	},
	
	computeExpressServerURL : function(tileXIndex, tileYIndex, maxZoomLevel, zoomLevel, tileSize, imageWidth, imageHeight) {
		var widthInfo = this.computeDimensionInfo(tileXIndex, maxZoomLevel, zoomLevel, tileSize, imageWidth);
		var heightInfo = this.computeDimensionInfo(tileYIndex, maxZoomLevel, zoomLevel, tileSize, imageHeight);
		return this.tileUrlProvider.assembleUrl(widthInfo[2], heightInfo[2], widthInfo[0], widthInfo[1], heightInfo[0], heightInfo[1]);
	},
	
	computeDimensionInfo : function(tileIndex, maxZoomLevel, zoomLevel, tileSize, totalSize) {
		var info = new Array(3);
		
		var magnification = Math.pow(2, (maxZoomLevel - zoomLevel));
		var imageTileSize = tileSize * magnification;
		
		var startPixel =  tileIndex * imageTileSize;
		var endPixel =  (tileIndex + 1) * imageTileSize;
		var tileSizeLimited = tileSize;

		// if start is beyond image edge, then will be returning nothing
		if (startPixel > totalSize) {
			tileSizeLimited = 0;
			startPixel = totalSize;
			endPixel = totalSize;
		}
		else {
			// if end is beyond image edge, then limit size of returned image
			if (endPixel > totalSize) {
				endPixel = totalSize;
				tileSizeLimited = ((endPixel - startPixel) / imageTileSize) * tileSize;
			}
		}
		
		var startPercentage = startPixel / totalSize;
		var endPercentage = endPixel / totalSize;
		
		info[0] = startPercentage;
		info[1] = endPercentage;
		info[2] = tileSizeLimited;
		return info;
	},
	
	limitPosition : function(curr, min, max) {
		var value = curr;
		
		if (value < min) {
			value = min;
		}
		
		if (value > max) {
			value = max;
		}
		
		return value;
	},
	
	viewedImageURL : function(desiredWidth, desiredHeight) {
		
		var currX = this.x;
		var currY = this.y;
		
		var magnification = Math.pow(2, (this.maxZoomLevel - this.zoomLevel));

		var startX = (0 - currX) * magnification;
		startX = this.limitPosition(startX, 0, this.imageWidth);
		var startXPercentage = startX / this.imageWidth;

		var startY = (0 - currY) * magnification;
		startY = this.limitPosition(startY, 0, this.imageHeight);
		var startYPercentage = startY / this.imageHeight;
		
		var endX = startX + (desiredWidth * magnification);
		endX = this.limitPosition(endX, 0, this.imageWidth);
		var endXPercentage = endX / this.imageWidth;
		
		var endY = startY + (desiredHeight * magnification);
		endY = this.limitPosition(endY, 0, this.imageHeight);
		var endYPercentage = endY / this.imageHeight;
		
		return this.tileUrlProvider.assembleUrl(desiredWidth, desiredHeight, startXPercentage, endXPercentage, startYPercentage, endYPercentage);
		
	},	

	fullImageURL : function() {
		var url = this.tileUrlProvider.assembleUrl(this.imageWidth, this.imageHeight, 0, 1, 0, 1);
		return url + "&method=scale";
	},	
	
	reducedImageURL : function(maxWidth, maxHeight) {
		
		var desiredWidth = maxWidth;
		var desiredHeight = maxHeight;
		var widthPercentage = maxWidth / this.imageWidth;
		var heightPercentage = maxHeight / this.imageHeight;
		
		// if width more constrained, 
		if (widthPercentage < heightPercentage)
		{
		    desiredHeight = parseInt(this.imageHeight * widthPercentage); 
		}
		else
		{
		    desiredWidth = parseInt(this.imageWidth * heightPercentage); 
		}
		
		var url = this.tileUrlProvider.assembleUrl(desiredWidth, desiredHeight, 0, 1, 0, 1);

		return url + "&method=scale";
		
	},	
	
	ensureImageInView : function() {
		
		var magnification = Math.pow(2, (this.maxZoomLevel - this.zoomLevel));

		var imageWidthForZoom = this.imageWidth / magnification;
		var imageHeightForZoom = this.imageHeight / magnification;

		if (this.x < 0) {
			if ((0 - this.x) > imageWidthForZoom) {
				this.x = 0;
				this.y = 0;
			}
		}
		else {
			if (this.x > this.width) {
				this.x = 0;
				this.y = 0;
			}
		}

		if (this.y < 0) {
			if ((0 - this.y) > imageHeightForZoom) {
				this.x = 0;
				this.y = 0;
			}
		}
		else {
			if (this.y > this.height) {
				this.x = 0;
				this.y = 0;
			}
		}
		
		return true;
		
	}	
	// END ExpressServer mods
};

// BEGIN ExpressServer mods
GSIV.TileUrlProvider = function(baseUri, catalog, item) {
	this.baseUri = baseUri;
	this.catalog = catalog;
	this.item = item;
};

GSIV.TileUrlProvider.prototype = {
	assembleUrl: function(width, height, xMin, xMax, yMin, yMax) {
		if (this.catalog == 'none') return this.baseUri;
		else {
			return this.baseUri + 
				'?cat=' + this.catalog +
				'&item=' + this.item + 
				'&wid=' + width + 
				'&hei=' + height + 
				'&rgn=' + xMin + 
				',' + yMin + 
				',' + xMax + 
				',' + yMax; 
		}
	}
};
// END ExpressServer mods

GSIV.mousePressedHandler = function(e) {
	e = e ? e : window.event;
	// only grab on left-click
	if (e.button < 2) {
		var self = this.backingBean;
		var coords = self.resolveCoordinates(e);
		if (self.pointExceedsBoundaries(coords)) {
			e.cancelBubble = true;
		}
		else {
			self.press(coords);
		}
	}

	// NOTE: MANDATORY! must return false so event does not propagate to well!
	return false;
};

GSIV.mouseReleasedHandler = function(e) {
	e = e ? e : window.event;
	var self = this.backingBean;
	if (self.pressed) {
		// OPTION: could decide to move viewer only on release, right here
		self.release(self.resolveCoordinates(e));
	}
};

GSIV.mouseMovedHandler = function(e) {
	e = e ? e : window.event;
	var self = this.backingBean;
	self.moveCount++;
	if (self.moveCount % GSIV.MOVE_THROTTLE == 0) {
		self.moveViewer(self.resolveCoordinates(e));
	}
};

GSIV.zoomInHandler = function(e) {
	e = e ? e : window.event;
	var self = this.parentNode.parentNode.backingBean;
	self.zoom(1);
	return false;
};

GSIV.zoomOutHandler = function(e) {
	e = e ? e : window.event;
	var self = this.parentNode.parentNode.backingBean;
	self.zoom(-1);
	return false;
};

GSIV.doubleClickHandler = function(e) {
	e = e ? e : window.event;
	var self = this.backingBean;
	coords = self.resolveCoordinates(e);
	if (!self.pointExceedsBoundaries(coords)) {
		self.resetSlideMotion();
		self.recenter(coords);
	}
};

// BEGIN ExpressServer mods
GSIV.keyboardMoveHandler = function(e) {
	e = e ? e : window.event;
	key = e.which ? e.which : e.keyCode;
	for (var i = 0; i < GSIV.VIEWERS.length; i++) {
		var viewer = GSIV.VIEWERS[i];
		if (key == 49)  //1
				viewer.positionTiles({'x': -GSIV.MOVE_THROTTLE,'y': GSIV.MOVE_THROTTLE}, true);
		if (key == 50)  //2
				viewer.positionTiles({'x': 0,'y': GSIV.MOVE_THROTTLE}, true);
		if (key == 51)  //3
				viewer.positionTiles({'x': GSIV.MOVE_THROTTLE,'y': GSIV.MOVE_THROTTLE}, true);
		if (key == 54)  //6
				viewer.positionTiles({'x': GSIV.MOVE_THROTTLE,'y': 0}, true);
		if (key == 55)  //7
				viewer.positionTiles({'x': -GSIV.MOVE_THROTTLE,'y': -GSIV.MOVE_THROTTLE}, true);
		if (key == 56)  //8
				viewer.positionTiles({'x': 0,'y': -GSIV.MOVE_THROTTLE}, true);
		if (key == 57)  //9
				viewer.positionTiles({'x': GSIV.MOVE_THROTTLE,'y': -GSIV.MOVE_THROTTLE}, true);
		if (key == 52)  //4
				viewer.positionTiles({'x': -GSIV.MOVE_THROTTLE,'y': 0}, true);
	}
};
// END ExpressServer mods

GSIV.keyboardZoomHandler = function(e) {
	e = e ? e : window.event;
	for (var i = 0; i < GSIV.VIEWERS.length; i++) {
		var viewer = GSIV.VIEWERS[i];
		if (e.keyCode == 109)
				viewer.zoom(-1);
		if (e.keyCode == 107)
				viewer.zoom(1);
	}
};

GSIV.MoveEvent = function(x, y) {
	this.x = x;
	this.y = y;
};

GSIV.ZoomEvent = function(x, y, level, percentage) {
	this.x = x;
	this.y = y;
	this.percentage = percentage;
	this.level = level;
};

// BEGIN ExpressServer mods
GSIV.scrollWheelZoomHandler = function(e) {
	if (!e) e = window.event; // For IE
     	var delta = 0;
     	if (e.wheelDelta)             // IE case
          		delta = e.wheelDelta;
     	else if (e.detail)            // Mozilla case
          		delta = -e.detail;       // Mozilla and IE deltas differ in sign
     	if (delta)
          		delta = delta<0?-1:+1;
	for (var i = 0; i < GSIV.VIEWERS.length; i++) {
		var viewer = GSIV.VIEWERS[i];
		if (delta>0)
			viewer.zoom(1);
		else
			viewer.zoom(-1);
	}
};
// END ExpressServer mods

