@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
277 lines (276 loc) • 10.9 kB
JavaScript
// 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;