@geogirafe/lib-geoportal
Version:
GeoGirafe is a flexible application to build online geoportals.
322 lines (321 loc) • 12.4 kB
JavaScript
import GirafeSingleton from '../../base/GirafeSingleton';
import MapManager from '../state/mapManager';
import StateManager from '../state/statemanager';
import { Feature } from 'ol';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { fromExtent } from 'ol/geom/Polygon';
import Style from 'ol/style/Style';
import { Stroke } from 'ol/style';
import ConfigManager from '../configuration/configmanager';
class OfflineManager extends GirafeSingleton {
get state() {
return this.stateManager.state;
}
constructor(type) {
super(type);
Object.defineProperty(this, "serviceWorker", {
enumerable: true,
configurable: true,
writable: true,
value: null
});
Object.defineProperty(this, "database", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "map", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "stateManager", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "configManager", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "totalLength", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "counter", {
enumerable: true,
configurable: true,
writable: true,
value: 0
});
Object.defineProperty(this, "progressCallback", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "storeVersion", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "dbCacheName", {
enumerable: true,
configurable: true,
writable: true,
value: void 0
});
Object.defineProperty(this, "tilesStoreName", {
enumerable: true,
configurable: true,
writable: true,
value: 'tiles'
});
Object.defineProperty(this, "bboxStoreName", {
enumerable: true,
configurable: true,
writable: true,
value: 'bbox'
});
Object.defineProperty(this, "vectorLayer", {
enumerable: true,
configurable: true,
writable: true,
value: new VectorLayer({
style: new Style({
stroke: new Stroke({
color: 'red',
width: 5
})
})
})
});
this.stateManager = StateManager.getInstance();
this.configManager = ConfigManager.getInstance();
this.map = MapManager.getInstance().getMap();
this.map.addLayer(this.vectorLayer);
}
initializeOfflineState(isOffline) {
this.registerEvents();
this.stateManager.state.isOffline = isOffline;
}
registerEvents() {
window.addEventListener('offline', () => {
this.state.isOffline = true;
});
window.addEventListener('online', () => {
this.state.isOffline = false;
});
this.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);
}
async openIndexedDB() {
return new Promise((resolve, reject) => {
console.debug('Opening IndexedDB');
const request = indexedDB.open(this.dbCacheName, this.storeVersion);
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
console.debug('Timeout while opening IndexedDB');
reject(new Error('Timeout while opening IndexedDB'));
}, 3000);
request.onerror = (event) => {
if (!timedOut) {
clearTimeout(timeoutId);
const message = event.target.error?.message;
console.debug(`IndexedDB could not be opened : ${message}`);
reject(new Error(message));
}
};
request.onsuccess = (event) => {
if (!timedOut) {
clearTimeout(timeoutId);
console.debug('IndexedDB is open');
resolve(event.target.result);
}
};
request.onupgradeneeded = (event) => {
// Version migration is necessary.
// open the indexedDB and create the new structure
console.debug('Upgrading IndexedDB');
const database = event.target.result;
// 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.');
resolve(database);
};
});
}
/** 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.database = await this.openIndexedDB();
await this.configManager.loadConfig();
this.serviceWorker.postMessage({
storeVersion: this.storeVersion,
dbCacheName: this.dbCacheName,
tilesStoreName: this.tilesStoreName
});
}
switchOffline() {
if (this.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 maxZoom = ConfigManager.getInstance().Config.offline?.downloadEndZoom;
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 = 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) {
response.blob().then((blob) => {
const transaction = 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 saveBoundingBox(bbox) {
if (this.database === undefined) {
this.database = await this.openIndexedDB();
}
// Save bbox to the store
const transaction = this.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() {
if (this.database === undefined) {
this.database = await this.openIndexedDB();
}
// Read bbox for offline tiles
const transaction = this.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;