Feature:improve offline basemap retention and fallback behaviour

This commit is contained in:
Pieter Vander Vennet 2025-08-01 00:44:25 +02:00
parent 77ef3a3572
commit 848ec121f1
15 changed files with 312 additions and 167 deletions

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": "cd ./src/service-worker/ && rollup -c ",
"generate:service-worker": "vite-node scripts/prepareServiceWorker.ts && 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",

View file

@ -7,7 +7,7 @@
"attribution": "<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>",
"type": "vector",
"tiles": [
"./service-worker/offline-basemap/{z}-{x}-{y}.mvt?fallback=true&auto=true"
"https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=2af8b969a9e8b692&auto=true"
],
"maxzoom": 15,
"minzoom": 0

View file

@ -1770,10 +1770,6 @@ input[type="range"].range-lg::-moz-range-thumb {
height: 0.875rem;
}
.h-3\/4 {
height: 75%;
}
.h-32 {
height: 8rem;
}
@ -4147,6 +4143,10 @@ input[type="range"].range-lg::-moz-range-thumb {
padding-bottom: 2.5rem;
}
.pb-16 {
padding-bottom: 4rem;
}
.pb-2 {
padding-bottom: 0.5rem;
}

View file

@ -1,10 +1,6 @@
import { Store, UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
import {
AvailableRasterLayers,
RasterLayerPolygon,
RasterLayerUtils,
} from "../../Models/RasterLayers"
import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "../../Models/RasterLayers"
/**
* When a user pans around on the map, they might pan out of the range of the current background raster layer.
@ -13,7 +9,7 @@ import {
export default class BackgroundLayerResetter {
constructor(
currentBackgroundLayer: UIEventSource<RasterLayerPolygon | undefined>,
availableLayers: { store: Store<RasterLayerPolygon[]> }
availableLayers: Store<RasterLayerPolygon[]>
) {
if (Utils.runningFromConsole) {
return
@ -28,7 +24,7 @@ export default class BackgroundLayerResetter {
) {
BackgroundLayerResetter.installHandler(
currentBackgroundLayer,
availableLayers.store
availableLayers
)
return true // unregister
}
@ -55,7 +51,7 @@ export default class BackgroundLayerResetter {
console.log("Current layer properties:", currentBgPolygon)
// Oops, we panned out of range for this layer!
// What is the 'best' map of the same category which is available?
const availableInSameCat = RasterLayerUtils.SelectBestLayerAccordingTo(
const availableInSameCat = RasterLayerUtils.selectBestLayerAccordingTo(
availableLayers,
currentBgPolygon?.properties?.category
)

View file

@ -9,7 +9,7 @@ import { eliCategory } from "../../Models/RasterLayerProperties"
*/
export class PreferredRasterLayerSelector {
private readonly _rasterLayerSetting: UIEventSource<RasterLayerPolygon>
private readonly _availableLayers: { store: Store<RasterLayerPolygon[]> }
private readonly _availableLayers: Store<RasterLayerPolygon[]>
private readonly _preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
>
@ -17,7 +17,7 @@ export class PreferredRasterLayerSelector {
constructor(
rasterLayerSetting: UIEventSource<RasterLayerPolygon>,
availableLayers: { store: Store<RasterLayerPolygon[]> },
availableLayers: Store<RasterLayerPolygon[]>,
queryParameter: UIEventSource<string>,
preferredBackgroundLayer: UIEventSource<
string | "photo" | "map" | "osmbasedmap" | undefined
@ -47,7 +47,7 @@ export class PreferredRasterLayerSelector {
if (AvailableRasterLayers.globalLayers.find((l) => l.id === layer.properties.id)) {
return
}
this._availableLayers.store.addCallbackD(() => this.updateLayer())
this._availableLayers.addCallbackD(() => this.updateLayer())
return true // unregister
})
this.updateLayer()
@ -73,7 +73,7 @@ export class PreferredRasterLayerSelector {
return
}
const isCategory = eliCategory.indexOf(<any>targetLayerId) >= 0
const available = this._availableLayers.store.data
const available = this._availableLayers.data
const foundLayer = isCategory
? available.find((l) => l.properties.category === targetLayerId)
: available.find((l) => l.properties.id.toLowerCase() === targetLayerId)

View file

@ -8,8 +8,8 @@ import { BBox } from "../Logic/BBox"
import { Store, Stores } from "../Logic/UIEventSource"
import { GeoOperations } from "../Logic/GeoOperations"
import { EliCategory, RasterLayerProperties } from "./RasterLayerProperties"
import { Utils } from "../Utils"
import { default as ELI } from "../../public/assets/data/editor-layer-index.json"
import { IsOnline } from "../Logic/Web/IsOnline"
export type EditorLayerIndex = (Feature<Polygon, EditorLayerIndexProperties> & RasterLayerPolygon)[]
@ -20,8 +20,6 @@ export class AvailableRasterLayers {
return AvailableRasterLayers._editorLayerIndex
}
public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> =
AvailableRasterLayers.initGlobalLayers()
public static bing = <RasterLayerPolygon>bingJson
public static readonly osmCartoProperties: RasterLayerProperties = {
id: "osm",
@ -29,19 +27,42 @@ export class AvailableRasterLayers {
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: {
text: "OpenStreetMap",
url: "https://openStreetMap.org/copyright",
url: "https://openStreetMap.org/copyright"
},
best: true,
max_zoom: 19,
min_zoom: 0,
category: "osmbasedmap",
category: "osmbasedmap"
}
public static readonly osmCarto: RasterLayerPolygon = {
type: "Feature",
properties: AvailableRasterLayers.osmCartoProperties,
geometry: BBox.global.asGeometry(),
geometry: BBox.global.asGeometry()
}
public static readonly sunnyOfflineProperties: RasterLayerProperties = {
"style": "./assets/sunny.json",
"best": true,
"id": "protomaps.sunny-self",
"name": "Protomaps Sunny",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
},
url: "https://mapcomplete.org/"
}
public static readonly sunnyOffline: RasterLayerPolygon = {
type: "Feature",
properties: AvailableRasterLayers.sunnyOfflineProperties,
geometry: BBox.global.asGeometry()
}
public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> =
AvailableRasterLayers.initGlobalLayers()
public static allAvailableGlobalLayers = new Set([
...AvailableRasterLayers.globalLayers,
AvailableRasterLayers.osmCarto,
@ -53,6 +74,7 @@ export class AvailableRasterLayers {
(properties) =>
properties.id !== "osm.carto" && properties.id !== "Bing" /*Added separately*/
)
gl.unshift(AvailableRasterLayers.sunnyOfflineProperties)
const glEli: RasterLayerProperties[] = globallayersEli["default"] ?? globallayersEli
const joined = gl.concat(glEli)
if (joined.some((j) => !j.id)) {
@ -71,20 +93,13 @@ export class AvailableRasterLayers {
/**
* The default background layer that any theme uses which does not explicitly define a background
*/
public static readonly defaultBackgroundLayer: RasterLayerPolygon =
AvailableRasterLayers.globalLayers.find((l) => {
return l.properties.id === "protomaps.sunny"
})
public static readonly defaultBackgroundLayer: RasterLayerPolygon = AvailableRasterLayers.sunnyOffline
public static layersAvailableAt(
location: Store<{ lon: number; lat: number }>,
enableBing?: Store<boolean>
): { store: Store<RasterLayerPolygon[]> } {
const store = { store: undefined }
Utils.AddLazyProperty(store, "store", () =>
AvailableRasterLayers._layersAvailableAt(location, enableBing)
)
return store
): Store<RasterLayerPolygon[]> {
return AvailableRasterLayers._layersAvailableAt(location, enableBing)
}
private static _layersAvailableAt(
@ -101,6 +116,9 @@ export class AvailableRasterLayers {
return Stores.ListStabilized(
availableLayersBboxes.mapD(
(eliPolygons) => {
if (!IsOnline.isOnline.data) {
return [this.sunnyOffline]
}
const loc = location.data
const lonlat: [number, number] = [loc?.lon ?? 0, loc?.lat ?? 0]
const matching: RasterLayerPolygon[] = eliPolygons.filter((eliPolygon) => {
@ -118,14 +136,14 @@ export class AvailableRasterLayers {
if (
!matching.some(
(l) =>
l.id === AvailableRasterLayers.defaultBackgroundLayer.properties.id
l.id === AvailableRasterLayers.defaultBackgroundLayer?.properties?.id
)
) {
matching.push(AvailableRasterLayers.defaultBackgroundLayer)
}
return matching
},
[enableBing]
[enableBing, IsOnline.isOnline]
)
)
}
@ -141,7 +159,7 @@ export class RasterLayerUtils {
* @param ignoreLayer
* @param skipLayers Skip the first N layers
*/
public static SelectBestLayerAccordingTo(
public static selectBestLayerAccordingTo(
available: RasterLayerPolygon[],
preferredCategory: string,
ignoreLayer?: RasterLayerPolygon,

View file

@ -47,7 +47,7 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
Feature<Point, GeoLocationPointProperties>
>
readonly availableLayers: { store: Store<RasterLayerPolygon[]> }
readonly availableLayers: Store<RasterLayerPolygon[]>
readonly currentView: FeatureSource<Feature<Polygon>>
readonly fullNodeDatabase?: FullNodeDatabaseSource
@ -180,14 +180,14 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
const setLayerCategory = (category: EliCategory, skipLayers: number = 0) => {
const timeOfCall = new Date()
this.availableLayers.store.addCallbackAndRunD((available) => {
this.availableLayers.addCallbackAndRunD((available) => {
const now = new Date()
const timeDiff = (now.getTime() - timeOfCall.getTime()) / 1000
if (timeDiff > 3) {
return true // unregister
}
const current = this.mapProperties.rasterLayer
const best = RasterLayerUtils.SelectBestLayerAccordingTo(
const best = RasterLayerUtils.selectBestLayerAccordingTo(
available,
category,
current.data,

View file

@ -36,8 +36,8 @@
let rasterLayer = state?.mapProperties.rasterLayer
if (bg !== undefined) {
if (eliCategory.indexOf(bg) >= 0) {
const availableLayers = state.availableLayers.store.data
const startLayer: RasterLayerPolygon = RasterLayerUtils.SelectBestLayerAccordingTo(
const availableLayers = state.availableLayers.data
const startLayer: RasterLayerPolygon = RasterLayerUtils.selectBestLayerAccordingTo(
availableLayers,
bg
)

View file

@ -1,5 +1,5 @@
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl } from "maplibre-gl"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl, VectorTileSource } from "maplibre-gl"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox"
@ -7,11 +7,11 @@ import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/M
import SvelteUIElement from "../Base/SvelteUIElement"
import MaplibreMap from "./MaplibreMap.svelte"
import * as htmltoimage from "html-to-image"
import Constants from "../../Models/Constants"
import { Protocol } from "pmtiles"
import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature, LineString } from "geojson"
import RasterLayerHandler from "./RasterLayerHandler"
import { IsOnline } from "../../Logic/Web/IsOnline"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -169,8 +169,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
const syncStores = () => {
this.MoveMapToCurrentLoc(this.location.data)
this.SetZoom(this.zoom.data)
this.moveMapToCurrentLoc(this.location.data)
this.setZoom(this.zoom.data)
this.setMaxBounds(this.maxbounds.data)
this.setAllowMoving(this.allowMoving.data)
this.setAllowRotating(this.allowRotating.data)
@ -178,7 +178,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.setMinzoom(this.minzoom.data)
this.setMaxzoom(this.maxzoom.data)
this.setBounds(this.bounds.data)
this.SetRotation(this.rotation.data)
this.setRotation(this.rotation.data)
this.setScale(this.showScale.data)
this.updateStores(true)
}
@ -201,7 +201,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
})
// map.on("contextmenu", ...) only works on desktop, hence we listen to the container:
map._container.addEventListener("contextmenu", (e) => {
const lngLat = map.unproject([e.x, e.y])
lastClickLocation.setData({ lon: lngLat.lng, lat: lngLat.lat, mode: "right" })
@ -210,7 +209,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
handleClick(e, "left")
})
map.on("rotateend", (_) => {
map.on("rotateend", () => {
this.updateStores()
})
map.on("pitchend", () => {
@ -245,14 +244,23 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
break
}
})
this.forceOfflineVersion(IsOnline.isOnline.data)
map.on("styledata", () => {
map.once("sourcedataloading", () => {
const isOffline = !IsOnline.isOnline.data
if (isOffline) {
this.forceOfflineVersion(true)
}
})
})
})
this.location.addCallbackAndRunD((loc) => {
this.MoveMapToCurrentLoc(loc)
this.moveMapToCurrentLoc(loc)
})
this.zoom.addCallbackAndRunD((z) => this.SetZoom(z))
this.zoom.addCallbackAndRunD((z) => this.setZoom(z))
this.maxbounds.addCallbackAndRun((bbox) => this.setMaxBounds(bbox))
this.rotation.addCallbackAndRunD((bearing) => this.SetRotation(bearing))
this.rotation.addCallbackAndRunD((bearing) => this.setRotation(bearing))
this.allowMoving.addCallbackAndRun((allowMoving) => {
this.setAllowMoving(allowMoving)
this.pingKeycodeEvent(allowMoving ? "unlocked" : "locked")
@ -263,6 +271,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.allowZooming.addCallbackAndRun((allowZooming) => this.setAllowZooming(allowZooming))
this.bounds.addCallbackAndRunD((bounds) => this.setBounds(bounds))
this.showScale?.addCallbackAndRun((showScale) => this.setScale(showScale))
IsOnline.isOnline.addCallbackAndRun(online => this.forceOfflineVersion(!online))
}
/**
@ -449,7 +458,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
element.style.height = "" + h
if (offset && rescaleIcons !== 1) {
const [_, __, relYStr] = offset
const relYStr = offset[2]
const relY = Number(relYStr)
y += img.height * (relY / 100)
}
@ -541,7 +550,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.pitch.setData(map.getPitch())
}
private SetZoom(z: number): void {
private setZoom(z: number): void {
const map = this._maplibreMap.data
if (!map || z === undefined) {
return
@ -554,7 +563,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
}
private SetRotation(bearing: number): void {
private setRotation(bearing: number): void {
const map = this._maplibreMap.data
if (!map || bearing === undefined) {
return
@ -562,7 +571,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
map.rotateTo(bearing, { duration: 0 })
}
private MoveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
private moveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
const map = this._maplibreMap.data
if (!map || loc === undefined) {
return
@ -754,4 +763,30 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
})
})
}
/**
*
* The service worker will attempt to load tiles from an archive instead of getting them from "api.protomaps.com"
* However, when truly offline, the browser might think that that domain is not reachable anymore, so we force
* <domain>.
*
* @private
*/
private forceOfflineVersion(enable: boolean) {
const src = <VectorTileSource>this._maplibreMap.data?.getSource("protomaps")
if (!src) {
return
}
console.log("Swapping the map source, forcing offline:", enable, "orig:", src.tiles)
if (enable) {
this._originalProtomapsSource = Array.from(src.tiles)
const l = window.location
src.tiles = [l.protocol + "//" + l.host + "/service-worker/offline-basemap/tile/{z}-{x}-{y}.mvt"]
} else if (this._originalProtomapsSource !== undefined) {
src.tiles = this._originalProtomapsSource
}
}
private _originalProtomapsSource: string[]
}

View file

@ -11,7 +11,10 @@
import Loading from "../Base/Loading.svelte"
import Page from "../Base/Page.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import { Square3Stack3dIcon } from "@babeard/svelte-heroicons/solid"
import { Square3Stack3dIcon, WifiIcon } from "@babeard/svelte-heroicons/solid"
import { IsOnline } from "../../Logic/Web/IsOnline"
import Cross_bottom_right from "../../assets/svg/Cross_bottom_right.svelte"
import CrossedOut from "../Base/CrossedOut.svelte"
export let state: ThemeViewState
@ -19,8 +22,7 @@
let mapproperties = state.mapProperties
let userstate = state.userRelatedState
let shown = state.guistate.pageStates.background
let availableLayers: { store: Store<RasterLayerPolygon[]> } = state.availableLayers
let _availableLayers = availableLayers.store
let availableLayers: Store<RasterLayerPolygon[]> = state.availableLayers
type CategoryType = "photo" | "map" | "other" | "osmbasedmap"
const categories: Record<CategoryType, EliCategory[]> = {
@ -32,7 +34,7 @@
function availableForCategory(type: CategoryType): Store<RasterLayerPolygon[]> {
const keywords = categories[type]
return _availableLayers.mapD((available) =>
return availableLayers.mapD((available) =>
available.filter((layer) => keywords.indexOf(<EliCategory>layer.properties.category) >= 0)
)
}
@ -51,6 +53,7 @@
}
export let onlyLink: boolean
let isOnline = IsOnline.isOnline
</script>
<Page {onlyLink} {shown} fullscreen={true}>
@ -59,7 +62,17 @@
<Tr t={Translations.t.general.backgroundMap} />
</div>
{#if $_availableLayers?.length < 1}
{#if !$isOnline}
<div class="flex justify-center items-center">
<div class="alert flex items-center gap-x-4">
<CrossedOut>
<WifiIcon />
</CrossedOut>
Your device is offline. Aerial imagery is not available
</div>
</div>
{:else if $availableLayers?.length < 1}
<Loading />
{:else}
<div class="flex flex-col gap-x-2 gap-y-2 sm:flex-row" style="height: calc( 100% - 5rem)">

View file

@ -55,6 +55,7 @@
import WelcomeBack from "./BigComponents/WelcomeBack.svelte"
import InsetSpacer from "./Base/InsetSpacer.svelte"
import { AndroidPolyfill } from "../Logic/Web/AndroidPolyfill"
import { IsOnline } from "../Logic/Web/IsOnline"
export let state: WithSearchState
new TitleHandler(state.selectedElement, state)
@ -183,6 +184,8 @@
function onMapDragged() {
mapIsDragged.ping()
}
let isOnline = IsOnline.isOnline
</script>
<main>
@ -258,9 +261,11 @@
<Filter class="h-6 w-6" />
</MapControlButton>
</If>
{#if $isOnline}
<If condition={state.featureSwitches.featureSwitchBackgroundSelection}>
<OpenBackgroundSelectorButton hideTooltip={true} {state} />
</If>
{/if}
<button
class="unstyled bg-black-transparent pointer-events-auto ml-1 h-fit max-h-12 cursor-pointer overflow-hidden rounded-2xl px-1 text-white opacity-50 hover:opacity-100"
style="background: #00000088; padding: 0.25rem; border-radius: 2rem;"
@ -441,7 +446,9 @@
<If condition={state.featureSwitches.featureSwitchFakeUser}>
<div class="alert w-fit">Faking a user (Testmode)</div>
</If>
{#if $apiState === "unknown"}
{#if !$isOnline}
<div class="alert">Offline mode</div>
{:else if $apiState === "unknown"}
<Loading />
{:else if $apiState !== "online"}
<div class="alert w-fit">API is {$apiState}</div>

View file

@ -1,37 +1,5 @@
{
"layers": [
{
"style": "assets/sunny-hosted.json",
"connect-src": [
"https://protomaps.github.io"
],
"best": true,
"id": "protomaps.sunny",
"name": "Protomaps Sunny (Hosted by Protomaps)",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
},
"url": "pmtiles://https://api.protomaps.com/tiles/v4.json?key=2af8b969a9e8b692"
},
{
"style": "assets/sunny.json",
"connect-src": [
"https://protomaps.github.io"
],
"best": true,
"id": "protomaps.sunny-self",
"name": "Protomaps Sunny (Offline)",
"type": "vector",
"category": "osmbasedmap",
"attribution": {
"text": "Protomaps",
"url": "https://protomaps.com/"
},
"url": "https://127.0.0.1/service-worker/offline-basemap/{z}-{x}-{y}.mvt"
},
{
"name": "OpenStreetMap Carto",
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
@ -134,7 +102,6 @@
"url": "https://protomaps.com/"
}
},
{
"name": "Americana",
"url": "https://americanamap.org/style.json",

View file

@ -173,6 +173,7 @@ export class OfflineBasemapManager {
private readonly blobs: TypedIdb<any>
private readonly meta: TypedIdb<AreaDescription>
private metaCached: AreaDescription[] = []
private readonly installing: Set<string> = new Set()
/**
* The 'offline base map manager' is responsible for keeping track of the locally installed 'protomaps' subpyramids.
@ -194,6 +195,7 @@ export class OfflineBasemapManager {
this._host = host
this.blobs = new TypedIdb("OfflineBasemap")
this.meta = new TypedIdb<AreaDescription>("OfflineBasemapMeta")
this.updateCachedMeta()
}
public async updateCachedMeta(): Promise<AreaDescription[]> {
@ -223,7 +225,6 @@ export class OfflineBasemapManager {
const x = tile.x >> zDiff
const y = tile.y >> zDiff
if (!this.isInstalled({ z, x, y })) {
console.log("Installing level " + z + " archive")
yield <AreaDescription>{
name: `${z}-${x}-${y}.pmtiles`,
minzoom: z,
@ -303,9 +304,19 @@ export class OfflineBasemapManager {
return undefined
}
async getFallback(z: number, x: number, y: number): Promise<Response> {
const url = `https://api.protomaps.com/tiles/v4/${z}/${x}/${y}.mvt?key=2af8b969a9e8b692`
return fetch(url)
private async attemptInstall(candidate: AreaDescription) {
if (this.installing.has(candidate.name)) {
return
}
this.installing.add(candidate.name)
try {
await this.installArea(candidate)
await this.updateCachedMeta()
} catch (e) {
console.error("Could not install basemap archive", candidate.name, "due to", e)
} finally {
this.installing.delete(candidate.name)
}
}
/**
@ -316,7 +327,7 @@ export class OfflineBasemapManager {
* @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
autoInstall?: boolean, fallback?: string
}): Promise<Response> {
if (this.metaCached.length === 0) {
await this.updateCachedMeta()
@ -324,28 +335,30 @@ export class OfflineBasemapManager {
let area = this.determineArea(z, x, y)
if (options?.autoInstall && !area) {
// We attempt to install the local files
// We attempt to install the local files ; but we don't wait
const candidates = this.getInstallCandidates({ z, x, y })
for (const candidate of candidates) {
await this.installArea(candidate)
this.attemptInstall(candidate)
}
await this.updateCachedMeta()
area = this.determineArea(z, x, y)
}
if (!area) {
if (options?.fallback) {
return this.getFallback(z, x, y)
return fetch(options?.fallback)
}
console.log("No suitable area in the archives (and no fallback):", { 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)
const tileData = await pmtiles.pmtiles.getZxy(z, x, y)
if (!tileData) {
console.log("Not found in the archives:", { z, x, y })
return new Response("Not found (not in tile archive, should not happen)", { status: 404 })
}
console.log("Served tile", { z, x, y }, "from installed archive")
return new Response(
tileData.data,
{
@ -357,7 +370,6 @@ export class OfflineBasemapManager {
deleteArea(description: AreaDescription): Promise<AreaDescription[]> {
this.blobs.del(description.name)
this.meta.del(description.name)
return this.updateCachedMeta()
}
}

View file

@ -2,10 +2,13 @@
// The global should be that of a service worker.
// This fixes `self`'s type.
import { OfflineBasemapManager } from "./OfflineBasemapManager"
import { SWGenerated } from "./SWGenerated"
declare var self: ServiceWorkerGlobalScope
export {}
import { OfflineBasemapManager } from "./OfflineBasemapManager"
const selfDomain = self.location.hostname
const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/")
@ -17,74 +20,164 @@ function jsonResponse(object: object | []): Response {
})
}
function routeOffline(event: FetchEvent) {
const url = new URL(event.request.url)
const rest = url.pathname.split("/service-worker/offline-basemap/")[1]
function respondFromCache(event: FetchEvent) {
event.respondWith(
caches.open(SWGenerated.vNumber).then(async cache => {
const cached = await cache.match(event.request)
if (!cached) {
const response = await fetch(event.request)
cache.put(event.request, response.clone())
return response
}
return cached
})
)
}
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.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"
async function listCachedRequests(): Promise<string[]> {
const cache = await caches.open(SWGenerated.vNumber)
const requests = await cache.keys()
return requests.map(req => req.url)
}
const [z, x, y] = tileMatch[1].split("-").map(Number)
event.respondWith(offlinemaps.getTileResponse(z, x, y, { fallback, autoInstall }))
class Router {
private readonly _endpoints: Record<string, (event: FetchEvent) => void>
private readonly _subpaths: Record<string, (event: FetchEvent, rest: string) => void>
private readonly _fallback: undefined | ((event: FetchEvent, rest: string) => void)
constructor(endpoints: Record<string, (event: FetchEvent) => void>,
subpaths: Record<string, (event: FetchEvent, rest: string) => void>,
fallback?: undefined | ((event: FetchEvent, rest: string) => void)
) {
this._endpoints = endpoints
this._subpaths = subpaths
this._fallback = fallback
}
public route(event: FetchEvent, rest?: string) {
const url = new URL(event.request.url)
rest ??= url.pathname.split("/service-worker/")[1]
console.log(">>> routing", rest)
if (rest.indexOf("/") > 0) {
const nextSegment = rest.split("/")[0]
if (this._subpaths[nextSegment]) {
return this._subpaths[nextSegment](event, rest.substring(nextSegment.length + 1))
}
} else if (this._endpoints[rest] !== undefined) {
return this._endpoints[rest](event)
}
if (this._fallback) {
return this._fallback(event, rest)
}
}
}
const basemapRouter = new Router(
{
"meta.json": (event) => {
event.respondWith(
offlinemaps.updateCachedMeta().then(meta => jsonResponse(meta)))
},
update: (event) => {
event.respondWith(
offlinemaps.updateCachedMeta()
.then(meta => jsonResponse({
"status": "installed",
installed: meta
})))
}
},
{
// No delete or install, this is done directly in the GUI
"tile":
(event, filename) => {
console.log("Got a tile request:", filename)
const tileRegex = /(\d+-\d+-\d+).mvt$/
const tileMatch = filename.match(tileRegex)
if (!tileMatch) {
console.log("This is _not_ a tile")
return
}
const url = new URL(event.request.url)
const autoInstall = url.searchParams.get("auto") === "true"
const [z, x, y] = tileMatch[1].split("-").map(s => Number(s))
event.respondWith(offlinemaps.getTileResponse(z, x, y, { autoInstall }))
}
}
)
const allOffline = new Router({
"status.json": (event) => {
event.respondWith(
listCachedRequests().then(cached =>
jsonResponse(
{
status: "ok", cached,
tiles: offlinemaps.getMeta()
}
))
)
}
}, {
"offline-basemap": (event, rest) => {
basemapRouter.route(event, rest)
}
},
(event: FetchEvent, rest: string) => {
console.log("Got a request to the service worker for an unknown endpoint:", rest)
})
const matchTiles = /(\d+)\/(\d+)\/(\d+).mvt$/
self.addEventListener("fetch", (event) => {
const url = event.request.url
if (url.indexOf("/service-worker/offline-basemap/") >= 0) {
routeOffline(event)
if (url.endsWith("/service-worker.js")) {
return // Installation of a new version, we don't interfere
}
console.log("Intercepting event", event.request.url)
if (url.indexOf("/service-worker/") >= 0) {
allOffline.route(event)
return
}
const urlObj = new URL(url)
if ((urlObj.host === "api.protomaps.com" || urlObj.hostname === selfDomain) && urlObj.pathname.indexOf("tiles/v4") >= 0) {
// "https://api.protomaps.com/tiles/v4/${z}/${x}/${y}.mvt?key=2af8b969a9e8b692&auto=true"
const auto = urlObj.searchParams.get("auto") === "true"
const match = urlObj.pathname.match(matchTiles)
if (!match) {
return
}
const z = Number(match[1])
const x = Number(match[2])
const y = Number(match[3])
console.log("Match is:", match, { x, y, z })
event.respondWith(offlinemaps.getTileResponse(z, x, y, {
autoInstall: auto,
fallback: url
}))
}
if (urlObj.hostname === selfDomain && selfDomain !== "localhost" && selfDomain !== "127.0.0.1") {
respondFromCache(event)
return
}
})
self.addEventListener("install", () => self.skipWaiting())
self.addEventListener("activate", event => event.waitUntil(self.clients.claim()))
self.addEventListener("activate", event => {
event.waitUntil(self.clients.claim())
// Delete the old caches (of an older version number
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map(async (key) => {
if (key !== SWGenerated.vNumber) {
await caches.delete(key)
}
})
)
))
})

View file

@ -16,5 +16,9 @@
],
"types": ["pmtiles"]
},
"include": ["index.ts", "OfflineBasemapManager.ts"]
"include": [
"index.ts",
"OfflineBasemapManager.ts",
"SWGenerated.ts"
]
}