diff --git a/src/ol/featureloader.js b/src/ol/featureloader.js index c80007a707..6247be210d 100644 --- a/src/ol/featureloader.js +++ b/src/ol/featureloader.js @@ -16,15 +16,19 @@ let withCredentials = false; * load features. * * This function takes an {@link module:ol/extent~Extent} representing the area to be loaded, - * a `{number}` representing the resolution (map units per pixel) and an - * {@link module:ol/proj/Projection} for the projection as + * a `{number}` representing the resolution (map units per pixel), an + * {@link module:ol/proj/Projection} for the projection and success and failure callbacks as * arguments. `this` within the function is bound to the * {@link module:ol/source/Vector} it's called from. * * The function is responsible for loading the features and adding them to the * source. - * @typedef {function(this:(import("./source/Vector").default|import("./VectorTile.js").default), import("./extent.js").Extent, number, - * import("./proj/Projection.js").default): void} FeatureLoader + * @typedef {function(this:(import("./source/Vector").default|import("./VectorTile.js").default), + * import("./extent.js").Extent, + * number, + * import("./proj/Projection.js").default, + * function(Array): void=, + * function(): void=): void} FeatureLoader * @api */ @@ -43,81 +47,77 @@ let withCredentials = false; /** * @param {string|FeatureUrlFunction} url Feature URL service. * @param {import("./format/Feature.js").default} format Feature format. - * @param {function(this:import("./VectorTile.js").default, Array, import("./proj/Projection.js").default, import("./extent.js").Extent): void|function(this:import("./source/Vector").default, Array): void} success - * Function called with the loaded features and optionally with the data - * projection. Called with the vector tile or source as `this`. - * @param {function(this:import("./VectorTile.js").default): void|function(this:import("./source/Vector").default): void} failure - * Function called when loading failed. Called with the vector tile or - * source as `this`. - * @return {FeatureLoader} The feature loader. + * @param {import("./extent.js").Extent} extent Extent. + * @param {number} resolution Resolution. + * @param {import("./proj/Projection.js").default} projection Projection. + * @param {function(Array, import("./proj/Projection.js").default): void} success Success + * Function called with the loaded features and optionally with the data projection. + * @param {function(): void} failure Failure + * Function called when loading failed. */ -export function loadFeaturesXhr(url, format, success, failure) { - return ( - /** - * @param {import("./extent.js").Extent} extent Extent. - * @param {number} resolution Resolution. - * @param {import("./proj/Projection.js").default} projection Projection. - * @this {import("./source/Vector").default|import("./VectorTile.js").default} - */ - function (extent, resolution, projection) { - const xhr = new XMLHttpRequest(); - xhr.open( - 'GET', - typeof url === 'function' ? url(extent, resolution, projection) : url, - true - ); - if (format.getType() == FormatType.ARRAY_BUFFER) { - xhr.responseType = 'arraybuffer'; - } - xhr.withCredentials = withCredentials; - /** - * @param {Event} event Event. - * @private - */ - xhr.onload = function (event) { - // status will be 0 for file:// urls - if (!xhr.status || (xhr.status >= 200 && xhr.status < 300)) { - const type = format.getType(); - /** @type {Document|Node|Object|string|undefined} */ - let source; - if (type == FormatType.JSON || type == FormatType.TEXT) { - source = xhr.responseText; - } else if (type == FormatType.XML) { - source = xhr.responseXML; - if (!source) { - source = new DOMParser().parseFromString( - xhr.responseText, - 'application/xml' - ); - } - } else if (type == FormatType.ARRAY_BUFFER) { - source = /** @type {ArrayBuffer} */ (xhr.response); - } - if (source) { - success.call( - this, - format.readFeatures(source, { - extent: extent, - featureProjection: projection, - }), - format.readProjection(source) - ); - } else { - failure.call(this); - } - } else { - failure.call(this); - } - }.bind(this); - /** - * @private - */ - xhr.onerror = function () { - failure.call(this); - }.bind(this); - xhr.send(); - } +export function loadFeaturesXhr( + url, + format, + extent, + resolution, + projection, + success, + failure +) { + const xhr = new XMLHttpRequest(); + xhr.open( + 'GET', + typeof url === 'function' ? url(extent, resolution, projection) : url, + true ); + if (format.getType() == FormatType.ARRAY_BUFFER) { + xhr.responseType = 'arraybuffer'; + } + xhr.withCredentials = withCredentials; + /** + * @param {Event} event Event. + * @private + */ + xhr.onload = function (event) { + // status will be 0 for file:// urls + if (!xhr.status || (xhr.status >= 200 && xhr.status < 300)) { + const type = format.getType(); + /** @type {Document|Node|Object|string|undefined} */ + let source; + if (type == FormatType.JSON || type == FormatType.TEXT) { + source = xhr.responseText; + } else if (type == FormatType.XML) { + source = xhr.responseXML; + if (!source) { + source = new DOMParser().parseFromString( + xhr.responseText, + 'application/xml' + ); + } + } else if (type == FormatType.ARRAY_BUFFER) { + source = /** @type {ArrayBuffer} */ (xhr.response); + } + if (source) { + success( + /** @type {Array} */ + (format.readFeatures(source, { + extent: extent, + featureProjection: projection, + })), + format.readProjection(source) + ); + } else { + failure(); + } + } else { + failure(); + } + }; + /** + * @private + */ + xhr.onerror = failure; + xhr.send(); } /** @@ -130,25 +130,38 @@ export function loadFeaturesXhr(url, format, success, failure) { * @api */ export function xhr(url, format) { - return loadFeaturesXhr( - url, - format, - /** - * @param {Array} features The loaded features. - * @param {import("./proj/Projection.js").default} dataProjection Data - * projection. - * @this {import("./source/Vector").default|import("./VectorTile.js").default} - */ - function (features, dataProjection) { - const sourceOrTile = /** @type {?} */ (this); - if (typeof sourceOrTile.addFeatures === 'function') { - /** @type {import("./source/Vector").default} */ (sourceOrTile).addFeatures( - features - ); - } - }, - /* FIXME handle error */ VOID - ); + /** + * @param {import("./extent.js").Extent} extent Extent. + * @param {number} resolution Resolution. + * @param {import("./proj/Projection.js").default} projection Projection. + * @param {function(): void=} success Success + * Function called when loading succeeded. + * @param {function(): void=} failure Failure + * Function called when loading failed. + * @this {import("./source/Vector").default} + */ + return function (extent, resolution, projection, success, failure) { + const source = /** @type {import("./source/Vector").default} */ (this); + loadFeaturesXhr( + url, + format, + extent, + resolution, + projection, + /** + * @param {Array} features The loaded features. + * @param {import("./proj/Projection.js").default} dataProjection Data + * projection. + */ + function (features, dataProjection) { + if (success !== undefined) { + success(features); + } + source.addFeatures(features); + }, + /* FIXME handle error */ failure ? failure : VOID + ); + }; } /** diff --git a/src/ol/source/Vector.js b/src/ol/source/Vector.js index 2a6ce00375..1c36c542a6 100644 --- a/src/ol/source/Vector.js +++ b/src/ol/source/Vector.js @@ -40,16 +40,24 @@ export class VectorSourceEvent extends Event { /** * @param {string} type Type. * @param {import("../Feature.js").default=} opt_feature Feature. + * @param {Array>=} opt_features Features. */ - constructor(type, opt_feature) { + constructor(type, opt_feature, opt_features) { super(type); /** - * The feature being added or removed. + * The added or removed feature for the `ADDFEATURE` and `REMOVEFEATURE` events, `undefined` otherwise. * @type {import("../Feature.js").default|undefined} * @api */ this.feature = opt_feature; + + /** + * The loaded features for the `FEATURESLOADED` event, `undefined` otherwise. + * @type {Array>|undefined} + * @api + */ + this.features = opt_features; } } @@ -904,7 +912,29 @@ class VectorSource extends Source { } ); if (!alreadyLoaded) { - this.loader_.call(this, extentToLoad, resolution, projection); + this.dispatchEvent( + new VectorSourceEvent(VectorEventType.FEATURESLOADSTART) + ); + this.loader_.call( + this, + extentToLoad, + resolution, + projection, + function (features) { + this.dispatchEvent( + new VectorSourceEvent( + VectorEventType.FEATURESLOADEND, + undefined, + features + ) + ); + }.bind(this), + function () { + this.dispatchEvent( + new VectorSourceEvent(VectorEventType.FEATURESLOADERROR) + ); + }.bind(this) + ); loadedExtentsRtree.insert(extentToLoad, {extent: extentToLoad.slice()}); this.loading = this.loader_ !== VOID; } diff --git a/src/ol/source/VectorEventType.js b/src/ol/source/VectorEventType.js index ef399e18ed..7cf9e9e9eb 100644 --- a/src/ol/source/VectorEventType.js +++ b/src/ol/source/VectorEventType.js @@ -34,4 +34,25 @@ export default { * @api */ REMOVEFEATURE: 'removefeature', + + /** + * Triggered when features starts loading. + * @event module:ol/source/Vector.VectorSourceEvent#featureloadstart + * @api + */ + FEATURESLOADSTART: 'featuresloadstart', + + /** + * Triggered when features finishes loading. + * @event module:ol/source/Vector.VectorSourceEvent#featureloadend + * @api + */ + FEATURESLOADEND: 'featuresloadend', + + /** + * Triggered if feature loading results in an error. + * @event module:ol/source/Vector.VectorSourceEvent#featureloaderror + * @api + */ + FEATURESLOADERROR: 'featuresloaderror', }; diff --git a/src/ol/source/VectorTile.js b/src/ol/source/VectorTile.js index 9b1047ceed..a4f6b467f8 100644 --- a/src/ol/source/VectorTile.js +++ b/src/ol/source/VectorTile.js @@ -528,11 +528,22 @@ export default VectorTile; * @param {string} url URL. */ export function defaultLoadFunction(tile, url) { - const loader = loadFeaturesXhr( - url, - tile.getFormat(), - tile.onLoad.bind(tile), - tile.onError.bind(tile) + tile.setLoader( + /** + * @param {import("../extent.js").Extent} extent Extent. + * @param {number} resolution Resolution. + * @param {import("../proj/Projection.js").default} projection Projection. + */ + function (extent, resolution, projection) { + loadFeaturesXhr( + url, + tile.getFormat(), + extent, + resolution, + projection, + tile.onLoad.bind(tile), + tile.onError.bind(tile) + ); + } ); - tile.setLoader(loader); } diff --git a/test/spec/ol/featureloader.test.js b/test/spec/ol/featureloader.test.js index 3849d03df6..14fdd90072 100644 --- a/test/spec/ol/featureloader.test.js +++ b/test/spec/ol/featureloader.test.js @@ -19,8 +19,10 @@ describe('ol.featureloader', function () { it('adds features to the source', function (done) { loader = xhr(url, format); source.on('addfeature', function (e) { - expect(source.getFeatures().length).to.be.greaterThan(0); - done(); + setTimeout(function () { + expect(source.getFeatures().length).to.be.greaterThan(0); + done(); + }, 0); }); loader.call(source, [], 1, 'EPSG:3857'); }); @@ -33,8 +35,10 @@ describe('ol.featureloader', function () { loader = xhr(url, format); source.on('addfeature', function (e) { - expect(source.getFeatures().length).to.be.greaterThan(0); - done(); + setTimeout(function () { + expect(source.getFeatures().length).to.be.greaterThan(0); + done(); + }, 0); }); loader.call(source, [], 1, 'EPSG:3857'); }); @@ -54,5 +58,23 @@ describe('ol.featureloader', function () { loader.call(source, [], 1, 'EPSG:3857'); }); }); + + it('it calls the success callback', function (done) { + const errorSpy = sinon.spy(); + loader = xhr(url, format); + loader.call( + source, + [], + 1, + 'EPSG:3857', + function () { + setTimeout(function () { + expect(errorSpy.callCount).to.be(0); + done(); + }, 0); + }, + errorSpy + ); + }); }); }); diff --git a/test/spec/ol/source/vector.test.js b/test/spec/ol/source/vector.test.js index 7f170adb41..7bf8df9aec 100644 --- a/test/spec/ol/source/vector.test.js +++ b/test/spec/ol/source/vector.test.js @@ -559,6 +559,35 @@ describe('ol.source.Vector', function () { }); describe('#loadFeatures', function () { + it('fires the FEATURESLOADSTART event', function (done) { + const source = new VectorSource(); + source.on('featuresloadstart', function () { + done(); + }); + source.loadFeatures( + [-10000, -10000, 10000, 10000], + 1, + getProjection('EPSG:3857') + ); + }); + + it('fires the FEATURESLOADEND event if the default load function is used', function (done) { + const source = new VectorSource({ + format: new GeoJSON(), + url: 'spec/ol/source/vectorsource/single-feature.json', + }); + source.on('featuresloadend', function (event) { + expect(event.features).to.be.an('array'); + expect(event.features.length).to.be(1); + done(); + }); + source.loadFeatures( + [-10000, -10000, 10000, 10000], + 1, + getProjection('EPSG:3857') + ); + }); + describe('with the "bbox" strategy', function () { it('requests the view extent plus render buffer', function (done) { const center = [-97.6114, 38.8403]; @@ -661,6 +690,54 @@ describe('ol.source.Vector', function () { getProjection('EPSG:3857') ); }); + + it('fires the FEATURESLOADEND event if the load function uses the callback', function (done) { + const source = new VectorSource(); + const spy = sinon.spy(); + source.on('featuresloadend', spy); + + const features = [new Feature(), new Feature()]; + + source.setLoader(function (bbox, resolution, projection, success) { + success(features); + setTimeout(function () { + expect(spy.calledOnce).to.be(true); + const event = spy.getCall(0).args[0]; + expect(event.features).to.be(features); + done(); + }, 0); + }); + source.loadFeatures( + [-10000, -10000, 10000, 10000], + 1, + getProjection('EPSG:3857') + ); + }); + + it('fires the FEATURESLOADERROR event if the load function uses the callback', function (done) { + const source = new VectorSource(); + const spy = sinon.spy(); + source.on('featuresloaderror', spy); + + source.setLoader(function ( + bbox, + resolution, + projection, + success, + failure + ) { + failure(); + setTimeout(function () { + expect(spy.calledOnce).to.be(true); + done(); + }, 0); + }); + source.loadFeatures( + [-10000, -10000, 10000, 10000], + 1, + getProjection('EPSG:3857') + ); + }); }); });