diff --git a/externs/olx.js b/externs/olx.js index 9700718064..840d4f2434 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -4977,12 +4977,13 @@ olx.source.TileWMSOptions.prototype.wrapX; /** * @typedef {{attributions: (Array.|undefined), - * features: (Array.|undefined), + * features: (Array.|ol.Collection.|undefined), * format: (ol.format.Feature|undefined), * loader: (ol.FeatureLoader|undefined), * logo: (string|olx.LogoOptions|undefined), * strategy: (ol.LoadingStrategy|undefined), * url: (string|undefined), + * useSpatialIndex: (boolean|undefined), * wrapX: (boolean|undefined)}} * @api */ @@ -4998,8 +4999,9 @@ olx.source.VectorOptions.prototype.attributions; /** - * Features. - * @type {Array.|undefined} + * Features. If provided as {@link ol.Collection}, the features in the source + * and the collection will stay in sync. + * @type {Array.|ol.Collection.|undefined} * @api stable */ olx.source.VectorOptions.prototype.features; @@ -5052,6 +5054,29 @@ olx.source.VectorOptions.prototype.strategy; olx.source.VectorOptions.prototype.url; +/** + * By default, an RTree is used as spatial index. When features are removed and + * added frequently, and the total number of features is low, setting this to + * `false` may improve performance. + * + * Note that + * {@link ol.source.Vector#getFeaturesInExtent}, + * {@link ol.source.Vector#getClosestFeatureToCoordinate} and + * {@link ol.source.Vector#getExtent} cannot be used when `useSpatialIndex` is + * set to `false`, and {@link ol.source.Vector#forEachFeatureInExtent} will loop + * through all features. + * + * When set to `false`, the features will be maintained in an + * {@link ol.Collection}, which can be retrieved through + * {@link ol.source.Vector#getFeaturesCollection}. + * + * The default is `true`. + * @type {boolean|undefined} + * @api + */ +olx.source.VectorOptions.prototype.useSpatialIndex; + + /** * Wrap the world horizontally. Default is `true`. For vector editing across the * -180° and 180° meridians to work properly, this should be set to `false`. The diff --git a/src/ol/source/vectorsource.js b/src/ol/source/vectorsource.js index 42f945f9d7..60976af89b 100644 --- a/src/ol/source/vectorsource.js +++ b/src/ol/source/vectorsource.js @@ -1,5 +1,4 @@ // FIXME bulk feature upload - suppress events -// FIXME put features in an ol.Collection // FIXME make change-detection more refined (notably, geometry hint) goog.provide('ol.source.Vector'); @@ -12,7 +11,10 @@ goog.require('goog.events'); goog.require('goog.events.Event'); goog.require('goog.events.EventType'); goog.require('goog.object'); +goog.require('ol.Collection'); +goog.require('ol.CollectionEventType'); goog.require('ol.Extent'); +goog.require('ol.Feature'); goog.require('ol.FeatureLoader'); goog.require('ol.LoadingStrategy'); goog.require('ol.ObjectEventType'); @@ -105,11 +107,14 @@ ol.source.Vector = function(opt_options) { this.strategy_ = goog.isDef(options.strategy) ? options.strategy : ol.loadingstrategy.all; + var useSpatialIndex = + goog.isDef(options.useSpatialIndex) ? options.useSpatialIndex : true; + /** * @private * @type {ol.structs.RBush.} */ - this.featuresRtree_ = new ol.structs.RBush(); + this.featuresRtree_ = useSpatialIndex ? new ol.structs.RBush() : null; /** * @private @@ -143,8 +148,27 @@ ol.source.Vector = function(opt_options) { */ this.featureChangeKeys_ = {}; - if (goog.isDef(options.features)) { - this.addFeaturesInternal(options.features); + /** + * @private + * @type {ol.Collection.} + */ + this.featuresCollection_ = null; + + var collection, features; + if (options.features instanceof ol.Collection) { + collection = options.features; + features = collection.getArray(); + } else if (goog.isArray(options.features)) { + features = options.features; + } + if (!useSpatialIndex && !goog.isDef(collection)) { + collection = new ol.Collection(features); + } + if (goog.isDef(features)) { + this.addFeaturesInternal(features); + } + if (goog.isDef(collection)) { + this.bindFeaturesCollection_(collection); } }; @@ -181,7 +205,9 @@ ol.source.Vector.prototype.addFeatureInternal = function(feature) { var geometry = feature.getGeometry(); if (goog.isDefAndNotNull(geometry)) { var extent = geometry.getExtent(); - this.featuresRtree_.insert(extent, feature); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.insert(extent, feature); + } } else { this.nullGeometryFeatures_[featureKey] = feature; } @@ -280,7 +306,9 @@ ol.source.Vector.prototype.addFeaturesInternal = function(features) { this.nullGeometryFeatures_[featureKey] = feature; } } - this.featuresRtree_.load(extents, geometryFeatures); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.load(extents, geometryFeatures); + } for (i = 0, length = newFeatures.length; i < length; i++) { this.dispatchEvent(new ol.source.VectorEvent( @@ -289,6 +317,54 @@ ol.source.Vector.prototype.addFeaturesInternal = function(features) { }; +/** + * @param {!ol.Collection.} collection Collection. + * @private + */ +ol.source.Vector.prototype.bindFeaturesCollection_ = function(collection) { + goog.asserts.assert(goog.isNull(this.featuresCollection_), + 'bindFeaturesCollection can only be called once'); + var modifyingCollection = false; + goog.events.listen(this, ol.source.VectorEventType.ADDFEATURE, + function(evt) { + if (!modifyingCollection) { + modifyingCollection = true; + collection.push(evt.feature); + modifyingCollection = false; + } + }); + goog.events.listen(this, ol.source.VectorEventType.REMOVEFEATURE, + function(evt) { + if (!modifyingCollection) { + modifyingCollection = true; + collection.remove(evt.feature); + modifyingCollection = false; + } + }); + goog.events.listen(collection, ol.CollectionEventType.ADD, + function(evt) { + if (!modifyingCollection) { + var feature = evt.element; + goog.asserts.assertInstanceof(feature, ol.Feature); + modifyingCollection = true; + this.addFeature(feature); + modifyingCollection = false; + } + }, false, this); + goog.events.listen(collection, ol.CollectionEventType.REMOVE, + function(evt) { + if (!modifyingCollection) { + var feature = evt.element; + goog.asserts.assertInstanceof(feature, ol.Feature); + modifyingCollection = true; + this.removeFeature(feature); + modifyingCollection = false; + } + }, false, this); + this.featuresCollection_ = collection; +}; + + /** * Remove all features from the source. * @param {boolean=} opt_fast Skip dispatching of {@link removefeature} events. @@ -305,8 +381,10 @@ ol.source.Vector.prototype.clear = function(opt_fast) { this.undefIdIndex_ = {}; } else { var rmFeatureInternal = this.removeFeatureInternal; - this.featuresRtree_.forEach(rmFeatureInternal, this); - goog.object.forEach(this.nullGeometryFeatures_, rmFeatureInternal, this); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.forEach(rmFeatureInternal, this); + goog.object.forEach(this.nullGeometryFeatures_, rmFeatureInternal, this); + } goog.asserts.assert(goog.object.isEmpty(this.featureChangeKeys_), 'featureChangeKeys is an empty object now'); goog.asserts.assert(goog.object.isEmpty(this.idIndex_), @@ -315,7 +393,9 @@ ol.source.Vector.prototype.clear = function(opt_fast) { 'undefIdIndex is an empty object now'); } - this.featuresRtree_.clear(); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.clear(); + } this.loadedExtentsRtree_.clear(); this.nullGeometryFeatures_ = {}; @@ -338,7 +418,11 @@ ol.source.Vector.prototype.clear = function(opt_fast) { * @api stable */ ol.source.Vector.prototype.forEachFeature = function(callback, opt_this) { - return this.featuresRtree_.forEach(callback, opt_this); + if (!goog.isNull(this.featuresRtree_)) { + return this.featuresRtree_.forEach(callback, opt_this); + } else if (!goog.isNull(this.featuresCollection_)) { + return this.featuresCollection_.forEach(callback, opt_this); + } }; @@ -381,6 +465,9 @@ ol.source.Vector.prototype.forEachFeatureAtCoordinateDirect = * the {@link ol.source.Vector#forEachFeatureIntersectingExtent * source.forEachFeatureIntersectingExtent()} method instead. * + * When `useSpatialIndex` is set to false, this method will loop through all + * features, equivalent to {@link ol.source.Vector#forEachFeature}. + * * @param {ol.Extent} extent Extent. * @param {function(this: T, ol.Feature): S} callback Called with each feature * whose bounding box intersects the provided extent. @@ -391,7 +478,11 @@ ol.source.Vector.prototype.forEachFeatureAtCoordinateDirect = */ ol.source.Vector.prototype.forEachFeatureInExtent = function(extent, callback, opt_this) { - return this.featuresRtree_.forEachInExtent(extent, callback, opt_this); + if (!goog.isNull(this.featuresRtree_)) { + return this.featuresRtree_.forEachInExtent(extent, callback, opt_this); + } else if (!goog.isNull(this.featuresCollection_)) { + return this.featuresCollection_.forEach(callback, opt_this); + } }; @@ -448,17 +539,36 @@ ol.source.Vector.prototype.forEachFeatureIntersectingExtent = }; +/** + * Get the features collection associated with this source. Will be `null` + * unless the source was configured with `useSpatialIndex` set to `false`, or + * with an {@link ol.Collection} as `features`. + * @return {ol.Collection.} + * @api + */ +ol.source.Vector.prototype.getFeaturesCollection = function() { + return this.featuresCollection_; +}; + + /** * Get all features on the source. * @return {Array.} Features. * @api stable */ ol.source.Vector.prototype.getFeatures = function() { - var features = this.featuresRtree_.getAll(); - if (!goog.object.isEmpty(this.nullGeometryFeatures_)) { - goog.array.extend( - features, goog.object.getValues(this.nullGeometryFeatures_)); + var features; + if (!goog.isNull(this.featuresCollection_)) { + features = this.featuresCollection_.getArray(); + } else if (!goog.isNull(this.featuresRtree_)) { + features = this.featuresRtree_.getAll(); + if (!goog.object.isEmpty(this.nullGeometryFeatures_)) { + goog.array.extend( + features, goog.object.getValues(this.nullGeometryFeatures_)); + } } + goog.asserts.assert(goog.isDef(features), + 'Neither featuresRtree_ nor featuresCollection_ are available'); return features; }; @@ -482,17 +592,25 @@ ol.source.Vector.prototype.getFeaturesAtCoordinate = function(coordinate) { * Get all features in the provided extent. Note that this returns all features * whose bounding boxes intersect the given extent (so it may include features * whose geometries do not intersect the extent). + * + * This method is not available when the source is configured with + * `useSpatialIndex` set to `false`. * @param {ol.Extent} extent Extent. * @return {Array.} Features. * @api */ ol.source.Vector.prototype.getFeaturesInExtent = function(extent) { + goog.asserts.assert(!goog.isNull(this.featuresRtree_), + 'getFeaturesInExtent does not work when useSpatialIndex is set to false'); return this.featuresRtree_.getInExtent(extent); }; /** * Get the closest feature to the provided coordinate. + * + * This method is not available when the source is configured with + * `useSpatialIndex` set to `false`. * @param {ol.Coordinate} coordinate Coordinate. * @return {ol.Feature} Closest feature. * @api stable @@ -512,6 +630,9 @@ ol.source.Vector.prototype.getClosestFeatureToCoordinate = var closestPoint = [NaN, NaN]; var minSquaredDistance = Infinity; var extent = [-Infinity, -Infinity, Infinity, Infinity]; + goog.asserts.assert(!goog.isNull(this.featuresRtree_), + 'getClosestFeatureToCoordinate does not work with useSpatialIndex set ' + + 'to false'); this.featuresRtree_.forEachInExtent(extent, /** * @param {ol.Feature} feature Feature. @@ -542,10 +663,15 @@ ol.source.Vector.prototype.getClosestFeatureToCoordinate = /** * Get the extent of the features currently in the source. + * + * This method is not available when the source is configured with + * `useSpatialIndex` set to `false`. * @return {ol.Extent} Extent. * @api stable */ ol.source.Vector.prototype.getExtent = function() { + goog.asserts.assert(!goog.isNull(this.featuresRtree_), + 'getExtent does not work when useSpatialIndex is set to false'); return this.featuresRtree_.getExtent(); }; @@ -575,16 +701,22 @@ ol.source.Vector.prototype.handleFeatureChange_ = function(event) { var geometry = feature.getGeometry(); if (!goog.isDefAndNotNull(geometry)) { if (!(featureKey in this.nullGeometryFeatures_)) { - this.featuresRtree_.remove(feature); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.remove(feature); + } this.nullGeometryFeatures_[featureKey] = feature; } } else { var extent = geometry.getExtent(); if (featureKey in this.nullGeometryFeatures_) { delete this.nullGeometryFeatures_[featureKey]; - this.featuresRtree_.insert(extent, feature); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.insert(extent, feature); + } } else { - this.featuresRtree_.update(extent, feature); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.update(extent, feature); + } } } var id = feature.getId(); @@ -668,7 +800,9 @@ ol.source.Vector.prototype.removeFeature = function(feature) { if (featureKey in this.nullGeometryFeatures_) { delete this.nullGeometryFeatures_[featureKey]; } else { - this.featuresRtree_.remove(feature); + if (!goog.isNull(this.featuresRtree_)) { + this.featuresRtree_.remove(feature); + } } this.removeFeatureInternal(feature); this.changed(); diff --git a/test/spec/ol/source/vectorsource.test.js b/test/spec/ol/source/vectorsource.test.js index df782e7a67..74bad15d13 100644 --- a/test/spec/ol/source/vectorsource.test.js +++ b/test/spec/ol/source/vectorsource.test.js @@ -427,10 +427,69 @@ describe('ol.source.Vector', function() { }); }); + describe('with useSpatialIndex set to false', function() { + var source; + beforeEach(function() { + source = new ol.source.Vector({useSpatialIndex: false}); + }); + + it('returns a features collection', function() { + expect(source.getFeaturesCollection()).to.be.a(ol.Collection); + }); + + it('#forEachFeatureInExtent loops through all features', function() { + source.addFeatures([new ol.Feature(), new ol.Feature()]); + var spy = sinon.spy(); + source.forEachFeatureInExtent([0, 0, 0, 0], spy); + expect(spy.callCount).to.be(2); + }); + + }); + + describe('with a collection of features', function() { + var collection, source; + beforeEach(function() { + collection = new ol.Collection(); + source = new ol.source.Vector({ + features: collection + }); + }); + + it('#getFeaturesCollection returns the configured collection', function() { + expect(source.getFeaturesCollection()).to.equal(collection); + }); + + it('keeps the collection in sync with the source\'s features', function() { + var feature = new ol.Feature(); + source.addFeature(feature); + expect(collection.getLength()).to.be(1); + source.removeFeature(feature); + expect(collection.getLength()).to.be(0); + source.addFeatures([feature]); + expect(collection.getLength()).to.be(1); + source.clear(); + expect(collection.getLength()).to.be(0); + }); + + it('keeps the source\'s features in sync with the collection', function() { + var feature = new ol.Feature(); + collection.push(feature); + expect(source.getFeatures().length).to.be(1); + collection.remove(feature); + expect(source.getFeatures().length).to.be(0); + collection.extend([feature]); + expect(source.getFeatures().length).to.be(1); + collection.clear(); + expect(source.getFeatures().length).to.be(0); + }); + + }); + }); goog.require('goog.events'); +goog.require('ol.Collection'); goog.require('ol.Feature'); goog.require('ol.geom.Point'); goog.require('ol.proj');