refactoring(maplibre): WIP

This commit is contained in:
Pieter Vander Vennet 2023-03-24 19:21:15 +01:00
parent 231d67361e
commit 4d48b1cf2b
89 changed files with 1166 additions and 3973 deletions

View file

@ -3,9 +3,13 @@ import { BBox } from "../BBox"
import Constants from "../../Models/Constants"
import { GeoLocationPointProperties, GeoLocationState } from "../State/GeoLocationState"
import { UIEventSource } from "../UIEventSource"
import Loc from "../../Models/Loc"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
import { Feature, LineString, Point } from "geojson"
import FeatureSource 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.
@ -14,28 +18,39 @@ import SimpleFeatureSource from "../FeatureSource/Sources/SimpleFeatureSource"
*/
export default class GeoLocationHandler {
public readonly geolocationState: GeoLocationState
private readonly _state: {
currentUserLocation: SimpleFeatureSource
layoutToUse: LayoutConfig
locationControl: UIEventSource<Loc>
selectedElement: UIEventSource<any>
leafletMap?: UIEventSource<any>
}
/**
* 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: FeatureSource
/**
* 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 historicalUserLocationsTrack: FeatureSource
public readonly mapHasMoved: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly selectedElement: UIEventSource<any>
private readonly mapProperties?: MapProperties
private readonly gpsLocationHistoryRetentionTime?: UIEventSource<number>
constructor(
geolocationState: GeoLocationState,
state: {
locationControl: UIEventSource<Loc>
currentUserLocation: SimpleFeatureSource
layoutToUse: LayoutConfig
selectedElement: UIEventSource<any>
leafletMap?: UIEventSource<any>
}
selectedElement: UIEventSource<any>,
mapProperties?: MapProperties,
gpsLocationHistoryRetentionTime?: UIEventSource<number>
) {
this.geolocationState = geolocationState
this._state = state
const mapLocation = state.locationControl
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()
@ -54,7 +69,7 @@ export default class GeoLocationHandler {
this.mapHasMoved.setData(true)
}
this.geolocationState.currentGPSLocation.addCallbackAndRunD((newLocation) => {
this.geolocationState.currentGPSLocation.addCallbackAndRunD((_) => {
const timeSinceLastRequest =
(new Date().getTime() - geolocationState.requestMoment.data?.getTime() ?? 0) / 1000
if (!this.mapHasMoved.data) {
@ -65,25 +80,17 @@ export default class GeoLocationHandler {
self.MoveMapToCurrentLocation()
}
if (this.geolocationState.isLocked.data) {
if (!this.geolocationState.allowMoving.data) {
// Jup, the map is locked to the bound location: move automatically
self.MoveMapToCurrentLocation()
return
}
})
geolocationState.isLocked.map(
(isLocked) => {
if (isLocked) {
state.leafletMap?.data?.dragging?.disable()
} else {
state.leafletMap?.data?.dragging?.enable()
}
},
[state.leafletMap]
)
geolocationState.allowMoving.syncWith(mapProperties.allowMoving, true)
this.CopyGeolocationIntoMapstate()
this.initUserLocationTrail()
}
/**
@ -95,12 +102,11 @@ export default class GeoLocationHandler {
*/
public MoveMapToCurrentLocation() {
const newLocation = this.geolocationState.currentGPSLocation.data
const mapLocation = this._state.locationControl
const state = this._state
const mapLocation = this.mapProperties.location
// We got a new location.
// Do we move the map to it?
if (state.selectedElement.data !== undefined) {
if (this.selectedElement.data !== undefined) {
// Nope, there is something selected, so we don't move to the current GPS-location
return
}
@ -110,8 +116,8 @@ export default class GeoLocationHandler {
}
// We check that the GPS location is not out of bounds
const bounds = state.layoutToUse.lockLocation
if (bounds && bounds !== true) {
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) {
@ -119,22 +125,25 @@ export default class GeoLocationHandler {
}
}
console.trace("Moving the map to the GPS-location")
mapLocation.setData({
zoom: Math.max(mapLocation.data.zoom, 16),
lon: newLocation.longitude,
lat: newLocation.latitude,
})
const zoom = this.mapProperties.zoom
zoom.setData(Math.max(zoom.data, 16))
this.mapHasMoved.setData(true)
this.geolocationState.requestMoment.setData(undefined)
}
private CopyGeolocationIntoMapstate() {
const state = this._state
const features: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([])
this.currentUserLocation = new StaticFeatureSource(features)
this.geolocationState.currentGPSLocation.addCallbackAndRun((location) => {
if (location === undefined) {
return
}
const feature = {
const feature = <Feature>{
type: "Feature",
properties: <GeoLocationPointProperties>{
id: "gps",
@ -148,7 +157,82 @@ export default class GeoLocationHandler {
},
}
state.currentUserLocation?.features?.setData([{ feature, freshness: new Date() }])
features.setData([feature])
})
}
private initUserLocationTrail() {
const features = LocalStorageSource.GetParsed<Feature[]>("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<Point>]) => {
if (location === undefined) {
return
}
const previousLocation = <Feature<Point>>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<LineString, OsmTags> = {
type: "Feature",
properties: {
id: "location_track",
"_date:now": new Date().toISOString(),
},
geometry: {
type: "LineString",
coordinates: allPoints.map(
(ff: Feature<Point>) => <[number, number]>ff.geometry.coordinates
),
},
}
return [feature]
})
this.historicalUserLocationsTrack = new StaticFeatureSource(asLine)
}
}

View file

@ -0,0 +1,64 @@
import { UIEventSource } from "../UIEventSource"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { QueryParameters } from "../Web/QueryParameters"
/**
* This actor is responsible to set the map location.
* It will attempt to
* - Set the map to the position as passed in by the query parameters (if available)
* - Set the map to the position remembered in LocalStorage (if available)
* - Set the map to the layout default
*
* Additionally, it will save the map location to local storage
*/
export default class InitialMapPositioning {
public zoom: UIEventSource<number>
public location: UIEventSource<{ lon: number; lat: number }>
constructor(layoutToUse: LayoutConfig) {
function localStorageSynced(
key: string,
deflt: number,
docs: string
): UIEventSource<number> {
const localStorage = LocalStorageSource.Get(key)
const previousValue = localStorage.data
const src = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(key, "" + deflt, docs).syncWith(localStorage)
)
if (src.data === deflt) {
const prev = Number(previousValue)
if (!isNaN(prev)) {
src.setData(prev)
}
}
return src
}
// -- Location control initialization
this.zoom = localStorageSynced(
"z",
layoutToUse?.startZoom ?? 1,
"The initial/current zoom level"
)
const lat = localStorageSynced(
"lat",
layoutToUse?.startLat ?? 0,
"The initial/current latitude"
)
const lon = localStorageSynced(
"lon",
layoutToUse?.startLon ?? 0,
"The initial/current longitude of the app"
)
this.location = new UIEventSource({ lon: lon.data, lat: lat.data })
this.location.addCallbackD((loc) => {
lat.setData(loc.lat)
lon.setData(loc.lon)
})
// Note: this syncs only in one direction
}
}

View file

@ -179,13 +179,6 @@ export class BBox {
])
}
toLeaflet(): [[number, number], [number, number]] {
return [
[this.minLat, this.minLon],
[this.maxLat, this.maxLon],
]
}
toLngLat(): [[number, number], [number, number]] {
return [
[this.minLon, this.minLat],
@ -193,7 +186,6 @@ export class BBox {
]
}
public asGeoJson<T>(properties: T): Feature<Polygon, T> {
return {
type: "Feature",

View file

@ -5,7 +5,7 @@ import BaseUIElement from "../UI/BaseUIElement"
import List from "../UI/Base/List"
import Title from "../UI/Base/Title"
import { BBox } from "./BBox"
import { Feature, Geometry, MultiPolygon, Polygon } from "@turf/turf"
import { Feature, Geometry, MultiPolygon, Polygon } from "geojson"
export interface ExtraFuncParams {
/**
@ -68,7 +68,7 @@ class EnclosingFunc implements ExtraFunction {
}
if (
GeoOperations.completelyWithin(
feat,
<Feature>feat,
<Feature<Polygon | MultiPolygon, any>>otherFeature
)
) {

View file

@ -1,168 +0,0 @@
import { Store } from "../../UIEventSource"
import { GeoOperations } from "../../GeoOperations"
import FeatureSource from "../FeatureSource"
import PointRenderingConfig from "../../../Models/ThemeConfig/PointRenderingConfig"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import LineRenderingConfig from "../../../Models/ThemeConfig/LineRenderingConfig"
/**
* This feature source helps the ShowDataLayer class: it introduces the necessary extra features and indicates with what renderConfig it should be rendered.
*/
export default class RenderingMultiPlexerFeatureSource {
public readonly features: Store<
(any & {
pointRenderingIndex: number | undefined
lineRenderingIndex: number | undefined
})[]
>
private readonly pointRenderings: { rendering: PointRenderingConfig; index: number }[]
private readonly centroidRenderings: { rendering: PointRenderingConfig; index: number }[]
private readonly projectedCentroidRenderings: {
rendering: PointRenderingConfig
index: number
}[]
private readonly startRenderings: { rendering: PointRenderingConfig; index: number }[]
private readonly endRenderings: { rendering: PointRenderingConfig; index: number }[]
private readonly hasCentroid: boolean
private lineRenderObjects: LineRenderingConfig[]
constructor(upstream: FeatureSource, layer: LayerConfig) {
const pointRenderObjects: { rendering: PointRenderingConfig; index: number }[] =
layer.mapRendering.map((r, i) => ({
rendering: r,
index: i,
}))
this.pointRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("point"))
this.centroidRenderings = pointRenderObjects.filter((r) =>
r.rendering.location.has("centroid")
)
this.projectedCentroidRenderings = pointRenderObjects.filter((r) =>
r.rendering.location.has("projected_centerpoint")
)
this.startRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("start"))
this.endRenderings = pointRenderObjects.filter((r) => r.rendering.location.has("end"))
this.hasCentroid =
this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0
this.lineRenderObjects = layer.lineRendering
this.features = upstream.features.map((features) => {
if (features === undefined) {
return undefined
}
const withIndex: any[] = []
function addAsPoint(feat, rendering, coordinate) {
const patched = {
...feat,
pointRenderingIndex: rendering.index,
}
patched.geometry = {
type: "Point",
coordinates: coordinate,
}
withIndex.push(patched)
}
for (const feat of features) {
if (feat === undefined) {
continue
}
this.inspectFeature(feat, addAsPoint, withIndex)
}
return withIndex
})
}
/**
* For every source feature, adds the necessary rendering-features
*/
private inspectFeature(
feat,
addAsPoint: (feat, rendering, centerpoint: [number, number]) => void,
withIndex: any[]
) {
if (feat.geometry.type === "Point") {
for (const rendering of this.pointRenderings) {
withIndex.push({
...feat,
pointRenderingIndex: rendering.index,
})
}
} else if (feat.geometry.type === "MultiPolygon") {
if (this.centroidRenderings.length > 0 || this.projectedCentroidRenderings.length > 0) {
const centerpoints: [number, number][] = (<[number, number][][][]>(
feat.geometry.coordinates
)).map((rings) =>
GeoOperations.centerpointCoordinates({
type: "Feature",
properties: {},
geometry: { type: "Polygon", coordinates: rings },
})
)
for (const centroidRendering of this.centroidRenderings) {
for (const centerpoint of centerpoints) {
addAsPoint(feat, centroidRendering, centerpoint)
}
}
for (const centroidRendering of this.projectedCentroidRenderings) {
for (const centerpoint of centerpoints) {
addAsPoint(feat, centroidRendering, centerpoint)
}
}
}
// AT last, add it 'as is' to what we should render
for (let i = 0; i < this.lineRenderObjects.length; i++) {
withIndex.push({
...feat,
lineRenderingIndex: i,
})
}
} else {
// This is a a line or polygon: add the centroids
let centerpoint: [number, number] = undefined
let projectedCenterPoint: [number, number] = undefined
if (this.hasCentroid) {
centerpoint = GeoOperations.centerpointCoordinates(feat)
if (this.projectedCentroidRenderings.length > 0) {
projectedCenterPoint = <[number, number]>(
GeoOperations.nearestPoint(feat, centerpoint).geometry.coordinates
)
}
}
for (const rendering of this.centroidRenderings) {
addAsPoint(feat, rendering, centerpoint)
}
if (feat.geometry.type === "LineString") {
for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, projectedCenterPoint)
}
// Add start- and endpoints
const coordinates = feat.geometry.coordinates
for (const rendering of this.startRenderings) {
addAsPoint(feat, rendering, coordinates[0])
}
for (const rendering of this.endRenderings) {
const coordinate = coordinates[coordinates.length - 1]
addAsPoint(feat, rendering, coordinate)
}
} else {
for (const rendering of this.projectedCentroidRenderings) {
addAsPoint(feat, rendering, centerpoint)
}
}
// AT last, add it 'as is' to what we should render
for (let i = 0; i < this.lineRenderObjects.length; i++) {
withIndex.push({
...feat,
lineRenderingIndex: i,
})
}
}
}
}

View file

@ -1,15 +1,8 @@
import { BBox } from "./BBox"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import * as turf from "@turf/turf"
import {
AllGeoJSON,
booleanWithin,
Coord,
Feature,
Geometry,
MultiPolygon,
Polygon,
} from "@turf/turf"
import { AllGeoJSON, booleanWithin, Coord } from "@turf/turf"
import { Feature, Geometry, MultiPolygon, Polygon } from "geojson"
import { GeoJSON, LineString, Point, Position } from "geojson"
import togpx from "togpx"
import Constants from "../Models/Constants"
@ -263,7 +256,10 @@ export class GeoOperations {
* @param way The road on which you want to find a point
* @param point Point defined as [lon, lat]
*/
public static nearestPoint(way: Feature<LineString | Polygon>, point: [number, number]) {
public static nearestPoint(
way: Feature<LineString | Polygon>,
point: [number, number]
): Feature<Point> {
if (way.geometry.type === "Polygon") {
way = { ...way }
way.geometry = { ...way.geometry }
@ -710,6 +706,63 @@ export class GeoOperations {
return true
}
/**
*
*
* const f = (type, feature: Feature) => GeoOperations.featureToCoordinateWithRenderingType(feature, type)
* const g = geometry => (<Feature> {type: "Feature", properties: {}, geometry})
* f("point", g({type:"Point", coordinates:[1,2]})) // => [1,2]
* f("centroid", g({type:"Point", coordinates:[1,2]})) // => undefined
* f("start", g({type:"Point", coordinates:[1,2]})) // => undefined
* f("centroid", g({type:"LineString", coordinates:[[1,2], [3,4]]})) // => [2,3]
* f("centroid", g({type:"Polygon", coordinates:[[[1,2], [3,4], [1,2]]]})) // => [2,3]
* f("projected_centerpoint", g({type:"LineString", coordinates:[[1,2], [3,4]]})) // => [1.9993137596003214,2.999313759600321]
* f("start", g({type:"LineString", coordinates:[[1,2], [3,4]]})) // => [1,2]
* f("end", g({type:"LineString", coordinates:[[1,2], [3,4]]})) // => [3,4]
*
*/
public static featureToCoordinateWithRenderingType(
feature: Feature,
location: "point" | "centroid" | "start" | "end" | "projected_centerpoint" | string
): [number, number] | undefined {
switch (location) {
case "point":
if (feature.geometry.type === "Point") {
return <[number, number]>feature.geometry.coordinates
}
return undefined
case "centroid":
if (feature.geometry.type === "Point") {
return undefined
}
return GeoOperations.centerpointCoordinates(feature)
case "projected_centerpoint":
if (
feature.geometry.type === "LineString" ||
feature.geometry.type === "MultiLineString"
) {
const centerpoint = GeoOperations.centerpointCoordinates(feature)
const projected = GeoOperations.nearestPoint(
<Feature<LineString>>feature,
centerpoint
)
return <[number, number]>projected.geometry.coordinates
}
return undefined
case "start":
if (feature.geometry.type === "LineString") {
return <[number, number]>feature.geometry.coordinates[0]
}
return undefined
case "end":
if (feature.geometry.type === "LineString") {
return <[number, number]>feature.geometry.coordinates.at(-1)
}
return undefined
default:
throw "Unkown location type: " + location
}
}
private static pointWithinRing(x: number, y: number, ring: [number, number][]) {
let inside = false
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {

View file

@ -29,8 +29,9 @@ export default class Maproulette {
/**
* The API key to use for all requests
*/
private apiKey: string
private readonly apiKey: string
public static singleton = new Maproulette()
/**
* Creates a new Maproulette instance
* @param endpoint The API endpoint to use

View file

@ -59,15 +59,6 @@ export default class CreateMultiPolygonWithPointReuseAction extends OsmCreateAct
}
}
public async getPreview(): Promise<FeatureSource> {
const outerPreview = await this.createOuterWay.getPreview()
outerPreview.features.data.push({
freshness: new Date(),
feature: this.geojsonPreview,
})
return outerPreview
}
protected async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
console.log("Running CMPWPRA")
const descriptions: ChangeDescription[] = []

View file

@ -22,24 +22,25 @@ export class Changes {
/**
* All the newly created features as featureSource + all the modified features
*/
public features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
public readonly features = new UIEventSource<{ feature: any; freshness: Date }[]>([])
public readonly pendingChanges: UIEventSource<ChangeDescription[]> =
LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
public readonly state: { allElements: ElementStorage; osmConnection: OsmConnection }
public readonly extraComment: UIEventSource<string> = new UIEventSource(undefined)
private historicalUserLocations: FeatureSource
private readonly historicalUserLocations: FeatureSource
private _nextId: number = -1 // Newly assigned ID's are negative
private readonly isUploading = new UIEventSource(false)
private readonly previouslyCreated: OsmObject[] = []
private readonly _leftRightSensitive: boolean
private _changesetHandler: ChangesetHandler
private readonly _changesetHandler: ChangesetHandler
constructor(
state?: {
allElements: ElementStorage
osmConnection: OsmConnection
historicalUserLocations: FeatureSource
},
leftRightSensitive: boolean = false
) {
@ -53,6 +54,7 @@ export class Changes {
state.allElements,
this
)
this.historicalUserLocations = state.historicalUserLocations
// Note: a changeset might be reused which was opened just before and might have already used some ids
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
@ -164,7 +166,6 @@ export class Changes {
const now = new Date()
const recentLocationPoints = locations
.map((ff) => ff.feature)
.filter((feat) => feat.geometry.type === "Point")
.filter((feat) => {
const visitTime = new Date(
@ -582,8 +583,4 @@ export class Changes {
)
return result
}
public setHistoricalUserLocations(locations: FeatureSource) {
this.historicalUserLocations = locations
}
}

View file

@ -1,4 +1,3 @@
import State from "../../State"
import { Utils } from "../../Utils"
import { BBox } from "../BBox"
@ -14,8 +13,8 @@ export interface GeoCodeResult {
export class Geocoding {
private static readonly host = "https://nominatim.openstreetmap.org/search?"
static async Search(query: string): Promise<GeoCodeResult[]> {
const b = State?.state?.currentBounds?.data ?? BBox.global
static async Search(query: string, bbox: BBox): Promise<GeoCodeResult[]> {
const b = bbox ?? BBox.global
const url =
Geocoding.host +
"format=json&limit=1&viewbox=" +

View file

@ -1,91 +0,0 @@
import FeatureSwitchState from "./FeatureSwitchState"
import { ElementStorage } from "../ElementStorage"
import { Changes } from "../Osm/Changes"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { UIEventSource } from "../UIEventSource"
import Loc from "../../Models/Loc"
import { BBox } from "../BBox"
import { QueryParameters } from "../Web/QueryParameters"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { Utils } from "../../Utils"
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Actors/PendingChangesUploader"
/**
* The part of the state keeping track of where the elements, loading them, configuring the feature pipeline etc
*/
export default class ElementsState extends FeatureSwitchState {
/**
The mapping from id -> UIEventSource<properties>
*/
public allElements: ElementStorage = new ElementStorage()
/**
The latest element that was selected
*/
public readonly selectedElement = new UIEventSource<any>(undefined, "Selected element")
/**
* The map location: currently centered lat, lon and zoom
*/
public readonly locationControl = new UIEventSource<Loc>(undefined, "locationControl")
/**
* The current visible extent of the screen
*/
public readonly currentBounds = new UIEventSource<BBox>(undefined)
constructor(layoutToUse: LayoutConfig) {
super(layoutToUse)
function localStorageSynced(
key: string,
deflt: number,
docs: string
): UIEventSource<number> {
const localStorage = LocalStorageSource.Get(key)
const previousValue = localStorage.data
const src = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(key, "" + deflt, docs).syncWith(localStorage)
)
if (src.data === deflt) {
const prev = Number(previousValue)
if (!isNaN(prev)) {
src.setData(prev)
}
}
return src
}
// -- Location control initialization
const zoom = localStorageSynced(
"z",
layoutToUse?.startZoom ?? 1,
"The initial/current zoom level"
)
const lat = localStorageSynced(
"lat",
layoutToUse?.startLat ?? 0,
"The initial/current latitude"
)
const lon = localStorageSynced(
"lon",
layoutToUse?.startLon ?? 0,
"The initial/current longitude of the app"
)
this.locationControl.setData({
zoom: Utils.asFloat(zoom.data),
lat: Utils.asFloat(lat.data),
lon: Utils.asFloat(lon.data),
})
this.locationControl.addCallback((latlonz) => {
// Sync the location controls
zoom.setData(latlonz.zoom)
lat.setData(latlonz.lat)
lon.setData(latlonz.lon)
})
}
}

View file

@ -1,9 +1,7 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import FeaturePipeline from "../FeatureSource/FeaturePipeline"
import { Tiles } from "../../Models/TileRange"
import ShowDataLayer from "../../UI/ShowDataLayer/ShowDataLayer"
import { TileHierarchyAggregator } from "../../UI/ShowDataLayer/TileHierarchyAggregator"
import ShowTileInfo from "../../UI/ShowDataLayer/ShowTileInfo"
import { UIEventSource } from "../UIEventSource"
import MapState from "./MapState"
import SelectedFeatureHandler from "../Actors/SelectedFeatureHandler"
@ -14,6 +12,7 @@ import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
import MetaTagRecalculator from "../FeatureSource/Actors/MetaTagRecalculator"
import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
export default class FeaturePipelineState extends MapState {
/**
@ -116,14 +115,12 @@ export default class FeaturePipelineState extends MapState {
[self.currentBounds, source.layer.isDisplayed, sourceBBox]
)
new ShowDataLayer({
new ShowDataLayer(self.maplibreMap, {
features: source,
leafletMap: self.leafletMap,
layerToShow: source.layer.layerDef,
layer: source.layer.layerDef,
doShowLayer: doShowFeatures,
selectedElement: self.selectedElement,
state: self,
popup: (tags, layer) => self.CreatePopup(tags, layer),
buildPopup: (tags, layer) => self.CreatePopup(tags, layer),
})
}
@ -136,8 +133,6 @@ export default class FeaturePipelineState extends MapState {
sourcesToRegister.forEach((source) => self.metatagRecalculator.registerSource(source))
new SelectedFeatureHandler(Hash.hash, this)
this.AddClusteringToMap(this.leafletMap)
}
public CreatePopup(tags: UIEventSource<any>, layer: LayerConfig): ScrollableFullScreen {
@ -148,27 +143,4 @@ export default class FeaturePipelineState extends MapState {
this.popups.set(tags.data.id, popup)
return popup
}
/**
* Adds the cluster-tiles to the given map
* @param leafletMap: a UIEventSource possible having a leaflet map
* @constructor
*/
public AddClusteringToMap(leafletMap: UIEventSource<any>) {
const clustering = this.layoutToUse.clustering
const self = this
new ShowDataLayer({
features: this.featureAggregator.getCountsForZoom(
clustering,
this.locationControl,
clustering.minNeededElements
),
leafletMap: leafletMap,
layerToShow: ShowTileInfo.styling,
popup: this.featureSwitchIsDebugging.data
? (tags, layer) => new FeatureInfoBox(tags, layer, self)
: undefined,
state: this,
})
}
}

View file

@ -30,7 +30,7 @@ export class GeoLocationState {
/**
* If true: the map will center (and re-center) to this location
*/
public readonly isLocked: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly allowMoving: UIEventSource<boolean> = new UIEventSource<boolean>(true)
public readonly currentGPSLocation: UIEventSource<GeolocationCoordinates | undefined> =
new UIEventSource<GeolocationCoordinates | undefined>(undefined)
@ -72,7 +72,6 @@ export class GeoLocationState {
self._previousLocationGrant.setData("false")
}
})
console.log("Previous location grant:", this._previousLocationGrant.data)
if (this._previousLocationGrant.data === "true") {
// A previous visit successfully granted permission. Chance is high that we are allowed to use it again!
@ -87,7 +86,6 @@ export class GeoLocationState {
}
this.requestPermission()
}
window["geolocation_state"] = this
}
/**

View file

@ -1,51 +1,31 @@
import UserRelatedState from "./UserRelatedState"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Store, UIEventSource } from "../UIEventSource"
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 StaticFeatureSource, {
TiledStaticFeatureSource,
} from "../FeatureSource/Sources/StaticFeatureSource"
import { OsmConnection } from "../Osm/OsmConnection"
import { Feature, LineString } from "geojson"
import { OsmTags } from "../../Models/OsmFeature"
export interface GlobalFilter {
filter: FilterState
id: string
onNewPoint: {
safetyCheck: Translation
confirmAddNew: TypedTranslation<{ preset: Translation }>
tags: Tag[]
}
}
import { Feature } from "geojson"
import { Map as MlMap } from "maplibre-gl"
import { GlobalFilter } from "../../Models/GlobalFilter"
import { MapProperties } from "../../Models/MapProperties"
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
/**
* Contains all the leaflet-map related state
*/
export default class MapState extends UserRelatedState {
/**
The leaflet instance of the big basemap
*/
public leafletMap = new UIEventSource<any /*L.Map*/>(undefined, "leafletmap")
export default class MapState {
/**
* The current background layer
*/
public backgroundLayer: UIEventSource<BaseLayer>
/**
* Last location where a click was registered
*/
@ -58,34 +38,6 @@ export default class MapState extends UserRelatedState {
* 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, with their metadata
*/
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"
)
/**
* 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 historicalUserLocationsTrack: FeatureSourceForLayer & Tiled
/**
* A feature source containing the current home location of the user
*/
public homeLocation: FeatureSourceForLayer & Tiled
/**
* A builtin layer which contains the selected element.
@ -94,7 +46,7 @@ export default class MapState extends UserRelatedState {
*/
public selectedElementsLayer: FeatureSourceForLayer & Tiled
public readonly mainMapObject: BaseUIElement & MinimapObj
public readonly mainMapObject: BaseUIElement
/**
* Which layers are enabled in the current theme and what filters are applied onto them
@ -114,9 +66,7 @@ export default class MapState extends UserRelatedState {
*/
public overlayToggles: { config: TilesourceConfig; isDisplayed: UIEventSource<boolean> }[]
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
super(layoutToUse, options)
constructor() {
this.availableBackgroundLayers = AvailableBaseLayers.AvailableLayersAt(this.locationControl)
let defaultLayer = AvailableBaseLayers.osmCarto
@ -130,13 +80,6 @@ export default class MapState extends UserRelatedState {
this.backgroundLayer = new UIEventSource<BaseLayer>(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,
@ -162,12 +105,8 @@ export default class MapState extends UserRelatedState {
MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection)
)
this.lockBounds()
this.AddAllOverlaysToMap(this.leafletMap)
this.initHomeLocation()
this.initGpsLocation()
this.initUserLocationTrail()
this.initCurrentView()
this.initSelectedElement()
@ -189,17 +128,6 @@ export default class MapState extends UserRelatedState {
}
}
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(
@ -244,17 +172,6 @@ export default class MapState extends UserRelatedState {
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
const 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 initSelectedElement() {
const layerDef: FilteredLayer = this.filteredLayers.data.filter(
(l) => l.layerDef.id === "selected_element"
@ -281,145 +198,6 @@ export default class MapState extends UserRelatedState {
this.selectedElementsLayer = new TiledStaticFeatureSource(store, layerDef)
}
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: Feature<LineString, OsmTags> = {
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,

View file

@ -1,21 +1,17 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../Osm/OsmConnection"
import { MangroveIdentity } from "../Web/MangroveReviews"
import { Store, UIEventSource } from "../UIEventSource"
import { QueryParameters } from "../Web/QueryParameters"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import Locale from "../../UI/i18n/Locale"
import ElementsState from "./ElementsState"
import SelectedElementTagsUpdater from "../Actors/SelectedElementTagsUpdater"
import { Changes } from "../Osm/Changes"
import ChangeToElementsActor from "../Actors/ChangeToElementsActor"
import PendingChangesUploader from "../Actors/PendingChangesUploader"
import Maproulette from "../Maproulette"
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
import FeatureSource from "../FeatureSource/FeatureSource"
/**
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
* which layers they enabled, ...
*/
export default class UserRelatedState extends ElementsState {
export default class UserRelatedState {
/**
The user credentials
*/
@ -29,29 +25,22 @@ export default class UserRelatedState extends ElementsState {
*/
public mangroveIdentity: MangroveIdentity
/**
* Maproulette connection
*/
public maprouletteConnection: Maproulette
public readonly installedUserThemes: Store<string[]>
public readonly showAllQuestionsAtOnce: UIEventSource<boolean>
public readonly homeLocation: FeatureSource
constructor(layoutToUse: LayoutConfig, options?: { attemptLogin: true | boolean }) {
super(layoutToUse)
/**
* 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"
)
this.osmConnection = new OsmConnection({
dryRun: this.featureSwitchIsTesting,
fakeUser: this.featureSwitchFakeUser.data,
oauth_token: QueryParameters.GetQueryParameter(
"oauth_token",
undefined,
"Used to complete the login"
),
osmConfiguration: <"osm" | "osm-test">this.featureSwitchApiURL.data,
attemptLogin: options?.attemptLogin,
})
constructor(osmConnection: OsmConnection, availableLanguages?: string[]) {
this.osmConnection = osmConnection
{
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
this.osmConnection.GetPreference("translation-mode")
@ -72,49 +61,22 @@ export default class UserRelatedState extends ElementsState {
})
}
this.changes = new Changes(this, layoutToUse?.isLeftRightSensitive() ?? false)
this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
this.osmConnection.GetPreference("show-all-questions", "false", {
documentation:
"Either 'true' or 'false'. If set, all questions will be shown all at once",
})
)
new ChangeToElementsActor(this.changes, this.allElements)
new PendingChangesUploader(this.changes, this.selectedElement)
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.GetLongPreference("identity", "mangrove")
)
this.maprouletteConnection = new Maproulette()
this.InitializeLanguage(availableLanguages)
if (layoutToUse?.hideFromOverview) {
this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
if (loggedIn) {
this.osmConnection
.GetPreference("hidden-theme-" + layoutToUse?.id + "-enabled")
.setData("true")
return true
}
})
}
if (this.layoutToUse !== undefined && !this.layoutToUse.official) {
console.log("Marking unofficial theme as visited")
this.osmConnection.GetLongPreference("unofficial-theme-" + this.layoutToUse.id).setData(
JSON.stringify({
id: this.layoutToUse.id,
icon: this.layoutToUse.icon,
title: this.layoutToUse.title.translations,
shortDescription: this.layoutToUse.shortDescription.translations,
definition: this.layoutToUse["definition"],
})
)
}
this.InitializeLanguage()
new SelectedElementTagsUpdater(this)
this.installedUserThemes = this.InitInstalledUserThemes()
this.homeLocation = this.initHomeLocation()
}
public GetUnofficialTheme(id: string):
@ -159,26 +121,50 @@ export default class UserRelatedState extends ElementsState {
}
}
private InitializeLanguage() {
const layoutToUse = this.layoutToUse
public markLayoutAsVisited(layout: LayoutConfig) {
if (!layout) {
console.error("Trying to mark a layout as visited, but ", layout, " got passed")
return
}
if (layout.hideFromOverview) {
this.osmConnection.isLoggedIn.addCallbackAndRunD((loggedIn) => {
if (loggedIn) {
this.osmConnection
.GetPreference("hidden-theme-" + layout?.id + "-enabled")
.setData("true")
return true
}
})
}
if (!layout.official) {
this.osmConnection.GetLongPreference("unofficial-theme-" + layout.id).setData(
JSON.stringify({
id: layout.id,
icon: layout.icon,
title: layout.title.translations,
shortDescription: layout.shortDescription.translations,
definition: layout["definition"],
})
)
}
}
private InitializeLanguage(availableLanguages?: string[]) {
Locale.language.syncWith(this.osmConnection.GetPreference("language"))
Locale.language.addCallback((currentLanguage) => {
if (layoutToUse === undefined) {
return
}
if (Locale.showLinkToWeblate.data) {
return true // Disable auto switching as we are in translators mode
}
if (this.layoutToUse.language.indexOf(currentLanguage) < 0) {
if (availableLanguages?.indexOf(currentLanguage) < 0) {
console.log(
"Resetting language to",
layoutToUse.language[0],
availableLanguages[0],
"as",
currentLanguage,
" is unsupported"
)
// The current language is not supported -> switch to a supported one
Locale.language.setData(layoutToUse.language[0])
Locale.language.setData(availableLanguages[0])
}
})
Locale.language.ping()
@ -193,4 +179,43 @@ export default class UserRelatedState extends ElementsState {
.map((k) => k.substring(prefix.length, k.length - postfix.length))
)
}
private initHomeLocation(): FeatureSource {
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(),
},
]
})
return new StaticFeatureSource(feature)
}
}