From 2cd0b11448f37fa0befa6a7942389ee16e4e5cea Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Wed, 30 Jul 2025 00:21:30 +0200 Subject: [PATCH] Feature: offline basemaps: first working version (without ability to install basemaps from the UI) --- .gitignore | 1 + package-lock.json | 106 +++++++- package.json | 19 +- public/assets/sunny.json | 7 +- scripts/generatePmTilesExtractionScript.ts | 39 +++ src/InstallServiceWorker.ts | 2 +- src/UI/Test.svelte | 59 ----- src/service-worker.ts | 84 ------- src/service-worker/OfflineBasemapManager.ts | 257 ++++++++++++++++++++ src/service-worker/index.ts | 47 ++++ src/service-worker/rollup.config.js | 12 + src/service-worker/tsconfig.json | 20 ++ test.html | 19 +- 13 files changed, 494 insertions(+), 178 deletions(-) create mode 100644 scripts/generatePmTilesExtractionScript.ts delete mode 100644 src/service-worker.ts create mode 100644 src/service-worker/OfflineBasemapManager.ts create mode 100644 src/service-worker/index.ts create mode 100644 src/service-worker/rollup.config.js create mode 100644 src/service-worker/tsconfig.json diff --git a/.gitignore b/.gitignore index 946c9f5901..4a551a2649 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ missing_translations.txt .DS_Store Svg.ts data/ +src/service-worker/.rollup.cache Folder.DotSettings.user index_*.ts diff --git a/package-lock.json b/package-lock.json index 0f534c5a50..e4ebdc9685 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@rapideditor/location-conflation": "^1.3.0", "@rgossiaux/svelte-headlessui": "^1.0.2", "@rgossiaux/svelte-heroicons": "^0.1.2", + "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-typescript": "^11.0.0", "@turf/boolean-intersects": "^7.2.0", "@turf/buffer": "^7.2.0", @@ -27,13 +28,6 @@ "@turf/distance": "^7.2.0", "@turf/length": "^7.2.0", "@turf/turf": "^7.2.0", - "@types/dompurify": "^3.0.2", - "@types/follow-redirects": "^1.14.4", - "@types/node": "^22.13.5", - "@types/pannellum": "^2.5.0", - "@types/pg": "^8.11.11", - "@types/qrcode-generator": "^1.0.6", - "@types/showdown": "^2.0.0", "buffer": "^6.0.3", "chart.js": "^3.8.0", "comunica": "^2.0.0", @@ -103,12 +97,19 @@ "@sveltejs/vite-plugin-svelte": "^2.0.2", "@tsconfig/svelte": "^3.0.0", "@types/chai": "^5.0.1", + "@types/dompurify": "^3.0.2", + "@types/follow-redirects": "^1.14.4", "@types/geojson": "^7946.0.10", "@types/jsonld": "^1.5.13", "@types/lz-string": "^1.3.34", "@types/mocha": "^10.0.1", + "@types/node": "^22.13.5", + "@types/pannellum": "^2.5.0", "@types/papaparse": "^5.3.15", + "@types/pg": "^8.11.11", "@types/prompt-sync": "^4.2.3", + "@types/qrcode-generator": "^1.0.6", + "@types/showdown": "^2.0.0", "@types/xml2js": "^0.4.9", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", @@ -6508,6 +6509,67 @@ "svelte": "^3.44.0" } }, + "node_modules/@rollup/plugin-commonjs": { + "version": "28.0.6", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.6.tgz", + "integrity": "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/plugin-json": { "version": "6.0.0", "dev": true, @@ -11502,6 +11564,7 @@ }, "node_modules/@types/dompurify": { "version": "3.0.2", + "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "*" @@ -11516,6 +11579,7 @@ "version": "1.14.4", "resolved": "https://registry.npmjs.org/@types/follow-redirects/-/follow-redirects-1.14.4.tgz", "integrity": "sha512-GWXfsD0Jc1RWiFmMuMFCpXMzi9L7oPDVwxUnZdg89kDNnqsRfUKXEtUYtA98A6lig1WXH/CYY/fvPW9HuN5fTA==", + "dev": true, "dependencies": { "@types/node": "*" } @@ -11630,6 +11694,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/@types/pannellum/-/pannellum-2.5.0.tgz", "integrity": "sha512-iFVwMHmsTx91t74gU12bDmB1ty5lRgmfK6X+FxymQe8n0nuw3Pp/vk0nw73YdL9WqZgthrpf1KLPzQjZDUsj0g==", + "dev": true, "license": "MIT" }, "node_modules/@types/papaparse": { @@ -11650,6 +11715,7 @@ "version": "8.11.11", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -11659,6 +11725,7 @@ }, "node_modules/@types/pg/node_modules/pg-types": { "version": "4.0.1", + "dev": true, "license": "MIT", "dependencies": { "pg-int8": "1.0.1", @@ -11675,6 +11742,7 @@ }, "node_modules/@types/pg/node_modules/postgres-array": { "version": "3.0.2", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11682,6 +11750,7 @@ }, "node_modules/@types/pg/node_modules/postgres-bytea": { "version": "3.0.0", + "dev": true, "license": "MIT", "dependencies": { "obuf": "~1.1.2" @@ -11692,6 +11761,7 @@ }, "node_modules/@types/pg/node_modules/postgres-date": { "version": "2.0.1", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11699,6 +11769,7 @@ }, "node_modules/@types/pg/node_modules/postgres-interval": { "version": "3.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11719,6 +11790,7 @@ }, "node_modules/@types/qrcode-generator": { "version": "1.0.6", + "dev": true, "license": "MIT", "dependencies": { "qrcode-generator": "*" @@ -11752,6 +11824,7 @@ }, "node_modules/@types/showdown": { "version": "2.0.0", + "dev": true, "license": "MIT" }, "node_modules/@types/slice-ansi": { @@ -11785,6 +11858,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, "license": "MIT" }, "node_modules/@types/uritemplate": { @@ -13160,6 +13234,12 @@ "node": ">= 12" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -17120,6 +17200,15 @@ "optional": true, "peer": true }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -23030,6 +23119,7 @@ }, "node_modules/obuf": { "version": "1.1.2", + "dev": true, "license": "MIT" }, "node_modules/once": { @@ -23401,6 +23491,7 @@ }, "node_modules/pg-numeric": { "version": "1.0.2", + "dev": true, "license": "ISC", "engines": { "node": ">=4" @@ -23783,6 +23874,7 @@ }, "node_modules/postgres-range": { "version": "1.1.3", + "dev": true, "license": "MIT" }, "node_modules/potpack": { diff --git a/package.json b/package.json index bf8650522e..1081d8f3a4 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,7 @@ "query:licenses": "vite-node scripts/generateLicenseInfo.ts -- --query && npm run generate:licenses", "clean:licenses": "find . -type f -name \"*.license\" -exec rm -f {} +", "generate:contributor-list": "vite-node scripts/generateContributors.ts", - "generate:service-worker": "tsc src/service-worker.ts --outFile public/service-worker.js && git_hash=$(git rev-parse HEAD) && sed -i.bak \"s/GITHUB-COMMIT/$git_hash/\" public/service-worker.js && rm public/service-worker.js.bak", + "generate:service-worker": "cd ./src/service-worker/ && rollup -c ", "generate": "npm run generate:licenses && npm run generate:images && npm run generate:charging-stations && npm run generate:translations && npm run reset:layeroverview && npm run generate:service-worker", "generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -", "clean:tests": "find . -type f -name \"*.doctest.ts\" | xargs -r rm", @@ -184,6 +184,7 @@ "@rapideditor/location-conflation": "^1.3.0", "@rgossiaux/svelte-headlessui": "^1.0.2", "@rgossiaux/svelte-heroicons": "^0.1.2", + "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-typescript": "^11.0.0", "@turf/boolean-intersects": "^7.2.0", "@turf/buffer": "^7.2.0", @@ -192,13 +193,6 @@ "@turf/distance": "^7.2.0", "@turf/length": "^7.2.0", "@turf/turf": "^7.2.0", - "@types/dompurify": "^3.0.2", - "@types/follow-redirects": "^1.14.4", - "@types/node": "^22.13.5", - "@types/pannellum": "^2.5.0", - "@types/pg": "^8.11.11", - "@types/qrcode-generator": "^1.0.6", - "@types/showdown": "^2.0.0", "buffer": "^6.0.3", "chart.js": "^3.8.0", "comunica": "^2.0.0", @@ -268,12 +262,19 @@ "@sveltejs/vite-plugin-svelte": "^2.0.2", "@tsconfig/svelte": "^3.0.0", "@types/chai": "^5.0.1", + "@types/dompurify": "^3.0.2", + "@types/follow-redirects": "^1.14.4", "@types/geojson": "^7946.0.10", "@types/jsonld": "^1.5.13", "@types/lz-string": "^1.3.34", "@types/mocha": "^10.0.1", + "@types/node": "^22.13.5", + "@types/pannellum": "^2.5.0", "@types/papaparse": "^5.3.15", + "@types/pg": "^8.11.11", "@types/prompt-sync": "^4.2.3", + "@types/qrcode-generator": "^1.0.6", + "@types/showdown": "^2.0.0", "@types/xml2js": "^0.4.9", "@typescript-eslint/eslint-plugin": "^6.1.0", "@typescript-eslint/parser": "^6.1.0", @@ -300,4 +301,4 @@ "vite-node": "^3.0.5", "vitest": "^3.2.1" } -} \ No newline at end of file +} diff --git a/public/assets/sunny.json b/public/assets/sunny.json index 9f7001be38..0a7eedb858 100644 --- a/public/assets/sunny.json +++ b/public/assets/sunny.json @@ -6,8 +6,11 @@ "protomaps": { "attribution": "Protomaps © OpenStreetMap", "type": "vector", - "url": "pmtiles://https://cache.mapcomplete.org/planet-latest.pmtiles", - "maxzoom": 15 + "tiles": [ + "https://127.0.0.1/service-worker/offline-basemap/{z}-{x}-{y}.mvt" + ], + "maxzoom": 15, + "minzoom": 0 } }, "layers": [ diff --git a/scripts/generatePmTilesExtractionScript.ts b/scripts/generatePmTilesExtractionScript.ts new file mode 100644 index 0000000000..5184186700 --- /dev/null +++ b/scripts/generatePmTilesExtractionScript.ts @@ -0,0 +1,39 @@ +import Script from "./Script" +import { Tiles } from "../src/Models/TileRange" + +class GeneratePmTilesExtractionScript extends Script { + + constructor() { + super("Generates a bash script to extract all subpyramids of maxzoom=8 from planet-latest.pmtiles") + } + + private emitRange(z: number, x: number, y: number, maxzoom?: number): string { + const [[max_lat, min_lon], [min_lat, max_lon]] = Tiles.tile_bounds(z, x, y) + let maxzoomflag = "" + if(maxzoom !== undefined){ + maxzoomflag = " --maxzoom="+maxzoom + } + return (`./pmtiles extract planet-latest.pmtiles --minzoom=${z}${maxzoomflag} --bbox=${[min_lon, min_lat + 0.0001, max_lon, max_lat].join(",")} ${z}-${x}-${y}.pmtiles`) + } + + private generateField(z: number, maxzoom?:number){ + const boundary = 2 << z + for (let x = 0; x < boundary; x++) { + const xCommands = [] + for (let y = 0; y < boundary; y++) { + xCommands.push(this.emitRange(z, x, y,maxzoom)) + } + console.log(xCommands.join(" && ") + " && echo 'All pyramids for x = " + x + " are generated' & ") + } + + } + + async main(): Promise { + this.generateField(0, 4) + this.generateField(5, 8) + this.generateField(9) + } + +} + +new GeneratePmTilesExtractionScript().run() diff --git a/src/InstallServiceWorker.ts b/src/InstallServiceWorker.ts index 49afbed208..85a0d76a76 100644 --- a/src/InstallServiceWorker.ts +++ b/src/InstallServiceWorker.ts @@ -5,7 +5,7 @@ window.addEventListener("load", async () => { return } try { - await navigator.serviceWorker.register("/service-worker.js") + await navigator.serviceWorker.register("/service-worker.js", { type: "module" }) console.log("Service worker registration successful") } catch (err) { console.error("Service worker registration failed", err) diff --git a/src/UI/Test.svelte b/src/UI/Test.svelte index 19806c21f5..5b0ecdf0ec 100644 --- a/src/UI/Test.svelte +++ b/src/UI/Test.svelte @@ -1,63 +1,4 @@ Nothing to test diff --git a/src/service-worker.ts b/src/service-worker.ts deleted file mode 100644 index 7cba05b543..0000000000 --- a/src/service-worker.ts +++ /dev/null @@ -1,84 +0,0 @@ -const version = "0.0.0" - -interface ServiceWorkerFetchEvent extends Event { - request: RequestInfo & { url: string } - respondWith: (response: any | PromiseLike) => Promise -} - -async function install() { - console.log("Installing service worker!") -} - -addEventListener("install", (e) => (e).waitUntil(install())) -addEventListener("activate", (e) => (e).waitUntil(activate())) - -async function clearCaches(exceptVersion = undefined) { - const keys = await caches.keys() - await Promise.all(keys.map((k) => k !== version && caches.delete(k))) - console.log("Cleared caches") -} - -async function activate() { - console.log("Activating service worker") - await clearCaches(version) -} - -async function fetchAndCache(event: ServiceWorkerFetchEvent): Promise { - const networkResponse = await fetch(event.request) - const cache = await caches.open(version) - await cache.put(event.request, networkResponse.clone()) - console.log("Cached", event.request) - return networkResponse -} - -async function cacheFirst(event: ServiceWorkerFetchEvent, attemptUpdate: boolean = false) { - const cacheResponse = await caches.match(event.request, { ignoreSearch: true }) - if (cacheResponse === undefined) { - return fetchAndCache(event) - } - console.debug("Loaded from cache: ", event.request) - if (attemptUpdate) { - fetchAndCache(event) - } - return cacheResponse -} - -const neverCache: RegExp[] = [/\.html$/, /service-worker/] -const neverCacheHost: RegExp[] = [/127\.0\.0\.[0-9]+/, /\.local/, /\.gitpod\.io/, /localhost/] - -async function handleRequest(event: ServiceWorkerFetchEvent) { - const origin = new URL(self.origin) - const requestUrl = new URL(event.request.url) - if (requestUrl.pathname.endsWith("service-worker-version")) { - console.log("Sending version number...") - await event.respondWith(new Response(JSON.stringify({ "service-worker-version": version }))) - return - } - if (requestUrl.pathname.endsWith("/service-worker-clear")) { - await clearCaches() - await event.respondWith(new Response(JSON.stringify({ "cache-cleared": true }))) - return - } - - const shouldBeCached = - origin.host === requestUrl.host && - !neverCacheHost.some((blacklisted) => origin.host.match(blacklisted)) && - !neverCache.some((blacklisted) => event.request.url.match(blacklisted)) - if (!shouldBeCached) { - console.debug("Not intercepting ", requestUrl.toString(), origin.host, requestUrl.host) - // We return _without_ calling event.respondWith, which signals the browser that it'll have to handle it himself - return - } - await event.respondWith(await cacheFirst(event)) -} - -self.addEventListener("fetch", async (e) => { - // Important: this lambda must run synchronously, as the browser will otherwise handle the request - const event: ServiceWorkerFetchEvent = e - try { - await handleRequest(event) - } catch (e) { - console.error("CRASH IN SW:", e) - await event.respondWith(fetch(event.request.url)) - } -}) diff --git a/src/service-worker/OfflineBasemapManager.ts b/src/service-worker/OfflineBasemapManager.ts new file mode 100644 index 0000000000..2805b21e0f --- /dev/null +++ b/src/service-worker/OfflineBasemapManager.ts @@ -0,0 +1,257 @@ +import { PMTiles, RangeResponse, Source } from "pmtiles" + + +interface AreaDescription { + /** + * Thie filename at the host and in the indexedDb + * Host name is not included + */ + name: string + /** + * Minzoom that is covered, inclusive + */ + minzoom: number, + /** + * Maxzoom that is covered, inclusive + */ + maxzoom: number, + /** + * The x, y of the tile that is covered (at minzoom) + */ + x: number, + y: number, + + /** + * ISO-datestring of when the data was processed + */ + dataVersion?: string + +} + +class TypedIdb { + private readonly _db: Promise + private readonly _name: string + + + constructor(db: string) { + this._name = db + this._db = TypedIdb.openDb(db) + } + + private static openDb(name: string): Promise { + return new Promise((resolve, reject) => { + + const request: IDBOpenDBRequest = indexedDB.open(name) + request.onerror = (event) => { + console.error("Could not open the Database: ", event) + reject(event) + } + request.onupgradeneeded = () => { + const db = request.result + db.createObjectStore(name) + } + request.onsuccess = () => { + resolve(request.result) + } + }) + } + + + async set(key: string, value: T): Promise { + const db = await this._db + return new Promise((resolve, reject) => { + const tx = db.transaction([this._name], "readwrite") + const store = tx.objectStore(this._name) + const request = store.put(value, key) + + request.onsuccess = () => resolve() + request.onerror = () => reject(request.error) + }) + } + + async get(key: string): Promise { + const db = await this._db + return new Promise((resolve, reject) => { + const tx = db.transaction([this._name], "readonly") + const store = tx.objectStore(this._name) + const request: IDBRequest = store.get(key) + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error) + }) + } + + async getAllValues(): Promise { + const db = await this._db + return new Promise((resolve, reject) => { + const tx = db.transaction([this._name], "readonly") + const store = tx.objectStore(this._name) + const values: T[] = [] + const request = store.openCursor() + + request.onsuccess = () => { + const cursor = request.result + if (cursor) { + values.push(cursor.value) + cursor.continue() // Triggers a new 'onsuccess' event + } else { + resolve(values) + } + } + + request.onerror = () => reject(request.error) + }) + } +} + +class BlobSource implements Source { + private readonly _blob: Blob + private readonly _name: string + public readonly pmtiles: PMTiles + + constructor(name: string, blob: Blob) { + this._name = name + this._blob = blob + this.pmtiles = new PMTiles(this) + } + + async getBytes(offset: number, length: number): Promise { + const sliced = this._blob.slice(offset, offset + length) + return { + data: await sliced.arrayBuffer() + } + } + + getKey() { + return this._name + } + + /** + * The underlying data was created with an extract from OpenStreetMap, + * extracted at a certain date. This date can be used as version indicator. + */ + async getDataVersion(): Promise { + const meta: Record = await this.pmtiles.getMetadata() + return meta["planetiler:osm:osmosisreplicationtime"] + + } + +} + +export class OfflineBasemapManager { + /** + * Where to get the initial map tiles + * @private + */ + private readonly _host: string + + private readonly blobs: TypedIdb + private readonly meta: TypedIdb + private metaCached: AreaDescription[] = [] + + /** + * The 'offline base map manager' is responsible for keeping track of the locally installed 'protomaps' subpyramids. + * + * Roughly taken, a 'protomaps pyramid' is a collection of MVT-tiles in a single file, all belonging to a certain bbox. + * The 'cache.mapcomplete.org' server has a collection of sliced pyramids: + * + * global-basemap.pmtiles: all tiles from z=0 to z=4, ~15MB + * 5-{x}-{y}.pmtiles: zoom levels 5 'till 8 (up to 9MB) + * 9-{x}-{y}.pmtimes: zoom levels 8 till 15 for a region, up to 90MB + * + * When a user downloads an offline map, they download a 9-* subpyramid, the corresponding 5-8 pyramid and the 'global-basemap' + * + */ + public constructor(host: string) { + if (!host.endsWith("/")) { + host += "/" + } + this._host = host + this.blobs = new TypedIdb("OfflineBasemap") + this.meta = new TypedIdb("OfflineBasemapMeta") + } + + private async updateCachedMeta() { + this.metaCached = await this.meta.getAllValues() + } + + /** + * ! Must be called from a fetch event in the service worker ! + * @param areaDescription + */ + public async installArea(areaDescription: AreaDescription) { + const target = this._host + areaDescription.name + console.log(">>><<< installing area from "+target) + const response = await fetch(target) + + const blob = await response.blob() + await this.blobs.set(areaDescription.name, blob) + areaDescription.dataVersion = await new BlobSource(areaDescription.name, blob).getDataVersion() + await this.meta.set(areaDescription.name, areaDescription) + await this.updateCachedMeta() + } + + /** + * Knows about the cache structure of cache.mapcomplete.org + * @see GeneratePmTilesExtractionScript + */ + public static getAreaDescriptionForMapcomplete(name: string): AreaDescription { + + if (!name.endsWith(".pmtiles")) { + throw "Invalid filename, should end with .pmtiles" + } + const [z, x, y] = name.substring(0, name.length - ".pmtiles".length).split("-").map(Number) + const maxzooms: Record = { 0: 4, 5: 8, 9: 15 } + return { + name, + minzoom: z, + maxzoom: maxzooms[z], + x, y + } + } + + getMeta(): AreaDescription[] { + return this.metaCached + } + + private determineArea(z: number, x: number, y: number): AreaDescription | undefined { + for (const areaDescription of this.metaCached) { + if (areaDescription.minzoom > z) { + continue + } + if (areaDescription.maxzoom < z) { + continue + } + const zdiff = z - areaDescription.minzoom + const xAtMinz = x >> zdiff + const yAtMinZ = y >> zdiff + if (xAtMinz === areaDescription.x && yAtMinZ === areaDescription.y) { + return areaDescription + } + } + return undefined + } + + async getTileResponse(z: number, x: number, y: number): Promise { + if (this.metaCached.length === 0) { + await this.updateCachedMeta() + } + const area = this.determineArea(z, x, y) + if (!area) { + return new Response("Not found", { status: 404 }) + } + const blob = await this.blobs.get(area.name) + const pmtiles = new BlobSource(area.name, blob) + const tileData = await pmtiles.pmtiles.getZxy(z, x, y) + if (!tileData) { + return new Response("Not found (not in tile archive, should not happen)", { status: 404 }) + } + + return new Response( + tileData.data, + { + headers: { 'Content-Type': 'application/x.protobuf' } + } + ) + } +} diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts new file mode 100644 index 0000000000..0bc7f546b3 --- /dev/null +++ b/src/service-worker/index.ts @@ -0,0 +1,47 @@ +// This file must have worker types, but not DOM types. +// The global should be that of a service worker. + +// This fixes `self`'s type. +declare var self: ServiceWorkerGlobalScope +export {} +import { OfflineBasemapManager } from "./OfflineBasemapManager" + +const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/") + + +function routeOffline(event: FetchEvent) { + const url = event.request.url + const rest = url.split("/service-worker/offline-basemap/")[1] + if (rest.indexOf("install") >= 0) { + const filename = url.split("/").pop() + if (!filename) { + return + } + const description = OfflineBasemapManager.getAreaDescriptionForMapcomplete(filename) + event.respondWith( + offlinemaps.installArea(description).then( + () => { + return new Response( + JSON.stringify({ + "status": "installed", + installed_previously: offlinemaps.getMeta() + }), { headers: { "Content-Type": "application/json" } }) + }) + ) + return + } + const tileRegex =/(\d+-\d+-\d+).mvt$/ + const tileMatch = rest.match(tileRegex) + if (tileMatch) { + const [z, x, y] = tileMatch[1].split("-").map(Number) + event.respondWith(offlinemaps.getTileResponse(z, x, y)) + } +} + +self.addEventListener("fetch", (event) => { + const url = event.request.url + if (url.indexOf("/service-worker/offline-basemap/") >= 0) { + routeOffline(event) + + } +}) diff --git a/src/service-worker/rollup.config.js b/src/service-worker/rollup.config.js new file mode 100644 index 0000000000..ea7c61a1e0 --- /dev/null +++ b/src/service-worker/rollup.config.js @@ -0,0 +1,12 @@ +import typescript from "@rollup/plugin-typescript" +import resolve from "@rollup/plugin-node-resolve" +import commonjs from "@rollup/plugin-commonjs" + +export default { + input: "index.ts", + output: { + file: "../../public/service-worker.js", + format: "es" + }, + plugins: [resolve(), commonjs(), typescript()] +} diff --git a/src/service-worker/tsconfig.json b/src/service-worker/tsconfig.json new file mode 100644 index 0000000000..526b143ff5 --- /dev/null +++ b/src/service-worker/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "moduleResolution": "node", + "rootDir": ".", + "outDir": "../../public/service-worker/", + "composite": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, + "lib": [ + "esnext", + "webworker" + ], + "types": ["pmtiles"] + }, + "include": ["index.ts", "OfflineBasemapManager.ts"] +} diff --git a/test.html b/test.html index a26ea86dc0..5cb278f1e8 100644 --- a/test.html +++ b/test.html @@ -9,6 +9,8 @@ + @@ -17,21 +19,6 @@ - +