diff options
Diffstat (limited to 'build/resources/main/static/plugins/jquery-mapael/jquery.mapael.js')
-rw-r--r-- | build/resources/main/static/plugins/jquery-mapael/jquery.mapael.js | 2781 |
1 files changed, 2781 insertions, 0 deletions
diff --git a/build/resources/main/static/plugins/jquery-mapael/jquery.mapael.js b/build/resources/main/static/plugins/jquery-mapael/jquery.mapael.js new file mode 100644 index 0000000..90d2599 --- /dev/null +++ b/build/resources/main/static/plugins/jquery-mapael/jquery.mapael.js @@ -0,0 +1,2781 @@ +/*! + * + * Jquery Mapael - Dynamic maps jQuery plugin (based on raphael.js) + * Requires jQuery, raphael.js and jquery.mousewheel + * + * Version: 2.2.0 + * + * Copyright (c) 2017 Vincent Brouté (https://www.vincentbroute.fr/mapael) + * Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php). + * + * Thanks to Indigo744 + * + */ +(function (factory) { + if (typeof exports === 'object') { + // CommonJS + module.exports = factory(require('jquery'), require('raphael'), require('jquery-mousewheel')); + } else if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['jquery', 'raphael', 'mousewheel'], factory); + } else { + // Browser globals + factory(jQuery, Raphael, jQuery.fn.mousewheel); + } +}(function ($, Raphael, mousewheel, undefined) { + + "use strict"; + + // The plugin name (used on several places) + var pluginName = "mapael"; + + // Version number of jQuery Mapael. See http://semver.org/ for more information. + var version = "2.2.0"; + + /* + * Mapael constructor + * Init instance vars and call init() + * @param container the DOM element on which to apply the plugin + * @param options the complete options to use + */ + var Mapael = function (container, options) { + var self = this; + + // the global container (DOM element object) + self.container = container; + + // the global container (jQuery object) + self.$container = $(container); + + // the global options + self.options = self.extendDefaultOptions(options); + + // zoom TimeOut handler (used to set and clear) + self.zoomTO = 0; + + // zoom center coordinate (set at touchstart) + self.zoomCenterX = 0; + self.zoomCenterY = 0; + + // Zoom pinch (set at touchstart and touchmove) + self.previousPinchDist = 0; + + // Zoom data + self.zoomData = { + zoomLevel: 0, + zoomX: 0, + zoomY: 0, + panX: 0, + panY: 0 + }; + + self.currentViewBox = { + x: 0, y: 0, w: 0, h: 0 + }; + + // Panning: tell if panning action is in progress + self.panning = false; + + // Animate view box + self.zoomAnimID = null; // Interval handler (used to set and clear) + self.zoomAnimStartTime = null; // Animation start time + self.zoomAnimCVBTarget = null; // Current ViewBox target + + // Map subcontainer jQuery object + self.$map = $("." + self.options.map.cssClass, self.container); + + // Save initial HTML content (used by destroy method) + self.initialMapHTMLContent = self.$map.html(); + + // The tooltip jQuery object + self.$tooltip = {}; + + // The paper Raphael object + self.paper = {}; + + // The areas object list + self.areas = {}; + + // The plots object list + self.plots = {}; + + // The links object list + self.links = {}; + + // The legends list + self.legends = {}; + + // The map configuration object (taken from map file) + self.mapConf = {}; + + // Holds all custom event handlers + self.customEventHandlers = {}; + + // Let's start the initialization + self.init(); + }; + + /* + * Mapael Prototype + * Defines all methods and properties needed by Mapael + * Each mapael object inherits their properties and methods from this prototype + */ + Mapael.prototype = { + + /* Filtering TimeOut value in ms + * Used for mouseover trigger over elements */ + MouseOverFilteringTO: 120, + /* Filtering TimeOut value in ms + * Used for afterPanning trigger when panning */ + panningFilteringTO: 150, + /* Filtering TimeOut value in ms + * Used for mouseup/touchend trigger when panning */ + panningEndFilteringTO: 50, + /* Filtering TimeOut value in ms + * Used for afterZoom trigger when zooming */ + zoomFilteringTO: 150, + /* Filtering TimeOut value in ms + * Used for when resizing window */ + resizeFilteringTO: 150, + + /* + * Initialize the plugin + * Called by the constructor + */ + init: function () { + var self = this; + + // Init check for class existence + if (self.options.map.cssClass === "" || $("." + self.options.map.cssClass, self.container).length === 0) { + throw new Error("The map class `" + self.options.map.cssClass + "` doesn't exists"); + } + + // Create the tooltip container + self.$tooltip = $("<div>").addClass(self.options.map.tooltip.cssClass).css("display", "none"); + + // Get the map container, empty it then append tooltip + self.$map.empty().append(self.$tooltip); + + // Get the map from $.mapael or $.fn.mapael (backward compatibility) + if ($[pluginName] && $[pluginName].maps && $[pluginName].maps[self.options.map.name]) { + // Mapael version >= 2.x + self.mapConf = $[pluginName].maps[self.options.map.name]; + } else if ($.fn[pluginName] && $.fn[pluginName].maps && $.fn[pluginName].maps[self.options.map.name]) { + // Mapael version <= 1.x - DEPRECATED + self.mapConf = $.fn[pluginName].maps[self.options.map.name]; + if (window.console && window.console.warn) { + window.console.warn("Extending $.fn.mapael is deprecated (map '" + self.options.map.name + "')"); + } + } else { + throw new Error("Unknown map '" + self.options.map.name + "'"); + } + + // Create Raphael paper + self.paper = new Raphael(self.$map[0], self.mapConf.width, self.mapConf.height); + + // issue #135: Check for Raphael bug on text element boundaries + if (self.isRaphaelBBoxBugPresent() === true) { + self.destroy(); + throw new Error("Can't get boundary box for text (is your container hidden? See #135)"); + } + + // add plugin class name on element + self.$container.addClass(pluginName); + + if (self.options.map.tooltip.css) self.$tooltip.css(self.options.map.tooltip.css); + self.setViewBox(0, 0, self.mapConf.width, self.mapConf.height); + + // Handle map size + if (self.options.map.width) { + // NOT responsive: map has a fixed width + self.paper.setSize(self.options.map.width, self.mapConf.height * (self.options.map.width / self.mapConf.width)); + } else { + // Responsive: handle resizing of the map + self.initResponsiveSize(); + } + + // Draw map areas + $.each(self.mapConf.elems, function (id) { + // Init area object + self.areas[id] = {}; + // Set area options + self.areas[id].options = self.getElemOptions( + self.options.map.defaultArea, + (self.options.areas[id] ? self.options.areas[id] : {}), + self.options.legend.area + ); + // draw area + self.areas[id].mapElem = self.paper.path(self.mapConf.elems[id]); + }); + + // Hook that allows to add custom processing on the map + if (self.options.map.beforeInit) self.options.map.beforeInit(self.$container, self.paper, self.options); + + // Init map areas in a second loop + // Allows text to be added after ALL areas and prevent them from being hidden + $.each(self.mapConf.elems, function (id) { + self.initElem(id, 'area', self.areas[id]); + }); + + // Draw links + self.links = self.drawLinksCollection(self.options.links); + + // Draw plots + $.each(self.options.plots, function (id) { + self.plots[id] = self.drawPlot(id); + }); + + // Attach zoom event + self.$container.on("zoom." + pluginName, function (e, zoomOptions) { + self.onZoomEvent(e, zoomOptions); + }); + + if (self.options.map.zoom.enabled) { + // Enable zoom + self.initZoom(self.mapConf.width, self.mapConf.height, self.options.map.zoom); + } + + // Set initial zoom + if (self.options.map.zoom.init !== undefined) { + if (self.options.map.zoom.init.animDuration === undefined) { + self.options.map.zoom.init.animDuration = 0; + } + self.$container.trigger("zoom", self.options.map.zoom.init); + } + + // Create the legends for areas + self.createLegends("area", self.areas, 1); + + // Create the legends for plots taking into account the scale of the map + self.createLegends("plot", self.plots, self.paper.width / self.mapConf.width); + + // Attach update event + self.$container.on("update." + pluginName, function (e, opt) { + self.onUpdateEvent(e, opt); + }); + + // Attach showElementsInRange event + self.$container.on("showElementsInRange." + pluginName, function (e, opt) { + self.onShowElementsInRange(e, opt); + }); + + // Attach delegated events + self.initDelegatedMapEvents(); + // Attach delegated custom events + self.initDelegatedCustomEvents(); + + // Hook that allows to add custom processing on the map + if (self.options.map.afterInit) self.options.map.afterInit(self.$container, self.paper, self.areas, self.plots, self.options); + + $(self.paper.desc).append(" and Mapael " + self.version + " (https://www.vincentbroute.fr/mapael/)"); + }, + + /* + * Destroy mapael + * This function effectively detach mapael from the container + * - Set the container back to the way it was before mapael instanciation + * - Remove all data associated to it (memory can then be free'ed by browser) + * + * This method can be call directly by user: + * $(".mapcontainer").data("mapael").destroy(); + * + * This method is also automatically called if the user try to call mapael + * on a container already containing a mapael instance + */ + destroy: function () { + var self = this; + + // Detach all event listeners attached to the container + self.$container.off("." + pluginName); + self.$map.off("." + pluginName); + + // Detach the global resize event handler + if (self.onResizeEvent) $(window).off("resize." + pluginName, self.onResizeEvent); + + // Empty the container (this will also detach all event listeners) + self.$map.empty(); + + // Replace initial HTML content + self.$map.html(self.initialMapHTMLContent); + + // Empty legend containers and replace initial HTML content + $.each(self.legends, function(legendType) { + $.each(self.legends[legendType], function(legendIndex) { + var legend = self.legends[legendType][legendIndex]; + legend.container.empty(); + legend.container.html(legend.initialHTMLContent); + }); + }); + + // Remove mapael class + self.$container.removeClass(pluginName); + + // Remove the data + self.$container.removeData(pluginName); + + // Remove all internal reference + self.container = undefined; + self.$container = undefined; + self.options = undefined; + self.paper = undefined; + self.$map = undefined; + self.$tooltip = undefined; + self.mapConf = undefined; + self.areas = undefined; + self.plots = undefined; + self.links = undefined; + self.customEventHandlers = undefined; + }, + + initResponsiveSize: function () { + var self = this; + var resizeTO = null; + + // Function that actually handle the resizing + var handleResize = function(isInit) { + var containerWidth = self.$map.width(); + + if (self.paper.width !== containerWidth) { + var newScale = containerWidth / self.mapConf.width; + // Set new size + self.paper.setSize(containerWidth, self.mapConf.height * newScale); + + // Create plots legend again to take into account the new scale + // Do not do this on init (it will be done later) + if (isInit !== true && self.options.legend.redrawOnResize) { + self.createLegends("plot", self.plots, newScale); + } + } + }; + + self.onResizeEvent = function() { + // Clear any previous setTimeout (avoid too much triggering) + clearTimeout(resizeTO); + // setTimeout to wait for the user to finish its resizing + resizeTO = setTimeout(function () { + handleResize(); + }, self.resizeFilteringTO); + }; + + // Attach resize handler + $(window).on("resize." + pluginName, self.onResizeEvent); + + // Call once + handleResize(true); + }, + + /* + * Extend the user option with the default one + * @param options the user options + * @return new options object + */ + extendDefaultOptions: function (options) { + + // Extend default options with user options + options = $.extend(true, {}, Mapael.prototype.defaultOptions, options); + + // Extend legend default options + $.each(['area', 'plot'], function (key, type) { + if ($.isArray(options.legend[type])) { + for (var i = 0; i < options.legend[type].length; ++i) + options.legend[type][i] = $.extend(true, {}, Mapael.prototype.legendDefaultOptions[type], options.legend[type][i]); + } else { + options.legend[type] = $.extend(true, {}, Mapael.prototype.legendDefaultOptions[type], options.legend[type]); + } + }); + + return options; + }, + + /* + * Init all delegated events for the whole map: + * mouseover + * mousemove + * mouseout + */ + initDelegatedMapEvents: function() { + var self = this; + + // Mapping between data-type value and the corresponding elements array + // Note: legend-elem and legend-label are not in this table because + // they need a special processing + var dataTypeToElementMapping = { + 'area' : self.areas, + 'area-text' : self.areas, + 'plot' : self.plots, + 'plot-text' : self.plots, + 'link' : self.links, + 'link-text' : self.links + }; + + /* Attach mouseover event delegation + * Note: we filter the event with a timeout to reduce the firing when the mouse moves quickly + */ + var mapMouseOverTimeoutID; + self.$container.on("mouseover." + pluginName, "[data-id]", function () { + var elem = this; + clearTimeout(mapMouseOverTimeoutID); + mapMouseOverTimeoutID = setTimeout(function() { + var $elem = $(elem); + var id = $elem.attr('data-id'); + var type = $elem.attr('data-type'); + + if (dataTypeToElementMapping[type] !== undefined) { + self.elemEnter(dataTypeToElementMapping[type][id]); + } else if (type === 'legend-elem' || type === 'legend-label') { + var legendIndex = $elem.attr('data-legend-id'); + var legendType = $elem.attr('data-legend-type'); + self.elemEnter(self.legends[legendType][legendIndex].elems[id]); + } + }, self.MouseOverFilteringTO); + }); + + /* Attach mousemove event delegation + * Note: timeout filtering is small to update the Tooltip position fast + */ + var mapMouseMoveTimeoutID; + self.$container.on("mousemove." + pluginName, "[data-id]", function (event) { + var elem = this; + clearTimeout(mapMouseMoveTimeoutID); + mapMouseMoveTimeoutID = setTimeout(function() { + var $elem = $(elem); + var id = $elem.attr('data-id'); + var type = $elem.attr('data-type'); + + if (dataTypeToElementMapping[type] !== undefined) { + self.elemHover(dataTypeToElementMapping[type][id], event); + } else if (type === 'legend-elem' || type === 'legend-label') { + /* Nothing to do */ + } + + }, 0); + }); + + /* Attach mouseout event delegation + * Note: we don't perform any timeout filtering to clear & reset elem ASAP + * Otherwise an element may be stuck in 'hover' state (which is NOT good) + */ + self.$container.on("mouseout." + pluginName, "[data-id]", function () { + var elem = this; + // Clear any + clearTimeout(mapMouseOverTimeoutID); + clearTimeout(mapMouseMoveTimeoutID); + var $elem = $(elem); + var id = $elem.attr('data-id'); + var type = $elem.attr('data-type'); + + if (dataTypeToElementMapping[type] !== undefined) { + self.elemOut(dataTypeToElementMapping[type][id]); + } else if (type === 'legend-elem' || type === 'legend-label') { + var legendIndex = $elem.attr('data-legend-id'); + var legendType = $elem.attr('data-legend-type'); + self.elemOut(self.legends[legendType][legendIndex].elems[id]); + } + }); + + /* Attach click event delegation + * Note: we filter the event with a timeout to avoid double click + */ + self.$container.on("click." + pluginName, "[data-id]", function (evt, opts) { + var $elem = $(this); + var id = $elem.attr('data-id'); + var type = $elem.attr('data-type'); + + if (dataTypeToElementMapping[type] !== undefined) { + self.elemClick(dataTypeToElementMapping[type][id]); + } else if (type === 'legend-elem' || type === 'legend-label') { + var legendIndex = $elem.attr('data-legend-id'); + var legendType = $elem.attr('data-legend-type'); + self.handleClickOnLegendElem(self.legends[legendType][legendIndex].elems[id], id, legendIndex, legendType, opts); + } + }); + }, + + /* + * Init all delegated custom events + */ + initDelegatedCustomEvents: function() { + var self = this; + + $.each(self.customEventHandlers, function(eventName) { + // Namespace the custom event + // This allow to easily unbound only custom events and not regular ones + var fullEventName = eventName + '.' + pluginName + ".custom"; + self.$container.off(fullEventName).on(fullEventName, "[data-id]", function (e) { + var $elem = $(this); + var id = $elem.attr('data-id'); + var type = $elem.attr('data-type').replace('-text', ''); + + if (!self.panning && + self.customEventHandlers[eventName][type] !== undefined && + self.customEventHandlers[eventName][type][id] !== undefined) + { + // Get back related elem + var elem = self.customEventHandlers[eventName][type][id]; + // Run callback provided by user + elem.options.eventHandlers[eventName](e, id, elem.mapElem, elem.textElem, elem.options); + } + }); + }); + + }, + + /* + * Init the element "elem" on the map (drawing text, setting attributes, events, tooltip, ...) + * + * @param id the id of the element + * @param type the type of the element (area, plot, link) + * @param elem object the element object (with mapElem), it will be updated + */ + initElem: function (id, type, elem) { + var self = this; + var $mapElem = $(elem.mapElem.node); + + // If an HTML link exists for this element, add cursor attributes + if (elem.options.href) { + elem.options.attrs.cursor = "pointer"; + if (elem.options.text) elem.options.text.attrs.cursor = "pointer"; + } + + // Set SVG attributes to map element + elem.mapElem.attr(elem.options.attrs); + // Set DOM attributes to map element + $mapElem.attr({ + "data-id": id, + "data-type": type + }); + if (elem.options.cssClass !== undefined) { + $mapElem.addClass(elem.options.cssClass); + } + + // Init the label related to the element + if (elem.options.text && elem.options.text.content !== undefined) { + // Set a text label in the area + var textPosition = self.getTextPosition(elem.mapElem.getBBox(), elem.options.text.position, elem.options.text.margin); + elem.options.text.attrs.text = elem.options.text.content; + elem.options.text.attrs.x = textPosition.x; + elem.options.text.attrs.y = textPosition.y; + elem.options.text.attrs['text-anchor'] = textPosition.textAnchor; + // Draw text + elem.textElem = self.paper.text(textPosition.x, textPosition.y, elem.options.text.content); + // Apply SVG attributes to text element + elem.textElem.attr(elem.options.text.attrs); + // Apply DOM attributes + $(elem.textElem.node).attr({ + "data-id": id, + "data-type": type + '-text' + }); + } + + // Set user event handlers + if (elem.options.eventHandlers) self.setEventHandlers(id, type, elem); + + // Set hover option for mapElem + self.setHoverOptions(elem.mapElem, elem.options.attrs, elem.options.attrsHover); + + // Set hover option for textElem + if (elem.textElem) self.setHoverOptions(elem.textElem, elem.options.text.attrs, elem.options.text.attrsHover); + }, + + /* + * Init zoom and panning for the map + * @param mapWidth + * @param mapHeight + * @param zoomOptions + */ + initZoom: function (mapWidth, mapHeight, zoomOptions) { + var self = this; + var mousedown = false; + var previousX = 0; + var previousY = 0; + var fnZoomButtons = { + "reset": function () { + self.$container.trigger("zoom", {"level": 0}); + }, + "in": function () { + self.$container.trigger("zoom", {"level": "+1"}); + }, + "out": function () { + self.$container.trigger("zoom", {"level": -1}); + } + }; + + // init Zoom data + $.extend(self.zoomData, { + zoomLevel: 0, + panX: 0, + panY: 0 + }); + + // init zoom buttons + $.each(zoomOptions.buttons, function(type, opt) { + if (fnZoomButtons[type] === undefined) throw new Error("Unknown zoom button '" + type + "'"); + // Create div with classes, contents and title (for tooltip) + var $button = $("<div>").addClass(opt.cssClass) + .html(opt.content) + .attr("title", opt.title); + // Assign click event + $button.on("click." + pluginName, fnZoomButtons[type]); + // Append to map + self.$map.append($button); + }); + + // Update the zoom level of the map on mousewheel + if (self.options.map.zoom.mousewheel) { + self.$map.on("mousewheel." + pluginName, function (e) { + var zoomLevel = (e.deltaY > 0) ? 1 : -1; + var coord = self.mapPagePositionToXY(e.pageX, e.pageY); + + self.$container.trigger("zoom", { + "fixedCenter": true, + "level": self.zoomData.zoomLevel + zoomLevel, + "x": coord.x, + "y": coord.y + }); + + e.preventDefault(); + }); + } + + // Update the zoom level of the map on touch pinch + if (self.options.map.zoom.touch) { + self.$map.on("touchstart." + pluginName, function (e) { + if (e.originalEvent.touches.length === 2) { + self.zoomCenterX = (e.originalEvent.touches[0].pageX + e.originalEvent.touches[1].pageX) / 2; + self.zoomCenterY = (e.originalEvent.touches[0].pageY + e.originalEvent.touches[1].pageY) / 2; + self.previousPinchDist = Math.sqrt(Math.pow((e.originalEvent.touches[1].pageX - e.originalEvent.touches[0].pageX), 2) + Math.pow((e.originalEvent.touches[1].pageY - e.originalEvent.touches[0].pageY), 2)); + } + }); + + self.$map.on("touchmove." + pluginName, function (e) { + var pinchDist = 0; + var zoomLevel = 0; + + if (e.originalEvent.touches.length === 2) { + pinchDist = Math.sqrt(Math.pow((e.originalEvent.touches[1].pageX - e.originalEvent.touches[0].pageX), 2) + Math.pow((e.originalEvent.touches[1].pageY - e.originalEvent.touches[0].pageY), 2)); + + if (Math.abs(pinchDist - self.previousPinchDist) > 15) { + var coord = self.mapPagePositionToXY(self.zoomCenterX, self.zoomCenterY); + zoomLevel = (pinchDist - self.previousPinchDist) / Math.abs(pinchDist - self.previousPinchDist); + self.$container.trigger("zoom", { + "fixedCenter": true, + "level": self.zoomData.zoomLevel + zoomLevel, + "x": coord.x, + "y": coord.y + }); + self.previousPinchDist = pinchDist; + } + return false; + } + }); + } + + // When the user drag the map, prevent to move the clicked element instead of dragging the map (behaviour seen with Firefox) + self.$map.on("dragstart", function() { + return false; + }); + + // Panning + var panningMouseUpTO = null; + var panningMouseMoveTO = null; + $("body").on("mouseup." + pluginName + (zoomOptions.touch ? " touchend." + pluginName : ""), function () { + mousedown = false; + clearTimeout(panningMouseUpTO); + clearTimeout(panningMouseMoveTO); + panningMouseUpTO = setTimeout(function () { + self.panning = false; + }, self.panningEndFilteringTO); + }); + + self.$map.on("mousedown." + pluginName + (zoomOptions.touch ? " touchstart." + pluginName : ""), function (e) { + clearTimeout(panningMouseUpTO); + clearTimeout(panningMouseMoveTO); + if (e.pageX !== undefined) { + mousedown = true; + previousX = e.pageX; + previousY = e.pageY; + } else { + if (e.originalEvent.touches.length === 1) { + mousedown = true; + previousX = e.originalEvent.touches[0].pageX; + previousY = e.originalEvent.touches[0].pageY; + } + } + }).on("mousemove." + pluginName + (zoomOptions.touch ? " touchmove." + pluginName : ""), function (e) { + var currentLevel = self.zoomData.zoomLevel; + var pageX = 0; + var pageY = 0; + + clearTimeout(panningMouseUpTO); + clearTimeout(panningMouseMoveTO); + + if (e.pageX !== undefined) { + pageX = e.pageX; + pageY = e.pageY; + } else { + if (e.originalEvent.touches.length === 1) { + pageX = e.originalEvent.touches[0].pageX; + pageY = e.originalEvent.touches[0].pageY; + } else { + mousedown = false; + } + } + + if (mousedown && currentLevel !== 0) { + var offsetX = (previousX - pageX) / (1 + (currentLevel * zoomOptions.step)) * (mapWidth / self.paper.width); + var offsetY = (previousY - pageY) / (1 + (currentLevel * zoomOptions.step)) * (mapHeight / self.paper.height); + var panX = Math.min(Math.max(0, self.currentViewBox.x + offsetX), (mapWidth - self.currentViewBox.w)); + var panY = Math.min(Math.max(0, self.currentViewBox.y + offsetY), (mapHeight - self.currentViewBox.h)); + + if (Math.abs(offsetX) > 5 || Math.abs(offsetY) > 5) { + $.extend(self.zoomData, { + panX: panX, + panY: panY, + zoomX: panX + self.currentViewBox.w / 2, + zoomY: panY + self.currentViewBox.h / 2 + }); + self.setViewBox(panX, panY, self.currentViewBox.w, self.currentViewBox.h); + + panningMouseMoveTO = setTimeout(function () { + self.$map.trigger("afterPanning", { + x1: panX, + y1: panY, + x2: (panX + self.currentViewBox.w), + y2: (panY + self.currentViewBox.h) + }); + }, self.panningFilteringTO); + + previousX = pageX; + previousY = pageY; + self.panning = true; + } + return false; + } + }); + }, + + /* + * Map a mouse position to a map position + * Transformation principle: + * ** start with (pageX, pageY) absolute mouse coordinate + * - Apply translation: take into accounts the map offset in the page + * ** from this point, we have relative mouse coordinate + * - Apply homothetic transformation: take into accounts initial factor of map sizing (fullWidth / actualWidth) + * - Apply homothetic transformation: take into accounts the zoom factor + * ** from this point, we have relative map coordinate + * - Apply translation: take into accounts the current panning of the map + * ** from this point, we have absolute map coordinate + * @param pageX: mouse client coordinate on X + * @param pageY: mouse client coordinate on Y + * @return map coordinate {x, y} + */ + mapPagePositionToXY: function(pageX, pageY) { + var self = this; + var offset = self.$map.offset(); + var initFactor = (self.options.map.width) ? (self.mapConf.width / self.options.map.width) : (self.mapConf.width / self.$map.width()); + var zoomFactor = 1 / (1 + (self.zoomData.zoomLevel * self.options.map.zoom.step)); + return { + x: (zoomFactor * initFactor * (pageX - offset.left)) + self.zoomData.panX, + y: (zoomFactor * initFactor * (pageY - offset.top)) + self.zoomData.panY + }; + }, + + /* + * Zoom on the map + * + * zoomOptions.animDuration zoom duration + * + * zoomOptions.level level of the zoom between minLevel and maxLevel (absolute number, or relative string +1 or -1) + * zoomOptions.fixedCenter set to true in order to preserve the position of x,y in the canvas when zoomed + * + * zoomOptions.x x coordinate of the point to focus on + * zoomOptions.y y coordinate of the point to focus on + * - OR - + * zoomOptions.latitude latitude of the point to focus on + * zoomOptions.longitude longitude of the point to focus on + * - OR - + * zoomOptions.plot plot ID to focus on + * - OR - + * zoomOptions.area area ID to focus on + * zoomOptions.areaMargin margin (in pixels) around the area + * + * If an area ID is specified, the algorithm will override the zoom level to focus on the area + * but it may be limited by the min/max zoom level limits set at initialization. + * + * If no coordinates are specified, the zoom will be focused on the center of the current view box + * + */ + onZoomEvent: function (e, zoomOptions) { + var self = this; + + // new Top/Left corner coordinates + var panX; + var panY; + // new Width/Height viewbox size + var panWidth; + var panHeight; + + // Zoom level in absolute scale (from 0 to max, by step of 1) + var zoomLevel = self.zoomData.zoomLevel; + + // Relative zoom level (from 1 to max, by step of 0.25 (default)) + var previousRelativeZoomLevel = 1 + self.zoomData.zoomLevel * self.options.map.zoom.step; + var relativeZoomLevel; + + var animDuration = (zoomOptions.animDuration !== undefined) ? zoomOptions.animDuration : self.options.map.zoom.animDuration; + + if (zoomOptions.area !== undefined) { + /* An area is given + * We will define x/y coordinate AND a new zoom level to fill the area + */ + if (self.areas[zoomOptions.area] === undefined) throw new Error("Unknown area '" + zoomOptions.area + "'"); + var areaMargin = (zoomOptions.areaMargin !== undefined) ? zoomOptions.areaMargin : 10; + var areaBBox = self.areas[zoomOptions.area].mapElem.getBBox(); + var areaFullWidth = areaBBox.width + 2 * areaMargin; + var areaFullHeight = areaBBox.height + 2 * areaMargin; + + // Compute new x/y focus point (center of area) + zoomOptions.x = areaBBox.cx; + zoomOptions.y = areaBBox.cy; + + // Compute a new absolute zoomLevel value (inverse of relative -> absolute) + // Take the min between zoomLevel on width vs. height to be able to see the whole area + zoomLevel = Math.min(Math.floor((self.mapConf.width / areaFullWidth - 1) / self.options.map.zoom.step), + Math.floor((self.mapConf.height / areaFullHeight - 1) / self.options.map.zoom.step)); + + } else { + + // Get user defined zoom level + if (zoomOptions.level !== undefined) { + if (typeof zoomOptions.level === "string") { + // level is a string, either "n", "+n" or "-n" + if ((zoomOptions.level.slice(0, 1) === '+') || (zoomOptions.level.slice(0, 1) === '-')) { + // zoomLevel is relative + zoomLevel = self.zoomData.zoomLevel + parseInt(zoomOptions.level, 10); + } else { + // zoomLevel is absolute + zoomLevel = parseInt(zoomOptions.level, 10); + } + } else { + // level is integer + if (zoomOptions.level < 0) { + // zoomLevel is relative + zoomLevel = self.zoomData.zoomLevel + zoomOptions.level; + } else { + // zoomLevel is absolute + zoomLevel = zoomOptions.level; + } + } + } + + if (zoomOptions.plot !== undefined) { + if (self.plots[zoomOptions.plot] === undefined) throw new Error("Unknown plot '" + zoomOptions.plot + "'"); + + zoomOptions.x = self.plots[zoomOptions.plot].coords.x; + zoomOptions.y = self.plots[zoomOptions.plot].coords.y; + } else { + if (zoomOptions.latitude !== undefined && zoomOptions.longitude !== undefined) { + var coords = self.mapConf.getCoords(zoomOptions.latitude, zoomOptions.longitude); + zoomOptions.x = coords.x; + zoomOptions.y = coords.y; + } + + if (zoomOptions.x === undefined) { + zoomOptions.x = self.currentViewBox.x + self.currentViewBox.w / 2; + } + + if (zoomOptions.y === undefined) { + zoomOptions.y = self.currentViewBox.y + self.currentViewBox.h / 2; + } + } + } + + // Make sure we stay in the zoom level boundaries + zoomLevel = Math.min(Math.max(zoomLevel, self.options.map.zoom.minLevel), self.options.map.zoom.maxLevel); + + // Compute relative zoom level + relativeZoomLevel = 1 + zoomLevel * self.options.map.zoom.step; + + // Compute panWidth / panHeight + panWidth = self.mapConf.width / relativeZoomLevel; + panHeight = self.mapConf.height / relativeZoomLevel; + + if (zoomLevel === 0) { + panX = 0; + panY = 0; + } else { + if (zoomOptions.fixedCenter !== undefined && zoomOptions.fixedCenter === true) { + panX = self.zoomData.panX + ((zoomOptions.x - self.zoomData.panX) * (relativeZoomLevel - previousRelativeZoomLevel)) / relativeZoomLevel; + panY = self.zoomData.panY + ((zoomOptions.y - self.zoomData.panY) * (relativeZoomLevel - previousRelativeZoomLevel)) / relativeZoomLevel; + } else { + panX = zoomOptions.x - panWidth / 2; + panY = zoomOptions.y - panHeight / 2; + } + + // Make sure we stay in the map boundaries + panX = Math.min(Math.max(0, panX), self.mapConf.width - panWidth); + panY = Math.min(Math.max(0, panY), self.mapConf.height - panHeight); + } + + // Update zoom level of the map + if (relativeZoomLevel === previousRelativeZoomLevel && panX === self.zoomData.panX && panY === self.zoomData.panY) return; + + if (animDuration > 0) { + self.animateViewBox(panX, panY, panWidth, panHeight, animDuration, self.options.map.zoom.animEasing); + } else { + self.setViewBox(panX, panY, panWidth, panHeight); + clearTimeout(self.zoomTO); + self.zoomTO = setTimeout(function () { + self.$map.trigger("afterZoom", { + x1: panX, + y1: panY, + x2: panX + panWidth, + y2: panY + panHeight + }); + }, self.zoomFilteringTO); + } + + $.extend(self.zoomData, { + zoomLevel: zoomLevel, + panX: panX, + panY: panY, + zoomX: panX + panWidth / 2, + zoomY: panY + panHeight / 2 + }); + }, + + /* + * Show some element in range defined by user + * Triggered by user $(".mapcontainer").trigger("showElementsInRange", [opt]); + * + * @param opt the options + * opt.hiddenOpacity opacity for hidden element (default = 0.3) + * opt.animDuration animation duration in ms (default = 0) + * opt.afterShowRange callback + * opt.ranges the range to show: + * Example: + * opt.ranges = { + * 'plot' : { + * 0 : { // valueIndex + * 'min': 1000, + * 'max': 1200 + * }, + * 1 : { // valueIndex + * 'min': 10, + * 'max': 12 + * } + * }, + * 'area' : { + * {'min': 10, 'max': 20} // No valueIndex, only an object, use 0 as valueIndex (easy case) + * } + * } + */ + onShowElementsInRange: function(e, opt) { + var self = this; + + // set animDuration to default if not defined + if (opt.animDuration === undefined) { + opt.animDuration = 0; + } + + // set hiddenOpacity to default if not defined + if (opt.hiddenOpacity === undefined) { + opt.hiddenOpacity = 0.3; + } + + // handle area + if (opt.ranges && opt.ranges.area) { + self.showElemByRange(opt.ranges.area, self.areas, opt.hiddenOpacity, opt.animDuration); + } + + // handle plot + if (opt.ranges && opt.ranges.plot) { + self.showElemByRange(opt.ranges.plot, self.plots, opt.hiddenOpacity, opt.animDuration); + } + + // handle link + if (opt.ranges && opt.ranges.link) { + self.showElemByRange(opt.ranges.link, self.links, opt.hiddenOpacity, opt.animDuration); + } + + // Call user callback + if (opt.afterShowRange) opt.afterShowRange(); + }, + + /* + * Show some element in range + * @param ranges: the ranges + * @param elems: list of element on which to check against previous range + * @hiddenOpacity: the opacity when hidden + * @animDuration: the animation duration + */ + showElemByRange: function(ranges, elems, hiddenOpacity, animDuration) { + var self = this; + // Hold the final opacity value for all elements consolidated after applying each ranges + // This allow to set the opacity only once for each elements + var elemsFinalOpacity = {}; + + // set object with one valueIndex to 0 if we have directly the min/max + if (ranges.min !== undefined || ranges.max !== undefined) { + ranges = {0: ranges}; + } + + // Loop through each valueIndex + $.each(ranges, function (valueIndex) { + var range = ranges[valueIndex]; + // Check if user defined at least a min or max value + if (range.min === undefined && range.max === undefined) { + return true; // skip this iteration (each loop), goto next range + } + // Loop through each elements + $.each(elems, function (id) { + var elemValue = elems[id].options.value; + // set value with one valueIndex to 0 if not object + if (typeof elemValue !== "object") { + elemValue = [elemValue]; + } + // Check existence of this value index + if (elemValue[valueIndex] === undefined) { + return true; // skip this iteration (each loop), goto next element + } + // Check if in range + if ((range.min !== undefined && elemValue[valueIndex] < range.min) || + (range.max !== undefined && elemValue[valueIndex] > range.max)) { + // Element not in range + elemsFinalOpacity[id] = hiddenOpacity; + } else { + // Element in range + elemsFinalOpacity[id] = 1; + } + }); + }); + // Now that we looped through all ranges, we can really assign the final opacity + $.each(elemsFinalOpacity, function (id) { + self.setElementOpacity(elems[id], elemsFinalOpacity[id], animDuration); + }); + }, + + /* + * Set element opacity + * Handle elem.mapElem and elem.textElem + * @param elem the element + * @param opacity the opacity to apply + * @param animDuration the animation duration to use + */ + setElementOpacity: function(elem, opacity, animDuration) { + var self = this; + + // Ensure no animation is running + //elem.mapElem.stop(); + //if (elem.textElem) elem.textElem.stop(); + + // If final opacity is not null, ensure element is shown before proceeding + if (opacity > 0) { + elem.mapElem.show(); + if (elem.textElem) elem.textElem.show(); + } + + self.animate(elem.mapElem, {"opacity": opacity}, animDuration, function () { + // If final attribute is 0, hide + if (opacity === 0) elem.mapElem.hide(); + }); + + self.animate(elem.textElem, {"opacity": opacity}, animDuration, function () { + // If final attribute is 0, hide + if (opacity === 0) elem.textElem.hide(); + }); + }, + + /* + * Update the current map + * + * Refresh attributes and tooltips for areas and plots + * @param opt option for the refresh : + * opt.mapOptions: options to update for plots and areas + * opt.replaceOptions: whether mapsOptions should entirely replace current map options, or just extend it + * opt.opt.newPlots new plots to add to the map + * opt.newLinks new links to add to the map + * opt.deletePlotKeys plots to delete from the map (array, or "all" to remove all plots) + * opt.deleteLinkKeys links to remove from the map (array, or "all" to remove all links) + * opt.setLegendElemsState the state of legend elements to be set : show (default) or hide + * opt.animDuration animation duration in ms (default = 0) + * opt.afterUpdate hook that allows to add custom processing on the map + */ + onUpdateEvent: function (e, opt) { + var self = this; + // Abort if opt is undefined + if (typeof opt !== "object") return; + + var i = 0; + var animDuration = (opt.animDuration) ? opt.animDuration : 0; + + // This function remove an element using animation (or not, depending on animDuration) + // Used for deletePlotKeys and deleteLinkKeys + var fnRemoveElement = function (elem) { + + self.animate(elem.mapElem, {"opacity": 0}, animDuration, function () { + elem.mapElem.remove(); + }); + + self.animate(elem.textElem, {"opacity": 0}, animDuration, function () { + elem.textElem.remove(); + }); + }; + + // This function show an element using animation + // Used for newPlots and newLinks + var fnShowElement = function (elem) { + // Starts with hidden elements + elem.mapElem.attr({opacity: 0}); + if (elem.textElem) elem.textElem.attr({opacity: 0}); + // Set final element opacity + self.setElementOpacity( + elem, + (elem.mapElem.originalAttrs.opacity !== undefined) ? elem.mapElem.originalAttrs.opacity : 1, + animDuration + ); + }; + + if (typeof opt.mapOptions === "object") { + if (opt.replaceOptions === true) self.options = self.extendDefaultOptions(opt.mapOptions); + else $.extend(true, self.options, opt.mapOptions); + + // IF we update areas, plots or legend, then reset all legend state to "show" + if (opt.mapOptions.areas !== undefined || opt.mapOptions.plots !== undefined || opt.mapOptions.legend !== undefined) { + $("[data-type='legend-elem']", self.$container).each(function (id, elem) { + if ($(elem).attr('data-hidden') === "1") { + // Toggle state of element by clicking + $(elem).trigger("click", {hideOtherElems: false, animDuration: animDuration}); + } + }); + } + } + + // Delete plots by name if deletePlotKeys is array + if (typeof opt.deletePlotKeys === "object") { + for (; i < opt.deletePlotKeys.length; i++) { + if (self.plots[opt.deletePlotKeys[i]] !== undefined) { + fnRemoveElement(self.plots[opt.deletePlotKeys[i]]); + delete self.plots[opt.deletePlotKeys[i]]; + } + } + // Delete ALL plots if deletePlotKeys is set to "all" + } else if (opt.deletePlotKeys === "all") { + $.each(self.plots, function (id, elem) { + fnRemoveElement(elem); + }); + // Empty plots object + self.plots = {}; + } + + // Delete links by name if deleteLinkKeys is array + if (typeof opt.deleteLinkKeys === "object") { + for (i = 0; i < opt.deleteLinkKeys.length; i++) { + if (self.links[opt.deleteLinkKeys[i]] !== undefined) { + fnRemoveElement(self.links[opt.deleteLinkKeys[i]]); + delete self.links[opt.deleteLinkKeys[i]]; + } + } + // Delete ALL links if deleteLinkKeys is set to "all" + } else if (opt.deleteLinkKeys === "all") { + $.each(self.links, function (id, elem) { + fnRemoveElement(elem); + }); + // Empty links object + self.links = {}; + } + + // New plots + if (typeof opt.newPlots === "object") { + $.each(opt.newPlots, function (id) { + if (self.plots[id] === undefined) { + self.options.plots[id] = opt.newPlots[id]; + self.plots[id] = self.drawPlot(id); + if (animDuration > 0) { + fnShowElement(self.plots[id]); + } + } + }); + } + + // New links + if (typeof opt.newLinks === "object") { + var newLinks = self.drawLinksCollection(opt.newLinks); + $.extend(self.links, newLinks); + $.extend(self.options.links, opt.newLinks); + if (animDuration > 0) { + $.each(newLinks, function (id) { + fnShowElement(newLinks[id]); + }); + } + } + + // Update areas attributes and tooltips + $.each(self.areas, function (id) { + // Avoid updating unchanged elements + if ((typeof opt.mapOptions === "object" && + ( + (typeof opt.mapOptions.map === "object" && typeof opt.mapOptions.map.defaultArea === "object") || + (typeof opt.mapOptions.areas === "object" && typeof opt.mapOptions.areas[id] === "object") || + (typeof opt.mapOptions.legend === "object" && typeof opt.mapOptions.legend.area === "object") + )) || opt.replaceOptions === true + ) { + self.areas[id].options = self.getElemOptions( + self.options.map.defaultArea, + (self.options.areas[id] ? self.options.areas[id] : {}), + self.options.legend.area + ); + self.updateElem(self.areas[id], animDuration); + } + }); + + // Update plots attributes and tooltips + $.each(self.plots, function (id) { + // Avoid updating unchanged elements + if ((typeof opt.mapOptions ==="object" && + ( + (typeof opt.mapOptions.map === "object" && typeof opt.mapOptions.map.defaultPlot === "object") || + (typeof opt.mapOptions.plots === "object" && typeof opt.mapOptions.plots[id] === "object") || + (typeof opt.mapOptions.legend === "object" && typeof opt.mapOptions.legend.plot === "object") + )) || opt.replaceOptions === true + ) { + self.plots[id].options = self.getElemOptions( + self.options.map.defaultPlot, + (self.options.plots[id] ? self.options.plots[id] : {}), + self.options.legend.plot + ); + + self.setPlotCoords(self.plots[id]); + self.setPlotAttributes(self.plots[id]); + + self.updateElem(self.plots[id], animDuration); + } + }); + + // Update links attributes and tooltips + $.each(self.links, function (id) { + // Avoid updating unchanged elements + if ((typeof opt.mapOptions === "object" && + ( + (typeof opt.mapOptions.map === "object" && typeof opt.mapOptions.map.defaultLink === "object") || + (typeof opt.mapOptions.links === "object" && typeof opt.mapOptions.links[id] === "object") + )) || opt.replaceOptions === true + ) { + self.links[id].options = self.getElemOptions( + self.options.map.defaultLink, + (self.options.links[id] ? self.options.links[id] : {}), + {} + ); + + self.updateElem(self.links[id], animDuration); + } + }); + + // Update legends + if (opt.mapOptions && ( + (typeof opt.mapOptions.legend === "object") || + (typeof opt.mapOptions.map === "object" && typeof opt.mapOptions.map.defaultArea === "object") || + (typeof opt.mapOptions.map === "object" && typeof opt.mapOptions.map.defaultPlot === "object") + )) { + // Show all elements on the map before updating the legends + $("[data-type='legend-elem']", self.$container).each(function (id, elem) { + if ($(elem).attr('data-hidden') === "1") { + $(elem).trigger("click", {hideOtherElems: false, animDuration: animDuration}); + } + }); + + self.createLegends("area", self.areas, 1); + if (self.options.map.width) { + self.createLegends("plot", self.plots, (self.options.map.width / self.mapConf.width)); + } else { + self.createLegends("plot", self.plots, (self.$map.width() / self.mapConf.width)); + } + } + + // Hide/Show all elements based on showlegendElems + // Toggle (i.e. click) only if: + // - slice legend is shown AND we want to hide + // - slice legend is hidden AND we want to show + if (typeof opt.setLegendElemsState === "object") { + // setLegendElemsState is an object listing the legend we want to hide/show + $.each(opt.setLegendElemsState, function (legendCSSClass, action) { + // Search for the legend + var $legend = self.$container.find("." + legendCSSClass)[0]; + if ($legend !== undefined) { + // Select all elem inside this legend + $("[data-type='legend-elem']", $legend).each(function (id, elem) { + if (($(elem).attr('data-hidden') === "0" && action === "hide") || + ($(elem).attr('data-hidden') === "1" && action === "show")) { + // Toggle state of element by clicking + $(elem).trigger("click", {hideOtherElems: false, animDuration: animDuration}); + } + }); + } + }); + } else { + // setLegendElemsState is a string, or is undefined + // Default : "show" + var action = (opt.setLegendElemsState === "hide") ? "hide" : "show"; + + $("[data-type='legend-elem']", self.$container).each(function (id, elem) { + if (($(elem).attr('data-hidden') === "0" && action === "hide") || + ($(elem).attr('data-hidden') === "1" && action === "show")) { + // Toggle state of element by clicking + $(elem).trigger("click", {hideOtherElems: false, animDuration: animDuration}); + } + }); + } + + // Always rebind custom events on update + self.initDelegatedCustomEvents(); + + if (opt.afterUpdate) opt.afterUpdate(self.$container, self.paper, self.areas, self.plots, self.options, self.links); + }, + + /* + * Set plot coordinates + * @param plot object plot element + */ + setPlotCoords: function(plot) { + var self = this; + + if (plot.options.x !== undefined && plot.options.y !== undefined) { + plot.coords = { + x: plot.options.x, + y: plot.options.y + }; + } else if (plot.options.plotsOn !== undefined && self.areas[plot.options.plotsOn] !== undefined) { + var areaBBox = self.areas[plot.options.plotsOn].mapElem.getBBox(); + plot.coords = { + x: areaBBox.cx, + y: areaBBox.cy + }; + } else { + plot.coords = self.mapConf.getCoords(plot.options.latitude, plot.options.longitude); + } + }, + + /* + * Set plot size attributes according to its type + * Note: for SVG, plot.mapElem needs to exists beforehand + * @param plot object plot element + */ + setPlotAttributes: function(plot) { + if (plot.options.type === "square") { + plot.options.attrs.width = plot.options.size; + plot.options.attrs.height = plot.options.size; + plot.options.attrs.x = plot.coords.x - (plot.options.size / 2); + plot.options.attrs.y = plot.coords.y - (plot.options.size / 2); + } else if (plot.options.type === "image") { + plot.options.attrs.src = plot.options.url; + plot.options.attrs.width = plot.options.width; + plot.options.attrs.height = plot.options.height; + plot.options.attrs.x = plot.coords.x - (plot.options.width / 2); + plot.options.attrs.y = plot.coords.y - (plot.options.height / 2); + } else if (plot.options.type === "svg") { + plot.options.attrs.path = plot.options.path; + + // Init transform string + if (plot.options.attrs.transform === undefined) { + plot.options.attrs.transform = ""; + } + + // Retrieve original boundary box if not defined + if (plot.mapElem.originalBBox === undefined) { + plot.mapElem.originalBBox = plot.mapElem.getBBox(); + } + + // The base transform will resize the SVG path to the one specified by width/height + // and also move the path to the actual coordinates + plot.mapElem.baseTransform = "m" + (plot.options.width / plot.mapElem.originalBBox.width) + ",0,0," + + (plot.options.height / plot.mapElem.originalBBox.height) + "," + + (plot.coords.x - plot.options.width / 2) + "," + + (plot.coords.y - plot.options.height / 2); + + plot.options.attrs.transform = plot.mapElem.baseTransform + plot.options.attrs.transform; + + } else { // Default : circle + plot.options.attrs.x = plot.coords.x; + plot.options.attrs.y = plot.coords.y; + plot.options.attrs.r = plot.options.size / 2; + } + }, + + /* + * Draw all links between plots on the paper + */ + drawLinksCollection: function (linksCollection) { + var self = this; + var p1 = {}; + var p2 = {}; + var coordsP1 = {}; + var coordsP2 = {}; + var links = {}; + + $.each(linksCollection, function (id) { + var elemOptions = self.getElemOptions(self.options.map.defaultLink, linksCollection[id], {}); + + if (typeof linksCollection[id].between[0] === 'string') { + p1 = self.options.plots[linksCollection[id].between[0]]; + } else { + p1 = linksCollection[id].between[0]; + } + + if (typeof linksCollection[id].between[1] === 'string') { + p2 = self.options.plots[linksCollection[id].between[1]]; + } else { + p2 = linksCollection[id].between[1]; + } + + if (p1.plotsOn !== undefined && self.areas[p1.plotsOn] !== undefined) { + var p1BBox = self.areas[p1.plotsOn].mapElem.getBBox(); + coordsP1 = { + x: p1BBox.cx, + y: p1BBox.cy + }; + } + else if (p1.latitude !== undefined && p1.longitude !== undefined) { + coordsP1 = self.mapConf.getCoords(p1.latitude, p1.longitude); + } else { + coordsP1.x = p1.x; + coordsP1.y = p1.y; + } + + if (p2.plotsOn !== undefined && self.areas[p2.plotsOn] !== undefined) { + var p2BBox = self.areas[p2.plotsOn].mapElem.getBBox(); + coordsP2 = { + x: p2BBox.cx, + y: p2BBox.cy + }; + } + else if (p2.latitude !== undefined && p2.longitude !== undefined) { + coordsP2 = self.mapConf.getCoords(p2.latitude, p2.longitude); + } else { + coordsP2.x = p2.x; + coordsP2.y = p2.y; + } + links[id] = self.drawLink(id, coordsP1.x, coordsP1.y, coordsP2.x, coordsP2.y, elemOptions); + }); + return links; + }, + + /* + * Draw a curved link between two couples of coordinates a(xa,ya) and b(xb, yb) on the paper + */ + drawLink: function (id, xa, ya, xb, yb, elemOptions) { + var self = this; + var link = { + options: elemOptions + }; + // Compute the "curveto" SVG point, d(x,y) + // c(xc, yc) is the center of (xa,ya) and (xb, yb) + var xc = (xa + xb) / 2; + var yc = (ya + yb) / 2; + + // Equation for (cd) : y = acd * x + bcd (d is the cure point) + var acd = -1 / ((yb - ya) / (xb - xa)); + var bcd = yc - acd * xc; + + // dist(c,d) = dist(a,b) (=abDist) + var abDist = Math.sqrt((xb - xa) * (xb - xa) + (yb - ya) * (yb - ya)); + + // Solution for equation dist(cd) = sqrt((xd - xc)² + (yd - yc)²) + // dist(c,d)² = (xd - xc)² + (yd - yc)² + // We assume that dist(c,d) = dist(a,b) + // so : (xd - xc)² + (yd - yc)² - dist(a,b)² = 0 + // With the factor : (xd - xc)² + (yd - yc)² - (factor*dist(a,b))² = 0 + // (xd - xc)² + (acd*xd + bcd - yc)² - (factor*dist(a,b))² = 0 + var a = 1 + acd * acd; + var b = -2 * xc + 2 * acd * bcd - 2 * acd * yc; + var c = xc * xc + bcd * bcd - bcd * yc - yc * bcd + yc * yc - ((elemOptions.factor * abDist) * (elemOptions.factor * abDist)); + var delta = b * b - 4 * a * c; + var x = 0; + var y = 0; + + // There are two solutions, we choose one or the other depending on the sign of the factor + if (elemOptions.factor > 0) { + x = (-b + Math.sqrt(delta)) / (2 * a); + y = acd * x + bcd; + } else { + x = (-b - Math.sqrt(delta)) / (2 * a); + y = acd * x + bcd; + } + + link.mapElem = self.paper.path("m " + xa + "," + ya + " C " + x + "," + y + " " + xb + "," + yb + " " + xb + "," + yb + ""); + + self.initElem(id, 'link', link); + + return link; + }, + + /* + * Check wether newAttrs object bring modifications to originalAttrs object + */ + isAttrsChanged: function(originalAttrs, newAttrs) { + for (var key in newAttrs) { + if (newAttrs.hasOwnProperty(key) && typeof originalAttrs[key] === 'undefined' || newAttrs[key] !== originalAttrs[key]) { + return true; + } + } + return false; + }, + + /* + * Update the element "elem" on the map with the new options + */ + updateElem: function (elem, animDuration) { + var self = this; + var mapElemBBox; + var plotOffsetX; + var plotOffsetY; + + if (elem.options.toFront === true) { + elem.mapElem.toFront(); + } + + // Set the cursor attribute related to the HTML link + if (elem.options.href !== undefined) { + elem.options.attrs.cursor = "pointer"; + if (elem.options.text) elem.options.text.attrs.cursor = "pointer"; + } else { + // No HTML links, check if a cursor was defined to pointer + if (elem.mapElem.attrs.cursor === 'pointer') { + elem.options.attrs.cursor = "auto"; + if (elem.options.text) elem.options.text.attrs.cursor = "auto"; + } + } + + // Update the label + if (elem.textElem) { + // Update text attr + elem.options.text.attrs.text = elem.options.text.content; + + // Get mapElem size, and apply an offset to handle future width/height change + mapElemBBox = elem.mapElem.getBBox(); + if (elem.options.size || (elem.options.width && elem.options.height)) { + if (elem.options.type === "image" || elem.options.type === "svg") { + plotOffsetX = (elem.options.width - mapElemBBox.width) / 2; + plotOffsetY = (elem.options.height - mapElemBBox.height) / 2; + } else { + plotOffsetX = (elem.options.size - mapElemBBox.width) / 2; + plotOffsetY = (elem.options.size - mapElemBBox.height) / 2; + } + mapElemBBox.x -= plotOffsetX; + mapElemBBox.x2 += plotOffsetX; + mapElemBBox.y -= plotOffsetY; + mapElemBBox.y2 += plotOffsetY; + } + + // Update position attr + var textPosition = self.getTextPosition(mapElemBBox, elem.options.text.position, elem.options.text.margin); + elem.options.text.attrs.x = textPosition.x; + elem.options.text.attrs.y = textPosition.y; + elem.options.text.attrs['text-anchor'] = textPosition.textAnchor; + + // Update text element attrs and attrsHover + self.setHoverOptions(elem.textElem, elem.options.text.attrs, elem.options.text.attrsHover); + + if (self.isAttrsChanged(elem.textElem.attrs, elem.options.text.attrs)) { + self.animate(elem.textElem, elem.options.text.attrs, animDuration); + } + } + + // Update elements attrs and attrsHover + self.setHoverOptions(elem.mapElem, elem.options.attrs, elem.options.attrsHover); + + if (self.isAttrsChanged(elem.mapElem.attrs, elem.options.attrs)) { + self.animate(elem.mapElem, elem.options.attrs, animDuration); + } + + // Update the cssClass + if (elem.options.cssClass !== undefined) { + $(elem.mapElem.node).removeClass().addClass(elem.options.cssClass); + } + }, + + /* + * Draw the plot + */ + drawPlot: function (id) { + var self = this; + var plot = {}; + + // Get plot options and store it + plot.options = self.getElemOptions( + self.options.map.defaultPlot, + (self.options.plots[id] ? self.options.plots[id] : {}), + self.options.legend.plot + ); + + // Set plot coords + self.setPlotCoords(plot); + + // Draw SVG before setPlotAttributes() + if (plot.options.type === "svg") { + plot.mapElem = self.paper.path(plot.options.path); + } + + // Set plot size attrs + self.setPlotAttributes(plot); + + // Draw other types of plots + if (plot.options.type === "square") { + plot.mapElem = self.paper.rect( + plot.options.attrs.x, + plot.options.attrs.y, + plot.options.attrs.width, + plot.options.attrs.height + ); + } else if (plot.options.type === "image") { + plot.mapElem = self.paper.image( + plot.options.attrs.src, + plot.options.attrs.x, + plot.options.attrs.y, + plot.options.attrs.width, + plot.options.attrs.height + ); + } else if (plot.options.type === "svg") { + // Nothing to do + } else { + // Default = circle + plot.mapElem = self.paper.circle( + plot.options.attrs.x, + plot.options.attrs.y, + plot.options.attrs.r + ); + } + + self.initElem(id, 'plot', plot); + + return plot; + }, + + /* + * Set user defined handlers for events on areas and plots + * @param id the id of the element + * @param type the type of the element (area, plot, link) + * @param elem the element object {mapElem, textElem, options, ...} + */ + setEventHandlers: function (id, type, elem) { + var self = this; + $.each(elem.options.eventHandlers, function (event) { + if (self.customEventHandlers[event] === undefined) self.customEventHandlers[event] = {}; + if (self.customEventHandlers[event][type] === undefined) self.customEventHandlers[event][type] = {}; + self.customEventHandlers[event][type][id] = elem; + }); + }, + + /* + * Draw a legend for areas and / or plots + * @param legendOptions options for the legend to draw + * @param legendType the type of the legend : "area" or "plot" + * @param elems collection of plots or areas on the maps + * @param legendIndex index of the legend in the conf array + */ + drawLegend: function (legendOptions, legendType, elems, scale, legendIndex) { + var self = this; + var $legend = {}; + var legendPaper = {}; + var width = 0; + var height = 0; + var title = null; + var titleBBox = null; + var legendElems = {}; + var i = 0; + var x = 0; + var y = 0; + var yCenter = 0; + var sliceOptions = []; + + $legend = $("." + legendOptions.cssClass, self.$container); + + // Save content for later + var initialHTMLContent = $legend.html(); + $legend.empty(); + + legendPaper = new Raphael($legend.get(0)); + // Set some data to object + $(legendPaper.canvas).attr({"data-legend-type": legendType, "data-legend-id": legendIndex}); + + height = width = 0; + + // Set the title of the legend + if (legendOptions.title && legendOptions.title !== "") { + title = legendPaper.text(legendOptions.marginLeftTitle, 0, legendOptions.title).attr(legendOptions.titleAttrs); + titleBBox = title.getBBox(); + title.attr({y: 0.5 * titleBBox.height}); + + width = legendOptions.marginLeftTitle + titleBBox.width; + height += legendOptions.marginBottomTitle + titleBBox.height; + } + + // Calculate attrs (and width, height and r (radius)) for legend elements, and yCenter for horizontal legends + + for (i = 0; i < legendOptions.slices.length; ++i) { + var yCenterCurrent = 0; + + sliceOptions[i] = $.extend(true, {}, (legendType === "plot") ? self.options.map.defaultPlot : self.options.map.defaultArea, legendOptions.slices[i]); + + if (legendOptions.slices[i].legendSpecificAttrs === undefined) { + legendOptions.slices[i].legendSpecificAttrs = {}; + } + + $.extend(true, sliceOptions[i].attrs, legendOptions.slices[i].legendSpecificAttrs); + + if (legendType === "area") { + if (sliceOptions[i].attrs.width === undefined) + sliceOptions[i].attrs.width = 30; + if (sliceOptions[i].attrs.height === undefined) + sliceOptions[i].attrs.height = 20; + } else if (sliceOptions[i].type === "square") { + if (sliceOptions[i].attrs.width === undefined) + sliceOptions[i].attrs.width = sliceOptions[i].size; + if (sliceOptions[i].attrs.height === undefined) + sliceOptions[i].attrs.height = sliceOptions[i].size; + } else if (sliceOptions[i].type === "image" || sliceOptions[i].type === "svg") { + if (sliceOptions[i].attrs.width === undefined) + sliceOptions[i].attrs.width = sliceOptions[i].width; + if (sliceOptions[i].attrs.height === undefined) + sliceOptions[i].attrs.height = sliceOptions[i].height; + } else { + if (sliceOptions[i].attrs.r === undefined) + sliceOptions[i].attrs.r = sliceOptions[i].size / 2; + } + + // Compute yCenter for this legend slice + yCenterCurrent = legendOptions.marginBottomTitle; + // Add title height if it exists + if (title) { + yCenterCurrent += titleBBox.height; + } + if (legendType === "plot" && (sliceOptions[i].type === undefined || sliceOptions[i].type === "circle")) { + yCenterCurrent += scale * sliceOptions[i].attrs.r; + } else { + yCenterCurrent += scale * sliceOptions[i].attrs.height / 2; + } + // Update yCenter if current larger + yCenter = Math.max(yCenter, yCenterCurrent); + } + + if (legendOptions.mode === "horizontal") { + width = legendOptions.marginLeft; + } + + // Draw legend elements (circle, square or image in vertical or horizontal mode) + for (i = 0; i < sliceOptions.length; ++i) { + var legendElem = {}; + var legendElemBBox = {}; + var legendLabel = {}; + + if (sliceOptions[i].display === undefined || sliceOptions[i].display === true) { + if (legendType === "area") { + if (legendOptions.mode === "horizontal") { + x = width + legendOptions.marginLeft; + y = yCenter - (0.5 * scale * sliceOptions[i].attrs.height); + } else { + x = legendOptions.marginLeft; + y = height; + } + + legendElem = legendPaper.rect(x, y, scale * (sliceOptions[i].attrs.width), scale * (sliceOptions[i].attrs.height)); + } else if (sliceOptions[i].type === "square") { + if (legendOptions.mode === "horizontal") { + x = width + legendOptions.marginLeft; + y = yCenter - (0.5 * scale * sliceOptions[i].attrs.height); + } else { + x = legendOptions.marginLeft; + y = height; + } + + legendElem = legendPaper.rect(x, y, scale * (sliceOptions[i].attrs.width), scale * (sliceOptions[i].attrs.height)); + + } else if (sliceOptions[i].type === "image" || sliceOptions[i].type === "svg") { + if (legendOptions.mode === "horizontal") { + x = width + legendOptions.marginLeft; + y = yCenter - (0.5 * scale * sliceOptions[i].attrs.height); + } else { + x = legendOptions.marginLeft; + y = height; + } + + if (sliceOptions[i].type === "image") { + legendElem = legendPaper.image( + sliceOptions[i].url, x, y, scale * sliceOptions[i].attrs.width, scale * sliceOptions[i].attrs.height); + } else { + legendElem = legendPaper.path(sliceOptions[i].path); + + if (sliceOptions[i].attrs.transform === undefined) { + sliceOptions[i].attrs.transform = ""; + } + legendElemBBox = legendElem.getBBox(); + sliceOptions[i].attrs.transform = "m" + ((scale * sliceOptions[i].width) / legendElemBBox.width) + ",0,0," + ((scale * sliceOptions[i].height) / legendElemBBox.height) + "," + x + "," + y + sliceOptions[i].attrs.transform; + } + } else { + if (legendOptions.mode === "horizontal") { + x = width + legendOptions.marginLeft + scale * (sliceOptions[i].attrs.r); + y = yCenter; + } else { + x = legendOptions.marginLeft + scale * (sliceOptions[i].attrs.r); + y = height + scale * (sliceOptions[i].attrs.r); + } + legendElem = legendPaper.circle(x, y, scale * (sliceOptions[i].attrs.r)); + } + + // Set attrs to the element drawn above + delete sliceOptions[i].attrs.width; + delete sliceOptions[i].attrs.height; + delete sliceOptions[i].attrs.r; + legendElem.attr(sliceOptions[i].attrs); + legendElemBBox = legendElem.getBBox(); + + // Draw the label associated with the element + if (legendOptions.mode === "horizontal") { + x = width + legendOptions.marginLeft + legendElemBBox.width + legendOptions.marginLeftLabel; + y = yCenter; + } else { + x = legendOptions.marginLeft + legendElemBBox.width + legendOptions.marginLeftLabel; + y = height + (legendElemBBox.height / 2); + } + + legendLabel = legendPaper.text(x, y, sliceOptions[i].label).attr(legendOptions.labelAttrs); + + // Update the width and height for the paper + if (legendOptions.mode === "horizontal") { + var currentHeight = legendOptions.marginBottom + legendElemBBox.height; + width += legendOptions.marginLeft + legendElemBBox.width + legendOptions.marginLeftLabel + legendLabel.getBBox().width; + if (sliceOptions[i].type !== "image" && legendType !== "area") { + currentHeight += legendOptions.marginBottomTitle; + } + // Add title height if it exists + if (title) { + currentHeight += titleBBox.height; + } + height = Math.max(height, currentHeight); + } else { + width = Math.max(width, legendOptions.marginLeft + legendElemBBox.width + legendOptions.marginLeftLabel + legendLabel.getBBox().width); + height += legendOptions.marginBottom + legendElemBBox.height; + } + + // Set some data to elements + $(legendElem.node).attr({ + "data-legend-id": legendIndex, + "data-legend-type": legendType, + "data-type": "legend-elem", + "data-id": i, + "data-hidden": 0 + }); + $(legendLabel.node).attr({ + "data-legend-id": legendIndex, + "data-legend-type": legendType, + "data-type": "legend-label", + "data-id": i, + "data-hidden": 0 + }); + + // Set array content + // We use similar names like map/plots/links + legendElems[i] = { + mapElem: legendElem, + textElem: legendLabel + }; + + // Hide map elements when the user clicks on a legend item + if (legendOptions.hideElemsOnClick.enabled) { + // Hide/show elements when user clicks on a legend element + legendLabel.attr({cursor: "pointer"}); + legendElem.attr({cursor: "pointer"}); + + self.setHoverOptions(legendElem, sliceOptions[i].attrs, sliceOptions[i].attrs); + self.setHoverOptions(legendLabel, legendOptions.labelAttrs, legendOptions.labelAttrsHover); + + if (sliceOptions[i].clicked !== undefined && sliceOptions[i].clicked === true) { + self.handleClickOnLegendElem(legendElems[i], i, legendIndex, legendType, {hideOtherElems: false}); + } + } + } + } + + // VMLWidth option allows you to set static width for the legend + // only for VML render because text.getBBox() returns wrong values on IE6/7 + if (Raphael.type !== "SVG" && legendOptions.VMLWidth) + width = legendOptions.VMLWidth; + + legendPaper.setSize(width, height); + + return { + container: $legend, + initialHTMLContent: initialHTMLContent, + elems: legendElems + }; + }, + + /* + * Allow to hide elements of the map when the user clicks on a related legend item + * @param elem legend element + * @param id legend element ID + * @param legendIndex corresponding legend index + * @param legendType corresponding legend type (area or plot) + * @param opts object additionnal options + * hideOtherElems boolean, if other elems shall be hidden + * animDuration duration of animation + */ + handleClickOnLegendElem: function(elem, id, legendIndex, legendType, opts) { + var self = this; + var legendOptions; + opts = opts || {}; + + if (!$.isArray(self.options.legend[legendType])) { + legendOptions = self.options.legend[legendType]; + } else { + legendOptions = self.options.legend[legendType][legendIndex]; + } + + var legendElem = elem.mapElem; + var legendLabel = elem.textElem; + var $legendElem = $(legendElem.node); + var $legendLabel = $(legendLabel.node); + var sliceOptions = legendOptions.slices[id]; + var mapElems = legendType === 'area' ? self.areas : self.plots; + // Check animDuration: if not set, this is a regular click, use the value specified in options + var animDuration = opts.animDuration !== undefined ? opts.animDuration : legendOptions.hideElemsOnClick.animDuration ; + + var hidden = $legendElem.attr('data-hidden'); + var hiddenNewAttr = (hidden === '0') ? {"data-hidden": '1'} : {"data-hidden": '0'}; + + if (hidden === '0') { + self.animate(legendLabel, {"opacity": 0.5}, animDuration); + } else { + self.animate(legendLabel, {"opacity": 1}, animDuration); + } + + $.each(mapElems, function (y) { + var elemValue; + + // Retreive stored data of element + // 'hidden-by' contains the list of legendIndex that is hiding this element + var hiddenBy = mapElems[y].mapElem.data('hidden-by'); + // Set to empty object if undefined + if (hiddenBy === undefined) hiddenBy = {}; + + if ($.isArray(mapElems[y].options.value)) { + elemValue = mapElems[y].options.value[legendIndex]; + } else { + elemValue = mapElems[y].options.value; + } + + // Hide elements whose value matches with the slice of the clicked legend item + if (self.getLegendSlice(elemValue, legendOptions) === sliceOptions) { + if (hidden === '0') { // we want to hide this element + hiddenBy[legendIndex] = true; // add legendIndex to the data object for later use + self.setElementOpacity(mapElems[y], legendOptions.hideElemsOnClick.opacity, animDuration); + } else { // We want to show this element + delete hiddenBy[legendIndex]; // Remove this legendIndex from object + // Check if another legendIndex is defined + // We will show this element only if no legend is no longer hiding it + if ($.isEmptyObject(hiddenBy)) { + self.setElementOpacity( + mapElems[y], + mapElems[y].mapElem.originalAttrs.opacity !== undefined ? mapElems[y].mapElem.originalAttrs.opacity : 1, + animDuration + ); + } + } + // Update elem data with new values + mapElems[y].mapElem.data('hidden-by', hiddenBy); + } + }); + + $legendElem.attr(hiddenNewAttr); + $legendLabel.attr(hiddenNewAttr); + + if ((opts.hideOtherElems === undefined || opts.hideOtherElems === true) && legendOptions.exclusive === true ) { + $("[data-type='legend-elem'][data-hidden=0]", self.$container).each(function () { + var $elem = $(this); + if ($elem.attr('data-id') !== id) { + $elem.trigger("click", {hideOtherElems: false}); + } + }); + } + + }, + + /* + * Create all legends for a specified type (area or plot) + * @param legendType the type of the legend : "area" or "plot" + * @param elems collection of plots or areas displayed on the map + * @param scale scale ratio of the map + */ + createLegends: function (legendType, elems, scale) { + var self = this; + var legendsOptions = self.options.legend[legendType]; + + if (!$.isArray(self.options.legend[legendType])) { + legendsOptions = [self.options.legend[legendType]]; + } + + self.legends[legendType] = {}; + for (var j = 0; j < legendsOptions.length; ++j) { + if (legendsOptions[j].display === true && $.isArray(legendsOptions[j].slices) && legendsOptions[j].slices.length > 0 && + legendsOptions[j].cssClass !== "" && $("." + legendsOptions[j].cssClass, self.$container).length !== 0 + ) { + self.legends[legendType][j] = self.drawLegend(legendsOptions[j], legendType, elems, scale, j); + } + } + }, + + /* + * Set the attributes on hover and the attributes to restore for a map element + * @param elem the map element + * @param originalAttrs the original attributes to restore on mouseout event + * @param attrsHover the attributes to set on mouseover event + */ + setHoverOptions: function (elem, originalAttrs, attrsHover) { + // Disable transform option on hover for VML (IE<9) because of several bugs + if (Raphael.type !== "SVG") delete attrsHover.transform; + elem.attrsHover = attrsHover; + + if (elem.attrsHover.transform) elem.originalAttrs = $.extend({transform: "s1"}, originalAttrs); + else elem.originalAttrs = originalAttrs; + }, + + /* + * Set the behaviour when mouse enters element ("mouseover" event) + * It may be an area, a plot, a link or a legend element + * @param elem the map element + */ + elemEnter: function (elem) { + var self = this; + if (elem === undefined) return; + + /* Handle mapElem Hover attributes */ + if (elem.mapElem !== undefined) { + self.animate(elem.mapElem, elem.mapElem.attrsHover, elem.mapElem.attrsHover.animDuration); + } + + /* Handle textElem Hover attributes */ + if (elem.textElem !== undefined) { + self.animate(elem.textElem, elem.textElem.attrsHover, elem.textElem.attrsHover.animDuration); + } + + /* Handle tooltip init */ + if (elem.options && elem.options.tooltip !== undefined) { + var content = ''; + // Reset classes + self.$tooltip.removeClass().addClass(self.options.map.tooltip.cssClass); + // Get content + if (elem.options.tooltip.content !== undefined) { + // if tooltip.content is function, call it. Otherwise, assign it directly. + if (typeof elem.options.tooltip.content === "function") content = elem.options.tooltip.content(elem.mapElem); + else content = elem.options.tooltip.content; + } + if (elem.options.tooltip.cssClass !== undefined) { + self.$tooltip.addClass(elem.options.tooltip.cssClass); + } + self.$tooltip.html(content).css("display", "block"); + } + + // workaround for older version of Raphael + if (elem.mapElem !== undefined || elem.textElem !== undefined) { + if (self.paper.safari) self.paper.safari(); + } + }, + + /* + * Set the behaviour when mouse moves in element ("mousemove" event) + * @param elem the map element + */ + elemHover: function (elem, event) { + var self = this; + if (elem === undefined) return; + + /* Handle tooltip position update */ + if (elem.options.tooltip !== undefined) { + var mouseX = event.pageX; + var mouseY = event.pageY; + + var offsetLeft = 10; + var offsetTop = 20; + if (typeof elem.options.tooltip.offset === "object") { + if (typeof elem.options.tooltip.offset.left !== "undefined") { + offsetLeft = elem.options.tooltip.offset.left; + } + if (typeof elem.options.tooltip.offset.top !== "undefined") { + offsetTop = elem.options.tooltip.offset.top; + } + } + + var tooltipPosition = { + "left": Math.min(self.$map.width() - self.$tooltip.outerWidth() - 5, + mouseX - self.$map.offset().left + offsetLeft), + "top": Math.min(self.$map.height() - self.$tooltip.outerHeight() - 5, + mouseY - self.$map.offset().top + offsetTop) + }; + + if (typeof elem.options.tooltip.overflow === "object") { + if (elem.options.tooltip.overflow.right === true) { + tooltipPosition.left = mouseX - self.$map.offset().left + 10; + } + if (elem.options.tooltip.overflow.bottom === true) { + tooltipPosition.top = mouseY - self.$map.offset().top + 20; + } + } + + self.$tooltip.css(tooltipPosition); + } + }, + + /* + * Set the behaviour when mouse leaves element ("mouseout" event) + * It may be an area, a plot, a link or a legend element + * @param elem the map element + */ + elemOut: function (elem) { + var self = this; + if (elem === undefined) return; + + /* reset mapElem attributes */ + if (elem.mapElem !== undefined) { + self.animate(elem.mapElem, elem.mapElem.originalAttrs, elem.mapElem.attrsHover.animDuration); + } + + /* reset textElem attributes */ + if (elem.textElem !== undefined) { + self.animate(elem.textElem, elem.textElem.originalAttrs, elem.textElem.attrsHover.animDuration); + } + + /* reset tooltip */ + if (elem.options && elem.options.tooltip !== undefined) { + self.$tooltip.css({ + 'display': 'none', + 'top': -1000, + 'left': -1000 + }); + } + + // workaround for older version of Raphael + if (elem.mapElem !== undefined || elem.textElem !== undefined) { + if (self.paper.safari) self.paper.safari(); + } + }, + + /* + * Set the behaviour when mouse clicks element ("click" event) + * It may be an area, a plot or a link (but not a legend element which has its own function) + * @param elem the map element + */ + elemClick: function (elem) { + var self = this; + if (elem === undefined) return; + + /* Handle click when href defined */ + if (!self.panning && elem.options.href !== undefined) { + window.open(elem.options.href, elem.options.target); + } + }, + + /* + * Get element options by merging default options, element options and legend options + * @param defaultOptions + * @param elemOptions + * @param legendOptions + */ + getElemOptions: function (defaultOptions, elemOptions, legendOptions) { + var self = this; + var options = $.extend(true, {}, defaultOptions, elemOptions); + if (options.value !== undefined) { + if ($.isArray(legendOptions)) { + for (var i = 0; i < legendOptions.length; ++i) { + options = $.extend(true, {}, options, self.getLegendSlice(options.value[i], legendOptions[i])); + } + } else { + options = $.extend(true, {}, options, self.getLegendSlice(options.value, legendOptions)); + } + } + return options; + }, + + /* + * Get the coordinates of the text relative to a bbox and a position + * @param bbox the boundary box of the element + * @param textPosition the wanted text position (inner, right, left, top or bottom) + * @param margin number or object {x: val, y:val} margin between the bbox and the text + */ + getTextPosition: function (bbox, textPosition, margin) { + var textX = 0; + var textY = 0; + var textAnchor = ""; + + if (typeof margin === "number") { + if (textPosition === "bottom" || textPosition === "top") { + margin = {x: 0, y: margin}; + } else if (textPosition === "right" || textPosition === "left") { + margin = {x: margin, y: 0}; + } else { + margin = {x: 0, y: 0}; + } + } + + switch (textPosition) { + case "bottom" : + textX = ((bbox.x + bbox.x2) / 2) + margin.x; + textY = bbox.y2 + margin.y; + textAnchor = "middle"; + break; + case "top" : + textX = ((bbox.x + bbox.x2) / 2) + margin.x; + textY = bbox.y - margin.y; + textAnchor = "middle"; + break; + case "left" : + textX = bbox.x - margin.x; + textY = ((bbox.y + bbox.y2) / 2) + margin.y; + textAnchor = "end"; + break; + case "right" : + textX = bbox.x2 + margin.x; + textY = ((bbox.y + bbox.y2) / 2) + margin.y; + textAnchor = "start"; + break; + default : // "inner" position + textX = ((bbox.x + bbox.x2) / 2) + margin.x; + textY = ((bbox.y + bbox.y2) / 2) + margin.y; + textAnchor = "middle"; + } + return {"x": textX, "y": textY, "textAnchor": textAnchor}; + }, + + /* + * Get the legend conf matching with the value + * @param value the value to match with a slice in the legend + * @param legend the legend params object + * @return the legend slice matching with the value + */ + getLegendSlice: function (value, legend) { + for (var i = 0; i < legend.slices.length; ++i) { + if ((legend.slices[i].sliceValue !== undefined && value === legend.slices[i].sliceValue) || + ((legend.slices[i].sliceValue === undefined) && + (legend.slices[i].min === undefined || value >= legend.slices[i].min) && + (legend.slices[i].max === undefined || value <= legend.slices[i].max)) + ) { + return legend.slices[i]; + } + } + return {}; + }, + + /* + * Animated view box changes + * As from http://code.voidblossom.com/animating-viewbox-easing-formulas/, + * (from https://github.com/theshaun works on mapael) + * @param x coordinate of the point to focus on + * @param y coordinate of the point to focus on + * @param w map defined width + * @param h map defined height + * @param duration defined length of time for animation + * @param easingFunction defined Raphael supported easing_formula to use + */ + animateViewBox: function (targetX, targetY, targetW, targetH, duration, easingFunction) { + var self = this; + + var cx = self.currentViewBox.x; + var dx = targetX - cx; + var cy = self.currentViewBox.y; + var dy = targetY - cy; + var cw = self.currentViewBox.w; + var dw = targetW - cw; + var ch = self.currentViewBox.h; + var dh = targetH - ch; + + // Init current ViewBox target if undefined + if (!self.zoomAnimCVBTarget) { + self.zoomAnimCVBTarget = { + x: targetX, y: targetY, w: targetW, h: targetH + }; + } + + // Determine zoom direction by comparig current vs. target width + var zoomDir = (cw > targetW) ? 'in' : 'out'; + + var easingFormula = Raphael.easing_formulas[easingFunction || "linear"]; + + // To avoid another frame when elapsed time approach end (2%) + var durationWithMargin = duration - (duration * 2 / 100); + + // Save current zoomAnimStartTime before assigning a new one + var oldZoomAnimStartTime = self.zoomAnimStartTime; + self.zoomAnimStartTime = (new Date()).getTime(); + + /* Actual function to animate the ViewBox + * Uses requestAnimationFrame to schedule itself again until animation is over + */ + var computeNextStep = function () { + // Cancel any remaining animationFrame + // It means this new step will take precedence over the old one scheduled + // This is the case when the user is triggering the zoom fast (e.g. with a big mousewheel run) + // This actually does nothing when performing a single zoom action + self.cancelAnimationFrame(self.zoomAnimID); + // Compute elapsed time + var elapsed = (new Date()).getTime() - self.zoomAnimStartTime; + // Check if animation should finish + if (elapsed < durationWithMargin) { + // Hold the future ViewBox values + var x, y, w, h; + + // There are two ways to compute the next ViewBox size + // 1. If the target ViewBox has changed between steps (=> ADAPTATION step) + // 2. Or if the target ViewBox is the same (=> NORMAL step) + // + // A change of ViewBox target between steps means the user is triggering + // the zoom fast (like a big scroll with its mousewheel) + // + // The new animation step with the new target will always take precedence over the + // last one and start from 0 (we overwrite zoomAnimStartTime and cancel the scheduled frame) + // + // So if we don't detect the change of target and adapt our computation, + // the user will see a delay at beginning the ratio will stays at 0 for some frames + // + // Hence when detecting the change of target, we animate from the previous target. + // + // The next step will then take the lead and continue from there, achieving a nicer + // experience for user. + + // Change of target IF: an old animation start value exists AND the target has actually changed + if (oldZoomAnimStartTime && self.zoomAnimCVBTarget && self.zoomAnimCVBTarget.w !== targetW) { + // Compute the real time elapsed with the last step + var realElapsed = (new Date()).getTime() - oldZoomAnimStartTime; + // Compute then the actual ratio we're at + var realRatio = easingFormula(realElapsed / duration); + // Compute new ViewBox values + // The difference with the normal function is regarding the delta value used + // We don't take the current (dx, dy, dw, dh) values yet because they are related to the new target + // But we take the old target + x = cx + (self.zoomAnimCVBTarget.x - cx) * realRatio; + y = cy + (self.zoomAnimCVBTarget.y - cy) * realRatio; + w = cw + (self.zoomAnimCVBTarget.w - cw) * realRatio; + h = ch + (self.zoomAnimCVBTarget.h - ch) * realRatio; + // Update cw, cy, cw and ch so the next step take animation from here + cx = x; + dx = targetX - cx; + cy = y; + dy = targetY - cy; + cw = w; + dw = targetW - cw; + ch = h; + dh = targetH - ch; + // Update the current ViewBox target + self.zoomAnimCVBTarget = { + x: targetX, y: targetY, w: targetW, h: targetH + }; + } else { + // This is the classical approach when nothing come interrupting the zoom + // Compute ratio according to elasped time and easing formula + var ratio = easingFormula(elapsed / duration); + // From the current value, we add a delta with a ratio that will leads us to the target + x = cx + dx * ratio; + y = cy + dy * ratio; + w = cw + dw * ratio; + h = ch + dh * ratio; + } + + // Some checks before applying the new viewBox + if (zoomDir === 'in' && (w > self.currentViewBox.w || w < targetW)) { + // Zooming IN and the new ViewBox seems larger than the current value, or smaller than target value + // We do NOT set the ViewBox with this value + // Otherwise, the user would see the camera going back and forth + } else if (zoomDir === 'out' && (w < self.currentViewBox.w || w > targetW)) { + // Zooming OUT and the new ViewBox seems smaller than the current value, or larger than target value + // We do NOT set the ViewBox with this value + // Otherwise, the user would see the camera going back and forth + } else { + // New values look good, applying + self.setViewBox(x, y, w, h); + } + + // Schedule the next step + self.zoomAnimID = self.requestAnimationFrame(computeNextStep); + } else { + /* Zoom animation done ! */ + // Perform some cleaning + self.zoomAnimStartTime = null; + self.zoomAnimCVBTarget = null; + // Make sure the ViewBox hits the target! + if (self.currentViewBox.w !== targetW) { + self.setViewBox(targetX, targetY, targetW, targetH); + } + // Finally trigger afterZoom event + self.$map.trigger("afterZoom", { + x1: targetX, y1: targetY, + x2: (targetX + targetW), y2: (targetY + targetH) + }); + } + }; + + // Invoke the first step directly + computeNextStep(); + }, + + /* + * requestAnimationFrame/cancelAnimationFrame polyfill + * Based on https://gist.github.com/jlmakes/47eba84c54bc306186ac1ab2ffd336d4 + * and also https://gist.github.com/paulirish/1579671 + * + * _requestAnimationFrameFn and _cancelAnimationFrameFn hold the current functions + * But requestAnimationFrame and cancelAnimationFrame shall be called since + * in order to be in window context + */ + // The function to use for requestAnimationFrame + requestAnimationFrame: function(callback) { + return this._requestAnimationFrameFn.call(window, callback); + }, + // The function to use for cancelAnimationFrame + cancelAnimationFrame: function(id) { + this._cancelAnimationFrameFn.call(window, id); + }, + // The requestAnimationFrame polyfill'd function + // Value set by self-invoking function, will be run only once + _requestAnimationFrameFn: (function () { + var polyfill = (function () { + var clock = (new Date()).getTime(); + + return function (callback) { + var currentTime = (new Date()).getTime(); + + // requestAnimationFrame strive to run @60FPS + // (e.g. every 16 ms) + if (currentTime - clock > 16) { + clock = currentTime; + callback(currentTime); + } else { + // Ask browser to schedule next callback when possible + return setTimeout(function () { + polyfill(callback); + }, 0); + } + }; + })(); + + return window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.msRequestAnimationFrame || + window.oRequestAnimationFrame || + polyfill; + })(), + // The CancelAnimationFrame polyfill'd function + // Value set by self-invoking function, will be run only once + _cancelAnimationFrameFn: (function () { + return window.cancelAnimationFrame || + window.webkitCancelAnimationFrame || + window.webkitCancelRequestAnimationFrame || + window.mozCancelAnimationFrame || + window.mozCancelRequestAnimationFrame || + window.msCancelAnimationFrame || + window.msCancelRequestAnimationFrame || + window.oCancelAnimationFrame || + window.oCancelRequestAnimationFrame || + clearTimeout; + })(), + + /* + * SetViewBox wrapper + * Apply new viewbox values and keep track of them + * + * This avoid using the internal variable paper._viewBox which + * may not be present in future version of Raphael + */ + setViewBox: function(x, y, w, h) { + var self = this; + // Update current value + self.currentViewBox.x = x; + self.currentViewBox.y = y; + self.currentViewBox.w = w; + self.currentViewBox.h = h; + // Perform set view box + self.paper.setViewBox(x, y, w, h, false); + }, + + /* + * Animate wrapper for Raphael element + * + * Perform an animation and ensure the non-animated attr are set. + * This is needed for specific attributes like cursor who will not + * be animated, and thus not set. + * + * If duration is set to 0 (or not set), no animation are performed + * and attributes are directly set (and the callback directly called) + */ + // List extracted from Raphael internal vars + // Diff between Raphael.availableAttrs and Raphael._availableAnimAttrs + _nonAnimatedAttrs: [ + "arrow-end", "arrow-start", "gradient", + "class", "cursor", "text-anchor", + "font", "font-family", "font-style", "font-weight", "letter-spacing", + "src", "href", "target", "title", + "stroke-dasharray", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit" + ], + /* + * @param element Raphael element + * @param attrs Attributes object to animate + * @param duration Animation duration in ms + * @param callback Callback to eventually call after animation is done + */ + animate: function(element, attrs, duration, callback) { + var self = this; + // Check element + if (!element) return; + if (duration > 0) { + // Filter out non-animated attributes + // Note: we don't need to delete from original attribute (they won't be set anyway) + var attrsNonAnimated = {}; + for (var i=0 ; i < self._nonAnimatedAttrs.length ; i++) { + var attrName = self._nonAnimatedAttrs[i]; + if (attrs[attrName] !== undefined) { + attrsNonAnimated[attrName] = attrs[attrName]; + } + } + // Set non-animated attributes + element.attr(attrsNonAnimated); + // Start animation for all attributes + element.animate(attrs, duration, 'linear', function() { + if (callback) callback(); + }); + } else { + // No animation: simply set all attributes... + element.attr(attrs); + // ... and call the callback if needed + if (callback) callback(); + } + }, + + /* + * Check for Raphael bug regarding drawing while beeing hidden (under display:none) + * See https://github.com/neveldo/jQuery-Mapael/issues/135 + * @return true/false + * + * Wants to override this behavior? Use prototype overriding: + * $.mapael.prototype.isRaphaelBBoxBugPresent = function() {return false;}; + */ + isRaphaelBBoxBugPresent: function() { + var self = this; + // Draw text, then get its boundaries + var textElem = self.paper.text(-50, -50, "TEST"); + var textElemBBox = textElem.getBBox(); + // remove element + textElem.remove(); + // If it has no height and width, then the paper is hidden + return (textElemBBox.width === 0 && textElemBBox.height === 0); + }, + + // Default map options + defaultOptions: { + map: { + cssClass: "map", + tooltip: { + cssClass: "mapTooltip" + }, + defaultArea: { + attrs: { + fill: "#343434", + stroke: "#5d5d5d", + "stroke-width": 1, + "stroke-linejoin": "round" + }, + attrsHover: { + fill: "#f38a03", + animDuration: 300 + }, + text: { + position: "inner", + margin: 10, + attrs: { + "font-size": 15, + fill: "#c7c7c7" + }, + attrsHover: { + fill: "#eaeaea", + "animDuration": 300 + } + }, + target: "_self", + cssClass: "area" + }, + defaultPlot: { + type: "circle", + size: 15, + attrs: { + fill: "#0088db", + stroke: "#fff", + "stroke-width": 0, + "stroke-linejoin": "round" + }, + attrsHover: { + "stroke-width": 3, + animDuration: 300 + }, + text: { + position: "right", + margin: 10, + attrs: { + "font-size": 15, + fill: "#c7c7c7" + }, + attrsHover: { + fill: "#eaeaea", + animDuration: 300 + } + }, + target: "_self", + cssClass: "plot" + }, + defaultLink: { + factor: 0.5, + attrs: { + stroke: "#0088db", + "stroke-width": 2 + }, + attrsHover: { + animDuration: 300 + }, + text: { + position: "inner", + margin: 10, + attrs: { + "font-size": 15, + fill: "#c7c7c7" + }, + attrsHover: { + fill: "#eaeaea", + animDuration: 300 + } + }, + target: "_self", + cssClass: "link" + }, + zoom: { + enabled: false, + minLevel: 0, + maxLevel: 10, + step: 0.25, + mousewheel: true, + touch: true, + animDuration: 200, + animEasing: "linear", + buttons: { + "reset": { + cssClass: "zoomButton zoomReset", + content: "•", // bullet sign + title: "Reset zoom" + }, + "in": { + cssClass: "zoomButton zoomIn", + content: "+", + title: "Zoom in" + }, + "out": { + cssClass: "zoomButton zoomOut", + content: "−", // minus sign + title: "Zoom out" + } + } + } + }, + legend: { + redrawOnResize: true, + area: [], + plot: [] + }, + areas: {}, + plots: {}, + links: {} + }, + + // Default legends option + legendDefaultOptions: { + area: { + cssClass: "areaLegend", + display: true, + marginLeft: 10, + marginLeftTitle: 5, + marginBottomTitle: 10, + marginLeftLabel: 10, + marginBottom: 10, + titleAttrs: { + "font-size": 16, + fill: "#343434", + "text-anchor": "start" + }, + labelAttrs: { + "font-size": 12, + fill: "#343434", + "text-anchor": "start" + }, + labelAttrsHover: { + fill: "#787878", + animDuration: 300 + }, + hideElemsOnClick: { + enabled: true, + opacity: 0.2, + animDuration: 300 + }, + slices: [], + mode: "vertical" + }, + plot: { + cssClass: "plotLegend", + display: true, + marginLeft: 10, + marginLeftTitle: 5, + marginBottomTitle: 10, + marginLeftLabel: 10, + marginBottom: 10, + titleAttrs: { + "font-size": 16, + fill: "#343434", + "text-anchor": "start" + }, + labelAttrs: { + "font-size": 12, + fill: "#343434", + "text-anchor": "start" + }, + labelAttrsHover: { + fill: "#787878", + animDuration: 300 + }, + hideElemsOnClick: { + enabled: true, + opacity: 0.2, + animDuration: 300 + }, + slices: [], + mode: "vertical" + } + } + + }; + + // Mapael version number + // Accessible as $.mapael.version + Mapael.version = version; + + // Extend jQuery with Mapael + if ($[pluginName] === undefined) $[pluginName] = Mapael; + + // Add jQuery DOM function + $.fn[pluginName] = function (options) { + // Call Mapael on each element + return this.each(function () { + // Avoid leaking problem on multiple instanciation by removing an old mapael object on a container + if ($.data(this, pluginName)) { + $.data(this, pluginName).destroy(); + } + // Create Mapael and save it as jQuery data + // This allow external access to Mapael using $(".mapcontainer").data("mapael") + $.data(this, pluginName, new Mapael(this, options)); + }); + }; + + return Mapael; + +})); |