import UserRelatedState from "./UserRelatedState"; import {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 * as personal from "../../assets/themes/personal/personal.json"; 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"; /** * 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: UIEventSource; /** * 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: FeatureSourceForLayer & Tiled; /** * All previously visited points */ public historicalUserLocations: FeatureSourceForLayer & Tiled; /** * 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"); /** * 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 = this.InitializeFilteredLayers() 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) { if (layout.lockLocation === true) { const tile = Tiles.embedded_tile( layout.startLat, layout.startLon, layout.startZoom - 1 ); const bounds = Tiles.tile_bounds(tile.z, tile.x, tile.y); // We use the bounds to get a sense of distance for this zoom level const latDiff = bounds[0][0] - bounds[1][0]; const lonDiff = bounds[0][1] - bounds[1][1]; layout.lockLocation = [ [layout.startLat - latDiff, layout.startLon - lonDiff], [layout.startLat + latDiff, layout.startLon + lonDiff], ]; } 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: UIEventSource<{ 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 SimpleFeatureSource(currentViewLayer, 0, features) } 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); } 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 SimpleFeatureSource(gpsLineLayerDef, Tiles.tile_index(0, 0, 0), asLine); } } private initHomeLocation() { const empty = [] const feature = UIEventSource.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 SimpleFeatureSource(flayer, Tiles.tile_index(0, 0, 0), feature) } } private InitializeFilteredLayers() { const layoutToUse = this.layoutToUse; const flayers: FilteredLayer[] = []; for (const layer of layoutToUse.layers) { let isDisplayed: UIEventSource if (layoutToUse.id === personal.id) { isDisplayed = this.osmConnection.GetPreference("personal-theme-layer-" + layer.id + "-enabled") .map(value => value === "yes", [], enabled => { return enabled ? "yes" : ""; }) } else { isDisplayed = QueryParameters.GetBooleanQueryParameter( "layer-" + layer.id, "" + layer.shownByDefault, "Wether or not layer " + layer.id + " is shown" ) } const flayer: FilteredLayer = { isDisplayed: 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 new UIEventSource(flayers); } }