import { QueryParameters } from "../Web/QueryParameters" import { BBox } from "../BBox" import Constants from "../../Models/Constants" import { GeoLocationState } from "../State/GeoLocationState" import { UIEventSource } from "../UIEventSource" import { Feature, LineString, Point } from "geojson" import { FeatureSource, WritableFeatureSource } from "../FeatureSource/FeatureSource" import { LocalStorageSource } from "../Web/LocalStorageSource" import { GeoOperations } from "../GeoOperations" import { OsmTags } from "../../Models/OsmFeature" import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource" import { MapProperties } from "../../Models/MapProperties" /** * The geolocation-handler takes a map-location and a geolocation state. * It'll move the map as appropriate given the state of the geolocation-API * It will also copy the geolocation into the appropriate FeatureSource to display on the map */ export default class GeoLocationHandler { public readonly geolocationState: GeoLocationState /** * The location as delivered by the GPS, wrapped as FeatureSource */ public currentUserLocation: FeatureSource /** * All previously visited points (as 'Point'-objects), with their metadata */ public historicalUserLocations: WritableFeatureSource> /** * A featureSource containing a single linestring which has the GPS-history of the user. * However, metadata (such as when every single point was visited) is lost here (but is kept in `historicalUserLocations`. * Note that this featureSource is _derived_ from 'historicalUserLocations' */ public readonly historicalUserLocationsTrack: FeatureSource /** * The last moment that the map has moved */ public readonly mapHasMoved: UIEventSource = new UIEventSource< Date | undefined >(undefined) private readonly selectedElement: UIEventSource private readonly mapProperties?: MapProperties private readonly gpsLocationHistoryRetentionTime?: UIEventSource constructor( geolocationState: GeoLocationState, selectedElement: UIEventSource, mapProperties?: MapProperties, gpsLocationHistoryRetentionTime?: UIEventSource ) { this.geolocationState = geolocationState const mapLocation = mapProperties.location this.selectedElement = selectedElement this.mapProperties = mapProperties this.gpsLocationHistoryRetentionTime = gpsLocationHistoryRetentionTime // Did an interaction move the map? let self = this let initTime = new Date() mapLocation.addCallbackD((_) => { if (new Date().getTime() - initTime.getTime() < 250) { return } self.mapHasMoved.setData(new Date()) return true // Unsubscribe }) const latLonGivenViaUrl = QueryParameters.wasInitialized("lat") || QueryParameters.wasInitialized("lon") if (latLonGivenViaUrl) { // The URL counts as a 'user interaction' this.mapHasMoved.setData(new Date()) } this.geolocationState.currentGPSLocation.addCallbackAndRunD((_) => { const timeSinceLastRequest = (new Date().getTime() - geolocationState.requestMoment.data?.getTime() ?? 0) / 1000 if (!this.mapHasMoved.data) { // The map hasn't moved yet; we received our first coordinates, so let's move there! self.MoveMapToCurrentLocation() } if ( timeSinceLastRequest < Constants.zoomToLocationTimeout && (this.mapHasMoved.data === undefined || this.mapHasMoved.data.getTime() < geolocationState.requestMoment.data?.getTime()) ) { // still within request time and the map hasn't moved since requesting to jump to the current location self.MoveMapToCurrentLocation() } if (!this.geolocationState.allowMoving.data) { // Jup, the map is locked to the bound location: move automatically self.MoveMapToCurrentLocation() return } }) geolocationState.allowMoving.syncWith(mapProperties.allowMoving, true) this.CopyGeolocationIntoMapstate() this.historicalUserLocationsTrack = this.initUserLocationTrail() } /** * Move the map to the GPS-location, except: * - If there is a selected element * - The location is out of the locked bound * - The GPS-location iss NULL-island * @constructor */ public MoveMapToCurrentLocation() { const newLocation = this.geolocationState.currentGPSLocation.data const mapLocation = this.mapProperties.location // We got a new location. // Do we move the map to it? if (this.selectedElement.data !== undefined) { // Nope, there is something selected, so we don't move to the current GPS-location return } if (newLocation.latitude === 0 && newLocation.longitude === 0) { console.debug("Not moving to GPS-location: it is null island") return } // We check that the GPS location is not out of bounds const bounds = this.mapProperties.maxbounds.data if (bounds !== undefined) { // B is an array with our lock-location const inRange = new BBox(bounds).contains([newLocation.longitude, newLocation.latitude]) if (!inRange) { return } } console.trace("Moving the map to the GPS-location") mapLocation.setData({ lon: newLocation.longitude, lat: newLocation.latitude, }) const zoom = this.mapProperties.zoom zoom.setData(Math.min(Math.max(zoom.data, 14), 18)) this.mapHasMoved.setData(new Date()) this.geolocationState.requestMoment.setData(undefined) } private CopyGeolocationIntoMapstate() { const features: UIEventSource = new UIEventSource([]) this.currentUserLocation = new StaticFeatureSource(features) const keysToCopy = ["speed", "accuracy", "altitude", "altitudeAccuracy", "heading"] let i = 0 this.geolocationState.currentGPSLocation.addCallbackAndRun((location) => { if (location === undefined) { return } const properties = { id: "gps-" + i, "user:location": "yes", date: new Date().toISOString(), } i++ for (const k in keysToCopy) { // For some weird reason, the 'Object.keys' method doesn't work for the 'location: GeolocationCoordinates'-object and will thus not copy all the properties when using {...location} // As such, they are copied here if (location[k]) { properties[k] = location[k] } } const feature = { type: "Feature", properties, geometry: { type: "Point", coordinates: [location.longitude, location.latitude], }, } features.setData([feature]) }) } private initUserLocationTrail() { const features = LocalStorageSource.GetParsed("gps_location_history", []) const now = new Date().getTime() features.data = features.data.filter((ff) => { if (ff.properties === undefined) { return false } const point_time = new Date(ff.properties["date"]) return ( now - point_time.getTime() < 1000 * (this.gpsLocationHistoryRetentionTime?.data ?? 24 * 60 * 60 * 1000) ) }) features.ping() let i = 0 this.currentUserLocation?.features?.addCallbackAndRunD(([location]: [Feature]) => { if (location === undefined) { return } const previousLocation = >features.data[features.data.length - 1] if (previousLocation !== undefined) { const previousLocationFreshness = new Date(previousLocation.properties.date) const d = GeoOperations.distanceBetween( <[number, number]>previousLocation.geometry.coordinates, <[number, number]>location.geometry.coordinates ) let timeDiff = Number.MAX_VALUE // in seconds const olderLocation = features.data[features.data.length - 2] if (olderLocation !== undefined) { const olderLocationFreshness = new Date(olderLocation.properties.date) timeDiff = (new Date(previousLocationFreshness).getTime() - new Date(olderLocationFreshness).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.properties.id = "gps/" + features.data.length i++ features.data.push(feature) features.ping() }) this.historicalUserLocations = new StaticFeatureSource(features) const asLine = features.map((allPoints) => { if (allPoints === undefined || allPoints.length < 2) { return [] } const feature: Feature = { type: "Feature", properties: { id: "location_track", "_date:now": new Date().toISOString(), }, geometry: { type: "LineString", coordinates: allPoints.map( (ff: Feature) => <[number, number]>ff.geometry.coordinates ), }, } return [feature] }) return new StaticFeatureSource(asLine) } }