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.
+
+
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');