diff --git a/android b/android index dc3f3f5ac..472b6da88 160000 --- a/android +++ b/android @@ -1 +1 @@ -Subproject commit dc3f3f5ac3d4d42bed7aeb58ff5386a063dbac06 +Subproject commit 472b6da88e038d34e2915ebd36350376acf0c58c diff --git a/langs/en.json b/langs/en.json index ea1cd894c..5213c68c7 100644 --- a/langs/en.json +++ b/langs/en.json @@ -778,19 +778,29 @@ }, "offline": { "actions": "Actions", + "alreadyOffline": "This area is already offline", + "areaToBig": "This area is to big to keep offline", "autoCheckmark": "Automatically download the basemap when browsing around", "autoExplanation": "If checked, MapComplete will automatically download the basemap to the cache for the area. This results in bigger initial data loads, but requires less internet over the long run. If you plan to visit a region with less connectivity, you can also select the area you want to download below.", "autoExplanationIntro": "What does automatically downloading basemaps mean?", "date": "Map generation data", "delete": "Delete basemap", "deleteAll": "Delete all basemaps", + "deleteAreas": "Clear offline areas", "download": "Download area", "installing": "Data is being downloaded", + "intro": " The offline background map is the basemap that is shown as background under the clickable map features (protomaps sunny). he data for this map is shared between all MapComplete themes. These are downloaded from {host} which has a weekly update schedule.", "localOnMap": "Offline basemaps on the map", + "markArea": "Keep this area available offline", "name": "Name", - "overview": "Offline basemaps overview", + "offlineNoUpdate": "Updating is currently not possible as you are offline", + "overview": "Offline background map", "range": "Zoom ranges", - "size": "Size" + "size": "Size", + "updateAll": "Update all offline areas", + "updateOnLoad": "Update the areas when loading", + "updateOnReconnect": "Update the available areas when connection is restored", + "updating": "Updating..." }, "plantDetection": { "back": "Back to species overview", @@ -853,7 +863,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", diff --git a/scripts/pmTilesExtractGenerator.ts b/scripts/pmTilesExtractGenerator.ts index 74ec5c0b2..50ab37b4a 100644 --- a/scripts/pmTilesExtractGenerator.ts +++ b/scripts/pmTilesExtractGenerator.ts @@ -46,12 +46,17 @@ export class PmTilesExtractGenerator { return `${this._targetDir}/${z}/${x}/${y}.pmtiles` } - async generateArchive(z: number, x: number, y: number, maxzoom?: number): Promise { + async generateArchive(z: number, x: number, y: number, maxzoom: number): Promise { const [[max_lat, min_lon], [min_lat, max_lon]] = Tiles.tile_bounds(z, x, y) let maxzoomflag = "" if (maxzoom !== undefined) { maxzoomflag = " --maxzoom=" + maxzoom } + + if(!maxzoom && z < 15){ + throw "No maxzoom for a pretty low zoom detected. This will result in a big archive" + } + const outputFileName = this.getFilename(z, x, y) await this.startProcess( `extract ${ diff --git a/scripts/serverPmTileExtracts.ts b/scripts/serverPmTileExtracts.ts index 14ce96539..5c77f22f9 100644 --- a/scripts/serverPmTileExtracts.ts +++ b/scripts/serverPmTileExtracts.ts @@ -66,7 +66,8 @@ class ServerPmTileExtracts extends Script { ScriptUtils.createParentDir(targetFile) console.log("Creating", targetFile) const start = new Date() - await generator.generateArchive(z, x, y) + const maxzoom = OfflineBasemapManager.zoomelevels[z] + await generator.generateArchive(z, x, y, maxzoom) const stop = new Date() console.log( "Creating ", @@ -82,7 +83,11 @@ class ServerPmTileExtracts extends Script { if (req.destroyed) { return null } - res.writeHead(200, { "Content-Type": "application/octet-stream" }) + const stats = statSync(targetFile) + + res.writeHead(200, { "Content-Type": "application/octet-stream" , + "Content-Length": stats.size + }) Server.sendFile(targetFile, res) return null diff --git a/src/Logic/Actors/OfflineForegroundDataManager.ts b/src/Logic/Actors/OfflineForegroundDataManager.ts new file mode 100644 index 000000000..34bd67743 --- /dev/null +++ b/src/Logic/Actors/OfflineForegroundDataManager.ts @@ -0,0 +1,134 @@ +/** + * 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 { IsOnline } from "../Web/IsOnline" +import { OfflineBasemapManager } from "../OfflineBasemapManager" + +export interface OfflineBbox { + bounds: [[number, number], [number, number]], + lastSuccess: Date + lastAttempt: Date + failed?: boolean + downloading?: boolean +} + +export class OfflineForegroundDataManager { + private _themeSource: ThemeSource + private readonly _bboxesForOffline: UIEventSource + public readonly bboxesForOffline: Store + private readonly _isUpdating: UIEventSource = new UIEventSource(false) + public readonly isUpdating: Store = this._isUpdating + + public readonly updateWhenReconnected: UIEventSource + public readonly updateOnLoad: UIEventSource + + constructor(state: WithLayoutSourceState) { + this._themeSource = state.indexedFeatures + this.updateOnLoad = LocalStorageSource.getParsed("updateOnLoad_" + state.theme.id, false) + this.updateWhenReconnected = LocalStorageSource.getParsed("updateWhenReconnected_" + state.theme.id, false) + + + const fromStorage = UIEventSource.asObject(LocalStorageSource.get("bboxes_for_offline_" + state.theme.id, "[]"), []) + this._bboxesForOffline = new UIEventSource(fromStorage.data) + this._bboxesForOffline.addCallbackAndRun(bboxes => fromStorage.set(bboxes)) + this._bboxesForOffline.update(bboxes => OfflineForegroundDataManager.clean(bboxes)) + this.bboxesForOffline = this._bboxesForOffline + this.bboxesForOffline.addCallbackAndRun(bb => console.trace(">>> bboxes got an update:", bb)) + 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() { + this._isUpdating.set(true) + + const ls = this._bboxesForOffline.data + for (let i = 0; i < ls.length; i++) { + const offlBox = ls[i] + await this.updateSingleBbox(offlBox) + } + this._isUpdating.set(false) + } + + public async addBbox(bbox: BBox) { + const data: OfflineBbox = { + bounds: bbox.toLngLat(), + lastAttempt: undefined, + lastSuccess: undefined, + } + this._bboxesForOffline.data.push(data) + this._bboxesForOffline.update(offl => OfflineForegroundDataManager.clean(offl)) + this._isUpdating.set(true) + await this.updateSingleBbox(data) + this._isUpdating.set(false) + } + + public removeBbox(bbox: OfflineBbox) { + const i = this._bboxesForOffline.data.indexOf(bbox) + this._bboxesForOffline.data.splice(i, 1) + this._bboxesForOffline.ping() + } + + public clearAll() { + this._bboxesForOffline.set([]) + } + + private async updateSingleBbox(offlBbox: OfflineBbox) { + if (this._bboxesForOffline.data.indexOf(offlBbox) < 0) { + throw "Assertion failed: offlBbox is not part of this._bboxesForOffline.data" + } + const bbox = new BBox(offlBbox.bounds) + offlBbox.downloading = true + offlBbox.failed = false + offlBbox.lastAttempt = new Date() + // As 'offlBbox' is included in the _bboxForOffline, pinging it will update downstream UI elements + console.log(">>> Updating bbox preping", offlBbox, this._bboxesForOffline.data.indexOf(offlBbox)) + this._bboxesForOffline.ping() + try { + await OfflineBasemapManager.singleton.installBbox(bbox) + console.log(">>> BBox update started") + await this._themeSource.downloadAll(bbox) + offlBbox.lastSuccess = new Date() + offlBbox.downloading = false + console.log(">>> BBox update finished", offlBbox, this._bboxesForOffline.data.indexOf(offlBbox)) + } catch (e) { + console.error("Got a failed bbox", e) + offlBbox.failed = true + } + offlBbox.downloading = false + this._bboxesForOffline.ping() + console.log(">>> bboxes for offline are now:", this._bboxesForOffline.data) + } + + /** + * Merges fully contained bboxes into the bigger bbox + * @param bboxes + * @private + */ + private static clean(bboxes: OfflineBbox[]): OfflineBbox[] { + const validated = bboxes.filter(bbox => { + try { + new BBox(bbox.bounds) + return true + } catch (e) { + return false + } + }) + return BBox.dropFullyContained(validated, holder => new BBox(holder.bounds)) + } +} diff --git a/src/Logic/BBox.ts b/src/Logic/BBox.ts index c1b629549..10f36c145 100644 --- a/src/Logic/BBox.ts +++ b/src/Logic/BBox.ts @@ -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,47 @@ 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[]; + static dropFullyContained(bboxes: ReadonlyArray, f: (t: T) => BBox): T[]; + static dropFullyContained(bboxes: ReadonlyArray, f?: (t: T) => BBox): T[] { + const newBboxes: T[] = [] + const seenBboxes: BBox[] = [] + f ??= x => x + for (const bboxHolder of bboxes) { + const bbox = f(bboxHolder) + if (seenBboxes.some(newBbox => bbox.isContainedIn(newBbox))) { + continue + } + for (let i = newBboxes.length - 1; i >= 0; i--) { + const newBbox = seenBboxes[i] + if (newBbox.isContainedIn(bbox)) { + newBboxes.splice(i, 1) + seenBboxes.splice(i, 1) + } + } + seenBboxes.push(bbox) + newBboxes.push(bboxHolder) + } + return newBboxes + } + } diff --git a/src/Logic/FeatureSource/FeatureSource.ts b/src/Logic/FeatureSource/FeatureSource.ts index db1b0be0d..8c4bb0889 100644 --- a/src/Logic/FeatureSource/FeatureSource.ts +++ b/src/Logic/FeatureSource/FeatureSource.ts @@ -7,12 +7,17 @@ export interface FeatureSource> { features: Store } +export interface UpdateAsyncOptions { + forceAllLayers?: boolean + noRetries?: boolean +} + export interface UpdatableFeatureSource> extends FeatureSource { /** * Forces an update and downloads the data, even if the feature source is supposed to be active */ - updateAsync(): void + updateAsync(updateAsynOptions?: UpdateAsyncOptions): Promise } export interface WritableFeatureSource> @@ -36,6 +41,8 @@ export interface FeatureSourceForTile extends Featu /** * A feature source which is aware of the indexes it contains */ -export interface IndexedFeatureSource extends FeatureSource { +export interface IndexedFeatureSource & { + id: string +}>> extends FeatureSource { readonly featuresById: Store> } diff --git a/src/Logic/FeatureSource/Sources/FeatureSourceMerger.ts b/src/Logic/FeatureSource/Sources/FeatureSourceMerger.ts index b6d68ebe2..c9383ab5b 100644 --- a/src/Logic/FeatureSource/Sources/FeatureSourceMerger.ts +++ b/src/Logic/FeatureSource/Sources/FeatureSourceMerger.ts @@ -1,5 +1,5 @@ import { Store, UIEventSource } from "../../UIEventSource" -import { FeatureSource, IndexedFeatureSource, UpdatableFeatureSource } from "../FeatureSource" +import { FeatureSource, IndexedFeatureSource, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource" import { Feature } from "geojson" import { Lists } from "../../../Utils/Lists" @@ -129,7 +129,13 @@ export class UpdatableFeatureSourceMerger< super(...sources) } - async updateAsync() { - await Promise.all(this._sources.map((src) => src.updateAsync())) + async updateAsync(options?: UpdateAsyncOptions) { + await Promise.all(this._sources.map(async (src) => { + try { + await src.updateAsync(options) + } catch (e) { + console.error("Could not update feature source due to", e) + } + })) } } diff --git a/src/Logic/FeatureSource/Sources/MvtSource.ts b/src/Logic/FeatureSource/Sources/MvtSource.ts index 1497be981..13b0b17d7 100644 --- a/src/Logic/FeatureSource/Sources/MvtSource.ts +++ b/src/Logic/FeatureSource/Sources/MvtSource.ts @@ -1,7 +1,7 @@ import { Feature, Feature as GeojsonFeature, Geometry } from "geojson" import { Store, UIEventSource } from "../../UIEventSource" -import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource" +import { FeatureSourceForTile, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource" import { MvtToGeojson } from "mvt-to-geojson" import { OsmTags } from "../../../Models/OsmFeature" @@ -34,7 +34,7 @@ export default class MvtSource> ) } - async updateAsync() { + async updateAsync(options?: UpdateAsyncOptions) { if (!this.currentlyRunning) { this.currentlyRunning = this.download() } diff --git a/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts b/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts index 9febaa37d..f07ba22fd 100644 --- a/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts +++ b/src/Logic/FeatureSource/Sources/OverpassFeatureSource.ts @@ -1,4 +1,4 @@ -import { UpdatableFeatureSource } from "../FeatureSource" +import { UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource" import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import { Or } from "../../Tags/Or" @@ -23,7 +23,6 @@ export default class OverpassFeatureSource public readonly features: UIEventSource = new UIEventSource(undefined) public readonly runningQuery: UIEventSource = new UIEventSource(false) - public readonly timeout: UIEventSource = new UIEventSource(0) private readonly retries: UIEventSource = new UIEventSource(0) @@ -103,19 +102,13 @@ export default class OverpassFeatureSource } /** - * 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 { - if (!navigator.onLine) { - return - } - let data: { features: T[] } = undefined - let lastUsed = 0 - const start = new Date() - const layersToDownload = this._layersToDownload.data - + private async attemptDownload(options?: UpdateAsyncOptions, overrideBounds?: BBox, serverIndexStart?: number): Promise<{ + features: T[] + }> { + const layersToDownload = options?.forceAllLayers ? this.state.layers : this._layersToDownload.data if (layersToDownload.length == 0) { return } @@ -124,6 +117,7 @@ export default class OverpassFeatureSource 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 { @@ -137,37 +131,54 @@ export default class OverpassFeatureSource 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(bounds))[0] + const data = (await overpass.queryGeoJson(bounds))[0] + if (data) { + this._lastQueryBBox = bounds + this._lastRequestedLayers = layersToDownload + return data // Success! + } } catch (e) { - this.retries.data++ - this.retries.ping() - console.error(`QUERY FAILED due to`, e) - + this.retries.update(i => i + 1) + console.error(`QUERY FAILED (attempt ${this.retries.data}, will retry: ${this._isActive.data}) due to`, e) + if(options?.noRetries){ + throw 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) + throw "Could not update the data" + + } + + /** + * Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers + * @private + */ + public async updateAsync(options?: UpdateAsyncOptions, overrideBounds?: BBox): Promise { + if (!navigator.onLine) { + return + } + const start = new Date() try { + this.runningQuery.setData(true) + const data: { features: T[] } = await this.attemptDownload(options, overrideBounds) if (data === undefined) { return undefined } @@ -184,10 +195,10 @@ export default class OverpassFeatureSource "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) + throw e } finally { this.retries.setData(0) this.runningQuery.setData(false) @@ -201,7 +212,7 @@ export default class OverpassFeatureSource * @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) { @@ -223,10 +234,6 @@ export default class OverpassFeatureSource 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 && diff --git a/src/Logic/FeatureSource/Sources/ThemeSource.ts b/src/Logic/FeatureSource/Sources/ThemeSource.ts index f0845d7de..e11ff30c6 100644 --- a/src/Logic/FeatureSource/Sources/ThemeSource.ts +++ b/src/Logic/FeatureSource/Sources/ThemeSource.ts @@ -21,9 +21,10 @@ import { IsOnline } from "../../Web/IsOnline" * * Note that special layers (with `source=null` will be ignored) */ -export default class ThemeSource & { id: string }>> - implements IndexedFeatureSource -{ +export default class ThemeSource & { + id: string +}> = Feature & { id: string }>> + implements IndexedFeatureSource { /** * Indicates if a data source is loading something */ @@ -84,8 +85,9 @@ export default class ThemeSource }) } - 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) { @@ -315,9 +317,9 @@ class ThemeSourceCore extends FeatureSourceMerger { ) } - public async downloadAll() { + public async downloadAll(bbox?: BBox) { console.log("Downloading all data:") - await this._downloadAll.updateAsync(this._mapBounds.data) + await this._downloadAll.updateAsync({forceAllLayers: true, noRetries: true}, bbox ?? this._mapBounds.data) // await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync())) console.log("Done") } diff --git a/src/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts b/src/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts index 07a14e666..a2cfcb179 100644 --- a/src/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts +++ b/src/Logic/FeatureSource/TiledFeatureSource/DynamicTileSource.ts @@ -1,7 +1,7 @@ import { Store, Stores } from "../../UIEventSource" import { Tiles } from "../../../Models/TileRange" import { BBox } from "../../BBox" -import { FeatureSource, UpdatableFeatureSource } from "../FeatureSource" +import { FeatureSource, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource" import FeatureSourceMerger from "../Sources/FeatureSourceMerger" import { Feature, Geometry } from "geojson" @@ -122,8 +122,8 @@ export class UpdatableDynamicTileSource< super(zoomlevel, minzoom, constructSource, mapProperties, options) } - async updateAsync() { + async updateAsync(options?: UpdateAsyncOptions) { const sources = super.downloadTiles(super.getNeededTileIndices()) - await Promise.all(sources.map((src) => src.updateAsync())) + await Promise.all(sources.map((src) => src.updateAsync(options))) } } diff --git a/src/Logic/OfflineBasemapManager.ts b/src/Logic/OfflineBasemapManager.ts index e1c5828d7..43c9787a5 100644 --- a/src/Logic/OfflineBasemapManager.ts +++ b/src/Logic/OfflineBasemapManager.ts @@ -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 = { 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("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 }) + }) + } } diff --git a/src/Logic/Osm/Overpass.ts b/src/Logic/Osm/Overpass.ts index 22177a2ac..4fb5354b1 100644 --- a/src/Logic/Osm/Overpass.ts +++ b/src/Logic/Osm/Overpass.ts @@ -59,12 +59,17 @@ export class Overpass { private async executeQuery( query: string - ): Promise<[{ features: T[] } & FeatureCollection, Date]> { - const json = await Utils.downloadJson<{ + ): Promise<[{features: T[]} & FeatureCollection, Date]> { + 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) diff --git a/src/Logic/SimpleMetaTagger.ts b/src/Logic/SimpleMetaTagger.ts index c91cd2c50..e99a15f81 100644 --- a/src/Logic/SimpleMetaTagger.ts +++ b/src/Logic/SimpleMetaTagger.ts @@ -518,7 +518,7 @@ export default class SimpleMetaTaggers { if (canonical === value) { break } - console.log( + console.debug( "Rewritten ", key, ` from '${value}' into '${canonical}' due to denomination`, @@ -845,7 +845,7 @@ export default class SimpleMetaTaggers { const property = match[1] set(strippedKey + ":left:" + property, v) set(strippedKey + ":right:" + property, v) - console.log("Left-right rewritten " + key) + console.debug("Left-right rewritten " + key) delete tags[key] } } diff --git a/src/Logic/State/GeolocationControlState.ts b/src/Logic/State/GeolocationControlState.ts index 2052a759d..aeff0e764 100644 --- a/src/Logic/State/GeolocationControlState.ts +++ b/src/Logic/State/GeolocationControlState.ts @@ -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(undefined) diff --git a/src/Logic/UIEventSource.ts b/src/Logic/UIEventSource.ts index 0a2ca2cab..572b86946 100644 --- a/src/Logic/UIEventSource.ts +++ b/src/Logic/UIEventSource.ts @@ -344,16 +344,27 @@ export abstract class Store implements Readable { * @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 { + 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 diff --git a/src/Logic/Web/IsOnline.ts b/src/Logic/Web/IsOnline.ts index a2c0cb300..dff6ae2f6 100644 --- a/src/Logic/Web/IsOnline.ts +++ b/src/Logic/Web/IsOnline.ts @@ -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 = new UIEventSource( @@ -17,5 +18,10 @@ export class IsOnline { } } - public static readonly isOnline: Store = 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 = UIEventSource.asBoolean(LocalStorageSource.get("forceOffline", "false")) + + public static readonly isOnline: Store = IsOnline._isOnline.map(online => online && !IsOnline.forceOffline.data, [IsOnline.forceOffline]) } diff --git a/src/Models/ThemeViewState/WithLayoutSourceState.ts b/src/Models/ThemeViewState/WithLayoutSourceState.ts index d09f41d96..22abec73b 100644 --- a/src/Models/ThemeViewState/WithLayoutSourceState.ts +++ b/src/Models/ThemeViewState/WithLayoutSourceState.ts @@ -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 + readonly offlineForegroundDataManager: OfflineForegroundDataManager + constructor(theme: ThemeConfig, mvtAvailableLayers: Store>) { 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) diff --git a/src/Models/TileRange.ts b/src/Models/TileRange.ts index a2b50dee2..8d7f0a424 100644 --- a/src/Models/TileRange.ts +++ b/src/Models/TileRange.ts @@ -26,6 +26,21 @@ export class Tiles { return result } + public static async mapRangeAsync(tileRange: TileRange, f: (x: number, y: number) => Promise): Promise { + 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 { 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() diff --git a/src/UI/BigComponents/MenuDrawerIndex.svelte b/src/UI/BigComponents/MenuDrawerIndex.svelte index c942d86fe..644920f2f 100644 --- a/src/UI/BigComponents/MenuDrawerIndex.svelte +++ b/src/UI/BigComponents/MenuDrawerIndex.svelte @@ -55,8 +55,6 @@ import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue" import QueuedImagesView from "../Image/QueuedImagesView.svelte" import InsetSpacer from "../Base/InsetSpacer.svelte" - import OfflineManagement from "./OfflineManagement.svelte" - import { GlobeEuropeAfrica } from "@babeard/svelte-heroicons/solid/GlobeEuropeAfrica" import { onDestroy } from "svelte" import Avatar from "../Base/Avatar.svelte" import { Changes } from "../../Logic/Osm/Changes" @@ -138,11 +136,8 @@
- {$userdetails.name} - + {$userdetails?.name} +
diff --git a/src/UI/BigComponents/OfflineForegroundManagement.svelte b/src/UI/BigComponents/OfflineForegroundManagement.svelte new file mode 100644 index 000000000..764ba7674 --- /dev/null +++ b/src/UI/BigComponents/OfflineForegroundManagement.svelte @@ -0,0 +1,205 @@ + + +
+
+ +
+
+ {#if minzoom > $zoom} +
+ +
+ {:else if $areaContained} +
+ +
+ {:else} + + {/if} +
+
+
+ + {#if !$isOnline} +
+ +
+{:else if $isUpdating} + + + +{:else} + +{/if} + + + +
+ + + + + + + diff --git a/src/UI/BigComponents/OfflineManagement.svelte b/src/UI/BigComponents/OfflineManagement.svelte index 0e660e947..5c1ef5e7d 100644 --- a/src/UI/BigComponents/OfflineManagement.svelte +++ b/src/UI/BigComponents/OfflineManagement.svelte @@ -24,13 +24,14 @@ import { default as Trans } from "../Base/Tr.svelte" import AccordionSingle from "../Flowbite/AccordionSingle.svelte" import { Lists } from "../../Utils/Lists" + import OfflineForegroundManagement from "./OfflineForegroundManagement.svelte" export let state: ThemeViewState & SpecialVisualizationState = undefined export let autoDownload = state.autoDownloadOfflineBasemap let focusZ = Math.max(...Object.keys(OfflineBasemapManager.zoomelevels).map(Number)) let map: UIEventSource = new UIEventSource(undefined) - let mapProperties: MapProperties = new MapLibreAdaptor(map) + let mapProperties: MapProperties = new MapLibreAdaptor(map).installQuicklocation() if (state?.showCurrentLocationOn) { state?.showCurrentLocationOn(map) } @@ -79,9 +80,9 @@ id: "center_point_" + z + "_" + x + "_" + y, txt: "Tile " + x + " " + y, } - return [f] + return [>f] }) - let installedFeature: Store[]> = installed.map((meta) => + let installedFeature: Store[]> = installed.map((meta) => (meta ?? []).map((area) => { const f = Tiles.asGeojson(area.minzoom, area.x, area.y) f.properties = { @@ -94,11 +95,11 @@ " " + Utils.toHumanByteSize(Number(area.size)), } - return f + return >f }) ) new ShowDataLayer(map, { - features: new StaticFeatureSource(installedFeature), + features: new StaticFeatureSource>(installedFeature), layer: new LayerConfig({ id: "downloaded", source: "special", @@ -134,7 +135,7 @@ }), }) new ShowDataLayer(map, { - features: new StaticFeatureSource(focusTileFeature), + features: new StaticFeatureSource>(focusTileFeature), layer: new LayerConfig({ id: "focustile", source: "special", @@ -154,57 +155,60 @@ }), }) const t = Translations.t.offline + -
- - - - - -
- -
-
-
+
+
+ +
{#if $installed === undefined} {:else}
- - -
-
- -
-
-
- {#if $focusTileIsInstalling} -
- - - -
- {:else} - - {/if} -
-
- - + + + + + + + +
+ +
+
+
+
+
+ +
+
+
+ {#if $focusTileIsInstalling} +
+ + + +
+ {:else} + + {/if} +
+
+ {Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))}