diff --git a/.gitignore b/.gitignore
index 946c9f590..4a551a264 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 0f534c5a5..e4ebdc968 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 bf8650522..1081d8f3a 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 9f7001be3..0a7eedb85 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 000000000..518418670
--- /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 49afbed20..85a0d76a7 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 19806c21f..5b0ecdf0e 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 7cba05b54..000000000
--- 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 000000000..2805b21e0
--- /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 000000000..0bc7f546b
--- /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 000000000..ea7c61a1e
--- /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 000000000..526b143ff
--- /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 a26ea86dc..5cb278f1e 100644
--- a/test.html
+++ b/test.html
@@ -9,6 +9,8 @@
+
@@ -17,21 +19,6 @@
-
+