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": "`api/` or `reverse/` will be appended by the code",
"photonEndpoint": "https://photon.komoot.io/", "photonEndpoint": "https://photon.komoot.io/",
"jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}", "jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}",
"protomaps_archive_server": "https://cache.mapcomplete.org",
"protomaps": { "protomaps": {
"#fork": "Bound to https://mapcomplete.org; get your own at https://protomaps.com/", "#fork": "Bound to https://mapcomplete.org; get your own at https://protomaps.com/",
"api-key": "2af8b969a9e8b692", "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>", "attribution": "<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>",
"type": "vector", "type": "vector",
"tiles": [ "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, "maxzoom": 15,
"minzoom": 0 "minzoom": 0

View file

@ -1,4 +1,8 @@
import { PMTiles, RangeResponse, Source } from "pmtiles" 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 { export interface AreaDescription {
@ -172,8 +176,12 @@ export class OfflineBasemapManager {
private readonly blobs: TypedIdb<any> private readonly blobs: TypedIdb<any>
private readonly meta: TypedIdb<AreaDescription> private readonly meta: TypedIdb<AreaDescription>
private metaCached: AreaDescription[] = [] public _installedAreas: UIEventSource<AreaDescription[]> = new UIEventSource([])
private readonly installing: Set<string> = new Set() 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. * 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' * 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("/")) { if (!host.endsWith("/")) {
host += "/" host += "/"
} }
@ -198,13 +206,13 @@ export class OfflineBasemapManager {
this.updateCachedMeta() this.updateCachedMeta()
} }
public async updateCachedMeta(): Promise<AreaDescription[]> { public async updateCachedMeta(): Promise<ReadonlyArray<Readonly<AreaDescription>>> {
this.metaCached = await this.meta.getAllValues() this._installedAreas.set(await this.meta.getAllValues())
return this.metaCached return this.installedAreas.data
} }
public isInstalled(toCompare: { z?: number, minzoom?: number, x: number, y: number }): boolean { 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 * @param areaDescription
* @private
*/ */
public async installArea(areaDescription: AreaDescription) { public async installArea(areaDescription: AreaDescription) {
const target = this._host + areaDescription.name const target = this._host + areaDescription.name
if (this.isInstalled(areaDescription)) {
// Already installed
return true
}
console.log("Installing area from " + target) console.log("Installing area from " + target)
const response = await fetch(target) const response = await fetch(target)
if (!response.ok) { if (!response.ok) {
return return false
} }
const blob = await response.blob() const blob = await response.blob()
await this.blobs.set(areaDescription.name, blob) await this.blobs.set(areaDescription.name, blob)
@ -254,6 +267,8 @@ export class OfflineBasemapManager {
areaDescription.size = blob.size areaDescription.size = blob.size
await this.meta.set(areaDescription.name, areaDescription) await this.meta.set(areaDescription.name, areaDescription)
await this.updateCachedMeta() 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 * Looks through the 'installedMeta' where a tile can be extracted from
* @param z * @param z
@ -287,7 +298,7 @@ export class OfflineBasemapManager {
* @private * @private
*/ */
private determineArea(z: number, x: number, y: number): AreaDescription | undefined { 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) { if (areaDescription.minzoom > z) {
continue continue
} }
@ -304,72 +315,119 @@ export class OfflineBasemapManager {
return undefined return undefined
} }
private async attemptInstall(candidate: AreaDescription) { /**
if (this.installing.has(candidate.name)) { * Attempts to install the given area,
return * 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) const alreadyInstalling = this._installing.data.get(candidate.name)
try { if (alreadyInstalling) {
await this.installArea(candidate) return alreadyInstalling
await this.updateCachedMeta() }
} catch (e) { const promise = this.installArea(candidate).catch(e => {
console.error("Could not install basemap archive", candidate.name, "due to", 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 z
* @param x * @param x
* @param y * @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?: { async getTileResponse(z: number, x: number, y: number): Promise<undefined | Response> {
autoInstall?: boolean, fallback?: string if (this._installedAreas.data.length === 0) {
}): Promise<Response> {
if (this.metaCached.length === 0) {
await this.updateCachedMeta() await this.updateCachedMeta()
} }
let area = this.determineArea(z, x, y) const 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)
}
if (!area) { if (!area) {
if (options?.fallback) {
return fetch(options?.fallback)
}
console.log("No suitable area in the archives (and no fallback):", { z, x, y }) 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 blob = await this.blobs.get(area.name)
const pmtiles = new BlobSource(area.name, blob) const pmtiles = new BlobSource(area.name, blob)
const tileData = await pmtiles.pmtiles.getZxy(z, x, y) const tileData = await pmtiles.pmtiles.getZxy(z, x, y)
if (!tileData) { if (!tileData) {
console.log("Not found in the archives:", { z, x, y }) 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") console.log("Served tile", { z, x, y }, "from installed archive")
return new Response( return new Response(
tileData.data, 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.blobs.del(description.name)
this.meta.del(description.name) this.meta.del(description.name)
return this.updateCachedMeta() 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 { export class ThemeMetaTagging {
public static readonly themeName = "usersettings" public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) { public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () => Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
feat.properties._description Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/&lt;/g,'<')?.replace(/&gt;/g,'>') ?? '' )
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/) 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) )
?.at(1) 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 )
Utils.AddLazyProperty( feat.properties['__current_backgroun'] = 'initial_value'
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 readonly mapillary_client_token_v4 = Constants.config.api_keys.mapillary_v4
public static defaultOverpassUrls = Constants.config.default_overpass_urls public static defaultOverpassUrls = Constants.config.default_overpass_urls
public static countryCoderEndpoint: string = Constants.config.country_coder_host public static countryCoderEndpoint: string = Constants.config.country_coder_host
public static readonly pmtiles_host = Constants.config.protomaps_archive_server
public static countryCoderInfo: ServerSourceInfo = { public static countryCoderInfo: ServerSourceInfo = {
url: this.countryCoderEndpoint, url: this.countryCoderEndpoint,
trigger: ["always"], trigger: ["always"],

View file

@ -22,6 +22,10 @@ import { GeolocationControlState } from "../../UI/BigComponents/GeolocationContr
import ShowOverlayRasterLayer from "../../UI/Map/ShowOverlayRasterLayer" import ShowOverlayRasterLayer from "../../UI/Map/ShowOverlayRasterLayer"
import { BBox } from "../../Logic/BBox" import { BBox } from "../../Logic/BBox"
import ShowDataLayer from "../../UI/Map/ShowDataLayer" 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: * 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 currentView: FeatureSource<Feature<Polygon>>
readonly fullNodeDatabase?: FullNodeDatabaseSource readonly fullNodeDatabase?: FullNodeDatabaseSource
readonly offlineMapManager = OfflineBasemapManager.singleton
public readonly autoDownloadOfflineBasemap = UIEventSource.asBoolean(LocalStorageSource.get("autodownload-offline-basemaps", "true"))
constructor(theme: ThemeConfig, selectedElement: Store<object>) { constructor(theme: ThemeConfig, selectedElement: Store<object>) {
const rasterLayer: UIEventSource<RasterLayerPolygon> = const rasterLayer: UIEventSource<RasterLayerPolygon> =
new UIEventSource<RasterLayerPolygon>(undefined) new UIEventSource<RasterLayerPolygon>(undefined)
@ -130,6 +136,7 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
this.initHotkeys() this.initHotkeys()
this.drawOverlayLayers() this.drawOverlayLayers()
this.drawLock() this.drawLock()
this.downloadOfflineBasemaps()
} }
/** /**
@ -278,4 +285,18 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
metaTags: this.userRelatedState.preferencesAsTags, 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 type { MapProperties } from "../../Models/MapProperties"
import ThemeViewState from "../../Models/ThemeViewState" import ThemeViewState from "../../Models/ThemeViewState"
import type { SpecialVisualizationState } from "../SpecialVisualization" 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 Loading from "../Base/Loading.svelte"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor" import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
@ -20,12 +18,15 @@
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { DownloadIcon, TrashIcon } from "@rgossiaux/svelte-heroicons/solid" import { DownloadIcon, TrashIcon } from "@rgossiaux/svelte-heroicons/solid"
import { Accordion, AccordionItem } from "flowbite-svelte" 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 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 map: UIEventSource<MlMap> = new UIEventSource(undefined)
let mapProperties: MapProperties = new MapLibreAdaptor(map) let mapProperties: MapProperties = new MapLibreAdaptor(map)
state?.showCurrentLocationOn(map) state?.showCurrentLocationOn(map)
@ -35,77 +36,52 @@
mapProperties.allowRotating.set(false) mapProperties.allowRotating.set(false)
const offlineMapManager = new OfflineBasemapManager("https://cache.mapcomplete.org/") const offlineMapManager = OfflineBasemapManager.singleton
let installedMeta: UIEventSource<AreaDescription[]> = new UIEventSource([]) let installing: Store<ReadonlyMap<string, object>> = offlineMapManager.installing
let installed = offlineMapManager.installedAreas
let focusTile: Store<{
function updateMeta() { x: number;
offlineMapManager.updateCachedMeta().then(meta => installedMeta.set(meta)) 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() async function download() {
const tile = focusTile.data
let installing = new UIEventSource<string[]>([]) await offlineMapManager.autoInstall(tile)
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))
}
} }
let focusTileFeature = focusTile.mapD(({ x, y, z }) => {
let installed: Store<Feature<Polygon>[]> = installedMeta.map(meta => 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 ?? []) (meta ?? [])
.map(area => { .map(area => {
const f = Tiles.asGeojson(area.minzoom, area.x, area.y) const f = Tiles.asGeojson(area.minzoom, area.x, area.y)
f.properties = { f.properties = {
id: area.minzoom + "-" + area.x + "-" + area.y, id: area.minzoom + "-" + area.x + "-" + area.y,
downloaded: "yes", 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 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, { new ShowDataLayer(map, {
features: new StaticFeatureSource(installed), features: new StaticFeatureSource(installedFeature),
layer: new LayerConfig({ layer: new LayerConfig({
id: "downloaded", id: "downloaded",
source: "special", source: "special",
@ -133,21 +109,11 @@
location: ["point", "centroid"], location: ["point", "centroid"],
label: "{text}", label: "{text}",
labelCss: "width: w-min", 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, { new ShowDataLayer(map, {
features: new StaticFeatureSource(focusTileFeature), features: new StaticFeatureSource(focusTileFeature),
layer: new LayerConfig({ layer: new LayerConfig({
@ -170,7 +136,14 @@
</script> </script>
<div class="flex flex-col h-full max-h-leave-room"> <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 /> <Loading />
{:else} {:else}
<div class="h-full overflow-auto pb-16"> <div class="h-full overflow-auto pb-16">
@ -210,9 +183,9 @@
<div class="leave-room"> <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={() => { <button on:click={() => {
installedMeta?.data?.forEach(area => del(area)) installed?.data?.forEach(area => del(area))
}}> }}>
<TrashIcon class="w-6" /> <TrashIcon class="w-6" />
Delete all Delete all
@ -225,7 +198,7 @@
<th>Zoom ranges</th> <th>Zoom ranges</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
{#each ($installedMeta ?? []) as area } {#each ($installed ?? []) as area }
<tr> <tr>
<td>{area.name}</td> <td>{area.name}</td>
<td>{area.dataVersion}</td> <td>{area.dataVersion}</td>
@ -250,14 +223,6 @@
</div> </div>
</AccordionItem> </AccordionItem>
<AccordionItem paddingDefault="p-2">
<div slot="header">
Service worker status
</div>
<div class="leave-room">
<ServiceWorkerStatus />
</div>
</AccordionItem>
</Accordion> </Accordion>
</div> </div>
{/if} {/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 { 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 { RasterLayerPolygon } from "../../Models/RasterLayers"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { BBox } from "../../Logic/BBox" import { BBox } from "../../Logic/BBox"
@ -11,7 +11,7 @@ import { Protocol } from "pmtiles"
import { GeoOperations } from "../../Logic/GeoOperations" import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature, LineString } from "geojson" import { Feature, LineString } from "geojson"
import RasterLayerHandler from "./RasterLayerHandler" 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` * The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
@ -76,7 +76,9 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
} }
) { ) {
if (!MapLibreAdaptor.pmtilesInited) { if (!MapLibreAdaptor.pmtilesInited) {
const offlineManager = OfflineBasemapManager.singleton
maplibregl.addProtocol("pmtiles", new Protocol().tile) maplibregl.addProtocol("pmtiles", new Protocol().tile)
maplibregl.addProtocol("pmtilesoffl", (request, abort) => offlineManager.tilev4(request, abort))
MapLibreAdaptor.pmtilesInited = true MapLibreAdaptor.pmtilesInited = true
} }
this._maplibreMap = maplibreMap this._maplibreMap = maplibreMap
@ -244,15 +246,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
break 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.location.addCallbackAndRunD((loc) => {
@ -271,7 +264,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
this.allowZooming.addCallbackAndRun((allowZooming) => this.setAllowZooming(allowZooming)) this.allowZooming.addCallbackAndRun((allowZooming) => this.setAllowZooming(allowZooming))
this.bounds.addCallbackAndRunD((bounds) => this.setBounds(bounds)) this.bounds.addCallbackAndRunD((bounds) => this.setBounds(bounds))
this.showScale?.addCallbackAndRun((showScale) => this.setScale(showScale)) 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. // The global should be that of a service worker.
// This fixes `self`'s type. // This fixes `self`'s type.
import { OfflineBasemapManager } from "./OfflineBasemapManager"
import { SWGenerated } from "./SWGenerated" import { SWGenerated } from "./SWGenerated"
declare var self: ServiceWorkerGlobalScope declare var self: ServiceWorkerGlobalScope
@ -10,8 +9,6 @@ export {}
const selfDomain = self.location.hostname const selfDomain = self.location.hostname
const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/")
function jsonResponse(object: object | []): Response { function jsonResponse(object: object | []): Response {
return new Response(JSON.stringify(object), { return new Response(JSON.stringify(object), {
headers: { 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({ const allOffline = new Router({
"status.json": (event) => { "status.json": (event) => {
@ -115,21 +77,13 @@ const allOffline = new Router({
jsonResponse( jsonResponse(
{ {
status: "ok", cached, 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) => { self.addEventListener("fetch", (event) => {
const url = event.request.url const url = event.request.url
if (url.endsWith("/service-worker.js")) { if (url.endsWith("/service-worker.js")) {
@ -141,23 +95,6 @@ self.addEventListener("fetch", (event) => {
return return
} }
const urlObj = new URL(url) 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") { if (urlObj.hostname === selfDomain && selfDomain !== "localhost" && selfDomain !== "127.0.0.1") {
respondFromCache(event) respondFromCache(event)
return return

View file

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