diff --git a/README.md b/README.md index 4c50d6b..e514c57 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,27 @@ A Google Maps JavaScript API v3 library to create and manage per-zoom-level clus Based on [Marker Clusterer – A Google Maps JavaScript API utility library](https://github.com/googlemaps/js-marker-clusterer) by Luke Mehe (Google Inc.). +You can find the minified JS file [in the releases tab](https://github.com/Connum/data-layer-clusterer/releases). + +## About this fork + +While working with the data layer feature was fun because of the simplicity of adding and getting map content via GeoJSON, I soon encountered the problem of too many markers, lines and polygons being displayed when zooming out of the map. I knew about the marker clusterer for normal layers, but it took me browsing through several StackOverflow posts and pages of search results to stumble upon nantunes' approach to data layers. + +Seeing that there hadn't been any work done on the project for almost a year, I tried out jesusr's fork, which included some fixes/optimizations, but out-of-the-box it would just throw JS errors in the console. After forking it and getting it to work, I had to find out that just like the version in the initial repo, there was no support for LineStrings or Polygons, which I needed. Also, the URL to the cluster marker icons was no longer valid, so that had to be fixed as well, and so I did. + +My current implementation now includes the following changes: +- Fixed cluster marker image URLs +- Added SVG versions of the marker images which will be used by default if supported by the browser and falls back to the PNG versions +- LineStrings and Polygons are being clustered as well, using the center point of their bounding rectangles +- new option 'setProperty': If set to true, instead of changing the StyleOption attribute 'visible' of the features directly, a boolean property 'in_cluster' (or a configurable property name defined in the constant DataLayerClusterer.CLUSTER_PROPERTY_NAME) is set on the features, which can then be used to toggle visibility (for example in order to take into account other properties for additonal filtering) +- new option 'recolorSvg': (string) only takes action if SVG is supported and being used: a selector string for an SVG element in the set imagePath that can be used for re-coloring the cluster marker image. This saves requests and prevents the different marker images popping up after loading. +- new option: 'minimumPolySize': (number) The minimum width or height of the bounding box of a feature (other than type 'Point') in pixels before it is forced into a cluster, even if the cluster ends up containing only this one feature. 0 or false to disable this functionality. Defaults to 50. + +To read more and view a working example, see my blog post at www.constantinmedia.com/2016/09/google-maps-javascript-api-v3-handling-large-amounts-of-features-using-clustering-in-data-layers/ + +## More to come +- When LineStrings and Polygons are becoming too small according to the minimumPolySize option, display a marker instead for better visibility. + ## License Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/bower.json b/bower.json index f539b13..22934d0 100644 --- a/bower.json +++ b/bower.json @@ -1,17 +1,18 @@ { "name": "data-layer-clusterer", - "version": "0.7.3", - "homepage": "https://github.com/nantunes/data-layer-clusterer", + "version": "1.0.1", + "homepage": "https://github.com/Connum/data-layer-clusterer", "authors": [ "Nelson Antunes" ], - "description": "The library creates and manages per-zoom-level clusters large amounts of data layer features. Google API v3.", + "description": "The library creates and manages per-zoom-level clusters for large amounts of data layer features. Google API v3.", "main": "src/datalayerclusterer.js", "keywords": [ "google", "maps", "data", "layer", + "features", "marker", "cluster", "clusterer", diff --git a/images/m1.png b/images/m1.png new file mode 100644 index 0000000..329ff52 Binary files /dev/null and b/images/m1.png differ diff --git a/images/m1.svg b/images/m1.svg new file mode 100644 index 0000000..c471f17 --- /dev/null +++ b/images/m1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/m2.png b/images/m2.png new file mode 100644 index 0000000..b999cbc Binary files /dev/null and b/images/m2.png differ diff --git a/images/m2.svg b/images/m2.svg new file mode 100644 index 0000000..104fb04 --- /dev/null +++ b/images/m2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/m3.png b/images/m3.png new file mode 100644 index 0000000..9f30b30 Binary files /dev/null and b/images/m3.png differ diff --git a/images/m3.svg b/images/m3.svg new file mode 100644 index 0000000..3b9e7a2 --- /dev/null +++ b/images/m3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/m4.png b/images/m4.png new file mode 100644 index 0000000..0d3f826 Binary files /dev/null and b/images/m4.png differ diff --git a/images/m4.svg b/images/m4.svg new file mode 100644 index 0000000..26c3dab --- /dev/null +++ b/images/m4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/m5.png b/images/m5.png new file mode 100644 index 0000000..61387d2 Binary files /dev/null and b/images/m5.png differ diff --git a/images/m5.svg b/images/m5.svg new file mode 100644 index 0000000..4f1c981 --- /dev/null +++ b/images/m5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index f539b13..22934d0 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,18 @@ { "name": "data-layer-clusterer", - "version": "0.7.3", - "homepage": "https://github.com/nantunes/data-layer-clusterer", + "version": "1.0.1", + "homepage": "https://github.com/Connum/data-layer-clusterer", "authors": [ "Nelson Antunes" ], - "description": "The library creates and manages per-zoom-level clusters large amounts of data layer features. Google API v3.", + "description": "The library creates and manages per-zoom-level clusters for large amounts of data layer features. Google API v3.", "main": "src/datalayerclusterer.js", "keywords": [ "google", "maps", "data", "layer", + "features", "marker", "cluster", "clusterer", diff --git a/src/datalayerclusterer.js b/src/datalayerclusterer.js index d49a9d4..432d758 100755 --- a/src/datalayerclusterer.js +++ b/src/datalayerclusterer.js @@ -3,9 +3,11 @@ 'use strict'; /** - * @name DataLayerClusterer for Google Maps v3 - * @version version 0.7.2 + * @name DataLayerClusterer for Google Maps v3 (Connum's Fork) + * @version version 1.0.1 * @author Nelson Antunes + * @author Jesús R Peinado + * @author Constantin Groß * * The library creates and manages per-zoom-level clusters for large amounts of * data layer features. @@ -33,7 +35,7 @@ * * @param {google.maps.Map} map The Google map to attach to. * @param {Object=} optOptions support the following options: - * 'map': (google.maps.Map) The Google map to attach to. + * 'map': (google.maps.Map) The Google map to attach to. * 'gridSize': (number) The grid size of a cluster in pixels. * 'maxZoom': (number) The maximum zoom level that a feature can be part of a * cluster. @@ -44,6 +46,22 @@ * 'minimumClusterSize': (number) The minimum number of features to be in a * cluster before the features are hidden and a count * is shown. + * 'minimumPolySize': (number) The minimum width or height of the bounding box + * of a feature (other than type 'Point') in pixels before + * it is forced into a cluster, even if the cluster ends up + * containing only this one feature. 0 or false to disable + * this functionality. + * 'setProperty': (boolean) when true, the features will not be hidden, but + * the property 'in_cluster' (or a configurable property name defined + * in the constant DataLayerClusterer.CLUSTER_PROPERTY_NAME) + * will be set to a boolean value, indicating whether the feature is + * currently being clustered or not. This allows to handle + * hiding/showing manually, taking other factors (like filtering) + * into account. + * 'recolorSVG': (string) only takes action if SVG is being used: + * a selector for an SVG element in the set imagePath that can be used + * for re-coloring the cluster marker image. This saves requests and + * prevents the different marker images popping up after loading. * 'styles': (object) An object that has style properties: * 'url': (string) The image url. * 'height': (number) The image height. @@ -55,34 +73,68 @@ * @constructor * @extends google.maps.OverlayView */ -function DataLayerClusterer(optOptions) { +function DataLayerClusterer (optOptions) { DataLayerClusterer.extend(DataLayerClusterer, google.maps.OverlayView); + this.addListener = function (type, callback) { + return this._dataLayer.addListener(type, callback); + }; + var options = optOptions || {}; - DataLayerClusterer.extend(DataLayerClusterer, { - clusters_: [], - sizes: [53, 56, 66, 78, 90], - ready_: false, - map: options.map || null, - gridSize_: options.gridSize || 60, - minClusterSize_: options.minimumClusterSize || 2, - maxZoom_: options.maxZoom || null, - className_: options.className || 'cluster', - styles_: options.styles || [], - imagePath_: options.imagePath || DataLayerClusterer.MARKER_CLUSTER_IMAGE_PATH_, - imageExtension_: options.imageExtension || DataLayerClusterer.MARKER_CLUSTER_IMAGE_EXTENSION_, - zoomOnClick_: options.zoomOnClick !== undefined ? options.zoomOnClick : true, - averageCenter_: options.averageCenter !== undefined ? options.averageCenter : true, - _dataLayer: new google.maps.Data() - }); - this.setupStyles_(); - this._dataLayer.setStyle(DataLayerClusterer.HIDDEN_FEATURE); - if (this.map !== null) { - this.setMap(this.map); - } + + this.clusters_ = []; + this.sizes = [53, 56, 66, 78, 90]; + this.colors = ['#008cff','#ffbf00','#ff0000','#ff00ed','#9c00ff']; + this.ready_ = false; + this.map = options.map || null; + this.gridSize_ = options.gridSize || 60; + this.minClusterSize_ = options.minimumClusterSize || 2; + this.minPolySize_ = options.minimumPolySize || 50; + this.setProperty_ = options.setProperty || false; + this.maxZoom_ = options.maxZoom || null; + this.className_ = options.className || 'cluster'; + this.styles_ = options.styles || []; + this.imagePath_ = options.imagePath || DataLayerClusterer.MARKER_CLUSTER_IMAGE_PATH_; + this.imageExtension_ = options.imageExtension || DataLayerClusterer.MARKER_CLUSTER_IMAGE_EXTENSION_; + this.zoomOnClick_ = options.zoomOnClick !== undefined ? options.zoomOnClick : true; + this.averageCenter_ = options.averageCenter !== undefined ? options.averageCenter : true; + this._dataLayer = new google.maps.Data(); + this.firstIdle_ = true; + this.prevBounds_ = null; + this.recolorSVG_ = typeof options.recolorSVG !== "undefined" && (typeof options.recolorSVG === "string" || options.recolorSVG instanceof String || options.recolorSVG === false) ? options.recolorSVG : 'g:first-child'; + this.baseSVG_ = null; + + if (this.recolorSVG_ && this.imageExtension_ == 'svg') { + var self = this, + xhr = new XMLHttpRequest(); + xhr.open("GET",/\.svg$/.test(this.imagePath_) ? this.imagePath_ : this.imagePath_ + '1.' + this.imageExtension_); + // Following line is just to be on the safe side; + // not needed if your server delivers SVG with correct MIME type + xhr.overrideMimeType("image/svg+xml"); + xhr.send(""); + + xhr.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status == 200) { + self.baseSVG_ = { + 'document': xhr.responseXML.documentElement, + 'colorElement': xhr.responseXML.documentElement.querySelector(self.recolorSVG_) + }; + if (!self.baseSVG_.document || !self.baseSVG_.colorElement) { + self.recolorSVG_ = false; + } + } else { + self.recolorSVG_ = false; + } + self.init_(); + } + }; + } else this.init_(); } /* ---- Constants ---- */ +DataLayerClusterer.CLUSTER_PROPERTY_NAME = 'in_cluster'; + DataLayerClusterer.VISIBLE_FEATURE = { visible: true }; @@ -99,7 +151,7 @@ DataLayerClusterer.HIDDEN_FEATURE = { * @param {bool} v * @return {void} */ -DataLayerClusterer.prototype.setVisible = function(v) { +DataLayerClusterer.prototype.setVisible = function (v) { if (!v) { this.map__ = this.getMap(); google.maps.event.removeListener(this._idle); @@ -124,7 +176,7 @@ DataLayerClusterer.prototype.setVisible = function(v) { * * @return {number} The number of clusters. */ -DataLayerClusterer.prototype.getTotalClusters = function() { +DataLayerClusterer.prototype.getTotalClusters = function () { return this.clusters_.length; }; @@ -134,7 +186,7 @@ DataLayerClusterer.prototype.getTotalClusters = function() { * @param {google.maps.LatLngBounds} bounds The bounds to extend. * @return {google.maps.LatLngBounds} The extended bounds. */ -DataLayerClusterer.prototype.getExtendedBounds = function(bounds) { +DataLayerClusterer.prototype.getExtendedBounds = function (bounds) { var projection = this.getProjection(); // Turn the bounds into latlng. @@ -153,8 +205,8 @@ DataLayerClusterer.prototype.getExtendedBounds = function(bounds) { blPix.y += this.gridSize_; // Convert the pixel points back to LatLng - var ne = projection.fromDivPixelToLatLng(trPix); - var sw = projection.fromDivPixelToLatLng(blPix); + var ne = projection.fromDivPixelToLatLng(trPix), + sw = projection.fromDivPixelToLatLng(blPix); // Extend the bounds to contain the new bounds. bounds.extend(ne); @@ -166,7 +218,7 @@ DataLayerClusterer.prototype.getExtendedBounds = function(bounds) { /** * Redraws the clusters. */ -DataLayerClusterer.prototype.redraw = function() { +DataLayerClusterer.prototype.redraw = function () { var oldClusters = this.clusters_.slice(); this.clusters_.length = 0; @@ -174,12 +226,16 @@ DataLayerClusterer.prototype.redraw = function() { // Remove the old clusters. // Do it in a timeout so the other clusters have been drawn first. - window.requestAnimationFrame(function() { + window.requestAnimationFrame(function () { var oldSize = oldClusters.length; for (var i = 0; i !== oldSize; ++i) { oldClusters[i].remove(); } }); + + if (this.map_) { + this.prevBounds_ = this.map_.getBounds(); + } }; @@ -190,7 +246,7 @@ DataLayerClusterer.prototype.redraw = function() { * * @return {boolean} True if zoomOnClick_ is set. */ -DataLayerClusterer.prototype.isZoomOnClick = function() { +DataLayerClusterer.prototype.isZoomOnClick = function () { return this.zoomOnClick_; }; @@ -199,7 +255,7 @@ DataLayerClusterer.prototype.isZoomOnClick = function() { * * @return {boolean} True if averageCenter_ is set. */ -DataLayerClusterer.prototype.isAverageCenter = function() { +DataLayerClusterer.prototype.isAverageCenter = function () { return this.averageCenter_; }; @@ -208,7 +264,7 @@ DataLayerClusterer.prototype.isAverageCenter = function() { * * @param {number} maxZoom The max zoom level. */ -DataLayerClusterer.prototype.setMaxZoom = function(maxZoom) { +DataLayerClusterer.prototype.setMaxZoom = function (maxZoom) { this.maxZoom_ = maxZoom; }; @@ -217,7 +273,7 @@ DataLayerClusterer.prototype.setMaxZoom = function(maxZoom) { * * @return {number} The max zoom level. */ -DataLayerClusterer.prototype.getMaxZoom = function() { +DataLayerClusterer.prototype.getMaxZoom = function () { return this.maxZoom_; }; @@ -226,7 +282,7 @@ DataLayerClusterer.prototype.getMaxZoom = function() { * * @return {number} The grid size. */ -DataLayerClusterer.prototype.getGridSize = function() { +DataLayerClusterer.prototype.getGridSize = function () { return this.gridSize_; }; @@ -235,7 +291,7 @@ DataLayerClusterer.prototype.getGridSize = function() { * * @param {number} size The grid size. */ -DataLayerClusterer.prototype.setGridSize = function(size) { +DataLayerClusterer.prototype.setGridSize = function (size) { this.gridSize_ = size; }; @@ -244,7 +300,7 @@ DataLayerClusterer.prototype.setGridSize = function(size) { * * @return {number} The grid size. */ -DataLayerClusterer.prototype.getMinClusterSize = function() { +DataLayerClusterer.prototype.getMinClusterSize = function () { return this.minClusterSize_; }; @@ -253,89 +309,93 @@ DataLayerClusterer.prototype.getMinClusterSize = function() { * * @param {number} size The grid size. */ -DataLayerClusterer.prototype.setMinClusterSize = function(size) { +DataLayerClusterer.prototype.setMinClusterSize = function (size) { this.minClusterSize_ = size; }; /* ---- google.maps.Data interface ---- */ -DataLayerClusterer.prototype.add = function(feature) { +DataLayerClusterer.prototype.add = function (feature) { return this._dataLayer.add(feature); }; -DataLayerClusterer.prototype.addGeoJson = function(geoJson, options) { +DataLayerClusterer.prototype.addGeoJson = function (geoJson, options) { return this._dataLayer.addGeoJson(geoJson, options); }; -DataLayerClusterer.prototype.contains = function(feature) { +DataLayerClusterer.prototype.contains = function (feature) { return this._dataLayer.contains(feature); }; -DataLayerClusterer.prototype.forEach = function(callback) { +DataLayerClusterer.prototype.forEach = function (callback) { return this._dataLayer.forEach(callback); }; -DataLayerClusterer.prototype.getControlPosition = function() { +DataLayerClusterer.prototype.getControlPosition = function () { return this._dataLayer.getControlPosition(); }; -DataLayerClusterer.prototype.getControls = function() { +DataLayerClusterer.prototype.getControls = function () { return this._dataLayer.getControls(); }; -DataLayerClusterer.prototype.getDrawingMode = function() { +DataLayerClusterer.prototype.getDrawingMode = function () { return this._dataLayer.getDrawingMode(); }; -DataLayerClusterer.prototype.getFeatureById = function(id) { +DataLayerClusterer.prototype.getFeatureById = function (id) { return this._dataLayer.getFeatureById(id); }; -DataLayerClusterer.prototype.getStyle = function() { +DataLayerClusterer.prototype.getStyle = function () { return this._dataLayer.getStyle(); }; -DataLayerClusterer.prototype.loadGeoJson = function(url, options, callback) { +DataLayerClusterer.prototype.loadGeoJson = function (url, options, callback) { return this._dataLayer.loadGeoJson(url, options, callback); }; -DataLayerClusterer.prototype.overrideStyle = function(feature, style) { +DataLayerClusterer.prototype.overrideStyle = function (feature, style) { return this._dataLayer.overrideStyle(feature, style); }; -DataLayerClusterer.prototype.remove = function(feature) { +DataLayerClusterer.prototype.remove = function (feature) { return this._dataLayer.remove(feature); }; -DataLayerClusterer.prototype.revertStyle = function(feature) { +DataLayerClusterer.prototype.revertStyle = function (feature) { return this._dataLayer.revertStyle(feature); }; -DataLayerClusterer.prototype.setControlPosition = function(controlPosition) { +DataLayerClusterer.prototype.setControlPosition = function (controlPosition) { return this._dataLayer.setControlPosition(controlPosition); }; -DataLayerClusterer.prototype.setControls = function(controls) { +DataLayerClusterer.prototype.setControls = function (controls) { return this._dataLayer.setControls(controls); }; -DataLayerClusterer.prototype.setDrawingMode = function(drawingMode) { +DataLayerClusterer.prototype.setDrawingMode = function (drawingMode) { return this._dataLayer.setDrawingMode(drawingMode); }; -DataLayerClusterer.prototype.setStyle = function(style) { - return this._dataLayer.setStyle(style); +DataLayerClusterer.prototype.setStyle = function (style) { + var returnVal = this._dataLayer.setStyle(style); + if (this.setProperty_) { + this.redraw(); + } + return returnVal; }; -DataLayerClusterer.prototype.toGeoJson = function(callback) { +DataLayerClusterer.prototype.toGeoJson = function (callback) { return this._dataLayer.toGeoJson(callback); }; /* ---- Private methods ---- */ -DataLayerClusterer.prototype.resetViewport = function() { +DataLayerClusterer.prototype.resetViewport = function () { // Remove all the clusters var csize = this.clusters_.length; for (var i = 0; i !== csize; ++i) { @@ -351,9 +411,9 @@ DataLayerClusterer.prototype.resetViewport = function() { * @param {boolean} ready The state. * @private */ -DataLayerClusterer.prototype.setReady_ = function(ready) { - if (!this.ready_) { - this.ready_ = ready; +DataLayerClusterer.prototype.setReady_ = function (ready) { + this.ready_ = ready; + if (ready) { this.createClusters_(); } }; @@ -366,8 +426,21 @@ DataLayerClusterer.prototype.setReady_ = function(ready) { * @return {boolean} True if the feature is in the bounds. * @private */ -DataLayerClusterer.prototype.isFeatureInBounds_ = function(f, bounds) { - return bounds.contains(f.getGeometry().get()); +DataLayerClusterer.prototype.isFeatureInBounds_ = function (f, bounds) { + var geom = f.getGeometry(), + inBounds = false; + + if (geom.getType() == 'Point') { + inBounds = bounds.contains(geom.get()); + } else { + var self = this; + geom.getArray().forEach(function (g) { + inBounds = g instanceof google.maps.LatLng ? bounds.contains(g) : bounds.contains(self.featureCenter_(g)); + return !inBounds; + }); + } + + return inBounds; }; /** @@ -379,54 +452,105 @@ DataLayerClusterer.prototype.isFeatureInBounds_ = function(f, bounds) { * @return {number} The distance between the two points in km. * @private */ -DataLayerClusterer.prototype.distanceBetweenPoints_ = function(p1, p2) { +DataLayerClusterer.prototype.distanceBetweenPoints_ = function (p1, p2) { if (!p1 || !p2) { return 0; } - var R = 6371; // Radius of the Earth in km - var dLat = (p2.lat() - p1.lat()) * Math.PI / 180; - var dLon = (p2.lng() - p1.lng()) * Math.PI / 180; - var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(p1.lat() * Math.PI / 180) * Math.cos(p2.lat() * Math.PI / 180) * - Math.sin(dLon / 2) * Math.sin(dLon / 2); - var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - var d = R * c; + var R = 6371, // Radius of the Earth in km + dLat = (p2.lat() - p1.lat()) * Math.PI / 180, + dLon = (p2.lng() - p1.lng()) * Math.PI / 180, + a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(p1.lat() * Math.PI / 180) * Math.cos(p2.lat() * Math.PI / 180) * + Math.sin(dLon / 2) * Math.sin(dLon / 2), + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)), + d = R * c; return d; }; +/** + * Calculates the bounds of a feature + * + * @private + */ +DataLayerClusterer.prototype.featureBounds_ = function (feature, extendBounds) { + var geom = feature.getGeometry ? feature.getGeometry() : feature, + geom_bounds = extendBounds || new google.maps.LatLngBounds(); + + if (geom.getType() == 'Point') { + geom_bounds.extend(geom.get()); + } else { + geom.getArray().forEach(function (g) { + if (g instanceof google.maps.LatLng) { + geom_bounds.extend(g); + } else { + g.getArray().forEach(function (LatLng) { + geom_bounds.extend(LatLng); + }); + } + }); + } + + return geom_bounds; +}; + +/** + * Calculates the center point of the bounds of a feature + * + * @private + */ +DataLayerClusterer.prototype.featureCenter_ = function (feature) { + var geom = feature.getGeometry ? feature.getGeometry() : feature; + if (geom.getType() == 'Point') { + return geom.get(); + } else { + return this.featureBounds_(feature).getCenter(); + } +}; + /** * Add a feature to a cluster, or creates a new cluster. * * @param {google.maps.Data.Feature} feature The feature to add. * @private */ -DataLayerClusterer.prototype.addToClosestCluster_ = function(feature) { - var distance = 40000; // Some large number - - var pos = feature.getGeometry().get(); +DataLayerClusterer.prototype.addToClosestCluster_ = function (feature) { + var distance = 40000, // Some large number + pos = this.featureCenter_(feature), + cluster, + isVisible = true; + + if (this.setProperty_) { + var propBefore = feature.getProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME); + feature.setProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME, false); + var fStyle = this.getStyle(feature); + if (typeof fStyle == 'function') fStyle = fStyle(feature); + isVisible = typeof fStyle.visible == 'undefined' || fStyle.visible; + feature.setProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME, propBefore); + } - var cluster; + if (isVisible) { + var csize = this.clusters_.length; - var csize = this.clusters_.length; - for (var i = 0; i !== csize; ++i) { - var center = this.clusters_[i].getCenter(); + for (var i = 0; i !== csize; ++i) { + var center = this.clusters_[i].getCenter(); - if (center) { - var d = this.distanceBetweenPoints_(center, pos); - if (d < distance) { - distance = d; - cluster = this.clusters_[i]; + if (center) { + var d = this.distanceBetweenPoints_(center, pos); + if (d < distance) { + distance = d; + cluster = this.clusters_[i]; + } } } - } - if (cluster && cluster.isFeatureInClusterBounds(feature)) { - cluster.addFeature(feature); - } else { - cluster = new FeatureCluster(this); - cluster.addFeature(feature); - this.clusters_.push(cluster); + if (cluster && cluster.isFeatureInClusterBounds(feature)) { + cluster.addFeature(feature); + } else { + cluster = new FeatureCluster(this); + cluster.addFeature(feature); + this.clusters_.push(cluster); + } } }; @@ -435,17 +559,15 @@ DataLayerClusterer.prototype.addToClosestCluster_ = function(feature) { * * @private */ -DataLayerClusterer.prototype.createClusters_ = function() { +DataLayerClusterer.prototype.createClusters_ = function () { if (!this.ready_ || !this.map_) { return; } - var mapBounds = new google.maps.LatLngBounds(this.map_.getBounds().getSouthWest(), - this.map_.getBounds().getNorthEast()); - var bounds = this.getExtendedBounds(mapBounds); - - var self = this; - this.forEach(function(feature) { + var mapBounds = new google.maps.LatLngBounds(this.map_.getBounds().getSouthWest(), this.map_.getBounds().getNorthEast()), + bounds = this.getExtendedBounds(mapBounds), + self = this; + this.forEach(function (feature) { if (self.isFeatureInBounds_(feature, bounds)) { self.addToClosestCluster_(feature); } @@ -460,7 +582,7 @@ DataLayerClusterer.prototype.createClusters_ = function() { * * Adds the data layer to the map and setup the events listeners. */ -DataLayerClusterer.prototype.onAdd = function() { +DataLayerClusterer.prototype.onAdd = function () { var map = this.getMap(); if (this.map_ !== map) { @@ -476,21 +598,24 @@ DataLayerClusterer.prototype.onAdd = function() { // Add the map event listeners var self = this; - this._zoomchanged = google.maps.event.addListener(this.map_, 'zoom_changed', function() { - var zoom = self.map_.getZoom(); - - if (self.prevZoom_ !== zoom) { + this._zoomchanged = google.maps.event.addListener(this.map_, 'zoom_changed', function () { + var zoom = self.map_.getZoom(), + nothingChanged = (self.prevBounds_ && self.prevBounds_.equals(self.map_.getBounds())); + if (self.prevZoom_ !== zoom && nothingChanged !== true) { self.prevZoom_ = zoom; self.resetViewport(); } }); - this._idle = google.maps.event.addListener(this.map_, 'idle', function() { - self.redraw(); + this._idle = google.maps.event.addListener(this.map_, 'idle', function () { + var nothingChanged = (self.map_ && self.prevZoom_ && self.prevZoom_ === self.map_.getZoom() && self.prevBounds_ && self.prevBounds_.equals(self.map_.getBounds())); + if (!self.firstIdle_ && nothingChanged !== true) { + self.redraw(); + } + self.firstIdle_ = false; }); this.setReady_(true); - this.redraw(); } else { this.setReady_(false); } @@ -501,7 +626,7 @@ DataLayerClusterer.prototype.onAdd = function() { * * Removes the data layer from the map and cleans the events listeners. */ -DataLayerClusterer.prototype.onRemove = function() { +DataLayerClusterer.prototype.onRemove = function () { if (this.map_ !== null) { if (this._zoomchanged !== null) { try { @@ -526,7 +651,7 @@ DataLayerClusterer.prototype.onRemove = function() { /** * Empty implementation of the interface method. */ -DataLayerClusterer.prototype.draw = function() {}; +DataLayerClusterer.prototype.draw = function () {}; /* ---- Utils ---- */ @@ -538,8 +663,8 @@ DataLayerClusterer.prototype.draw = function() {}; * @param {Object} obj2 The object to extend with. * @return {Object} The new extended object. */ -DataLayerClusterer.extend = function(obj1, obj2) { - return (function(object) { +DataLayerClusterer.extend = function (obj1, obj2) { + return (function (object) { for (var property in object.prototype) { if (object.prototype[property]) { this.prototype[property] = object.prototype[property]; @@ -558,7 +683,7 @@ DataLayerClusterer.extend = function(obj1, obj2) { * @constructor * @ignore */ -function FeatureCluster(featureClusterer) { +function FeatureCluster (featureClusterer) { this.featureClusterer_ = featureClusterer; this.map_ = featureClusterer.getMap(); @@ -573,6 +698,8 @@ function FeatureCluster(featureClusterer) { this.clusterIcon_ = new FeatureClusterIcon(this, featureClusterer.getStyles(), featureClusterer.getGridSize(), this.classId); + + this.forced_ = false; } /** @@ -581,7 +708,7 @@ function FeatureCluster(featureClusterer) { * @param {google.maps.Data.Feature} feature The feature to check. * @return {boolean} True if the feature is already added. */ -FeatureCluster.prototype.isFeatureAlreadyAdded = function(feature) { +FeatureCluster.prototype.isFeatureAlreadyAdded = function (feature) { if (this.features_.indexOf) { return this.features_.indexOf(feature) !== -1; } else { @@ -603,19 +730,21 @@ FeatureCluster.prototype.isFeatureAlreadyAdded = function(feature) { * @param {google.maps.Data.Feature} feature The feature to add. * @return {boolean} True if the feature was added. */ -FeatureCluster.prototype.addFeature = function(feature) { +FeatureCluster.prototype.addFeature = function (feature) { if (this.isFeatureAlreadyAdded(feature)) { return false; } + var geom = feature.getGeometry(), centerPoint = this.center_ || this.featureClusterer_.featureCenter_(feature); + if (!this.center_) { - this.center_ = feature.getGeometry().get(); + this.center_ = centerPoint; this.calculateBounds_(); } else { if (this.averageCenter_) { - var l = this.features_.length + 1; - var lat = (this.center_.lat() * (l - 1) + feature.getGeometry().get().lat()) / l; - var lng = (this.center_.lng() * (l - 1) + feature.getGeometry().get().lng()) / l; + var l = this.features_.length + 1, + lat = (this.center_.lat() * (l - 1) + centerPoint.lat()) / l, + lng = (this.center_.lng() * (l - 1) + centerPoint.lng()) / l; this.center_ = new google.maps.LatLng(lat, lng); this.calculateBounds_(); } @@ -624,21 +753,42 @@ FeatureCluster.prototype.addFeature = function(feature) { this.features_.push(feature); var len = this.features_.length; - if (len < this.minClusterSize_) { - // Min cluster size not reached so show the feature. - this.featureClusterer_.overrideStyle(feature, DataLayerClusterer.VISIBLE_FEATURE); + + if (len == 1 && !!this.featureClusterer_.minPolySize_ && feature.getGeometry().getType() != 'Point') { + var polyMinSize = this.featureClusterer_.minPolySize_, + bounds = this.featureClusterer_.featureBounds_(feature), + SW = bounds.getSouthWest(), + NE = bounds.getNorthEast(), + proj = this.map_.getProjection(), + swPx = proj.fromLatLngToPoint(SW), + nePx = proj.fromLatLngToPoint(NE), + pixelWidth = Math.round(Math.abs((nePx.x - swPx.x)* Math.pow(2, this.map_.getZoom()))), + pixelHeight = Math.round(Math.abs((swPx.y - nePx.y)* Math.pow(2, this.map_.getZoom()))); + + if (pixelWidth < polyMinSize && pixelHeight < polyMinSize) { + this.forced_ = true; + } else { + this.forced_ = false; + } } - if (len === this.minClusterSize_) { - // Hide the features that were showing. - for (var i = 0; i < len; i++) { - this.featureClusterer_.overrideStyle(this.features_[i], DataLayerClusterer.HIDDEN_FEATURE); + if (len < this.minClusterSize_ && !this.forced_) { + // Min cluster size not reached so show the feature. + if (this.featureClusterer_.setProperty_) { + feature.setProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME, false); + } else { + this.featureClusterer_.overrideStyle(feature, DataLayerClusterer.VISIBLE_FEATURE); } } - if (len >= this.minClusterSize_) { - for (var j = 0; j < len; j++) { - this.featureClusterer_.overrideStyle(this.features_[j], DataLayerClusterer.HIDDEN_FEATURE); + if (len >= this.minClusterSize_ || this.forced_) { + // Hide the features that were showing. + for (var i = 0; i < len; i++) { + if (this.featureClusterer_.setProperty_) { + this.features_[i].setProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME, true); + } else { + this.featureClusterer_.overrideStyle(this.features_[i], DataLayerClusterer.HIDDEN_FEATURE); + } } } @@ -651,7 +801,7 @@ FeatureCluster.prototype.addFeature = function(feature) { * * @return {DataLayerClusterer} The associated feature clusterer. */ -FeatureCluster.prototype.getDataLayerClusterer = function() { +FeatureCluster.prototype.getDataLayerClusterer = function () { return this.featureClusterer_; }; @@ -660,12 +810,12 @@ FeatureCluster.prototype.getDataLayerClusterer = function() { * * @return {google.maps.LatLngBounds} the cluster bounds. */ -FeatureCluster.prototype.getBounds = function() { - var bounds = new google.maps.LatLngBounds(this.center_, this.center_); +FeatureCluster.prototype.getBounds = function () { + var bounds = new google.maps.LatLngBounds(this.center_, this.center_), + fsize = this.features_.length; - var fsize = this.features_.length; for (var i = 0; i !== fsize; ++i) { - bounds.extend(this.features_[i].getGeometry().get()); + bounds = this.featureClusterer_.featureBounds_(this.features_[i], bounds); } return bounds; @@ -674,7 +824,7 @@ FeatureCluster.prototype.getBounds = function() { /** * Removes the cluster */ -FeatureCluster.prototype.remove = function() { +FeatureCluster.prototype.remove = function () { this.clusterIcon_.remove(); this.features_.length = 0; delete this.features_; @@ -685,7 +835,7 @@ FeatureCluster.prototype.remove = function() { * * @return {number} The cluster size. */ -FeatureCluster.prototype.getSize = function() { +FeatureCluster.prototype.getSize = function () { return this.features_.length; }; @@ -694,7 +844,7 @@ FeatureCluster.prototype.getSize = function() { * * @return {Array.} The cluster's features. */ -FeatureCluster.prototype.getFeatures = function() { +FeatureCluster.prototype.getFeatures = function () { return this.features_; }; @@ -703,7 +853,7 @@ FeatureCluster.prototype.getFeatures = function() { * * @return {google.maps.LatLng} The cluster center. */ -FeatureCluster.prototype.getCenter = function() { +FeatureCluster.prototype.getCenter = function () { return this.center_; }; @@ -713,7 +863,7 @@ FeatureCluster.prototype.getCenter = function() { * * @private */ -FeatureCluster.prototype.calculateBounds_ = function() { +FeatureCluster.prototype.calculateBounds_ = function () { var bounds = new google.maps.LatLngBounds(this.center_, this.center_); this.bounds_ = this.featureClusterer_.getExtendedBounds(bounds); }; @@ -724,8 +874,8 @@ FeatureCluster.prototype.calculateBounds_ = function() { * @param {google.maps.Data.Feature} feature The feature to check. * @return {boolean} True if the feature lies in the bounds. */ -FeatureCluster.prototype.isFeatureInClusterBounds = function(feature) { - return this.bounds_.contains(feature.getGeometry().get()); +FeatureCluster.prototype.isFeatureInClusterBounds = function (feature) { + return this.bounds_.contains(this.featureClusterer_.featureCenter_(feature)); }; /** @@ -733,35 +883,39 @@ FeatureCluster.prototype.isFeatureInClusterBounds = function(feature) { * * @return {google.maps.Map} The map. */ -FeatureCluster.prototype.getMap = function() { +FeatureCluster.prototype.getMap = function () { return this.map_; }; /** * Updates the cluster icon */ -FeatureCluster.prototype.updateIcon = function() { - var zoom = this.map_.getZoom(); - var mz = this.featureClusterer_.getMaxZoom(); +FeatureCluster.prototype.updateIcon = function () { + var zoom = this.map_.getZoom(), + mz = this.featureClusterer_.getMaxZoom(); if (mz && zoom > mz) { // The zoom is greater than our max zoom so show all the features in cluster. var fsize = this.features_.length; for (var i = 0; i !== fsize; ++i) { - this.featureClusterer_.overrideStyle(this.features_[i], DataLayerClusterer.VISIBLE_FEATURE); + if (this.featureClusterer_.setProperty_) { + this.features_[i].setProperty(DataLayerClusterer.CLUSTER_PROPERTY_NAME, false); + } else { + this.featureClusterer_.overrideStyle(this.features_[i], DataLayerClusterer.VISIBLE_FEATURE); + } } return; } - if (this.features_.length < this.minClusterSize_) { + if (this.features_.length < this.minClusterSize_ && !this.forced_) { // Min cluster size not yet reached. this.clusterIcon_.hide(); return; } - var numStyles = this.featureClusterer_.getStyles().length; - var sums = this.featureClusterer_.getCalculator()(this.features_, numStyles); + var numStyles = this.featureClusterer_.getStyles().length, + sums = this.featureClusterer_.getCalculator()(this.features_, numStyles); this.clusterIcon_.setSums(sums); @@ -786,7 +940,7 @@ FeatureCluster.prototype.updateIcon = function() { * @constructor * @extends google.maps.OverlayView */ -function FeatureClusterIcon(cluster, styles, optpadding, classId) { +function FeatureClusterIcon (cluster, styles, optpadding, classId) { DataLayerClusterer.extend(FeatureClusterIcon, google.maps.OverlayView); this.styles_ = styles; @@ -807,7 +961,7 @@ function FeatureClusterIcon(cluster, styles, optpadding, classId) { /** * Hide the icon. */ -FeatureClusterIcon.prototype.hide = function() { +FeatureClusterIcon.prototype.hide = function () { if (this.div_) { this.div_.style.display = 'none'; } @@ -817,7 +971,7 @@ FeatureClusterIcon.prototype.hide = function() { /** * Position and show the icon. */ -FeatureClusterIcon.prototype.show = function() { +FeatureClusterIcon.prototype.show = function () { if (this.div_) { var pos = this.getPosFromLatLng_(this.center_); this.div_.style.cssText = this.createCss(pos); @@ -829,7 +983,7 @@ FeatureClusterIcon.prototype.show = function() { /** * Remove the icon from the map */ -FeatureClusterIcon.prototype.remove = function() { +FeatureClusterIcon.prototype.remove = function () { this.setMap(null); }; @@ -838,7 +992,7 @@ FeatureClusterIcon.prototype.remove = function() { * * @param {google.maps.LatLng} center The latlng to set as the center. */ -FeatureClusterIcon.prototype.setCenter = function(center) { +FeatureClusterIcon.prototype.setCenter = function (center) { this.center_ = center; }; @@ -849,7 +1003,7 @@ FeatureClusterIcon.prototype.setCenter = function(center) { * Adding the cluster icon to the dom. * @ignore */ -FeatureClusterIcon.prototype.onAdd = function() { +FeatureClusterIcon.prototype.onAdd = function () { this.div_ = document.createElement('DIV'); if (this.visible_) { var pos = this.getPosFromLatLng_(this.center_); @@ -862,7 +1016,7 @@ FeatureClusterIcon.prototype.onAdd = function() { panes.overlayMouseTarget.appendChild(this.div_); var self = this; - google.maps.event.addDomListener(this.div_, 'click', function() { + google.maps.event.addDomListener(this.div_, 'click', function () { self.triggerClusterClick(); }); }; @@ -871,7 +1025,7 @@ FeatureClusterIcon.prototype.onAdd = function() { * Draw the icon. * @ignore */ -FeatureClusterIcon.prototype.draw = function() { +FeatureClusterIcon.prototype.draw = function () { if (this.visible_) { var pos = this.getPosFromLatLng_(this.center_); this.div_.style.top = pos.y + 'px'; @@ -883,7 +1037,7 @@ FeatureClusterIcon.prototype.draw = function() { * Implementation of the onRemove interface. * @ignore */ -FeatureClusterIcon.prototype.onRemove = function() { +FeatureClusterIcon.prototype.onRemove = function () { if (this.div_ && this.div_.parentNode) { this.hide(); this.div_.parentNode.removeChild(this.div_); @@ -897,7 +1051,7 @@ FeatureClusterIcon.prototype.onRemove = function() { /** * Triggers the clusterclick event and zoom's if the option is set. */ -FeatureClusterIcon.prototype.triggerClusterClick = function() { +FeatureClusterIcon.prototype.triggerClusterClick = function () { var featureClusterer = this.cluster_.getDataLayerClusterer(); // Trigger the clusterclick event. @@ -916,7 +1070,7 @@ FeatureClusterIcon.prototype.triggerClusterClick = function() { * @return {google.maps.Point} The position in pixels. * @private */ -FeatureClusterIcon.prototype.getPosFromLatLng_ = function(latlng) { +FeatureClusterIcon.prototype.getPosFromLatLng_ = function (latlng) { var pos = this.getProjection().fromLatLngToDivPixel(latlng); pos.x -= parseInt(this.width_ / 2, 10); pos.y -= parseInt(this.height_ / 2, 10); @@ -929,9 +1083,10 @@ FeatureClusterIcon.prototype.getPosFromLatLng_ = function(latlng) { * @param {google.maps.Point} pos The position. * @return {string} The css style text. */ -FeatureClusterIcon.prototype.createCss = function(pos) { +FeatureClusterIcon.prototype.createCss = function (pos) { var style = []; style.push('background-image:url(' + this.url_ + ');'); + if (this.cluster_.featureClusterer_.recolorSVG_) style.push('background-size: contain;'); var backgroundPosition = this.backgroundPosition_ ? this.backgroundPosition_ : '0 0'; style.push('background-position:' + backgroundPosition + ';'); @@ -956,8 +1111,8 @@ FeatureClusterIcon.prototype.createCss = function(pos) { this.height_ + 'px; width:' + this.width_ + 'px; text-align:center;'); } - var txtColor = this.textColor_ ? this.textColor_ : 'black'; - var txtSize = this.textSize_ ? this.textSize_ : 11; + var txtColor = this.textColor_ ? this.textColor_ : 'black', + txtSize = this.textSize_ ? this.textSize_ : 11; style.push('cursor:pointer; top:' + pos.y + 'px; left:' + pos.x + 'px; color:' + txtColor + '; position:absolute; font-size:' + @@ -968,7 +1123,7 @@ FeatureClusterIcon.prototype.createCss = function(pos) { /** * Sets the icon to the the styles. */ -FeatureClusterIcon.prototype.useStyle = function() { +FeatureClusterIcon.prototype.useStyle = function () { var index = Math.max(0, this.sums_.index - 1); index = Math.min(this.styles_.length - 1, index); var style = this.styles_[index]; @@ -988,7 +1143,7 @@ FeatureClusterIcon.prototype.useStyle = function() { * 'text': (string) The text to display in the icon. * 'index': (number) The style index of the icon. */ -FeatureClusterIcon.prototype.setSums = function(sums) { +FeatureClusterIcon.prototype.setSums = function (sums) { this.sums_ = sums; this.text_ = sums.text; this.index_ = sums.index; @@ -1003,7 +1158,6 @@ FeatureClusterIcon.prototype.setSums = function(sums) { /* ---- To remove soon ---- */ /* * TODO: Allow the styling using a similar interface than google.map.Data. - * Use SVG icon by default, remove dependency of google-maps-utility-library-v3.googlecode.com. */ /** @@ -1012,25 +1166,46 @@ FeatureClusterIcon.prototype.setSums = function(sums) { * @type {string} */ DataLayerClusterer.MARKER_CLUSTER_IMAGE_PATH_ = - 'http://google-maps-utility-library-v3.googlecode.com/svn/trunk/markerclusterer/images/m'; -DataLayerClusterer.MARKER_CLUSTER_IMAGE_EXTENSION_ = 'png'; + 'https://cdn.rawgit.com/Connum/data-layer-clusterer/master/images/m'; +DataLayerClusterer.MARKER_CLUSTER_IMAGE_EXTENSION_ = document.implementation.hasFeature("http://www.w3.org/TR/SVG11/feature#Image", "1.1") ? 'svg' : 'png'; + + +/** + * Initialises the clustering when everything is ready + * + * @private + */ +DataLayerClusterer.prototype.init_ = function () { + this.setupStyles_(); + + if (this.map !== null) { + this.setMap(this.map); + } +} /** * Sets up the styles object. * * @private */ -DataLayerClusterer.prototype.setupStyles_ = function() { +DataLayerClusterer.prototype.setupStyles_ = function () { if (this.styles_.length) { return; } var ssizes = this.sizes.length; for (var i = 0; i !== ssizes; ++i) { + var thisSize = this.sizes[i], + thisColor = this.colors[i], + markerUrl = this.imagePath_ + (i + 1) + '.' + this.imageExtension_; + if (this.recolorSVG_) { + this.baseSVG_.colorElement.style.fill = thisColor; + markerUrl = 'data:image/svg+xml;base64,' + btoa(this.baseSVG_.document.outerHTML); + } this.styles_.push({ - url: this.imagePath_ + (i + 1) + '.' + this.imageExtension_, - height: this.sizes[i], - width: this.sizes[i] + url: markerUrl, + height: thisSize, + width: thisSize }); } }; @@ -1040,7 +1215,7 @@ DataLayerClusterer.prototype.setupStyles_ = function() { * * @param {Object} styles The style to set. */ -DataLayerClusterer.prototype.setStyles = function(styles) { +DataLayerClusterer.prototype.setStyles = function (styles) { this.styles_ = styles; }; @@ -1049,28 +1224,28 @@ DataLayerClusterer.prototype.setStyles = function(styles) { * * @return {Object} The styles object. */ -DataLayerClusterer.prototype.getStyles = function() { +DataLayerClusterer.prototype.getStyles = function () { return this.styles_; }; /** * Set the calculator function. * - * @param {function(Array, number)} calculator The function to set as the + * @param {function (Array, number)} calculator The function to set as the * calculator. The function should return a object properties: * 'text' (string) and 'index' (number). * */ -DataLayerClusterer.prototype.setCalculator = function(calculator) { +DataLayerClusterer.prototype.setCalculator = function (calculator) { this.calculator_ = calculator; }; /** * Get the calculator function. * - * @return {function(Array, number)} the calculator function. + * @return {function (Array, number)} the calculator function. */ -DataLayerClusterer.prototype.getCalculator = function() { +DataLayerClusterer.prototype.getCalculator = function () { return this.calculator_; }; @@ -1082,10 +1257,10 @@ DataLayerClusterer.prototype.getCalculator = function() { * @return {Object} A object properties: 'text' (string) and 'index' (number). * @private */ -DataLayerClusterer.prototype.calculator_ = function(features, numStyles) { - var index = 0; - var count = features.length; - var dv = count; +DataLayerClusterer.prototype.calculator_ = function (features, numStyles) { + var index = 0, + count = features.length, + dv = count; while (dv !== 0) { dv = parseInt(dv / 10, 10); index++;