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) ? { target: optionsOrMarker } : optionsOrMarker;
	
	var hidden;
	var inTheBorder;
	var target;
	var iconDiv;
	var mapViewChangeListener;
	var syncClickWithTarget;
	var layoutStrategy;
	var locationStrategy;
	var computeLocation;
	var generateTooltip;
	
	
	// This initialise function is called at the end of the constructor.
	function init()
	{
		hidden = limbOptions.hidden;
		target = limbOptions.target;
		syncClickWithTarget = (typeof limbOptions.syncClick === "boolean") ? limbOptions.syncClick : false;
		layoutStrategy = (typeof limbOptions.layout === "object") ? limbOptions.layout : new lucid.maps.limbs.layout.DefaultLayoutStrategy();
		locationStrategy = (typeof limbOptions.location === "object") ? limbOptions.location : new lucid.maps.limbs.location.BoundsEdgeLocationStrategy();
		generateTooltip = (typeof limbOptions.tooltipFactory === "function") ? limbOptions.tooltipFactory : lucid.maps.limbs.tooltip.standardTooltip;
		
		computeLocation = locationStrategy.getMethodForTargetType( target );
		
		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()
	{
		target = null;
		
		if (mapViewChangeListener)
		{
			google.maps.event.removeListener( mapViewChangeListener );
		}
		
		removeIconDiv();
	};
	
	function createIconDiv()
	{
		var icon = 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", layoutStrategy.getMinZIndex() );
		iconDiv.hide();
		
		if (limbOptions.clickable !== false)
		{
			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 target.getMap();
	};
	
	/**
	 * @return {google.maps.Marker|google.maps.Polyline|google.maps.Polygon|google.maps.Rectangle|google.maps.Circle}  The target this LIMB is associated with.
	 */
	this.getTarget = function()
	{
		return target;
	};
	
	function getTitle()
	{
		if (typeof limbOptions.title !== "undefined")
		{
			return limbOptions.title;
		}
		else if (typeof target.getTitle === "function")
		{
			return target.getTitle();
		}
		else
		{
			// The client app should have defined a title for a non-marker target.
			// Log a message and use a default title to ensure this mistake is visible.
			if (console)
			{
				console.log( "No title defined for LIMB." );
			}
			
			return "<no title>";
		}
	}
	
	function getIcon()
	{
		if (typeof limbOptions.icon !== "undefined")
		{
			return limbOptions.icon;
		}
		else if (typeof target.getIcon === "function")
		{
			return target.getIcon();
		}
		else
		{
			// The client app should have defined an icon for a non-marker target.
			// Use a dummy icon definition. However this won't show a broken image on the page.
			// So log a message to alert the developer to this mistake.
			if (console)
			{
				console.log( "No icon defined for LIMB." );
			}
			
			return { size: new google.maps.Size( 20, 20 ),
			         url: "no_icon.png" };
		}
	}
	
	function getMapElement()
	{
		return jQuery( thisRenderer.getMap().getDiv() );
	}
	
	function getLimbElement()
	{
		return getMapElement().parent();
	}
	
	function handleClick()
	{
		google.maps.event.trigger( thisRenderer, "click", thisRenderer );
		
		if (syncClickWithTarget === true)
		{
			google.maps.event.trigger( target, "click" );
		}
	}
	
	/**
	 * Set whether the click event on the LIMB should cause an effective click on the associated target.
	 * 
	 * @param {boolean} synchonised  Whether the click events should be synchonised.
	 */
	this.setSyncClickWithTarget = function( synchonised )
	{
		syncClickWithTarget = 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 mapBounds = map.getBounds();
		
		if (typeof mapBounds === "undefined")
		{
			// The getBounds method returns undefined when the map is still initialising.
			refreshTargetVisible();
			return;
		}
		
		var targetLocation = computeLocation( target, mapBounds );
		if (mapBounds.contains( targetLocation ))
		{
			refreshTargetVisible();
			return;
		}
		else
		{
			refreshTargetOffTheMap();
		}
		
		var viewCentre = map.getCenter();
		var heading = lucid.maps.geometry.computeNormalisedHeading( viewCentre, targetLocation );
		
		var headingAngle = lucid.maps.geometry.convertHeadingToAngle( heading );
		if (map.getTilt() > 0)
		{
			headingAngle = lucid.maps.geometry.tiltedAngle( headingAngle, map.getTilt() );
		}
		headingAngle = lucid.maps.geometry.normaliseAngle( headingAngle );
		
		var mapWidth = mapElement.width();
		var mapHeight = mapElement.height();
		
		var intersection = lucid.maps.geometry.computeBoundingBoxIntersectionAtAngle( mapWidth, mapHeight, 0, 0, headingAngle );
		
		// 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 target'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,
		                   "targetLocation": targetLocation,
		                   "heading": heading };
		
		layoutStrategy.layout( iconDiv, display, mapDetails );
		
		if (limbOptions.clickable !== false)
		{
			var limbLocation = computeLimbLocation();
			var distance = google.maps.geometry.spherical.computeDistanceBetween( limbLocation, targetLocation );
			
			iconDiv.attr( "title", generateTooltip( getTitle(), distance, display, mapDetails ) );
		}
		// else: the LIMB does not respond to mouse hover; a tooltip is not needed
		
		
		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 );
		}
	}
	
	function refreshTargetVisible()
	{
		inTheBorder = false;
		iconDiv.hide();
	}
	
	function refreshTargetOffTheMap()
	{
		inTheBorder = true;
		
		if (hidden === true)
		{
			iconDiv.hide();
		}
		else
		{
			iconDiv.show();
		}
	}
	
	
	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().
 *                               If not defined, the LIMB is shown by default.
 * @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|google.maps.Polyline|google.maps.Polygon|google.maps.Rectangle|google.maps.Circle} target  The target on the map which is to be displayed in the map border when the target is outside the map's viewport.
 * @property {string} [title]  Title describing the target location.
 *                             If not defined and the target is a Marker, then the LIMB will use the title of the Marker.
 *                             This property must be defined if the target is not a Marker.
 * @property {google.maps.Icon} [icon]  Icon specification which must contain the URL and size of the icon image to display.
 *                                      If not defined and the target is a Marker, then the LIMB will use the icon of the Marker.
 *                                      This property must be defined if the target is not a Marker.
 * @property {boolean} [clickable]  Whether the icon in the map border is clickable.
 *                                  If not defined, the clickable setting is taken from the target.
 *                                  NOTE: While it is possible to change the clickable setting of google.maps.Marker objects, all other targets default to being clickable.
 * @property {boolean} [syncClick]  Whether to synchronise the click on the LIMB with the same action performed when clicking on the target itself.
 *                                  The effect is to trigger a click event on the target when the LIMB is clicked.
 *                                  NOTE: This will not bring the target into view. You will need to assign another click handler to pan the map.
 *                                  If left undefined, this property defaults to false.
 * @property {lucid.maps.limbs.layout.LayoutStrategy} [layout]  A strategy for positioning the LIMB.
 *                                                              If not defined, a DefaultLayoutStrategy will be used.
 * @property {lucid.maps.limbs.location.LocationStrategy} [location]  A strategy for computing the location of the target.
 *                                                                    If not defined, a BoundsEdgeLocationStrategy will be used.
 * @property {function} [tooltipFactory]  A strategy for generating the tooltip shown when the user mouses-over the LIMB.
 *                                        If not defined, the lucid.maps.limbs.tooltip.standardTooltip tooltip factory will be used.
 *                                        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.target !== "undefined")
		options.target = optionsToApply.target;
	
	if (typeof optionsToApply.title !== "undefined")
		options.title = optionsToApply.title;
	
	if (typeof optionsToApply.icon !== "undefined")
		options.icon = optionsToApply.icon;
	
	if (typeof optionsToApply.clickable !== "undefined")
		options.clickable = optionsToApply.clickable;
	
	if (typeof optionsToApply.syncClick !== "undefined")
		options.syncClick = optionsToApply.syncClick;
	
	if (typeof optionsToApply.layout !== "undefined")
		options.layout = optionsToApply.layout;
	
	if (typeof optionsToApply.location !== "undefined")
		options.location = optionsToApply.location;
	
	if (typeof optionsToApply.tooltipFactory !== "undefined")
		options.tooltipFactory = optionsToApply.tooltipFactory;
};