Merge develop

This commit is contained in:
Pieter Vander Vennet 2024-12-31 16:31:55 +01:00
commit 00c233a2eb
188 changed files with 4982 additions and 1745 deletions

View file

@ -8,10 +8,13 @@ import { FeatureSource, WritableFeatureSource } from "../FeatureSource/FeatureSo
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { GeoOperations } from "../GeoOperations"
import { OsmTags } from "../../Models/OsmFeature"
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
import StaticFeatureSource, {
WritableStaticFeatureSource,
} from "../FeatureSource/Sources/StaticFeatureSource"
import { MapProperties } from "../../Models/MapProperties"
import { Orientation } from "../../Sensors/Orientation"
;("use strict")
/**
* 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
@ -43,13 +46,13 @@ export default class GeoLocationHandler {
public readonly mapHasMoved: UIEventSource<Date | undefined> = new UIEventSource<
Date | undefined
>(undefined)
private readonly selectedElement: UIEventSource<any>
private readonly selectedElement: UIEventSource<Feature>
private readonly mapProperties?: MapProperties
private readonly gpsLocationHistoryRetentionTime?: UIEventSource<number>
constructor(
geolocationState: GeoLocationState,
selectedElement: UIEventSource<any>,
selectedElement: UIEventSource<Feature>,
mapProperties?: MapProperties,
gpsLocationHistoryRetentionTime?: UIEventSource<number>
) {
@ -59,13 +62,12 @@ export default class GeoLocationHandler {
this.mapProperties = mapProperties
this.gpsLocationHistoryRetentionTime = gpsLocationHistoryRetentionTime
// Did an interaction move the map?
let self = this
let initTime = new Date()
mapLocation.addCallbackD((_) => {
const initTime = new Date()
mapLocation.addCallbackD(() => {
if (new Date().getTime() - initTime.getTime() < 250) {
return
}
self.mapHasMoved.setData(new Date())
this.mapHasMoved.setData(new Date())
return true // Unsubscribe
})
@ -76,12 +78,12 @@ export default class GeoLocationHandler {
this.mapHasMoved.setData(new Date())
}
this.geolocationState.currentGPSLocation.addCallbackAndRunD((_) => {
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()
this.MoveMapToCurrentLocation()
}
if (
timeSinceLastRequest < Constants.zoomToLocationTimeout &&
@ -90,12 +92,12 @@ export default class GeoLocationHandler {
geolocationState.requestMoment.data?.getTime())
) {
// still within request time and the map hasn't moved since requesting to jump to the current location
self.MoveMapToCurrentLocation()
this.MoveMapToCurrentLocation()
}
if (!this.geolocationState.allowMoving.data) {
// Jup, the map is locked to the bound location: move automatically
self.MoveMapToCurrentLocation(0)
this.MoveMapToCurrentLocation(0)
return
}
})
@ -183,7 +185,7 @@ export default class GeoLocationHandler {
}
private initUserLocationTrail() {
const features = LocalStorageSource.getParsed<Feature[]>("gps_location_history", [])
const features = LocalStorageSource.getParsed<Feature<Point>[]>("gps_location_history", [])
const now = new Date().getTime()
features.data = features.data.filter((ff) => {
if (ff.properties === undefined) {
@ -230,7 +232,7 @@ export default class GeoLocationHandler {
features.ping()
})
this.historicalUserLocations = <any>new StaticFeatureSource(features)
this.historicalUserLocations = new WritableStaticFeatureSource<Feature<Point>>(features)
const asLine = features.map((allPoints) => {
if (allPoints === undefined || allPoints.length < 2) {

View file

@ -4,10 +4,12 @@ import { LocalStorageSource } from "../Web/LocalStorageSource"
import { QueryParameters } from "../Web/QueryParameters"
import Hash from "../Web/Hash"
import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
import { OsmObject } from "../Osm/OsmObject"
import Constants from "../../Models/Constants"
import { Utils } from "../../Utils"
import { GeoLocationState } from "../State/GeoLocationState"
import { OsmConnection } from "../Osm/OsmConnection"
;("use strict")
/**
* This actor is responsible to set the map location.
@ -25,7 +27,11 @@ export default class InitialMapPositioning {
public location: UIEventSource<{ lon: number; lat: number }>
public useTerrain: Store<boolean>
constructor(layoutToUse: ThemeConfig, geolocationState: GeoLocationState) {
constructor(
layoutToUse: ThemeConfig,
geolocationState: GeoLocationState,
osmConnection: OsmConnection
) {
function localStorageSynced(
key: string,
deflt: number,
@ -47,8 +53,6 @@ export default class InitialMapPositioning {
return src
}
const initialHash = Hash.hash.data
// -- Location control initialization
this.zoom = localStorageSynced(
"z",
@ -72,6 +76,7 @@ export default class InitialMapPositioning {
})
this.useTerrain = new ImmutableStore<boolean>(layoutToUse.enableTerrain)
const initialHash = Hash.hash.data
if (initialHash?.match(/^(node|way|relation)\/[0-9]+$/)) {
// We pan to the selected element
const [type, id] = initialHash.split("/")
@ -88,6 +93,16 @@ export default class InitialMapPositioning {
const [lat, lon] = osmObject.centerpoint()
this.location.setData({ lon, lat })
})
} else if (layoutToUse.id === "notes" && initialHash?.match(/[0-9]+/)) {
console.log("Loading note", initialHash)
const noteId = Number(initialHash)
if (osmConnection.isLoggedIn.data) {
osmConnection.getNote(noteId).then((note) => {
const [lon, lat] = note.geometry.coordinates
console.log("Got note:", note)
this.location.set({ lon, lat })
})
}
} else if (
Constants.GeoIpServer &&
lat.data === defaultLat &&

View file

@ -1,4 +1,4 @@
import { Feature } from "geojson"
import { Feature, Geometry } from "geojson"
import { UpdatableFeatureSource } from "../FeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
@ -7,6 +7,9 @@ import { Overpass } from "../../Osm/Overpass"
import { Utils } from "../../../Utils"
import { TagsFilter } from "../../Tags/TagsFilter"
import { BBox } from "../../BBox"
import { FeatureCollection } from "@turf/turf"
import { OsmTags } from "../../../Models/OsmFeature"
;("use strict")
/**
* A wrapper around the 'Overpass'-object.
@ -56,17 +59,13 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
this.state = state
this._isActive = options?.isActive ?? new ImmutableStore(true)
this.padToZoomLevel = options?.padToTiles
const self = this
this._layersToDownload = options?.ignoreZoom
? new ImmutableStore(state.layers)
: state.zoom.map((zoom) => this.layersToDownload(zoom))
state.bounds.mapD(
(_) => {
self.updateAsyncIfNeeded()
},
[this._layersToDownload]
)
state.bounds.mapD(() => {
this.updateAsyncIfNeeded()
}, [this._layersToDownload])
}
private layersToDownload(zoom: number): LayerConfig[] {
@ -104,10 +103,11 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
/**
* Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers
* Will always attempt to download, even is 'options.isActive.data' is 'false', the zoom level is incorrect, ...
* @private
*/
public async updateAsync(overrideBounds?: BBox): Promise<void> {
let data: any = undefined
let data: FeatureCollection<Geometry, OsmTags> = undefined
let lastUsed = 0
const start = new Date()
const layersToDownload = this._layersToDownload.data
@ -116,8 +116,7 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
return
}
const self = this
const overpassUrls = self.state.overpassUrl.data
const overpassUrls = this.state.overpassUrl.data
if (overpassUrls === undefined || overpassUrls.length === 0) {
throw "Panic: overpassFeatureSource didn't receive any overpassUrls"
}
@ -140,10 +139,11 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
return undefined
}
this.runningQuery.setData(true)
console.trace("Overpass feature source: querying geojson")
data = (await overpass.queryGeoJson(bounds))[0]
} catch (e) {
self.retries.data++
self.retries.ping()
this.retries.data++
this.retries.ping()
console.error(`QUERY FAILED due to`, e)
await Utils.waitFor(1000)
@ -153,12 +153,12 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
console.log("Trying next time with", overpassUrls[lastUsed])
} else {
lastUsed = 0
self.timeout.setData(self.retries.data * 5)
this.timeout.setData(this.retries.data * 5)
while (self.timeout.data > 0) {
while (this.timeout.data > 0) {
await Utils.waitFor(1000)
self.timeout.data--
self.timeout.ping()
this.timeout.data--
this.timeout.ping()
}
}
}
@ -180,14 +180,14 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
timeNeeded,
"seconds"
)
self.features.setData(data.features)
this.features.setData(data.features)
this._lastQueryBBox = bounds
this._lastRequestedLayers = layersToDownload
} catch (e) {
console.error("Got the overpass response, but could not process it: ", e, e.stack)
} finally {
self.retries.setData(0)
self.runningQuery.setData(false)
this.retries.setData(0)
this.runningQuery.setData(false)
}
}

View file

@ -1,7 +1,8 @@
import { FeatureSource } from "../FeatureSource"
import { ImmutableStore, Store } from "../../UIEventSource"
import { FeatureSource, WritableFeatureSource } from "../FeatureSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import { Feature } from "geojson"
;("use strict")
/**
* A simple, read only feature store.
*/
@ -30,3 +31,29 @@ export default class StaticFeatureSource<T extends Feature = Feature> implements
return new StaticFeatureSource(geojson)
}
}
export class WritableStaticFeatureSource<T extends Feature = Feature>
implements WritableFeatureSource<T>
{
public readonly features: UIEventSource<T[]> = undefined
constructor(features: UIEventSource<T[]> | T[] | { features: T[] } | { features: Store<T[]> }) {
if (features === undefined) {
throw "Static feature source received undefined as source"
}
let feats: T[] | UIEventSource<T[]>
if (features["features"]) {
feats = features["features"]
} else {
feats = <T[] | UIEventSource<T[]>>features
}
if (Array.isArray(feats)) {
this.features = new UIEventSource<T[]>(feats)
} else {
this.features = feats
}
}
}

View file

@ -24,8 +24,6 @@ export default class ThemeSource extends FeatureSourceMerger {
*/
public readonly isLoading: Store<boolean>
private readonly supportsForceDownload: UpdatableFeatureSource[]
public static readonly fromCacheZoomLevel = 15
/**
@ -44,8 +42,6 @@ export default class ThemeSource extends FeatureSourceMerger {
mvtAvailableLayers: Set<string>,
fullNodeDatabaseSource?: FullNodeDatabaseSource
) {
const supportsForceDownload: UpdatableFeatureSource[] = []
const { bounds, zoom } = mapProperties
// remove all 'special' layers
layers = layers.filter((layer) => layer.source !== null && layer.source !== undefined)
@ -95,7 +91,6 @@ export default class ThemeSource extends FeatureSourceMerger {
)
overpassSource = ThemeSource.setupOverpass(osmLayers, bounds, zoom, featureSwitches)
nonMvtSources.push(overpassSource)
supportsForceDownload.push(overpassSource)
}
function setIsLoading() {
@ -110,7 +105,6 @@ export default class ThemeSource extends FeatureSourceMerger {
ThemeSource.setupGeojsonSource(l, mapProperties, isDisplayed(l.id))
)
const downloadAllBounds: UIEventSource<BBox> = new UIEventSource<BBox>(undefined)
const downloadAll = new OverpassFeatureSource(
{
layers: layers.filter((l) => l.isNormal()),
@ -123,6 +117,7 @@ export default class ThemeSource extends FeatureSourceMerger {
},
{
ignoreZoom: true,
isActive: new ImmutableStore(false),
}
)
@ -135,13 +130,8 @@ export default class ThemeSource extends FeatureSourceMerger {
)
this.isLoading = isLoading
supportsForceDownload.push(...geojsonSources)
supportsForceDownload.push(...mvtSources) // Non-mvt sources are handled by overpass
this._mapBounds = mapProperties.bounds
this._downloadAll = downloadAll
this.supportsForceDownload = supportsForceDownload
this._mapBounds = mapProperties.bounds
}
private static setupMvtSource(

View file

@ -1,11 +1,10 @@
import { BBox } from "./BBox"
import * as turf from "@turf/turf"
import { AllGeoJSON, booleanWithin, Coord, Lines } from "@turf/turf"
import { AllGeoJSON, booleanWithin, Coord } from "@turf/turf"
import {
Feature,
FeatureCollection,
GeoJSON,
Geometry,
LineString,
MultiLineString,
MultiPolygon,
@ -15,6 +14,9 @@ import {
} from "geojson"
import { Tiles } from "../Models/TileRange"
import { Utils } from "../Utils"
import { NearestPointOnLine } from "@turf/nearest-point-on-line"
;("use strict")
export class GeoOperations {
private static readonly _earthRadius = 6378137
@ -52,15 +54,21 @@ export class GeoOperations {
/**
* Create a union between two features
*/
public static union(f0: Feature, f1: Feature): Feature<Polygon | MultiPolygon> | null {
return turf.union(<any>f0, <any>f1)
public static union(
f0: Feature<Polygon | MultiPolygon>,
f1: Feature<Polygon | MultiPolygon>
): Feature<Polygon | MultiPolygon> | null {
return turf.union(f0, f1)
}
public static intersect(f0: Feature, f1: Feature): Feature<Polygon | MultiPolygon> | null {
return turf.intersect(<any>f0, <any>f1)
public static intersect(
f0: Feature<Polygon | MultiPolygon>,
f1: Feature<Polygon | MultiPolygon>
): Feature<Polygon | MultiPolygon> | null {
return turf.intersect(f0, f1)
}
static surfaceAreaInSqMeters(feature: any) {
static surfaceAreaInSqMeters(feature: Feature<Polygon | MultiPolygon>): number {
return turf.area(feature)
}
@ -68,19 +76,31 @@ export class GeoOperations {
* Converts a GeoJson feature to a point GeoJson feature
* @param feature
*/
static centerpoint(feature: any): Feature<Point> {
const newFeature: Feature<Point> = turf.center(feature)
static centerpoint(feature: Feature): Feature<Point> {
const newFeature: Feature<Point> = turf.center(<turf.Feature>feature)
newFeature.properties = feature.properties
newFeature.id = feature.id
return newFeature
}
/**
* Returns [lon,lat] coordinates
* Returns [lon,lat] coordinates.
* @param feature
*
* GeoOperations.centerpointCoordinates(undefined) // => undefined
*/
static centerpointCoordinates(feature: undefined | null): undefined
static centerpointCoordinates(
feature: AllGeoJSON | GeoJSON | undefined
): [number, number] | undefined
static centerpointCoordinates(
feature: NonNullable<AllGeoJSON> | NonNullable<GeoJSON>
): NonNullable<[number, number]>
static centerpointCoordinates(feature: AllGeoJSON | GeoJSON): [number, number] {
return <[number, number]>turf.center(<any>feature).geometry.coordinates
if (feature === undefined || feature === null) {
return undefined
}
return <[number, number]>turf.center(<turf.Feature>feature).geometry.coordinates
}
/**
@ -132,11 +152,14 @@ export class GeoOperations {
* const overlap0 = GeoOperations.calculateOverlap(line0, [polygon]);
* overlap.length // => 1
*/
static calculateOverlap(feature: any, otherFeatures: any[]): { feat: any; overlap: number }[] {
static calculateOverlap(
feature: Feature,
otherFeatures: Feature[]
): { feat: Feature; overlap: number }[] {
const featureBBox = BBox.get(feature)
const result: { feat: any; overlap: number }[] = []
const result: { feat: Feature; overlap: number }[] = []
if (feature.geometry.type === "Point") {
const coor = feature.geometry.coordinates
const coor = <[number, number]>feature.geometry.coordinates
for (const otherFeature of otherFeatures) {
if (
feature.properties.id !== undefined &&
@ -189,7 +212,7 @@ export class GeoOperations {
}
if (otherFeature.geometry.type === "Point") {
if (this.inside(otherFeature, feature)) {
if (this.inside(<Feature<Point>>otherFeature, feature)) {
result.push({ feat: otherFeature, overlap: undefined })
}
continue
@ -255,9 +278,10 @@ export class GeoOperations {
const y: number = pointCoordinate[1]
if (feature.geometry.type === "MultiPolygon") {
const coordinatess = feature.geometry.coordinates
const coordinatess: [number, number][][][] = <[number, number][][][]>(
feature.geometry.coordinates
)
for (const coordinates of coordinatess) {
// @ts-ignore
const inThisPolygon = GeoOperations.pointInPolygonCoordinates(x, y, coordinates)
if (inThisPolygon) {
return true
@ -267,24 +291,30 @@ export class GeoOperations {
}
if (feature.geometry.type === "Polygon") {
// @ts-ignore
return GeoOperations.pointInPolygonCoordinates(x, y, feature.geometry.coordinates)
return GeoOperations.pointInPolygonCoordinates(
x,
y,
<[number, number][][]>feature.geometry.coordinates
)
}
throw "GeoOperations.inside: unsupported geometry type " + feature.geometry.type
}
static lengthInMeters(feature: any) {
static lengthInMeters(feature: Feature): number {
return turf.length(feature) * 1000
}
static buffer(feature: any, bufferSizeInMeter: number) {
static buffer(
feature: Feature,
bufferSizeInMeter: number
): Feature<Polygon | MultiPolygon> | FeatureCollection<Polygon | MultiPolygon> {
return turf.buffer(feature, bufferSizeInMeter / 1000, {
units: "kilometers",
})
}
static bbox(feature: Feature | FeatureCollection): Feature<LineString, {}> {
static bbox(feature: Feature | FeatureCollection): Feature<LineString> {
const [lon, lat, lon0, lat0] = turf.bbox(feature)
return {
type: "Feature",
@ -316,17 +346,8 @@ export class GeoOperations {
public static nearestPoint(
way: Feature<LineString>,
point: [number, number]
): Feature<
Point,
{
index: number
dist: number
location: number
}
> {
return <any>(
turf.nearestPointOnLine(<Feature<LineString>>way, point, { units: "kilometers" })
)
): NearestPointOnLine {
return turf.nearestPointOnLine(<Feature<LineString>>way, point, { units: "kilometers" })
}
/**
@ -344,18 +365,32 @@ export class GeoOperations {
way: Feature<LineString | MultiLineString | Polygon | MultiPolygon>
): Feature<LineString | MultiLineString> {
if (way.geometry.type === "Polygon") {
way = { ...way }
way.geometry = { ...way.geometry }
way.geometry.type = "LineString"
way.geometry.coordinates = (<Polygon>way.geometry).coordinates[0]
} else if (way.geometry.type === "MultiPolygon") {
way = { ...way }
way.geometry = { ...way.geometry }
way.geometry.type = "MultiLineString"
way.geometry.coordinates = (<MultiPolygon>way.geometry).coordinates[0]
return <Feature<LineString>>{
type: "Feature",
geometry: {
type: "LineString",
coordinates: way.geometry.coordinates[0],
},
properties: way.properties,
}
}
return <any>way
if (way.geometry.type === "MultiPolygon") {
return <Feature<MultiLineString>>{
type: "Feature",
geometry: {
type: "MultiLineString",
coordinates: way.geometry.coordinates[0],
},
properties: way.properties,
}
}
if (way.geometry.type === "LineString") {
return <Feature<LineString>>way
}
if (way.geometry.type === "MultiLineString") {
return <Feature<MultiLineString>>way
}
throw "Invalid geometry to create a way from this"
}
public static toCSV(
@ -394,9 +429,6 @@ export class GeoOperations {
for (const feature of _features) {
const properties = feature.properties
for (const key in properties) {
if (!properties.hasOwnProperty(key)) {
continue
}
addH(key)
}
}
@ -602,7 +634,7 @@ export class GeoOperations {
* const copy = GeoOperations.removeOvernoding(feature)
* expect(copy.geometry.coordinates[0]).deep.equal([[4.477944199999975,51.02783550000022],[4.477987899999996,51.027818800000034],[4.478004500000021,51.02783399999988],[4.478025499999962,51.02782489999994],[4.478079099999993,51.027873899999896],[4.47801040000006,51.027903799999955],[4.477944199999975,51.02783550000022]])
*/
static removeOvernoding(feature: any) {
static removeOvernoding(feature: Feature<LineString | Polygon>) {
if (feature.geometry.type !== "LineString" && feature.geometry.type !== "Polygon") {
throw "Overnode removal is only supported on linestrings and polygons"
}
@ -613,10 +645,10 @@ export class GeoOperations {
}
let coordinates: [number, number][]
if (feature.geometry.type === "LineString") {
coordinates = [...feature.geometry.coordinates]
coordinates = <[number, number][]>[...feature.geometry.coordinates]
copy.geometry.coordinates = coordinates
} else {
coordinates = [...feature.geometry.coordinates[0]]
coordinates = <[number, number][]>[...feature.geometry.coordinates[0]]
copy.geometry.coordinates[0] = coordinates
}
@ -663,7 +695,7 @@ export class GeoOperations {
public static along(a: Coord, b: Coord, distanceMeter: number): Coord {
return turf.along(
<any>{
<Feature<LineString>>{
type: "Feature",
geometry: {
type: "LineString",
@ -705,8 +737,8 @@ export class GeoOperations {
* GeoOperations.completelyWithin(park, pond) // => false
*/
static completelyWithin(
feature: Feature<Geometry, any>,
possiblyEnclosingFeature: Feature<Polygon | MultiPolygon, any>
feature: Feature,
possiblyEnclosingFeature: Feature<Polygon | MultiPolygon>
): boolean {
return booleanWithin(feature, possiblyEnclosingFeature)
}
@ -1169,7 +1201,7 @@ export class GeoOperations {
// Calculate the length of the intersection
let intersectionPoints = turf.lineIntersect(feature, otherFeature)
const intersectionPoints = turf.lineIntersect(feature, otherFeature)
if (intersectionPoints.features.length == 0) {
// No intersections.
// If one point is inside of the polygon, all points are
@ -1182,7 +1214,7 @@ export class GeoOperations {
return null
}
let intersectionPointsArray = intersectionPoints.features.map((d) => {
const intersectionPointsArray = intersectionPoints.features.map((d) => {
return d.geometry.coordinates
})
@ -1204,7 +1236,7 @@ export class GeoOperations {
}
}
let intersection = turf.lineSlice(
const intersection = turf.lineSlice(
turf.point(intersectionPointsArray[0]),
turf.point(intersectionPointsArray[1]),
feature

View file

@ -107,12 +107,15 @@ export class ImageUploadManager {
* @param file a jpg file to upload
* @param tagsStore The tags of the feature
* @param targetKey Use this key to save the attribute under. Default: 'image'
* @param noblur if true, then the api call will indicate that the image is already blurred. The server won't apply blurring in this case
* @param feature the feature this image is about. Will be used as fallback to get the GPS-coordinates
*/
public async uploadImageAndApply(
file: File,
tagsStore: UIEventSource<OsmTags>,
targetKey: string,
noblur: boolean
noblur: boolean,
feature: Feature
): Promise<void> {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
@ -130,7 +133,8 @@ export class ImageUploadManager {
author,
file,
targetKey,
noblur
noblur,
feature
)
if (!uploadResult) {
return
@ -157,7 +161,7 @@ export class ImageUploadManager {
blob: File,
targetKey: string | undefined,
noblur: boolean,
feature?: Feature,
feature: Feature,
ignoreGps: boolean = false
): Promise<UploadResult> {
this.increaseCountFor(this._uploadStarted, featureId)
@ -168,9 +172,22 @@ export class ImageUploadManager {
if (this._gps.data && !ignoreGps) {
location = [this._gps.data.longitude, this._gps.data.latitude]
}
if (location === undefined || location?.some((l) => l === undefined)) {
{
feature ??= this._indexedFeatures.featuresById.data.get(featureId)
location = GeoOperations.centerpointCoordinates(feature)
if (feature === undefined) {
throw "ImageUploadManager: no feature given and no feature found in the indexedFeature. Cannot upload this image"
}
const featureCenterpoint = GeoOperations.centerpointCoordinates(feature)
if (
location === undefined ||
location?.some((l) => l === undefined) ||
GeoOperations.distanceBetween(location, featureCenterpoint) > 150
) {
/* GPS location is either unknown or very far away from the photographed location.
* Default to the centerpoint
*/
location = featureCenterpoint
}
}
try {
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(

View file

@ -130,7 +130,7 @@ export default class PanoramaxImageProvider extends ImageProvider {
}
public async getInfo(hash: string): Promise<ProvidedImage> {
return await this.getInfoFor(hash).then((r) => this.featureToImage(<any>r))
return await this.getInfoFor(hash).then((r) => this.featureToImage(<any>r))
}
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
@ -234,8 +234,19 @@ export class PanoramaxUploader implements ImageUploader {
) {
lat = exifLat
lon = exifLon
if (tags?.GPSLatitudeRef?.value?.[0] === "S") {
lat *= -1
}
if (tags?.GPSLongitudeRef?.value?.[0] === "W") {
lon *= -1
}
}
const [date, time] =( tags.DateTime.value[0] ?? tags.DateTimeOriginal.value[0] ?? tags.GPSDateStamp ?? tags["Date Created"]).split(" ")
const [date, time] = (
tags.DateTime.value[0] ??
tags.DateTimeOriginal.value[0] ??
tags.GPSDateStamp ??
tags["Date Created"]
).split(" ")
const exifDatetime = new Date(date.replaceAll(":", "-") + "T" + time)
if (exifDatetime.getFullYear() === 1970) {
// The data probably got reset to the epoch

View file

@ -1,9 +1,24 @@
import Constants from "../Models/Constants"
import { SpecialVisualizationState } from "../UI/SpecialVisualization"
export interface MaprouletteTask {
name: string
description: string
instruction: string
}
export const maprouletteStatus = [
"Open",
"Fixed",
"False_positive",
"Skipped",
"Deleted",
"Already fixed",
"Too_Hard",
"Disabled",
] as const
export type MaprouletteStatus = (typeof maprouletteStatus)[number]
export default class Maproulette {
public static readonly defaultEndpoint = "https://maproulette.org/api/v2"
@ -16,16 +31,6 @@ export default class Maproulette {
public static readonly STATUS_TOO_HARD = 6
public static readonly STATUS_DISABLED = 9
public static readonly STATUS_MEANING = {
0: "Open",
1: "Fixed",
2: "False_positive",
3: "Skipped",
4: "Deleted",
5: "Already fixed",
6: "Too_Hard",
9: "Disabled",
}
public static singleton = new Maproulette()
/*
* The API endpoint to use
@ -59,12 +64,11 @@ export default class Maproulette {
if (code === "Created") {
return Maproulette.STATUS_OPEN
}
for (let i = 0; i < 9; i++) {
if (Maproulette.STATUS_MEANING["" + i] === code) {
return i
}
const i = maprouletteStatus.indexOf(<any>code)
if (i < 0) {
return undefined
}
return undefined
return i
}
/**
@ -78,6 +82,7 @@ export default class Maproulette {
async closeTask(
taskId: number,
status = Maproulette.STATUS_FIXED,
state: SpecialVisualizationState,
options?: {
comment?: string
tags?: string
@ -86,13 +91,16 @@ export default class Maproulette {
}
): Promise<void> {
console.log("Maproulette: setting", `${this.endpoint}/task/${taskId}/${status}`, options)
options ??= {}
const userdetails = state.osmConnection.userDetails.data
options.tags = `MapComplete MapComplete:${state.theme.id}; userid: ${userdetails?.uid}; username: ${userdetails?.name}`
const response = await fetch(`${this.endpoint}/task/${taskId}/${status}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
apiKey: this.apiKey,
},
body: options !== undefined ? JSON.stringify(options) : undefined,
body: JSON.stringify(options),
})
if (response.status !== 204) {
console.log(`Failed to close task: ${response.status}`)

View file

@ -2,7 +2,7 @@ import ChangeTagAction from "./ChangeTagAction"
import { Tag } from "../../Tags/Tag"
import OsmChangeAction from "./OsmChangeAction"
import { ChangeDescription } from "./ChangeDescription"
import { Store, UIEventSource } from "../../UIEventSource"
import { UIEventSource } from "../../UIEventSource"
export default class LinkImageAction extends OsmChangeAction {
private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string

View file

@ -600,7 +600,7 @@ export class Changes {
" trying again before dropping it from the changes (" +
e +
")"
this._reportError(msg)
// this._reportError(msg) // We don't report this yet, might be a temporary fluke
const osmObj = await downloader.DownloadObjectAsync(id, 0)
return { id, osmObj }
}

View file

@ -6,6 +6,7 @@ import { LocalStorageSource } from "../Web/LocalStorageSource"
import { AuthConfig } from "./AuthConfig"
import Constants from "../../Models/Constants"
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
import { Feature, Point } from "geojson"
interface OsmUserInfo {
id: number
@ -41,6 +42,39 @@ export default class UserDetails {
export type OsmServiceState = "online" | "readonly" | "offline" | "unknown" | "unreachable"
interface CapabilityResult {
version: "0.6" | string
generator: "OpenStreetMap server" | string
copyright: "OpenStreetMap and contributors" | string
attribution: "http://www.openstreetmap.org/copyright" | string
license: "http://opendatacommons.org/licenses/odbl/1-0/" | string
api: {
version: { minimum: "0.6"; maximum: "0.6" }
area: { maximum: 0.25 | number }
note_area: { maximum: 25 | number }
tracepoints: { per_page: 5000 | number }
waynodes: { maximum: 2000 | number }
relationmembers: { maximum: 32000 | number }
changesets: {
maximum_elements: 10000 | number
default_query_limit: 100 | number
maximum_query_limit: 100 | number
}
notes: { default_query_limit: 100 | number; maximum_query_limit: 10000 | number }
timeout: { seconds: 300 | number }
status: {
database: OsmServiceState
api: OsmServiceState
gpx: OsmServiceState
}
}
policy: {
imagery: {
blacklist: { regex: string }[]
}
}
}
export class OsmConnection {
public auth: osmAuth
public userDetails: UIEventSource<UserDetails>
@ -424,6 +458,10 @@ export class OsmConnection {
return id
}
public async getNote(id: number): Promise<Feature<Point>> {
return JSON.parse(await this.get("notes/" + id + ".json"))
}
public static GpxTrackVisibility = ["private", "public", "trackable", "identifiable"] as const
public async uploadGpxTrack(
@ -466,7 +504,7 @@ export class OsmConnection {
(options.filename ?? "gpx_track_mapcomplete_" + new Date().toISOString()) +
"\"\r\nContent-Type: application/gpx+xml",
}
user
const boundary = "987654"
let body = ""
@ -608,20 +646,26 @@ export class OsmConnection {
return parsed
}
private async FetchCapabilities(): Promise<{ api: OsmServiceState; gpx: OsmServiceState }> {
private async FetchCapabilities(): Promise<{
api: OsmServiceState
gpx: OsmServiceState
database: OsmServiceState
}> {
if (Utils.runningFromConsole) {
return { api: "online", gpx: "online" }
return { api: "online", gpx: "online", database: "online" }
}
const result = await Utils.downloadAdvanced(this.Backend() + "/api/0.6/capabilities")
if (result["content"] === undefined) {
console.log("Something went wrong:", result)
return { api: "unreachable", gpx: "unreachable" }
try {
const result = await Utils.downloadJson<CapabilityResult>(
this.Backend() + "/api/0.6/capabilities.json"
)
if (result?.api?.status === undefined) {
console.log("Something went wrong:", result)
return { api: "unreachable", gpx: "unreachable", database: "unreachable" }
}
return result.api.status
} catch (e) {
console.error("Could not fetch capabilities")
return { api: "offline", gpx: "offline", database: "online" }
}
const xmlRaw = result["content"]
const parsed = new DOMParser().parseFromString(xmlRaw, "text/xml")
const statusEl = parsed.getElementsByTagName("status")[0]
const api = <OsmServiceState>statusEl.getAttribute("api")
const gpx = <OsmServiceState>statusEl.getAttribute("gpx")
return { api, gpx }
}
}

View file

@ -6,7 +6,7 @@ import osmtogeojson from "osmtogeojson"
import { FeatureCollection } from "@turf/turf"
import { Geometry } from "geojson"
import { OsmTags } from "../../Models/OsmFeature"
;("use strict")
/**
* Interfaces overpass to get all the latest data
*/
@ -74,9 +74,9 @@ export class Overpass {
console.warn("No features for", json)
}
const geojson = osmtogeojson(json)
const geojson = <FeatureCollection<Geometry, OsmTags>>osmtogeojson(json)
const osmTime = new Date(json.osm3s.timestamp_osm_base)
return [<any>geojson, osmTime]
return [geojson, osmTime]
}
/**

View file

@ -1,14 +1,42 @@
import { Utils } from "../../Utils"
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
export class ThemeMetaTagging {
public static readonly themeName = "usersettings"
public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/&lt;/g,'<')?.replace(/&gt;/g,'>') ?? '' )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_a', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.href.match(/mastodon|en.osm.town/) !== null)[0]?.href }) (feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_link', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.getAttribute("rel")?.indexOf('me') >= 0)[0]?.href})(feat) )
Utils.AddLazyProperty(feat.properties, '_mastodon_candidate', () => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a )
feat.properties['__current_backgroun'] = 'initial_value'
}
}
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () =>
feat.properties._description
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)
?.at(1)
)
Utils.AddLazyProperty(
feat.properties,
"_d",
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
}
}

View file

@ -1,6 +1,5 @@
import { Utils } from "../Utils"
import { Readable, Subscriber, Unsubscriber, Updater, Writable } from "svelte/store"
/**
* Various static utils
*/
@ -276,7 +275,7 @@ export abstract class Store<T> implements Readable<T> {
public bindD<X>(
f: (t: Exclude<T, undefined | null>) => Store<X>,
extraSources: UIEventSource<object>[] = []
extraSources: Store<any>[] = []
): Store<X> {
return this.bind((t) => {
if (t === null) {
@ -952,15 +951,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
g: (j: J, t: T) => T,
allowUnregister = false
): UIEventSource<J> {
const self = this
const stack = new Error().stack.split("\n")
const callee = stack[1]
const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee)
const update = function () {
newSource.setData(f(self.data))
const update = () => {
newSource.setData(f(this.data))
return allowUnregister && newSource._callbacks.length() === 0
}
@ -971,7 +968,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
if (g !== undefined) {
newSource.addCallback((latest) => {
self.setData(g(latest, self.data))
this.setData(g(latest, this.data))
})
}
@ -980,8 +977,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public syncWith(otherSource: UIEventSource<T>, reverseOverride = false): UIEventSource<T> {
this.addCallback((latest) => otherSource.setData(latest))
const self = this
otherSource.addCallback((latest) => self.setData(latest))
otherSource.addCallback((latest) => this.setData(latest))
if (reverseOverride) {
if (otherSource.data !== undefined) {
this.setData(otherSource.data)

View file

@ -421,6 +421,7 @@ export default class LinkedDataLoader {
delete output["chargeEnd"]
delete output["chargeStart"]
delete output["timeUnit"]
delete output["id"]
asBoolean("covered")
asBoolean("fee", true)
@ -518,6 +519,9 @@ export default class LinkedDataLoader {
property: string,
variable?: string
): Promise<SparqlResult<T, G>> {
if (property === "schema:photos") {
console.log(">> Getting photos")
}
const results = await new TypedSparql().typedSparql<T, G>(
{
schema: "http://schema.org/",
@ -531,15 +535,23 @@ export default class LinkedDataLoader {
" ?parking a <http://schema.mobivoc.org/BicycleParkingStation>",
"?parking " + property + " " + (variable ?? "")
)
return results
}
/**
*
* @param url
* @param property
* @param subExpr
* @private
*/
private static async fetchVeloparkGraphProperty<T extends string>(
url: string,
property: string,
subExpr?: string
): Promise<SparqlResult<T, "g">> {
return await new TypedSparql().typedSparql<T, "g">(
const result = await new TypedSparql().typedSparql<T, "g">(
{
schema: "http://schema.org/",
mv: "http://schema.mobivoc.org/",
@ -551,8 +563,15 @@ export default class LinkedDataLoader {
"g",
" ?parking a <http://schema.mobivoc.org/BicycleParkingStation>",
S.graph("g", "?section " + property + " " + (subExpr ?? ""), "?section a ?type")
S.graph(
"g",
"?section " + property + " " + (subExpr ?? ""),
"?section a ?type",
"BIND(STR(?section) AS ?id)"
)
)
return result
}
/**
@ -569,26 +588,69 @@ export default class LinkedDataLoader {
continue
}
for (const sectionKey in subResult) {
if (!r[sectionKey]) {
r[sectionKey] = {}
}
const section = subResult[sectionKey]
for (const key in section) {
r[sectionKey][key] ??= section[key]
if (sectionKey === "default") {
r["default"] ??= {}
const section = subResult["default"]
for (const key in section) {
r["default"][key] ??= section[key]
}
} else {
const section = subResult[sectionKey]
const actualId = Array.from(section["id"] ?? [])[0] ?? sectionKey
r[actualId] ??= {}
for (const key in section) {
r[actualId][key] ??= section[key]
}
}
}
}
if (r["default"] !== undefined && Object.keys(r).length > 1) {
/**
* Copy all values from the section with name "key" into the other sections,
* remove section "key" afterwards
* @param key
*/
function spreadSection(key: string) {
for (const section in r) {
if (section === "default") {
if (section === key) {
continue
}
for (const k in r.default) {
r[section][k] ??= r.default[k]
for (const k in r[key]) {
r[section][k] ??= r[key][k]
}
}
delete r.default
delete r[key]
}
// The "default" part of the result contains all general info
// The other 'sections' need to get those copied! Then, we delete the "default"-section
if (r["default"] !== undefined && Object.keys(r).length > 1) {
spreadSection("default")
}
if (Object.keys(r).length > 1) {
// This result has multiple sections
// We should check that the naked URL got distributed and scrapped
const keys = Object.keys(r)
if (Object.keys(r).length > 2) {
console.log("Multiple sections detected: ", JSON.stringify(keys))
}
const shortestKeyLength: number = Math.min(...keys.map((k) => k.length))
const key = keys.find((k) => k.length === shortestKeyLength)
if (keys.some((k) => !k.startsWith(key))) {
throw (
"Invalid multi-object: the shortest key is not the start of all the others: " +
JSON.stringify(keys)
)
}
spreadSection(key)
}
if (Object.keys(r).length == 1) {
const key = Object.keys(r)[0]
if (key.indexOf("#") > 0) {
const newKey = key.split("#")[0]
r[newKey] = r[key]
delete r[key]
}
}
return r
}
@ -675,6 +737,7 @@ export default class LinkedDataLoader {
/**
* Fetches all data relevant to velopark.
* The id will be saved as `ref:velopark`
* If the entry has multiple sections, this will return multiple items
* @param url
*/
public static async fetchVeloparkEntry(
@ -685,6 +748,7 @@ export default class LinkedDataLoader {
if (this.veloparkCache[cacheKey]) {
return this.veloparkCache[cacheKey]
}
// Note: the proxy doesn't make any changes in this case
const withProxyUrl = Constants.linkedDataProxy.replace("{url}", encodeURIComponent(url))
const optionalPaths: Record<string, string | Record<string, string>> = {
"schema:interactionService": {
@ -697,6 +761,7 @@ export default class LinkedDataLoader {
"schema:email": "email",
"schema:telephone": "phone",
},
// "schema:photos": "images",
"schema:dateModified": "_last_edit_timestamp",
}
if (includeExtras) {
@ -740,9 +805,22 @@ export default class LinkedDataLoader {
graphOptionalPaths,
extra
)
for (const unpatchedKey in unpatched) {
// Dirty hack
const rawData = await Utils.downloadJsonCached<object>(url, 1000 * 60 * 60)
const images = rawData["photos"]?.map((ph) => <string>ph.image)
if (images) {
unpatched[unpatchedKey].images = new Set<string>(images)
}
}
console.log("Got unpatched:", unpatched)
const patched: Feature[] = []
for (const section in unpatched) {
for (let section in unpatched) {
const p = LinkedDataLoader.patchVeloparkProperties(unpatched[section])
if (Object.keys(unpatched).length === 1 && section.endsWith("#section1")) {
section = section.split("#")[0]
}
p["ref:velopark"] = [section]
patched.push(LinkedDataLoader.asGeojson(p))
}

View file

@ -67,13 +67,14 @@ export default class TypedSparql {
bindings.forEach((item) => {
const result = <Record<VARS | G, Set<string>>>{}
item.forEach((value, key) => {
if (!result[key.value]) {
result[key.value] = new Set()
}
result[key.value] ??= new Set()
result[key.value].add(value.value)
})
if (graphVariable && result[graphVariable]?.size > 0) {
const id = Array.from(result[graphVariable])?.[0] ?? "default"
const id: string =
<string>Array.from(result["id"] ?? [])?.[0] ??
Array.from(result[graphVariable] ?? [])?.[0] ??
"default"
resultAllGraphs[id] = result
} else {
resultAllGraphs["default"] = result