forked from MapComplete/MapComplete
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:
parent
ca819cf8a6
commit
44748051dd
11 changed files with 193 additions and 293 deletions
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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(/</g, "<")?.replace(/>/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(/</g,'<')?.replace(/>/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'
|
||||
}
|
||||
}
|
|
@ -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"],
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -18,7 +18,6 @@
|
|||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"OfflineBasemapManager.ts",
|
||||
"SWGenerated.ts"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue