import UserRelatedState from "./UserRelatedState" import { Store, Stores, UIEventSource } from "../UIEventSource" import BaseLayer from "../../Models/BaseLayer" import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" import AvailableBaseLayers from "../Actors/AvailableBaseLayers" import Attribution from "../../UI/BigComponents/Attribution" import Minimap, { MinimapObj } from "../../UI/Base/Minimap" import { Tiles } from "../../Models/TileRange" import BaseUIElement from "../../UI/BaseUIElement" import FilteredLayer, { FilterState } from "../../Models/FilteredLayer" import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig" import { QueryParameters } from "../Web/QueryParameters" import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer" import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource" import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource" import { LocalStorageSource } from "../Web/LocalStorageSource" import { GeoOperations } from "../GeoOperations" import TitleHandler from "../Actors/TitleHandler" import { BBox } from "../BBox" import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import { TiledStaticFeatureSource } from "../FeatureSource/Sources/StaticFeatureSource" import { Translation, TypedTranslation } from "../../UI/i18n/Translation" import { Tag } from "../Tags/Tag" import { OsmConnection } from "../Osm/OsmConnection" export interface GlobalFilter { filter: FilterState id: string onNewPoint: { safetyCheck: Translation confirmAddNew: TypedTranslation<{ preset: Translation }> tags: Tag[] } } /** * Contains all the leaflet-map related state */ export default class MapState extends UserRelatedState { /** The leaflet instance of the big basemap */ public leafletMap = new UIEventSource(undefined, "leafletmap") /** * A list of currently available background layers */ public availableBackgroundLayers: Store /** * The current background layer */ public backgroundLayer: UIEventSource /** * Last location where a click was registered */ public readonly LastClickLocation: UIEventSource<{ lat: number lon: number }> = new UIEventSource<{ lat: number; lon: number }>(undefined) /** * The bounds of the current map view */ public currentView: FeatureSourceForLayer & Tiled /** * The location as delivered by the GPS */ public currentUserLocation: SimpleFeatureSource /** * All previously visited points */ public historicalUserLocations: SimpleFeatureSource /** * The number of seconds that the GPS-locations are stored in memory. * Time in seconds */ public gpsLocationHistoryRetentionTime = new UIEventSource( 7 * 24 * 60 * 60, "gps_location_retention" ) public historicalUserLocationsTrack: FeatureSourceForLayer & Tiled /** * A feature source containing the current home location of the user */ public homeLocation: FeatureSourceForLayer & Tiled public readonly mainMapObject: BaseUIElement & MinimapObj /** * Which layers are enabled in the current theme and what filters are applied onto them */ public filteredLayers: UIEventSource = new UIEventSource( [], "filteredLayers" ) /** * Filters which apply onto all layers */ public globalFilters: UIEventSource = new UIEventSource([], "globalFilters") /** * Which overlays are shown */ public overlayToggles: { config: TilesourceConfig; isDisplayed: UIEventSource }[] constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) { super(layoutToUse, options) this.availableBackgroundLayers = AvailableBaseLayers.AvailableLayersAt(this.locationControl) let defaultLayer = AvailableBaseLayers.osmCarto const available = this.availableBackgroundLayers.data for (const layer of available) { if (this.backgroundLayerId.data === layer.id) { defaultLayer = layer } } const self = this this.backgroundLayer = new UIEventSource(defaultLayer) this.backgroundLayer.addCallbackAndRunD((layer) => self.backgroundLayerId.setData(layer.id)) const attr = new Attribution( this.locationControl, this.osmConnection.userDetails, this.layoutToUse, this.currentBounds ) // Will write into this.leafletMap this.mainMapObject = Minimap.createMiniMap({ background: this.backgroundLayer, location: this.locationControl, leafletMap: this.leafletMap, bounds: this.currentBounds, attribution: attr, lastClickLocation: this.LastClickLocation, }) this.overlayToggles = this.layoutToUse?.tileLayerSources ?.filter((c) => c.name !== undefined) ?.map((c) => ({ config: c, isDisplayed: QueryParameters.GetBooleanQueryParameter( "overlay-" + c.id, c.defaultState, "Wether or not the overlay " + c.id + " is shown" ), })) ?? [] this.filteredLayers = new UIEventSource( MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection) ) this.lockBounds() this.AddAllOverlaysToMap(this.leafletMap) this.initHomeLocation() this.initGpsLocation() this.initUserLocationTrail() this.initCurrentView() new TitleHandler(this) } public AddAllOverlaysToMap(leafletMap: UIEventSource) { const initialized = new Set() for (const overlayToggle of this.overlayToggles) { new ShowOverlayLayer(overlayToggle.config, leafletMap, overlayToggle.isDisplayed) initialized.add(overlayToggle.config) } for (const tileLayerSource of this.layoutToUse?.tileLayerSources ?? []) { if (initialized.has(tileLayerSource)) { continue } new ShowOverlayLayer(tileLayerSource, leafletMap) } } private lockBounds() { const layout = this.layoutToUse if (!layout?.lockLocation) { return } console.warn("Locking the bounds to ", layout.lockLocation) this.mainMapObject.installBounds( new BBox(layout.lockLocation), this.featureSwitchIsTesting.data ) } private initCurrentView() { let currentViewLayer: FilteredLayer = this.filteredLayers.data.filter( (l) => l.layerDef.id === "current_view" )[0] if (currentViewLayer === undefined) { // This layer is not needed by the theme and thus unloaded return } let i = 0 const self = this const features: Store<{ feature: any; freshness: Date }[]> = this.currentBounds.map( (bounds) => { if (bounds === undefined) { return [] } i++ const feature = { freshness: new Date(), feature: { type: "Feature", properties: { id: "current_view-" + i, current_view: "yes", zoom: "" + self.locationControl.data.zoom, }, geometry: { type: "Polygon", coordinates: [ [ [bounds.maxLon, bounds.maxLat], [bounds.minLon, bounds.maxLat], [bounds.minLon, bounds.minLat], [bounds.maxLon, bounds.minLat], [bounds.maxLon, bounds.maxLat], ], ], }, }, } return [feature] } ) this.currentView = new TiledStaticFeatureSource(features, currentViewLayer) } private initGpsLocation() { // Initialize the gps layer data. This is emtpy for now, the actual writing happens in the Geolocationhandler let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter( (l) => l.layerDef.id === "gps_location" )[0] if (gpsLayerDef === undefined) { return } this.currentUserLocation = new SimpleFeatureSource(gpsLayerDef, Tiles.tile_index(0, 0, 0)) } private initUserLocationTrail() { const features = LocalStorageSource.GetParsed<{ feature: any; freshness: Date }[]>( "gps_location_history", [] ) const now = new Date().getTime() features.data = features.data .map((ff) => ({ feature: ff.feature, freshness: new Date(ff.freshness) })) .filter( (ff) => now - ff.freshness.getTime() < 1000 * this.gpsLocationHistoryRetentionTime.data ) features.ping() const self = this let i = 0 this.currentUserLocation?.features?.addCallbackAndRunD(([location]) => { if (location === undefined) { return } const previousLocation = features.data[features.data.length - 1] if (previousLocation !== undefined) { const d = GeoOperations.distanceBetween( previousLocation.feature.geometry.coordinates, location.feature.geometry.coordinates ) let timeDiff = Number.MAX_VALUE // in seconds const olderLocation = features.data[features.data.length - 2] if (olderLocation !== undefined) { timeDiff = (new Date(previousLocation.freshness).getTime() - new Date(olderLocation.freshness).getTime()) / 1000 } if (d < 20 && timeDiff < 60) { // Do not append changes less then 20m - it's probably noise anyway return } } const feature = JSON.parse(JSON.stringify(location.feature)) feature.properties.id = "gps/" + features.data.length i++ features.data.push({ feature, freshness: new Date() }) features.ping() }) let gpsLayerDef: FilteredLayer = this.filteredLayers.data.filter( (l) => l.layerDef.id === "gps_location_history" )[0] if (gpsLayerDef !== undefined) { this.historicalUserLocations = new SimpleFeatureSource( gpsLayerDef, Tiles.tile_index(0, 0, 0), features ) this.changes.setHistoricalUserLocations(this.historicalUserLocations) } const asLine = features.map((allPoints) => { if (allPoints === undefined || allPoints.length < 2) { return [] } const feature = { type: "Feature", properties: { id: "location_track", "_date:now": new Date().toISOString(), }, geometry: { type: "LineString", coordinates: allPoints.map((ff) => ff.feature.geometry.coordinates), }, } self.allElements.ContainingFeatures.set(feature.properties.id, feature) return [ { feature, freshness: new Date(), }, ] }) let gpsLineLayerDef: FilteredLayer = this.filteredLayers.data.filter( (l) => l.layerDef.id === "gps_track" )[0] if (gpsLineLayerDef !== undefined) { this.historicalUserLocationsTrack = new TiledStaticFeatureSource( asLine, gpsLineLayerDef ) } } private initHomeLocation() { const empty = [] const feature = Stores.ListStabilized( this.osmConnection.userDetails.map((userDetails) => { if (userDetails === undefined) { return undefined } const home = userDetails.home if (home === undefined) { return undefined } return [home.lon, home.lat] }) ).map((homeLonLat) => { if (homeLonLat === undefined) { return empty } return [ { feature: { type: "Feature", properties: { id: "home", "user:home": "yes", _lon: homeLonLat[0], _lat: homeLonLat[1], }, geometry: { type: "Point", coordinates: homeLonLat, }, }, freshness: new Date(), }, ] }) const flayer = this.filteredLayers.data.filter((l) => l.layerDef.id === "home_location")[0] if (flayer !== undefined) { this.homeLocation = new TiledStaticFeatureSource(feature, flayer) } } private static getPref( osmConnection: OsmConnection, key: string, layer: LayerConfig ): UIEventSource { return osmConnection.GetPreference(key, layer.shownByDefault + "").sync( (v) => { if (v === undefined) { return undefined } return v === "true" }, [], (b) => { if (b === undefined) { return undefined } return "" + b } ) } public static InitializeFilteredLayers( layoutToUse: { layers: LayerConfig[]; id: string }, osmConnection: OsmConnection ): FilteredLayer[] { if (layoutToUse === undefined) { return [] } const flayers: FilteredLayer[] = [] for (const layer of layoutToUse.layers) { let isDisplayed: UIEventSource if (layer.syncSelection === "local") { isDisplayed = LocalStorageSource.GetParsed( layoutToUse.id + "-layer-" + layer.id + "-enabled", layer.shownByDefault ) } else if (layer.syncSelection === "theme-only") { isDisplayed = MapState.getPref( osmConnection, layoutToUse.id + "-layer-" + layer.id + "-enabled", layer ) } else if (layer.syncSelection === "global") { isDisplayed = MapState.getPref( osmConnection, "layer-" + layer.id + "-enabled", layer ) } else { isDisplayed = QueryParameters.GetBooleanQueryParameter( "layer-" + layer.id, layer.shownByDefault, "Wether or not layer " + layer.id + " is shown" ) } const flayer: FilteredLayer = { isDisplayed, layerDef: layer, appliedFilters: new UIEventSource>( new Map() ), } layer.filters.forEach((filterConfig) => { const stateSrc = filterConfig.initState() stateSrc.addCallbackAndRun((state) => flayer.appliedFilters.data.set(filterConfig.id, state) ) flayer.appliedFilters .map((dict) => dict.get(filterConfig.id)) .addCallback((state) => stateSrc.setData(state)) }) flayers.push(flayer) } for (const layer of layoutToUse.layers) { if (layer.filterIsSameAs === undefined) { continue } const toReuse = flayers.find((l) => l.layerDef.id === layer.filterIsSameAs) if (toReuse === undefined) { throw ( "Error in layer " + layer.id + ": it defines that it should be use the filters of " + layer.filterIsSameAs + ", but this layer was not loaded" ) } console.warn( "Linking filter and isDisplayed-states of " + layer.id + " and " + layer.filterIsSameAs ) const selfLayer = flayers.findIndex((l) => l.layerDef.id === layer.id) flayers[selfLayer] = { isDisplayed: toReuse.isDisplayed, layerDef: layer, appliedFilters: toReuse.appliedFilters, } } return flayers } }