forked from MapComplete/MapComplete
Feature:improve offline basemap retention and fallback behaviour
This commit is contained in:
parent
77ef3a3572
commit
848ec121f1
15 changed files with 312 additions and 167 deletions
|
|
@ -108,7 +108,7 @@
|
|||
"query:licenses": "vite-node scripts/generateLicenseInfo.ts -- --query && npm run generate:licenses",
|
||||
"clean:licenses": "find . -type f -name \"*.license\" -exec rm -f {} +",
|
||||
"generate:contributor-list": "vite-node scripts/generateContributors.ts",
|
||||
"generate:service-worker": "cd ./src/service-worker/ && rollup -c ",
|
||||
"generate:service-worker": "vite-node scripts/prepareServiceWorker.ts && cd ./src/service-worker/ && rollup -c ",
|
||||
"generate": "npm run generate:licenses && npm run generate:images && npm run generate:charging-stations && npm run generate:translations && npm run reset:layeroverview && npm run generate:service-worker",
|
||||
"generate:charging-stations": "cd ./assets/layers/charging_station && vite-node csvToJson.ts && cd -",
|
||||
"clean:tests": "find . -type f -name \"*.doctest.ts\" | xargs -r rm",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
"attribution": "<a href=\"https://github.com/protomaps/basemaps\">Protomaps</a> © <a href=\"https://openstreetmap.org\">OpenStreetMap</a>",
|
||||
"type": "vector",
|
||||
"tiles": [
|
||||
"./service-worker/offline-basemap/{z}-{x}-{y}.mvt?fallback=true&auto=true"
|
||||
"https://api.protomaps.com/tiles/v4/{z}/{x}/{y}.mvt?key=2af8b969a9e8b692&auto=true"
|
||||
],
|
||||
"maxzoom": 15,
|
||||
"minzoom": 0
|
||||
|
|
|
|||
|
|
@ -1770,10 +1770,6 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.h-3\/4 {
|
||||
height: 75%;
|
||||
}
|
||||
|
||||
.h-32 {
|
||||
height: 8rem;
|
||||
}
|
||||
|
|
@ -4147,6 +4143,10 @@ input[type="range"].range-lg::-moz-range-thumb {
|
|||
padding-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.pb-16 {
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
import {
|
||||
AvailableRasterLayers,
|
||||
RasterLayerPolygon,
|
||||
RasterLayerUtils,
|
||||
} from "../../Models/RasterLayers"
|
||||
import { AvailableRasterLayers, RasterLayerPolygon, RasterLayerUtils } from "../../Models/RasterLayers"
|
||||
|
||||
/**
|
||||
* When a user pans around on the map, they might pan out of the range of the current background raster layer.
|
||||
|
|
@ -13,7 +9,7 @@ import {
|
|||
export default class BackgroundLayerResetter {
|
||||
constructor(
|
||||
currentBackgroundLayer: UIEventSource<RasterLayerPolygon | undefined>,
|
||||
availableLayers: { store: Store<RasterLayerPolygon[]> }
|
||||
availableLayers: Store<RasterLayerPolygon[]>
|
||||
) {
|
||||
if (Utils.runningFromConsole) {
|
||||
return
|
||||
|
|
@ -28,7 +24,7 @@ export default class BackgroundLayerResetter {
|
|||
) {
|
||||
BackgroundLayerResetter.installHandler(
|
||||
currentBackgroundLayer,
|
||||
availableLayers.store
|
||||
availableLayers
|
||||
)
|
||||
return true // unregister
|
||||
}
|
||||
|
|
@ -55,7 +51,7 @@ export default class BackgroundLayerResetter {
|
|||
console.log("Current layer properties:", currentBgPolygon)
|
||||
// Oops, we panned out of range for this layer!
|
||||
// What is the 'best' map of the same category which is available?
|
||||
const availableInSameCat = RasterLayerUtils.SelectBestLayerAccordingTo(
|
||||
const availableInSameCat = RasterLayerUtils.selectBestLayerAccordingTo(
|
||||
availableLayers,
|
||||
currentBgPolygon?.properties?.category
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { eliCategory } from "../../Models/RasterLayerProperties"
|
|||
*/
|
||||
export class PreferredRasterLayerSelector {
|
||||
private readonly _rasterLayerSetting: UIEventSource<RasterLayerPolygon>
|
||||
private readonly _availableLayers: { store: Store<RasterLayerPolygon[]> }
|
||||
private readonly _availableLayers: Store<RasterLayerPolygon[]>
|
||||
private readonly _preferredBackgroundLayer: UIEventSource<
|
||||
string | "photo" | "map" | "osmbasedmap" | undefined
|
||||
>
|
||||
|
|
@ -17,7 +17,7 @@ export class PreferredRasterLayerSelector {
|
|||
|
||||
constructor(
|
||||
rasterLayerSetting: UIEventSource<RasterLayerPolygon>,
|
||||
availableLayers: { store: Store<RasterLayerPolygon[]> },
|
||||
availableLayers: Store<RasterLayerPolygon[]>,
|
||||
queryParameter: UIEventSource<string>,
|
||||
preferredBackgroundLayer: UIEventSource<
|
||||
string | "photo" | "map" | "osmbasedmap" | undefined
|
||||
|
|
@ -47,7 +47,7 @@ export class PreferredRasterLayerSelector {
|
|||
if (AvailableRasterLayers.globalLayers.find((l) => l.id === layer.properties.id)) {
|
||||
return
|
||||
}
|
||||
this._availableLayers.store.addCallbackD(() => this.updateLayer())
|
||||
this._availableLayers.addCallbackD(() => this.updateLayer())
|
||||
return true // unregister
|
||||
})
|
||||
this.updateLayer()
|
||||
|
|
@ -73,7 +73,7 @@ export class PreferredRasterLayerSelector {
|
|||
return
|
||||
}
|
||||
const isCategory = eliCategory.indexOf(<any>targetLayerId) >= 0
|
||||
const available = this._availableLayers.store.data
|
||||
const available = this._availableLayers.data
|
||||
const foundLayer = isCategory
|
||||
? available.find((l) => l.properties.category === targetLayerId)
|
||||
: available.find((l) => l.properties.id.toLowerCase() === targetLayerId)
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { BBox } from "../Logic/BBox"
|
|||
import { Store, Stores } from "../Logic/UIEventSource"
|
||||
import { GeoOperations } from "../Logic/GeoOperations"
|
||||
import { EliCategory, RasterLayerProperties } from "./RasterLayerProperties"
|
||||
import { Utils } from "../Utils"
|
||||
import { default as ELI } from "../../public/assets/data/editor-layer-index.json"
|
||||
import { IsOnline } from "../Logic/Web/IsOnline"
|
||||
|
||||
export type EditorLayerIndex = (Feature<Polygon, EditorLayerIndexProperties> & RasterLayerPolygon)[]
|
||||
|
||||
|
|
@ -20,8 +20,6 @@ export class AvailableRasterLayers {
|
|||
return AvailableRasterLayers._editorLayerIndex
|
||||
}
|
||||
|
||||
public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> =
|
||||
AvailableRasterLayers.initGlobalLayers()
|
||||
public static bing = <RasterLayerPolygon>bingJson
|
||||
public static readonly osmCartoProperties: RasterLayerProperties = {
|
||||
id: "osm",
|
||||
|
|
@ -29,19 +27,42 @@ export class AvailableRasterLayers {
|
|||
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
attribution: {
|
||||
text: "OpenStreetMap",
|
||||
url: "https://openStreetMap.org/copyright",
|
||||
url: "https://openStreetMap.org/copyright"
|
||||
},
|
||||
best: true,
|
||||
max_zoom: 19,
|
||||
min_zoom: 0,
|
||||
category: "osmbasedmap",
|
||||
category: "osmbasedmap"
|
||||
}
|
||||
public static readonly osmCarto: RasterLayerPolygon = {
|
||||
type: "Feature",
|
||||
properties: AvailableRasterLayers.osmCartoProperties,
|
||||
geometry: BBox.global.asGeometry(),
|
||||
geometry: BBox.global.asGeometry()
|
||||
}
|
||||
|
||||
public static readonly sunnyOfflineProperties: RasterLayerProperties = {
|
||||
"style": "./assets/sunny.json",
|
||||
"best": true,
|
||||
"id": "protomaps.sunny-self",
|
||||
"name": "Protomaps Sunny",
|
||||
"type": "vector",
|
||||
"category": "osmbasedmap",
|
||||
"attribution": {
|
||||
"text": "Protomaps",
|
||||
"url": "https://protomaps.com/"
|
||||
},
|
||||
url: "https://mapcomplete.org/"
|
||||
}
|
||||
public static readonly sunnyOffline: RasterLayerPolygon = {
|
||||
type: "Feature",
|
||||
properties: AvailableRasterLayers.sunnyOfflineProperties,
|
||||
geometry: BBox.global.asGeometry()
|
||||
}
|
||||
|
||||
|
||||
public static readonly globalLayers: ReadonlyArray<RasterLayerPolygon> =
|
||||
AvailableRasterLayers.initGlobalLayers()
|
||||
|
||||
public static allAvailableGlobalLayers = new Set([
|
||||
...AvailableRasterLayers.globalLayers,
|
||||
AvailableRasterLayers.osmCarto,
|
||||
|
|
@ -53,6 +74,7 @@ export class AvailableRasterLayers {
|
|||
(properties) =>
|
||||
properties.id !== "osm.carto" && properties.id !== "Bing" /*Added separately*/
|
||||
)
|
||||
gl.unshift(AvailableRasterLayers.sunnyOfflineProperties)
|
||||
const glEli: RasterLayerProperties[] = globallayersEli["default"] ?? globallayersEli
|
||||
const joined = gl.concat(glEli)
|
||||
if (joined.some((j) => !j.id)) {
|
||||
|
|
@ -71,20 +93,13 @@ export class AvailableRasterLayers {
|
|||
/**
|
||||
* The default background layer that any theme uses which does not explicitly define a background
|
||||
*/
|
||||
public static readonly defaultBackgroundLayer: RasterLayerPolygon =
|
||||
AvailableRasterLayers.globalLayers.find((l) => {
|
||||
return l.properties.id === "protomaps.sunny"
|
||||
})
|
||||
public static readonly defaultBackgroundLayer: RasterLayerPolygon = AvailableRasterLayers.sunnyOffline
|
||||
|
||||
public static layersAvailableAt(
|
||||
location: Store<{ lon: number; lat: number }>,
|
||||
enableBing?: Store<boolean>
|
||||
): { store: Store<RasterLayerPolygon[]> } {
|
||||
const store = { store: undefined }
|
||||
Utils.AddLazyProperty(store, "store", () =>
|
||||
AvailableRasterLayers._layersAvailableAt(location, enableBing)
|
||||
)
|
||||
return store
|
||||
): Store<RasterLayerPolygon[]> {
|
||||
return AvailableRasterLayers._layersAvailableAt(location, enableBing)
|
||||
}
|
||||
|
||||
private static _layersAvailableAt(
|
||||
|
|
@ -101,6 +116,9 @@ export class AvailableRasterLayers {
|
|||
return Stores.ListStabilized(
|
||||
availableLayersBboxes.mapD(
|
||||
(eliPolygons) => {
|
||||
if (!IsOnline.isOnline.data) {
|
||||
return [this.sunnyOffline]
|
||||
}
|
||||
const loc = location.data
|
||||
const lonlat: [number, number] = [loc?.lon ?? 0, loc?.lat ?? 0]
|
||||
const matching: RasterLayerPolygon[] = eliPolygons.filter((eliPolygon) => {
|
||||
|
|
@ -118,14 +136,14 @@ export class AvailableRasterLayers {
|
|||
if (
|
||||
!matching.some(
|
||||
(l) =>
|
||||
l.id === AvailableRasterLayers.defaultBackgroundLayer.properties.id
|
||||
l.id === AvailableRasterLayers.defaultBackgroundLayer?.properties?.id
|
||||
)
|
||||
) {
|
||||
matching.push(AvailableRasterLayers.defaultBackgroundLayer)
|
||||
}
|
||||
return matching
|
||||
},
|
||||
[enableBing]
|
||||
[enableBing, IsOnline.isOnline]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -141,7 +159,7 @@ export class RasterLayerUtils {
|
|||
* @param ignoreLayer
|
||||
* @param skipLayers Skip the first N layers
|
||||
*/
|
||||
public static SelectBestLayerAccordingTo(
|
||||
public static selectBestLayerAccordingTo(
|
||||
available: RasterLayerPolygon[],
|
||||
preferredCategory: string,
|
||||
ignoreLayer?: RasterLayerPolygon,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
|
|||
Feature<Point, GeoLocationPointProperties>
|
||||
>
|
||||
|
||||
readonly availableLayers: { store: Store<RasterLayerPolygon[]> }
|
||||
readonly availableLayers: Store<RasterLayerPolygon[]>
|
||||
readonly currentView: FeatureSource<Feature<Polygon>>
|
||||
readonly fullNodeDatabase?: FullNodeDatabaseSource
|
||||
|
||||
|
|
@ -180,14 +180,14 @@ export class UserMapFeatureswitchState extends WithUserRelatedState {
|
|||
|
||||
const setLayerCategory = (category: EliCategory, skipLayers: number = 0) => {
|
||||
const timeOfCall = new Date()
|
||||
this.availableLayers.store.addCallbackAndRunD((available) => {
|
||||
this.availableLayers.addCallbackAndRunD((available) => {
|
||||
const now = new Date()
|
||||
const timeDiff = (now.getTime() - timeOfCall.getTime()) / 1000
|
||||
if (timeDiff > 3) {
|
||||
return true // unregister
|
||||
}
|
||||
const current = this.mapProperties.rasterLayer
|
||||
const best = RasterLayerUtils.SelectBestLayerAccordingTo(
|
||||
const best = RasterLayerUtils.selectBestLayerAccordingTo(
|
||||
available,
|
||||
category,
|
||||
current.data,
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@
|
|||
let rasterLayer = state?.mapProperties.rasterLayer
|
||||
if (bg !== undefined) {
|
||||
if (eliCategory.indexOf(bg) >= 0) {
|
||||
const availableLayers = state.availableLayers.store.data
|
||||
const startLayer: RasterLayerPolygon = RasterLayerUtils.SelectBestLayerAccordingTo(
|
||||
const availableLayers = state.availableLayers.data
|
||||
const startLayer: RasterLayerPolygon = RasterLayerUtils.selectBestLayerAccordingTo(
|
||||
availableLayers,
|
||||
bg
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl } from "maplibre-gl"
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||
import maplibregl, { Map as MLMap, Map as MlMap, ScaleControl, VectorTileSource } from "maplibre-gl"
|
||||
import { RasterLayerPolygon } from "../../Models/RasterLayers"
|
||||
import { Utils } from "../../Utils"
|
||||
import { BBox } from "../../Logic/BBox"
|
||||
|
|
@ -7,11 +7,11 @@ import { ExportableMap, KeyNavigationEvent, MapProperties } from "../../Models/M
|
|||
import SvelteUIElement from "../Base/SvelteUIElement"
|
||||
import MaplibreMap from "./MaplibreMap.svelte"
|
||||
import * as htmltoimage from "html-to-image"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { Protocol } from "pmtiles"
|
||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||
import { Feature, LineString } from "geojson"
|
||||
import RasterLayerHandler from "./RasterLayerHandler"
|
||||
import { IsOnline } from "../../Logic/Web/IsOnline"
|
||||
|
||||
/**
|
||||
* The 'MapLibreAdaptor' bridges 'MapLibre' with the various properties of the `MapProperties`
|
||||
|
|
@ -169,8 +169,8 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
}
|
||||
|
||||
const syncStores = () => {
|
||||
this.MoveMapToCurrentLoc(this.location.data)
|
||||
this.SetZoom(this.zoom.data)
|
||||
this.moveMapToCurrentLoc(this.location.data)
|
||||
this.setZoom(this.zoom.data)
|
||||
this.setMaxBounds(this.maxbounds.data)
|
||||
this.setAllowMoving(this.allowMoving.data)
|
||||
this.setAllowRotating(this.allowRotating.data)
|
||||
|
|
@ -178,7 +178,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
this.setMinzoom(this.minzoom.data)
|
||||
this.setMaxzoom(this.maxzoom.data)
|
||||
this.setBounds(this.bounds.data)
|
||||
this.SetRotation(this.rotation.data)
|
||||
this.setRotation(this.rotation.data)
|
||||
this.setScale(this.showScale.data)
|
||||
this.updateStores(true)
|
||||
}
|
||||
|
|
@ -201,7 +201,6 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
})
|
||||
|
||||
// map.on("contextmenu", ...) only works on desktop, hence we listen to the container:
|
||||
|
||||
map._container.addEventListener("contextmenu", (e) => {
|
||||
const lngLat = map.unproject([e.x, e.y])
|
||||
lastClickLocation.setData({ lon: lngLat.lng, lat: lngLat.lat, mode: "right" })
|
||||
|
|
@ -210,7 +209,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
handleClick(e, "left")
|
||||
})
|
||||
|
||||
map.on("rotateend", (_) => {
|
||||
map.on("rotateend", () => {
|
||||
this.updateStores()
|
||||
})
|
||||
map.on("pitchend", () => {
|
||||
|
|
@ -245,14 +244,23 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
break
|
||||
}
|
||||
})
|
||||
this.forceOfflineVersion(IsOnline.isOnline.data)
|
||||
map.on("styledata", () => {
|
||||
map.once("sourcedataloading", () => {
|
||||
const isOffline = !IsOnline.isOnline.data
|
||||
if (isOffline) {
|
||||
this.forceOfflineVersion(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
this.location.addCallbackAndRunD((loc) => {
|
||||
this.MoveMapToCurrentLoc(loc)
|
||||
this.moveMapToCurrentLoc(loc)
|
||||
})
|
||||
this.zoom.addCallbackAndRunD((z) => this.SetZoom(z))
|
||||
this.zoom.addCallbackAndRunD((z) => this.setZoom(z))
|
||||
this.maxbounds.addCallbackAndRun((bbox) => this.setMaxBounds(bbox))
|
||||
this.rotation.addCallbackAndRunD((bearing) => this.SetRotation(bearing))
|
||||
this.rotation.addCallbackAndRunD((bearing) => this.setRotation(bearing))
|
||||
this.allowMoving.addCallbackAndRun((allowMoving) => {
|
||||
this.setAllowMoving(allowMoving)
|
||||
this.pingKeycodeEvent(allowMoving ? "unlocked" : "locked")
|
||||
|
|
@ -263,6 +271,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
this.allowZooming.addCallbackAndRun((allowZooming) => this.setAllowZooming(allowZooming))
|
||||
this.bounds.addCallbackAndRunD((bounds) => this.setBounds(bounds))
|
||||
this.showScale?.addCallbackAndRun((showScale) => this.setScale(showScale))
|
||||
IsOnline.isOnline.addCallbackAndRun(online => this.forceOfflineVersion(!online))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -449,7 +458,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
element.style.height = "" + h
|
||||
|
||||
if (offset && rescaleIcons !== 1) {
|
||||
const [_, __, relYStr] = offset
|
||||
const relYStr = offset[2]
|
||||
const relY = Number(relYStr)
|
||||
y += img.height * (relY / 100)
|
||||
}
|
||||
|
|
@ -541,7 +550,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
this.pitch.setData(map.getPitch())
|
||||
}
|
||||
|
||||
private SetZoom(z: number): void {
|
||||
private setZoom(z: number): void {
|
||||
const map = this._maplibreMap.data
|
||||
if (!map || z === undefined) {
|
||||
return
|
||||
|
|
@ -554,7 +563,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
}
|
||||
}
|
||||
|
||||
private SetRotation(bearing: number): void {
|
||||
private setRotation(bearing: number): void {
|
||||
const map = this._maplibreMap.data
|
||||
if (!map || bearing === undefined) {
|
||||
return
|
||||
|
|
@ -562,7 +571,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
map.rotateTo(bearing, { duration: 0 })
|
||||
}
|
||||
|
||||
private MoveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
|
||||
private moveMapToCurrentLoc(loc: { lat: number; lon: number }): void {
|
||||
const map = this._maplibreMap.data
|
||||
if (!map || loc === undefined) {
|
||||
return
|
||||
|
|
@ -754,4 +763,30 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
|
|||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* The service worker will attempt to load tiles from an archive instead of getting them from "api.protomaps.com"
|
||||
* However, when truly offline, the browser might think that that domain is not reachable anymore, so we force
|
||||
* <domain>.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private forceOfflineVersion(enable: boolean) {
|
||||
const src = <VectorTileSource>this._maplibreMap.data?.getSource("protomaps")
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
console.log("Swapping the map source, forcing offline:", enable, "orig:", src.tiles)
|
||||
if (enable) {
|
||||
this._originalProtomapsSource = Array.from(src.tiles)
|
||||
const l = window.location
|
||||
src.tiles = [l.protocol + "//" + l.host + "/service-worker/offline-basemap/tile/{z}-{x}-{y}.mvt"]
|
||||
} else if (this._originalProtomapsSource !== undefined) {
|
||||
|
||||
src.tiles = this._originalProtomapsSource
|
||||
}
|
||||
}
|
||||
|
||||
private _originalProtomapsSource: string[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
import Loading from "../Base/Loading.svelte"
|
||||
import Page from "../Base/Page.svelte"
|
||||
import ThemeViewState from "../../Models/ThemeViewState"
|
||||
import { Square3Stack3dIcon } from "@babeard/svelte-heroicons/solid"
|
||||
import { Square3Stack3dIcon, WifiIcon } from "@babeard/svelte-heroicons/solid"
|
||||
import { IsOnline } from "../../Logic/Web/IsOnline"
|
||||
import Cross_bottom_right from "../../assets/svg/Cross_bottom_right.svelte"
|
||||
import CrossedOut from "../Base/CrossedOut.svelte"
|
||||
|
||||
export let state: ThemeViewState
|
||||
|
||||
|
|
@ -19,8 +22,7 @@
|
|||
let mapproperties = state.mapProperties
|
||||
let userstate = state.userRelatedState
|
||||
let shown = state.guistate.pageStates.background
|
||||
let availableLayers: { store: Store<RasterLayerPolygon[]> } = state.availableLayers
|
||||
let _availableLayers = availableLayers.store
|
||||
let availableLayers: Store<RasterLayerPolygon[]> = state.availableLayers
|
||||
|
||||
type CategoryType = "photo" | "map" | "other" | "osmbasedmap"
|
||||
const categories: Record<CategoryType, EliCategory[]> = {
|
||||
|
|
@ -32,7 +34,7 @@
|
|||
|
||||
function availableForCategory(type: CategoryType): Store<RasterLayerPolygon[]> {
|
||||
const keywords = categories[type]
|
||||
return _availableLayers.mapD((available) =>
|
||||
return availableLayers.mapD((available) =>
|
||||
available.filter((layer) => keywords.indexOf(<EliCategory>layer.properties.category) >= 0)
|
||||
)
|
||||
}
|
||||
|
|
@ -51,6 +53,7 @@
|
|||
}
|
||||
|
||||
export let onlyLink: boolean
|
||||
let isOnline = IsOnline.isOnline
|
||||
</script>
|
||||
|
||||
<Page {onlyLink} {shown} fullscreen={true}>
|
||||
|
|
@ -59,7 +62,17 @@
|
|||
|
||||
<Tr t={Translations.t.general.backgroundMap} />
|
||||
</div>
|
||||
{#if $_availableLayers?.length < 1}
|
||||
|
||||
{#if !$isOnline}
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="alert flex items-center gap-x-4">
|
||||
<CrossedOut>
|
||||
<WifiIcon />
|
||||
</CrossedOut>
|
||||
Your device is offline. Aerial imagery is not available
|
||||
</div>
|
||||
</div>
|
||||
{:else if $availableLayers?.length < 1}
|
||||
<Loading />
|
||||
{:else}
|
||||
<div class="flex flex-col gap-x-2 gap-y-2 sm:flex-row" style="height: calc( 100% - 5rem)">
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
import WelcomeBack from "./BigComponents/WelcomeBack.svelte"
|
||||
import InsetSpacer from "./Base/InsetSpacer.svelte"
|
||||
import { AndroidPolyfill } from "../Logic/Web/AndroidPolyfill"
|
||||
import { IsOnline } from "../Logic/Web/IsOnline"
|
||||
export let state: WithSearchState
|
||||
new TitleHandler(state.selectedElement, state)
|
||||
|
||||
|
|
@ -183,6 +184,8 @@
|
|||
function onMapDragged() {
|
||||
mapIsDragged.ping()
|
||||
}
|
||||
|
||||
let isOnline = IsOnline.isOnline
|
||||
</script>
|
||||
|
||||
<main>
|
||||
|
|
@ -258,9 +261,11 @@
|
|||
<Filter class="h-6 w-6" />
|
||||
</MapControlButton>
|
||||
</If>
|
||||
{#if $isOnline}
|
||||
<If condition={state.featureSwitches.featureSwitchBackgroundSelection}>
|
||||
<OpenBackgroundSelectorButton hideTooltip={true} {state} />
|
||||
</If>
|
||||
{/if}
|
||||
<button
|
||||
class="unstyled bg-black-transparent pointer-events-auto ml-1 h-fit max-h-12 cursor-pointer overflow-hidden rounded-2xl px-1 text-white opacity-50 hover:opacity-100"
|
||||
style="background: #00000088; padding: 0.25rem; border-radius: 2rem;"
|
||||
|
|
@ -441,7 +446,9 @@
|
|||
<If condition={state.featureSwitches.featureSwitchFakeUser}>
|
||||
<div class="alert w-fit">Faking a user (Testmode)</div>
|
||||
</If>
|
||||
{#if $apiState === "unknown"}
|
||||
{#if !$isOnline}
|
||||
<div class="alert">Offline mode</div>
|
||||
{:else if $apiState === "unknown"}
|
||||
<Loading />
|
||||
{:else if $apiState !== "online"}
|
||||
<div class="alert w-fit">API is {$apiState}</div>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,5 @@
|
|||
{
|
||||
"layers": [
|
||||
{
|
||||
"style": "assets/sunny-hosted.json",
|
||||
"connect-src": [
|
||||
"https://protomaps.github.io"
|
||||
],
|
||||
"best": true,
|
||||
"id": "protomaps.sunny",
|
||||
"name": "Protomaps Sunny (Hosted by Protomaps)",
|
||||
"type": "vector",
|
||||
"category": "osmbasedmap",
|
||||
"attribution": {
|
||||
"text": "Protomaps",
|
||||
"url": "https://protomaps.com/"
|
||||
},
|
||||
"url": "pmtiles://https://api.protomaps.com/tiles/v4.json?key=2af8b969a9e8b692"
|
||||
},
|
||||
{
|
||||
"style": "assets/sunny.json",
|
||||
"connect-src": [
|
||||
"https://protomaps.github.io"
|
||||
],
|
||||
"best": true,
|
||||
"id": "protomaps.sunny-self",
|
||||
"name": "Protomaps Sunny (Offline)",
|
||||
"type": "vector",
|
||||
"category": "osmbasedmap",
|
||||
"attribution": {
|
||||
"text": "Protomaps",
|
||||
"url": "https://protomaps.com/"
|
||||
},
|
||||
"url": "https://127.0.0.1/service-worker/offline-basemap/{z}-{x}-{y}.mvt"
|
||||
},
|
||||
{
|
||||
"name": "OpenStreetMap Carto",
|
||||
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
|
|
@ -134,7 +102,6 @@
|
|||
"url": "https://protomaps.com/"
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Americana",
|
||||
"url": "https://americanamap.org/style.json",
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ export class OfflineBasemapManager {
|
|||
private readonly blobs: TypedIdb<any>
|
||||
private readonly meta: TypedIdb<AreaDescription>
|
||||
private metaCached: AreaDescription[] = []
|
||||
private readonly installing: Set<string> = new Set()
|
||||
|
||||
/**
|
||||
* The 'offline base map manager' is responsible for keeping track of the locally installed 'protomaps' subpyramids.
|
||||
|
|
@ -194,6 +195,7 @@ export class OfflineBasemapManager {
|
|||
this._host = host
|
||||
this.blobs = new TypedIdb("OfflineBasemap")
|
||||
this.meta = new TypedIdb<AreaDescription>("OfflineBasemapMeta")
|
||||
this.updateCachedMeta()
|
||||
}
|
||||
|
||||
public async updateCachedMeta(): Promise<AreaDescription[]> {
|
||||
|
|
@ -223,7 +225,6 @@ export class OfflineBasemapManager {
|
|||
const x = tile.x >> zDiff
|
||||
const y = tile.y >> zDiff
|
||||
if (!this.isInstalled({ z, x, y })) {
|
||||
console.log("Installing level " + z + " archive")
|
||||
yield <AreaDescription>{
|
||||
name: `${z}-${x}-${y}.pmtiles`,
|
||||
minzoom: z,
|
||||
|
|
@ -303,9 +304,19 @@ export class OfflineBasemapManager {
|
|||
return undefined
|
||||
}
|
||||
|
||||
async getFallback(z: number, x: number, y: number): Promise<Response> {
|
||||
const url = `https://api.protomaps.com/tiles/v4/${z}/${x}/${y}.mvt?key=2af8b969a9e8b692`
|
||||
return fetch(url)
|
||||
private async attemptInstall(candidate: AreaDescription) {
|
||||
if (this.installing.has(candidate.name)) {
|
||||
return
|
||||
}
|
||||
this.installing.add(candidate.name)
|
||||
try {
|
||||
await this.installArea(candidate)
|
||||
await this.updateCachedMeta()
|
||||
} catch (e) {
|
||||
console.error("Could not install basemap archive", candidate.name, "due to", e)
|
||||
} finally {
|
||||
this.installing.delete(candidate.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -316,7 +327,7 @@ export class OfflineBasemapManager {
|
|||
* @param fallback: if set and no local tile could be found: ask protomaps instead
|
||||
*/
|
||||
async getTileResponse(z: number, x: number, y: number, options?: {
|
||||
autoInstall?: boolean, fallback?: boolean
|
||||
autoInstall?: boolean, fallback?: string
|
||||
}): Promise<Response> {
|
||||
if (this.metaCached.length === 0) {
|
||||
await this.updateCachedMeta()
|
||||
|
|
@ -324,28 +335,30 @@ export class OfflineBasemapManager {
|
|||
let area = this.determineArea(z, x, y)
|
||||
|
||||
if (options?.autoInstall && !area) {
|
||||
// We attempt to install the local files
|
||||
// We attempt to install the local files ; but we don't wait
|
||||
const candidates = this.getInstallCandidates({ z, x, y })
|
||||
for (const candidate of candidates) {
|
||||
await this.installArea(candidate)
|
||||
this.attemptInstall(candidate)
|
||||
}
|
||||
await this.updateCachedMeta()
|
||||
area = this.determineArea(z, x, y)
|
||||
}
|
||||
|
||||
if (!area) {
|
||||
if (options?.fallback) {
|
||||
return this.getFallback(z, x, y)
|
||||
return fetch(options?.fallback)
|
||||
}
|
||||
console.log("No suitable area in the archives (and no fallback):", { z, x, y })
|
||||
return new Response("Not found: no suitable area found", { status: 404 })
|
||||
}
|
||||
const blob = await this.blobs.get(area.name)
|
||||
const pmtiles = new BlobSource(area.name, blob)
|
||||
const tileData = await pmtiles.pmtiles.getZxy(z, x, y)
|
||||
if (!tileData) {
|
||||
console.log("Not found in the archives:", { z, x, y })
|
||||
return new Response("Not found (not in tile archive, should not happen)", { status: 404 })
|
||||
}
|
||||
|
||||
console.log("Served tile", { z, x, y }, "from installed archive")
|
||||
return new Response(
|
||||
tileData.data,
|
||||
{
|
||||
|
|
@ -357,7 +370,6 @@ export class OfflineBasemapManager {
|
|||
deleteArea(description: AreaDescription): Promise<AreaDescription[]> {
|
||||
this.blobs.del(description.name)
|
||||
this.meta.del(description.name)
|
||||
|
||||
return this.updateCachedMeta()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@
|
|||
// The global should be that of a service worker.
|
||||
|
||||
// This fixes `self`'s type.
|
||||
import { OfflineBasemapManager } from "./OfflineBasemapManager"
|
||||
import { SWGenerated } from "./SWGenerated"
|
||||
|
||||
declare var self: ServiceWorkerGlobalScope
|
||||
export {}
|
||||
import { OfflineBasemapManager } from "./OfflineBasemapManager"
|
||||
|
||||
const selfDomain = self.location.hostname
|
||||
|
||||
const offlinemaps = new OfflineBasemapManager("https://cache.mapcomplete.org/")
|
||||
|
||||
|
|
@ -17,74 +20,164 @@ function jsonResponse(object: object | []): Response {
|
|||
})
|
||||
}
|
||||
|
||||
function routeOffline(event: FetchEvent) {
|
||||
const url = new URL(event.request.url)
|
||||
const rest = url.pathname.split("/service-worker/offline-basemap/")[1]
|
||||
function respondFromCache(event: FetchEvent) {
|
||||
event.respondWith(
|
||||
caches.open(SWGenerated.vNumber).then(async cache => {
|
||||
const cached = await cache.match(event.request)
|
||||
if (!cached) {
|
||||
const response = await fetch(event.request)
|
||||
cache.put(event.request, response.clone())
|
||||
return response
|
||||
}
|
||||
return cached
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (rest === "meta.json") {
|
||||
event.respondWith(
|
||||
offlinemaps.updateCachedMeta().then(meta => jsonResponse(meta)))
|
||||
return
|
||||
}
|
||||
if (rest.indexOf("delete") >= 0) {
|
||||
const filename = rest.split("/").pop()
|
||||
if (!filename) {
|
||||
return
|
||||
}
|
||||
const description = OfflineBasemapManager.getAreaDescriptionForMapcomplete(filename)
|
||||
event.respondWith(
|
||||
offlinemaps.deleteArea(description)
|
||||
.then(() =>
|
||||
offlinemaps.updateCachedMeta())
|
||||
.then(meta => jsonResponse({
|
||||
"status": "installed",
|
||||
installed: meta
|
||||
})))
|
||||
}
|
||||
if (rest.indexOf("update") >= 0) {
|
||||
event.respondWith(
|
||||
offlinemaps.updateCachedMeta()
|
||||
.then(meta => jsonResponse({
|
||||
"status": "installed",
|
||||
installed: meta
|
||||
})))
|
||||
return
|
||||
}
|
||||
if (rest.indexOf("install") >= 0) {
|
||||
const filename = rest.split("/").pop()
|
||||
if (!filename) {
|
||||
return
|
||||
}
|
||||
const description = OfflineBasemapManager.getAreaDescriptionForMapcomplete(filename)
|
||||
event.respondWith(
|
||||
offlinemaps.installArea(description)
|
||||
.then(() =>
|
||||
offlinemaps.updateCachedMeta())
|
||||
.then(meta => jsonResponse({
|
||||
"status": "installed",
|
||||
installed: meta
|
||||
}))
|
||||
)
|
||||
return
|
||||
}
|
||||
const tileRegex =/(\d+-\d+-\d+).mvt$/
|
||||
const tileMatch = rest.match(tileRegex)
|
||||
if (tileMatch) {
|
||||
const fallback = url.searchParams.get("fallback") === "true"
|
||||
const autoInstall = url.searchParams.get("auto") === "true"
|
||||
async function listCachedRequests(): Promise<string[]> {
|
||||
const cache = await caches.open(SWGenerated.vNumber)
|
||||
const requests = await cache.keys()
|
||||
return requests.map(req => req.url)
|
||||
}
|
||||
|
||||
const [z, x, y] = tileMatch[1].split("-").map(Number)
|
||||
event.respondWith(offlinemaps.getTileResponse(z, x, y, { fallback, autoInstall }))
|
||||
class Router {
|
||||
private readonly _endpoints: Record<string, (event: FetchEvent) => void>
|
||||
private readonly _subpaths: Record<string, (event: FetchEvent, rest: string) => void>
|
||||
private readonly _fallback: undefined | ((event: FetchEvent, rest: string) => void)
|
||||
|
||||
constructor(endpoints: Record<string, (event: FetchEvent) => void>,
|
||||
subpaths: Record<string, (event: FetchEvent, rest: string) => void>,
|
||||
fallback?: undefined | ((event: FetchEvent, rest: string) => void)
|
||||
) {
|
||||
this._endpoints = endpoints
|
||||
this._subpaths = subpaths
|
||||
this._fallback = fallback
|
||||
}
|
||||
|
||||
public route(event: FetchEvent, rest?: string) {
|
||||
const url = new URL(event.request.url)
|
||||
rest ??= url.pathname.split("/service-worker/")[1]
|
||||
console.log(">>> routing", rest)
|
||||
if (rest.indexOf("/") > 0) {
|
||||
const nextSegment = rest.split("/")[0]
|
||||
if (this._subpaths[nextSegment]) {
|
||||
return this._subpaths[nextSegment](event, rest.substring(nextSegment.length + 1))
|
||||
}
|
||||
} else if (this._endpoints[rest] !== undefined) {
|
||||
return this._endpoints[rest](event)
|
||||
}
|
||||
if (this._fallback) {
|
||||
return this._fallback(event, rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const basemapRouter = new Router(
|
||||
{
|
||||
"meta.json": (event) => {
|
||||
event.respondWith(
|
||||
offlinemaps.updateCachedMeta().then(meta => jsonResponse(meta)))
|
||||
|
||||
},
|
||||
update: (event) => {
|
||||
event.respondWith(
|
||||
offlinemaps.updateCachedMeta()
|
||||
.then(meta => jsonResponse({
|
||||
"status": "installed",
|
||||
installed: meta
|
||||
})))
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
// No delete or install, this is done directly in the GUI
|
||||
"tile":
|
||||
(event, filename) => {
|
||||
console.log("Got a tile request:", filename)
|
||||
const tileRegex = /(\d+-\d+-\d+).mvt$/
|
||||
const tileMatch = filename.match(tileRegex)
|
||||
if (!tileMatch) {
|
||||
console.log("This is _not_ a tile")
|
||||
return
|
||||
}
|
||||
const url = new URL(event.request.url)
|
||||
const autoInstall = url.searchParams.get("auto") === "true"
|
||||
const [z, x, y] = tileMatch[1].split("-").map(s => Number(s))
|
||||
event.respondWith(offlinemaps.getTileResponse(z, x, y, { autoInstall }))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const allOffline = new Router({
|
||||
"status.json": (event) => {
|
||||
event.respondWith(
|
||||
listCachedRequests().then(cached =>
|
||||
jsonResponse(
|
||||
{
|
||||
status: "ok", cached,
|
||||
tiles: offlinemaps.getMeta()
|
||||
}
|
||||
))
|
||||
)
|
||||
}
|
||||
}, {
|
||||
"offline-basemap": (event, rest) => {
|
||||
basemapRouter.route(event, rest)
|
||||
}
|
||||
},
|
||||
(event: FetchEvent, rest: string) => {
|
||||
console.log("Got a request to the service worker for an unknown endpoint:", rest)
|
||||
})
|
||||
|
||||
const matchTiles = /(\d+)\/(\d+)\/(\d+).mvt$/
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = event.request.url
|
||||
if (url.indexOf("/service-worker/offline-basemap/") >= 0) {
|
||||
routeOffline(event)
|
||||
if (url.endsWith("/service-worker.js")) {
|
||||
return // Installation of a new version, we don't interfere
|
||||
}
|
||||
console.log("Intercepting event", event.request.url)
|
||||
if (url.indexOf("/service-worker/") >= 0) {
|
||||
allOffline.route(event)
|
||||
return
|
||||
}
|
||||
const urlObj = new URL(url)
|
||||
if ((urlObj.host === "api.protomaps.com" || urlObj.hostname === selfDomain) && urlObj.pathname.indexOf("tiles/v4") >= 0) {
|
||||
// "https://api.protomaps.com/tiles/v4/${z}/${x}/${y}.mvt?key=2af8b969a9e8b692&auto=true"
|
||||
const auto = urlObj.searchParams.get("auto") === "true"
|
||||
const match = urlObj.pathname.match(matchTiles)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
const z = Number(match[1])
|
||||
const x = Number(match[2])
|
||||
const y = Number(match[3])
|
||||
console.log("Match is:", match, { x, y, z })
|
||||
|
||||
event.respondWith(offlinemaps.getTileResponse(z, x, y, {
|
||||
autoInstall: auto,
|
||||
fallback: url
|
||||
}))
|
||||
}
|
||||
if (urlObj.hostname === selfDomain && selfDomain !== "localhost" && selfDomain !== "127.0.0.1") {
|
||||
respondFromCache(event)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting())
|
||||
self.addEventListener("activate", event => event.waitUntil(self.clients.claim()))
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
|
||||
// Delete the old caches (of an older version number
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.map(async (key) => {
|
||||
if (key !== SWGenerated.vNumber) {
|
||||
await caches.delete(key)
|
||||
}
|
||||
})
|
||||
)
|
||||
))
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,5 +16,9 @@
|
|||
],
|
||||
"types": ["pmtiles"]
|
||||
},
|
||||
"include": ["index.ts", "OfflineBasemapManager.ts"]
|
||||
"include": [
|
||||
"index.ts",
|
||||
"OfflineBasemapManager.ts",
|
||||
"SWGenerated.ts"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue