forked from MapComplete/MapComplete
Feature: first version of offline foreground data management
This commit is contained in:
parent
5a0866ff08
commit
988643dafa
17 changed files with 470 additions and 109 deletions
|
@ -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",
|
||||
|
|
84
src/Logic/Actors/OfflineForegroundDataManager.ts
Normal file
84
src/Logic/Actors/OfflineForegroundDataManager.ts
Normal 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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
166
src/UI/BigComponents/OfflineForegroundManagement.svelte
Normal file
166
src/UI/BigComponents/OfflineForegroundManagement.svelte
Normal 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>
|
|
@ -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={() => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue