Feature(offline): rework to use a protocol handler instead of a service worker to intercept as service workers don't always work, simplify code, add option to auto-download

This commit is contained in:
Pieter Vander Vennet 2025-08-01 14:54:30 +02:00
parent ca819cf8a6
commit 44748051dd
11 changed files with 193 additions and 293 deletions

View file

@ -67,6 +67,7 @@
"#photonEndpoint": "`api/` or `reverse/` will be appended by the code",
"photonEndpoint": "https://photon.komoot.io/",
"jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}",
"protomaps_archive_server": "https://cache.mapcomplete.org",
"protomaps": {
"#fork": "Bound to https://mapcomplete.org; get your own at https://protomaps.com/",
"api-key": "2af8b969a9e8b692",

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": [
"https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=2af8b969a9e8b692&auto=true"
"pmtilesoffl://https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=2af8b969a9e8b692"
],
"maxzoom": 15,
"minzoom": 0

View file

@ -1,4 +1,8 @@
import { PMTiles, RangeResponse, Source } from "pmtiles"
import { RequestParameters } from "maplibre-gl"
import { IsOnline } from "./Web/IsOnline"
import Constants from "../Models/Constants"
import { Store, UIEventSource } from "./UIEventSource"
export interface AreaDescription {
@ -172,8 +176,12 @@ export class OfflineBasemapManager {
private readonly blobs: TypedIdb<any>
private readonly meta: TypedIdb<AreaDescription>
private metaCached: AreaDescription[] = []
private readonly installing: Set<string> = new Set()
public _installedAreas: UIEventSource<AreaDescription[]> = new UIEventSource([])
public installedAreas: Store<ReadonlyArray<Readonly<AreaDescription>>> = this._installedAreas
private readonly _installing: UIEventSource<Map<string, Promise<boolean>>> = new UIEventSource(new Map())
public readonly installing: Store<ReadonlyMap<string, object>> = this._installing
public static singleton = new OfflineBasemapManager(Constants.pmtiles_host)
/**
* The 'offline base map manager' is responsible for keeping track of the locally installed 'protomaps' subpyramids.
@ -188,7 +196,7 @@ export class OfflineBasemapManager {
* 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) {
private constructor(host: string) {
if (!host.endsWith("/")) {
host += "/"
}
@ -198,13 +206,13 @@ export class OfflineBasemapManager {
this.updateCachedMeta()
}
public async updateCachedMeta(): Promise<AreaDescription[]> {
this.metaCached = await this.meta.getAllValues()
return this.metaCached
public async updateCachedMeta(): Promise<ReadonlyArray<Readonly<AreaDescription>>> {
this._installedAreas.set(await this.meta.getAllValues())
return this.installedAreas.data
}
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)
return this.installedAreas.data.some(area => area.x === toCompare.x && area.y === toCompare.y && (toCompare.minzoom ?? toCompare.z) === area.minzoom)
}
@ -238,15 +246,20 @@ export class OfflineBasemapManager {
}
/**
* ! Must be called from a fetch event in the service worker !
* Installs the area if not yet installed
* @param areaDescription
* @private
*/
public async installArea(areaDescription: AreaDescription) {
const target = this._host + areaDescription.name
if (this.isInstalled(areaDescription)) {
// Already installed
return true
}
console.log("Installing area from " + target)
const response = await fetch(target)
if (!response.ok) {
return
return false
}
const blob = await response.blob()
await this.blobs.set(areaDescription.name, blob)
@ -254,6 +267,8 @@ export class OfflineBasemapManager {
areaDescription.size = blob.size
await this.meta.set(areaDescription.name, areaDescription)
await this.updateCachedMeta()
console.log("Successfully installed", areaDescription.name)
return true
}
/**
@ -275,10 +290,6 @@ export class OfflineBasemapManager {
}
}
getMeta(): AreaDescription[] {
return this.metaCached
}
/**
* Looks through the 'installedMeta' where a tile can be extracted from
* @param z
@ -287,7 +298,7 @@ export class OfflineBasemapManager {
* @private
*/
private determineArea(z: number, x: number, y: number): AreaDescription | undefined {
for (const areaDescription of this.metaCached) {
for (const areaDescription of this.installedAreas.data) {
if (areaDescription.minzoom > z) {
continue
}
@ -304,72 +315,119 @@ export class OfflineBasemapManager {
return undefined
}
private async attemptInstall(candidate: AreaDescription) {
if (this.installing.has(candidate.name)) {
return
/**
* Attempts to install the given area,
* returns 'true' if the area was successfully installed OR was already installed previously
* @param candidate
* @private
*/
private attemptInstall(candidate: AreaDescription): Promise<boolean> {
if (!IsOnline.isOnline.data) {
return Promise.resolve(false)
}
this.installing.add(candidate.name)
try {
await this.installArea(candidate)
await this.updateCachedMeta()
} catch (e) {
const alreadyInstalling = this._installing.data.get(candidate.name)
if (alreadyInstalling) {
return alreadyInstalling
}
const promise = this.installArea(candidate).catch(e => {
console.error("Could not install basemap archive", candidate.name, "due to", e)
} finally {
this.installing.delete(candidate.name)
return false
}).finally(() => {
this._installing.data.delete(candidate.name)
this._installing.ping()
})
this._installing.data.set(candidate.name, promise)
this._installing.ping()
return promise
}
/**
* Attempts to install all required areas for the given location
* @param tile
*/
public async autoInstall(tile: { z: number, x: number, y: number }) {
const candidates = this.getInstallCandidates(tile)
for (const candidate of candidates) {
await this.attemptInstall(candidate)
}
}
/**
*
* Searches for the correct MVT tile locally and returns it as a response.
* Returns undefined if not found
* @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?: string
}): Promise<Response> {
if (this.metaCached.length === 0) {
async getTileResponse(z: number, x: number, y: number): Promise<undefined | Response> {
if (this._installedAreas.data.length === 0) {
await this.updateCachedMeta()
}
let area = this.determineArea(z, x, y)
if (options?.autoInstall && !area) {
// We attempt to install the local files ; but we don't wait
const candidates = this.getInstallCandidates({ z, x, y })
for (const candidate of candidates) {
this.attemptInstall(candidate)
}
area = this.determineArea(z, x, y)
}
const area = this.determineArea(z, x, y)
if (!area) {
if (options?.fallback) {
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 })
return undefined
}
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 })
return undefined
}
console.log("Served tile", { z, x, y }, "from installed archive")
return new Response(
tileData.data,
{
headers: { 'Content-Type': 'application/x.protobuf' }
headers: { "Content-Type": "application/x.protobuf" }
}
)
}
deleteArea(description: AreaDescription): Promise<AreaDescription[]> {
deleteArea(description: AreaDescription): Promise<ReadonlyArray<Readonly<AreaDescription>>> {
this.blobs.del(description.name)
this.meta.del(description.name)
return this.updateCachedMeta()
}
private async fallback(params: RequestParameters,
abortController: AbortController) {
params.url = params.url.substr("pmtilesoffl://".length)
const response = await fetch(
new Request(params.url, params)
, abortController)
if (!response.ok) {
throw new Error("Could not fetch " + params.url + "; status code is" + response.status)
}
return { data: await response.arrayBuffer() }
}
public async tilev4(
params: RequestParameters,
abortController: AbortController
): Promise<{ data: unknown } | { data: { tiles: string[], minzoom: number, maxzoom: number, bounds: number[] } } | {
data: Uint8Array,
cacheControl: string,
expires: string
} | { data: Uint8Array } | { data: null }> {
if (params.type === "arrayBuffer") {
const re = new RegExp(/(\d+)\/(\d+)\/(\d+).(mvt|pbf)/)
const result = params.url.match(re)
if (!result) {
return await this.fallback(params, abortController)
}
const z = Number(result[1])
const x = Number(result[2])
const y = Number(result[3])
const r = await this.getTileResponse(z, x, y)
if (r?.ok) {
return { data: await r.arrayBuffer() }
}
}
return await this.fallback(params, abortController)
}
}

View file

@ -3,40 +3,12 @@ import { Utils } from "../../Utils"
export class ThemeMetaTagging {
public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
feat.properties._description
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
?.at(1)
)
Utils.AddLazyProperty(
feat.properties,
"_d",
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/&lt;/g,'<')?.replace(/&gt;/g,'>') ?? '' )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_a', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.href.match(/mastodon|en.osm.town/) !== null)[0]?.href }) (feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_link', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.getAttribute("rel")?.indexOf('me') >= 0)[0]?.href})(feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate', () => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a )
feat.properties['__current_backgroun'] = 'initial_value'
}
}

View file

@ -142,6 +142,8 @@ export default class Constants {
public static readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4
public static defaultOverpassUrls = Constants.config.default_overpass_urls
public static countryCoderEndpoint: string = Constants.config.country_coder_host
public static readonly pmtiles_host = Constants.config.protomaps_archive_server
public static countryCoderInfo: ServerSourceInfo = {
url: this.countryCoderEndpoint,
trigger: ["always"],

View file

@ -22,6 +22,10 @@ import { GeolocationControlState } from "../../UI/BigComponents/GeolocationContr
import ShowOverlayRasterLayer from "../../UI/Map/ShowOverlayRasterLayer"
import { BBox } from "../../Logic/BBox"
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
import { OfflineBasemapManager } from "../../Logic/OfflineBasemapManager"
import { IsOnline } from "../../Logic/Web/IsOnline"
import { Tiles } from "../TileRange"
import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource"
/**
* The first core of the state management; everything related to:
@ -51,6 +55,8 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
readonly currentView: FeatureSource<Feature<Polygon>>
readonly fullNodeDatabase?: FullNodeDatabaseSource
readonly offlineMapManager = OfflineBasemapManager.singleton
public readonly autoDownloadOfflineBasemap = UIEventSource.asBoolean(LocalStorageSource.get("autodownload-offline-basemaps", "true"))
constructor(theme: ThemeConfig, selectedElement: Store<object>) {
const rasterLayer: UIEventSource<RasterLayerPolygon> =
new UIEventSource<RasterLayerPolygon>(undefined)
@ -130,6 +136,7 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
this.initHotkeys()
this.drawOverlayLayers()
this.drawLock()
this.downloadOfflineBasemaps()
}
/**
@ -278,4 +285,18 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
metaTags: this.userRelatedState.preferencesAsTags,
})
}
private downloadOfflineBasemaps() {
const tile = this.mapProperties.location.mapD(l => {
if (!IsOnline.isOnline.data || !this.autoDownloadOfflineBasemap.data) {
return undefined
}
const z = Math.min(Math.floor(this.mapProperties.zoom.data), 10)
return Tiles.embedded_tile(l.lat, l.lon, z)
},
[IsOnline.isOnline, this.mapProperties.zoom, this.autoDownloadOfflineBasemap])
tile.addCallbackAndRunD(tile => {
this.offlineMapManager.autoInstall(tile)
})
}
}

View file

@ -8,8 +8,6 @@
import type { MapProperties } from "../../Models/MapProperties"
import ThemeViewState from "../../Models/ThemeViewState"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import type { AreaDescription } from "../../service-worker/OfflineBasemapManager"
import { OfflineBasemapManager } from "../../service-worker/OfflineBasemapManager"
import Loading from "../Base/Loading.svelte"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
@ -20,12 +18,15 @@
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { DownloadIcon, TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Accordion, AccordionItem } from "flowbite-svelte"
import ServiceWorkerStatus from "./ServiceWorkerStatus.svelte"
import type { AreaDescription } from "../../Logic/OfflineBasemapManager"
import { OfflineBasemapManager } from "../../Logic/OfflineBasemapManager"
import Checkbox from "../Base/Checkbox.svelte"
export let state: ThemeViewState & SpecialVisualizationState = undefined
let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number))
export let autoDownload = state.autoDownloadOfflineBasemap
let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number))
let map: UIEventSource<MlMap> = new UIEventSource(undefined)
let mapProperties: MapProperties = new MapLibreAdaptor(map)
state?.showCurrentLocationOn(map)
@ -35,77 +36,52 @@
mapProperties.allowRotating.set(false)
const offlineMapManager = new OfflineBasemapManager("https://cache.mapcomplete.org/")
let installedMeta: UIEventSource<AreaDescription[]> = new UIEventSource([])
function updateMeta() {
offlineMapManager.updateCachedMeta().then(meta => installedMeta.set(meta))
const offlineMapManager = OfflineBasemapManager.singleton
let installing: Store<ReadonlyMap<string, object>> = offlineMapManager.installing
let installed = offlineMapManager.installedAreas
let focusTile: Store<{
x: number;
y: number;
z: number
} | undefined> = mapProperties.location.mapD(location => Tiles.embedded_tile(location.lat, location.lon, focusZ))
let focusTileIsInstalled = focusTile.mapD(tile => offlineMapManager.isInstalled(tile), [installed])
let focusTileIsInstalling = focusTile.mapD(tile => {
const { x, y, z } = tile
return installing.data?.has(`${z}-${x}-${y}.pmtiles`)
}, [installing])
async function del(areaDescr: AreaDescription) {
await offlineMapManager.deleteArea(areaDescr)
}
updateMeta()
let installing = new UIEventSource<string[]>([])
async function install(tile: AreaDescription) {
const key = `${tile.minzoom}-${tile.x}-${tile.y}`
installing.set([...installing.data ?? [], key])
try {
const descr = OfflineBasemapManager.getAreaDescriptionForMapcomplete(key + ".pmtiles")
await offlineMapManager.installArea(descr)
updateMeta()
} catch (e) {
installing.set(installing.data.filter(k => k !== key))
} finally {
installing.set(installing.data.filter(k => k !== key))
}
async function download() {
const tile = focusTile.data
await offlineMapManager.autoInstall(tile)
}
let installed: Store<Feature<Polygon>[]> = installedMeta.map(meta =>
let focusTileFeature = focusTile.mapD(({ x, y, z }) => {
const f = Tiles.asGeojson(z, x, y)
f.properties = {
id: "center_point_" + z + "_" + x + "_" + y,
txt: "Tile " + x + " " + y
}
return [f]
})
let installedFeature: Store<Feature<Polygon>[]> = installed.map(meta =>
(meta ?? [])
.map(area => {
const f = Tiles.asGeojson(area.minzoom, area.x, area.y)
f.properties = {
id: area.minzoom + "-" + area.x + "-" + area.y,
downloaded: "yes",
text: area.name + " " + area.dataVersion + " " + Utils.toHumanByteSize(Number(area.size))
text: area.name + " " + new Date(area.dataVersion).toLocaleDateString() + " " + Utils.toHumanByteSize(Number(area.size))
}
return f
}
)
)
let focusTile: Store<{
x: number;
y: number;
z: number
} | undefined> = mapProperties.location.mapD(location => Tiles.embedded_tile(location.lat, location.lon, focusZ))
let focusTileIsInstalled = focusTile.mapD(tile => offlineMapManager.isInstalled(tile), [installedMeta])
let focusTileIsInstalling = focusTile.mapD(tile => {
const { x, y, z } = tile
return installing.data?.some(area => area === `${z}-${x}-${y}.pmtiles`)
}, [installing])
async function del(areaDescr: AreaDescription) {
await offlineMapManager.deleteArea(areaDescr)
updateMeta()
}
async function download() {
const areasToInstall = Array.from(offlineMapManager.getInstallCandidates(focusTile.data))
for (const area of areasToInstall) {
console.log("Attempting to install", area)
await install(area)
}
}
new ShowDataLayer(map, {
features: new StaticFeatureSource(installed),
features: new StaticFeatureSource(installedFeature),
layer: new LayerConfig({
id: "downloaded",
source: "special",
@ -133,21 +109,11 @@
location: ["point", "centroid"],
label: "{text}",
labelCss: "width: w-min",
labelCssClasses: "bg-white rounded px-2"
labelCssClasses: "bg-white rounded px-2 items-center flex flex-col"
}
]
})
})
let focusTileFeature = focusTile.mapD(({ x, y, z }) => {
const f = Tiles.asGeojson(z, x, y)
f.properties = {
id: "center_point_" + z + "_" + x + "_" + y,
txt: "Tile " + x + " " + y
}
return [f]
})
new ShowDataLayer(map, {
features: new StaticFeatureSource(focusTileFeature),
layer: new LayerConfig({
@ -170,7 +136,14 @@
</script>
<div class="flex flex-col h-full max-h-leave-room">
{#if $installedMeta === undefined}
<Checkbox selected={autoDownload}>Automatically download the basemap when browsing around</Checkbox>
<div>
If checked, MapComplete will automatically download the basemap to the cache for the area.
This results in bigger initial data loads, but requires less internet over the long run.
If you plan to visit a region with less connectivity, you can also select the area you want to download below.
</div>
{#if $installed === undefined}
<Loading />
{:else}
<div class="h-full overflow-auto pb-16">
@ -210,9 +183,9 @@
<div class="leave-room">
{Utils.toHumanByteSize(Utils.sum($installedMeta.map(area => area.size)))}
{Utils.toHumanByteSize(Utils.sum($installed.map(area => area.size)))}
<button on:click={() => {
installedMeta?.data?.forEach(area => del(area))
installed?.data?.forEach(area => del(area))
}}>
<TrashIcon class="w-6" />
Delete all
@ -225,7 +198,7 @@
<th>Zoom ranges</th>
<th>Actions</th>
</tr>
{#each ($installedMeta ?? []) as area }
{#each ($installed ?? []) as area }
<tr>
<td>{area.name}</td>
<td>{area.dataVersion}</td>
@ -250,14 +223,6 @@
</div>
</AccordionItem>
<AccordionItem paddingDefault="p-2">
<div slot="header">
Service worker status
</div>
<div class="leave-room">
<ServiceWorkerStatus />
</div>
</AccordionItem>
</Accordion>
</div>
{/if}

View file

@ -1,22 +0,0 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import Loading from "../Base/Loading.svelte"
let loadedAssets = new UIEventSource<any>(undefined)
async function update() {
loadedAssets.set(await Utils.downloadJson("./service-worker/status.json"))
}
update()
</script>
{#if $loadedAssets === undefined}
<Loading />
{:else}
<button on:click={() => update()}>Update</button>
{JSON.stringify($loadedAssets)}
{/if}

View file

@ -1,5 +1,5 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl, VectorTileSource } from "maplibre-gl"
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl } from "maplibre-gl"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox"
@ -11,7 +11,7 @@ import { Protocol } from "pmtiles"
import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature, LineString } from "geojson"
import RasterLayerHandler from "./RasterLayerHandler"
import { IsOnline } from "../../Logic/Web/IsOnline"
import { OfflineBasemapManager } from "../../Logic/OfflineBasemapManager"
/**
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -76,7 +76,9 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
}
) {
if (!MapLibreAdaptor.pmtilesInited) {
const offlineManager = OfflineBasemapManager.singleton
maplibregl.addProtocol("pmtiles", new Protocol().tile)
maplibregl.addProtocol("pmtilesoffl", (request, abort) => offlineManager.tilev4(request, abort))
MapLibreAdaptor.pmtilesInited = true
}
this._maplibreMap = maplibreMap
@ -244,15 +246,6 @@ 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) => {
@ -271,7 +264,6 @@ 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))
}
/**
@ -764,29 +756,4 @@ 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

@ -2,7 +2,6 @@
// 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
@ -10,8 +9,6 @@ export {}
const selfDomain = self.location.hostname
const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/")
function jsonResponse(object: object | []): Response {
return new Response(JSON.stringify(object), {
headers: {
@ -72,41 +69,6 @@ class Router {
}
}
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) => {
@ -115,21 +77,13 @@ const allOffline = new Router({
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.endsWith("/service-worker.js")) {
@ -141,23 +95,6 @@ self.addEventListener("fetch", (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

View file

@ -18,7 +18,6 @@
},
"include": [
"index.ts",
"OfflineBasemapManager.ts",
"SWGenerated.ts"
]
}