Merge branch 'feature/offline-fg-data' into develop

This commit is contained in:
Pieter Vander Vennet 2025-10-15 18:08:23 +02:00
commit 96f99a1267
24 changed files with 619 additions and 135 deletions

@ -1 +1 @@
Subproject commit dc3f3f5ac3d4d42bed7aeb58ff5386a063dbac06 Subproject commit 472b6da88e038d34e2915ebd36350376acf0c58c

View file

@ -778,19 +778,29 @@
}, },
"offline": { "offline": {
"actions": "Actions", "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", "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.", "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?", "autoExplanationIntro": "What does automatically downloading basemaps mean?",
"date": "Map generation data", "date": "Map generation data",
"delete": "Delete basemap", "delete": "Delete basemap",
"deleteAll": "Delete all basemaps", "deleteAll": "Delete all basemaps",
"deleteAreas": "Clear offline areas",
"download": "Download area", "download": "Download area",
"installing": "Data is being downloaded", "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", "localOnMap": "Offline basemaps on the map",
"markArea": "Keep this area available offline",
"name": "Name", "name": "Name",
"overview": "Offline basemaps overview", "offlineNoUpdate": "Updating is currently not possible as you are offline",
"overview": "Offline background map",
"range": "Zoom ranges", "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": { "plantDetection": {
"back": "Back to species overview", "back": "Back to species overview",
@ -853,7 +863,7 @@
"reviews": { "reviews": {
"affiliated_reviewer_warning": "(Affiliated review)", "affiliated_reviewer_warning": "(Affiliated review)",
"attribution": "By Mangrove.reviews", "attribution": "By Mangrove.reviews",
"averageRating": "Average rating of {n} stars", "averageRating": "Average ting of {n} stars",
"delete": "Delete review", "delete": "Delete review",
"deleteConfirm": "Permanently delete this review", "deleteConfirm": "Permanently delete this review",
"deleteText": "This cannot be undone", "deleteText": "This cannot be undone",

View file

@ -46,12 +46,17 @@ export class PmTilesExtractGenerator {
return `${this._targetDir}/${z}/${x}/${y}.pmtiles` return `${this._targetDir}/${z}/${x}/${y}.pmtiles`
} }
async generateArchive(z: number, x: number, y: number, maxzoom?: number): Promise<string> { async generateArchive(z: number, x: number, y: number, maxzoom: number): Promise<string> {
const [[max_lat, min_lon], [min_lat, max_lon]] = Tiles.tile_bounds(z, x, y) const [[max_lat, min_lon], [min_lat, max_lon]] = Tiles.tile_bounds(z, x, y)
let maxzoomflag = "" let maxzoomflag = ""
if (maxzoom !== undefined) { if (maxzoom !== undefined) {
maxzoomflag = " --maxzoom=" + maxzoom 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) const outputFileName = this.getFilename(z, x, y)
await this.startProcess( await this.startProcess(
`extract ${ `extract ${

View file

@ -66,7 +66,8 @@ class ServerPmTileExtracts extends Script {
ScriptUtils.createParentDir(targetFile) ScriptUtils.createParentDir(targetFile)
console.log("Creating", targetFile) console.log("Creating", targetFile)
const start = new Date() 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() const stop = new Date()
console.log( console.log(
"Creating ", "Creating ",
@ -82,7 +83,11 @@ class ServerPmTileExtracts extends Script {
if (req.destroyed) { if (req.destroyed) {
return null 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) Server.sendFile(targetFile, res)
return null return null

View file

@ -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<OfflineBbox[]>
public readonly bboxesForOffline: Store<OfflineBbox[]>
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.updateOnLoad = LocalStorageSource.getParsed("updateOnLoad_" + state.theme.id, false)
this.updateWhenReconnected = LocalStorageSource.getParsed("updateWhenReconnected_" + state.theme.id, false)
const fromStorage = UIEventSource.asObject<OfflineBbox[]>(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))
}
}

View file

@ -298,7 +298,7 @@ export class BBox {
* const expanded = bbox.expandToTileBounds(15) * const expanded = bbox.expandToTileBounds(15)
* !isNaN(expanded.minLat) // => true * !isNaN(expanded.minLat) // => true
*/ */
expandToTileBounds(zoomlevel: number): BBox { public expandToTileBounds(zoomlevel: number): BBox {
if (zoomlevel === undefined) { if (zoomlevel === undefined) {
return this return this
} }
@ -309,7 +309,7 @@ export class BBox {
return new BBox([].concat(boundsul, boundslr)) return new BBox([].concat(boundsul, boundslr))
} }
toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } { public toMercator(): { minLat: number; maxLat: number; minLon: number; maxLon: number } {
const [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat]) const [minLon, minLat] = GeoOperations.ConvertWgs84To900913([this.minLon, this.minLat])
const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat]) const [maxLon, maxLat] = GeoOperations.ConvertWgs84To900913([this.maxLon, this.maxLat])
@ -341,7 +341,47 @@ 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[];
static dropFullyContained<T>(bboxes: ReadonlyArray<T>, f: (t: T) => BBox): T[];
static dropFullyContained<T>(bboxes: ReadonlyArray<T>, f?: (t: T) => BBox): T[] {
const newBboxes: T[] = []
const seenBboxes: BBox[] = []
f ??= x => <BBox> 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
}
} }

View file

@ -7,12 +7,17 @@ export interface FeatureSource<T extends Feature = Feature<Geometry, OsmTags>> {
features: Store<T[]> features: Store<T[]>
} }
export interface UpdateAsyncOptions {
forceAllLayers?: boolean
noRetries?: boolean
}
export interface UpdatableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> export interface UpdatableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>>
extends FeatureSource<T> { extends FeatureSource<T> {
/** /**
* Forces an update and downloads the data, even if the feature source is supposed to be active * Forces an update and downloads the data, even if the feature source is supposed to be active
*/ */
updateAsync(): void updateAsync(updateAsynOptions?: UpdateAsyncOptions): Promise<void>
} }
export interface WritableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>> export interface WritableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>>
@ -36,6 +41,8 @@ export interface FeatureSourceForTile<T extends Feature = Feature> extends Featu
/** /**
* A feature source which is aware of the indexes it contains * A feature source which is aware of the indexes it contains
*/ */
export interface IndexedFeatureSource<T extends Feature> extends FeatureSource<T> { export interface IndexedFeatureSource<T extends Feature = Feature<Geometry, Record<string, string> & {
id: string
}>> extends FeatureSource<T> {
readonly featuresById: Store<Map<string, Feature>> readonly featuresById: Store<Map<string, Feature>>
} }

View file

@ -1,5 +1,5 @@
import { Store, UIEventSource } from "../../UIEventSource" import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSource, IndexedFeatureSource, UpdatableFeatureSource } from "../FeatureSource" import { FeatureSource, IndexedFeatureSource, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource"
import { Feature } from "geojson" import { Feature } from "geojson"
import { Lists } from "../../../Utils/Lists" import { Lists } from "../../../Utils/Lists"
@ -129,7 +129,13 @@ export class UpdatableFeatureSourceMerger<
super(...sources) super(...sources)
} }
async updateAsync() { async updateAsync(options?: UpdateAsyncOptions) {
await Promise.all(this._sources.map((src) => src.updateAsync())) 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)
}
}))
} }
} }

View file

@ -1,7 +1,7 @@
import { Feature, Feature as GeojsonFeature, Geometry } from "geojson" import { Feature, Feature as GeojsonFeature, Geometry } from "geojson"
import { Store, UIEventSource } from "../../UIEventSource" import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource" import { FeatureSourceForTile, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource"
import { MvtToGeojson } from "mvt-to-geojson" import { MvtToGeojson } from "mvt-to-geojson"
import { OsmTags } from "../../../Models/OsmFeature" import { OsmTags } from "../../../Models/OsmFeature"
@ -34,7 +34,7 @@ export default class MvtSource<T extends Feature<Geometry, OsmTags>>
) )
} }
async updateAsync() { async updateAsync(options?: UpdateAsyncOptions) {
if (!this.currentlyRunning) { if (!this.currentlyRunning) {
this.currentlyRunning = this.download() this.currentlyRunning = this.download()
} }

View file

@ -1,4 +1,4 @@
import { UpdatableFeatureSource } from "../FeatureSource" import { UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource" import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { Or } from "../../Tags/Or" import { Or } from "../../Tags/Or"
@ -23,7 +23,6 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
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)
@ -103,19 +102,13 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
} }
/** /**
* 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(options?: UpdateAsyncOptions, overrideBounds?: BBox, serverIndexStart?: number): Promise<{
if (!navigator.onLine) { features: T[]
return }> {
} const layersToDownload = options?.forceAllLayers ? this.state.layers : this._layersToDownload.data
let data: { features: T[] } = undefined
let lastUsed = 0
const start = new Date()
const layersToDownload = this._layersToDownload.data
if (layersToDownload.length == 0) { if (layersToDownload.length == 0) {
return return
} }
@ -124,6 +117,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
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 {
@ -137,37 +131,54 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
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 (attempt ${this.retries.data}, will retry: ${this._isActive.data}) due to`, e)
console.error(`QUERY FAILED due to`, e) if(options?.noRetries){
throw 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)
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<void> {
if (!navigator.onLine) {
return
}
const start = new Date()
try { try {
this.runningQuery.setData(true)
const data: { features: T[] } = await this.attemptDownload(options, overrideBounds)
if (data === undefined) { if (data === undefined) {
return undefined return undefined
} }
@ -184,10 +195,10 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
"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)
throw e
} finally { } finally {
this.retries.setData(0) this.retries.setData(0)
this.runningQuery.setData(false) this.runningQuery.setData(false)
@ -201,7 +212,7 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
* @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) {
@ -223,10 +234,6 @@ export default class OverpassFeatureSource<T extends OsmFeature = OsmFeature>
return undefined return undefined
} }
if (this.timeout.data > 0) {
console.log("Still in timeout - not updating")
return undefined
}
const requestedBounds = this.state.bounds.data const requestedBounds = this.state.bounds.data
if ( if (
this._lastQueryBBox !== undefined && this._lastQueryBBox !== undefined &&

View file

@ -21,9 +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 }>> export default class ThemeSource<T extends Feature<Geometry, Record<string, any> & {
implements IndexedFeatureSource<T> 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
*/ */
@ -84,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>) {
@ -315,9 +317,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({forceAllLayers: true, noRetries: true}, bbox ?? this._mapBounds.data)
// await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync())) // await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
console.log("Done") console.log("Done")
} }

View file

@ -1,7 +1,7 @@
import { Store, Stores } from "../../UIEventSource" import { Store, Stores } from "../../UIEventSource"
import { Tiles } from "../../../Models/TileRange" import { Tiles } from "../../../Models/TileRange"
import { BBox } from "../../BBox" import { BBox } from "../../BBox"
import { FeatureSource, UpdatableFeatureSource } from "../FeatureSource" import { FeatureSource, UpdatableFeatureSource, UpdateAsyncOptions } from "../FeatureSource"
import FeatureSourceMerger from "../Sources/FeatureSourceMerger" import FeatureSourceMerger from "../Sources/FeatureSourceMerger"
import { Feature, Geometry } from "geojson" import { Feature, Geometry } from "geojson"
@ -122,8 +122,8 @@ export class UpdatableDynamicTileSource<
super(zoomlevel, minzoom, constructSource, mapProperties, options) super(zoomlevel, minzoom, constructSource, mapProperties, options)
} }
async updateAsync() { async updateAsync(options?: UpdateAsyncOptions) {
const sources = super.downloadTiles(super.getNeededTileIndices()) const sources = super.downloadTiles(super.getNeededTileIndices())
await Promise.all(sources.map((src) => src.updateAsync())) await Promise.all(sources.map((src) => src.updateAsync(options)))
} }
} }

View file

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

View file

@ -59,12 +59,17 @@ export class Overpass {
private async executeQuery<T extends Feature>( private async executeQuery<T extends Feature>(
query: string query: string
): Promise<[{ features: T[] } & FeatureCollection, Date]> { ): Promise<[{features: T[]} & FeatureCollection, Date]> {
const json = await Utils.downloadJson<{ const jsonResult = await Utils.downloadJsonAdvanced<{
elements: [] elements: []
remark remark
osm3s: { timestamp_osm_base: string } osm3s: { timestamp_osm_base: string }
}>(this.buildUrl(query), {}) }>(this.buildUrl(query), {}, 1)
if (jsonResult["error"]) {
throw jsonResult["error"]
}
const json = jsonResult["content"]
if (json.elements.length === 0 && json.remark !== undefined) { if (json.elements.length === 0 && json.remark !== undefined) {
console.warn("Timeout or other runtime error while querying overpass", json.remark) console.warn("Timeout or other runtime error while querying overpass", json.remark)

View file

@ -518,7 +518,7 @@ export default class SimpleMetaTaggers {
if (canonical === value) { if (canonical === value) {
break break
} }
console.log( console.debug(
"Rewritten ", "Rewritten ",
key, key,
` from '${value}' into '${canonical}' due to denomination`, ` from '${value}' into '${canonical}' due to denomination`,
@ -845,7 +845,7 @@ export default class SimpleMetaTaggers {
const property = match[1] const property = match[1]
set(strippedKey + ":left:" + property, v) set(strippedKey + ":left:" + property, v)
set(strippedKey + ":right:" + property, v) set(strippedKey + ":right:" + property, v)
console.log("Left-right rewritten " + key) console.debug("Left-right rewritten " + key)
delete tags[key] delete tags[key]
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,8 +55,6 @@
import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue" import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue"
import QueuedImagesView from "../Image/QueuedImagesView.svelte" import QueuedImagesView from "../Image/QueuedImagesView.svelte"
import InsetSpacer from "../Base/InsetSpacer.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 { onDestroy } from "svelte"
import Avatar from "../Base/Avatar.svelte" import Avatar from "../Base/Avatar.svelte"
import { Changes } from "../../Logic/Osm/Changes" import { Changes } from "../../Logic/Osm/Changes"
@ -138,11 +136,8 @@
<Avatar userdetails={state.osmConnection.userDetails} /> <Avatar userdetails={state.osmConnection.userDetails} />
<div class="flex w-full flex-col gap-y-2"> <div class="flex w-full flex-col 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 <LogoutButton clss="as-link small subtle text-sm" osmConnection={state.osmConnection} />
clss="as-link small subtle text-sm"
osmConnection={state.osmConnection}
/>
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,205 @@
<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"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
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 zoom = mapProperties.zoom
let minzoom = Math.min(...state.theme.layers.filter(l => l.isNormal()).map(l => l.minzoom)) - 4
let autoUpdateReconnected = offlineManager.updateWhenReconnected
let autoUpdateOnLoad = offlineManager.updateOnLoad
let bboxes = offlineManager.bboxesForOffline
let bboxesAsGeojson = new StaticFeatureSource<Feature<Polygon, {
id: string
}>>(offlineManager.bboxesForOffline.map(bboxes => Lists.noNull(bboxes.map((bbox, i) => {
try {
console.log("BBOX",i, bbox)
const props = {
id: "bbox-offline-" + i + bbox.lastSuccess+"_"+new Date().getTime(),
...bbox,
}
if (bbox.lastSuccess) {
props["lastSuccessHR"] = new Date(bbox.lastSuccess).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
})
}
return new BBox(bbox.bounds).asGeoJson(props)
} 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 areaContained = viewportSource.mapD(([viewport]) => bboxes.data.some(bbox =>
BBox.get(viewport).isContainedIn(new BBox(bbox.bounds))), [bboxes])
let areaSelection = new StaticFeatureSource<Feature<Polygon, { id: string }>>(viewportSource)
let isUpdating = offlineManager.isUpdating
const offlineLayer = new LayerConfig({
id: "downloaded",
source: "special",
lineRendering: [
{
color: {
render: "blue",
mappings: [
{
if: "downloading=true",
then: "yellow",
}, {
if: "failed=true",
then: "red",
},
],
},
},
],
pointRendering: [
{
location: ["point", "centroid"],
label: {
mappings: [
{ if: "downloading=true", then: "Loading" },
{ if: "lastSuccessHR~*", then: "{lastSuccessHR}" }],
},
labelCss: "width: w-min",
labelCssClasses: "bg-white rounded px-2 items-center flex flex-col whitespace-nowrap",
},
],
})
const viewportLayer = new LayerConfig({
id: "viewport",
source: "special",
lineRendering: [
{
color: "blue",
fillColor: "#0000",
width: 1,
dashArray: "5 5",
},
],
pointRendering: null,
})
new ShowDataLayer(map, {
layer: offlineLayer,
features: bboxesAsGeojson,
onClick: feature => {
console.log(">>>", feature )
}
})
new ShowDataLayer(map, {
layer: viewportLayer,
features: areaSelection,
})
function updateAll() {
offlineManager.updateAll()
}
function addBox() {
const f = viewportSource.data[0]
const bbox = BBox.get(f)
offlineManager.addBbox(bbox)
}
function deleteAll() {
offlineManager.clearAll()
}
const isOnline = IsOnline.isOnline
const t = Translations.t.offline
</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">
{#if minzoom > $zoom}
<div class="alert">
<Tr t={t.areaToBig} />
</div>
{:else if $areaContained}
<div class="alert">
<Tr t={t.alreadyOffline} />
</div>
{:else}
<button class="primary pointer-events-auto" class:disabled={!$isOnline} on:click={() => addBox()}>
<DownloadIcon class="w-6 h-6" />
<Tr t={t.markArea} />
</button>
{/if}
</div>
</div>
<div class="flex justify-between flex-wrap-reverse">
{#if !$isOnline}
<div class="alert">
<Tr t={t.offlineNoUpdate} />
</div>
{:else if $isUpdating}
<Loading cls="flex items-center">
<Tr t={t.updating} />
</Loading>
{:else}
<button on:click={() => updateAll()} class:disabled={$bboxes.length === 0}>
<ArrowPathIcon class="w-6 h-6" />
<Tr t={t.updateAll} />
</button>
{/if}
<button on:click={() => deleteAll()} class:disabled={$bboxes.length === 0}>
<TrashIcon class="w-6 h-6" color="red" />
<Tr t={t.deleteAreas} />
</button>
</div>
<Checkbox selected={autoUpdateReconnected}>
<Tr t={t.updateOnReconnect} />
</Checkbox>
<Checkbox selected={autoUpdateOnLoad}>
<Tr t={t.updateOnLoad} />
</Checkbox>

View file

@ -24,13 +24,14 @@
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"
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()
if (state?.showCurrentLocationOn) { if (state?.showCurrentLocationOn) {
state?.showCurrentLocationOn(map) state?.showCurrentLocationOn(map)
} }
@ -79,9 +80,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 = {
@ -94,11 +95,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",
@ -134,7 +135,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",
@ -154,57 +155,60 @@
}), }),
}) })
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" />
<Trans t={t.intro.Subs(state.offlineMapManager)} />
<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={() => {

View file

@ -786,7 +786,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", () => {
@ -797,7 +797,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
} }
} }