/*! * * 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 = $("
").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 = $("
").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; }));