Feature: first version of offline foreground data management

This commit is contained in:
Pieter Vander Vennet 2025-09-02 03:13:11 +02:00
parent 5a0866ff08
commit 988643dafa
17 changed files with 470 additions and 109 deletions

View file

@ -774,7 +774,7 @@
"installing": "Data is being downloaded",
"localOnMap": "Offline basemaps on the map",
"name": "Name",
"overview": "Offline basemaps overview",
"overview": "Offline background map",
"range": "Zoom ranges",
"size": "Size"
},
@ -839,7 +839,7 @@
"reviews": {
"affiliated_reviewer_warning": "(Affiliated review)",
"attribution": "By Mangrove.reviews",
"averageRating": "Average rating of {n} stars",
"averageRating": "Average ting of {n} stars",
"delete": "Delete review",
"deleteConfirm": "Permanently delete this review",
"deleteText": "This cannot be undone",

View file

@ -0,0 +1,84 @@
/**
* Keeps track of what foreground (mostly OSM) data is loaded and should be _reloaded_ automatically when internet is restored
*
*/
import ThemeSource from "../FeatureSource/Sources/ThemeSource"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { Store, UIEventSource } from "../UIEventSource"
import { BBox } from "../BBox"
import { WithLayoutSourceState } from "../../Models/ThemeViewState/WithLayoutSourceState"
import { Lists } from "../../Utils/Lists"
import { IsOnline } from "../Web/IsOnline"
export class OfflineForegroundDataManager {
private _themeSource: ThemeSource
private readonly _bboxesForOffline: UIEventSource<[[number, number], [number, number]][]>
public readonly bboxesForOffline: UIEventSource<[[number, number], [number, number]][]>
private readonly _isUpdating: UIEventSource<boolean> = new UIEventSource(false)
public readonly isUpdating: Store<boolean> = this._isUpdating
public readonly updateWhenReconnected: UIEventSource<boolean>
public readonly updateOnLoad: UIEventSource<boolean>
constructor(state: WithLayoutSourceState) {
this._themeSource = state.indexedFeatures
this._bboxesForOffline =
UIEventSource.asObject<[[number, number], [number, number]][]>(LocalStorageSource.get("bboxes_for_offline_" + state.theme.id, "[]"), [])
this._bboxesForOffline.update(bboxes => OfflineForegroundDataManager.clean(bboxes))
this.updateWhenReconnected = LocalStorageSource.getParsed("updateWhenReconnected_" + state.theme.id, false)
this.updateOnLoad = LocalStorageSource.getParsed("updateOnLoad_" + state.theme.id, false)
this.bboxesForOffline = new UIEventSource(this._bboxesForOffline.data)
this.bboxesForOffline.addCallbackD(bboxes => {
bboxes = OfflineForegroundDataManager.clean(bboxes)
this._bboxesForOffline.set(bboxes) // Will trigger an update
})
this._bboxesForOffline.addCallbackD(bboxes => this.updateBboxes(bboxes))
this.updateWhenReconnected.once(() => {
IsOnline.isOnline.addCallbackD(isOnline => {
if (isOnline) {
this.updateAll()
}
})
}, v => v === true)
this.updateOnLoad.once(() => {
this.updateAll()
}, v => v === true)
}
public async updateAll() {
await this.updateBboxes(this._bboxesForOffline.data)
}
private async updateBboxes(bboxes: [[number, number], [number, number]][]) {
if (this._isUpdating.data) {
console.trace("Duplicate updateBboxes")
return
}
this._isUpdating.set(true)
for (const bboxCoor of bboxes) {
console.trace("Downloading ", bboxCoor.flatMap(x => x).join("; "))
await this._themeSource.downloadAll(new BBox(bboxCoor))
}
this._isUpdating.set(false)
}
private static clean(bboxes: [[number, number], [number, number]][]) {
const asBbox = bboxes.map(bbox => {
try {
return new BBox(bbox)
} catch (e) {
console.error("Got an invalid bbox:", bbox)
return undefined
}
})
const cleaned = BBox.dropFullyContained(Lists.noNull(asBbox))
return cleaned.map(bbox => bbox.toLngLat())
}
}

View file

@ -298,7 +298,7 @@ export class BBox {
* const expanded = bbox.expandToTileBounds(15)
* !isNaN(expanded.minLat) // => true
*/
expandToTileBounds(zoomlevel: number): BBox {
public expandToTileBounds(zoomlevel: number): BBox {
if (zoomlevel === undefined) {
return this
}
@ -309,7 +309,7 @@ export class BBox {
return new BBox([].concat(boundsul, boundslr))
}
toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } {
public toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } {
const [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
@ -341,7 +341,40 @@ export class BBox {
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
}
center() {
public center() {
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2]
}
/**
* Simplifies a list of bboxes so that bboxes that are fully contained are filtered out.
* Elements will keep the same relative order
*
* const bbox1 = new BBox([ 1,1,2,2])
* const bbox2 = new BBox([0,0,3,3])
* const bbox3 = new BBox([0,1,3,2])
* const bbox4 = new BBox([1,0,2,3])
* BBox.dropFullyContained([bbox1, bbox2]) // => [bbox2]
* BBox.dropFullyContained([bbox2, bbox1]) // => [bbox2]
* BBox.dropFullyContained([bbox2, bbox1, bbox3]) // => [bbox2]
* BBox.dropFullyContained([bbox4, bbox3]) // => [bbox4, bbox3]
* BBox.dropFullyContained([bbox3, bbox3]) // => [bbox3]
*
*/
static dropFullyContained(bboxes: ReadonlyArray<BBox>): BBox[] {
const newBboxes: BBox[] = []
for (const bbox of bboxes) {
if (newBboxes.some(newBbox => bbox.isContainedIn(newBbox))) {
continue
}
for (let i = newBboxes.length - 1; i >= 0; i--) {
const newBbox = newBboxes[i]
if (newBbox.isContainedIn(bbox)) {
newBboxes.splice(i, 1)
}
}
newBboxes.push(bbox)
}
return newBboxes
}
}

View file

@ -36,6 +36,8 @@ export interface FeatureSourceForTile<T extends Feature = Feature> extends Featu
/**
* A feature source which is aware of the indexes it contains
*/
export interface IndexedFeatureSource<T extends Feature> extends FeatureSource<T> {
export interface IndexedFeatureSource<T extends Feature = Feature<Geometry, Record<string, string> & {
id: string
}>> extends FeatureSource<T> {
readonly featuresById: Store<Map<string, Feature>>
}

View file

@ -22,7 +22,6 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
public readonly features: UIEventSource<T[]> = new UIEventSource(undefined)
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly timeout: UIEventSource<number> = new UIEventSource<number>(0)
private readonly retries: UIEventSource<number> = new UIEventSource<number>(0)
@ -102,19 +101,11 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
}
/**
* Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers
* Will always attempt to download, even is 'options.isActive.data' is 'false', the zoom level is incorrect, ...
* @private
* Attempts to download the data from an overpass server;
* fails over to the next server in case of failure
*/
public async updateAsync(overrideBounds?: BBox): Promise<void> {
if (!navigator.onLine) {
return
}
let data: { features: T[] } = undefined
let lastUsed = 0
const start = new Date()
private async attemptDownload(overrideBounds?: BBox, serverIndexStart?: number): Promise<{ features: T[] }> {
const layersToDownload = this._layersToDownload.data
if (layersToDownload.length == 0) {
return
}
@ -123,6 +114,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
if (overpassUrls === undefined || overpassUrls.length === 0) {
throw "Panic: overpassFeatureSource didn't receive any overpassUrls"
}
let serverToTry = serverIndexStart ?? 0 // Index in overpassUrls
// Note: the bounds are updated between attempts, in case that the user zoomed around
let bounds: BBox
do {
@ -136,37 +128,51 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
return
}
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload)
const overpass = this.getFilter(overpassUrls[serverToTry], layersToDownload)
if (overpass === undefined) {
return undefined
}
this.runningQuery.setData(true)
data = (await overpass.queryGeoJson<T>(bounds))[0]
const data = (await overpass.queryGeoJson<T>(bounds))[0]
if (data) {
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
return data // Success!
}
} catch (e) {
this.retries.data++
this.retries.ping()
this.retries.update(i => i + 1)
console.error(`QUERY FAILED due to`, e)
await Utils.waitFor(1000)
if (lastUsed + 1 < overpassUrls.length) {
lastUsed++
console.log("Trying next time with", overpassUrls[lastUsed])
} else {
lastUsed = 0
this.timeout.setData(this.retries.data * 5)
serverToTry++
serverToTry %= overpassUrls.length
console.log("Trying next time with", overpassUrls[serverToTry])
while (this.timeout.data > 0) {
await Utils.waitFor(1000)
this.timeout.data--
this.timeout.ping()
}
if (serverToTry === 0) {
// We do a longer timeout as we cycled through all our overpass instances
await Utils.waitFor(1000 * this.retries.data)
}
} finally {
this.retries.set(0)
}
} while (data === undefined && this._isActive.data)
} while (this._isActive.data)
}
/**
* Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers
* Will always attempt to download, even is 'options.isActive.data' is 'false', the zoom level is incorrect, ...
* @private
*/
public async updateAsync(overrideBounds?: BBox): Promise<void> {
if (!navigator.onLine) {
return
}
const start = new Date()
try {
this.runningQuery.setData(true)
const data: { features: T[] } = await this.attemptDownload(overrideBounds)
if (data === undefined) {
return undefined
}
@ -183,8 +189,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
"seconds"
)
this.features.setData(data.features)
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
} catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally {
@ -200,7 +205,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
* @constructor
* @private
*/
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
private getFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = layersToDownload.map((layer) => layer.source.osmTags)
filters = Lists.noNull(filters)
if (filters.length === 0) {
@ -222,10 +227,6 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
return undefined
}
if (this.timeout.data > 0) {
console.log("Still in timeout - not updating")
return undefined
}
const requestedBounds = this.state.bounds.data
if (
this._lastQueryBBox !== undefined &&

View file

@ -21,7 +21,10 @@ import { IsOnline } from "../../Web/IsOnline"
*
* Note that special layers (with `source=null` will be ignored)
*/
export default class ThemeSource<T extends Feature<Geometry, Record<string, any> & {id: string}>> implements IndexedFeatureSource<T> {
export default class ThemeSource<T extends Feature<Geometry, Record<string, any> & {
id: string
}> = Feature<Geometry, Record<string, string> & { id: string }>>
implements IndexedFeatureSource<T> {
/**
* Indicates if a data source is loading something
*/
@ -82,8 +85,9 @@ export default class ThemeSource<T extends Feature<Geometry, Record<string, any>
})
}
public async downloadAll() {
return this.core.data.downloadAll()
public async downloadAll(bbox?: BBox) {
const core = await this.core.awaitValue()
await core.downloadAll(bbox)
}
public addSource(source: FeatureSource<T>) {
@ -308,9 +312,9 @@ class ThemeSourceCore<T extends OsmFeature> extends FeatureSourceMerger<T> {
)
}
public async downloadAll() {
public async downloadAll(bbox?: BBox) {
console.log("Downloading all data:")
await this._downloadAll.updateAsync(this._mapBounds.data)
await this._downloadAll.updateAsync(bbox ?? this._mapBounds.data)
// await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
console.log("Done")
}

View file

@ -4,6 +4,8 @@ import { IsOnline } from "./Web/IsOnline"
import Constants from "../Models/Constants"
import { Store, UIEventSource } from "./UIEventSource"
import { Utils } from "../Utils"
import { BBox } from "./BBox"
import { Tiles } from "../Models/TileRange"
export interface AreaDescription {
/**
@ -170,7 +172,7 @@ export class OfflineBasemapManager {
* Where to get the initial map tiles
* @private
*/
private readonly _host: string
public readonly host: string
public static readonly zoomelevels: Record<number, number | undefined> = {
0: 4,
@ -207,7 +209,7 @@ export class OfflineBasemapManager {
if (!host.endsWith("/")) {
host += "/"
}
this._host = host
this.host = host
this.blobs = new TypedIdb("OfflineBasemap")
this.meta = new TypedIdb<AreaDescription>("OfflineBasemapMeta")
this.updateCachedMeta()
@ -264,7 +266,7 @@ export class OfflineBasemapManager {
* @private
*/
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
@ -448,4 +450,16 @@ export class OfflineBasemapManager {
}
return await this.fallback(params, abortController)
}
public async installBbox(bbox: BBox) {
const maxZoomlevel = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(s => Number(s)))
const range = Tiles.TileRangeBetween(maxZoomlevel, bbox.maxLat, bbox.maxLon, bbox.minLat, bbox.minLon)
if (range.total > 100) {
throw "BBox is to big to isntall"
}
await Tiles.mapRangeAsync(range, (x, y) => {
const z = maxZoomlevel
return this.autoInstall({ z, x, y })
})
}
}

View file

@ -49,21 +49,26 @@ export class Overpass {
bounds.getEast() +
"]"
const query = this.buildScript(bbox)
return await this.ExecuteQuery<T>(query)
return await this.executeQuery<T>(query)
}
public buildUrl(query: string) {
return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
}
private async ExecuteQuery<T extends Feature>(
private async executeQuery<T extends Feature>(
query: string
): Promise<[{features: T[]}, Date]> {
const json = await Utils.downloadJson<{
const jsonResult = await Utils.downloadJsonAdvanced<{
elements: []
remark
osm3s: { timestamp_osm_base: string }
}>(this.buildUrl(query), {})
}>(this.buildUrl(query), {}, 1)
if (jsonResult["error"]) {
throw jsonResult["error"]
}
const json = jsonResult["content"]
if (json.elements.length === 0 && json.remark !== undefined) {
console.warn("Timeout or other runtime error while querying overpass", json.remark)

View file

@ -6,6 +6,8 @@ import { MapProperties } from "../../Models/MapProperties"
/**
* Does the user interaction state with a geolocation button, such as keeping track of the last click,
* and lock status + moving the map when clicked
*
* Note: _not_ part of the big hierarchy
*/
export class GeolocationControlState {
public readonly lastClick = new UIEventSource<Date>(undefined)

View file

@ -346,16 +346,27 @@ export abstract class Store<T> implements Readable<T> {
* @param callback
* @param condition
*/
public once(callback: () => void, condition?: (v: T) => boolean) {
public once(callback: (t: T) => void, condition?: (v: T) => boolean) {
condition ??= (v) => !!v
this.addCallbackAndRunD((v) => {
if (condition(v)) {
callback()
callback(v)
return true
}
})
}
/**
* awaits until there is a value meeting the condition (default: not null, not undefined); return this value
* If the store contains a value already matching the criteria, the promise will resolve immediately
*/
public awaitValue(condition?: (v: T) => boolean): Promise<T> {
return new Promise(resolve => {
this.once(value => resolve(value), condition)
})
}
/**
* Create a new UIEVentSource. Whenever 'this.data' changes, the returned UIEventSource will get this value as well.
* However, this value can be overridden without affecting source

View file

@ -1,5 +1,6 @@
import { Store, UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "./LocalStorageSource"
export class IsOnline {
private static readonly _isOnline: UIEventSource<boolean> = new UIEventSource(
@ -17,5 +18,10 @@ export class IsOnline {
}
}
public static readonly isOnline: Store<boolean> = IsOnline._isOnline
/**
* Doesn't yet properly work - many features don't use 'isOnline' yet to guard features but rather attempt to connect and fallback through failure
*/
public static readonly forceOffline: UIEventSource<boolean> = UIEventSource.asBoolean(LocalStorageSource.get("forceOffline", "false"))
public static readonly isOnline: Store<boolean> = IsOnline._isOnline.map(online => online && !IsOnline.forceOffline.data, [IsOnline.forceOffline])
}

View file

@ -10,6 +10,7 @@ import { Tag } from "../../Logic/Tags/Tag"
import Hotkeys from "../../UI/Base/Hotkeys"
import Translations from "../../UI/i18n/Translations"
import { Feature } from "geojson"
import { OfflineForegroundDataManager } from "../../Logic/Actors/OfflineForegroundDataManager"
export class WithLayoutSourceState extends WithSelectedElementState {
readonly layerState: LayerState
@ -23,6 +24,8 @@ export class WithLayoutSourceState extends WithSelectedElementState {
*/
readonly floors: Store<string[]>
readonly offlineForegroundDataManager: OfflineForegroundDataManager
constructor(theme: ThemeConfig, mvtAvailableLayers: Store<Set<string>>) {
super(theme)
/* Set up the layout source
@ -49,6 +52,7 @@ export class WithLayoutSourceState extends WithSelectedElementState {
this.dataIsLoading = layoutSource.isLoading
this.indexedFeatures = layoutSource
this.featureProperties = new FeaturePropertiesStore(layoutSource)
this.offlineForegroundDataManager = new OfflineForegroundDataManager(this)
this.floors = WithLayoutSourceState.initFloors(this.featuresInView)

View file

@ -26,6 +26,21 @@ export class Tiles {
return result
}
public static async mapRangeAsync<T>(tileRange: TileRange, f: (x: number, y: number) => Promise<T>): Promise<T[]> {
const result: T[] = []
const total = tileRange.total
if (total > 100000) {
throw `Tilerange too big (z is ${tileRange.zoomlevel}, total tiles needed: ${tileRange.total})`
}
for (let x = tileRange.xstart; x <= tileRange.xend; x++) {
for (let y = tileRange.ystart; y <= tileRange.yend; y++) {
const t = await f(x, y)
result.push(t)
}
}
return result
}
/**
* Calculates the tile bounds of the
* @param z
@ -63,7 +78,7 @@ export class Tiles {
static centerPointOf(zOrId: number, x?: number, y?: number): [number, number] {
let z: number
if (x === undefined) {
;[z, x, y] = Tiles.tile_from_index(zOrId)
[z, x, y] = Tiles.tile_from_index(zOrId)
} else {
z = zOrId
}
@ -95,7 +110,7 @@ export class Tiles {
static asGeojson(zIndex: number, x?: number, y?: number): Feature<Polygon> {
let z = zIndex
if (x === undefined) {
;[z, x, y] = Tiles.tile_from_index(zIndex)
[z, x, y] = Tiles.tile_from_index(zIndex)
}
const bounds = Tiles.tile_bounds_lon_lat(z, x, y)
return new BBox(bounds).asGeoJson()

View file

@ -137,7 +137,7 @@
<Avatar userdetails={state.osmConnection.userDetails} />
<div class="flex flex-col w-full gap-y-2">
<div class="flex w-full flex-col gap-y-2">
<b>{$userdetails.name}</b>
<b>{$userdetails?.name}</b>
<LogoutButton clss="as-link small subtle text-sm" osmConnection={state.osmConnection} />
</div>
</div>
@ -201,7 +201,7 @@
<Page {onlyLink} shown={pg.manageOffline} fullscreen>
<svelte:fragment slot="header">
<GlobeEuropeAfrica />
Manage offline basemap
Manage map data for offline use
</svelte:fragment>
<OfflineManagement {state} />
</Page>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import ThemeViewState from "../../Models/ThemeViewState"
import Checkbox from "../Base/Checkbox.svelte"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Map as MlMap } from "maplibre-gl"
import type { MapProperties } from "../../Models/MapProperties"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import { OfflineBasemapManager } from "../../Logic/OfflineBasemapManager"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import { BBox } from "../../Logic/BBox"
import ShowDataLayer from "../Map/ShowDataLayer"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { Feature, Polygon } from "geojson"
import Loading from "../Base/Loading.svelte"
import { Lists } from "../../Utils/Lists"
import { ArrowPathIcon, TrashIcon } from "@babeard/svelte-heroicons/mini"
import { DownloadIcon } from "@rgossiaux/svelte-heroicons/solid"
import { IsOnline } from "../../Logic/Web/IsOnline"
export let state: ThemeViewState
let map: UIEventSource<MlMap> = new UIEventSource(undefined)
let mapProperties: MapProperties = new MapLibreAdaptor(map).installQuicklocation()
let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number))
let offlineManager = state.offlineForegroundDataManager
state?.showCurrentLocationOn(map)
mapProperties.maxzoom.set(focusZ - 1)
mapProperties.zoom.set(Math.min(focusZ - 1, state.mapProperties.zoom.data))
mapProperties.location.set(state.mapProperties.location.data)
mapProperties.allowRotating.set(false)
let autoUpdateReconnected = offlineManager.updateWhenReconnected
let autoUpdateOnLoad = offlineManager.updateOnLoad
let bboxes = offlineManager.bboxesForOffline
let bboxesAsGeojson = new StaticFeatureSource<Feature<Polygon, {
id: string
}>>(bboxes.map(bboxes => Lists.noNull(bboxes.map((bbox, i) => {
try {
return new BBox(bbox).asGeoJson({
id: "bbox-offline-" + i
})
} catch (e) {
return undefined
}
}))))
const viewportSource: Store<Feature<Polygon, { id: string }>[]> = mapProperties.bounds.mapD(bounds => {
const centerLat = (bounds.maxLat + bounds.minLat) / 2
const diff = Math.min(bounds.maxLat - bounds.minLat, bounds.maxLon - bounds.minLon) / Math.cos(centerLat * Math.PI / 180)
const centerLon = (bounds.maxLon + bounds.minLon) / 2
const lonDiff = ((diff) * Math.cos(centerLat * Math.PI / 180)) / 4
const bbox = new BBox([centerLon - diff / 4, centerLat - lonDiff / 4, centerLon + diff / 4, centerLat + lonDiff])
const f = bbox.asGeoJson({
id: "viewport"
})
return [<Feature<Polygon, { id: string }>>f]
}, [mapProperties.zoom])
let areaSelection = new StaticFeatureSource<Feature<Polygon, { id: string }>>(viewportSource)
let isUpdating = offlineManager.isUpdating
const offlineLayer = new LayerConfig({
id: "downloaded",
source: "special",
lineRendering: [
{
color: "blue"
}
],
pointRendering: [
{
location: ["point", "centroid"],
label: "{text}",
labelCss: "width: w-min",
labelCssClasses: "bg-white rounded px-2 items-center flex flex-col"
}
]
})
const viewportLayer = new LayerConfig({
id: "viewport",
source: "special",
lineRendering: [
{
color: "blue",
fillColor: "#0000"
}
],
pointRendering: [
{
location: ["point", "centroid"],
label: "{text}",
labelCss: "width: w-min",
labelCssClasses: "bg-white rounded px-2 items-center flex flex-col"
}
]
})
new ShowDataLayer(map, {
layer: offlineLayer,
features: bboxesAsGeojson
})
new ShowDataLayer(map, {
layer: viewportLayer,
features: areaSelection
})
function updateAll() {
offlineManager.updateAll()
}
function addBox() {
const f = viewportSource.data[0]
const bbox = BBox.get(f)
offlineManager.bboxesForOffline.update(ls => [...ls, bbox.toLngLat()])
try {
state.offlineMapManager.installBbox(bbox) // Install the background map as well
} catch (e) {
// Area is probably too big to install (>200 tiles of z=10)
console.log(e)
}
}
function deleteAll() {
offlineManager.bboxesForOffline.set([])
}
const isOnline = IsOnline.isOnline
</script>
<div class="h-1/2 relative">
<div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} />
</div>
<div class="pointer-events-none absolute w-full h-full flex items-end p-4 justify-center">
<button class="primary pointer-events-auto" class:disabled={!$isOnline || $isUpdating} on:click={() => addBox()}>
<DownloadIcon class="w-6 h-6" />
Keep selected area available offline
</button>
</div>
</div>
{#if !$isOnline}
<div class="alert">Offline mode - cannot update now</div>
{:else if $isUpdating}
<Loading>Updating data...</Loading>
{:else}
<button on:click={() => updateAll()}>
<ArrowPathIcon class="w-6 h-6" />
Update all marked areas now
</button>
{/if}
<Checkbox selected={autoUpdateReconnected}>
Automatically update the foreground data of marked areas when internet connection is connected again
</Checkbox>
<Checkbox selected={autoUpdateOnLoad}>
Automatically update the foreground data of marked areas when loading MapComplete
</Checkbox>
<button on:click={() => deleteAll()} class:disabled={$bboxes.length === 0}>
<TrashIcon class="w-6 h-6" color="red" />
Delete offline areas
</button>

View file

@ -24,13 +24,15 @@
import { default as Trans } from "../Base/Tr.svelte"
import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
import { Lists } from "../../Utils/Lists"
import OfflineForegroundManagement from "./OfflineForegroundManagement.svelte"
import { IsOnline } from "../../Logic/Web/IsOnline"
export let state: ThemeViewState & SpecialVisualizationState = undefined
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)
let mapProperties: MapProperties = new MapLibreAdaptor(map).installQuicklocation()
state?.showCurrentLocationOn(map)
mapProperties.maxzoom.set(focusZ - 1)
mapProperties.zoom.set(Math.min(focusZ - 1, state.mapProperties.zoom.data))
@ -77,9 +79,9 @@
id: "center_point_" + z + "_" + x + "_" + y,
txt: "Tile " + x + " " + y,
}
return [f]
return [<Feature<Polygon, { id: string }>>f]
})
let installedFeature: Store<Feature<Polygon>[]> = installed.map((meta) =>
let installedFeature: Store<Feature<Polygon, { id: string }>[]> = installed.map((meta) =>
(meta ?? []).map((area) => {
const f = Tiles.asGeojson(area.minzoom, area.x, area.y)
f.properties = {
@ -92,11 +94,11 @@
" " +
Utils.toHumanByteSize(Number(area.size)),
}
return f
return <Feature<Polygon, { id: string }>>f
})
)
new ShowDataLayer(map, {
features: new StaticFeatureSource(installedFeature),
features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(installedFeature),
layer: new LayerConfig({
id: "downloaded",
source: "special",
@ -132,7 +134,7 @@
}),
})
new ShowDataLayer(map, {
features: new StaticFeatureSource(focusTileFeature),
features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(focusTileFeature),
layer: new LayerConfig({
id: "focustile",
source: "special",
@ -152,57 +154,62 @@
}),
})
const t = Translations.t.offline
</script>
<div class="max-h-leave-room flex h-full flex-col overflow-auto">
<Checkbox selected={autoDownload}>
<Trans t={t.autoCheckmark} />
</Checkbox>
<AccordionSingle noBorder>
<Trans slot="header" cls="text-sm" t={t.autoExplanationIntro} />
<div class="low-interaction">
<Trans t={t.autoExplanation} />
</div>
</AccordionSingle>
<div />
<div class="max-h-leave-room flex h-full flex-col justify-between overflow-auto">
<div class="leave-room w-full p-2 m-0">
<OfflineForegroundManagement {state} />
</div>
{#if $installed === undefined}
<Loading />
{:else}
<div class="pb-16">
<Accordion class="" inactiveClass="text-black">
<AccordionItem paddingDefault="p-2">
<Trans slot="header" t={t.localOnMap} />
<div class="leave-room relative">
<div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} />
</div>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center"
>
<div class="mb-16 h-32 w-16" />
{#if $focusTileIsInstalling}
<div class="normal-background rounded-lg">
<Loading>
<Trans t={t.installing} />
</Loading>
</div>
{:else}
<button
class="primary pointer-events-auto"
on:click={() => download()}
class:disabled={$focusTileIsInstalled}
>
<DownloadIcon class="h-8 w-8" />
<Trans t={t.download} />
</button>
{/if}
</div>
</div>
</AccordionItem>
<AccordionItem paddingDefault="p-2">
<Trans t={t.overview} slot="header" />
The offline background map is the basemap that is shown as background under the clickable map features
(protomaps sunny).
The data for this map is shared between all MapComplete themes.
These are downloaded from {state.offlineMapManager.host} which has a weekly update schedule.
<Checkbox selected={autoDownload}>
<Trans t={t.autoCheckmark} />
</Checkbox>
<AccordionSingle noBorder>
<Trans slot="header" cls="text-sm" t={t.autoExplanationIntro} />
<div class="low-interaction">
<Trans t={t.autoExplanation} />
</div>
</AccordionSingle>
<div class="leave-room">
<div class="h-1/2 relative">
<div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} />
</div>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center"
>
<div class="mb-16 h-32 w-16" />
{#if $focusTileIsInstalling}
<div class="normal-background rounded-lg">
<Loading>
<Trans t={t.installing} />
</Loading>
</div>
{:else}
<button
class="primary pointer-events-auto"
on:click={() => download()}
class:disabled={$focusTileIsInstalled}
>
<DownloadIcon class="h-8 w-8" />
<Trans t={t.download} />
</button>
{/if}
</div>
</div>
{Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))}
<button
on:click={() => {

View file

@ -777,7 +777,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
* In that case, calling this method will install an extra handler on 'drag', updating the location faster.
* To avoid rendering artefacts or too frequenting pinging, this is ratelimited to one update every 'rateLimitMs' milliseconds
*/
public installQuicklocation(ratelimitMs = 50) {
public installQuicklocation(ratelimitMs = 50): this {
this._maplibreMap.addCallbackAndRunD((map) => {
let lastUpdate = new Date().getTime()
map.on("drag", () => {
@ -788,7 +788,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
lastUpdate = now
const center = map.getCenter()
this.location.set({ lon: center.lng, lat: center.lat })
const bounds = map.getBounds()
const bbox = new BBox([
[bounds.getEast(), bounds.getNorth()],
[bounds.getWest(), bounds.getSouth()]
])
this.bounds.set(bbox)
})
})
return this
}
}