/*! * GMAP3 Plugin for jQuery * Version : 7.1 * Date : 2016/04/17 * Author : DEMONTE Jean-Baptiste * Contact : jbdemonte@gmail.com * Web site : http://gmap3.net * Licence : GPL-3.0+ */ (function ($, window, document) { "use strict"; var gm, services = {}, loadOptions, // Proxify functions to get shorter minimized code when = $.when, extend = $.extend, isArray = $.isArray, isFunction = $.isFunction, deferred = $.Deferred; /** * Duplicate option to never modify original object * @param {Object} options * @returns {Object} */ function dupOpts(options) { return extend(true, {}, options || {}); } /** * Slice an array like * @params {Array|Object} * @params {Number} [start] * @params {Number} [end] * @returns {Array} */ function slice() { var fn = Array.prototype.slice, args = fn.call(arguments, 1); return fn.apply(arguments[0], args); } /** * Return true if value is undefined * @param {*} value * @returns {Boolean} */ function isUndefined(value) { return typeof value === 'undefined'; } /** * Equivalent to Promise.all * @param {Deferred[]} deferreds * @returns {Deferred} */ function all(deferreds) { return when.apply($, deferreds); } /** * Equivalent to Promise.resolve * @param {*} value * @returns {Deferred} */ function resolved(value) { return when().then(function () { return value; }); } /** * return the distance between 2 latLng in meters * @param {LatLng} origin * @param {LatLng} destination * @returns {Number} **/ function distanceInMeter(origin, destination) { var m = Math, pi = m.PI, e = pi * origin.lat() / 180, f = pi * origin.lng() / 180, g = pi * destination.lat() / 180, h = pi * destination.lng() / 180, cos = m.cos, sin = m.sin; return 1000 * 6371 * m.acos(m.min(cos(e) * cos(g) * cos(f) * cos(h) + cos(e) * sin(f) * cos(g) * sin(h) + sin(e) * sin(g), 1)); } function ready(fn) { if (document.readyState != 'loading'){ fn(); } else { document.addEventListener('DOMContentLoaded', fn); } } function serialize(obj) { return objectKeys(obj).map(function (key) { return encodeURIComponent(key) + "=" + encodeURIComponent(obj[key]); }).join("&"); } // Auto-load google maps library if needed (function () { var dfd = deferred(), cbName = '__gmap3', script; $.holdReady(true); ready(function () { if (window.google && window.google.maps || loadOptions === false) { dfd.resolve(); } else { // callback function - resolving promise after maps successfully loaded window[cbName] = function () { delete window[cbName]; dfd.resolve(); }; script = document.createElement('script'); script.type = 'text/javascript'; script.src = 'https://maps.googleapis.com/maps/api/js?callback=' + cbName + (loadOptions ? '&' + (typeof loadOptions === 'string' ? loadOptions : serialize(loadOptions)) : ''); $("head").append(script); } }); return dfd.promise(); })().then(function () { $.holdReady(false); }); /** * Instantiate only once a google service * @param {String} name * @returns {Object} */ function service(name) { if (!services[name]) { services[name] = gmElement(name); } return services[name]; } /** * Return GoogleMap Class (or overwritten by user) instance * @param {String} name * @returns {Object} */ function gmElement(name) { var cls = gm[name]; function F(args) { return cls.apply(this, args); } F.prototype = cls.prototype; return new F(slice(arguments, 1)); } /** * Resolve a GeocodeRequest * https://developers.google.com/maps/documentation/javascript/geocoding * @param {String|Object} request * @returns {Deferred} */ function geocode(request) { var dfd = deferred(); if (typeof request === 'string') { request = { address: request }; } service('Geocoder').geocode(request, function(results, status) { if (status === gm.GeocoderStatus.OK) { dfd.resolve(results[0].geometry.location); } else { dfd.reject(); } }); return dfd; } /** * Callable function taking a parameter as string * @callback StringCallback * @param {String} */ /** * Split a string and execute a function on each item * @param {String} str - Space separated list of items * @param {StringCallback} fn - Callback function */ function foreachStr(str, fn) { str.split(' ').forEach(fn); } /** * Execute a function on each items if items is an array and on items as a single element if it is not an array * @param {Array|*} items - Items to execute foreach callback on * @param {Function} fn - Callback function */ function foreach(items, fn) { (isArray(items) ? items : [items]).forEach(fn); } /** * Return Object keys * @param {Object} obj * @returns {String[]} */ function objectKeys(obj) { return Object.keys(obj); } /** * Return Object values * @param {Object} obj * @returns {*[]} */ function objectValues(obj) { return objectKeys(obj).map(function (key) { return obj[key]; }); } /** * Resolution function * @callback OptionCallback * @param {Object} options * @returns {Deferred|*} */ /** * Convert bounds from array [ n, e, s, w ] to google.maps.LatLngBounds * @param {Object} options - Container of options.bounds * @param {OptionCallback} fn * @returns {Deferred} */ function resolveLatLngBounds(options, fn) { options = dupOpts(options); if (options.bounds) { options.bounds = toLatLngBound(options.bounds); } return resolved(fn(options)); } /** * Resolve an address location / convert a LatLng array to google.maps.LatLng object * @param {Object} options * @param {String} key - LatLng key name in options object * @param {OptionCallback} fn * @returns {Deferred} */ function resolveLatLng(options, key, fn) { var dfd = deferred(); options = dupOpts(options); when() .then(function () { var address = options.address; if (address) { delete options.address; return geocode(address).then(function (latLng) { options[key] = latLng; }); } options[key] = toLatLng(options[key]); }) .then(function () { dfd.resolve(fn(options)); }); return dfd; } /** * Convert an array of mixed LatLng to google.maps.LatLng object * No address resolution here * @param {Object} options * @param {String} key - Array key name in options object * @param {OptionCallback} fn * @returns {Deferred} */ function resolveArrayOfLatLng(options, key, fn) { options = dupOpts(options); options[key] = (options[key] || []).map(function (item) { return toLatLng(item); }); return resolved(fn(options)); } /** * Convert a LatLng array to google.maps.LatLng * @param {Array|*} mixed * @param {Boolean} [convertLiteral] * @returns {LatLng} */ function toLatLng(mixed, convertLiteral) { return isArray(mixed) ? new gm.LatLng(mixed[0], mixed[1]) : (convertLiteral && mixed && !(mixed instanceof gm.LatLng) ? new gm.LatLng(mixed.lat, mixed.lng) : mixed); } /** * Convert a LatLngBound array to google.maps.LatLngBound * @param {Array|*} mixed * @param {Boolean} [convertLiteral] * @returns {LatLngBounds} */ function toLatLngBound(mixed, convertLiteral) { if (isArray(mixed)) { return new gm.LatLngBounds({lat: mixed[2], lng: mixed[3]}, {lat: mixed[0], lng: mixed[1]}); } else if (convertLiteral && !mixed.getCenter){ return new gm.LatLngBounds({lat: mixed.south, lng: mixed.west}, {lat: mixed.north, lng: mixed.east}); } return mixed; } /** * Create a custom overlay view * @param {Map} map * @param {Object} options * @returns {OverlayView} */ function createOverlayView(map, options) { var GMOverlayView = gm.OverlayView; var $div = $(document.createElement("div")) .css({ border: "none", borderWidth: 0, position: "absolute" }) .append(options.content); options = extend({x: 0, y: 0}, options); if (options.position) { options.position = toLatLng(options.position, true); } else if (options.bounds) { options.bounds = toLatLngBound(options.bounds, true); } /** * Class OverlayView * @constructor */ function OverlayView() { var self = this, listeners = []; GMOverlayView.call(self); self.setMap(map); function fromLatLngToDivPixel(latlng) { return self.getProjection().fromLatLngToDivPixel(latlng); } self.onAdd = function () { var panes = self.getPanes(); panes.overlayMouseTarget.appendChild($div[0]); }; if (options.position) { self.getPosition = function () { return options.position; }; self.setPosition = function (latlng) { options.position = latlng; self.draw(); }; self.draw = function () { var ps = fromLatLngToDivPixel(options.position); $div.css({ left: (ps.x + options.x) + 'px', top: (ps.y + options.y) + 'px' }); }; } else { self.getBounds = function () { return options.bounds; }; self.setBounds = function (bounds) { options.bounds = bounds; self.draw(); }; self.draw = function() { var sw = fromLatLngToDivPixel(options.bounds.getSouthWest()); var ne = fromLatLngToDivPixel(options.bounds.getNorthEast()); $div.css({ left: (sw.x + options.x) + 'px', top: (ne.y + options.y) + 'px', width: (ne.x - sw.x + options.x) + 'px', height: (sw.y - ne.y + options.y) + 'px' }); }; } self.onRemove = function () { listeners.map(function (handler) { gm.event.removeListener(handler); }); $div.remove(); self.$ = $div = null; // mem leaks }; self.$ = $div; } OverlayView.prototype = new GMOverlayView(); return new OverlayView(); } /** * Return a map projection * @param {Map} map * @returns {*} */ function getProjection(map) { function Overlay() { var self = this; self.onAdd = self.onRemove = self.draw = function () {}; return gm.OverlayView.call(self); } Overlay.prototype = new gm.OverlayView(); var overlay = new Overlay(); overlay.setMap(map); return overlay.getProjection(); } /** * Class used as event first parameter on clustering overlays * @param {Cluster} cluster * @param {Marker[]} markers * @param {OverlayView} overlay * @param {LatLngBounds} bounds * @constructor */ function ClusterOverlay(cluster, markers, overlay, bounds) { var self = this; self.cluster = cluster; self.markers = markers; self.$ = overlay.$; self.overlay = overlay; overlay.getBounds = function () { return gmElement('LatLngBounds', bounds.getSouthWest(), bounds.getNorthEast()); }; } /** * Cluster Group definition. * @typedef {Object} ClusterGroupDef * @property {String|jQuery} content * @property {Number} [x] Offset * @property {Number} [y] Offset */ /** * Cluster evaluation function * @callback clusterCallback * @param {Marker[]} markers * @return {ClusterGroupDef|undefined} */ /** * Class used to handle clustering * @param {Map} map * @param {Object} options * @param {Integer} [options.size] * @param {Object[]} [options.markers] markers definition * @param {clusterCallback} [options.cb] callback used to evaluate clustering elements * @constructor */ function Cluster(map, options) { var timer, igniter, previousViewHash, projection, filter, self = this, markers = [], radius = (options.size || 200) >> 1, enabled = true, overlays = {}, handlers = []; options = options || {}; options.markers = options.markers || []; /** * Cluster evaluation function * @callback bindCallback * @param {ClusterOverlay[]} instances */ /** * Bind a function to each current or future overlays * @param {bindCallback} fn */ self._b = function (fn) { fn(objectValues(overlays)); handlers.push(fn); }; /** * Get the marker list * @returns {Marker[]} */ self.markers = function () { return slice(markers); }; /** * Get the current groups * @returns {ClusterOverlay[]} */ self.groups = function () { return objectValues(overlays); }; /** * Enable the clustering feature */ self.enable = function () { if (!enabled) { enabled = true; previousViewHash = ''; delayRedraw(); } }; /** * Disable the clustering feature */ self.disable = function () { if (enabled) { enabled = false; previousViewHash = ''; delayRedraw(); } }; /** * Add a marker * @param {Marker} marker */ self.add = function (marker) { markers.push(marker); previousViewHash = ''; delayRedraw(); }; /** * Remove a marker * @param {Marker} marker */ self.remove = function (marker) { markers = markers.filter(function (item) { return item !== marker; }); previousViewHash = ''; delayRedraw(); }; /** * Filtering function, Cluster only handle those who return true * @callback filterCallback * @param {Marker} marker * @returns {Boolean} */ /** * Set a filter function * @param {filterCallback} fn */ self.filter = function (fn) { if (filter !== fn) { filter = fn; previousViewHash = ''; delayRedraw(); } }; /** * Generate extended visible bounds * @returns {LatLngBounds} */ function extendsMapBounds() { var circle = gmElement('Circle', { center: map.getCenter(), radius: 1.15 * distanceInMeter(map.getCenter(), map.getBounds().getNorthEast()) // + 15% }); return circle.getBounds(); } /** * Generate bounds extended by radius * @param {LatLng} latLng * @returns {LatLngBounds} */ function extendsBounds(latLng) { var p = projection.fromLatLngToDivPixel(latLng); return gmElement('LatLngBounds', projection.fromDivPixelToLatLng(gmElement('Point', p.x - radius, p.y + radius)), projection.fromDivPixelToLatLng(gmElement('Point', p.x + radius, p.y - radius)) ); } options.markers.map(function (opts) { opts.position = toLatLng(opts.position); markers.push(gmElement('Marker', opts)); }); /** * Redraw clusters */ function redraw() { var keys, bounds, overlayOptions, hash, currentMarkers, viewHash, zoom = map.getZoom(), currentHashes = {}, newOverlays = [], ignore = {}; viewHash = '' + zoom; if (zoom > 3) { bounds = extendsMapBounds(); foreach(markers, function (marker, index) { if (!bounds.contains(marker.getPosition())) { viewHash += '-' + index; ignore[index] = true; if (marker.getMap()) { marker.setMap(null); } } }); } if (filter) { foreach(markers, function (marker, index) { if (!ignore[index] && !filter(marker)) { viewHash += '-' + index; ignore[index] = true; if (marker.getMap()) { marker.setMap(null); } } }); } if (viewHash === previousViewHash) { return; } previousViewHash = viewHash; foreach(markers, function (marker, index) { if (ignore[index]) { return; } keys = [index]; bounds = extendsBounds(marker.getPosition()); if (enabled) { foreach(slice(markers, index + 1), function (marker, idx) { idx += index + 1; if (!ignore[idx] && bounds.contains(marker.getPosition())) { keys.push(idx); ignore[idx] = true; } }); } hash = keys.join('-'); currentHashes[hash] = true; if (overlays[hash]) { // hash is already set return; } currentMarkers = keys.map(function (key) { return markers[key]; }); // ask the user callback on this subset (may be composed by only one marker) overlayOptions = options.cb(slice(currentMarkers)); // create an overlay if cb returns its properties if (overlayOptions) { bounds = gmElement('LatLngBounds'); foreach(currentMarkers, function (marker) { bounds.extend(marker.getPosition()); if (marker.getMap()) { marker.setMap(null); } }); overlayOptions = dupOpts(overlayOptions); overlayOptions.position = bounds.getCenter(); overlays[hash] = new ClusterOverlay(self, slice(currentMarkers), createOverlayView(map, overlayOptions), bounds); newOverlays.push(overlays[hash]); } else { foreach(currentMarkers, function (marker) { if (!marker.getMap()) { // to avoid marker blinking marker.setMap(map); } }); } }); // remove previous overlays foreach(objectKeys(overlays), function (key) { if (!currentHashes[key]) { overlays[key].overlay.setMap(null); delete overlays[key]; } }); if (newOverlays.length) { foreach(handlers, function (fn) { fn(newOverlays); }); } } /** * Restart redraw timer */ function delayRedraw() { clearTimeout(timer); timer = setTimeout(redraw, 100); } /** * Init clustering */ function init() { gm.event.addListener(map, "zoom_changed", delayRedraw); gm.event.addListener(map, "bounds_changed", delayRedraw); redraw(); } igniter = setInterval(function () { projection = getProjection(map); if (projection) { clearInterval(igniter); init(); } }, 10); } /** * Configure google maps loading library * @param {string|object} options */ $.gmap3 = function (options) { loadOptions = options; }; /** * jQuery Plugin */ $.fn.gmap3 = function (options) { var items = []; gm = window.google.maps; // once gmap3 is loaded, google.maps library should be loaded this.each(function () { var $this = $(this), gmap3 = $this.data("gmap3"); if (!gmap3) { gmap3 = new Gmap3($this, options); $this.data("gmap3", gmap3); } items.push(gmap3); }); return new Handler(this, items); }; /** * Class Handler * Chainable objet which handle all Gmap3 items associated to all jQuery elements in the current command set * @param {jQuery} chain - "this" to return to maintain the jQuery chain * @param {Gmap3[]} items * @constructor */ function Handler(chain, items) { var self = this; // Map all functions from Gmap3 class objectKeys(items[0]).forEach(function (name) { self[name] = function () { var results = [], args = slice(arguments); items.forEach(function (item) { results.push(item[name].apply(item, args)); }); return name === 'get' ? (results.length > 1 ? results : results[0]) : self; }; }); self.$ = chain; } /** * Class Gmap3 * Handle a Google.maps.Map instance * @param {jQuery} $container - Element to display the map in * @param {Object} options - MapOptions * @constructor */ function Gmap3($container, options) { var map, previousResults = [], promise = when(), self = this; function context() { return { $: $container, get: self.get }; } /** * Attach events to instances * @param {Object } events * @param {Array|Object} instances * @param {array} [args] arguments to add * @param {Boolean} once */ function attachEvents(events, instances, args, once) { var hasArgs = arguments.length > 3; if (!hasArgs) { once = args; } $.each(events, function (eventName, handlers) { foreach(instances, function (instance) { var isClusterOverlay = instance instanceof ClusterOverlay; var isDom = isClusterOverlay || (instance instanceof gm.OverlayView); var eventListener = isDom ? instance.$.get(0) : instance; gm.event['add' + (isDom ? 'Dom' : '') + 'Listener' + (once ? 'Once' : '')](eventListener, eventName, function (event) { foreach(handlers, function (handler) { if (isFunction(handler)) { if (isClusterOverlay) { handler.call(context(), undefined /* marker */, instance, instance.cluster, event); } else if (hasArgs) { var buffer = slice(args); buffer.unshift(instance); buffer.push(event); handler.apply(context(), buffer); } else { handler.call(context(), instance, event); } } }); }); }); }); } /** * Decorator to handle multiple call based on array of options * @param {Function} fn * @returns {Deferred} */ function multiple(fn) { return function (options) { if (isArray(options)) { var instances = []; var promises = options.map(function (opts) { return fn.call(self, opts).then(function (instance) { instances.push(instance); }); }); return all(promises).then(function () { previousResults.push(instances); return instances; }); } else { return fn.apply(self, arguments).then(function (instance) { previousResults.push(instance); return instance; }); } }; } /** * Decorator to chain promise result onto the main promise chain * @param {Function} fn * @returns {Deferred} */ function chainToPromise(fn) { return function () { var args = slice(arguments); promise = promise.then(function (instance) { if (isFunction(args[0])) { // handle return as a deferred / promise to support both sync & async result return when(args[0].call(context(), instance)).then(function (value) { args[0] = value; return fn.apply(self, args); }); } return when(fn.apply(self, args)); }); return promise; }; } self.map = chainToPromise(function (options) { return map || resolveLatLng(options, 'center', function (opts) { map = gmElement('Map', $container.get(0), opts); previousResults.push(map); return map; }); }); // Space separated string of : separated element // (google.maps class name) : (latLng property name) : (add map - 0|1 - default = 1) foreachStr('Marker:position Circle:center InfoWindow:position:0 Polyline:path Polygon:paths', function (item) { item = item.split(':'); var property = item[1] || ''; self[item[0].toLowerCase()] = chainToPromise(multiple(function (options) { return (property.match(/^path/) ? resolveArrayOfLatLng : resolveLatLng)(options, property, function (opts) { if (item[2] !== '0') { opts.map = map; } return gmElement(item[0], opts); }); })); }); foreachStr('TrafficLayer TransitLayer BicyclingLayer', function (item) { self[item.toLowerCase()] = chainToPromise(function () { var instance = gmElement(item); previousResults.push(instance); instance.setMap(map); return instance; }); }); self.kmllayer = chainToPromise(multiple(function (options) { options = dupOpts(options); options.map = map; return when(gmElement('KmlLayer', options)); })); self.rectangle = chainToPromise(multiple(function (options) { return resolveLatLngBounds(options, function (opts) { opts.map = map; return gmElement('Rectangle', opts); }); })); self.overlay = chainToPromise(multiple(function (options) { function fn(opts) { return createOverlayView(map, opts); } options = dupOpts(options); return options.bounds ? resolveLatLngBounds(options, fn) : resolveLatLng(options, 'position', fn); })); self.groundoverlay = chainToPromise(function (url, bounds, options) { return resolveLatLngBounds({bounds: bounds}, function (opts) { options = dupOpts(options); options.map = map; var instance = gmElement('GroundOverlay', url, opts.bounds, options); previousResults.push(instance); return instance; }); }); self.styledmaptype = chainToPromise(function (styleId, styles, options) { var instance = gmElement('StyledMapType', styles, options); previousResults.push(instance); map.mapTypes.set(styleId, instance); return instance; }); self.streetviewpanorama = chainToPromise(function (container, options) { return resolveLatLng(options, 'position', function (opts) { var instance = gmElement('StreetViewPanorama', $(container).get(0), opts); map.setStreetView(instance); previousResults.push(instance); return instance; }); }); self.route = chainToPromise(function (options) { var dfd = deferred(); options = dupOpts(options); options.origin = toLatLng(options.origin); options.destination = toLatLng(options.destination); service('DirectionsService').route(options, function (results, status) { previousResults.push(results); dfd.resolve(status === gm.DirectionsStatus.OK ? results : false); }); return dfd; }); self.cluster = chainToPromise(function (options) { var cluster = new Cluster(map, dupOpts(options)); previousResults.push(cluster); return resolved(cluster); }); self.directionsrenderer = chainToPromise(function (options) { var instance; if (options) { options = dupOpts(options); options.map = map; if (options.panel) { options.panel = $(options.panel).get(0); } instance = gmElement('DirectionsRenderer', options); } previousResults.push(instance); return instance; }); self.latlng = chainToPromise(multiple(function (options) { return resolveLatLng(options, 'latlng', function (opts) { previousResults.push(opts.latlng); return opts.latlng; }); })); self.fit = chainToPromise(function () { var bounds = gmElement('LatLngBounds'); foreach(previousResults, function (instances) { if (instances !== map) { foreach(instances, function (instance) { if (instance) { if (instance.getPosition && instance.getPosition()) { bounds.extend(instance.getPosition()); } else if (instance.getBounds && instance.getBounds()) { bounds.extend(instance.getBounds().getNorthEast()); bounds.extend(instance.getBounds().getSouthWest()); } else if (instance.getPaths && instance.getPaths()) { foreach(instance.getPaths().getArray(), function (path) { foreach(path.getArray(), function (latLng) { bounds.extend(latLng); }); }); } else if (instance.getPath && instance.getPath()) { foreach(instance.getPath().getArray(), function (latLng) { bounds.extend(latLng); }); } else if (instance.getCenter && instance.getCenter()) { bounds.extend(instance.getCenter()); } } }); } }); if (!bounds.isEmpty()) { map.fitBounds(bounds); } return true; }); self.wait = function (duration) { promise = promise.then(function (instance) { var dfd = deferred(); setTimeout(function () { dfd.resolve(instance); }, duration); return dfd; }); }; self.then = function (fn) { if (isFunction(fn)) { promise = promise.then(function (instance) { return when(fn.call(context(), instance)).then(function (newInstance) { return isUndefined(newInstance) ? instance : newInstance; }); }); } }; foreachStr('on once', function (name, once) { self[name] = function () { var events = arguments[0]; if (events) { if (typeof events === 'string') { // cast call on('click', handler) to on({click: handler}) events = {}; events[arguments[0]] = slice(arguments, 1); } promise.then(function (instances) { if (instances) { if (instances instanceof Cluster) { instances._b(function (items) { if (items && items.length) { attachEvents(events, items, once); } }); return attachEvents(events, instances.markers(), [undefined, instances], once); } attachEvents(events, instances, once); } }); } }; }); self.get = function (index) { if (isUndefined(index)) { return previousResults.map(function (instance) { return isArray(instance) ? instance.slice() : instance; }); } else { if (index < 0) { index = previousResults.length + index; } return isArray(previousResults[index]) ? previousResults[index].slice() : previousResults[index]; } }; if (options) { self.map(options); } } })(jQuery, window, document);