Source: maps/limbs/Limb.js


/**
 * @class
 * Displays a label on the border of a Google Map.
 * If multiple labels are to be displayed you are recommended to use a LimbFactory.
 * 
 * @constructor
 * @param {lucid.maps.limbs.LimbOptions|google.maps.Marker} optionsOrMarker  Settings for the LIMB to be rendered or the Marker to be tracked.
 */
lucid.maps.limbs.Limb = function( optionsOrMarker )
{
	var thisRenderer = this;
	
	var limbOptions = (optionsOrMarker.constructor == (new google.maps.Marker()).constructor) ? { marker: optionsOrMarker } : optionsOrMarker;
	
	var hidden;
	var inTheBorder;
	var marker;
	var iconDiv;
	var mapViewChangeListener;
	var syncClickWithMarker;
	var layoutManager;
	var generateTooltip;
	
	
	// This initialise function is called at the end of the constructor.
	function init()
	{
		hidden = limbOptions.hidden;
		marker = limbOptions.marker;
		syncClickWithMarker = false;
		layoutManager = (typeof limbOptions.layout === "object") ? limbOptions.layout : new lucid.maps.limbs.layout.DefaultLayoutStrategy();
		generateTooltip = (typeof limbOptions.tooltipFactory === "function") ? limbOptions.tooltipFactory : lucid.maps.limbs.tooltip.standardTooltip;
		
		if (limbOptions.independent !== false)
		{
			mapViewChangeListener = google.maps.event.addListener( thisRenderer.getMap(), "bounds_changed", handleRefresh );
		}
		
		createIconDiv();
		
		thisRenderer.refresh();
	}
	
	
	/**
	 * Destroy this instance and any associated resources.
	 * This method should be called when the instance is no longer required.
	 */
	this.destroy = function()
	{
		marker = null;
		
		if (mapViewChangeListener)
		{
			google.maps.event.removeListener( mapViewChangeListener );
		}
		
		removeIconDiv();
	};
	
	function createIconDiv()
	{
		var icon = (limbOptions.icon) ? limbOptions.icon : marker.getIcon();
		
		iconDiv = jQuery( "<div></div>" );
		iconDiv.css( "background", "url( '" + icon.url + "' ) no-repeat center center" );
		iconDiv.css( "position", "absolute" );
		iconDiv.css( "width", icon.size.width );
		iconDiv.css( "height", icon.size.height );
		iconDiv.css( "zIndex", layoutManager.getMinZIndex() );
		iconDiv.hide();
		
		if (limbOptions.clickable !== false)
		{
			if (marker.getTitle() != null)
			{
				iconDiv.attr( "title", marker.getTitle() );
			}
			
			iconDiv.css( "cursor", "pointer" );
			iconDiv.click( handleClick );
		}
		
		iconDiv.appendTo( getLimbElement() );
	}
	
	function removeIconDiv()
	{
		if (iconDiv)
		{
			iconDiv.remove();
			iconDiv = null;
		}
	}
	
	/**
	 * Temporarily hide the label.
	 * This does not remove the LIMB, it just takes it off display.
	 * Call this if you hide the map element.
	 */
	this.hide = function()
	{
		hidden = true;
		
		if (iconDiv)
		{
			iconDiv.hide();
		}
	};
	
	/**
	 * Re-display the label after being hidden with a call to 'hide'.
	 */
	this.show = function()
	{
		hidden = false;
		
		if (iconDiv)
		{
			if (inTheBorder === true)
			{
				iconDiv.show();
			}
		}
	};
	
	/**
	 * @return {google.maps.Map}  The map this LIMB is associated with.
	 */
	this.getMap = function()
	{
		return marker.getMap();
	};
	
	/**
	 * @return {google.maps.Marker}  The marker this LIMB is associated with.
	 */
	this.getMarker = function()
	{
		return marker;
	};
	
	function getMapElement()
	{
		return jQuery( thisRenderer.getMap().getDiv() );
	}
	
	function getLimbElement()
	{
		return getMapElement().parent();
	}
	
	function handleClick()
	{
		google.maps.event.trigger( thisRenderer, "click", thisRenderer );
		
		if (syncClickWithMarker === true)
		{
			google.maps.event.trigger( marker, "click" );
		}
	}
	
	/**
	 * Set whether the click event on the LIMB should cause an effective click on the associated marker.
	 * 
	 * @param {boolean} synchonised  Whether the click events should be synchonised.
	 */
	this.setSyncClickWithMarker = function( synchonised )
	{
		syncClickWithMarker = synchonised;
	};
	
	function handleRefresh()
	{
		thisRenderer.refresh();
	}
	
	/**
	 * Refresh the display of the LIMB on the page.
	 */
	this.refresh = function()
	{
		var map = this.getMap();
		var mapElement = getMapElement();
		var markerLocation = marker.getPosition();

		var mapBounds = map.getBounds();
		if (   (typeof mapBounds === "undefined")        // The getBounds method returns undefined when the map is still initialising.
		    || (mapBounds.contains( markerLocation ))  )
		{
			inTheBorder = false;
			
			iconDiv.hide();
			return;
		}
		else
		{
			inTheBorder = true;
			
			if (hidden === true)
			{
				iconDiv.hide();
			}
			else
			{
				iconDiv.show();
			}
		}

		var viewCentre = map.getCenter();
		var heading = computeHeading();
		var angle = 90 - heading;
		var angleInRadians = angle * Math.PI / 180;
		var mapWidth = mapElement.width();
		var mapHeight = mapElement.height();

		var intersection = computeIntersectionOnLeftOrRightEdge();
		if (Math.abs( intersection.y ) > (mapHeight / 2))
		// The intersection hits the top/bottom edge before the left/right edge.
		{
			intersection = computeIntersectionOnTopOrBottomEdge();
		}

		// The intersection coords are relative to the centre of the map.
		// Offset this to an origin in the top-left corner.
		// Also reverse the y-axis (positive values point up the Maths plane, but point down the screen).
		var intersectionFromTopLeft = { x: (mapWidth / 2) + intersection.x,
		                                y: (mapHeight / 2) - intersection.y };
		
		// Centre the icon on that position by applying an offset.
		// TODO Use the marker's icon's offset.
		var display = {};
		display.x = Math.round( intersectionFromTopLeft.x - (iconDiv.width() / 2) );
		display.y = Math.round( intersectionFromTopLeft.y - (iconDiv.height() / 2) );
		
		// The origin of these display coords are in the map element.
		// Offset these coords against the mapElement position to position the LIMB correctly on the screen.
		var mapPosition = mapElement.position();
		var outerWidthOffset = ( mapElement.outerWidth( true ) - mapElement.innerWidth() ) / 2;
		var outerHeightOffset = ( mapElement.outerHeight( true ) - mapElement.innerHeight() ) / 2;
		display.x = mapPosition.left + outerWidthOffset + display.x;
		display.y = mapPosition.top + outerHeightOffset + display.y;
		
		var mapDetails = { "viewCentre": viewCentre,
		                   "markerLocation": markerLocation,
		                   "heading": heading };
		
		layoutManager.layout( iconDiv, display, mapDetails );
		
		if (limbOptions.clickable !== false)
		{
			var limbLocation = computeLimbLocation();
			var distance = google.maps.geometry.spherical.computeDistanceBetween( limbLocation, markerLocation );
			
			iconDiv.attr( "title", generateTooltip( marker, distance, display, mapDetails ) );
		}
		// else: the LIMB does not respond to mouse hover; a tooltip is not needed 
		
		
		function computeHeading()
		{
			var heading = google.maps.geometry.spherical.computeHeading( viewCentre, markerLocation );

			// The function above can return a negative value.
			// The computeIntersectionOnLeftOrRightEdge/computeIntersectionOnTopOrBottomEdge rely on values being in the range 0 - 360.
			// So normalise the value now.
			while (heading > 360)
				heading -= 360;
			while (heading < 0)
				heading += 360;

			return heading;
		}

		function computeIntersectionOnLeftOrRightEdge()
		{
			// The intersection will be on either the left or right edge.
			var intersectionX = (heading < 180) ? (mapWidth / 2) : -(mapWidth / 2);

			var distanceToIntersection = intersectionX / Math.cos( angleInRadians );

			var intersectionY = distanceToIntersection * Math.sin( angleInRadians );

			return { x: intersectionX, y: intersectionY };
		}

		function computeIntersectionOnTopOrBottomEdge()
		{
			// The intersection will be on either the top or bottom edge.
			var intersectionY = ((heading > 90) && (heading < 270)) ? -(mapHeight / 2) : (mapHeight / 2);

			var distanceToIntersection = intersectionY / Math.sin( angleInRadians );

			var intersectionX = distanceToIntersection * Math.cos( angleInRadians );

			return { x: intersectionX, y: intersectionY };
		}
		
		function computeLimbLocation()
		{
			// We do this by converting the intersection coords from pixels to distance.
			// This is done by computing the scaling-factor between the element dimensions and the real-world distance.
			var mapHeightDistance = mapBounds.getNorthEast().lat() - mapBounds.getSouthWest().lat();
			var mapHeightScale = mapHeightDistance / mapHeight;
			var limbLocationLat = viewCentre.lat() + (intersection.y * mapHeightScale);
			
			var mapWidthDistance = mapBounds.getNorthEast().lng() - mapBounds.getSouthWest().lng();
			var mapWidthScale = mapWidthDistance / mapWidth;
			var limbLocationLng = viewCentre.lng() + (intersection.x * mapWidthScale);
			
			return new google.maps.LatLng( limbLocationLat, limbLocationLng );
		}
	}
	
	
	init();
};

/**
 * Indicates when the LIMB is clicked.
 *
 * @event lucid.maps.limbs.Limb#click
 * @type {object}
 */

/**
 * @type {object}
 * @property {boolean} [hidden]  Whether the LIMB is initially hidden. If so, it can be made visible with a call to show().
 * @property {boolean} [independent]  Whether this LIMB is independent of a lucid.maps.limbs.LimbFactory.
 *                                    It is more efficient for a group of LIMBs to be managed by a manager, but if
 *                                    a single LIMB is being displayed then the Limb will manage itself if
 *                                    you set this property to true. The default is true.
 * @property {google.maps.Marker} marker  The marker on the map which is to be displayed in the map border
 *                                        when the marker is outside the map's viewport.
 * @property {google.maps.Icon} [icon]  Icon specification which must contain the URL and size of the icon image to display.
 * @property {boolean} [clickable]  Whether the icon in the map border is clickable. If not defined the clickable setting is taken from the marker.
 * @property {lucid.maps.limbs.layout.LayoutStrategy} [layout]  A strategy for positioning the LIMB.
 *                                                              If not defined a DefaultLayoutStrategy will be used.
 * @property {function} [tooltipFactory]  A strategy for generating the tooltip shown when the user mouses-over the LIMB.
 *                                        NOTE: A tooltip is only shown if the LIMB is clickable.
 */
lucid.maps.limbs.LimbOptions = {};
// TODO Use the google.maps.Icon to specify the icon graphic - would need to support sprite origin and scaled size. Also read the size from a loaded image if it's not defined.

/**
 * Copy lucid.maps.limbs.LimbOptions from one instance to another.
 * Only the settings defined in the 'optionsToApply' object will be copied onto the 'options' object.
 * 
 * @param {lucid.maps.limbs.LimbOptions} options  The target instance.
 * @param {lucid.maps.limbs.LimbOptions} optionsToApply  Options that take precedence and should be copied into the target object.
 */
lucid.maps.limbs.applyLimbOptions = function( options, optionsToApply )
{
	if (typeof optionsToApply.hidden !== "undefined")
		options.hidden = optionsToApply.hidden;
	
	if (typeof optionsToApply.independent !== "undefined")
		options.independent = optionsToApply.independent;
	
	if (typeof optionsToApply.marker !== "undefined")
		options.marker = optionsToApply.marker;
	
	if (typeof optionsToApply.icon !== "undefined")
		options.icon = optionsToApply.icon;
	
	if (typeof optionsToApply.clickable !== "undefined")
		options.clickable = optionsToApply.clickable;
	
	if (typeof optionsToApply.layout !== "undefined")
		options.layout = optionsToApply.layout;
};