diff --git a/examples/cluster.html b/examples/cluster.html new file mode 100644 index 0000000000..58ccb0ca42 --- /dev/null +++ b/examples/cluster.html @@ -0,0 +1,51 @@ + + + + + + + + + + + Clustering example + + + + + +
+ +
+
+
+
+
+ +
+ +
+

Clustering example

+

Example of using ol.Cluster.

+
+

See the cluster.js source to see how this is done.

+
+
cluster vector
+
+ +
+ +
+ + + + + + + diff --git a/examples/cluster.js b/examples/cluster.js new file mode 100644 index 0000000000..041c5455fa --- /dev/null +++ b/examples/cluster.js @@ -0,0 +1,80 @@ +goog.require('ol.Feature'); +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.geom.Point'); +goog.require('ol.layer.Tile'); +goog.require('ol.layer.Vector'); +goog.require('ol.source.Cluster'); +goog.require('ol.source.MapQuest'); +goog.require('ol.source.Vector'); +goog.require('ol.style.Circle'); +goog.require('ol.style.Fill'); +goog.require('ol.style.Stroke'); +goog.require('ol.style.Style'); +goog.require('ol.style.Text'); + + +var count = 20000; +var features = new Array(count); +var e = 4500000; +for (var i = 0; i < count; ++i) { + var coordinates = [2 * e * Math.random() - e, 2 * e * Math.random() - e]; + features[i] = new ol.Feature(new ol.geom.Point(coordinates)); +} + +var source = new ol.source.Vector({ + features: features +}); + +var clusterSource = new ol.source.Cluster({ + distance: 40, + source: source +}); + +var styleCache = {}; +var clusters = new ol.layer.Vector({ + source: clusterSource, + style: function(feature, resolution) { + var size = feature.get('features').length; + var style = styleCache[size]; + if (!style) { + style = [new ol.style.Style({ + image: new ol.style.Circle({ + radius: 10, + stroke: new ol.style.Stroke({ + color: '#fff' + }), + fill: new ol.style.Fill({ + color: '#3399CC' + }) + }), + text: new ol.style.Text({ + text: size.toString(), + fill: new ol.style.Fill({ + color: '#fff' + }) + }) + })]; + styleCache[size] = style; + } + return style; + } +}); + +var raster = new ol.layer.Tile({ + source: new ol.source.MapQuest({layer: 'sat'}) +}); + +var raw = new ol.layer.Vector({ + source: source +}); + +var map = new ol.Map({ + layers: [raster, clusters], + renderer: 'canvas', + target: 'map', + view: new ol.View({ + center: [0, 0], + zoom: 2 + }) +}); diff --git a/externs/olx.js b/externs/olx.js index 19f3e2c1c3..1d948c4f9d 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -2681,6 +2681,67 @@ olx.source.BingMapsOptions.prototype.imagerySet; */ olx.source.BingMapsOptions.prototype.tileLoadFunction; +/** + * @typedef {{attributions: (Array.|undefined), + * distance: (number|undefined), + * extent: (ol.Extent|undefined), + * format: (ol.format.Feature|undefined), + * logo: (string|undefined), + * projection: ol.proj.ProjectionLike, + * source: ol.source.Vector}} + * @api + */ +olx.source.ClusterOptions; + + +/** + * Attributions. + * @type {Array.|undefined} + */ +olx.source.ClusterOptions.prototype.attributions; + + +/** + * Minimum distance in pixels between clusters. Default is `20`. + * @type {number|undefined} + */ +olx.source.ClusterOptions.prototype.distance; + + +/** + * Extent. + * @type {ol.Extent|undefined} + */ +olx.source.ClusterOptions.prototype.extent; + + +/** + * Format. + * @type {ol.format.Feature|undefined} + */ +olx.source.ClusterOptions.prototype.format; + + +/** + * Logo. + * @type {string|undefined} + */ +olx.source.ClusterOptions.prototype.logo; + + +/** + * Projection. + * @type {ol.proj.ProjectionLike} + */ +olx.source.ClusterOptions.prototype.projection; + + +/** + * Source. + * @type {ol.source.Vector} + */ +olx.source.ClusterOptions.prototype.source; + /** * @typedef {{attributions: (Array.|undefined), diff --git a/src/ol/source/clustersource.js b/src/ol/source/clustersource.js new file mode 100644 index 0000000000..0e5455a143 --- /dev/null +++ b/src/ol/source/clustersource.js @@ -0,0 +1,150 @@ +// FIXME keep cluster cache by resolution ? +// FIXME distance not respected because of the centroid + +goog.provide('ol.source.Cluster'); + +goog.require('goog.array'); +goog.require('goog.asserts'); +goog.require('goog.events.EventType'); +goog.require('goog.object'); +goog.require('ol.Feature'); +goog.require('ol.coordinate'); +goog.require('ol.extent'); +goog.require('ol.geom.Point'); +goog.require('ol.source.Vector'); + + + +/** + * @constructor + * @param {olx.source.ClusterOptions} options + * @extends {ol.source.Vector} + * @api + */ +ol.source.Cluster = function(options) { + goog.base(this, { + attributions: options.attributions, + extent: options.extent, + logo: options.logo, + projection: options.projection + }); + + /** + * @type {number|undefined} + * @private + */ + this.resolution_ = undefined; + + /** + * @type {number} + * @private + */ + this.distance_ = goog.isDef(options.distance) ? options.distance : 20; + + /** + * @type {Array.} + * @private + */ + this.features_ = []; + + /** + * @type {ol.source.Vector} + * @private + */ + this.source_ = options.source; + + this.source_.on(goog.events.EventType.CHANGE, + ol.source.Cluster.prototype.onSourceChange_, this); +}; +goog.inherits(ol.source.Cluster, ol.source.Vector); + + +/** + * @param {ol.Extent} extent + * @param {number} resolution + */ +ol.source.Cluster.prototype.loadFeatures = function(extent, resolution) { + if (resolution !== this.resolution_) { + this.clear(); + this.resolution_ = resolution; + this.cluster_(); + this.addFeatures(this.features_); + } +}; + + +/** + * handle the source changing + * @private + */ +ol.source.Cluster.prototype.onSourceChange_ = function() { + this.clear(); + this.cluster_(); + this.addFeatures(this.features_); + this.dispatchChangeEvent(); +}; + + +/** + * @private + */ +ol.source.Cluster.prototype.cluster_ = function() { + goog.array.clear(this.features_); + var extent = ol.extent.createEmpty(); + goog.asserts.assert(goog.isDef(this.resolution_)); + var mapDistance = this.distance_ * this.resolution_; + var features = this.source_.getFeatures(); + + /** + * @type {Object.} + */ + var clustered = {}; + + for (var i = 0, ii = features.length; i < ii; i++) { + var feature = features[i]; + if (!goog.object.containsKey(clustered, goog.getUid(feature).toString())) { + var geometry = feature.getGeometry(); + goog.asserts.assert(geometry instanceof ol.geom.Point); + var coordinates = geometry.getCoordinates(); + ol.extent.createOrUpdateFromCoordinate(coordinates, extent); + ol.extent.buffer(extent, mapDistance, extent); + + var neighbors = this.source_.getFeaturesInExtent(extent); + goog.asserts.assert(neighbors.length >= 1); + neighbors = goog.array.filter(neighbors, function(neighbor) { + var uid = goog.getUid(neighbor).toString(); + if (!goog.object.containsKey(clustered, uid)) { + goog.object.set(clustered, uid, true); + return true; + } else { + return false; + } + }); + this.features_.push(this.createCluster_(neighbors)); + } + } + goog.asserts.assert( + goog.object.getCount(clustered) == this.source_.getFeatures().length); +}; + + +/** + * @param {Array.} features Features + * @return {ol.Feature} + * @private + */ +ol.source.Cluster.prototype.createCluster_ = function(features) { + var length = features.length; + var centroid = [0, 0]; + for (var i = 0; i < length; i++) { + var geometry = features[i].getGeometry(); + goog.asserts.assert(geometry instanceof ol.geom.Point); + var coordinates = geometry.getCoordinates(); + ol.coordinate.add(centroid, coordinates); + } + ol.coordinate.scale(centroid, 1 / length); + + var cluster = new ol.Feature(new ol.geom.Point(centroid)); + cluster.set('features', features); + return cluster; +}; diff --git a/test/spec/ol/source/clustersource.test.js b/test/spec/ol/source/clustersource.test.js new file mode 100644 index 0000000000..100206abf2 --- /dev/null +++ b/test/spec/ol/source/clustersource.test.js @@ -0,0 +1,21 @@ +goog.provide('ol.test.source.ClusterSource'); + +goog.require('ol.source.Vector'); + +describe('ol.source.Cluster', function() { + + describe('constructor', function() { + it('returns a cluster source', function() { + var source = new ol.source.Cluster({ + projection: ol.proj.get('EPSG:4326'), + source: new ol.source.Vector() + }); + expect(source).to.be.a(ol.source.Source); + expect(source).to.be.a(ol.source.Cluster); + }); + }); +}); + +goog.require('ol.proj'); +goog.require('ol.source.Cluster'); +goog.require('ol.source.Source');