forked from MapComplete/MapComplete
Refactor geolocation handler into multiple parts, fix lock behaviour
This commit is contained in:
parent
e10c9e87ca
commit
4de76136de
7 changed files with 362 additions and 372 deletions
|
@ -1,218 +1,123 @@
|
||||||
import { Store, UIEventSource } from "../UIEventSource"
|
|
||||||
import Svg from "../../Svg"
|
|
||||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
|
||||||
import { VariableUiElement } from "../../UI/Base/VariableUIElement"
|
|
||||||
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
|
|
||||||
import { QueryParameters } from "../Web/QueryParameters"
|
import { QueryParameters } from "../Web/QueryParameters"
|
||||||
import { BBox } from "../BBox"
|
import { BBox } from "../BBox"
|
||||||
import Constants from "../../Models/Constants"
|
import Constants from "../../Models/Constants"
|
||||||
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
|
import { GeoLocationPointProperties, GeoLocationState } from "../State/GeoLocationState"
|
||||||
|
import State from "../../State"
|
||||||
|
import { UIEventSource } from "../UIEventSource"
|
||||||
|
|
||||||
export interface GeoLocationPointProperties {
|
/**
|
||||||
id: "gps"
|
* The geolocation-handler takes a map-location and a geolocation state.
|
||||||
"user:location": "yes"
|
* It'll move the map as appropriate given the state of the geolocation-API
|
||||||
date: string
|
*/
|
||||||
latitude: number
|
export default class GeoLocationHandler {
|
||||||
longitude: number
|
public readonly geolocationState: GeoLocationState
|
||||||
speed: number
|
private readonly _state: State
|
||||||
accuracy: number
|
public readonly mapHasMoved: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||||
heading: number
|
|
||||||
altitude: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class GeoLocationHandler extends VariableUiElement {
|
constructor(
|
||||||
private readonly currentLocation?: SimpleFeatureSource
|
geolocationState: GeoLocationState,
|
||||||
|
state: State // { locationControl: UIEventSource<Loc>, selectedElement: UIEventSource<any>, leafletMap?: UIEventSource<any> })
|
||||||
/**
|
) {
|
||||||
* Wether or not the geolocation is active, aka the user requested the current location
|
this.geolocationState = geolocationState
|
||||||
*/
|
this._state = state
|
||||||
private readonly _isActive: UIEventSource<boolean>
|
const mapLocation = state.locationControl
|
||||||
|
// Did an interaction move the map?
|
||||||
/**
|
let self = this
|
||||||
* Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user
|
let initTime = new Date()
|
||||||
*/
|
mapLocation.addCallbackD((_) => {
|
||||||
private readonly _isLocked: UIEventSource<boolean>
|
if (new Date().getTime() - initTime.getTime() < 250) {
|
||||||
|
return
|
||||||
/**
|
|
||||||
* The callback over the permission API
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _permission: UIEventSource<string>
|
|
||||||
/**
|
|
||||||
* Literally: _currentGPSLocation.data != undefined
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _hasLocation: Store<boolean>
|
|
||||||
private readonly _currentGPSLocation: UIEventSource<GeolocationCoordinates>
|
|
||||||
/**
|
|
||||||
* Kept in order to update the marker
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _leafletMap: UIEventSource<L.Map>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
|
|
||||||
*/
|
|
||||||
private _lastUserRequest: UIEventSource<Date>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
|
|
||||||
* On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions.
|
|
||||||
*
|
|
||||||
* Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately.
|
|
||||||
* If the user denies the geolocation this time, we unset this flag
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
private readonly _previousLocationGrant: UIEventSource<string>
|
|
||||||
private readonly _layoutToUse: LayoutConfig
|
|
||||||
|
|
||||||
constructor(state: {
|
|
||||||
selectedElement: UIEventSource<any>
|
|
||||||
currentUserLocation?: SimpleFeatureSource
|
|
||||||
leafletMap: UIEventSource<any>
|
|
||||||
layoutToUse: LayoutConfig
|
|
||||||
featureSwitchGeolocation: UIEventSource<boolean>
|
|
||||||
}) {
|
|
||||||
const currentGPSLocation = new UIEventSource<GeolocationCoordinates>(
|
|
||||||
undefined,
|
|
||||||
"GPS-coordinate"
|
|
||||||
)
|
|
||||||
const leafletMap = state.leafletMap
|
|
||||||
const initedAt = new Date()
|
|
||||||
let autozoomDone = false
|
|
||||||
const hasLocation = currentGPSLocation.map((location) => location !== undefined)
|
|
||||||
const previousLocationGrant = LocalStorageSource.Get("geolocation-permissions")
|
|
||||||
const isActive = new UIEventSource<boolean>(false)
|
|
||||||
const isLocked = new UIEventSource<boolean>(false)
|
|
||||||
const permission = new UIEventSource<string>("")
|
|
||||||
const lastClick = new UIEventSource<Date>(undefined)
|
|
||||||
const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
|
|
||||||
if (lastClick === undefined) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
|
self.mapHasMoved.setData(true)
|
||||||
return timeDiff <= 3
|
return true // Unsubscribe
|
||||||
})
|
})
|
||||||
|
|
||||||
const latLonGiven =
|
const latLonGivenViaUrl =
|
||||||
QueryParameters.wasInitialized("lat") && QueryParameters.wasInitialized("lon")
|
QueryParameters.wasInitialized("lat") || QueryParameters.wasInitialized("lon")
|
||||||
const willFocus = lastClick.map((lastUserRequest) => {
|
if (latLonGivenViaUrl) {
|
||||||
const timeDiffInited = (new Date().getTime() - initedAt.getTime()) / 1000
|
// The URL counts as a 'user interaction'
|
||||||
if (!latLonGiven && !autozoomDone && timeDiffInited < Constants.zoomToLocationTimeout) {
|
this.mapHasMoved.setData(true)
|
||||||
return true
|
}
|
||||||
}
|
|
||||||
if (lastUserRequest === undefined) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const timeDiff = (new Date().getTime() - lastUserRequest.getTime()) / 1000
|
|
||||||
return timeDiff <= Constants.zoomToLocationTimeout
|
|
||||||
})
|
|
||||||
|
|
||||||
lastClick.addCallbackAndRunD((_) => {
|
this.geolocationState.currentGPSLocation.addCallbackAndRunD((newLocation) => {
|
||||||
window.setTimeout(() => {
|
const timeSinceLastRequest =
|
||||||
if (lastClickWithinThreeSecs.data || willFocus.data) {
|
(new Date().getTime() - geolocationState.requestMoment.data?.getTime() ?? 0) / 1000
|
||||||
lastClick.ping()
|
if (!this.mapHasMoved.data) {
|
||||||
|
// The map hasn't moved yet; we received our first coordinates, so let's move there!
|
||||||
|
console.log(
|
||||||
|
"Moving the map to an initial location; time since last request is",
|
||||||
|
timeSinceLastRequest
|
||||||
|
)
|
||||||
|
if (timeSinceLastRequest < Constants.zoomToLocationTimeout) {
|
||||||
|
self.MoveMapToCurrentLocation()
|
||||||
}
|
}
|
||||||
}, 500)
|
}
|
||||||
|
|
||||||
|
if (this.geolocationState.isLocked.data) {
|
||||||
|
// Jup, the map is locked to the bound location: move automatically
|
||||||
|
self.MoveMapToCurrentLocation()
|
||||||
|
return
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
super(
|
geolocationState.isLocked.map(
|
||||||
hasLocation.map(
|
(isLocked) => {
|
||||||
(hasLocationData) => {
|
if (isLocked) {
|
||||||
if (permission.data === "denied") {
|
state.leafletMap?.data?.dragging?.disable()
|
||||||
return Svg.location_refused_svg()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isActive.data) {
|
|
||||||
return Svg.location_empty_svg()
|
|
||||||
}
|
|
||||||
if (!hasLocationData) {
|
|
||||||
// Position not yet found but we are active: we spin to indicate activity
|
|
||||||
// If will focus is active too, we indicate this differently
|
|
||||||
const icon = willFocus.data ? Svg.location_svg() : Svg.location_empty_svg()
|
|
||||||
icon.SetStyle("animation: spin 4s linear infinite;")
|
|
||||||
return icon
|
|
||||||
}
|
|
||||||
if (isLocked.data) {
|
|
||||||
return Svg.location_locked_svg()
|
|
||||||
}
|
|
||||||
if (lastClickWithinThreeSecs.data) {
|
|
||||||
return Svg.location_unlocked_svg()
|
|
||||||
}
|
|
||||||
|
|
||||||
// We have a location, so we show a dot in the center
|
|
||||||
return Svg.location_svg()
|
|
||||||
},
|
|
||||||
[isActive, isLocked, permission, lastClickWithinThreeSecs, willFocus]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
this.SetClass("mapcontrol")
|
|
||||||
this._isActive = isActive
|
|
||||||
this._isLocked = isLocked
|
|
||||||
this._permission = permission
|
|
||||||
this._previousLocationGrant = previousLocationGrant
|
|
||||||
this._currentGPSLocation = currentGPSLocation
|
|
||||||
this._leafletMap = leafletMap
|
|
||||||
this._layoutToUse = state.layoutToUse
|
|
||||||
this._hasLocation = hasLocation
|
|
||||||
this._lastUserRequest = lastClick
|
|
||||||
const self = this
|
|
||||||
|
|
||||||
const currentPointer = this._isActive.map(
|
|
||||||
(isActive) => {
|
|
||||||
if (isActive && !self._hasLocation.data) {
|
|
||||||
return "cursor-wait"
|
|
||||||
}
|
|
||||||
return "cursor-pointer"
|
|
||||||
},
|
|
||||||
[this._hasLocation]
|
|
||||||
)
|
|
||||||
currentPointer.addCallbackAndRun((pointerClass) => {
|
|
||||||
self.RemoveClass("cursor-wait")
|
|
||||||
self.RemoveClass("cursor-pointer")
|
|
||||||
self.SetClass(pointerClass)
|
|
||||||
})
|
|
||||||
|
|
||||||
this.onClick(() => {
|
|
||||||
/*
|
|
||||||
* If the previous click was within 3 seconds (and we have an active location), then we lock to the location
|
|
||||||
*/
|
|
||||||
if (self._hasLocation.data) {
|
|
||||||
if (isLocked.data) {
|
|
||||||
isLocked.setData(false)
|
|
||||||
} else if (lastClick.data !== undefined) {
|
|
||||||
const timeDiff = (new Date().getTime() - lastClick.data.getTime()) / 1000
|
|
||||||
if (timeDiff <= 3) {
|
|
||||||
isLocked.setData(true)
|
|
||||||
lastClick.setData(undefined)
|
|
||||||
} else {
|
|
||||||
lastClick.setData(new Date())
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
lastClick.setData(new Date())
|
state.leafletMap?.data?.dragging?.enable()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
[state.leafletMap]
|
||||||
|
)
|
||||||
|
|
||||||
|
this.CopyGeolocationIntoMapstate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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._state.locationControl
|
||||||
|
const state = this._state
|
||||||
|
// We got a new location.
|
||||||
|
// Do we move the map to it?
|
||||||
|
|
||||||
|
if (state.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 = state.layoutToUse.lockLocation
|
||||||
|
if (bounds && bounds !== true) {
|
||||||
|
// B is an array with our lock-location
|
||||||
|
const inRange = new BBox(bounds).contains([newLocation.longitude, newLocation.latitude])
|
||||||
|
if (!inRange) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.init(true, true)
|
mapLocation.setData({
|
||||||
|
zoom: mapLocation.data.zoom,
|
||||||
|
lon: newLocation.longitude,
|
||||||
|
lat: newLocation.latitude,
|
||||||
})
|
})
|
||||||
|
this.mapHasMoved.setData(true)
|
||||||
|
}
|
||||||
|
|
||||||
const doAutoZoomToLocation =
|
private CopyGeolocationIntoMapstate() {
|
||||||
!latLonGiven &&
|
const state = this._state
|
||||||
state.featureSwitchGeolocation.data &&
|
this.geolocationState.currentGPSLocation.addCallback((location) => {
|
||||||
state.selectedElement.data !== undefined
|
|
||||||
this.init(false, doAutoZoomToLocation)
|
|
||||||
|
|
||||||
isLocked.addCallbackAndRunD((isLocked) => {
|
|
||||||
if (isLocked) {
|
|
||||||
leafletMap.data?.dragging?.disable()
|
|
||||||
} else {
|
|
||||||
leafletMap.data?.dragging?.enable()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.currentLocation = state.currentUserLocation
|
|
||||||
this._currentGPSLocation.addCallback((location) => {
|
|
||||||
self._previousLocationGrant.setData("granted")
|
|
||||||
const feature = {
|
const feature = {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: <GeoLocationPointProperties>{
|
properties: <GeoLocationPointProperties>{
|
||||||
|
@ -232,164 +137,7 @@ export default class GeoLocationHandler extends VariableUiElement {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
self.currentLocation?.features?.setData([{ feature, freshness: new Date() }])
|
state.currentUserLocation?.features?.setData([{ feature, freshness: new Date() }])
|
||||||
|
|
||||||
if (willFocus.data) {
|
|
||||||
console.log("Zooming to user location: willFocus is set")
|
|
||||||
lastClick.setData(undefined)
|
|
||||||
autozoomDone = true
|
|
||||||
self.MoveToCurrentLocation(16)
|
|
||||||
} else if (self._isLocked.data) {
|
|
||||||
self.MoveToCurrentLocation()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(askPermission: boolean, zoomToLocation: boolean) {
|
|
||||||
const self = this
|
|
||||||
|
|
||||||
if (self._isActive.data) {
|
|
||||||
self.MoveToCurrentLocation(16)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof navigator === "undefined") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
navigator?.permissions?.query({ name: "geolocation" })?.then(function (status) {
|
|
||||||
console.log("Geolocation permission is ", status.state)
|
|
||||||
if (status.state === "granted") {
|
|
||||||
self.StartGeolocating(zoomToLocation)
|
|
||||||
}
|
|
||||||
self._permission.setData(status.state)
|
|
||||||
status.onchange = function () {
|
|
||||||
self._permission.setData(status.state)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (askPermission) {
|
|
||||||
self.StartGeolocating(zoomToLocation)
|
|
||||||
} else if (this._previousLocationGrant.data === "granted") {
|
|
||||||
this._previousLocationGrant.setData("")
|
|
||||||
self.StartGeolocating(zoomToLocation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the currently loaded location.
|
|
||||||
*
|
|
||||||
* // Should move to any location
|
|
||||||
* let resultingLocation = undefined
|
|
||||||
* let resultingzoom = 1
|
|
||||||
* const state = {
|
|
||||||
* selectedElement: new UIEventSource<any>(undefined);
|
|
||||||
* currentUserLocation: undefined ,
|
|
||||||
* leafletMap: new UIEventSource<any>({getZoom: () => resultingzoom; setView: (loc, zoom) => {resultingLocation = loc; resultingzoom = zoom}),
|
|
||||||
* layoutToUse: new LayoutConfig(<any>{
|
|
||||||
* id: 'test',
|
|
||||||
* title: {"en":"test"}
|
|
||||||
* description: "A testing theme",
|
|
||||||
* layers: []
|
|
||||||
* }),
|
|
||||||
* featureSwitchGeolocation : new UIEventSource<boolean>(true)
|
|
||||||
* }
|
|
||||||
* const handler = new GeoLocationHandler(state)
|
|
||||||
* handler._currentGPSLocation.setData(<any> {latitude : 51.3, longitude: 4.1})
|
|
||||||
* handler.MoveToCurrentLocation()
|
|
||||||
* resultingLocation // => [51.3, 4.1]
|
|
||||||
* handler._currentGPSLocation.setData(<any> {latitude : 60, longitude: 60) // out of bounds
|
|
||||||
* handler.MoveToCurrentLocation()
|
|
||||||
* resultingLocation // => [60, 60]
|
|
||||||
*
|
|
||||||
* // should refuse to move if out of bounds
|
|
||||||
* let resultingLocation = undefined
|
|
||||||
* let resultingzoom = 1
|
|
||||||
* const state = {
|
|
||||||
* selectedElement: new UIEventSource<any>(undefined);
|
|
||||||
* currentUserLocation: undefined ,
|
|
||||||
* leafletMap: new UIEventSource<any>({getZoom: () => resultingzoom; setView: (loc, zoom) => {resultingLocation = loc; resultingzoom = zoom}),
|
|
||||||
* layoutToUse: new LayoutConfig(<any>{
|
|
||||||
* id: 'test',
|
|
||||||
* title: {"en":"test"}
|
|
||||||
* "lockLocation": [ [ 2.1, 50.4], [6.4, 51.54 ]],
|
|
||||||
* description: "A testing theme",
|
|
||||||
* layers: []
|
|
||||||
* }),
|
|
||||||
* featureSwitchGeolocation : new UIEventSource<boolean>(true)
|
|
||||||
* }
|
|
||||||
* const handler = new GeoLocationHandler(state)
|
|
||||||
* handler._currentGPSLocation.setData(<any> {latitude : 51.3, longitude: 4.1})
|
|
||||||
* handler.MoveToCurrentLocation()
|
|
||||||
* resultingLocation // => [51.3, 4.1]
|
|
||||||
* handler._currentGPSLocation.setData(<any> {latitude : 60, longitude: 60) // out of bounds
|
|
||||||
* handler.MoveToCurrentLocation()
|
|
||||||
* resultingLocation // => [51.3, 4.1]
|
|
||||||
*/
|
|
||||||
private MoveToCurrentLocation(targetZoom?: number) {
|
|
||||||
const location = this._currentGPSLocation.data
|
|
||||||
this._lastUserRequest.setData(undefined)
|
|
||||||
|
|
||||||
if (
|
|
||||||
this._currentGPSLocation.data.latitude === 0 &&
|
|
||||||
this._currentGPSLocation.data.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 b = this._layoutToUse.lockLocation
|
|
||||||
let inRange = true
|
|
||||||
if (b) {
|
|
||||||
if (b !== true) {
|
|
||||||
// B is an array with our locklocation
|
|
||||||
inRange = new BBox(b).contains([location.longitude, location.latitude])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!inRange) {
|
|
||||||
console.log("Not zooming to GPS location: out of bounds", b, location)
|
|
||||||
} else {
|
|
||||||
const currentZoom = this._leafletMap.data.getZoom()
|
|
||||||
this._leafletMap.data.setView(
|
|
||||||
[location.latitude, location.longitude],
|
|
||||||
Math.max(targetZoom ?? 0, currentZoom)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private StartGeolocating(zoomToGPS = true) {
|
|
||||||
const self = this
|
|
||||||
|
|
||||||
this._lastUserRequest.setData(zoomToGPS ? new Date() : new Date(0))
|
|
||||||
if (self._permission.data === "denied") {
|
|
||||||
self._previousLocationGrant.setData("")
|
|
||||||
self._isActive.setData(false)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if (this._currentGPSLocation.data !== undefined) {
|
|
||||||
this.MoveToCurrentLocation(16)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self._isActive.data) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self._isActive.setData(true)
|
|
||||||
|
|
||||||
navigator.geolocation.watchPosition(
|
|
||||||
function (position) {
|
|
||||||
self._currentGPSLocation.setData(position.coords)
|
|
||||||
},
|
|
||||||
function () {
|
|
||||||
console.warn("Could not get location with navigator.geolocation")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enableHighAccuracy: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||||
import SimpleMetaTagger from "../SimpleMetaTagger"
|
import SimpleMetaTagger from "../SimpleMetaTagger"
|
||||||
import FeatureSource from "../FeatureSource/FeatureSource"
|
import FeatureSource from "../FeatureSource/FeatureSource"
|
||||||
import { ElementStorage } from "../ElementStorage"
|
import { ElementStorage } from "../ElementStorage"
|
||||||
import { GeoLocationPointProperties } from "../Actors/GeoLocationHandler"
|
import { GeoLocationPointProperties } from "../State/GeoLocationState"
|
||||||
import { GeoOperations } from "../GeoOperations"
|
import { GeoOperations } from "../GeoOperations"
|
||||||
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
|
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
|
||||||
import { OsmConnection } from "./OsmConnection"
|
import { OsmConnection } from "./OsmConnection"
|
||||||
|
|
138
Logic/State/GeoLocationState.ts
Normal file
138
Logic/State/GeoLocationState.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import { UIEventSource } from "../UIEventSource"
|
||||||
|
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||||
|
|
||||||
|
type GeolocationState = "prompt" | "requested" | "granted" | "denied"
|
||||||
|
|
||||||
|
export interface GeoLocationPointProperties extends GeolocationCoordinates {
|
||||||
|
id: "gps"
|
||||||
|
"user:location": "yes"
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An abstract representation of the current state of the geolocation.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class GeoLocationState {
|
||||||
|
/**
|
||||||
|
* What do we know about the current state of having access to the GPS?
|
||||||
|
* If 'prompt', then we just started and didn't request access yet
|
||||||
|
* 'requested' means the user tapped the 'locate me' button at least once
|
||||||
|
* 'granted' means that it is granted
|
||||||
|
* 'denied' means that we don't have access
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public readonly permission: UIEventSource<GeolocationState> = new UIEventSource("prompt")
|
||||||
|
|
||||||
|
public readonly requestMoment: UIEventSource<Date | undefined> = new UIEventSource(undefined)
|
||||||
|
/**
|
||||||
|
* If true: the map will center (and re-center) to this location
|
||||||
|
*/
|
||||||
|
public readonly isLocked: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||||
|
|
||||||
|
public readonly currentGPSLocation: UIEventSource<GeolocationCoordinates | undefined> =
|
||||||
|
new UIEventSource<GeolocationCoordinates | undefined>(undefined)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small flag on localstorage. If the user previously granted the geolocation, it will be set.
|
||||||
|
* On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions.
|
||||||
|
*
|
||||||
|
* Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately.
|
||||||
|
* If the user denies the geolocation this time, we unset this flag
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private readonly _previousLocationGrant: UIEventSource<"true" | "false"> = <any>(
|
||||||
|
LocalStorageSource.Get("geolocation-permissions")
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to detect a permission retraction
|
||||||
|
*/
|
||||||
|
private readonly _grantedThisSession: UIEventSource<boolean> = new UIEventSource<boolean>(false)
|
||||||
|
constructor() {
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
this.permission.addCallbackAndRunD(async (state) => {
|
||||||
|
if (state === "granted") {
|
||||||
|
self._previousLocationGrant.setData("true")
|
||||||
|
self._grantedThisSession.setData(true)
|
||||||
|
await self.startWatching()
|
||||||
|
}
|
||||||
|
if (state === "prompt" && self._grantedThisSession.data) {
|
||||||
|
// This is _really_ weird: we had a grant earlier, but it's 'prompt' now?
|
||||||
|
// This means that the rights have been revoked again!
|
||||||
|
// self.permission.setData("denied")
|
||||||
|
self._previousLocationGrant.setData("false")
|
||||||
|
self.permission.setData("denied")
|
||||||
|
console.warn("Detected a downgrade in permissions!")
|
||||||
|
}
|
||||||
|
if (state === "denied") {
|
||||||
|
self._previousLocationGrant.setData("false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this._previousLocationGrant.data === "true") {
|
||||||
|
// A previous visit successfully granted permission. Chance is high that we are allowed to use it again!
|
||||||
|
|
||||||
|
// We set the flag to false again. If the user only wanted to share their location once, we are not gonna keep bothering them
|
||||||
|
this._previousLocationGrant.setData("false")
|
||||||
|
console.log("Requesting access to GPS as this was previously granted")
|
||||||
|
this.requestPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs the listener for updates
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async startWatching() {
|
||||||
|
const self = this
|
||||||
|
navigator.geolocation.watchPosition(
|
||||||
|
function (position) {
|
||||||
|
self.currentGPSLocation.setData(position.coords)
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
console.warn("Could not get location with navigator.geolocation")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests the user to allow access to their position.
|
||||||
|
* When granted, will be written to the 'geolocationState'.
|
||||||
|
* This class will start watching
|
||||||
|
*/
|
||||||
|
public requestPermission() {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
// Not compatible with this browser
|
||||||
|
this.permission.setData("denied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.permission.data !== "prompt") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.requestMoment.setData(new Date())
|
||||||
|
this.permission.setData("requested")
|
||||||
|
try {
|
||||||
|
navigator?.permissions
|
||||||
|
?.query({ name: "geolocation" })
|
||||||
|
.then((status) => {
|
||||||
|
console.log("Geolocation permission is ", status.state)
|
||||||
|
this.permission.setData(status.state)
|
||||||
|
const self = this
|
||||||
|
status.onchange = function () {
|
||||||
|
self.permission.setData(status.state)
|
||||||
|
}
|
||||||
|
// We _must_ call 'startWatching', as that is the actual trigger for the popup...
|
||||||
|
self.startWatching()
|
||||||
|
})
|
||||||
|
.catch((e) => console.error("Could not get geopermission", e))
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not get permission:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
99
UI/BigComponents/GeolocationControl.ts
Normal file
99
UI/BigComponents/GeolocationControl.ts
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
import { VariableUiElement } from "../Base/VariableUIElement"
|
||||||
|
import Svg from "../../Svg"
|
||||||
|
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||||
|
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an icon depending on the state of the geolocation.
|
||||||
|
* Will set the 'lock' if clicked twice
|
||||||
|
*/
|
||||||
|
export class GeolocationControl extends VariableUiElement {
|
||||||
|
constructor(geolocationHandler: GeoLocationHandler) {
|
||||||
|
const lastClick = new UIEventSource<Date>(undefined)
|
||||||
|
const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
|
||||||
|
if (lastClick === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
|
||||||
|
return timeDiff <= 3
|
||||||
|
})
|
||||||
|
const geolocationState = geolocationHandler.geolocationState
|
||||||
|
super(
|
||||||
|
geolocationState.permission.map(
|
||||||
|
(permission) => {
|
||||||
|
if (permission === "denied") {
|
||||||
|
return Svg.location_refused_svg()
|
||||||
|
}
|
||||||
|
if (geolocationState.isLocked.data) {
|
||||||
|
return Svg.location_locked_svg()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === "prompt") {
|
||||||
|
return Svg.location_empty_svg()
|
||||||
|
}
|
||||||
|
if (geolocationState.currentGPSLocation === undefined) {
|
||||||
|
// Position not yet found, but permission is either requested or granted: we spin to indicate activity
|
||||||
|
const icon = !geolocationHandler.mapHasMoved.data
|
||||||
|
? Svg.location_svg()
|
||||||
|
: Svg.location_empty_svg()
|
||||||
|
return icon
|
||||||
|
.SetClass("cursor-wait")
|
||||||
|
.SetStyle("animation: spin 4s linear infinite;")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastClickWithinThreeSecs.data &&
|
||||||
|
geolocationState.permission.data === "granted"
|
||||||
|
) {
|
||||||
|
return Svg.location_unlocked_svg()
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a location, so we show a dot in the center
|
||||||
|
return Svg.location_svg()
|
||||||
|
},
|
||||||
|
[
|
||||||
|
geolocationState.currentGPSLocation,
|
||||||
|
geolocationState.isLocked,
|
||||||
|
geolocationHandler.mapHasMoved,
|
||||||
|
lastClickWithinThreeSecs,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
this.onClick(async () => {
|
||||||
|
if (geolocationState.permission.data !== "granted") {
|
||||||
|
await geolocationState.requestPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geolocationState.isLocked.data === true) {
|
||||||
|
// Unlock
|
||||||
|
geolocationState.isLocked.setData(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geolocationState.currentGPSLocation.data === undefined) {
|
||||||
|
// No location is known yet, not much we can do
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A location _is_ known! Let's zoom to this location
|
||||||
|
geolocationHandler.MoveMapToCurrentLocation()
|
||||||
|
|
||||||
|
if (lastClickWithinThreeSecs.data && geolocationState.permission.data === "granted") {
|
||||||
|
geolocationState.isLocked.setData(true)
|
||||||
|
lastClick.setData(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastClick.setData(new Date())
|
||||||
|
})
|
||||||
|
|
||||||
|
lastClick.addCallbackAndRunD((_) => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (lastClickWithinThreeSecs.data) {
|
||||||
|
lastClick.ping()
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,18 +5,16 @@ import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler"
|
||||||
import Svg from "../../Svg"
|
import Svg from "../../Svg"
|
||||||
import MapState from "../../Logic/State/MapState"
|
import MapState from "../../Logic/State/MapState"
|
||||||
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
|
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
|
||||||
import { Utils } from "../../Utils"
|
|
||||||
import { TagUtils } from "../../Logic/Tags/TagUtils"
|
|
||||||
import { BBox } from "../../Logic/BBox"
|
|
||||||
import { OsmFeature } from "../../Models/OsmFeature"
|
|
||||||
import LevelSelector from "./LevelSelector"
|
import LevelSelector from "./LevelSelector"
|
||||||
|
import { GeolocationControl } from "./GeolocationControl"
|
||||||
|
|
||||||
export default class RightControls extends Combine {
|
export default class RightControls extends Combine {
|
||||||
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
|
constructor(
|
||||||
const geolocatioHandler = new GeoLocationHandler(state)
|
state: MapState & { featurePipeline: FeaturePipeline },
|
||||||
|
geolocationHandler: GeoLocationHandler
|
||||||
|
) {
|
||||||
const geolocationButton = new Toggle(
|
const geolocationButton = new Toggle(
|
||||||
new MapControlButton(geolocatioHandler, {
|
new MapControlButton(new GeolocationControl(geolocationHandler), {
|
||||||
dontStyle: true,
|
dontStyle: true,
|
||||||
}).SetClass("p-1"),
|
}).SetClass("p-1"),
|
||||||
undefined,
|
undefined,
|
||||||
|
|
|
@ -29,6 +29,8 @@ import Img from "./Base/Img"
|
||||||
import UserInformationPanel from "./BigComponents/UserInformation"
|
import UserInformationPanel from "./BigComponents/UserInformation"
|
||||||
import { LoginToggle } from "./Popup/LoginButton"
|
import { LoginToggle } from "./Popup/LoginButton"
|
||||||
import { FixedUiElement } from "./Base/FixedUiElement"
|
import { FixedUiElement } from "./Base/FixedUiElement"
|
||||||
|
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
|
||||||
|
import { GeoLocationState } from "../Logic/State/GeoLocationState"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default MapComplete GUI initializer
|
* The default MapComplete GUI initializer
|
||||||
|
@ -38,10 +40,14 @@ import { FixedUiElement } from "./Base/FixedUiElement"
|
||||||
export default class DefaultGUI {
|
export default class DefaultGUI {
|
||||||
private readonly guiState: DefaultGuiState
|
private readonly guiState: DefaultGuiState
|
||||||
private readonly state: FeaturePipelineState
|
private readonly state: FeaturePipelineState
|
||||||
|
private readonly geolocationHandler: GeoLocationHandler | undefined
|
||||||
|
|
||||||
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
|
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
|
||||||
this.state = state
|
this.state = state
|
||||||
this.guiState = guiState
|
this.guiState = guiState
|
||||||
|
if (this.state.featureSwitchGeolocation.data) {
|
||||||
|
this.geolocationHandler = new GeoLocationHandler(new GeoLocationState(), state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setup() {
|
public setup() {
|
||||||
|
@ -231,15 +237,16 @@ export default class DefaultGUI {
|
||||||
.SetClass("flex items-center justify-center normal-background h-full")
|
.SetClass("flex items-center justify-center normal-background h-full")
|
||||||
.AttachTo("on-small-screen")
|
.AttachTo("on-small-screen")
|
||||||
|
|
||||||
new Combine([Toggle.If(state.featureSwitchSearch, () =>
|
new Combine([
|
||||||
new SearchAndGo(state).SetClass(
|
Toggle.If(state.featureSwitchSearch, () =>
|
||||||
"shadow rounded-full h-min w-full overflow-hidden sm:max-w-sm pointer-events-auto"
|
new SearchAndGo(state).SetClass(
|
||||||
)
|
"shadow rounded-full h-min w-full overflow-hidden sm:max-w-sm pointer-events-auto"
|
||||||
)])
|
)
|
||||||
.AttachTo("top-right")
|
),
|
||||||
|
]).AttachTo("top-right")
|
||||||
|
|
||||||
new LeftControls(state, guiState).AttachTo("bottom-left")
|
new LeftControls(state, guiState).AttachTo("bottom-left")
|
||||||
new RightControls(state).AttachTo("bottom-right")
|
new RightControls(state, this.geolocationHandler).AttachTo("bottom-right")
|
||||||
|
|
||||||
new CenterMessageBox(state).AttachTo("centermessage")
|
new CenterMessageBox(state).AttachTo("centermessage")
|
||||||
document.getElementById("centermessage").classList.add("pointer-events-none")
|
document.getElementById("centermessage").classList.add("pointer-events-none")
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Utils } from "../../Utils"
|
import { Utils } from "../../Utils"
|
||||||
import { Feature } from "geojson"
|
import { Feature } from "geojson"
|
||||||
import { Point } from "@turf/turf"
|
import { Point } from "@turf/turf"
|
||||||
import { GeoLocationPointProperties } from "../../Logic/Actors/GeoLocationHandler"
|
import { GeoLocationPointProperties } from "../../Logic/State/GeoLocationState"
|
||||||
import UploadTraceToOsmUI from "../BigComponents/UploadTraceToOsmUI"
|
import UploadTraceToOsmUI from "../BigComponents/UploadTraceToOsmUI"
|
||||||
import { SpecialVisualization } from "../SpecialVisualization"
|
import { SpecialVisualization } from "../SpecialVisualization"
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue