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", "installing": "Data is being downloaded",
"localOnMap": "Offline basemaps on the map", "localOnMap": "Offline basemaps on the map",
"name": "Name", "name": "Name",
"overview": "Offline basemaps overview", "overview": "Offline background map",
"range": "Zoom ranges", "range": "Zoom ranges",
"size": "Size" "size": "Size"
}, },
@ -839,7 +839,7 @@
"reviews": { "reviews": {
"affiliated_reviewer_warning": "(Affiliated review)", "affiliated_reviewer_warning": "(Affiliated review)",
"attribution": "By Mangrove.reviews", "attribution": "By Mangrove.reviews",
"averageRating": "Average rating of {n} stars", "averageRating": "Average ting of {n} stars",
"delete": "Delete review", "delete": "Delete review",
"deleteConfirm": "Permanently delete this review", "deleteConfirm": "Permanently delete this review",
"deleteText": "This cannot be undone", "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) * const expanded = bbox.expandToTileBounds(15)
* !isNaN(expanded.minLat) // => true * !isNaN(expanded.minLat) // => true
*/ */
expandToTileBounds(zoomlevel: number): BBox { public expandToTileBounds(zoomlevel: number): BBox {
if (zoomlevel === undefined) { if (zoomlevel === undefined) {
return this return this
} }
@ -309,7 +309,7 @@ export class BBox {
return new BBox([].concat(boundsul, boundslr)) 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 [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat]) const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
@ -341,7 +341,40 @@ export class BBox {
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0 return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
} }
center() { public center() {
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2] 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 * 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>> 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 features: UIEventSource<T[]> = new UIEventSource(undefined)
public readonly runningQuery: UIEventSource<boolean> = new UIEventSource<boolean>(false) 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) 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 * Attempts to download the data from an overpass server;
* Will always attempt to download, even is 'options.isActive.data' is 'false', the zoom level is incorrect, ... * fails over to the next server in case of failure
* @private
*/ */
public async updateAsync(overrideBounds?: BBox): Promise<void> { private async attemptDownload(overrideBounds?: BBox, serverIndexStart?: number): Promise<{ features: T[] }> {
if (!navigator.onLine) {
return
}
let data: { features: T[] } = undefined
let lastUsed = 0
const start = new Date()
const layersToDownload = this._layersToDownload.data const layersToDownload = this._layersToDownload.data
if (layersToDownload.length == 0) { if (layersToDownload.length == 0) {
return return
} }
@ -123,6 +114,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
if (overpassUrls === undefined || overpassUrls.length === 0) { if (overpassUrls === undefined || overpassUrls.length === 0) {
throw "Panic: overpassFeatureSource didn't receive any overpassUrls" 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 // Note: the bounds are updated between attempts, in case that the user zoomed around
let bounds: BBox let bounds: BBox
do { do {
@ -136,37 +128,51 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
return return
} }
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload) const overpass = this.getFilter(overpassUrls[serverToTry], layersToDownload)
if (overpass === undefined) { if (overpass === undefined) {
return undefined return undefined
} }
this.runningQuery.setData(true) const data = (await overpass.queryGeoJson<T>(bounds))[0]
data = (await overpass.queryGeoJson<T>(bounds))[0] if (data) {
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
return data // Success!
}
} catch (e) { } catch (e) {
this.retries.data++ this.retries.update(i => i + 1)
this.retries.ping()
console.error(`QUERY FAILED due to`, e) console.error(`QUERY FAILED due to`, e)
await Utils.waitFor(1000) await Utils.waitFor(1000)
if (lastUsed + 1 < overpassUrls.length) { serverToTry++
lastUsed++ serverToTry %= overpassUrls.length
console.log("Trying next time with", overpassUrls[lastUsed]) console.log("Trying next time with", overpassUrls[serverToTry])
} else {
lastUsed = 0
this.timeout.setData(this.retries.data * 5)
while (this.timeout.data > 0) { if (serverToTry === 0) {
await Utils.waitFor(1000) // We do a longer timeout as we cycled through all our overpass instances
this.timeout.data-- await Utils.waitFor(1000 * this.retries.data)
this.timeout.ping()
} }
} finally {
this.retries.set(0)
} }
} } while (this._isActive.data)
} while (data === undefined && 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 { try {
this.runningQuery.setData(true)
const data: { features: T[] } = await this.attemptDownload(overrideBounds)
if (data === undefined) { if (data === undefined) {
return undefined return undefined
} }
@ -183,8 +189,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
"seconds" "seconds"
) )
this.features.setData(data.features) this.features.setData(data.features)
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
} catch (e) { } catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack) console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally { } finally {
@ -200,7 +205,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
* @constructor * @constructor
* @private * @private
*/ */
private GetFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass { private getFilter(interpreterUrl: string, layersToDownload: LayerConfig[]): Overpass {
let filters: TagsFilter[] = layersToDownload.map((layer) => layer.source.osmTags) let filters: TagsFilter[] = layersToDownload.map((layer) => layer.source.osmTags)
filters = Lists.noNull(filters) filters = Lists.noNull(filters)
if (filters.length === 0) { if (filters.length === 0) {
@ -222,10 +227,6 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature> im
return undefined return undefined
} }
if (this.timeout.data > 0) {
console.log("Still in timeout - not updating")
return undefined
}
const requestedBounds = this.state.bounds.data const requestedBounds = this.state.bounds.data
if ( if (
this._lastQueryBBox !== undefined && this._lastQueryBBox !== undefined &&

View file

@ -21,7 +21,10 @@ import { IsOnline } from "../../Web/IsOnline"
* *
* Note that special layers (with `source=null` will be ignored) * 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 * 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() { public async downloadAll(bbox?: BBox) {
return this.core.data.downloadAll() const core = await this.core.awaitValue()
await core.downloadAll(bbox)
} }
public addSource(source: FeatureSource<T>) { 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:") 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())) // await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
console.log("Done") console.log("Done")
} }

View file

@ -4,6 +4,8 @@ import { IsOnline } from "./Web/IsOnline"
import Constants from "../Models/Constants" import Constants from "../Models/Constants"
import { Store, UIEventSource } from "./UIEventSource" import { Store, UIEventSource } from "./UIEventSource"
import { Utils } from "../Utils" import { Utils } from "../Utils"
import { BBox } from "./BBox"
import { Tiles } from "../Models/TileRange"
export interface AreaDescription { export interface AreaDescription {
/** /**
@ -170,7 +172,7 @@ export class OfflineBasemapManager {
* Where to get the initial map tiles * Where to get the initial map tiles
* @private * @private
*/ */
private readonly _host: string public readonly host: string
public static readonly zoomelevels: Record<number, number | undefined> = { public static readonly zoomelevels: Record<number, number | undefined> = {
0: 4, 0: 4,
@ -207,7 +209,7 @@ export class OfflineBasemapManager {
if (!host.endsWith("/")) { if (!host.endsWith("/")) {
host += "/" host += "/"
} }
this._host = host this.host = host
this.blobs = new TypedIdb("OfflineBasemap") this.blobs = new TypedIdb("OfflineBasemap")
this.meta = new TypedIdb<AreaDescription>("OfflineBasemapMeta") this.meta = new TypedIdb<AreaDescription>("OfflineBasemapMeta")
this.updateCachedMeta() this.updateCachedMeta()
@ -264,7 +266,7 @@ export class OfflineBasemapManager {
* @private * @private
*/ */
public async installArea(areaDescription: AreaDescription) { public async installArea(areaDescription: AreaDescription) {
const target = this._host + areaDescription.name const target = this.host + areaDescription.name
if (this.isInstalled(areaDescription)) { if (this.isInstalled(areaDescription)) {
// Already installed // Already installed
return true return true
@ -448,4 +450,16 @@ export class OfflineBasemapManager {
} }
return await this.fallback(params, abortController) 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() + bounds.getEast() +
"]" "]"
const query = this.buildScript(bbox) const query = this.buildScript(bbox)
return await this.ExecuteQuery<T>(query) return await this.executeQuery<T>(query)
} }
public buildUrl(query: string) { public buildUrl(query: string) {
return `${this._interpreterUrl}?data=${encodeURIComponent(query)}` return `${this._interpreterUrl}?data=${encodeURIComponent(query)}`
} }
private async ExecuteQuery<T extends Feature>( private async executeQuery<T extends Feature>(
query: string query: string
): Promise<[{features: T[]}, Date]> { ): Promise<[{features: T[]}, Date]> {
const json = await Utils.downloadJson<{ const jsonResult = await Utils.downloadJsonAdvanced<{
elements: [] elements: []
remark remark
osm3s: { timestamp_osm_base: string } 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) { if (json.elements.length === 0 && json.remark !== undefined) {
console.warn("Timeout or other runtime error while querying overpass", json.remark) 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, * 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 * and lock status + moving the map when clicked
*
* Note: _not_ part of the big hierarchy
*/ */
export class GeolocationControlState { export class GeolocationControlState {
public readonly lastClick = new UIEventSource<Date>(undefined) public readonly lastClick = new UIEventSource<Date>(undefined)

View file

@ -346,16 +346,27 @@ export abstract class Store<T> implements Readable<T> {
* @param callback * @param callback
* @param condition * @param condition
*/ */
public once(callback: () => void, condition?: (v: T) => boolean) { public once(callback: (t: T) => void, condition?: (v: T) => boolean) {
condition ??= (v) => !!v condition ??= (v) => !!v
this.addCallbackAndRunD((v) => { this.addCallbackAndRunD((v) => {
if (condition(v)) { if (condition(v)) {
callback() callback(v)
return true 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. * 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 * However, this value can be overridden without affecting source

View file

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

View file

@ -26,6 +26,21 @@ export class Tiles {
return result 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 * Calculates the tile bounds of the
* @param z * @param z
@ -63,7 +78,7 @@ export class Tiles {
static centerPointOf(zOrId: number, x?: number, y?: number): [number, number] { static centerPointOf(zOrId: number, x?: number, y?: number): [number, number] {
let z: number let z: number
if (x === undefined) { if (x === undefined) {
;[z, x, y] = Tiles.tile_from_index(zOrId) [z, x, y] = Tiles.tile_from_index(zOrId)
} else { } else {
z = zOrId z = zOrId
} }
@ -95,7 +110,7 @@ export class Tiles {
static asGeojson(zIndex: number, x?: number, y?: number): Feature<Polygon> { static asGeojson(zIndex: number, x?: number, y?: number): Feature<Polygon> {
let z = zIndex let z = zIndex
if (x === undefined) { 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) const bounds = Tiles.tile_bounds_lon_lat(z, x, y)
return new BBox(bounds).asGeoJson() return new BBox(bounds).asGeoJson()

View file

@ -137,7 +137,7 @@
<Avatar userdetails={state.osmConnection.userDetails} /> <Avatar userdetails={state.osmConnection.userDetails} />
<div class="flex flex-col w-full gap-y-2"> <div class="flex flex-col w-full gap-y-2">
<div class="flex w-full flex-col 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} /> <LogoutButton clss="as-link small subtle text-sm" osmConnection={state.osmConnection} />
</div> </div>
</div> </div>
@ -201,7 +201,7 @@
<Page {onlyLink} shown={pg.manageOffline} fullscreen> <Page {onlyLink} shown={pg.manageOffline} fullscreen>
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<GlobeEuropeAfrica /> <GlobeEuropeAfrica />
Manage offline basemap Manage map data for offline use
</svelte:fragment> </svelte:fragment>
<OfflineManagement {state} /> <OfflineManagement {state} />
</Page> </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 { default as Trans } from "../Base/Tr.svelte"
import AccordionSingle from "../Flowbite/AccordionSingle.svelte" import AccordionSingle from "../Flowbite/AccordionSingle.svelte"
import { Lists } from "../../Utils/Lists" import { Lists } from "../../Utils/Lists"
import OfflineForegroundManagement from "./OfflineForegroundManagement.svelte"
import { IsOnline } from "../../Logic/Web/IsOnline"
export let state: ThemeViewState & SpecialVisualizationState = undefined export let state: ThemeViewState & SpecialVisualizationState = undefined
export let autoDownload = state.autoDownloadOfflineBasemap export let autoDownload = state.autoDownloadOfflineBasemap
let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number)) let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number))
let map: UIEventSource<MlMap> = new UIEventSource(undefined) let map: UIEventSource<MlMap> = new UIEventSource(undefined)
let mapProperties: MapProperties = new MapLibreAdaptor(map) let mapProperties: MapProperties = new MapLibreAdaptor(map).installQuicklocation()
state?.showCurrentLocationOn(map) state?.showCurrentLocationOn(map)
mapProperties.maxzoom.set(focusZ - 1) mapProperties.maxzoom.set(focusZ - 1)
mapProperties.zoom.set(Math.min(focusZ - 1, state.mapProperties.zoom.data)) mapProperties.zoom.set(Math.min(focusZ - 1, state.mapProperties.zoom.data))
@ -77,9 +79,9 @@
id: "center_point_" + z + "_" + x + "_" + y, id: "center_point_" + z + "_" + x + "_" + y,
txt: "Tile " + 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) => { (meta ?? []).map((area) => {
const f = Tiles.asGeojson(area.minzoom, area.x, area.y) const f = Tiles.asGeojson(area.minzoom, area.x, area.y)
f.properties = { f.properties = {
@ -92,11 +94,11 @@
" " + " " +
Utils.toHumanByteSize(Number(area.size)), Utils.toHumanByteSize(Number(area.size)),
} }
return f return <Feature<Polygon, { id: string }>>f
}) })
) )
new ShowDataLayer(map, { new ShowDataLayer(map, {
features: new StaticFeatureSource(installedFeature), features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(installedFeature),
layer: new LayerConfig({ layer: new LayerConfig({
id: "downloaded", id: "downloaded",
source: "special", source: "special",
@ -132,7 +134,7 @@
}), }),
}) })
new ShowDataLayer(map, { new ShowDataLayer(map, {
features: new StaticFeatureSource(focusTileFeature), features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(focusTileFeature),
layer: new LayerConfig({ layer: new LayerConfig({
id: "focustile", id: "focustile",
source: "special", source: "special",
@ -152,9 +154,24 @@
}), }),
}) })
const t = Translations.t.offline const t = Translations.t.offline
</script> </script>
<div class="max-h-leave-room flex h-full flex-col overflow-auto"> <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 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}> <Checkbox selected={autoDownload}>
<Trans t={t.autoCheckmark} /> <Trans t={t.autoCheckmark} />
</Checkbox> </Checkbox>
@ -164,15 +181,9 @@
<Trans t={t.autoExplanation} /> <Trans t={t.autoExplanation} />
</div> </div>
</AccordionSingle> </AccordionSingle>
<div />
{#if $installed === undefined} <div class="leave-room">
<Loading /> <div class="h-1/2 relative">
{: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"> <div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} /> <MaplibreMap {map} {mapProperties} />
</div> </div>
@ -198,11 +209,7 @@
{/if} {/if}
</div> </div>
</div> </div>
</AccordionItem>
<AccordionItem paddingDefault="p-2">
<Trans t={t.overview} slot="header" />
<div class="leave-room">
{Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))} {Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))}
<button <button
on:click={() => { 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. * 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 * 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) => { this._maplibreMap.addCallbackAndRunD((map) => {
let lastUpdate = new Date().getTime() let lastUpdate = new Date().getTime()
map.on("drag", () => { map.on("drag", () => {
@ -788,7 +788,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
lastUpdate = now lastUpdate = now
const center = map.getCenter() const center = map.getCenter()
this.location.set({ lon: center.lng, lat: center.lat }) 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
} }
} }