From 2dd0240ce879b008d28d97c7657a32de484bb482 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Thu, 31 Jul 2025 12:30:09 +0200 Subject: [PATCH] Feature(offline): add management module for offline basemaps --- .gitignore | 1 + public/css/index-tailwind-output.css | 4 ++ src/Models/MenuState.ts | 1 + src/UI/BigComponents/MenuDrawer.svelte | 17 ++++- src/UI/BigComponents/MenuDrawerIndex.svelte | 37 ++++++----- src/Utils.ts | 8 +++ src/assets/global-raster-layers.json | 4 +- src/service-worker/OfflineBasemapManager.ts | 71 +++++++++++++++++++-- src/service-worker/index.ts | 69 ++++++++++++++++---- 9 files changed, 171 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 4a551a264..46c0a8cab 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ assets/editor-layer-index.json assets/generated/* assets/layers/favourite/favourite.json public/*.webmanifest +public/service-worker/tsconfig.tsbuildinfo /*.html !/index.html !/customGenerator.html diff --git a/public/css/index-tailwind-output.css b/public/css/index-tailwind-output.css index 3dbb3911a..6de18e592 100644 --- a/public/css/index-tailwind-output.css +++ b/public/css/index-tailwind-output.css @@ -1770,6 +1770,10 @@ input[type="range"].range-lg::-moz-range-thumb { height: 0.875rem; } +.h-3\/4 { + height: 75%; +} + .h-32 { height: 8rem; } diff --git a/src/Models/MenuState.ts b/src/Models/MenuState.ts index 367dfd4a1..e5b00b72c 100644 --- a/src/Models/MenuState.ts +++ b/src/Models/MenuState.ts @@ -26,6 +26,7 @@ export class MenuState { "favourites", "filter", "hotkeys", + "manageOffline", "menu", "privacy", "search", diff --git a/src/UI/BigComponents/MenuDrawer.svelte b/src/UI/BigComponents/MenuDrawer.svelte index 964428ea8..81cd04284 100644 --- a/src/UI/BigComponents/MenuDrawer.svelte +++ b/src/UI/BigComponents/MenuDrawer.svelte @@ -18,7 +18,7 @@ import RasterLayerOverview from "../Map/RasterLayerOverview.svelte" import ThemeIntroPanel from "./ThemeIntroPanel.svelte" import Marker from "../Map/Marker.svelte" - import { ShareIcon } from "@babeard/svelte-heroicons/mini" + import { BoltIcon, ShareIcon } from "@babeard/svelte-heroicons/mini" import SidebarUnit from "../Base/SidebarUnit.svelte" import PanoramaxLink from "./PanoramaxLink.svelte" import { UIEventSource } from "../../Logic/UIEventSource" @@ -27,6 +27,7 @@ import Hotkeys from "../Base/Hotkeys" import MenuDrawerIndex from "./MenuDrawerIndex.svelte" import ThemeViewState from "../../Models/ThemeViewState" + import HotkeyTable from "./HotkeyTable.svelte" export let onlyLink: boolean export let state: ThemeViewState @@ -49,6 +50,8 @@ }) } }) + let hotkeys = Hotkeys._docs +
@@ -111,6 +114,18 @@ /> + {#if $hotkeys.length > 0} +
+ + + + + + + +
+ {/if} + diff --git a/src/UI/BigComponents/MenuDrawerIndex.svelte b/src/UI/BigComponents/MenuDrawerIndex.svelte index 402d441f1..2524e22d5 100644 --- a/src/UI/BigComponents/MenuDrawerIndex.svelte +++ b/src/UI/BigComponents/MenuDrawerIndex.svelte @@ -16,7 +16,6 @@ import Tr from "../Base/Tr.svelte" import LoginToggle from "../Base/LoginToggle.svelte" import { CloseButton } from "flowbite-svelte" - import HotkeyTable from "./HotkeyTable.svelte" import { Utils } from "../../Utils" import Constants from "../../Models/Constants" import Mastodon from "../../assets/svg/Mastodon.svelte" @@ -61,6 +60,8 @@ import QueuedImagesView from "../Image/QueuedImagesView.svelte" import InsetSpacer from "../Base/InsetSpacer.svelte" import UserCircle from "@rgossiaux/svelte-heroicons/solid/UserCircle" + import OfflineManagement from "./OfflineManagement.svelte" + import { GlobeEuropeAfrica } from "@babeard/svelte-heroicons/solid/GlobeEuropeAfrica" export let state: { favourites: FavouritesFeatureSource @@ -71,7 +72,6 @@ mapProperties?: MapProperties userRelatedState?: UserRelatedState } - let hotkeys = Hotkeys._docs let userdetails = state.osmConnection.userDetails let usersettingslayer = new LayerConfig(usersettings, "usersettings", true) @@ -134,13 +134,17 @@ -
+
{#if $userdetails.img} avatar {:else} {/if} - {$userdetails.name} +
+ + {$userdetails.name} + +
@@ -194,22 +198,16 @@
{/if} - {#if $hotkeys.length > 0} -
- - - - - - - -
- {/if} - -
- -
+ + + + + Manage offline basemap + + + + ud.languages @@ -268,6 +266,7 @@
+ diff --git a/src/Utils.ts b/src/Utils.ts index eb60d2848..40dda604a 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -1882,6 +1882,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be return [].concat(...param) } + public static sum(list: number[]): number { + let total = 0 + for (const number of list) { + total += number + } + return total + } + /** * * JSON.stringify(Utils.reorder({b: "0", a: "1"}, ["a", "b"])) // => '{"a":"1","b":"0"}' diff --git a/src/assets/global-raster-layers.json b/src/assets/global-raster-layers.json index 983e288cf..6f5ce6269 100644 --- a/src/assets/global-raster-layers.json +++ b/src/assets/global-raster-layers.json @@ -23,14 +23,14 @@ ], "best": true, "id": "protomaps.sunny-self", - "name": "Protomaps Sunny (Hosted by pietervdvn)", + "name": "Protomaps Sunny (Offline)", "type": "vector", "category": "osmbasedmap", "attribution": { "text": "Protomaps", "url": "https://protomaps.com/" }, - "url": "https://cache.mapcomplete.org/global-basemap.pmtiles" + "url": "https://127.0.0.1/service-worker/offline-basemap/{z}-{x}-{y}.mvt" }, { "name": "OpenStreetMap Carto", diff --git a/src/service-worker/OfflineBasemapManager.ts b/src/service-worker/OfflineBasemapManager.ts index 18e3d542a..a6b2a1f83 100644 --- a/src/service-worker/OfflineBasemapManager.ts +++ b/src/service-worker/OfflineBasemapManager.ts @@ -163,12 +163,12 @@ export class OfflineBasemapManager { */ private readonly _host: string - public static readonly zoomelevels = { + public static readonly zoomelevels: Record = { 0: 4, 5: 7, 8: 9, 10: undefined - } + } as const private readonly blobs: TypedIdb private readonly meta: TypedIdb @@ -201,6 +201,41 @@ export class OfflineBasemapManager { return this.metaCached } + public isInstalled(toCompare: { z?: number, minzoom?: number, x: number, y: number }): boolean { + return this.metaCached.some(area => area.x === toCompare.x && area.y === toCompare.y && (toCompare.minzoom ?? toCompare.z) === area.minzoom) + } + + + /** + * Returns all AreaDescriptions needed for the specified tile. Most specific zoom level last. + * Already installed area descriptions are _not_ returned + * @param tile + */ + public* getInstallCandidates(tile: { z: number, x: number, y: number }): Generator { + + for (const k in OfflineBasemapManager.zoomelevels) { + const z = Number(k) + + const zDiff = tile.z - z + if (zDiff < 0) { + continue + } + const x = tile.x >> zDiff + const y = tile.y >> zDiff + if (!this.isInstalled({ z, x, y })) { + console.log("Installing level " + z + " archive") + yield { + name: `${z}-${x}-${y}.pmtiles`, + minzoom: z, + maxzoom: OfflineBasemapManager.zoomelevels[z] ?? 15, + x, y + } + } + } + + + } + /** * ! Must be called from a fetch event in the service worker ! * @param areaDescription @@ -230,11 +265,11 @@ export class OfflineBasemapManager { 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 } + const maxzooms: Record = this.zoomelevels return { name, minzoom: z, - maxzoom: maxzooms[z], + maxzoom: maxzooms[z] ?? 15, x, y } } @@ -243,6 +278,13 @@ export class OfflineBasemapManager { return this.metaCached } + /** + * Looks through the 'installedMeta' where a tile can be extracted from + * @param z + * @param x + * @param y + * @private + */ private determineArea(z: number, x: number, y: number): AreaDescription | undefined { for (const areaDescription of this.metaCached) { if (areaDescription.minzoom > z) { @@ -261,13 +303,30 @@ export class OfflineBasemapManager { return undefined } - async getTileResponse(z: number, x: number, y: number): Promise { + async getFallback(z: number, x: number, y: number): Promise { + const url = `https://api.protomaps.com/tiles/v4/${z}/${x}/${y}.mvt?key=2af8b969a9e8b692` + return fetch(url) + } + + /** + * + * @param z + * @param x + * @param y + * @param fallback: if set and no local tile could be found: ask protomaps instead + */ + async getTileResponse(z: number, x: number, y: number, options?: { + autoInstall?: boolean, fallback?: boolean + }): 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 }) + if (options?.fallback) { + return this.getFallback(z, x, y) + } + return new Response("Not found: no suitable area found", { status: 404 }) } const blob = await this.blobs.get(area.name) const pmtiles = new BlobSource(area.name, blob) diff --git a/src/service-worker/index.ts b/src/service-worker/index.ts index 0bc7f546b..7f3027cb3 100644 --- a/src/service-worker/index.ts +++ b/src/service-worker/index.ts @@ -6,35 +6,75 @@ declare var self: ServiceWorkerGlobalScope export {} import { OfflineBasemapManager } from "./OfflineBasemapManager" + const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/") +function jsonResponse(object: object | []): Response { + return new Response(JSON.stringify(object), { + headers: { + "Content-Type": "application/json" + } + }) +} 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() + const url = new URL(event.request.url) + const rest = url.pathname.split("/service-worker/offline-basemap/")[1] + + if (rest === "meta.json") { + event.respondWith( + offlinemaps.updateCachedMeta().then(meta => jsonResponse(meta))) + return + } + if (rest.indexOf("delete") >= 0) { + const filename = rest.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" } }) - }) + offlinemaps.deleteArea(description) + .then(() => + offlinemaps.updateCachedMeta()) + .then(meta => jsonResponse({ + "status": "installed", + installed: meta + }))) + } + if (rest.indexOf("update") >= 0) { + event.respondWith( + offlinemaps.updateCachedMeta() + .then(meta => jsonResponse({ + "status": "installed", + installed: meta + }))) + return + } + if (rest.indexOf("install") >= 0) { + const filename = rest.split("/").pop() + if (!filename) { + return + } + const description = OfflineBasemapManager.getAreaDescriptionForMapcomplete(filename) + event.respondWith( + offlinemaps.installArea(description) + .then(() => + offlinemaps.updateCachedMeta()) + .then(meta => jsonResponse({ + "status": "installed", + installed: meta + })) ) return } const tileRegex =/(\d+-\d+-\d+).mvt$/ const tileMatch = rest.match(tileRegex) if (tileMatch) { + const fallback = url.searchParams.get("fallback") === "true" + const autoInstall = url.searchParams.get("auto") === "true" + const [z, x, y] = tileMatch[1].split("-").map(Number) - event.respondWith(offlinemaps.getTileResponse(z, x, y)) + event.respondWith(offlinemaps.getTileResponse(z, x, y, { fallback, autoInstall })) } } @@ -45,3 +85,6 @@ self.addEventListener("fetch", (event) => { } }) + +self.addEventListener("install", () => self.skipWaiting()) +self.addEventListener("activate", event => event.waitUntil(self.clients.claim()))