diff --git a/contribs/gmf/examples/common_dependencies.js b/contribs/gmf/examples/common_dependencies.js index 8e3d090ec849..1f2476aa64f6 100644 --- a/contribs/gmf/examples/common_dependencies.js +++ b/contribs/gmf/examples/common_dependencies.js @@ -1,4 +1,4 @@ -import 'gmf/sass/vars.scss' +import 'gmf/sass/vars.scss'; import 'jquery'; import 'angular'; import 'angular-gettext'; diff --git a/contribs/gmf/examples/search.js b/contribs/gmf/examples/search.js index 9dc788f5ddad..2ebd2f47f5df 100644 --- a/contribs/gmf/examples/search.js +++ b/contribs/gmf/examples/search.js @@ -117,7 +117,7 @@ function MainController(gmfThemes, ngeoFeatureOverlayMgr, ngeoNotification) { }); /** - * @type {function()} + * @type {function(): void} */ this.searchIsReady = () => { ngeoNotification.notify({ diff --git a/contribs/gmf/src/datasource/Manager.js b/contribs/gmf/src/datasource/Manager.js index da2805ff7181..241dc8770330 100644 --- a/contribs/gmf/src/datasource/Manager.js +++ b/contribs/gmf/src/datasource/Manager.js @@ -358,17 +358,17 @@ export class DatasourceManager { // (2) Collect 'leaf' treeCtrls const newTreeCtrls = []; - const visitor = (treeCtrls, treeCtrl) => { + const visitor = (treeCtrl) => { const node = /** @type {!import('gmf/themes.js').GmfGroup|!import('gmf/themes.js').GmfLayer} */ ( treeCtrl.node); const groupNode = /** @type {!import('gmf/themes.js').GmfGroup} */ (node); const children = groupNode.children; if (!children) { - treeCtrls.push(treeCtrl); + newTreeCtrls.push(treeCtrl); } }; for (let i = 0, ii = value.length; i < ii; i++) { - value[i].traverseDepthFirst(visitor.bind(this, newTreeCtrls)); + value[i].traverseDepthFirst(visitor); } // (3) Add new 'treeCtrls' diff --git a/contribs/gmf/src/editing/Snapping.js b/contribs/gmf/src/editing/Snapping.js index 19f35be96118..662a88fefba3 100644 --- a/contribs/gmf/src/editing/Snapping.js +++ b/contribs/gmf/src/editing/Snapping.js @@ -565,7 +565,7 @@ EditingSnappingService.prototype.handleMapMoveEnd_ = function() { * @property {?angular.IDeferred} requestDeferred * @property {import('gmf/themes.js').GmfSnappingConfig} snappingConfig * @property {Function} stateWatcherUnregister - * @property {ngeo.layertree.Controller} treeCtrl + * @property {import("ngeo/layertree/Controller.js").defaault} treeCtrl * @property {WFSConfig} wfsConfig */ diff --git a/contribs/gmf/src/editing/editFeatureSelectorComponent.js b/contribs/gmf/src/editing/editFeatureSelectorComponent.js index 982aa8dd22ae..960c44cc2265 100644 --- a/contribs/gmf/src/editing/editFeatureSelectorComponent.js +++ b/contribs/gmf/src/editing/editFeatureSelectorComponent.js @@ -49,7 +49,7 @@ module.run(/* @ngInject */ ($templateCache) => { * buffer in pixels to use when making queries to get the features. * @htmlAttribute {import("ol/layer/Vector.js").default} gmf-editfeatureselector-vector The vector * layer where the selected or created features are drawn. - * @htmlAttribute {ngeo.layertree.Controller} gmf-editfeatureselector-tree The + * @htmlAttribute {import("ngeo/layertree/Controller.js").default} gmf-editfeatureselector-tree The * layertree controller handling the selectable editable layers list. * @htmlAttribute {boolean} gmf-editfeatureselector-closeaftersave If true, * immediately return to the main edit panel after save. Default is false. @@ -174,7 +174,7 @@ function Controller($scope, $timeout, gmfThemes, gmfTreeManager) { }; /** - * @type {function()} + * @type {function(): void} * @private */ this.treeCtrlsWatcherUnregister_ = $scope.$watchCollection(() => { diff --git a/contribs/gmf/src/objectediting/component.js b/contribs/gmf/src/objectediting/component.js index 264550c98528..ee79e209555e 100644 --- a/contribs/gmf/src/objectediting/component.js +++ b/contribs/gmf/src/objectediting/component.js @@ -957,6 +957,7 @@ Controller.prototype.setFeatureStyle_ = function() { * * @param {import("ngeo/layertree/Controller.js").LayertreeController} treeCtrl Layertree controller * to register + * @return {void} * @private */ Controller.prototype.registerTreeCtrl_ = function(treeCtrl) { diff --git a/contribs/gmf/src/raster/component.js b/contribs/gmf/src/raster/component.js index 3b62df062d44..394becd6118d 100644 --- a/contribs/gmf/src/raster/component.js +++ b/contribs/gmf/src/raster/component.js @@ -127,7 +127,8 @@ module.directive('gmfElevation', rasterComponent); * @hidden * @param {!angular.IScope} $scope Scope. * @param {!angular.IFilterService} $filter Angular filter. - * @param {!import("ngeo/misc/debounce.js").miscDebounce} ngeoDebounce Ngeo debounce factory + * @param {!import("ngeo/misc/debounce.js").miscDebounce + * } ngeoDebounce Ngeo debounce factory * @param {!import("gmf/raster/RasterService.js").RasterService} gmfRaster Gmf Raster service * @param {!angular.gettext.gettextCatalog} gettextCatalog Gettext catalog. * @constructor @@ -146,7 +147,8 @@ function Controller($scope, $filter, ngeoDebounce, gmfRaster, gettextCatalog) { this.filter_ = $filter; /** - * @type {import("ngeo/misc/debounce.js").miscDebounce} + * @type {import("ngeo/misc/debounce.js").miscDebounce + * } * @private */ this.ngeoDebounce_ = ngeoDebounce; diff --git a/examples/mapfishprint.js b/examples/mapfishprint.js index c69d9515e83b..ac1c26ab72fa 100644 --- a/examples/mapfishprint.js +++ b/examples/mapfishprint.js @@ -126,7 +126,7 @@ function MainController($timeout, ngeoCreatePrint, ngeoPrintUtils) { this.printUtils_ = ngeoPrintUtils; /** - * @type {function(import("ol/render/Event.js").default)} + * @type {function(import("ol/render/Event.js").default): void} */ const postcomposeListener = ngeoPrintUtils.createPrintMaskPostcompose( /** diff --git a/examples/offline.css b/examples/offline.css new file mode 100644 index 000000000000..e0e34bc7cab9 --- /dev/null +++ b/examples/offline.css @@ -0,0 +1,60 @@ +#map { + width: 600px; + height: 400px; + position: relative; +} +ngeo-offline div { + z-index: 1; +} +ngeo-offline .main-button { + position: absolute; + right: 1rem; + bottom: 5rem; + cursor: pointer; +} +ngeo-offline .main-button .no-data { + color: black; +} +ngeo-offline .main-button .with-data { + color: red; +} +ngeo-offline .main-button .no-data, +ngeo-offline .main-button .with-data { + background-color: white; + text-align: center; + font-size: 2.5rem; + line-height: 2rem; + border-radius: 2rem; + font-family: FontAwesome; +} + +ngeo-offline .validate-extent { + position: absolute; + bottom: 0.5rem; + width: 10rem; + left: calc(50% - 5rem); +} +ngeo-offline .in-progress { + position: absolute; + left: calc(50% - 3.3rem); + top: calc(50% - 3rem); + color: white; + font-weight: bold; + background-color: #337ab7; + padding: 2rem 2rem; + border-radius: 1rem; +} +ngeo-offline .modal-content { + width: 30rem; +} +ngeo-offline .modal-body button { + display: block; + margin: 0.5rem auto; + width: 25rem; +} +.offline-msg { + display: none; +} +.offline .offline-msg { + display: block; +} diff --git a/examples/offline.html b/examples/offline.html new file mode 100644 index 000000000000..70ec1786a492 --- /dev/null +++ b/examples/offline.html @@ -0,0 +1,20 @@ + + + + Offline example + + + + + +
You are currently offline.
+
+ + +
+

This example shows how to use the ngeo-offline component.

+ + diff --git a/examples/offline.js b/examples/offline.js new file mode 100644 index 000000000000..b636bb2d250a --- /dev/null +++ b/examples/offline.js @@ -0,0 +1,85 @@ +/** + * @module app.offline + */ +const exports = {}; + +import '@fortawesome/fontawesome-free/css/fontawesome.min.css'; +import './offline.css'; +import './common_dependencies.js'; +import olMap from 'ol/Map.js'; + +import olView from 'ol/View.js'; +import olLayerTile from 'ol/layer/Tile.js'; +import olSourceOSM from 'ol/source/OSM.js'; +import ngeoMapModule from 'ngeo/map/module.js'; +import ngeoOfflineModule from 'ngeo/offline/module.js'; +import ngeoOfflineConfiguration from 'ngeo/offline/Configuration.js'; +import NgeoOfflineServiceManager from 'ngeo/offline/ServiceManager.js'; +import angular from 'angular'; + + +/** @type {!angular.IModule} **/ +exports.module = angular.module('app', [ + 'gettext', + ngeoMapModule.name, + ngeoOfflineModule.name, + NgeoOfflineServiceManager.module.name, +]); + +exports.module.value('ngeoOfflineTestUrl', '../../src/offline/component.html'); + +// Define the offline download configuration service +ngeoOfflineModule.service('ngeoOfflineConfiguration', ngeoOfflineConfiguration); + +class MainController { + + /** + * @param {import("ngeo/map/FeatureOverlayMgr.js").FeatureOverlayMgr} ngeoFeatureOverlayMgr + * ngeo feature overlay manager service. + * @param {import("ngeo/offline/NetworkStatus.js").default} ngeoNetworkStatus ngeo network status service. + * @param {NgeoOfflineServiceManager} ngeoOfflineServiceManager ngeo offline service. + * @ngInject + */ + constructor(ngeoFeatureOverlayMgr, ngeoNetworkStatus, ngeoOfflineServiceManager) { + + /** + * Save a square of 10 km sideways (Map's unit is the meter). + * @type {number} + * @export + */ + this.offlineExtentSize = 10000; + + /** + * @type {ngeoNetworkStatus} + * @export + */ + this.ngeoNetworkStatus = ngeoNetworkStatus; + + /** + * @type {olMap} + * @export + */ + this.map = new olMap({ + layers: [ + new olLayerTile({ + source: new olSourceOSM() + }) + ], + view: new olView({ + center: [352379, 5172733], + zoom: 4 + }) + }); + + ngeoFeatureOverlayMgr.init(this.map); + + ngeoOfflineServiceManager.setSaveService('offlineDownloader'); + ngeoOfflineServiceManager.setRestoreService('ngeoOfflineRestorer'); + } +} + + +exports.module.controller('MainController', MainController); + + +export default exports; diff --git a/package.json b/package.json index 00bdb0cffbcc..6bfecbf7d1dc 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "karma-sourcemap-loader": "0.3.7", "karma-webpack": "3.0.5", "loader-utils": "1.2.3", + "localforage": "^1.7.3", "ls": "0.2.1", "moment": "2.22.2", "node-sass": "4.12.0", @@ -122,11 +123,10 @@ "ts-node": "8.1.0", "tsconfig-paths": "3.8.0", "typedoc": "0.14.2", - "typescript": "3.1.6", + "typescript": "^3.4.5", "webpack": "4.30.0", "webpack-cli": "3.3.0", "webpack-dev-server": "3.3.1", "webpack-merge": "4.2.1" - }, - "dependencies": {} + } } diff --git a/src/format/FeatureHash.js b/src/format/FeatureHash.js index a35fd8558b86..8d80b18b4bdd 100644 --- a/src/format/FeatureHash.js +++ b/src/format/FeatureHash.js @@ -418,7 +418,7 @@ export default class extends olFormatTextFeature { if (this.encodeStyles_) { const styleFunction = feature.getStyleFunction(); if (styleFunction !== undefined) { - let styles = styleFunction.call(feature, 0); + let styles = styleFunction(feature, 0); if (styles !== null) { const encodedStyles = []; styles = Array.isArray(styles) ? styles : [styles]; diff --git a/src/layertree/Controller.js b/src/layertree/Controller.js index 3ab8c9126ba3..3f378c7b2232 100644 --- a/src/layertree/Controller.js +++ b/src/layertree/Controller.js @@ -367,7 +367,7 @@ export const LayertreeVisitorDecision = { /** - * @typedef {function(LayertreeController): (!LayertreeVisitorDecision|undefined)} Visitor + * @typedef {function(LayertreeController): (!LayertreeVisitorDecision|void)} Visitor */ diff --git a/src/map/component.js b/src/map/component.js index 09bf2292066c..08a64e4be2f9 100644 --- a/src/map/component.js +++ b/src/map/component.js @@ -48,7 +48,7 @@ function mapComponent($window) { * @param {angular.IAttributes} attrs Attributes. */ link: (scope, element, attrs) => { - // Get the 'ol.Map' object from attributes and manage it accordingly + // Get the 'import("ol/Map.js").default' object from attributes and manage it accordingly const attr = 'ngeoMap'; const prop = attrs[attr]; diff --git a/src/offline/AbstractLocalforageWrapper.js b/src/offline/AbstractLocalforageWrapper.js new file mode 100644 index 000000000000..ba3080d14da7 --- /dev/null +++ b/src/offline/AbstractLocalforageWrapper.js @@ -0,0 +1,105 @@ +/** + * @typedef {{ + * id: number, + * plugin: string, + * command: string, + * args: !Array<*>, + * context: (*|undefined) + * }} + */ +// eslint-disable-next-line no-unused-vars +let Action; + +/** + * @abstract + */ +const exports = class AbstractLocalforageWrapper { + constructor() { + this.waitingPromises_ = new Map(); + this.currentId_ = 0; + } + + setItem(...args) { + return this.createAction('setItem', ...args); + } + + getItem(...args) { + return this.createAction('getItem', ...args); + } + + clear() { + return this.createAction('clear'); + } + + config(...args) { + return this.createAction('config', ...args); + } + + /** + * @export + * @param {string} command . + * @param {...*} args . + * @return {Promise} . + */ + createAction(command, ...args) { + const id = ++this.currentId_; + /** + * @type {Action} + */ + const action = { + 'plugin': 'localforage', + 'command': command, + 'args': args, + 'id': id + }; + const waitingPromise = {}; + const promise = new Promise((resolve, reject) => { + waitingPromise['resolve'] = resolve; + waitingPromise['reject'] = reject; + }); + this.waitingPromises_.set(id, waitingPromise); + this.postToBackend(action); + return promise; + } + + /** + * @export + * @param {*} event . + */ + receiveMessage(event) { + /** + * @type {Action} + */ + const action = event['data']; + const id = action['id']; + const command = action['command']; + const args = action['args'] || []; + const context = action['context']; + const msg = action['msg']; + + const waitingPromise = this.waitingPromises_.get(id); + if (command === 'error') { + console.error(msg, args, context); + if (waitingPromise) { + waitingPromise.reject(args, context); + this.waitingPromises_.delete(id); + } + } else if (command === 'response') { + waitingPromise.resolve(...args); + this.waitingPromises_.delete(id); + } else { + console.error('Unhandled command', JSON.stringify(action, null, '\t')); + } + } + + /** + * @abstract + * @protected + * @param {Action} action . + */ + postToBackend(action) { + } +}; + + +export default exports; diff --git a/src/offline/Configuration.js b/src/offline/Configuration.js new file mode 100644 index 000000000000..bcadc492988f --- /dev/null +++ b/src/offline/Configuration.js @@ -0,0 +1,353 @@ +import olObservable from 'ol/Observable.js'; +import olLayerLayer from 'ol/layer/Layer.js'; +import olLayerVector from 'ol/layer/Vector.js'; +import olLayerTile from 'ol/layer/Tile.js'; +import olLayerImage from 'ol/layer/Image.js'; +import * as olProj from 'ol/proj.js'; +import {defaultImageLoadFunction} from 'ol/source/Image.js'; +import olSourceImageWMS from 'ol/source/ImageWMS.js'; +import olSourceTileWMS from 'ol/source/TileWMS.js'; +import {createForProjection as createTileGridForProjection} from 'ol/tilegrid.js'; +import SerializerDeserializer from 'ngeo/offline/SerializerDeserializer.js'; +import LocalforageCordovaWrapper from 'ngeo/offline/LocalforageCordovaWrapper.js'; +import LocalforageAndroidWrapper from 'ngeo/offline/LocalforageAndroidWrapper.js'; +import LocalforageIosWrapper from 'ngeo/offline/LocalforageIosWrapper.js'; +import ngeoCustomEvent from 'ngeo/CustomEvent.js'; +import utils from 'ngeo/offline/utils.js'; +const defaultImageLoadFunction_ = defaultImageLoadFunction; + +import localforage from 'localforage/src/localforage.js'; + + +/** + * @implements {ngeox.OfflineOnTileDownload} + */ +const exports = class extends olObservable { + + /** + * @ngInject + * @param {!angular.IScope} $rootScope The rootScope provider. + * @param {!import("ngeo/map/BackgroundLayerMgr.js").MapBackgroundLayerManager} ngeoBackgroundLayerMgr + * Background layer manager. + * @param {number} ngeoOfflineGutter A gutter around the tiles to download (to avoid cut symbols) + */ + constructor($rootScope, ngeoBackgroundLayerMgr, ngeoOfflineGutter) { + super(); + + this.localforage_ = this.createLocalforage(); + this.configureLocalforage(); + + /** + * @private + * @type {!angular.IScope} + */ + this.rootScope_ = $rootScope; + + /** + * @protected + * @type {boolean} + */ + this.hasData = false; + this.initializeHasOfflineData(); + + /** + * @private + * @type {!import("ngeo/map/BackgroundLayerMgr.js").MapBackgroundLayerManager} + */ + this.ngeoBackgroundLayerMgr_ = ngeoBackgroundLayerMgr; + + /** + * @private + * @type {SerializerDeserializer} + */ + this.serDes_ = new SerializerDeserializer({gutter: ngeoOfflineGutter}); + + /** + * @private + * @type {number} + */ + this.gutter_ = ngeoOfflineGutter; + } + + /** + * @private + * @param {number} progress new progress. + */ + dispatchProgress_(progress) { + this.dispatchEvent(new ngeoCustomEvent('progress', { + 'progress': progress + })); + } + + /** + * @protected + */ + initializeHasOfflineData() { + this.getItem('offline_content').then(value => this.setHasOfflineData(!!value)); + } + + /** + * @export + * @return {boolean} whether some offline data is available in the storage + */ + hasOfflineData() { + return this.hasData; + } + + /** + * @param {boolean} value whether there is offline data available in the storage. + */ + setHasOfflineData(value) { + const needDigest = value !== this.hasData; + this.hasData = value; + if (needDigest) { + this.rootScope_.$applyAsync(); // force update of the UI + } + } + + /** + * Hook to allow measuring get/set item performance. + * @param {string} msg A message + * @param {string} key The key to work on + * @param {Promise} promise A promise + * @return {Promise} The promise we passed + */ + traceGetSetItem(msg, key, promise) { + return promise; + } + + createLocalforage() { + if (location.search.includes('localforage=cordova')) { + console.log('Using cordova localforage'); + return new LocalforageCordovaWrapper(); + } else if (location.search.includes('localforage=android')) { + console.log('Using android localforage'); + return new LocalforageAndroidWrapper(); + } else if (location.search.includes('localforage=ios')) { + console.log('Using ios localforage'); + return new LocalforageIosWrapper(); + } + return localforage; + } + + configureLocalforage() { + this.localforage_.config({ + 'name': 'ngeoOfflineStorage', + 'version': 1.0, + 'storeName': 'offlineStorage' + }); + } + + /** + * @param {string} key The key + * @return {Promise} A promise + */ + getItem(key) { + return this.traceGetSetItem('getItem', key, this.localforage_['getItem'](key)); + } + + /** + * @param {string} key . + * @return {Promise} . + */ + removeItem(key) { + return this.traceGetSetItem('removeItem', key, this.localforage_['removeItem'](key)); + } + + /** + * @param {string} key The key + * @param {*} value A value + * @return {Promise} A promise + */ + setItem(key, value) { + return this.traceGetSetItem('setItem', key, this.localforage_['setItem'](key, value)); + } + + /** + * @return {Promise} A promise + */ + clear() { + this.setHasOfflineData(false); + return this.traceGetSetItem('clear', '', this.localforage_['clear']()); + } + + /** + * @param {!import("ol/Map.js").default} map A map + * @return {number} An "estimation" of the size of the data to download + */ + estimateLoadDataSize(map) { + return 50; + } + + /** + * @param {import("./index.js").OfflineLayerMetadata} layerItem The layer metadata + * @return {string} A key identifying an offline layer and used during restore. + */ + getLayerKey(layerItem) { + return /** @type {string} */ (layerItem.layer.get('label')); + } + + /** + * @override + * @param {number} progress The download progress + * @param {import("./index.js").OfflineTile} tile The tile + * @return {Promise} A promise + */ + onTileDownloadSuccess(progress, tile) { + this.dispatchProgress_(progress); + + if (tile.response) { + return this.setItem(utils.normalizeURL(tile.url), tile.response); + } + return Promise.resolve(); + } + + /** + * @override + * @param {number} progress The progress + * @return {Promise} A promise + */ + onTileDownloadError(progress) { + this.dispatchProgress_(progress); + return Promise.resolve(); + } + + /** + * @param {import("ol/Map.js").default} map A map + * @param {import("ol/layer/Layer.js").default} layer A layer + * @param {Array} ancestors The ancestors of that layer + * @param {import("ol/extent.js").Extent} userExtent The extent selected by the user. + * @return {Array} The extent to download per zoom level + */ + getExtentByZoom(map, layer, ancestors, userExtent) { + const currentZoom = map.getView().getZoom(); + // const viewportExtent = map.calculateExtent(map.getSize()); + + const results = []; + [0, 1, 2, 3, 4].forEach((dz) => { + results.push({ + zoom: currentZoom + dz, + extent: userExtent + }); + }); + return results; + } + + /** + * @protected + * @param {import("ol/source/Source.js").default} source An ImageWMS source + * @param {!import("ol/proj/Projection.js").default} projection The projection + * @return {import("ol/source/Source.js").default} A tiled equivalent source + */ + sourceImageWMSToTileWMS(source, projection) { + if (source instanceof olSourceImageWMS && source.getUrl() + && source.getImageLoadFunction() === defaultImageLoadFunction_) { + const tileGrid = createTileGridForProjection(source.getProjection() || projection, 42, 256); + source = new olSourceTileWMS({ + gutter: this.gutter_, + url: source.getUrl(), + tileGrid: tileGrid, + attributions: source.getAttributions(), + projection: source.getProjection(), + params: source.getParams() + }); + } + return source; + } + + /** + * @param {import("ol/Map.js").default} map The map to work on. + * @param {import("ol/extent.js").Extent} userExtent The extent selected by the user. + * @return {!Array} the downloadable layers and metadata. + */ + createLayerMetadatas(map, userExtent) { + const layersItems = []; + + /** + * @param {import("ol/layer/Base.js").default} layer . + * @param {Array} ancestors . + * @return {boolean} whether to traverse this layer children. + */ + const visitLayer = (layer, ancestors) => { + if (layer instanceof olLayerLayer) { + const extentByZoom = this.getExtentByZoom(map, layer, ancestors, userExtent); + const projection = olProj.get(map.getView().getProjection()); + const source = this.sourceImageWMSToTileWMS(layer.getSource(), projection); + let layerType; + let layerSerialization; + if (layer instanceof olLayerTile || layer instanceof olLayerImage) { + layerType = 'tile'; + layerSerialization = this.serDes_.serializeTileLayer(layer, source); + } else if (layer instanceof olLayerVector) { + layerType = 'vector'; + } + + const backgroundLayer = this.ngeoBackgroundLayerMgr_.get(map) === layer; + layersItems.push({ + backgroundLayer, + map, + extentByZoom, + layerType, + layerSerialization, + layer, + source, + ancestors + }); + } + return true; + }; + map.getLayers().forEach((root) => { + utils.traverseLayer(root, [], visitLayer); + }); + return layersItems; + } + + /** + * @private + * @param {import("./index.js").OfflinePersistentLayer} offlineLayer The offline layer + * @return {function(import("ol/ImageTile.js").default, string)} the tile function + */ + createTileLoadFunction_(offlineLayer) { + const that = this; + /** + * Load the tile from persistent storage. + * @param {import("ol/ImageTile.js").default} imageTile The image tile + * @param {string} src The tile URL + */ + const tileLoadFunction = function(imageTile, src) { + that.getItem(utils.normalizeURL(src)).then((content) => { + if (!content) { + // use a transparent 1x1 image to make the map consistent + /* eslint-disable-next-line */ + content = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + } + /** @type {HTMLImageElement} */ (imageTile.getImage()).src = content; + }); + }; + return tileLoadFunction; + } + + /** + * @param {import("./index.js").OfflinePersistentLayer} offlineLayer The layer to recreate + * @return {import("ol/layer/Layer.js").default} the layer. + */ + recreateOfflineLayer(offlineLayer) { + if (offlineLayer.layerType === 'tile') { + const serialization = offlineLayer.layerSerialization; + const tileLoadFunction = this.createTileLoadFunction_(offlineLayer); + const layer = this.serDes_.deserializeTileLayer(serialization, tileLoadFunction); + return layer; + } + return null; + } + + /** + * @return {number} The number + */ + getMaxNumberOfParallelDownloads() { + return 11; + } +}; + + +export default exports; diff --git a/src/offline/Downloader.js b/src/offline/Downloader.js new file mode 100644 index 000000000000..4c53ee36fb67 --- /dev/null +++ b/src/offline/Downloader.js @@ -0,0 +1,158 @@ +import {DEVICE_PIXEL_RATIO} from 'ol/has.js'; +import olSourceTileWMS from 'ol/source/TileWMS.js'; +import olSourceWMTS from 'ol/source/WMTS.js'; +import TilesDownloader from 'ngeo/offline/TilesDownloader.js'; +import angular from 'angular'; + + +/** + * @param {import("ol/coordinate.js").Coordinate} a Some coordinates. + * @param {import("ol/coordinate.js").Coordinate} b Some other coordinates. + * @return {number} The squared magnitude. + */ +function magnitude2(a, b) { + let magnitudeSquared = 0; + for (let i = 0; i < a.length; ++i) { + magnitudeSquared += Math.pow(a[1] - b[1], 2); + } + return magnitudeSquared; +} + + +const Downloader = class { + + /** + * @ngInject + * @param {import("ngeo/offline/Configuration.js").default} ngeoOfflineConfiguration + * A service for customizing offline behaviour. + */ + constructor(ngeoOfflineConfiguration) { + /** + * @private + * @type {import("ngeo/offline/Configuration.js").default} + */ + this.configuration_ = ngeoOfflineConfiguration; + + /** + * @type {TilesDownloader} + * @private + */ + this.tileDownloader_ = null; + } + + cancel() { + this.tileDownloader_.cancel(); + } + + /** + * @param {import("./index.js").OfflineLayerMetadata} layerMetadata Layers metadata. + * @param {Array} queue Queue of tiles to download. + */ + queueLayerTiles_(layerMetadata, queue) { + const source = /** @type {olSourceTileWMS|olSourceWMTS} */ (layerMetadata.source); + const {map, extentByZoom} = layerMetadata; + + if (!source) { + return; + } + console.assert(source instanceof olSourceTileWMS || source instanceof olSourceWMTS); + const projection = map.getView().getProjection(); + const tileGrid = source.getTileGrid(); + const tileUrlFunction = source.getTileUrlFunction(); + + console.assert(extentByZoom); + for (const extentZoom of extentByZoom) { + const z = extentZoom.zoom; + const extent = extentZoom.extent; + const queueByZ = []; + let minX, minY, maxX, maxY; + tileGrid.forEachTileCoord(extent, z, (coord) => { + maxX = coord[1]; + maxY = coord[2]; + if (minX === undefined || minY === undefined) { + minX = coord[1]; + minY = coord[2]; + } + const url = tileUrlFunction(coord, DEVICE_PIXEL_RATIO, projection); + console.assert(url); + + /** + * @type {import("./index.js").OfflineTile} + */ + const tile = {coord, url, response: null}; + queueByZ.push(tile); + }); + + // @ts-ignore + const centerTileCoord = [z, (minX + maxX) / 2, (minY + maxY) / 2]; + queueByZ.sort((a, b) => magnitude2(a.coord, centerTileCoord) - magnitude2(b.coord, centerTileCoord)); + queue.push(...queueByZ); + } + } + + /** + * @param {import("ol/extent.js").Extent} extent The extent to download. + * @param {import("ol/Map.js").default} map The map to work on. + * @return {Promise} A promise resolving when save is finished. + */ + save(extent, map) { + /** + * @type {!Array} + */ + const layersMetadatas = this.configuration_.createLayerMetadatas(map, extent); + + /** + * @type {!Array} + */ + const persistentLayers = []; + const queue = []; + const zooms = []; + for (const layerItem of layersMetadatas) { + if (layerItem.layerType === 'tile') { + const tiles = []; + this.queueLayerTiles_(layerItem, tiles); + queue.push(...tiles); + } + persistentLayers.push({ + backgroundLayer: layerItem.backgroundLayer, + layerType: layerItem.layerType, + layerSerialization: layerItem.layerSerialization, + key: this.configuration_.getLayerKey(layerItem), + }); + + layerItem.extentByZoom.forEach((obj) => { + const zoom = obj.zoom; + if (zooms.indexOf(zoom) < 0) { + zooms.push(zoom); + } + }); + } + + /** + * @type {import("./index.js").OfflinePersistentContent} + */ + const persistentObject = { + extent: extent, + layers: persistentLayers, + zooms: zooms.sort((a, b) => (a < b ? -1 : 1)) + }; + const setOfflineContentPromise = this.configuration_.setItem('offline_content', persistentObject); + + const maxDownloads = this.configuration_.getMaxNumberOfParallelDownloads(); + this.tileDownloader_ = new TilesDownloader(queue, this.configuration_, maxDownloads); + const tileDownloadPromise = this.tileDownloader_.download(); + + const allPromise = Promise.all([setOfflineContentPromise, tileDownloadPromise]); + const setHasOfflineData = () => this.configuration_.setHasOfflineData(true); + allPromise.then(setHasOfflineData, setHasOfflineData); + return allPromise; + } +}; + +const name = 'offlineDownloader'; +Downloader.module = angular.module(name, []).service(name, Downloader); + +const exports = Downloader; + + +export default exports; diff --git a/src/offline/LocalforageAndroidWrapper.js b/src/offline/LocalforageAndroidWrapper.js new file mode 100644 index 000000000000..127a276f67ec --- /dev/null +++ b/src/offline/LocalforageAndroidWrapper.js @@ -0,0 +1,30 @@ +import AbstractWrapper from 'ngeo/offline/AbstractLocalforageWrapper.js'; + +const exports = class AndroidWrapper extends AbstractWrapper { + constructor() { + super(); + window['androidWrapper'] = this; + } + + /** + * @override + */ + postToBackend(action) { + const stringified = JSON.stringify(action); + window['ngeoHost']['postMessageToAndroid'](stringified); + } + + /** + * @export + * @param {string} actionString . + */ + receiveFromAndroid(actionString) { + const action = JSON.parse(actionString); + this.receiveMessage({ + 'data': action + }); + } +}; + + +export default exports; diff --git a/src/offline/LocalforageCordovaWrapper.js b/src/offline/LocalforageCordovaWrapper.js new file mode 100644 index 000000000000..1f5d17ff18ae --- /dev/null +++ b/src/offline/LocalforageCordovaWrapper.js @@ -0,0 +1,18 @@ +import AbstractWrapper from 'ngeo/offline/AbstractLocalforageWrapper.js'; + +const exports = class CordovaWrapper extends AbstractWrapper { + constructor() { + super(); + window.addEventListener('message', this.receiveMessage.bind(this), false); + } + + /** + * @override + */ + postToBackend(action) { + window['parent'].postMessage(action, '*'); + } +}; + + +export default exports; diff --git a/src/offline/LocalforageIosWrapper.js b/src/offline/LocalforageIosWrapper.js new file mode 100644 index 000000000000..ab50c465d440 --- /dev/null +++ b/src/offline/LocalforageIosWrapper.js @@ -0,0 +1,34 @@ +import AbstractWrapper from 'ngeo/offline/AbstractLocalforageWrapper.js'; + +const exports = class IosWrapper extends AbstractWrapper { + constructor() { + super(); + window['iosWrapper'] = this; + } + + /** + * @override + */ + postToBackend(action) { + if (action['command'] === 'setItem') { + action['args'][1] = JSON.stringify(action['args'][1]); + } + const stringified = JSON.stringify(action); + window['webkit']['messageHandlers']['ios']['postMessage'](stringified); + } + + /** + * @export + * @param {string} actionString . + */ + receiveFromIos(actionString) { + const action = JSON.parse(actionString); + action['args'] = (action['args'] || []).map(item => JSON.parse(item)); + this.receiveMessage({ + 'data': action + }); + } +}; + + +export default exports; diff --git a/src/offline/Mode.js b/src/offline/Mode.js new file mode 100644 index 000000000000..bb99ae9a9ecf --- /dev/null +++ b/src/offline/Mode.js @@ -0,0 +1,87 @@ +import angular from 'angular'; + + +class Mode { + + /** + * @param {import("ngeo/offline/Configuration.js").default} ngeoOfflineConfiguration + * ngeo offline configuration service. + * @ngInject + * @ngdoc service + * @ngname ngeoOfflineState + */ + constructor(ngeoOfflineConfiguration) { + + /** + * Offline mode is enabled or not. + * @type {boolean} + * @private + */ + this.enabled_ = false; + + /** + * Offline component. + * @type {import("ngeo/offline/component.js").Controller|undefined} + * @private + */ + this.component_; + + /** + * @private + * @type {import("ngeo/offline/Configuration.js").default} + */ + this.ngeoOfflineConfiguration_ = ngeoOfflineConfiguration; + } + + /** + * Return if we are in offline mode. + * @return {boolean} whether offline mode is enabled + * @export + */ + isEnabled() { + return this.enabled_; + } + + /** + * Enable offline mode. ATM we cannot escape from the offline mode. + * @export + */ + enable() { + this.enabled_ = true; + } + + /** + * + * @param {import("ngeo/offline/component.js").Controller} component Offline component. + * @export + */ + registerComponent(component) { + this.component_ = component; + } + + /** + * @export + */ + activateOfflineMode() { + this.component_.activateOfflineMode(); + } + + /** + * @return {boolean} True if data are accessible offline. + * @export + */ + hasData() { + return this.ngeoOfflineConfiguration_.hasOfflineData(); + } + +} + +/** + * @type {!angular.IModule} + */ +const module = angular.module('ngeoOfflineMode', []); +module.service('ngeoOfflineMode', Mode); +Mode.module = module; + + +export default Mode; diff --git a/src/offline/NetworkStatus.js b/src/offline/NetworkStatus.js new file mode 100644 index 000000000000..142cde6fb57d --- /dev/null +++ b/src/offline/NetworkStatus.js @@ -0,0 +1,214 @@ +import ngeoMiscDebounce from 'ngeo/misc/debounce.js'; +import angular from 'angular'; + + +const Service = class { + + /** + * This service watches the status of network connection. + * + * Currently it watches every $http and $.ajax requests errors, if an error + * occurs we wait 2 sec then we make an http request on the checker file. + * If the checker responds that means we are online, otherwise we make a + * 2nd request 2 sec later, if the 2nd requests failed that means we + * are offline. + * + * A timeout of 1 sec is set for the checker file, so if we have a bad + * connection, we consider we are offline. + * + * During offline mode we test every 2 sec if we are back online. + * + * @ngInject + * @param {!jQuery} $document Angular document service. + * @param {angular.IWindowService} $window Angular window service. + * @param {angular.ITimeoutService} $timeout Angular timeout service. + * @param {angular.IScope} $rootScope The root scope. + * @param {string} ngeoOfflineTestUrl Url of the test page. + */ + constructor($document, $window, $timeout, $rootScope, ngeoOfflineTestUrl) { + + /** + * @private + * @type {!jQuery} + */ + this.$document_ = $document; + + /** + * @private + * @type {!Window} + */ + this.$window_ = $window; + + /** + * @private + * @type {!angular.ITimeoutService} + */ + this.$timeout_ = $timeout; + + /** + * @private + * @type {angular.IScope} + */ + this.$rootScope_ = $rootScope; + + /** + * @private + * @type {string} + */ + this.ngeoOfflineTestUrl_ = ngeoOfflineTestUrl; + + /** + * @private + * @type {!number} + */ + this.count_ = 0; + + /** + * @type {!boolean|undefined} + * @private + */ + this.offline_; + + /** + * @private + * @type {angular.IPromise|undefined} + */ + this.promise_; + + this.initialize_(); + + } + + initialize_() { + this.offline_ = !this.$window_.navigator.onLine; + + // airplane mode, works offline(firefox) + this.$window_.addEventListener('offline', () => { + this.triggerChangeStatusEvent_(true); + }); + + // online event doesn't means we have a internet connection, that means we + // have possiby one (connected to a router ...) + this.$window_.addEventListener('online', () => { + this.check(undefined); + }); + + // We catch every $.ajax request errors or (canceled request). + if (this.$document_['ajaxError']) { + this.$document_['ajaxError']((evt, jqxhr, settings, thrownError) => { + // Filter out canceled requests + if (!/^(canceled|abort)$/.test(thrownError)) { + this.check(2000); + } + }); + } + } + + /** + * Check fir network status + * + * @param {number=} timeout Delay for timeout. + */ + check(timeout) { + if (this.promise_) { + this.$timeout_.cancel(this.promise_); + this.promise_ = undefined; + } + if (timeout !== undefined) { + this.count_++; + this.promise_ = this.$timeout_(() => this.check(), timeout); + return; + } + $.ajax({ + method: 'GET', + url: this.ngeoOfflineTestUrl_, + timeout: 1000, + success: () => { + this.count_ = 0; + if (this.offline_) { + this.triggerChangeStatusEvent_(false); + } + }, + error: () => { + this.count_++; + // We consider we are offline after 3 requests failed + if (this.count_ > 2 && !this.offline_) { + this.triggerChangeStatusEvent_(true); + } + } + }); + + } + + /** + * @param {boolean} offline whether it's offline or not. + * @private + */ + triggerChangeStatusEvent_(offline) { + this.offline_ = offline; + // this.$rootScope_.$broadcast('ngeoNetworkStatusChange', net.offline); + this.$rootScope_.$digest(); + } + + /** + * @return {boolean} True if we are disconnected. + * @export + */ + isDisconnected() { + return !!this.offline_; + } +}; + + +const name = 'ngeoNetworkStatus'; + +Service.module = angular.module(name, [ + ngeoMiscDebounce.name +]); +Service.module.service(name, Service); + + +/** + * @ngInject + * @param {angular.IQService} $q The Angular $q service. + * @param {import("ngeo/misc/debounce.js").miscDebounce} ngeoDebounce ngeo debounce service. + * @param {Service} ngeoNetworkStatus ngeo network status service. + * @return {angular.IHttpInterceptor} the interceptor + */ +const httpInterceptor = function($q, ngeoDebounce, ngeoNetworkStatus) { + const debouncedCheck = ngeoDebounce(() => ngeoNetworkStatus.check(undefined), 2000, false); + return { + request(config) { + return config; + }, + requestError(rejection) { + return $q.reject(rejection); + }, + response(response) { + return response; + }, + responseError(rejection) { + debouncedCheck(); + return $q.reject(rejection); + } + }; +}; +Service.module.factory('httpInterceptor', httpInterceptor); + + +/** + * @ngInject + * @private + * @param {angular.IHttpProvider} $httpProvider . + */ +Service.module.configFunction_ = function($httpProvider) { + $httpProvider.interceptors.push('httpInterceptor'); +}; +Service.module.config(Service.module.configFunction_); + +Service.module.value('ngeoOfflineTestUrl', ''); + +const exports = Service; + + +export default exports; diff --git a/src/offline/Restorer.js b/src/offline/Restorer.js new file mode 100644 index 000000000000..19d4c8851e23 --- /dev/null +++ b/src/offline/Restorer.js @@ -0,0 +1,66 @@ +import ngeoMapBackgroundLayerMgr from 'ngeo/map/BackgroundLayerMgr.js'; +import angular from 'angular'; + + +class Restorer { + + /** + * @ngInject + * @param {import("ngeo/offline/Configuration.js").default} + * ngeoOfflineConfiguration A service for customizing offline behaviour. + * @param {import("ngeo/map/BackgroundLayerMgr.js").MapBackgroundLayerManager} + * ngeoBackgroundLayerMgr The background layer manager. + */ + constructor(ngeoOfflineConfiguration, ngeoBackgroundLayerMgr) { + /** + * @private + * @type {import("ngeo/offline/Configuration.js").default} + */ + this.configuration_ = ngeoOfflineConfiguration; + + /** + * @private + * @type {import("ngeo/map/BackgroundLayerMgr.js").MapBackgroundLayerManager} + */ + this.ngeoBackgroundLayerMgr_ = ngeoBackgroundLayerMgr; + } + + /** + * @param {import("ol/Map.js").default} map The map to work on. + * @return {Promise} A promise to the extent of the restored area. + */ + restore(map) { + return this.configuration_.getItem('offline_content').then( + offlineContent => this.doRestore(map, offlineContent)); + } + + /** + * @protected + * @param {import("ol/Map.js").default} map A map + * @param {import("./index.js").OfflinePersistentContent} offlineContent The offline content + * @return {import("ol/extent.js").Extent} The extent of the restored area + */ + doRestore(map, offlineContent) { + map.getLayerGroup().getLayers().clear(); + for (const offlineLayer of offlineContent.layers) { + const layer = this.configuration_.recreateOfflineLayer(offlineLayer); + if (layer) { + map.addLayer(layer); + if (offlineLayer.backgroundLayer) { + this.ngeoBackgroundLayerMgr_.set(map, layer); + } + } + } + return offlineContent.extent; + } +} + +const name = 'ngeoOfflineRestorer'; +Restorer.module = angular.module(name, [ + ngeoMapBackgroundLayerMgr.name +]).service(name, Restorer); + +const exports = Restorer; + + +export default exports; diff --git a/src/offline/SerializerDeserializer.js b/src/offline/SerializerDeserializer.js new file mode 100644 index 000000000000..91b63aef7bde --- /dev/null +++ b/src/offline/SerializerDeserializer.js @@ -0,0 +1,219 @@ +import OlTilegridTileGrid from 'ol/tilegrid/TileGrid.js'; +import OlTilegridWMTS from 'ol/tilegrid/WMTS.js'; +import * as olProj from 'ol/proj.js'; +import OlSourceTileWMS from 'ol/source/TileWMS.js'; +import OlSourceWMTS from 'ol/source/WMTS.js'; +import OlLayerTile from 'ol/layer/Tile.js'; + + +const SerDes = class { + /** + * @param {Object} options The options + */ + constructor({gutter}) { + /** + * @private + */ + this.gutter_ = gutter; + } + + /** + * @private + * @param {import("ol/Object.js").default} olObject An OL object + * @return {Object} The serializable properties of the object + */ + createBaseObject_(olObject) { + const properties = olObject.getProperties(); + const obj = {}; + for (const key in properties) { + const value = properties[key]; + const typeOf = typeof value; + if (typeOf === 'string' || typeOf === 'number') { + obj[key] = value; + } + } + return obj; + } + + /** + * @param {OlTilegridTileGrid} tilegrid . + * @return {string} . + */ + serializeTilegrid(tilegrid) { + const obj = {}; + obj['extent'] = tilegrid.getExtent(); + obj['minZoom'] = tilegrid.getMinZoom(); + obj['origin'] = tilegrid.getOrigin(0); // hack + obj['resolutions'] = tilegrid.getResolutions(); + obj['tileSize'] = tilegrid.getTileSize(tilegrid.getMinZoom()); + return JSON.stringify(obj); + } + + /** + * @param {string} serialization . + * @return {OlTilegridTileGrid} tilegrid + */ + deserializeTilegrid(serialization) { + const options = /** @type {import ("ol/tilegrid/WMTS").Options} */ (JSON.parse(serialization)); + return new OlTilegridTileGrid(options); + } + + /** + * @param {OlTilegridWMTS} tilegrid . + * @return {string|undefined} . + */ + serializeTilegridWMTS(tilegrid) { + if (!tilegrid) { + return undefined; + } + const obj = {}; + const resolutions = tilegrid.getResolutions(); + obj['extent'] = tilegrid.getExtent(); + obj['minZoom'] = tilegrid.getMinZoom(); + obj['matrixIds'] = tilegrid.getMatrixIds(); + obj['resolutions'] = resolutions; + + obj['origins'] = []; + for (let z = 0; z < resolutions.length; ++z) { + obj['origins'].push(tilegrid.getOrigin(z)); + } + return JSON.stringify(obj); + } + + /** + * @param {string} serialization . + * @return {OlTilegridWMTS} tilegrid . + */ + deserializeTilegridWMTS(serialization) { + const options = /** @type {import ("ol/tilegrid/WMTS").Options} */ (JSON.parse(serialization)); + return new OlTilegridWMTS(options); + } + + + /** + * @param {OlSourceTileWMS} source . + * @return {string} . + */ + serializeSourceTileWMS(source) { + const obj = this.createBaseObject_(source); + obj['params'] = source.getParams(); + obj['urls'] = source.getUrls(); + obj['tileGrid'] = this.serializeTilegrid(source.getTileGrid()); + const projection = source.getProjection(); + if (projection) { + obj['projection'] = olProj.get(source.getProjection()).getCode(); + } + + return JSON.stringify(obj); + } + + /** + * @param {string} serialization . + * @param {function(import("ol/ImageTile.js").default, string)=} tileLoadFunction . + * @return {OlSourceTileWMS} source . + */ + deserializeSourceTileWMS(serialization, tileLoadFunction) { + const options = /** @type {import ("ol/source/TileWMS").Options} */ (JSON.parse(serialization)); + options.tileLoadFunction = tileLoadFunction; + if (options.tileGrid) { + options.tileGrid = this.deserializeTilegrid(/** @type{any} */ (options).tileGrid); + } + options.gutter = this.gutter_; + return new OlSourceTileWMS(options); + } + + /** + * @param {OlSourceWMTS} source . + * @return {string} . + */ + serializeSourceWMTS(source) { + const obj = this.createBaseObject_(source); + obj.dimensions = source.getDimensions(); + obj.format = source.getFormat(); + obj.urls = source.getUrls(); + obj.version = source.getVersion(); + obj.layer = source.getLayer(); + obj.style = source.getStyle(); + obj.matrixSet = source.getMatrixSet(); + // The OL getTileGrid method is expected to return a WMTS tilegrid so it is OK to cast here. + const tileGridWMTS = /** @type {OlTilegridWMTS} */ (source.getTileGrid()); + obj.tileGrid = this.serializeTilegridWMTS(tileGridWMTS); + obj.requestEncoding = source.getRequestEncoding(); + const projection = source.getProjection(); + if (projection) { + obj.projection = olProj.get(source.getProjection()).getCode(); + } + + return JSON.stringify(obj); + } + + /** + * @param {string} serialization . + * @param {function(import("ol/ImageTile.js").default, string)=} tileLoadFunction . + * @return {OlSourceWMTS} . + */ + deserializeSourceWMTS(serialization, tileLoadFunction) { + const options = /** @type {import("ol/source/WMTS").Options} */ (JSON.parse(serialization)); + options.tileLoadFunction = tileLoadFunction; + if (options.tileGrid) { + options.tileGrid = this.deserializeTilegridWMTS(/** @type{any} */(options).tileGrid); + } + return new OlSourceWMTS(options); + } + + /** + * @private + * @param {number} number Some number which may be Infinity + * @return {number} The same number or an arbitrary big number instead of Infinity + */ + makeInfinitySerializable_(number) { + if (number === Infinity) { + return 1000; + } + return number; + } + + /** + * @param {!import("ol/layer/Tile.js").default|import("ol/layer/Image").default} layer . + * @param {import("ol/source/Source.js").default=} source . + * @return {string} . + */ + serializeTileLayer(layer, source) { + const obj = this.createBaseObject_(layer); + obj.opacity = layer.getOpacity(); + obj.visible = layer.getVisible(); + obj.minResolution = layer.getMinResolution(); + obj.maxResolution = this.makeInfinitySerializable_(layer.getMaxResolution()); + obj.zIndex = layer.getZIndex(); + source = source || layer.getSource(); + if (source instanceof OlSourceTileWMS) { + obj.source = this.serializeSourceTileWMS(source); + obj.sourceType = 'tileWMS'; + } else if (source instanceof OlSourceWMTS) { + obj.source = this.serializeSourceWMTS(source); + obj.sourceType = 'WMTS'; + } + return JSON.stringify(obj); + } + + /** + * @param {string} serialization . + * @param {function(import("ol/ImageTile.js").default, string)=} tileLoadFunction . + * @return {!import("ol/layer/Tile.js").default} . + */ + deserializeTileLayer(serialization, tileLoadFunction) { + const options = /** @type import("ol/layer/Tile").Options */ (JSON.parse(serialization)); + const sourceType = options['sourceType']; + if (sourceType === 'tileWMS') { + options.source = this.deserializeSourceTileWMS(/** @type any */ (options).source, tileLoadFunction); + } else if (sourceType === 'WMTS') { + options.source = this.deserializeSourceWMTS(/** @type any */ (options).source, tileLoadFunction); + } + return new OlLayerTile(options); + } +}; + +const exports = SerDes; + + +export default exports; diff --git a/src/offline/ServiceManager.js b/src/offline/ServiceManager.js new file mode 100644 index 000000000000..5772ae68173a --- /dev/null +++ b/src/offline/ServiceManager.js @@ -0,0 +1,116 @@ +import angular from 'angular'; + + +class ServiceManager { + + /** + * @param {angular.auto.IInjectorService} $injector Main injector. + * @struct + * @ngInject + * @ngdoc service + * @ngname ngeoOfflineServiceManager + */ + constructor($injector) { + + /** + * @type {angular.auto.IInjectorService} + * @private + */ + this.$injector_ = $injector; + + /** + * @type {*} + * @private + */ + this.saveService_ = null; + + /** + * @type {*} + * @private + */ + this.restoreService_ = null; + } + + /** + * @param {string|Object} serviceLike A service like. + * @param {string} method A method. + * @return {Object} A returned object. + */ + getOfflineService_(serviceLike, method) { + if (typeof serviceLike === 'string') { + if (!this.$injector_.has(serviceLike)) { + console.error(`The offline ${method} service could not be found`); + return; + } + const service = this.$injector_.get(serviceLike); + if (!service[method]) { + console.error(`The offline service ${serviceLike} does not have a ${method} method`); + return; + } + return service; + } + if (!serviceLike[method]) { + console.error(`The provided offline service does not have a ${method} method`); + return; + } + return serviceLike; + } + + /** + * Set the service to call on 'save'. + * @param {string|{save: Function}} saveLikeService + * A service name that can be injected or an object that have a 'save' method. + */ + setSaveService(saveLikeService) { + this.saveService_ = this.getOfflineService_(saveLikeService, 'save'); + } + + /** + * Set the service to call on 'restore' + * @param {string|{restore: Function}} restoreLikeService + * A service name that can be injected or an object that have a 'restore' method. + */ + setRestoreService(restoreLikeService) { + this.restoreService_ = this.getOfflineService_(restoreLikeService, 'restore'); + } + + cancel() { + if (!this.saveService_) { + console.warn('You must register a saveService first'); + return; + } + this.saveService_.cancel(); + } + + /** + * Ask the provided service to save the data to an offline purpose + * @param {import("ol/extent.js").Extent} extent The extent to dowload. + * @param {import("ol/Map").default} map The map to work on. + */ + save(extent, map) { + if (!this.saveService_) { + console.warn('You must register a saveService first'); + return; + } + this.saveService_.save(extent, map); + } + + /** + * Ask the provided service to restore the saved data on the map + * @param {import("ol/Map.js").default} map The map to work on. + * @return {Promise} A promise to the extent of the downloaded area + */ + restore(map) { + if (!this.restoreService_) { + console.warn('You must register a restoreService first'); + return Promise.reject(); + } + return this.restoreService_.restore(map); + } +} + +ServiceManager.module = angular.module('ngeoOfflineServiceManager', []); +ServiceManager.module.service('ngeoOfflineServiceManager', ServiceManager); + + +export default ServiceManager; diff --git a/src/offline/TilesDownloader.js b/src/offline/TilesDownloader.js new file mode 100644 index 000000000000..38daff949988 --- /dev/null +++ b/src/offline/TilesDownloader.js @@ -0,0 +1,196 @@ +/** + * @param {!Blob} blob A blob + * @return {Promise} data URL + */ +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function() { + resolve(/** @type String */ (reader.result)); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +const exports = class { + + /** + * @param {Array} tiles An array of tiles to download. + * @param {import("./index.js").OfflineOnTileDownload} callbacks The callbacks. + * @param {number} workers The maximum number of workers. + */ + constructor(tiles, callbacks, workers) { + /** + * @private + * @type {number} + */ + this.maxNumberOfWorkers_ = workers; + + /** + * @private + */ + this.wasStarted_ = false; + + /** + * @type {Array} + * @private + */ + this.tiles_ = tiles; + + /** + * @private + * @type {import("./index.js").OfflineOnTileDownload} + */ + this.callbacks_ = callbacks; + + /** + * @private + */ + this.allCount_ = 0; + + /** + * @private + */ + this.okCount_ = 0; + + /** + * @private + */ + this.koCount_ = 0; + + /** + * @private + */ + this.requestedCount_ = 0; + + /** + * @private + * @type {function(): any} + */ + this.resolvePromise_; + + /** + * @private + * @type {Promise} + */ + this.promise_ = null; + + /** + * @type {number} + * @private + */ + this.tileIndex_ = 0; + + /** + * @type {boolean} + * @private + */ + this.cancel_ = false; + } + + cancel() { + this.cancel_ = true; + } + + /** + * @private to download. + */ + downloadTile_() { + if (this.cancel_ || this.tileIndex_ >= this.tiles_.length) { + return; + } + const tile = this.tiles_[this.tileIndex_++]; + const tileUrl = tile.url; + const xhr = new XMLHttpRequest(); + xhr['tileUrl'] = tile.url; + xhr.open('GET', tileUrl, true); + xhr.responseType = 'blob'; + const onTileDownloaded = () => { + if (this.allCount_ === this.tiles_.length) { + this.resolvePromise_(); + } + this.downloadTile_(); + }; + + const errorCallback = (e) => { + if (this.cancel_) { + return; + } + ++this.allCount_; + ++this.koCount_; + const progress = this.allCount_ / this.tiles_.length; + this.callbacks_.onTileDownloadError(progress).then(onTileDownloaded, onTileDownloaded); + }; + + const onloadCallback = (e) => { + /** + * @type {Blob} + */ + const response = e.target.response; + if (response && response.size !== 0) { // non-empty tile + blobToDataUrl(response).then( + (dataUrl) => { + if (this.cancel_) { + return; + } + ++this.allCount_; + ++this.okCount_; + tile.response = dataUrl; + const progress = this.allCount_ / this.tiles_.length; + this.callbacks_.onTileDownloadSuccess(progress, tile).then(onTileDownloaded, onTileDownloaded); + }, + () => { + if (this.cancel_) { + return; + } + errorCallback(e); + } + ); + } else { + if (this.cancel_) { + return; + } + ++this.allCount_; + ++this.okCount_; + this.callbacks_.onTileDownloadSuccess(this.allCount_ / this.tiles_.length, tile).then( + onTileDownloaded, onTileDownloaded); + } + }; + + xhr.onload = onloadCallback; + xhr.onerror = errorCallback; + xhr.onabort = errorCallback; + xhr.ontimeout = errorCallback; + xhr.send(); + ++this.requestedCount_; + } + + /** + * @return {Promise} A promise that resolves when the downloads are complete (failing or not) + */ + download() { + if (this.promise_) { + return this.promise_; + } + + this.promise_ = new Promise((resolve, reject) => { + this.resolvePromise_ = resolve; + }); + + console.assert(this.tiles_); + if (this.tiles_.length === 0) { + this.callbacks_.onTileDownloadError(1); // forcing progress update + this.resolvePromise_(); + } else { + for (let i = 0; i < this.maxNumberOfWorkers_; ++i) { + this.downloadTile_(); + } + } + + return this.promise_; + } +}; + + +export default exports; diff --git a/src/offline/component.html b/src/offline/component.html new file mode 100644 index 000000000000..672190a52354 --- /dev/null +++ b/src/offline/component.html @@ -0,0 +1,129 @@ +
+ +
+
+ +
+
+
+ +
+
Save map
+
Abort
+
+ + +
+
{{$ctrl.progressPercents}}%
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/offline/component.js b/src/offline/component.js new file mode 100644 index 000000000000..ca22571117bb --- /dev/null +++ b/src/offline/component.js @@ -0,0 +1,585 @@ +import ngeoMapFeatureOverlayMgr from 'ngeo/map/FeatureOverlayMgr.js'; +import ngeoMessageModalComponent from 'ngeo/message/modalComponent.js'; +import {extentToRectangle} from 'ngeo/utils.js'; +import OlCollection from 'ol/Collection.js'; +import {unByKey} from 'ol/Observable.js'; +import OlFeature from 'ol/Feature.js'; +import OlGeomPolygon from 'ol/geom/Polygon.js'; +import olGeomGeometryLayout from 'ol/geom/GeometryLayout.js'; +import {DEVICE_PIXEL_RATIO} from 'ol/has.js'; +import angular from 'angular'; + +/** + * @type {!angular.IModule} + */ +const module = angular.module('ngeoOffline', [ + ngeoMapFeatureOverlayMgr.name, + ngeoMessageModalComponent.name +]); + +module.value('ngeoOfflineTemplateUrl', + /** + * @param {JQuery} element Element. + * @param {angular.IAttributes} attrs Attributes. + * @return {string} Template URL. + */ + (element, attrs) => { + const templateUrl = attrs['ngeoOfflineTemplateurl']; + return templateUrl !== undefined ? templateUrl : + 'ngeo/offline/component.html'; + }); + +module.run(/* @ngInject */ ($templateCache) => { + // @ts-ignore: webpack + $templateCache.put('ngeo/offline/component.html', require('./component.html')); +}); + +/** + * @param {!JQuery} $element Element. + * @param {!angular.IAttributes} $attrs Attributes. + * @param {!function(!JQuery, !angular.IAttributes): string} ngeoOfflineTemplateUrl Template function. + * @return {string} Template URL. + * @ngInject + */ +function ngeoOfflineTemplateUrl($element, $attrs, ngeoOfflineTemplateUrl) { + return ngeoOfflineTemplateUrl($element, $attrs); +} + + +/** + * Provides the "offline" component. + * + * Example: + * + * + * ngeo-offline-mask-margin="::100" + * ngeo-offline-min_zoom="::11" + * ngeo-offline-max_zoom="::15" + * + * + * See our live example: [../examples/offline.html](../examples/offline.html) + * + * @htmlAttribute {import("ol/Map.js").default} ngeo-offline-map The map. + * @htmlAttribute {number} ngeo-offline-extentsize The size, in map units, of a side of the extent. + * @private + * @ngdoc component + * @ngname ngeoOffline + */ +const component = { + bindings: { + 'map': '} + * @private + */ + this.overlayCollection_ = new OlCollection(); + + this.featuresOverlay_.setFeatures(this.overlayCollection_); + + /** + * @type {function(import("ol/render/Event").default):any} + */ + this.postcomposeListener_; + + /** + * @type {import("ol/events.js").EventsKey|Array.} + * @private + */ + this.postComposeListenerKey_ = null; + + + /** + * @type {OlGeomPolygon} + * @private + */ + this.dataPolygon_ = null; + + /** + * Whether the current view is the extent selection. + * @type {boolean} + * @export + */ + this.selectingExtent = false; + + /** + * Whether the current view is downloading one. + * @type {boolean} + * @export + */ + this.downloading = false; + + /** + * The progression of the data loading (0-100%). + * @type {number} + * @export + */ + this.progressPercents = 0; + + /** + * Whether the menu is currently displayed. + * @type {boolean} + * @export + */ + this.menuDisplayed = false; + + /** + * Whether the cancel download modal is displayed. + * @type {boolean} + * @export + */ + this.displayAlertAbortDownload = false; + + /** + * Whether the load data modal is displayed. + * @type {boolean} + * @export + */ + this.displayAlertLoadData = false; + + /** + * Whether the "no layer" modal is displayed. + * @type {boolean} + * @export + */ + this.displayAlertNoLayer = false; + + /** + * Offline mask minimum margin in pixels. + * @type {number} + * @export + */ + this.maskMargin; + + /** + * Minimum zoom where offline is enable. + * @type {number} + * @export + */ + this.minZoom; + + /** + * Maximum zoom where offline is enable. + * @type {number} + * @export + */ + this.maxZoom; + + /** + * Map view max zoom constraint. + * @type {number} + * @export + */ + this.originalMinZoom; + + /** + * Map view min zoom constraint. + * @type {number} + * @export + */ + this.originalMaxZoom; + + /** + * @type {number} + * @export + */ + this.estimatedLoadDataSize; + + /** + * @private + * @param {import("ngeo/CustomEvent").default} event the progress event. + */ + this.progressCallback_ = (event) => { + const progress = event.detail['progress']; + this.progressPercents = Math.floor(progress * 100); + if (progress === 1) { + this.finishDownload_(); + } + this.$timeout_(() => {}, 0); // FIXME: force redraw + }; + } + + $onInit() { + this.offlineMode.registerComponent(this); + this.postcomposeListener_ = this.createMaskPostcompose_(); + this.ngeoOfflineConfiguration_.on('progress', this.progressCallback_); + this.maskMargin = this.maskMargin || 100; + this.minZoom = this.minZoom || 10; + this.maxZoom = this.maxZoom || 15; + } + + $onDestroy() { + this.ngeoOfflineConfiguration_.un('progress', this.progressCallback_); + } + + /** + * @return {boolean} True if data are accessible offline. + * @export + */ + hasData() { + return this.ngeoOfflineConfiguration_.hasOfflineData(); + } + + /** + * @export + */ + computeSizeAndDisplayAlertLoadData() { + this.estimatedLoadDataSize = this.ngeoOfflineConfiguration_.estimateLoadDataSize(this.map); + if (this.estimatedLoadDataSize > 0) { + this.displayAlertLoadData = true; + } else { + this.displayAlertNoLayer = true; + } + } + /** + * Toggle the selecting extent view. + * @param {boolean=} finished If just finished downloading. + * @export + */ + toggleViewExtentSelection(finished) { + this.menuDisplayed = false; + this.selectingExtent = !this.selectingExtent; + + if (this.postComposeListenerKey_) { + unByKey(this.postComposeListenerKey_); + this.postComposeListenerKey_ = null; + this.removeZoomConstraints_(); + } + if (this.selectingExtent && !this.postComposeListenerKey_) { + this.addZoomConstraints_(); + this.postComposeListenerKey_ = this.map.on('postcompose', this.postcomposeListener_); + } + this.map.render(); + } + + /** + * Validate the current extent and download data. + * @export + */ + validateExtent() { + this.progressPercents = 0; + const extent = this.getDowloadExtent_(); + this.downloading = true; + this.ngeoOfflineServiceManager_.save(extent, this.map); + } + + + /** + * @private + */ + finishDownload_() { + this.downloading = false; + this.toggleViewExtentSelection(true); + } + + /** + * Ask to abort the download of data. + * @export + */ + askAbortDownload() { + this.displayAlertAbortDownload = true; + } + + /** + * Abort the download of data. + * @export + */ + abortDownload() { + this.downloading = false; + this.ngeoOfflineServiceManager_.cancel(); + this.deleteData(); + } + + /** + * Show the main menu. + * @export + */ + showMenu() { + this.menuDisplayed = true; + } + + /** + * Activate offline mode. + * Zoom to the extent of that data and restore the data. + * @export + */ + activateOfflineMode() { + this.ngeoOfflineServiceManager_.restore(this.map).then((extent) => { + this.dataPolygon_ = this.createPolygonFromExtent_(extent); + const size = /** @type {import("ol/size.js").Size} */ (this.map.getSize()); + this.map.getView().fit(extent, {size}); + this.menuDisplayed = false; + this.displayExtent_(); + this.offlineMode.enable(); + }); + } + + /** + * + * Deactivate offline mode. + * Reload the page. + * @export + */ + deactivateOfflineMode() { + window.location.reload(); + } + + /** + * Toggle the visibility of the data's extent. + * @export + */ + toggleExtentVisibility() { + if (this.isExtentVisible()) { + this.overlayCollection_.clear(); + } else { + this.displayExtent_(); + } + } + + /** + * @return {boolean} True if the extent is currently visible. False otherwise. + * @export + */ + isExtentVisible() { + return this.overlayCollection_.getLength() > 0; + } + + /** + * Delete the saved data. + * @export + */ + deleteData() { + this.overlayCollection_.clear(); + this.dataPolygon_ = null; + if (this.networkStatus.isDisconnected()) { + this.menuDisplayed = false; + } + + const reloadIfInOfflineMode = () => { + if (this.offlineMode.isEnabled()) { + this.deactivateOfflineMode(); + } + }; + this.ngeoOfflineConfiguration_.clear().then(reloadIfInOfflineMode); + } + + /** + * @private + */ + displayExtent_() { + if (!this.isExtentVisible()) { + const feature = new OlFeature(this.dataPolygon_); + this.overlayCollection_.push(feature); + } + } + + /** + * When enabling mask extent, zoom the view to the defined zoom range and + * add constraints to the view to not allow user to move out of this range. + * @private + */ + addZoomConstraints_() { + const view = this.map.getView(); + const zoom = view.getZoom(); + + this.originalMinZoom = view.getMinZoom(); + this.originalMaxZoom = view.getMaxZoom(); + + if (zoom < this.minZoom) { + view.setZoom(this.minZoom); + } else if (zoom > this.maxZoom) { + view.setZoom(this.maxZoom); + } + view.setMaxZoom(this.maxZoom); + view.setMinZoom(this.minZoom); + } + + /** + * @private + */ + removeZoomConstraints_() { + const view = this.map.getView(); + view.setMaxZoom(this.originalMaxZoom); + view.setMinZoom(this.originalMinZoom); + } + + /** + * @return {function(import("ol/render/Event").default):any} Function to use as a map postcompose listener. + * @private + */ + createMaskPostcompose_() { + return (evt) => { + const context = evt.context; + const frameState = evt.frameState; + const resolution = frameState.viewState.resolution; + + const viewportWidth = frameState.size[0] * frameState.pixelRatio; + const viewportHeight = frameState.size[1] * frameState.pixelRatio; + + const extentLength = this.extentSize ? + this.extentSize / resolution * DEVICE_PIXEL_RATIO : + Math.min(viewportWidth, viewportHeight) - this.maskMargin * 2; + + const extentHalfLength = Math.ceil(extentLength / 2); + + // Draw a mask on the whole map. + context.beginPath(); + context.moveTo(0, 0); + context.lineTo(viewportWidth, 0); + context.lineTo(viewportWidth, viewportHeight); + context.lineTo(0, viewportHeight); + context.lineTo(0, 0); + context.closePath(); + + // Draw the get data zone + const extent = this.createExtent_([viewportWidth / 2, viewportHeight / 2], extentHalfLength); + + context.moveTo(extent[0], extent[1]); + context.lineTo(extent[0], extent[3]); + context.lineTo(extent[2], extent[3]); + context.lineTo(extent[2], extent[1]); + context.lineTo(extent[0], extent[1]); + context.closePath(); + + // Fill the mask + context.fillStyle = 'rgba(0, 5, 25, 0.5)'; + context.fill(); + }; + } + + /** + * A polygon on the whole extent of the projection, with a hole for the offline extent. + * @param {import("ol/extent.js").Extent} extent An extent + * @return {OlGeomPolygon} Polygon to save, based on the projection extent, the center of the map and + * the extentSize property. + * @private + */ + createPolygonFromExtent_(extent) { + const projExtent = this.map.getView().getProjection().getExtent(); + return new OlGeomPolygon([ + extentToRectangle(projExtent), + extentToRectangle(extent), + ], olGeomGeometryLayout.XY); + } + + /** + * @param {import("ol/coordinate.js").Coordinate} center, a xy point. + * @param {number} halfLength a half length of a square's side. + * @return {Array.} an extent. + * @private + */ + createExtent_(center, halfLength) { + const minx = center[0] - halfLength; + const miny = center[1] - halfLength; + const maxx = center[0] + halfLength; + const maxy = center[1] + halfLength; + return [minx, miny, maxx, maxy]; + } + + /** + * @return {import("ol/extent.js").Extent} the download extent. + * @private + */ + getDowloadExtent_() { + const center = /** @type {import("ol/coordinate.js").Coordinate}*/(this.map.getView().getCenter()); + const halfLength = Math.ceil(this.extentSize || this.getExtentSize_()) / 2; + return this.createExtent_(center, halfLength); + } + + getExtentSize_() { + const mapSize = this.map.getSize(); + const maskSizePixel = DEVICE_PIXEL_RATIO * Math.min(mapSize[0], mapSize[1]) - this.maskMargin * 2; + const maskSizeMeter = maskSizePixel * this.map.getView().getResolution() / DEVICE_PIXEL_RATIO; + return maskSizeMeter; + } +}; + + +module.controller('ngeoOfflineController', Controller); + + +export default module; diff --git a/src/offline/index.js b/src/offline/index.js new file mode 100644 index 000000000000..5af32292a576 --- /dev/null +++ b/src/offline/index.js @@ -0,0 +1,71 @@ + +/** + * @typedef {Object} OfflineExtentByZoom + * @property {number} zoom + * @property {import("ol/extent.js").Extent} extent + */ + + +/** + * @typedef {Object} OfflineLayerMetadata + * @property {import("ol/Map.js").default} map + * @property {Arra} extentByZoom + * @property {string} content + * @property {string} contentType + * @property {import("ol/layer/Layer.js").default} layer + * @property {import("ol/source/Source.js").default} source + * @property {string} layerType + * @property {string} layerSerialization + * @property {boolean} backgroundLayer + * @property {import("ol/layer/Group.js").default} ancestors + */ + + +/** + * @typedef {Object} OfflinePersistentLayer + * @property {string} layerType + * @property {string} layerSerialization + * @property {boolean} backgroundLayer + * @property {string} key + */ + + +/** + * @typedef {Object} OfflinePersistentContent + * @property {import("ol/extent.js").Extent} extent + * @property {!Array} layers + * @property {!Array} zooms + */ + + +/** + * @typedef {Object} OfflineTile + * @property {import("ol/coordinate.js").Coordinate} coord + * @property {string} url + * @property {string} response + */ + + +/** + * @callback onTileDownloadSuccess + * @param {number} progress + * @param {OfflineTile} tile + * @return {Promise} + */ + + +/** + * @callback onTileDownloadError + * @param {number} progress + * @return {Promise} + */ + + +/** + * @typedef {Object} OfflineOnTileDownload + * @property {onTileDownloadSuccess} onTileDownloadSuccess + * @property {onTileDownloadError} onTileDownloadError + */ + + +export default {}; diff --git a/src/offline/module.js b/src/offline/module.js new file mode 100644 index 000000000000..3ad7ec200fe6 --- /dev/null +++ b/src/offline/module.js @@ -0,0 +1,25 @@ +import ngeoOfflineComponent from 'ngeo/offline/component.js'; +import ngeoOfflineNetworkStatus from 'ngeo/offline/NetworkStatus.js'; +import ngeoOfflineServiceManager from 'ngeo/offline/ServiceManager.js'; +import downloader from 'ngeo/offline/Downloader.js'; +import restorer from 'ngeo/offline/Restorer.js'; +import mode from 'ngeo/offline/Mode.js'; +import angular from 'angular'; + + +/** + * @type {!angular.IModule} + */ +const exports = angular.module('ngeoOfflineModule', [ + ngeoOfflineComponent.name, + ngeoOfflineNetworkStatus.module.name, + ngeoOfflineServiceManager.module.name, + downloader.module.name, + restorer.module.name, + mode.module.name +]); + +exports.value('ngeoOfflineGutter', 96); + + +export default exports; diff --git a/src/offline/utils.js b/src/offline/utils.js new file mode 100644 index 000000000000..87c9cfaa3323 --- /dev/null +++ b/src/offline/utils.js @@ -0,0 +1,32 @@ +const exports = {}; +import olLayerGroup from 'ol/layer/Group.js'; + + +/** + * @param {import("ol/layer/Base.js").default} layer A layer tree. + * @param {!Array} ancestors The groups to which the layer belongs to. + * @param {function(import("ol/layer/Base.js").default, Array): boolean} + * visitor A function which will return false if descend must stop. + */ +exports.traverseLayer = function(layer, ancestors, visitor) { + const descend = visitor(layer, ancestors); + if (descend && layer instanceof olLayerGroup) { + layer.getLayers().forEach((childLayer) => { + exports.traverseLayer(childLayer, [...ancestors, layer], visitor); + }); + } +}; + +const extractor = new RegExp('[^/]*//[^/]+/(.*)'); +/** + * Extract the part after the URL authority. + * @param {string} url A URL to normalize + * @return {string} The normalized string. + */ +exports.normalizeURL = function(url) { + const matches = url.match(extractor); + return matches[1]; +}; + + +export default exports; diff --git a/src/print/VectorEncoder.js b/src/print/VectorEncoder.js index 966e1113038a..3ffd1368ee9b 100644 --- a/src/print/VectorEncoder.js +++ b/src/print/VectorEncoder.js @@ -71,6 +71,9 @@ VectorEncoder.prototype.encodeVectorLayer = function(arr, layer, resolution) { for (let i = 0, ii = features.length; i < ii; ++i) { const originalFeature = features[i]; + /** + * @type {import("ol/style/Style.js").default|Array} + */ let styleData = null; const styleFunction = originalFeature.getStyleFunction() || layer.getStyleFunction(); if (styleFunction !== undefined) { @@ -80,8 +83,7 @@ VectorEncoder.prototype.encodeVectorLayer = function(arr, layer, resolution) { /** * @type {Array} */ - const styles = (styleData !== null && !Array.isArray(styleData)) ? [styleData] : styleData; - console.assert(Array.isArray(styles)); + const styles = Array.isArray(styleData) ? styleData : styleData === null ? null : [styleData]; if (styles !== null && styles.length > 0) { let isOriginalFeatureAdded = false; diff --git a/src/search/createGeoJSONBloodhound.js b/src/search/createGeoJSONBloodhound.js index 75fee4fa4ae5..9d4274cb2b49 100644 --- a/src/search/createGeoJSONBloodhound.js +++ b/src/search/createGeoJSONBloodhound.js @@ -6,7 +6,6 @@ import olFormatGeoJSON from 'ol/format/GeoJSON.js'; import 'corejs-typeahead'; - /** * @param {string} url an URL to a search service. * @param {(function(GeoJSON.Feature): boolean)=} opt_filter function to filter results. diff --git a/src/utils.js b/src/utils.js index b8f4b0cdc9fa..688aa55c1e11 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,11 +1,11 @@ -import {platformModifierKeyOnly, singleClick} from 'ol/events/condition.js'; +import {noModifierKeys, singleClick} from 'ol/events/condition.js'; import olGeomLineString from 'ol/geom/LineString.js'; import olGeomMultiPoint from 'ol/geom/MultiPoint.js'; import olGeomMultiLineString from 'ol/geom/MultiLineString.js'; import olGeomMultiPolygon from 'ol/geom/MultiPolygon.js'; import olGeomPoint from 'ol/geom/Point.js'; import olGeomPolygon from 'ol/geom/Polygon.js'; - +import {getTopLeft, getTopRight, getBottomLeft, getBottomRight} from 'ol/extent.js'; /** * Utility method that converts a simple geometry to its multi equivalent. If @@ -122,5 +122,22 @@ export function encodeQueryString(queryData) { * @hidden */ export function deleteCondition(event) { - return platformModifierKeyOnly(event) && singleClick(event); + return noModifierKeys(event) && singleClick(event); +} + +/** + * Takes an import("ol/extent.js").Extent and return an Array of + * ol.Coordinate representing a rectangle polygon. + * @param {import("ol/extent.js").Extent} extent The extent. + * @return {Array.} The Array of coordinate of the rectangle. + */ +export function extentToRectangle(extent) { + const result = [ + getTopLeft(extent), + getTopRight(extent), + getBottomRight(extent), + getBottomLeft(extent), + getTopLeft(extent), + ]; + return result; } diff --git a/tsconfig.json b/tsconfig.json index a772827dccbd..c072f0790cd2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,7 @@ // "ol/*": ["node_modules/ol/*"], // Temporary, run that to use it: // npm install --prefix openlayers_src https://api.github.com/repos/openlayers/openlayers/tarball/v5.3.1 + "localforage/*": ["node_modules/localforage/*"], "ol/*": ["openlayers_src/node_modules/ol/src/ol/*"], "olcs/*": ["node_modules/ol-cesium/src/olcs/*"], "@geoblocks/proj/*": ["node_modules/@geoblocks/proj/*"],