diff --git a/.eslintrc.yaml b/.eslintrc.yaml index ac33066afd55..b3a6251712c5 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -18,3 +18,10 @@ rules: max-len: - error - code: 110 +globals: + angular: false + Cesium: false + google: false + Bloodhound: false + DateFormatter: false + localforage: false diff --git a/examples/offline.html b/examples/offline.html new file mode 100644 index 000000000000..b43760221132 --- /dev/null +++ b/examples/offline.html @@ -0,0 +1,21 @@ + + + + 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..adff7405c894 --- /dev/null +++ b/examples/offline.js @@ -0,0 +1,88 @@ +/** + * @module app.offline + */ +const exports = {}; + +import './offline.less'; +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'; + + +// Useful to work on example - remove me later +import 'bootstrap/js/modal.js'; +import 'jquery-ui/ui/widgets/resizable.js'; +import 'jquery-ui/ui/widgets/draggable.js'; + +/** @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 {ngeoFeatureOverlayMgr} ngeoFeatureOverlayMgr ngeo feature overlay manager service. + * @param {ngeoNetworkStatus} 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 {ol.Map} + * @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/examples/offline.less b/examples/offline.less new file mode 100644 index 000000000000..39dff172ae64 --- /dev/null +++ b/examples/offline.less @@ -0,0 +1,74 @@ +@import "~bootstrap/dist/css/bootstrap.css"; +@import "~font-awesome/less/font-awesome.less"; + +#map { + width: 600px; + height: 400px; + position: relative; +} + +ngeo-offline { + div { + z-index: 1; + } + .main-button { + position: absolute; + right: 1rem; + bottom: 5rem; + cursor: pointer; + .no-data { + color: black; + } + .with-data { + color: red; + } + .no-data, .with-data { + background-color: white; + text-align: center; + font-size: 2.5rem; + line-height: 2rem; + border-radius: 2rem; + font-family: FontAwesome; + &::after { + content: @fa-var-arrow-circle-o-down; + } + } + } + + .validate-extent { + position: absolute; + bottom: 0.5rem; + width: 10rem; + left: calc(~"50% - 5rem"); + } + + .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; + } + + .modal-content { + width: 30rem; + } + + .modal-body { + button { + display: block; + margin: 0.5rem auto; + width: 25rem; + } + } +} + +.offline-msg { + display: none; +} +.offline .offline-msg { + display: block; +} diff --git a/package.json b/package.json index 00bdb0cffbcc..1a352ce9bd09 100644 --- a/package.json +++ b/package.json @@ -128,5 +128,7 @@ "webpack-dev-server": "3.3.1", "webpack-merge": "4.2.1" }, - "dependencies": {} + "dependencies": { + "localforage": "^1.7.3" + } } diff --git a/src/message/modalComponent.js b/src/message/modalComponent.js index a20faaf3a7d2..904137161bde 100644 --- a/src/message/modalComponent.js +++ b/src/message/modalComponent.js @@ -1,6 +1,9 @@ -import angular from 'angular'; +/** + * @module ngeo.message.modalComponent + */ import 'jquery-ui/ui/widgets/draggable.js'; import 'bootstrap/js/src/modal.js'; +import angular from 'angular'; /** diff --git a/src/offline/AbstractLocalforageWrapper.js b/src/offline/AbstractLocalforageWrapper.js new file mode 100644 index 000000000000..f5e76d4a905f --- /dev/null +++ b/src/offline/AbstractLocalforageWrapper.js @@ -0,0 +1,108 @@ +/** + * @module ngeo.offline.AbstractLocalforageWrapper + */ +/** + * @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..75fa0c65e1fd --- /dev/null +++ b/src/offline/Configuration.js @@ -0,0 +1,353 @@ +/** + * @module ngeo.offline.Configuration + */ +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 * as realLocalforage from 'localforage'; + +/** + * @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 {ngeo.offline.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 realLocalforage; + } + + 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 {!ol.Map} map A map + * @return {number} An "estimation" of the size of the data to download + */ + estimateLoadDataSize(map) { + return 50; + } + + /** + * @param {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 {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 {ol.Map} map A map + * @param {ol.layer.Layer} layer A layer + * @param {Array} ancestors The ancestors of that layer + * @param {ol.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 {ol.source.Source} source An ImageWMS source + * @param {ol.proj.Projection} projection The projection + * @return {ol.source.Source} 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 {ol.Map} map The map to work on. + * @param {ol.Extent} userExtent The extent selected by the user. + * @return {!Array} the downloadable layers and metadata. + */ + createLayerMetadatas(map, userExtent) { + const layersItems = []; + + /** + * @param {ol.layer.Base} 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 {ngeox.OfflinePersistentLayer} offlineLayer The offline layer + * @return {function(ol.ImageTile, string)} the tile function + */ + createTileLoadFunction_(offlineLayer) { + const that = this; + /** + * Load the tile from persistent storage. + * @param {ol.ImageTile} 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 + content = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + } + imageTile.getImage().src = content; + }); + }; + return tileLoadFunction; + } + + /** + * @param {ngeox.OfflinePersistentLayer} offlineLayer The layer to recreate + * @return {ol.layer.Layer} 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..675cbd622eb6 --- /dev/null +++ b/src/offline/Downloader.js @@ -0,0 +1,157 @@ +/** + * @module ngeo.offline.Downloader + */ +import {DEVICE_PIXEL_RATIO} from 'ol/has.js'; +import googAsserts from 'goog/asserts.js'; +import olSourceTileWMS from 'ol/source/TileWMS.js'; +import olSourceWMTS from 'ol/source/WMTS.js'; +import TilesDownloader from 'ngeo/offline/TilesDownloader.js'; + + +/** + * @param {ol.Coordinate} a Some coordinates. + * @param {ol.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 {ngeo.offline.Configuration} ngeoOfflineConfiguration A service for customizing offline behaviour. + */ + constructor(ngeoOfflineConfiguration) { + /** + * @private + * @type {ngeo.offline.Configuration} + */ + this.configuration_ = ngeoOfflineConfiguration; + + /** + * @type {TilesDownloader} + * @private + */ + this.tileDownloader_ = null; + } + + cancel() { + this.tileDownloader_.cancel(); + } + + /** + * @param {ngeox.OfflineLayerMetadata} layerMetadata Layers metadata. + * @param {Array} queue Queue of tiles to download. + */ + queueLayerTiles_(layerMetadata, queue) { + const {map, source, extentByZoom} = layerMetadata; + + if (!source) { + return; + } + googAsserts.assert(source instanceof olSourceTileWMS || source instanceof olSourceWMTS); + const projection = map.getView().getProjection(); + const tileGrid = source.getTileGrid(); + const tileUrlFunction = source.getTileUrlFunction(); + + googAsserts.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) { + minX = coord[1]; + minY = coord[2]; + } + const url = tileUrlFunction(coord, DEVICE_PIXEL_RATIO, projection); + googAsserts.assert(url); + + /** + * @type {ngeox.OfflineTile} + */ + const tile = {coord, url}; + queueByZ.push(tile); + }); + + 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 {ol.Extent} extent The extent to download. + * @param {ol.Map} 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 {ngeox.OfflinePersistentContent} + */ + const persistentObject = { + extent: extent, + layers: persistentLayers, + zooms: zooms.sort((a, b) => (a < b ? -1 : 1)) + }; + const setOfflineContentPromise = this.configuration_.setItem('offline_content', persistentObject); + + this.tileDownloader_ = new TilesDownloader(queue, this.configuration_, this.configuration_.getMaxNumberOfParallelDownloads()); + 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..ab2121b406c4 --- /dev/null +++ b/src/offline/LocalforageAndroidWrapper.js @@ -0,0 +1,33 @@ +/** + * @module ngeo.offline.LocalforageAndroidWrapper + */ +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..7cccd05f4f01 --- /dev/null +++ b/src/offline/LocalforageCordovaWrapper.js @@ -0,0 +1,21 @@ +/** + * @module ngeo.offline.LocalforageCordovaWrapper + */ +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..09435c3f39df --- /dev/null +++ b/src/offline/LocalforageIosWrapper.js @@ -0,0 +1,37 @@ +/** + * @module ngeo.offline.LocalforageIosWrapper + */ +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..2ab306310ac6 --- /dev/null +++ b/src/offline/Mode.js @@ -0,0 +1,89 @@ +/** + * @module ngeo.offline.Mode + */ + +import angular from 'angular'; + + +const exports = class { + + /** + * @param {ngeo.offline.Configuration} 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 {ngeo.offline.component.Controller|undefined} + * @private + */ + this.component_; + + /** + * @private + * @type {ngeo.offline.Configuration} + */ + 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 {ngeo.offline.component.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} + */ +exports.module = angular.module('ngeoOfflineMode', []); +exports.module.service('ngeoOfflineMode', exports); + + +export default exports; diff --git a/src/offline/NetworkStatus.js b/src/offline/NetworkStatus.js new file mode 100644 index 000000000000..c6ffea650910 --- /dev/null +++ b/src/offline/NetworkStatus.js @@ -0,0 +1,213 @@ +/** + * @module ngeo.offline.NetworkStatus + */ +import ngeoMiscDebounce from 'ngeo/misc/debounce.js'; +import angular from 'angular'; + + +/** + * @ngInject + * @param {angular.IQService} $q The Angular $q service. + * @param {ngeoMiscDebounce} ngeoDebounce ngeo debounce service. + * @param {ngeo.offline.NetworkStatus} 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); + } + }; +}; + +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). + 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.factory('httpInterceptor', httpInterceptor); +Service.module.service(name, Service); + +/** + * @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..2f98a75e34fc --- /dev/null +++ b/src/offline/Restorer.js @@ -0,0 +1,66 @@ +/** + * @module ngeo.offline.Restorer + */ +import ngeoMapBackgroundLayerMgr from 'ngeo/map/BackgroundLayerMgr.js'; +import angular from 'angular'; + + +class Restorer { + + /** + * @ngInject + * @param {ngeo.offline.Configuration} ngeoOfflineConfiguration A service for customizing offline behaviour. + * @param {ngeo.map.BackgroundLayerMgr} ngeoBackgroundLayerMgr The background layer manager. + */ + constructor(ngeoOfflineConfiguration, ngeoBackgroundLayerMgr) { + /** + * @private + * @type {ngeo.offline.Configuration} + */ + this.configuration_ = ngeoOfflineConfiguration; + + /** + * @private + * @type {ngeo.map.BackgroundLayerMgr} + */ + this.ngeoBackgroundLayerMgr_ = ngeoBackgroundLayerMgr; + } + + /** + * @param {ol.Map} 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 {ol.Map} map A map + * @param {ngeox.OfflinePersistentContent} offlineContent The offline content + * @return {ol.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.module.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..19df1655457c --- /dev/null +++ b/src/offline/SerializerDeserializer.js @@ -0,0 +1,222 @@ +/** + * @module ngeo.offline.SerializerDeserializer + */ +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 {ol.Object} 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 {ol.tilegrid.TileGrid} 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 {ol.tilegrid.TileGrid} tilegrid + */ + deserializeTilegrid(serialization) { + const options = /** @type {olx.tilegrid.TileGridOptions} */ (JSON.parse(serialization)); + return new olTilegridTileGrid(options); + } + + /** + * @param {ol.tilegrid.WMTS} 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 {ol.tilegrid.WMTS} tilegrid . + */ + deserializeTilegridWMTS(serialization) { + const options = /** @type {olx.tilegrid.WMTSOptions} */ (JSON.parse(serialization)); + return new olTilegridWMTS(options); + } + + + /** + * @param {ol.source.TileWMS} 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(ol.ImageTile, string)=} tileLoadFunction . + * @return {ol.source.TileWMS} source . + */ + deserializeSourceTileWMS(serialization, tileLoadFunction) { + const options = /** @type {olx.source.TileWMSOptions} */ (JSON.parse(serialization)); + options['tileLoadFunction'] = tileLoadFunction; + if (options['tileGrid']) { + options['tileGrid'] = this.deserializeTilegrid(options['tileGrid']); + } + options['gutter'] = this.gutter_; + return new olSourceTileWMS(options); + } + + /** + * @param {ol.source.WMTS} 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 {ol.tilegrid.WMTS} */ (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(ol.ImageTile, string)=} tileLoadFunction . + * @return {ol.source.WMTS} . + */ + deserializeSourceWMTS(serialization, tileLoadFunction) { + const options = /** @type {olx.source.WMTSOptions} */ (JSON.parse(serialization)); + options['tileLoadFunction'] = tileLoadFunction; + if (options['tileGrid']) { + options['tileGrid'] = this.deserializeTilegridWMTS(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 {ol.layer.Tile|ol.layer.Image} layer . + * @param {ol.source.Source=} 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(ol.ImageTile, string)=} tileLoadFunction . + * @return {ol.layer.Tile} . + */ + deserializeTileLayer(serialization, tileLoadFunction) { + const options = /** @type {olx.layer.TileOptions} */ (JSON.parse(serialization)); + const sourceType = options['sourceType']; + if (sourceType === 'tileWMS') { + options['source'] = this.deserializeSourceTileWMS(options['source'], tileLoadFunction); + } else if (sourceType === 'WMTS') { + options['source'] = this.deserializeSourceWMTS(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..08c1d07f8446 --- /dev/null +++ b/src/offline/ServiceManager.js @@ -0,0 +1,121 @@ +/** + * @module ngeo.offline.ServiceManager + */ + +import angular from 'angular'; + + +const exports = class { + + /** + * @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 {ol.Extent} extent The extent to dowload. + * @param {ol.Map} 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 {ol.Map} 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); + } +}; + +/** + * @type {!angular.IModule} + */ +exports.module = angular.module('ngeoOfflineServiceManager', []); +exports.module.service('ngeoOfflineServiceManager', exports); + + +export default exports; diff --git a/src/offline/TilesDownloader.js b/src/offline/TilesDownloader.js new file mode 100644 index 000000000000..8d616c3ce3fd --- /dev/null +++ b/src/offline/TilesDownloader.js @@ -0,0 +1,201 @@ +/** + * @module ngeo.offline.TilesDownloader + */ +import googAsserts from 'goog/asserts.js'; + + +/** + * @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(reader.result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +const exports = class { + + /** + * @param {Array} tiles An array of tiles to download. + * @param {ngeox.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 {ngeox.OfflineOnTileDownload} + */ + this.callbacks_ = callbacks; + + /** + * @private + */ + this.allCount_ = 0; + + /** + * @private + */ + this.okCount_ = 0; + + /** + * @private + */ + this.koCount_ = 0; + + /** + * @private + */ + this.requestedCount_ = 0; + + /** + * @private + * @type {function()} + */ + 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; + }); + + googAsserts.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..6d5f89aa68af --- /dev/null +++ b/src/offline/component.js @@ -0,0 +1,586 @@ +/** + * @module ngeo.offline.component + */ +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 exports = angular.module('ngeoOffline', [ + ngeoMapFeatureOverlayMgr.name, + ngeoMessageModalComponent.name +]); + +exports.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'; + }); + +exports.run(/* @ngInject */ ($templateCache) => { + $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 {ol.Map} 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 + */ +exports.component_ = { + bindings: { + 'map': '} + * @private + */ + this.postComposeListenerKey_ = null; + + + /** + * @type {ol.geom.Polygon} + * @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 {ngeo.CustomEvent} 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 {ol.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(ol.render.Event)} 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 {ol.Extent} extent An extent + * @return {ol.geom.Polygon} 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 {ol.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 {Array.} the download extent. + * @private + */ + getDowloadExtent_() { + const center = /** @type {ol.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; + } +}; + + +exports.controller('ngeoOfflineController', exports.Controller); + + +export default exports; diff --git a/src/offline/module.js b/src/offline/module.js new file mode 100644 index 000000000000..0f360a9f276d --- /dev/null +++ b/src/offline/module.js @@ -0,0 +1,28 @@ +/** + * @module ngeo.offline.module + */ +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..cf4777139dc4 --- /dev/null +++ b/src/offline/utils.js @@ -0,0 +1,34 @@ +/** + * @module ngeo.offline.utils + */ +const exports = {}; +import olLayerGroup from 'ol/layer/Group.js'; + + +/** + * @param {ol.layer.Base} layer A layer tree. + * @param {!Array} ancestors The groups to which the layer belongs to. + * @param {function(ol.layer.Base, 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/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..fb8f8a094662 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,7 +5,7 @@ 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 @@ -124,3 +124,18 @@ export function encodeQueryString(queryData) { export function deleteCondition(event) { return platformModifierKeyOnly(event) && singleClick(event); } + +/** + * Takes an ol.Extent and return an Array of ol.Coordinate representing a rectangle polygon. + * @param {ol.Extent} extent The extent. + * @return {Array.} The Array of coordinate of the rectangle. + */ +export function extentToRectangle(extent) { + return [ + getTopLeft(extent), + getTopRight(extent), + getBottomRight(extent), + getBottomLeft(extent), + getTopLeft(extent), + ]; +}