/*!
 *
 * Jquery Mapael - Dynamic maps jQuery plugin (based on raphael.js)
 * Requires jQuery, raphael.js and jquery.mousewheel
 *
 * Version: 2.2.0
 *
 * Copyright (c) 2018 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, -100, 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) {
          setTimeout(function () { 
            $is_lang_ar = $('html').attr('lang') === 'ar';
            $('.torch-detail-slide').owlCarousel({
              loop: true,
              dots: false,
              // autoPlay: 100,
              autoplay: true,
              autoplayTimeout:1000,
              items: 1,
              rtl: $is_lang_ar ? true : false
            });
          }, 1);
          // 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: '&#8226;', // bullet sign
              title: 'Reset zoom'
            },
            in: {
              cssClass: 'zoomButton zoomIn',
              content: '+',
              title: 'Zoom in'
            },
            out: {
              cssClass: 'zoomButton zoomOut',
              content: '&#8722;', // 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;
});
