Feature: offline basemaps: first working version (without ability to install basemaps from the UI)

This commit is contained in:
Pieter Vander Vennet 2025-07-30 00:21:30 +02:00
parent 4c858fbe7e
commit 2cd0b11448
13 changed files with 494 additions and 178 deletions

1
.gitignore vendored
View file

@ -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
View file

@ -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": {

View file

@ -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"
}
}
}

View file

@ -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": [

View 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()

View file

@ -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)

View file

@ -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

View file

@ -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))
}
})

View 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' }
}
)
}
}

View 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)
}
})

View 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()]
}

View 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"]
}

File diff suppressed because one or more lines are too long