/**
* @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;
};