UNPKG

@geogirafe/lib-geoportal

Version:

GeoGirafe is a flexible application to build online geoportals.

277 lines (276 loc) 10.9 kB
// SPDX-License-Identifier: Apache-2.0 import GirafeSingleton from '../../base/GirafeSingleton.js'; import { IndexedDbHelper } from '../sw/service-worker-tools.js'; import { Feature } from 'ol'; import VectorLayer from 'ol/layer/Vector.js'; import VectorSource from 'ol/source/Vector.js'; import { fromExtent } from 'ol/geom/Polygon.js'; import Style from 'ol/style/Style.js'; import { Stroke } from 'ol/style.js'; class OfflineManager extends GirafeSingleton { serviceWorker = null; indexedDbHelper = new IndexedDbHelper(); get map() { return this.context.mapManager.getMap(); } get state() { return this.context.stateManager.state; } async database() { const database = await this.indexedDbHelper.openDb(this.dbCacheName, this.storeVersion, this.upgradeIndexedDb); return database; } totalLength = 0; counter = 0; progressCallback; storeVersion; dbCacheName; tilesStoreName = 'tiles'; bboxStoreName = 'bbox'; vectorLayer = new VectorLayer({ style: new Style({ stroke: new Stroke({ color: 'red', width: 5 }) }) }); initializeSingleton() { this.map.addLayer(this.vectorLayer); } initializeOfflineState(isOffline) { this.registerEvents(); this.context.stateManager.state.isOffline = isOffline; } registerEvents() { globalThis.addEventListener('offline', () => { this.state.isOffline = true; }); globalThis.addEventListener('online', () => { this.state.isOffline = false; }); this.context.stateManager.subscribe('isOffline', () => { this.switchOffline(); }); } /** Exports all the WMTS tiles for the layers in parameter and store them to local cache */ async exportWMTSTiles(bbox, wmtsLayers, progressCallback) { this.progressCallback = progressCallback; await this.saveBoundingBox(bbox); const tileUrls = this.getAllTileUrls(bbox, wmtsLayers); await this.fetchAndSaveTiles(tileUrls); } upgradeIndexedDb(database) { // Version migration is necessary. // open the indexedDB and create the new structure console.debug('Upgrading IndexedDB'); // First : a store for tiles const tilesStore = database.createObjectStore('tiles', { autoIncrement: true }); tilesStore.createIndex('url', 'url', { unique: true }); // Second : A store for offline available bbox database.createObjectStore('bbox', { autoIncrement: true }); console.debug('IndexedDB upgraded.'); } /** The offline manager works with a ServiceWorker in charge of intercepting * the fetch requests and read the data from the local cache if the application * is offline. This method defines the servicework object to use. * Without this, the offline mode won't work. */ async setServiceWorker(sw, storeVersion, dbCacheName) { if (!sw) { console.warn("ServiceWorker cannot be initialized. Offline functionalities won't be available."); return; } this.serviceWorker = sw; this.storeVersion = storeVersion; this.dbCacheName = dbCacheName; this.serviceWorker.postMessage({ storeVersion: this.storeVersion, dbCacheName: this.dbCacheName, tilesStoreName: this.tilesStoreName }); } switchOffline() { if (this.context.stateManager.state.isOffline) { this.displayBoundBoxes(); } else if (this.vectorLayer) { this.vectorLayer.setSource(null); } } getAllTileUrls(bbox, wmtsLayers) { const tileUrls = []; for (const wmtsLayer of wmtsLayers) { const layerSource = wmtsLayer._olayer?.getSource(); if (layerSource) { const tileGrid = layerSource.getTileGrid(); if (tileGrid) { tileUrls.push(...this.getTileUrlsForWmtsLayer(tileGrid, bbox, layerSource)); } } } return tileUrls; } getTileUrlsForWmtsLayer(tileGrid, bbox, layerSource) { const minZoom = tileGrid.getMinZoom(); const resolution = this.map.getView().getResolution(); const currentZ = tileGrid.getZForResolution(resolution); const maxZoom = currentZ; if (!maxZoom) { throw new Error('Offline configuration is missing. Cannot download maps for offline usage.'); } const projection = layerSource.getProjection(); const tileUrls = []; for (let z = minZoom; z <= maxZoom; z++) { const tileRange = tileGrid.getTileRangeForExtentAndZ(bbox, z); for (let x = tileRange.minX; x <= tileRange.maxX; ++x) { for (let y = tileRange.minY; y <= tileRange.maxY; ++y) { const tileUrl = layerSource.getTileUrlFunction()([z, x, y], window.devicePixelRatio, projection); if (tileUrl) { tileUrls.push(tileUrl); } } } } return tileUrls; } async fetchAndSaveTiles(tileUrls) { this.totalLength = tileUrls.length; console.debug(`Number of Tile to load: ${this.totalLength}`); const iterator = tileUrls.values(); // Use 4 parallel workers to download tiles this.counter = 0; const workers = new Array(4).fill(iterator).map((iterator) => this.doWork(iterator)); Promise.allSettled(workers).then(() => { console.debug('Everything downloaded.'); if (this.progressCallback) { // Last info : 100% done this.progressCallback(100); } }); } async doWork(iterator) { for (const url of iterator) { const response = await fetch(url); if (response.ok) { const blob = await response.blob(); const transaction = (await this.database()).transaction([this.tilesStoreName], 'readwrite'); const store = transaction.objectStore(this.tilesStoreName); const index = store.index('url'); const dbRequest = index.getKey(url); dbRequest.onsuccess = () => { let request; if (dbRequest.result) { const key = dbRequest.result; request = store.put({ url: url, data: blob }, key); } else { request = store.put({ url: url, data: blob }); } request.onsuccess = () => { if (this.progressCallback) { this.progressCallback(Math.round((this.counter * 100) / this.totalLength)); } console.debug(`${this.counter++}/${this.totalLength} Tile ${url} added.`); }; request.onerror = () => { console.error(`Error while saving tile ${url}`); }; }; } } } async getTotalSizeMB() { const database = await this.database(); return new Promise((resolve, reject) => { if (!database) { reject(new Error('Database is not initialized.')); return; } const transaction = database.transaction([this.tilesStoreName], 'readonly'); const store = transaction.objectStore(this.tilesStoreName); const request = store.openCursor(); let totalBytes = 0; request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const value = cursor.value; if (value && value.data instanceof Blob) { totalBytes += value.data.size; } cursor.continue(); } else { // No more entries const totalMB = totalBytes / (1024 * 1024); resolve(totalMB); } }; request.onerror = () => { reject(new Error('Failed to iterate over store to compute size.')); }; }); } async clearStore(storeName) { const database = await this.database(); return new Promise((resolve, reject) => { if (!this.database) { reject(new Error('Database is not initialized.')); return; } const transaction = database.transaction([storeName], 'readwrite'); const store = transaction.objectStore(storeName); const request = store.clear(); request.onsuccess = () => { console.debug(`Store "${storeName}" cleared successfully.`); resolve(); }; request.onerror = () => { reject(new Error(`Failed to clear store "${storeName}".`)); }; }); } async clearTileStore() { this.clearStore(this.tilesStoreName); } async clearBBoxStore() { this.vectorLayer.setSource(null); this.clearStore(this.bboxStoreName); } async saveBoundingBox(bbox) { const database = await this.database(); // Save bbox to the store const transaction = database.transaction([this.bboxStoreName], 'readwrite'); const store = transaction.objectStore(this.bboxStoreName); const request = store.put(bbox); request.onsuccess = () => { console.debug('BBOX Added to store'); }; request.onerror = () => { console.error('Error while saving bbox'); }; } async displayBoundBoxes() { const database = await this.database(); // Read bbox for offline tiles const transaction = database.transaction([this.bboxStoreName], 'readonly'); const store = transaction.objectStore(this.bboxStoreName); const request = store.getAll(); request.onsuccess = () => { const vectorSource = new VectorSource(); if (request.result) { for (const bbox of request.result) { const feature = new Feature({ geometry: fromExtent(bbox) }); vectorSource.addFeature(feature); } } this.vectorLayer.setSource(vectorSource); }; request.onerror = () => { console.error('Error while saving bbox'); }; } } export default OfflineManager;