forked from MapComplete/MapComplete
Merge branch 'feature/offline-fg-data' into develop
This commit is contained in:
commit
96f99a1267
24 changed files with 619 additions and 135 deletions
2
android
2
android
|
@ -1 +1 @@
|
|||
Subproject commit dc3f3f5ac3d4d42bed7aeb58ff5386a063dbac06
|
||||
Subproject commit 472b6da88e038d34e2915ebd36350376acf0c58c
|
|
@ -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",
|
||||
|
|
|
@ -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 ${
|
||||
|
|
|
@ -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
|
||||
|
|
134
src/Logic/Actors/OfflineForegroundDataManager.ts
Normal file
134
src/Logic/Actors/OfflineForegroundDataManager.ts
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { Utils } from "../../Utils"
|
||||
import { LocalStorageSource } from "./LocalStorageSource"
|
||||
|
||||
export class IsOnline {
|
||||
private static readonly _isOnline: UIEventSource<boolean> = new UIEventSource(
|
||||
|
@ -17,5 +18,10 @@ export class IsOnline {
|
|||
}
|
||||
}
|
||||
|
||||
public static readonly isOnline: Store<boolean> = IsOnline._isOnline
|
||||
/**
|
||||
* Doesn't yet properly work - many features don't use 'isOnline' yet to guard features but rather attempt to connect and fallback through failure
|
||||
*/
|
||||
public static readonly forceOffline: UIEventSource<boolean> = UIEventSource.asBoolean(LocalStorageSource.get("forceOffline", "false"))
|
||||
|
||||
public static readonly isOnline: Store<boolean> = IsOnline._isOnline.map(online => online && !IsOnline.forceOffline.data, [IsOnline.forceOffline])
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { Tag } from "../../Logic/Tags/Tag"
|
|||
import Hotkeys from "../../UI/Base/Hotkeys"
|
||||
import Translations from "../../UI/i18n/Translations"
|
||||
import { Feature } from "geojson"
|
||||
import { OfflineForegroundDataManager } from "../../Logic/Actors/OfflineForegroundDataManager"
|
||||
|
||||
export class WithLayoutSourceState extends WithSelectedElementState {
|
||||
readonly layerState: LayerState
|
||||
|
@ -23,6 +24,8 @@ export class WithLayoutSourceState extends WithSelectedElementState {
|
|||
*/
|
||||
readonly floors: Store<string[]>
|
||||
|
||||
readonly offlineForegroundDataManager: OfflineForegroundDataManager
|
||||
|
||||
constructor(theme: ThemeConfig, mvtAvailableLayers: Store<Set<string>>) {
|
||||
super(theme)
|
||||
/* Set up the layout source
|
||||
|
@ -49,6 +52,7 @@ export class WithLayoutSourceState extends WithSelectedElementState {
|
|||
this.dataIsLoading = layoutSource.isLoading
|
||||
this.indexedFeatures = layoutSource
|
||||
this.featureProperties = new FeaturePropertiesStore(layoutSource)
|
||||
this.offlineForegroundDataManager = new OfflineForegroundDataManager(this)
|
||||
|
||||
this.floors = WithLayoutSourceState.initFloors(this.featuresInView)
|
||||
|
||||
|
|
|
@ -26,6 +26,21 @@ export class Tiles {
|
|||
return result
|
||||
}
|
||||
|
||||
public static async mapRangeAsync<T>(tileRange: TileRange, f: (x: number, y: number) => Promise<T>): Promise<T[]> {
|
||||
const result: T[] = []
|
||||
const total = tileRange.total
|
||||
if (total > 100000) {
|
||||
throw `Tilerange too big (z is ${tileRange.zoomlevel}, total tiles needed: ${tileRange.total})`
|
||||
}
|
||||
for (let x = tileRange.xstart; x <= tileRange.xend; x++) {
|
||||
for (let y = tileRange.ystart; y <= tileRange.yend; y++) {
|
||||
const t = await f(x, y)
|
||||
result.push(t)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the tile bounds of the
|
||||
* @param z
|
||||
|
@ -63,7 +78,7 @@ export class Tiles {
|
|||
static centerPointOf(zOrId: number, x?: number, y?: number): [number, number] {
|
||||
let z: number
|
||||
if (x === undefined) {
|
||||
;[z, x, y] = Tiles.tile_from_index(zOrId)
|
||||
[z, x, y] = Tiles.tile_from_index(zOrId)
|
||||
} else {
|
||||
z = zOrId
|
||||
}
|
||||
|
@ -95,7 +110,7 @@ export class Tiles {
|
|||
static asGeojson(zIndex: number, x?: number, y?: number): Feature<Polygon> {
|
||||
let z = zIndex
|
||||
if (x === undefined) {
|
||||
;[z, x, y] = Tiles.tile_from_index(zIndex)
|
||||
[z, x, y] = Tiles.tile_from_index(zIndex)
|
||||
}
|
||||
const bounds = Tiles.tile_bounds_lon_lat(z, x, y)
|
||||
return new BBox(bounds).asGeoJson()
|
||||
|
|
|
@ -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>
|
||||
|
|
205
src/UI/BigComponents/OfflineForegroundManagement.svelte
Normal file
205
src/UI/BigComponents/OfflineForegroundManagement.svelte
Normal 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>
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue