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",
|
"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",
|
||||||
|
|
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)
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 (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 {
|
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 &&
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 })
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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>
|
||||||
|
|
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 { 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,57 +154,62 @@
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
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">
|
||||||
<Checkbox selected={autoDownload}>
|
<div class="leave-room w-full p-2 m-0">
|
||||||
<Trans t={t.autoCheckmark} />
|
<OfflineForegroundManagement {state} />
|
||||||
</Checkbox>
|
</div>
|
||||||
<AccordionSingle noBorder>
|
|
||||||
<Trans slot="header" cls="text-sm" t={t.autoExplanationIntro} />
|
|
||||||
<div class="low-interaction">
|
|
||||||
<Trans t={t.autoExplanation} />
|
|
||||||
</div>
|
|
||||||
</AccordionSingle>
|
|
||||||
<div />
|
|
||||||
{#if $installed === undefined}
|
{#if $installed === undefined}
|
||||||
<Loading />
|
<Loading />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="pb-16">
|
<div class="pb-16">
|
||||||
<Accordion class="" inactiveClass="text-black">
|
<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">
|
<AccordionItem paddingDefault="p-2">
|
||||||
<Trans t={t.overview} slot="header" />
|
<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="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)))}
|
{Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))}
|
||||||
<button
|
<button
|
||||||
on:click={() => {
|
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.
|
* 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue