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": {
"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",

View file

@ -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<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)
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 ${

View file

@ -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

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)
* !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>): 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[]>
}
export interface UpdateAsyncOptions {
forceAllLayers?: boolean
noRetries?: boolean
}
export interface UpdatableFeatureSource<T extends Feature = Feature<Geometry, OsmTags>>
extends FeatureSource<T> {
/**
* 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>>
@ -36,6 +41,8 @@ export interface FeatureSourceForTile<T extends Feature = Feature> extends Featu
/**
* A feature source which is aware of the indexes it contains
*/
export interface IndexedFeatureSource<T extends Feature> extends FeatureSource<T> {
export interface IndexedFeatureSource<T extends Feature = Feature<Geometry, Record<string, string> & {
id: string
}>> extends FeatureSource<T> {
readonly featuresById: Store<Map<string, Feature>>
}

View file

@ -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)
}
}))
}
}

View file

@ -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<T extends Feature<Geometry, OsmTags>>
)
}
async updateAsync() {
async updateAsync(options?: UpdateAsyncOptions) {
if (!this.currentlyRunning) {
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 LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
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 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)
@ -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
* Will always attempt to download, even is 'options.isActive.data' is 'false', the zoom level is incorrect, ...
* @private
* Attempts to download the data from an overpass server;
* fails over to the next server in case of failure
*/
public async updateAsync(overrideBounds?: BBox): Promise<void> {
if (!navigator.onLine) {
return
}
let data: { features: T[] } = undefined
let lastUsed = 0
const start = new Date()
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<T extends OsmFeature = OsmFeature>
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<T extends OsmFeature = OsmFeature>
return
}
const overpass = this.GetFilter(overpassUrls[lastUsed], layersToDownload)
const overpass = this.getFilter(overpassUrls[serverToTry], layersToDownload)
if (overpass === undefined) {
return undefined
}
this.runningQuery.setData(true)
data = (await overpass.queryGeoJson<T>(bounds))[0]
const data = (await overpass.queryGeoJson<T>(bounds))[0]
if (data) {
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
return data // Success!
}
} catch (e) {
this.retries.data++
this.retries.ping()
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<void> {
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<T extends OsmFeature = OsmFeature>
"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<T extends OsmFeature = OsmFeature>
* @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<T extends OsmFeature = OsmFeature>
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 &&

View file

@ -21,9 +21,10 @@ import { IsOnline } from "../../Web/IsOnline"
*
* Note that special layers (with `source=null` will be ignored)
*/
export default class ThemeSource<T extends Feature<Geometry, Record<string, any> & { id: string }>>
implements IndexedFeatureSource<T>
{
export default class ThemeSource<T extends Feature<Geometry, Record<string, any> & {
id: string
}> = Feature<Geometry, Record<string, string> & { id: string }>>
implements IndexedFeatureSource<T> {
/**
* Indicates if a data source is loading something
*/
@ -84,8 +85,9 @@ export default class ThemeSource<T extends Feature<Geometry, Record<string, any>
})
}
public async downloadAll() {
return this.core.data.downloadAll()
public async downloadAll(bbox?: BBox) {
const core = await this.core.awaitValue()
await core.downloadAll(bbox)
}
public addSource(source: FeatureSource<T>) {
@ -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:")
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")
}

View file

@ -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)))
}
}

View file

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

View file

@ -59,12 +59,17 @@ export class Overpass {
private async executeQuery<T extends Feature>(
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)

View file

@ -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]
}
}

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,
* and lock status + moving the map when clicked
*
* Note: _not_ part of the big hierarchy
*/
export class GeolocationControlState {
public readonly lastClick = new UIEventSource<Date>(undefined)

View file

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

View file

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

View file

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

View file

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

View file

@ -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 @@
<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">
<b>{$userdetails.name}</b>
<LogoutButton
clss="as-link small subtle text-sm"
osmConnection={state.osmConnection}
/>
<b>{$userdetails?.name}</b>
<LogoutButton clss="as-link small subtle text-sm" osmConnection={state.osmConnection} />
</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 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<MlMap> = 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 [<Feature<Polygon, { id: string }>>f]
})
let installedFeature: Store<Feature<Polygon>[]> = installed.map((meta) =>
let installedFeature: Store<Feature<Polygon, { id: string }>[]> = installed.map((meta) =>
(meta ?? []).map((area) => {
const f = Tiles.asGeojson(area.minzoom, area.x, area.y)
f.properties = {
@ -94,11 +95,11 @@
" " +
Utils.toHumanByteSize(Number(area.size)),
}
return f
return <Feature<Polygon, { id: string }>>f
})
)
new ShowDataLayer(map, {
features: new StaticFeatureSource(installedFeature),
features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(installedFeature),
layer: new LayerConfig({
id: "downloaded",
source: "special",
@ -134,7 +135,7 @@
}),
})
new ShowDataLayer(map, {
features: new StaticFeatureSource(focusTileFeature),
features: new StaticFeatureSource<Feature<Polygon, { id: string }>>(focusTileFeature),
layer: new LayerConfig({
id: "focustile",
source: "special",
@ -154,57 +155,60 @@
}),
})
const t = Translations.t.offline
</script>
<div class="max-h-leave-room flex h-full flex-col overflow-auto">
<Checkbox selected={autoDownload}>
<Trans t={t.autoCheckmark} />
</Checkbox>
<AccordionSingle noBorder>
<Trans slot="header" cls="text-sm" t={t.autoExplanationIntro} />
<div class="low-interaction">
<Trans t={t.autoExplanation} />
</div>
</AccordionSingle>
<div />
<div class="max-h-leave-room flex h-full flex-col justify-between overflow-auto">
<div class="leave-room w-full p-2 m-0">
<OfflineForegroundManagement {state} />
</div>
{#if $installed === undefined}
<Loading />
{:else}
<div class="pb-16">
<Accordion class="" inactiveClass="text-black">
<AccordionItem paddingDefault="p-2">
<Trans slot="header" t={t.localOnMap} />
<div class="leave-room relative">
<div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} />
</div>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center"
>
<div class="mb-16 h-32 w-16" />
{#if $focusTileIsInstalling}
<div class="normal-background rounded-lg">
<Loading>
<Trans t={t.installing} />
</Loading>
</div>
{:else}
<button
class="primary pointer-events-auto"
on:click={() => download()}
class:disabled={$focusTileIsInstalled}
>
<DownloadIcon class="h-8 w-8" />
<Trans t={t.download} />
</button>
{/if}
</div>
</div>
</AccordionItem>
<AccordionItem paddingDefault="p-2">
<Trans t={t.overview} slot="header" />
<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="h-1/2 relative">
<div class="absolute left-0 top-0 h-full w-full rounded-lg">
<MaplibreMap {map} {mapProperties} />
</div>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center"
>
<div class="mb-16 h-32 w-16" />
{#if $focusTileIsInstalling}
<div class="normal-background rounded-lg">
<Loading>
<Trans t={t.installing} />
</Loading>
</div>
{:else}
<button
class="primary pointer-events-auto"
on:click={() => download()}
class:disabled={$focusTileIsInstalled}
>
<DownloadIcon class="h-8 w-8" />
<Trans t={t.download} />
</button>
{/if}
</div>
</div>
{Utils.toHumanByteSize(Lists.sum($installed.map((area) => area.size)))}
<button
on:click={() => {

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.
* To avoid rendering artefacts or too frequenting pinging, this is ratelimited to one update every 'rateLimitMs' milliseconds
*/
public installQuicklocation(ratelimitMs = 50) {
public installQuicklocation(ratelimitMs = 50): this {
this._maplibreMap.addCallbackAndRunD((map) => {
let lastUpdate = new Date().getTime()
map.on("drag", () => {
@ -797,7 +797,14 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
lastUpdate = now
const center = map.getCenter()
this.location.set({ lon: center.lng, lat: center.lat })
const bounds = map.getBounds()
const bbox = new BBox([
[bounds.getEast(), bounds.getNorth()],
[bounds.getWest(), bounds.getSouth()]
])
this.bounds.set(bbox)
})
})
return this
}
}