forked from MapComplete/MapComplete
Feature: offline basemaps: first working version (without ability to install basemaps from the UI)
This commit is contained in:
parent
4c858fbe7e
commit
2cd0b11448
13 changed files with 494 additions and 178 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -21,6 +21,7 @@ missing_translations.txt
|
|||
.DS_Store
|
||||
Svg.ts
|
||||
data/
|
||||
src/service-worker/.rollup.cache
|
||||
|
||||
Folder.DotSettings.user
|
||||
index_*.ts
|
||||
|
|
106
package-lock.json
generated
106
package-lock.json
generated
|
@ -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": {
|
||||
|
|
19
package.json
19
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,8 +6,11 @@
|
|||
"protomaps": {
|
||||
"attribution": "<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>",
|
||||
"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": [
|
||||
|
|
39
scripts/generatePmTilesExtractionScript.ts
Normal file
39
scripts/generatePmTilesExtractionScript.ts
Normal file
|
@ -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<void> {
|
||||
this.generateField(0, 4)
|
||||
this.generateField(5, 8)
|
||||
this.generateField(9)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
new GeneratePmTilesExtractionScript().run()
|
|
@ -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)
|
||||
|
|
|
@ -1,63 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { PMTiles } from "pmtiles"
|
||||
|
||||
// What piece of the map to download and cache locally for the current map view?
|
||||
const gentBBox = {
|
||||
"type": "Feature",
|
||||
"properties": {},
|
||||
"geometry": {
|
||||
"coordinates": [
|
||||
[
|
||||
[
|
||||
3.6911333519349228,
|
||||
51.069673467969096
|
||||
],
|
||||
[
|
||||
3.6911333519349228,
|
||||
51.02886180987505
|
||||
],
|
||||
[
|
||||
3.773873676856965,
|
||||
51.02886180987505
|
||||
],
|
||||
[
|
||||
3.773873676856965,
|
||||
51.069673467969096
|
||||
],
|
||||
[
|
||||
3.6911333519349228,
|
||||
51.069673467969096
|
||||
]
|
||||
]
|
||||
],
|
||||
"type": "Polygon"
|
||||
}
|
||||
}
|
||||
|
||||
// URL or fetch source of the PMTiles archive
|
||||
let source = new PMTiles("https://cache.mapcomplete.org/planet-latest.pmtiles")
|
||||
|
||||
async function test(z: number, x: number, y: number) {
|
||||
|
||||
|
||||
const meta = await source.getMetadata()
|
||||
console.log(meta)
|
||||
const minzoom = meta["minzoom"]
|
||||
const maxzoom = meta["maxzoom"]
|
||||
console.log("Range is", minzoom, maxzoom)
|
||||
for (const l of meta["vector_layers"]) {
|
||||
// console.log(l)
|
||||
}
|
||||
// const header = await source.getHeader()
|
||||
// console.log(header)
|
||||
let resp = await source.getZxy(z, x, y)
|
||||
console.log(resp)
|
||||
// let tileData = resp?.data
|
||||
// do something with tileData
|
||||
}
|
||||
|
||||
test(14, 8338, 5469) // Elf-julistraat Brugge
|
||||
test(15, 12233, 10534)
|
||||
</script>
|
||||
|
||||
Nothing to test
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
const version = "0.0.0"
|
||||
|
||||
interface ServiceWorkerFetchEvent extends Event {
|
||||
request: RequestInfo & { url: string }
|
||||
respondWith: (response: any | PromiseLike<Response>) => Promise<void>
|
||||
}
|
||||
|
||||
async function install() {
|
||||
console.log("Installing service worker!")
|
||||
}
|
||||
|
||||
addEventListener("install", (e) => (<any>e).waitUntil(install()))
|
||||
addEventListener("activate", (e) => (<any>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<Response> {
|
||||
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 = <ServiceWorkerFetchEvent>e
|
||||
try {
|
||||
await handleRequest(event)
|
||||
} catch (e) {
|
||||
console.error("CRASH IN SW:", e)
|
||||
await event.respondWith(fetch(event.request.url))
|
||||
}
|
||||
})
|
257
src/service-worker/OfflineBasemapManager.ts
Normal file
257
src/service-worker/OfflineBasemapManager.ts
Normal file
|
@ -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<T> {
|
||||
private readonly _db: Promise<IDBDatabase>
|
||||
private readonly _name: string
|
||||
|
||||
|
||||
constructor(db: string) {
|
||||
this._name = db
|
||||
this._db = TypedIdb.openDb(db)
|
||||
}
|
||||
|
||||
private static openDb(name: string): Promise<IDBDatabase> {
|
||||
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<void> {
|
||||
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<T> {
|
||||
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<T> = store.get(key)
|
||||
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
async getAllValues(): Promise<T[]> {
|
||||
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<RangeResponse> {
|
||||
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<string> {
|
||||
const meta: Record<string, string> = <any>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<any>
|
||||
private readonly meta: TypedIdb<AreaDescription>
|
||||
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<AreaDescription>("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<number, number> = { 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<Response> {
|
||||
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' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
47
src/service-worker/index.ts
Normal file
47
src/service-worker/index.ts
Normal file
|
@ -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)
|
||||
|
||||
}
|
||||
})
|
12
src/service-worker/rollup.config.js
Normal file
12
src/service-worker/rollup.config.js
Normal file
|
@ -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()]
|
||||
}
|
20
src/service-worker/tsconfig.json
Normal file
20
src/service-worker/tsconfig.json
Normal file
|
@ -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"]
|
||||
}
|
19
test.html
19
test.html
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue