Merge upstream

This commit is contained in:
Pieter Vander Vennet 2024-10-23 18:44:26 +02:00
commit 40eceafc80
264 changed files with 15565 additions and 4191 deletions

View file

@ -5,10 +5,7 @@ import { Feature } from "geojson"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
export default class TitleHandler {
constructor(
selectedElement: Store<Feature>,
state: SpecialVisualizationState
) {
constructor(selectedElement: Store<Feature>, state: SpecialVisualizationState) {
const currentTitle: Store<string> = selectedElement.map(
(selected) => {
const lng = Locale.language.data
@ -34,7 +31,6 @@ export default class TitleHandler {
const el = document.createElement("span")
el.innerHTML = title
return el.textContent + " | " + defaultTitle
},
[Locale.language]
)

View file

@ -6,7 +6,7 @@ import { Feature, Polygon } from "geojson"
export class BBox {
static global: BBox = new BBox([
[-180, -90],
[180, 90]
[180, 90],
])
readonly maxLat: number
readonly maxLon: number
@ -53,7 +53,7 @@ export class BBox {
static fromLeafletBounds(bounds) {
return new BBox([
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()]
[bounds.getEast(), bounds.getSouth()],
])
}
@ -74,7 +74,7 @@ export class BBox {
// Note: x is longitude
f["bbox"] = new BBox([
[minX, minY],
[maxX, maxY]
[maxX, maxY],
])
}
return f["bbox"]
@ -94,7 +94,7 @@ export class BBox {
}
return new BBox([
[maxLon, maxLat],
[minLon, minLat]
[minLon, minLat],
])
}
@ -121,7 +121,7 @@ export class BBox {
public unionWith(other: BBox) {
return new BBox([
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)]
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
])
}
@ -174,7 +174,7 @@ export class BBox {
return new BBox([
[lon - s / 2, lat - s / 2],
[lon + s / 2, lat + s / 2]
[lon + s / 2, lat + s / 2],
])
}
@ -231,29 +231,26 @@ export class BBox {
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
return new BBox([
[this.minLon - lonDiff, this.minLat - latDiff],
[this.maxLon + lonDiff, this.maxLat + latDiff]
[this.maxLon + lonDiff, this.maxLat + latDiff],
])
}
padAbsolute(degrees: number): BBox {
return new BBox([
[this.minLon - degrees, this.minLat - degrees],
[this.maxLon + degrees, this.maxLat + degrees]
[this.maxLon + degrees, this.maxLat + degrees],
])
}
toLngLat(): [[number, number], [number, number]] {
return [
[this.minLon, this.minLat],
[this.maxLon, this.maxLat]
[this.maxLon, this.maxLat],
]
}
toLngLatFlat(): [number, number, number, number] {
return [
this.minLon, this.minLat,
this.maxLon, this.maxLat,
]
return [this.minLon, this.minLat, this.maxLon, this.maxLat]
}
public asGeojsonCached() {
@ -267,7 +264,7 @@ export class BBox {
return {
type: "Feature",
properties: properties,
geometry: this.asGeometry()
geometry: this.asGeometry(),
}
}
@ -280,9 +277,9 @@ export class BBox {
[this.maxLon, this.minLat],
[this.maxLon, this.maxLat],
[this.minLon, this.maxLat],
[this.minLon, this.minLat]
]
]
[this.minLon, this.minLat],
],
],
}
}
@ -309,7 +306,7 @@ export class BBox {
minLon,
maxLon,
minLat,
maxLat
maxLat,
}
}

View file

@ -109,7 +109,7 @@ export default class DetermineTheme {
const dict = new Map<string, QuestionableTagRenderingConfigJson>()
for (const tagRendering of questions.tagRenderings) {
dict.set(tagRendering.id, <QuestionableTagRenderingConfigJson> tagRendering)
dict.set(tagRendering.id, <QuestionableTagRenderingConfigJson>tagRendering)
}
return dict

View file

@ -9,7 +9,8 @@ import { OsmFeature } from "../../../Models/OsmFeature"
* If multiple sources contain the same object (as determined by 'id'), only one copy of them is retained
*/
export default class FeatureSourceMerger<Src extends FeatureSource = FeatureSource>
implements IndexedFeatureSource {
implements IndexedFeatureSource
{
public features: UIEventSource<Feature[]> = new UIEventSource([])
public readonly featuresById: Store<Map<string, Feature>>
protected readonly _featuresById: UIEventSource<Map<string, Feature>>
@ -118,10 +119,11 @@ export default class FeatureSourceMerger<Src extends FeatureSource = FeatureSour
}
export class UpdatableFeatureSourceMerger<
Src extends UpdatableFeatureSource = UpdatableFeatureSource
>
Src extends UpdatableFeatureSource = UpdatableFeatureSource
>
extends FeatureSourceMerger<Src>
implements IndexedFeatureSource, UpdatableFeatureSource {
implements IndexedFeatureSource, UpdatableFeatureSource
{
constructor(...sources: Src[]) {
super(...sources)
}

View file

@ -4,7 +4,6 @@ import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSourceForTile, UpdatableFeatureSource } from "../FeatureSource"
import { MvtToGeojson } from "mvt-to-geojson"
export default class MvtSource implements FeatureSourceForTile, UpdatableFeatureSource {
public readonly features: Store<GeojsonFeature<Geometry, { [name: string]: any }>[]>
public readonly x: number
@ -28,7 +27,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
y: number,
z: number,
layerName?: string,
isActive?: Store<boolean>,
isActive?: Store<boolean>
) {
this._url = url
this._layerName = layerName
@ -43,7 +42,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
}
return fs
},
[isActive],
[isActive]
)
}
@ -54,7 +53,6 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
await this.currentlyRunning
}
private async download(): Promise<void> {
try {
const result = await fetch(this._url)
@ -66,7 +64,7 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
const features = MvtToGeojson.fromBuffer(buffer, this.x, this.y, this.z)
for (const feature of features) {
const properties = feature.properties
if(!properties["osm_type"]){
if (!properties["osm_type"]) {
continue
}
let type: string = "node"
@ -90,6 +88,4 @@ export default class MvtSource implements FeatureSourceForTile, UpdatableFeature
console.error("Could not download MVT " + this._url + " tile due to", e)
}
}
}

View file

@ -23,8 +23,7 @@ export class SummaryTileSourceRewriter implements FeatureSource {
filteredLayers: ReadonlyMap<string, FilteredLayer>
) {
this.filteredLayers = Array.from(filteredLayers.values()).filter(
(l) =>
Constants.priviliged_layers.indexOf(<any>l.layerDef.id) < 0
(l) => Constants.priviliged_layers.indexOf(<any>l.layerDef.id) < 0
)
this._summarySource = summarySource
filteredLayers.forEach((v) => {

View file

@ -95,8 +95,14 @@ export class GeoOperations {
/**
* Starting on `from`, travels `distance` meters in the direction of the `bearing` (default: 90)
*/
static destination(from: Coord | [number,number],distance: number, bearing: number = 90): [number,number]{
return <[number,number]> turf.destination(from, distance, bearing, {units: "meters"}).geometry.coordinates
static destination(
from: Coord | [number, number],
distance: number,
bearing: number = 90
): [number, number] {
return <[number, number]>(
turf.destination(from, distance, bearing, { units: "meters" }).geometry.coordinates
)
}
static convexHull(featureCollection, options: { concavity?: number }) {
@ -928,13 +934,13 @@ export class GeoOperations {
if (meters === undefined) {
return ""
}
meters = Utils.roundHuman( Math.round(meters))
meters = Utils.roundHuman(Math.round(meters))
if (meters < 1000) {
return Utils.roundHuman(meters) + "m"
}
if (meters >= 10000) {
const km = Utils.roundHuman(Math.round(meters / 1000))
const km = Utils.roundHuman(Math.round(meters / 1000))
return km + "km"
}

View file

@ -34,10 +34,10 @@ export default class AllImageProviders {
AllImageProviders.genericImageProvider,
]
public static apiUrls: string[] = [].concat(
...AllImageProviders.ImageAttributionSource.map((src) => src.apiUrls()),
...AllImageProviders.ImageAttributionSource.map((src) => src.apiUrls())
)
public static defaultKeys = [].concat(
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes),
AllImageProviders.ImageAttributionSource.map((provider) => provider.defaultKeyPrefixes)
)
private static providersByName = {
imgur: Imgur.singleton,
@ -71,13 +71,12 @@ export default class AllImageProviders {
*/
public static LoadImagesFor(
tags: Store<Record<string, string>>,
tagKey?: string[],
tagKey?: string[]
): Store<ProvidedImage[]> {
if (tags?.data?.id === undefined) {
return undefined
}
const source = new UIEventSource([])
const allSources: Store<ProvidedImage[]>[] = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) {
@ -86,11 +85,11 @@ export default class AllImageProviders {
However, we override them if a custom image tag is set, e.g. 'image:menu'
*/
const prefixes = tagKey ?? imageProvider.defaultKeyPrefixes
const singleSource = tags.bindD(tags => imageProvider.getRelevantUrls(tags, prefixes))
const singleSource = tags.bindD((tags) => imageProvider.getRelevantUrls(tags, prefixes))
allSources.push(singleSource)
singleSource.addCallbackAndRunD((_) => {
const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
const dedup = Utils.DedupOnId(all, i => i?.id ?? i?.url)
const dedup = Utils.DedupOnId(all, (i) => i?.id ?? i?.url)
source.set(dedup)
})
}
@ -103,7 +102,7 @@ export default class AllImageProviders {
*/
public static loadImagesFrom(urls: string[]): Store<ProvidedImage[]> {
const tags = {
id:"na"
id: "na",
}
for (let i = 0; i < urls.length; i++) {
const url = urls[i]

View file

@ -27,12 +27,14 @@ export default class GenericImageProvider extends ImageProvider {
return undefined
}
return [{
key: key,
url: value,
provider: this,
id: value,
}]
return [
{
key: key,
url: value,
provider: this,
id: value,
},
]
}
SourceIcon() {

View file

@ -9,15 +9,15 @@ export interface ProvidedImage {
key: string
provider: ImageProvider
id: string
date?: Date,
date?: Date
status?: string | "ready"
/**
* Compass angle of the taken image
* 0 = north, 90° = East
*/
rotation?: number
lat?: number,
lon?: number,
lat?: number
lon?: number
host?: string
}
@ -26,8 +26,10 @@ export default abstract class ImageProvider {
public abstract readonly name: string
public abstract SourceIcon(img?: {id: string, url: string, host?: string}, location?: { lon: number; lat: number }): BaseUIElement
public abstract SourceIcon(
img?: { id: string; url: string; host?: string },
location?: { lon: number; lat: number }
): BaseUIElement
/**
* Gets all the relevant URLS for the given tags and for the given prefixes;
@ -35,12 +37,19 @@ export default abstract class ImageProvider {
* @param tags
* @param prefixes
*/
public async getRelevantUrlsFor(tags: Record<string, string>, prefixes: string[]): Promise<ProvidedImage[]> {
public async getRelevantUrlsFor(
tags: Record<string, string>,
prefixes: string[]
): Promise<ProvidedImage[]> {
const relevantUrls: ProvidedImage[] = []
const seenValues = new Set<string>()
for (const key in tags) {
if (!prefixes.some((prefix) => key === prefix || key.match(new RegExp(prefix+":[0-9]+")))) {
if (
!prefixes.some(
(prefix) => key === prefix || key.match(new RegExp(prefix + ":[0-9]+"))
)
) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
@ -50,10 +59,10 @@ export default abstract class ImageProvider {
}
seenValues.add(value)
let images = this.ExtractUrls(key, value)
if(!Array.isArray(images)){
images = await images
if (!Array.isArray(images)) {
images = await images
}
if(images){
if (images) {
relevantUrls.push(...images)
}
}
@ -61,12 +70,17 @@ export default abstract class ImageProvider {
return relevantUrls
}
public getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
public getRelevantUrls(
tags: Record<string, string>,
prefixes: string[]
): Store<ProvidedImage[]> {
return Stores.FromPromise(this.getRelevantUrlsFor(tags, prefixes))
}
public abstract ExtractUrls(key: string, value: string): undefined | ProvidedImage[] | Promise<ProvidedImage[]>
public abstract ExtractUrls(
key: string,
value: string
): undefined | ProvidedImage[] | Promise<ProvidedImage[]>
public abstract DownloadAttribution(providedImage: {
url: string

View file

@ -28,7 +28,10 @@ export class ImageUploadManager {
private readonly _osmConnection: OsmConnection
private readonly _changes: Changes
public readonly isUploading: Store<boolean>
private readonly _reportError: (message: (string | Error | XMLHttpRequest), extramessage?: string) => Promise<void>
private readonly _reportError: (
message: string | Error | XMLHttpRequest,
extramessage?: string
) => Promise<void>
constructor(
layout: ThemeConfig,
@ -38,7 +41,10 @@ export class ImageUploadManager {
changes: Changes,
gpsLocation: Store<GeolocationCoordinates | undefined>,
allFeatures: IndexedFeatureSource,
reportError: (message: string | Error | XMLHttpRequest, extramessage?: string ) => Promise<void>
reportError: (
message: string | Error | XMLHttpRequest,
extramessage?: string
) => Promise<void>
) {
this._uploader = uploader
this._featureProperties = featureProperties
@ -56,7 +62,7 @@ export class ImageUploadManager {
(startedCount) => {
return startedCount > failed.data + done.data
},
[failed, done],
[failed, done]
)
}
@ -105,7 +111,7 @@ export class ImageUploadManager {
file: File,
tagsStore: UIEventSource<OsmTags>,
targetKey: string,
noblur: boolean,
noblur: boolean
): Promise<void> {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
@ -130,10 +136,16 @@ export class ImageUploadManager {
}
const properties = this._featureProperties.getStore(featureId)
const action = new LinkImageAction(featureId, uploadResult. key, uploadResult . value, properties, {
theme: tags?.data?.["_orig_theme"] ?? this._theme.id,
changeType: "add-image",
})
const action = new LinkImageAction(
featureId,
uploadResult.key,
uploadResult.value,
properties,
{
theme: tags?.data?.["_orig_theme"] ?? this._theme.id,
changeType: "add-image",
}
)
await this._changes.applyAction(action)
}
@ -153,34 +165,51 @@ export class ImageUploadManager {
if (this._gps.data) {
location = [this._gps.data.longitude, this._gps.data.latitude]
}
if (location === undefined || location?.some(l => l === undefined)) {
if (location === undefined || location?.some((l) => l === undefined)) {
const feature = this._indexedFeatures.featuresById.data.get(featureId)
location = GeoOperations.centerpointCoordinates(feature)
}
try {
({ key, value, absoluteUrl } = await this._uploader.uploadImage(blob, location, author, noblur))
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
blob,
location,
author,
noblur
))
} catch (e) {
this.increaseCountFor(this._uploadRetried, featureId)
console.error("Could not upload image, trying again:", e)
try {
({ key, value , absoluteUrl} = await this._uploader.uploadImage(blob, location, author, noblur))
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
blob,
location,
author,
noblur
))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} catch (e) {
console.error("Could again not upload image due to", e)
this.increaseCountFor(this._uploadFailed, featureId)
await this._reportError(e, JSON.stringify({ctx:"While uploading an image in the Image Upload Manager", featureId, author, targetKey}))
await this._reportError(
e,
JSON.stringify({
ctx: "While uploading an image in the Image Upload Manager",
featureId,
author,
targetKey,
})
)
return undefined
}
}
console.log("Uploading image done, creating action for", featureId)
key = targetKey ?? key
if(targetKey){
if (targetKey) {
// This is a non-standard key, so we use the image link directly
value = absoluteUrl
}
this.increaseCountFor(this._uploadFinished, featureId)
return {key, absoluteUrl, value}
return { key, absoluteUrl, value }
}
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {

View file

@ -6,10 +6,14 @@ export interface ImageUploader {
*/
uploadImage(
blob: File,
currentGps: [number,number],
currentGps: [number, number],
author: string,
noblur: boolean
): Promise<UploadResult>
}
export interface UploadResult{ key: string; value: string, absoluteUrl: string }
export interface UploadResult {
key: string
value: string
absoluteUrl: string
}

View file

@ -19,7 +19,6 @@ export class Imgur extends ImageProvider {
return [Imgur.apiUrl]
}
SourceIcon(): BaseUIElement {
return undefined
}
@ -32,7 +31,7 @@ export class Imgur extends ImageProvider {
key: key,
provider: this,
id: value,
}
},
]
}
return undefined

View file

@ -118,7 +118,7 @@ export class Mapillary extends ImageProvider {
}
SourceIcon(
img: {id: string, url: string},
img: { id: string; url: string },
location?: {
lon: number
lat: number
@ -182,7 +182,7 @@ export class Mapillary extends ImageProvider {
key,
rotation,
lat: geometry.coordinates[1],
lon: geometry.coordinates[0]
lon: geometry.coordinates[0],
}
}
}

View file

@ -10,11 +10,8 @@ import { Store, Stores, UIEventSource } from "../UIEventSource"
import SvelteUIElement from "../../UI/Base/SvelteUIElement"
import Panoramax_bw from "../../assets/svg/Panoramax_bw.svelte"
import Link from "../../UI/Base/Link"
import { Utils } from "../../Utils"
export default class PanoramaxImageProvider extends ImageProvider {
public static readonly singleton = new PanoramaxImageProvider()
private static readonly xyz = new PanoramaxXYZ()
private static defaultPanoramax = new AuthorizedPanoramax(Constants.panoramax.url, Constants.panoramax.token, 3000)
@ -22,17 +19,24 @@ export default class PanoramaxImageProvider extends ImageProvider {
public defaultKeyPrefixes: string[] = ["panoramax"]
public readonly name: string = "panoramax"
private static knownMeta: Record<string, { data: ImageData, time: Date }> = {}
private static knownMeta: Record<string, { data: ImageData; time: Date }> = {}
public SourceIcon(img?: { id: string, url: string, host?: string }, location?: {
lon: number;
lat: number;
}): BaseUIElement {
public SourceIcon(
img?: { id: string; url: string; host?: string },
location?: {
lon: number
lat: number
}
): BaseUIElement {
const p = new Panoramax(img.host)
return new Link(new SvelteUIElement(Panoramax_bw), p.createViewLink({
imageId: img?.id,
location,
}), true)
return new Link(
new SvelteUIElement(Panoramax_bw),
p.createViewLink({
imageId: img?.id,
location,
}),
true
)
}
public addKnownMeta(meta: ImageData) {
@ -50,18 +54,16 @@ export default class PanoramaxImageProvider extends ImageProvider {
return { url, data }
}
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData, url: string }> {
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData; url: string }> {
const data = await PanoramaxImageProvider.xyz.imageInfo(imageId)
return { data, url: "https://api.panoramax.xyz/" }
}
/**
* Reads a geovisio-somewhat-looking-like-geojson object and converts it to a provided image
* @param meta
* @private
*/
private featureToImage(info: { data: ImageData, url: string }) {
private featureToImage(info: { data: ImageData; url: string }) {
const meta = info?.data
if (!meta) {
return undefined
@ -82,8 +84,9 @@ export default class PanoramaxImageProvider extends ImageProvider {
id: meta.id,
url: makeAbsolute(meta.assets.sd.href),
url_hd: makeAbsolute(meta.assets.hd.href),
host: meta["links"].find(l => l.rel === "root")?.href,
lon, lat,
host: meta["links"].find((l) => l.rel === "root")?.href,
lon,
lat,
key: "panoramax",
provider: this,
status: meta.properties["geovisio:status"],
@ -92,14 +95,13 @@ export default class PanoramaxImageProvider extends ImageProvider {
}
}
private async getInfoFor(id: string): Promise<{ data: ImageData, url: string }> {
private async getInfoFor(id: string): Promise<{ data: ImageData; url: string }> {
if (!id.match(/^[a-zA-Z0-9-]+$/)) {
return undefined
}
const cached = PanoramaxImageProvider.knownMeta[id]
if (cached) {
if (new Date().getTime() - cached.time.getTime() < 1000) {
return { data: cached.data, url: undefined }
}
}
@ -120,10 +122,9 @@ export default class PanoramaxImageProvider extends ImageProvider {
if (!Panoramax.isId(value)) {
return undefined
}
return [await this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
return [await this.getInfoFor(value).then((r) => this.featureToImage(<any>r))]
}
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
const source = UIEventSource.FromPromise(super.getRelevantUrlsFor(tags, prefixes))
@ -131,7 +132,10 @@ export default class PanoramaxImageProvider extends ImageProvider {
if (data === undefined) {
return true
}
return data?.some(img => img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken")
return data?.some(
(img) =>
img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken"
)
}
Stores.Chronic(1500, () =>
@ -146,7 +150,10 @@ export default class PanoramaxImageProvider extends ImageProvider {
return Stores.ListStabilized( source)
}
public async DownloadAttribution(providedImage: { url: string; id: string; }): Promise<LicenseInfo> {
public async DownloadAttribution(providedImage: {
url: string
id: string
}): Promise<LicenseInfo> {
const meta = await this.getInfoFor(providedImage.id)
return {
@ -169,9 +176,15 @@ export class PanoramaxUploader implements ImageUploader {
this.panoramax = new AuthorizedPanoramax(url, token)
}
async uploadImage(blob: File, currentGps: [number, number], author: string, noblur: boolean = false, sequenceId?: string ): Promise<{
key: string;
value: string;
async uploadImage(
blob: File,
currentGps: [number, number],
author: string,
noblur: boolean = false,
sequenceId?: string
): Promise<{
key: string
value: string
absoluteUrl: string
}> {
// https://panoramax.openstreetmap.fr/api/docs/swagger#/
@ -195,7 +208,8 @@ export class PanoramaxUploader implements ImageUploader {
const p = this.panoramax
const defaultSequence: {id: string, "stats:items":{count:number}} = (await p.mySequences()).find(s => s.id === (sequenceId ?? "6e702976-580b-419c-8fb3-cf7bd364e6f8"))
const defaultSequence: {id: string, "stats:items":{count:number}} =
(await p.mySequences()).find(s => s.id === Constants.panoramax.sequence)
console.log("Upload options are", lon, lat, datetime, blob)
const img = <ImageData>await p.addImage(blob, defaultSequence, {
lon,
@ -205,7 +219,6 @@ export class PanoramaxUploader implements ImageUploader {
exifOverride: {
Artist: author,
},
})
PanoramaxImageProvider.singleton.addKnownMeta(img)
return {
@ -214,5 +227,4 @@ export class PanoramaxUploader implements ImageUploader {
absoluteUrl: img.assets.hd.href,
}
}
}

View file

@ -11,8 +11,10 @@ export class WikidataImageProvider extends ImageProvider {
public static readonly singleton = new WikidataImageProvider()
public readonly defaultKeyPrefixes = ["wikidata"]
public readonly name = "Wikidata"
private static readonly keyBlacklist: ReadonlySet<string> = new Set(
["mapillary", ...Utils.Times(i => "mapillary:" + i, 10)])
private static readonly keyBlacklist: ReadonlySet<string> = new Set([
"mapillary",
...Utils.Times((i) => "mapillary:" + i, 10),
])
private constructor() {
super()
@ -26,7 +28,7 @@ export class WikidataImageProvider extends ImageProvider {
return new SvelteUIElement(Wikidata_icon)
}
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
if (WikidataImageProvider.keyBlacklist.has(key)) {
return undefined
}

View file

@ -37,7 +37,7 @@ export class WikimediaImageProvider extends ImageProvider {
return value
}
const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value,
value
)}`
if (useHd) {
return baseUrl
@ -106,7 +106,8 @@ export class WikimediaImageProvider extends ImageProvider {
value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("Category:")) {
const urls = await Wikimedia.GetCategoryContents(value)
return urls.filter((url) => url.startsWith("File:"))
return urls
.filter((url) => url.startsWith("File:"))
.map((image) => this.UrlForImage(image))
}
if (value.startsWith("File:")) {
@ -117,7 +118,7 @@ export class WikimediaImageProvider extends ImageProvider {
return undefined
}
// We do a last effort and assume this is a file
return [(this.UrlForImage("File:" + value))]
return [this.UrlForImage("File:" + value)]
}
public async DownloadAttribution(img: { url: string }): Promise<LicenseInfo> {
@ -147,7 +148,7 @@ export class WikimediaImageProvider extends ImageProvider {
console.warn(
"The file",
filename,
"has no usable metedata or license attached... Please fix the license info file yourself!",
"has no usable metedata or license attached... Please fix the license info file yourself!"
)
return undefined
}

View file

@ -33,7 +33,7 @@ export default class ChangeLocationAction extends OsmChangeAction {
meta: {
theme: string
reason: string
},
}
) {
super(id, true)
this.state = state
@ -66,12 +66,10 @@ export default class ChangeLocationAction extends OsmChangeAction {
return [d]
}
const insertIntoWay = new InsertPointIntoWayAction(
lat, lon, this._id, snapToWay, {
allowReuseOfPreviouslyCreatedPoints: false,
reusePointWithinMeters: 0.25,
},
).prepareChangeDescription()
const insertIntoWay = new InsertPointIntoWayAction(lat, lon, this._id, snapToWay, {
allowReuseOfPreviouslyCreatedPoints: false,
reusePointWithinMeters: 0.25,
}).prepareChangeDescription()
return [d, { ...insertIntoWay, meta: d.meta }]
}

View file

@ -38,7 +38,7 @@ export default class CreateNewNodeAction extends OsmCreateAction {
theme: string
changeType: "create" | "import" | null
specialMotivation?: string
},
}
) {
super(null, basicTags !== undefined && basicTags.length > 0)
this._basicTags = basicTags
@ -102,21 +102,12 @@ export default class CreateNewNodeAction extends OsmCreateAction {
return [newPointChange]
}
const change = new InsertPointIntoWayAction(
this._lat,
this._lon,
id,
this._snapOnto,
{
reusePointWithinMeters: this._reusePointDistance,
allowReuseOfPreviouslyCreatedPoints: this._reusePreviouslyCreatedPoint,
},
).prepareChangeDescription()
const change = new InsertPointIntoWayAction(this._lat, this._lon, id, this._snapOnto, {
reusePointWithinMeters: this._reusePointDistance,
allowReuseOfPreviouslyCreatedPoints: this._reusePreviouslyCreatedPoint,
}).prepareChangeDescription()
return [
newPointChange,
{ ...change, meta: this.meta },
]
return [newPointChange, { ...change, meta: this.meta }]
}
private setElementId(id: number) {

View file

@ -2,7 +2,7 @@ import { ChangeDescription } from "./ChangeDescription"
import { GeoOperations } from "../../GeoOperations"
import { OsmWay } from "../OsmObject"
export default class InsertPointIntoWayAction {
export default class InsertPointIntoWayAction {
private readonly _lat: number
private readonly _lon: number
private readonly _idToInsert: number
@ -21,22 +21,19 @@ export default class InsertPointIntoWayAction {
allowReuseOfPreviouslyCreatedPoints?: boolean
reusePointWithinMeters?: number
}
){
) {
this._lat = lat
this._lon = lon
this._idToInsert = idToInsert
this._snapOnto = snapOnto
this._options = options
}
/**
* Tries to create the changedescription of the way where the point is inserted
* Returns `undefined` if inserting failed
*/
public prepareChangeDescription(): Omit<ChangeDescription, "meta"> | undefined {
public prepareChangeDescription(): Omit<ChangeDescription, "meta"> | undefined {
// Project the point onto the way
console.log("Snapping a node onto an existing way...")
const geojson = this._snapOnto.asGeoJson()
@ -59,13 +56,19 @@ export default class InsertPointIntoWayAction {
}
const prev = outerring[index]
if (GeoOperations.distanceBetween(prev, projectedCoor) < this._options.reusePointWithinMeters) {
if (
GeoOperations.distanceBetween(prev, projectedCoor) <
this._options.reusePointWithinMeters
) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index]
reusedPointCoordinates = this._snapOnto.coordinates[index]
}
const next = outerring[index + 1]
if (GeoOperations.distanceBetween(next, projectedCoor) < this._options.reusePointWithinMeters) {
if (
GeoOperations.distanceBetween(next, projectedCoor) <
this._options.reusePointWithinMeters
) {
// We reuse this point instead!
reusedPointId = this._snapOnto.nodes[index + 1]
reusedPointCoordinates = this._snapOnto.coordinates[index + 1]
@ -82,15 +85,13 @@ export default class InsertPointIntoWayAction {
locations.splice(index + 1, 0, [this._lon, this._lat])
ids.splice(index + 1, 0, this._idToInsert)
return {
return {
type: "way",
id: this._snapOnto.id,
changes: {
coordinates: locations,
nodes: ids,
}
},
}
}
}

View file

@ -217,7 +217,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction implements Pr
const url = `${
this.state.osmConnection?._oauth_config?.url ?? "https://api.openstreetmap.org"
}/api/0.6/${this.wayToReplaceId}/full`
const rawData = await Utils.downloadJsonCached<{elements: any[]}>(url, 1000)
const rawData = await Utils.downloadJsonCached<{ elements: any[] }>(url, 1000)
parsed = OsmObject.ParseObjects(rawData.elements)
}
const allNodes = parsed.filter((o) => o.type === "node")

View file

@ -49,11 +49,11 @@ export class Changes {
featureSwitches: {
featureSwitchMorePrivacy?: Store<boolean>
featureSwitchIsTesting?: Store<boolean>
},
osmConnection: OsmConnection,
reportError?: (error: string) => void,
featureProperties?: FeaturePropertiesStore,
historicalUserLocations?: FeatureSource,
}
osmConnection: OsmConnection
reportError?: (error: string) => void
featureProperties?: FeaturePropertiesStore
historicalUserLocations?: FeatureSource
allElements?: IndexedFeatureSource
},
leftRightSensitive: boolean = false,
@ -64,8 +64,11 @@ export class Changes {
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...(this.pendingChanges.data?.map((pch) => pch.id ?? 0) ?? []))
if(isNaN(this._nextId) && state.reportError !== undefined){
state.reportError("Got a NaN as nextID. Pending changes IDs are:" +this.pendingChanges.data?.map(pch => pch?.id).join("."))
if (isNaN(this._nextId) && state.reportError !== undefined) {
state.reportError(
"Got a NaN as nextID. Pending changes IDs are:" +
this.pendingChanges.data?.map((pch) => pch?.id).join(".")
)
this._nextId = -100
}
this.state = state
@ -84,12 +87,12 @@ export class Changes {
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
}
public static createTestObject(): Changes{
public static createTestObject(): Changes {
return new Changes({
osmConnection: new OsmConnection(),
featureSwitches:{
featureSwitchIsTesting: new ImmutableStore(true)
}
featureSwitches: {
featureSwitchIsTesting: new ImmutableStore(true),
},
})
}
@ -849,12 +852,16 @@ export class Changes {
)
// We keep all the refused changes to try them again
this.pendingChanges.setData(refusedChanges.flatMap((c) => c).filter(c => {
if(c.id === null || c.id === undefined){
return false
}
return true
}))
this.pendingChanges.setData(
refusedChanges
.flatMap((c) => c)
.filter((c) => {
if (c.id === null || c.id === undefined) {
return false
}
return true
})
)
} catch (e) {
console.error(
"Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those",

View file

@ -205,12 +205,12 @@ export class ChangesetHandler {
try {
return await this.UploadWithNew(generateChangeXML, openChangeset, extraMetaTags)
} catch (e) {
const req = (<XMLHttpRequest>e)
const req = <XMLHttpRequest>e
if (req.status === 403) {
// Someone got the banhammer
// This is the message that OSM returned, will be something like "you have an important message, go to osm.org"
const msg = req.responseText
alert(msg+"\n\nWe'll take you to openstreetmap.org now")
const msg = req.responseText
alert(msg + "\n\nWe'll take you to openstreetmap.org now")
window.location.replace(this.osmConnection.Backend())
return
}

View file

@ -154,6 +154,7 @@ export class OsmConnection {
console.log("Not authenticated")
}
}
public GetPreference<T extends string = string>(
key: string,
defaultValue: string = undefined,
@ -161,10 +162,10 @@ export class OsmConnection {
prefix?: string
}
): UIEventSource<T | undefined> {
const prefix =options?.prefix ?? "mapcomplete-"
const prefix = options?.prefix ?? "mapcomplete-"
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
}
public getPreference<T extends string = string>(
key: string,
defaultValue: string = undefined,
@ -172,6 +173,7 @@ export class OsmConnection {
): UIEventSource<T | undefined> {
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
}
public OnLoggedIn(action: (userDetails: UserDetails) => void) {
this._onLoggedIn.push(action)
}
@ -534,7 +536,10 @@ export class OsmConnection {
redirect_uri: Utils.runningFromConsole
? "https://mapcomplete.org/land.html"
: window.location.protocol + "//" + window.location.host + "/land.html",
singlepage: true, // We always use 'singlePage', it is the most stable - including in PWA
/* We use 'singlePage' as much as possible, it is the most stable - including in PWA.
* However, this breaks in iframes so we open a popup in that case
*/
singlepage: !this._iframeMode,
auto: true,
apiUrl: this._oauth_config.api_url ?? this._oauth_config.url,
})

View file

@ -5,7 +5,6 @@ import OSMAuthInstance = OSMAuth.osmAuth
import { Utils } from "../../Utils"
export class OsmPreferences {
/**
* A 'cache' of all the preference stores
* @private
@ -39,7 +38,6 @@ export class OsmPreferences {
})
}
private setPreferencesAll(key: string, value: string) {
if (this._allPreferences.data[key] !== value) {
this._allPreferences.data[key] = value
@ -54,11 +52,11 @@ export class OsmPreferences {
}
return this.preferences[key]
}
const pref = this.preferences[key] = new UIEventSource(value, "preference: " + key)
const pref = (this.preferences[key] = new UIEventSource(value, "preference: " + key))
if (value) {
this.setPreferencesAll(key, value)
}
pref.addCallback(v => {
pref.addCallback((v) => {
console.log("Got an update:", key, "--->", v)
this.uploadKvSplit(key, v)
this.setPreferencesAll(key, v)
@ -71,7 +69,7 @@ export class OsmPreferences {
this.seenKeys = Object.keys(prefs)
const legacy = OsmPreferences.getLegacyCombinedItems(prefs)
const merged = OsmPreferences.mergeDict(prefs)
if(Object.keys(legacy).length > 0){
if (Object.keys(legacy).length > 0) {
await this.removeLegacy(legacy)
}
for (const key in merged) {
@ -82,12 +80,7 @@ export class OsmPreferences {
}
}
public getPreference(
key: string,
defaultValue: string = undefined,
prefix?: string,
) {
public getPreference(key: string, defaultValue: string = undefined, prefix?: string) {
return this.getPreferenceSeedFromlocal(key, defaultValue, { prefix })
}
@ -100,27 +93,29 @@ export class OsmPreferences {
key: string,
defaultValue: string = undefined,
options?: {
prefix?: string,
prefix?: string
saveToLocalStorage?: true | boolean
},
}
): UIEventSource<string> {
if (options?.prefix) {
key = options.prefix + key
}
key = key.replace(/[:/"' {}.%\\]/g, "")
const localStorage = LocalStorageSource.get(key) // cached
if (localStorage.data === "null" || localStorage.data === "undefined") {
localStorage.set(undefined)
}
const pref: UIEventSource<string> = this.initPreference(key, localStorage.data ?? defaultValue) // cached
const pref: UIEventSource<string> = this.initPreference(
key,
localStorage.data ?? defaultValue
) // cached
if (this.localStorageInited.has(key)) {
return pref
}
if (options?.saveToLocalStorage ?? true) {
pref.addCallback(v => localStorage.set(v)) // Keep a local copy
pref.addCallback((v) => localStorage.set(v)) // Keep a local copy
}
this.localStorageInited.add(key)
return pref
@ -134,7 +129,7 @@ export class OsmPreferences {
public async removeLegacy(legacyDict: Record<string, string>) {
for (const k in legacyDict) {
const v = legacyDict[k]
console.log("Upgrading legacy preference",k )
console.log("Upgrading legacy preference", k)
await this.removeAllWithPrefix(k)
this.osmConnection.getPreference(k).set(v)
}
@ -148,20 +143,19 @@ export class OsmPreferences {
const newDict = {}
const allKeys: string[] = Object.keys(dict)
const normalKeys = allKeys.filter(k => !k.match(/[a-z-_0-9A-Z]*:[0-9]+/))
const normalKeys = allKeys.filter((k) => !k.match(/[a-z-_0-9A-Z]*:[0-9]+/))
for (const normalKey of normalKeys) {
if (normalKey.match(/-combined-[0-9]*$/) || normalKey.match(/-combined-length$/)) {
// Ignore legacy keys
continue
}
const partKeys = OsmPreferences.keysStartingWith(allKeys, normalKey)
const parts = partKeys.map(k => dict[k])
const parts = partKeys.map((k) => dict[k])
newDict[normalKey] = parts.join("")
}
return newDict
}
/**
* Gets all items which have a 'combined'-string, the legacy long preferences
*
@ -180,7 +174,9 @@ export class OsmPreferences {
public static getLegacyCombinedItems(dict: Record<string, string>): Record<string, string> {
const merged: Record<string, string> = {}
const keys = Object.keys(dict)
const toCheck = Utils.NoNullInplace(Utils.Dedup(keys.map(k => k.match(/(.*)-combined-[0-9]+$/)?.[1])))
const toCheck = Utils.NoNullInplace(
Utils.Dedup(keys.map((k) => k.match(/(.*)-combined-[0-9]+$/)?.[1]))
)
for (const key of toCheck) {
let i = 0
let str = ""
@ -195,7 +191,6 @@ export class OsmPreferences {
return merged
}
/**
* Bulk-downloads all preferences
* @private
@ -221,10 +216,9 @@ export class OsmPreferences {
dict[k] = pref.getAttribute("v")
}
resolve(dict)
},
}
)
})
}
/**
@ -238,7 +232,7 @@ export class OsmPreferences {
*
*/
private static keysStartingWith(allKeys: string[], key: string): string[] {
const keys = allKeys.filter(k => k === key || k.match(new RegExp(key + ":[0-9]+")))
const keys = allKeys.filter((k) => k === key || k.match(new RegExp(key + ":[0-9]+")))
keys.sort()
return keys
}
@ -249,14 +243,12 @@ export class OsmPreferences {
*
*/
private async uploadKvSplit(k: string, v: string) {
if (v === null || v === undefined || v === "" || v === "undefined" || v === "null") {
const keysToDelete = OsmPreferences.keysStartingWith(this.seenKeys, k)
await Promise.all(keysToDelete.map(k => this.deleteKeyDirectly(k)))
await Promise.all(keysToDelete.map((k) => this.deleteKeyDirectly(k)))
return
}
await this.uploadKeyDirectly(k, v.slice(0, 255))
v = v.slice(255)
let i = 0
@ -265,7 +257,6 @@ export class OsmPreferences {
v = v.slice(255)
i++
}
}
/**
@ -283,25 +274,23 @@ export class OsmPreferences {
return
}
return new Promise<void>((resolve, reject) => {
this.auth.xhr(
{
method: "DELETE",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
headers: { "Content-Type": "text/plain" },
},
(error) => {
if (error) {
console.warn("Could not remove preference", error)
reject(error)
return
}
console.debug("Preference ", k, "removed!")
resolve()
},
)
},
)
this.auth.xhr(
{
method: "DELETE",
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
headers: { "Content-Type": "text/plain" },
},
(error) => {
if (error) {
console.warn("Could not remove preference", error)
reject(error)
return
}
console.debug("Preference ", k, "removed!")
resolve()
}
)
})
}
/**
@ -328,7 +317,6 @@ export class OsmPreferences {
}
return new Promise<void>((resolve, reject) => {
this.auth.xhr(
{
method: "PUT",
@ -343,7 +331,7 @@ export class OsmPreferences {
return
}
resolve()
},
}
)
})
}
@ -351,12 +339,10 @@ export class OsmPreferences {
async removeAllWithPrefix(prefix: string) {
const keys = this.seenKeys
for (const key of keys) {
if(!key.startsWith(prefix)){
if (!key.startsWith(prefix)) {
continue
}
await this.deleteKeyDirectly(key)
}
}
}

View file

@ -1,4 +1,8 @@
import GeocodingProvider, { SearchResult, GeocodingOptions, GeocodeResult } from "./GeocodingProvider"
import GeocodingProvider, {
SearchResult,
GeocodingOptions,
GeocodeResult,
} from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { Store, Stores } from "../UIEventSource"
@ -8,7 +12,7 @@ export default class CombinedSearcher implements GeocodingProvider {
constructor(...providers: ReadonlyArray<GeocodingProvider>) {
this._providers = Utils.NoNull(providers)
this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined)
this._providersWithSuggest = this._providers.filter((pr) => pr.suggest !== undefined)
}
/**
@ -21,12 +25,10 @@ export default class CombinedSearcher implements GeocodingProvider {
const results: GeocodeResult[] = []
const seenIds = new Set<string>()
for (const geocodedElement of geocoded) {
if(geocodedElement === undefined){
if (geocodedElement === undefined) {
continue
}
for (const entry of geocodedElement) {
if (entry.osm_id === undefined) {
throw "Invalid search result: a search result always must have an osm_id to be able to merge results from different sources"
}
@ -42,14 +44,13 @@ export default class CombinedSearcher implements GeocodingProvider {
}
async search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
const results = (await Promise.all(this._providers.map(pr => pr.search(query, options))))
const results = await Promise.all(this._providers.map((pr) => pr.search(query, options)))
return CombinedSearcher.merge(results)
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return Stores.concat(
this._providersWithSuggest.map(pr => pr.suggest(query, options)))
.map(gcrss => CombinedSearcher.merge(gcrss))
this._providersWithSuggest.map((pr) => pr.suggest(query, options))
).map((gcrss) => CombinedSearcher.merge(gcrss))
}
}

View file

@ -23,7 +23,6 @@ export default class CoordinateSearch implements GeocodingProvider {
/lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/lng[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
]
/**
@ -70,8 +69,9 @@ export default class CoordinateSearch implements GeocodingProvider {
* results[0] // => {lat: 51.047977, lon: 3.51184, "display_name": "lon: 3.51184, lat: 51.047977", "category": "coordinate","osm_id": "3.51184/51.047977", "source": "coordinate:latlon"}
*/
private directSearch(query: string): GeocodeResult[] {
const matches = Utils.NoNull(CoordinateSearch.latLonRegexes.map(r => query.match(r)))
.map(m => CoordinateSearch.asResult(m[2], m[1], "latlon"))
const matches = Utils.NoNull(CoordinateSearch.latLonRegexes.map((r) => query.match(r))).map(
(m) => CoordinateSearch.asResult(m[2], m[1], "latlon")
)
const matchesLonLat = Utils.NoNull(CoordinateSearch.lonLatRegexes.map(r => query.match(r)))
.map(m => CoordinateSearch.asResult(m[1], m[2], "lonlat"))
@ -90,7 +90,7 @@ export default class CoordinateSearch implements GeocodingProvider {
}
private static round6(n: number): string {
return "" + (Math.round(n * 1000000) / 1000000)
return "" + Math.round(n * 1000000) / 1000000
}
private static asResult(lonIn: string, latIn: string, source: string): GeocodeResult {
@ -118,5 +118,4 @@ export default class CoordinateSearch implements GeocodingProvider {
async search(query: string): Promise<GeocodeResult[]> {
return this.directSearch(query)
}
}

View file

@ -6,16 +6,20 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayerState from "../State/LayerState"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
export type FilterSearchResult = { option: FilterConfigOption, filter: FilterConfig, layer: LayerConfig, index: number }
export type FilterSearchResult = {
option: FilterConfigOption
filter: FilterConfig
layer: LayerConfig
index: number
}
/**
* Searches matching filters
*/
export default class FilterSearch {
private readonly _state: {layerState: LayerState, theme: ThemeConfig}
private readonly _state: { layerState: LayerState; theme: ThemeConfig }
constructor(state: {layerState: LayerState, theme: ThemeConfig}) {
constructor(state: { layerState: LayerState; theme: ThemeConfig }) {
this._state = state
}
@ -23,12 +27,15 @@ export default class FilterSearch {
if (query.length === 0) {
return []
}
const queries = query.split(" ").map(query => {
if (!Utils.isEmoji(query)) {
return Utils.simplifyStringForSearch(query)
}
return query
}).filter(q => q.length > 0)
const queries = query
.split(" ")
.map((query) => {
if (!Utils.isEmoji(query)) {
return Utils.simplifyStringForSearch(query)
}
return query
})
.filter((q) => q.length > 0)
const possibleFilters: FilterSearchResult[] = []
for (const layer of this._state.theme.layers) {
if (!Array.isArray(layer.filters)) {
@ -46,29 +53,36 @@ export default class FilterSearch {
if (!option.osmTags) {
continue
}
if(option.fields.length > 0){
if (option.fields.length > 0) {
// Filters with a search field are not supported as of now, see #2141
continue
}
let terms = ([option.question.txt,
...(option.searchTerms?.[Locale.language.data] ?? option.searchTerms?.["en"] ?? [])]
.flatMap(term => [term, ...(term?.split(" ") ?? [])]))
terms = terms.map(t => Utils.simplifyStringForSearch(t))
let terms = [
option.question.txt,
...(option.searchTerms?.[Locale.language.data] ??
option.searchTerms?.["en"] ??
[]),
].flatMap((term) => [term, ...(term?.split(" ") ?? [])])
terms = terms.map((t) => Utils.simplifyStringForSearch(t))
terms.push(option.emoji)
Utils.NoNullInplace(terms)
const distances = queries.flatMap(query => terms.map(entry => {
const d = Utils.levenshteinDistance(query, entry.slice(0, query.length))
const dRelative = d / query.length
return dRelative
}))
const distances = queries.flatMap((query) =>
terms.map((entry) => {
const d = Utils.levenshteinDistance(query, entry.slice(0, query.length))
const dRelative = d / query.length
return dRelative
})
)
const levehnsteinD = Math.min(...distances)
if (levehnsteinD > 0.25) {
continue
}
possibleFilters.push({
option, layer, filter, index:
i,
option,
layer,
filter,
index: i,
})
}
}
@ -85,7 +99,7 @@ export default class FilterSearch {
if (!Array.isArray(filteredLayer.layerDef.filters)) {
continue
}
if (Constants.priviliged_layers.indexOf(<any> id) >= 0) {
if (Constants.priviliged_layers.indexOf(<any>id) >= 0) {
continue
}
for (const filter of filteredLayer.layerDef.filters) {
@ -116,13 +130,16 @@ export default class FilterSearch {
* Note that this depends on the language and the displayed text. For example, two filters {"en": "A", "nl": "B"} and {"en": "X", "nl": "B"} will be joined for dutch but not for English
*
*/
static mergeSemiIdenticalLayers<T extends FilterSearchResult = FilterSearchResult>(filters: ReadonlyArray<T>, language: string):T[][] {
const results : Record<string, T[]> = {}
static mergeSemiIdenticalLayers<T extends FilterSearchResult = FilterSearchResult>(
filters: ReadonlyArray<T>,
language: string
): T[][] {
const results: Record<string, T[]> = {}
for (const filter of filters) {
const txt = filter.option.question.textFor(language)
if(results[txt]){
if (results[txt]) {
results[txt].push(filter)
}else{
} else {
results[txt] = [filter]
}
}

View file

@ -7,8 +7,8 @@ export default class GeocodingFeatureSource implements FeatureSource {
public features: Store<Feature<Geometry, Record<string, string>>[]>
constructor(provider: Store<SearchResult[]>) {
this.features = provider.map(geocoded => {
if(geocoded === undefined){
this.features = provider.map((geocoded) => {
if (geocoded === undefined) {
return []
}
const features: Feature[] = []
@ -28,18 +28,16 @@ export default class GeocodingFeatureSource implements FeatureSource {
osm_id: gc.osm_type + "/" + gc.osm_id,
osm_key: gc.feature?.properties?.osm_key,
osm_value: gc.feature?.properties?.osm_value,
source: gc.source
source: gc.source,
},
geometry: {
type: "Point",
coordinates: [gc.lon, gc.lat]
}
coordinates: [gc.lon, gc.lat],
},
})
}
return features
})
}
}

View file

@ -8,7 +8,7 @@ import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import { GeoOperations } from "../GeoOperations"
export type GeocodingCategory =
"coordinate"
| "coordinate"
| "city"
| "house"
| "street"
@ -19,7 +19,7 @@ export type GeocodingCategory =
| "airport"
| "shop"
export type GeocodeResult = {
export type GeocodeResult = {
/**
* The name of the feature being displayed
*/
@ -27,8 +27,8 @@ export type GeocodeResult = {
/**
* Some optional, extra information
*/
description?: string | Promise<string>,
feature?: Feature,
description?: string | Promise<string>
feature?: Feature
lat: number
lon: number
/**
@ -37,22 +37,18 @@ export type GeocodeResult = {
*/
boundingbox?: number[]
osm_type?: "node" | "way" | "relation"
osm_id: string,
category?: GeocodingCategory,
payload?: object,
osm_id: string
category?: GeocodingCategory
payload?: object
source?: string
}
export type SearchResult =
| GeocodeResult
export type SearchResult = GeocodeResult
export interface GeocodingOptions {
bbox?: BBox
}
export default interface GeocodingProvider {
search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]>
/**
@ -62,26 +58,28 @@ export default interface GeocodingProvider {
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]>
}
export type ReverseGeocodingResult = Feature<Geometry, {
osm_id: number,
osm_type: "node" | "way" | "relation",
country: string,
city: string,
countrycode: string,
type: GeocodingCategory,
street: string
}>
export type ReverseGeocodingResult = Feature<
Geometry,
{
osm_id: number
osm_type: "node" | "way" | "relation"
country: string
city: string
countrycode: string
type: GeocodingCategory
street: string
}
>
export interface ReverseGeocodingProvider {
reverseSearch(
coordinate: { lon: number; lat: number },
zoom: number,
language?: string,
): Promise<ReverseGeocodingResult[]>;
language?: string
): Promise<ReverseGeocodingResult[]>
}
export class GeocodingUtils {
public static searchLayer = GeocodingUtils.initSearchLayer()
private static initSearchLayer(): LayerConfig {
@ -103,16 +101,14 @@ export class GeocodingUtils {
train_station: 14,
airport: 13,
shop: 16,
}
public static mergeSimilarResults(results: GeocodeResult[]){
public static mergeSimilarResults(results: GeocodeResult[]) {
const byName: Record<string, GeocodeResult[]> = {}
for (const result of results) {
const nm = result.display_name
if(!byName[nm]) {
if (!byName[nm]) {
byName[nm] = []
}
byName[nm].push(result)
@ -123,11 +119,13 @@ export class GeocodingUtils {
const options = byName[nm]
const added = options[0]
merged.push(added)
const centers: [number,number][] = [[added.lon, added.lat]]
const centers: [number, number][] = [[added.lon, added.lat]]
for (const other of options) {
const otherCenter:[number,number] = [other.lon, other.lat]
const nearbyFound= centers.some(center => GeoOperations.distanceBetween(center, otherCenter) < 500)
if(!nearbyFound){
const otherCenter: [number, number] = [other.lon, other.lat]
const nearbyFound = centers.some(
(center) => GeoOperations.distanceBetween(center, otherCenter) < 500
)
if (!nearbyFound) {
merged.push(other)
centers.push(otherCenter)
}
@ -136,7 +134,6 @@ export class GeocodingUtils {
return merged
}
public static categoryToIcon: Record<GeocodingCategory, DefaultPinIcon> = {
city: "building_office_2",
coordinate: "globe_alt",
@ -148,8 +145,5 @@ export class GeocodingUtils {
county: "building_office_2",
airport: "airport",
shop: "building_storefront",
}
}

View file

@ -5,22 +5,26 @@ import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Utils } from "../../Utils"
export default class LayerSearch {
private readonly _theme: ThemeConfig
private readonly _layerWhitelist: Set<string>
constructor(theme: ThemeConfig) {
this._theme = theme
this._layerWhitelist = new Set(theme.layers
.filter(l => l.isNormal())
.map(l => l.id))
this._layerWhitelist = new Set(theme.layers.filter((l) => l.isNormal()).map((l) => l.id))
}
static scoreLayers(query: string, options: {
whitelist?: Set<string>, blacklist?: Set<string>
}): Record<string, number> {
static scoreLayers(
query: string,
options: {
whitelist?: Set<string>
blacklist?: Set<string>
}
): Record<string, number> {
const result: Record<string, number> = {}
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
const queryParts = query
.trim()
.split(" ")
.map((q) => Utils.simplifyStringForSearch(q))
for (const id in ThemeSearch.officialThemes.layers) {
if (options?.whitelist && !options?.whitelist.has(id)) {
continue
@ -29,18 +33,17 @@ export default class LayerSearch {
continue
}
const keywords = ThemeSearch.officialThemes.layers[id]
result[id] = Math.min(...queryParts.map(q => SearchUtils.scoreKeywords(q, keywords)))
result[id] = Math.min(...queryParts.map((q) => SearchUtils.scoreKeywords(q, keywords)))
}
return result
}
public search(query: string, limit: number, scoreThreshold: number = 2): LayerConfig[] {
if (query.length < 1) {
return []
}
const scores = LayerSearch.scoreLayers(query, { whitelist: this._layerWhitelist })
const asList: ({ layer: LayerConfig, score: number })[] = []
const asList: { layer: LayerConfig; score: number }[] = []
for (const layer in scores) {
asList.push({
layer: this._theme.getLayer(layer),
@ -50,10 +53,8 @@ export default class LayerSearch {
asList.sort((a, b) => a.score - b.score)
return asList
.filter(sorted => sorted.score < scoreThreshold)
.filter((sorted) => sorted.score < scoreThreshold)
.slice(0, limit)
.map(l => l.layer)
.map((l) => l.layer)
}
}

View file

@ -7,14 +7,14 @@ import { ImmutableStore, Store, Stores } from "../UIEventSource"
import OpenStreetMapIdSearch from "./OpenStreetMapIdSearch"
type IntermediateResult = {
feature: Feature,
feature: Feature
/**
* Lon, lat
*/
center: [number, number],
levehnsteinD: number,
physicalDistance: number,
searchTerms: string[],
center: [number, number]
levehnsteinD: number
physicalDistance: number
searchTerms: string[]
description: string
}
export default class LocalElementSearch implements GeocodingProvider {
@ -24,37 +24,50 @@ export default class LocalElementSearch implements GeocodingProvider {
constructor(state: ThemeViewState, limit: number) {
this._state = state
this._limit = limit
}
async search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
return this.searchEntries(query, options, false).data
}
private getPartialResult(query: string, candidateId: string | undefined, matchStart: boolean, centerpoint: [number, number], features: Feature[]): IntermediateResult[] {
const results: IntermediateResult [] = []
private getPartialResult(
query: string,
candidateId: string | undefined,
matchStart: boolean,
centerpoint: [number, number],
features: Feature[]
): IntermediateResult[] {
const results: IntermediateResult[] = []
for (const feature of features) {
const props = feature.properties
const searchTerms: string[] = Utils.NoNull([props.name, props.alt_name, props.local_name,
(props["addr:street"] && props["addr:number"]) ?
props["addr:street"] + props["addr:number"] : undefined])
const searchTerms: string[] = Utils.NoNull([
props.name,
props.alt_name,
props.local_name,
props["addr:street"] && props["addr:number"]
? props["addr:street"] + props["addr:number"]
: undefined,
])
let levehnsteinD: number
if (candidateId === props.id) {
levehnsteinD = 0
} else {
levehnsteinD = Math.min(...searchTerms.flatMap(entry => entry.split(/ /)).map(entry => {
let simplified = Utils.simplifyStringForSearch(entry)
if (matchStart) {
simplified = simplified.slice(0, query.length)
}
return Utils.levenshteinDistance(query, simplified)
}))
levehnsteinD = Math.min(
...searchTerms
.flatMap((entry) => entry.split(/ /))
.map((entry) => {
let simplified = Utils.simplifyStringForSearch(entry)
if (matchStart) {
simplified = simplified.slice(0, query.length)
}
return Utils.levenshteinDistance(query, simplified)
})
)
}
const center = GeoOperations.centerpointCoordinates(feature)
if ((levehnsteinD / query.length) <= 0.3) {
if (levehnsteinD / query.length <= 0.3) {
let description = ""
if (feature.properties["addr:street"]) {
description += "" + feature.properties["addr:street"]
@ -75,7 +88,11 @@ export default class LocalElementSearch implements GeocodingProvider {
return results
}
searchEntries(query: string, options?: GeocodingOptions, matchStart?: boolean): Store<SearchResult[]> {
searchEntries(
query: string,
options?: GeocodingOptions,
matchStart?: boolean
): Store<SearchResult[]> {
if (query.length < 3) {
return new ImmutableStore([])
}
@ -88,17 +105,26 @@ export default class LocalElementSearch implements GeocodingProvider {
const partials: Store<IntermediateResult[]>[] = []
for (const [_, geoIndexedStore] of properties) {
const partialResult = geoIndexedStore.features.map(features => this.getPartialResult(query, candidateId, matchStart, centerPoint, features))
const partialResult = geoIndexedStore.features.map((features) =>
this.getPartialResult(query, candidateId, matchStart, centerPoint, features)
)
partials.push(partialResult)
}
const listed: Store<IntermediateResult[]> = Stores.concat(partials).map(l => l.flatMap(x => x))
return listed.mapD(results => {
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
const listed: Store<IntermediateResult[]> = Stores.concat(partials).map((l) =>
l.flatMap((x) => x)
)
return listed.mapD((results) => {
results.sort(
(a, b) =>
a.physicalDistance +
a.levehnsteinD * 25 -
(b.physicalDistance + b.levehnsteinD * 25)
)
if (this._limit) {
results = results.slice(0, this._limit)
}
return results.map(entry => {
return results.map((entry) => {
const [osm_type, osm_id] = entry.feature.properties.id.split("/")
return <SearchResult>{
lon: entry.center[0],
@ -113,12 +139,9 @@ export default class LocalElementSearch implements GeocodingProvider {
}
})
})
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return this.searchEntries(query, options, true)
}
}

View file

@ -6,26 +6,24 @@ import Locale from "../../UI/i18n/Locale"
import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider"
export class NominatimGeocoding implements GeocodingProvider {
private readonly _host ;
private readonly _host
private readonly limit: number
constructor(limit: number = 3, host: string = Constants.nominatimEndpoint) {
constructor(limit: number = 3, host: string = Constants.nominatimEndpoint) {
this.limit = limit
this._host = host
}
public search(query: string, options?:GeocodingOptions): Promise<SearchResult[]> {
public search(query: string, options?: GeocodingOptions): Promise<SearchResult[]> {
const b = options?.bbox ?? BBox.global
const url = `${
this._host
}search?format=json&limit=${this.limit}&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${
const url = `${this._host}search?format=json&limit=${
this.limit
}&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${
Locale.language.data
}&q=${query}`
return Utils.downloadJson(url)
}
async reverseSearch(
coordinate: { lon: number; lat: number },
zoom: number = 17,

View file

@ -5,12 +5,13 @@ import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils"
export default class OpenStreetMapIdSearch implements GeocodingProvider {
private static readonly regex = /((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/
private static readonly regex =
/((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/
private static readonly types: Readonly<Record<string, "node" | "way" | "relation">> = {
"n": "node",
"w": "way",
"r": "relation",
n: "node",
w: "way",
r: "relation",
}
private readonly _state: SpecialVisualizationState
@ -55,15 +56,17 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider {
category: "coordinate",
osm_type: <"node" | "way" | "relation">osm_type,
osm_id,
lat: 0, lon: 0,
lat: 0,
lon: 0,
source: "osmid",
}
}
const [lat, lon] = obj.centerpoint()
return {
lat, lon,
display_name: obj.tags.name ?? obj.tags.alt_name ?? obj.tags.local_name ?? obj.tags.ref ?? id,
lat,
lon,
display_name:
obj.tags.name ?? obj.tags.alt_name ?? obj.tags.local_name ?? obj.tags.ref ?? id,
description: osm_type,
osm_type: <"node" | "way" | "relation">osm_type,
osm_id,
@ -72,14 +75,15 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider {
}
async search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> {
if (!isNaN(Number(query))) {
const n = Number(query)
return Utils.NoNullInplace(await Promise.all([
this.getInfoAbout(`node/${n}`).catch(x => undefined),
this.getInfoAbout(`way/${n}`).catch(x => undefined),
this.getInfoAbout(`relation/${n}`).catch(() => undefined),
]))
return Utils.NoNullInplace(
await Promise.all([
this.getInfoAbout(`node/${n}`).catch((x) => undefined),
this.getInfoAbout(`way/${n}`).catch((x) => undefined),
this.getInfoAbout(`relation/${n}`).catch(() => undefined),
])
)
}
const id = OpenStreetMapIdSearch.extractId(query)
@ -92,5 +96,4 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider {
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return UIEventSource.FromPromise(this.search(query, options))
}
}

View file

@ -2,7 +2,8 @@ import Constants from "../../Models/Constants"
import GeocodingProvider, {
GeocodeResult,
GeocodingCategory,
GeocodingOptions, GeocodingUtils,
GeocodingOptions,
GeocodingUtils,
ReverseGeocodingProvider,
ReverseGeocodingResult,
} from "./GeocodingProvider"
@ -16,15 +17,14 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
private readonly _endpoint: string
private supportedLanguages = ["en", "de", "fr"]
private static readonly types = {
"R": "relation",
"W": "way",
"N": "node",
R: "relation",
W: "way",
N: "node",
}
private readonly ignoreBounds: boolean
private readonly suggestionLimit: number = 5
private readonly searchLimit: number = 1
constructor(ignoreBounds: boolean = false, suggestionLimit:number = 5, searchLimit:number = 1, endpoint?: string) {
this.ignoreBounds = ignoreBounds
this.suggestionLimit = suggestionLimit
@ -32,17 +32,22 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
this._endpoint = endpoint ?? Constants.photonEndpoint ?? "https://photon.komoot.io/"
}
async reverseSearch(coordinate: {
lon: number;
lat: number
}, zoom: number, language?: string): Promise<ReverseGeocodingResult[]> {
const url = `${this._endpoint}/reverse?lon=${coordinate.lon}&lat=${coordinate.lat}&${this.getLanguage(language)}`
async reverseSearch(
coordinate: {
lon: number
lat: number
},
zoom: number,
language?: string
): Promise<ReverseGeocodingResult[]> {
const url = `${this._endpoint}/reverse?lon=${coordinate.lon}&lat=${
coordinate.lat
}&${this.getLanguage(language)}`
const result = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
for (const f of result.features) {
f.properties.osm_type = PhotonSearch.types[f.properties.osm_type]
}
return <ReverseGeocodingResult[]>result.features
}
/**
@ -51,13 +56,11 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
* @private
*/
private getLanguage(language?: string): string {
language ??= Locale.language.data
if (this.supportedLanguages.indexOf(language) < 0) {
return ""
}
return `&lang=${language}`
}
suggest(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
@ -77,7 +80,6 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
switch (type) {
case "house": {
const addr = ifdef("", p.street) + ifdef(" ", p.housenumber)
if (!addr) {
return p.city
@ -96,7 +98,6 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
case "country":
return undefined
}
}
private getCategory(entry: Feature) {
@ -123,9 +124,11 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
const [lon, lat] = options.bbox.center()
bbox = `&lon=${lon}&lat=${lat}`
}
const url = `${this._endpoint}/api/?q=${encodeURIComponent(query)}&limit=${limit}${this.getLanguage()}${bbox}`
const url = `${this._endpoint}/api/?q=${encodeURIComponent(
query
)}&limit=${limit}${this.getLanguage()}${bbox}`
const results = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
const encoded= results.features.map(f => {
const encoded = results.features.map((f) => {
const [lon, lat] = GeoOperations.centerpointCoordinates(f)
let boundingbox: number[] = undefined
if (f.properties.extent) {
@ -140,11 +143,11 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
osm_type: PhotonSearch.types[f.properties.osm_type],
category: this.getCategory(f),
boundingbox,
lon, lat,
lon,
lat,
source: this._endpoint,
}
})
return GeocodingUtils.mergeSimilarResults(encoded)
}
}

View file

@ -3,13 +3,11 @@ import { Utils } from "../../Utils"
import ThemeSearch from "./ThemeSearch"
export default class SearchUtils {
/** Applies special search terms, such as 'studio', 'osmcha', ...
* Returns 'false' if nothing is matched.
* Doesn't return control flow if a match is found (navigates to another page in this case)
*/
public static applySpecialSearch(searchTerm: string, ) {
public static applySpecialSearch(searchTerm: string) {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return false
@ -44,10 +42,8 @@ export default class SearchUtils {
return true
}
return false
}
/**
* Searches for the smallest distance in words; will split both the query and the terms
*
@ -55,19 +51,26 @@ export default class SearchUtils {
* SearchUtils.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
*
*/
public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number {
if(!keywords){
public static scoreKeywords(
query: string,
keywords: Record<string, string[]> | string[],
language?: string
): number {
if (!keywords) {
return Infinity
}
language ??= Locale.language.data
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
const queryParts = query
.trim()
.split(" ")
.map((q) => Utils.simplifyStringForSearch(q))
let terms: string[]
if (Array.isArray(keywords)) {
terms = keywords
} else {
terms = (keywords[language] ?? []).concat(keywords["*"])
}
const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" "))
const termsAll = Utils.NoNullInplace(terms).flatMap((t) => t.split(" "))
let distanceSummed = 0
for (let i = 0; i < queryParts.length; i++) {

View file

@ -8,57 +8,59 @@ import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
import { OsmConnection } from "../Osm/OsmConnection"
type ThemeSearchScore = {
theme: MinimalThemeInformation,
lowest: number,
perLayer?: Record<string, number>,
other: number,
theme: MinimalThemeInformation
lowest: number
perLayer?: Record<string, number>
other: number
}
export default class ThemeSearch {
public static readonly officialThemes: {
themes: MinimalThemeInformation[],
themes: MinimalThemeInformation[]
layers: Record<string, Record<string, string[]>>
} = <any> themeOverview
public static readonly officialThemesById: Map<string, MinimalThemeInformation> = new Map<string, MinimalThemeInformation>()
} = <any>themeOverview
public static readonly officialThemesById: Map<string, MinimalThemeInformation> = new Map<
string,
MinimalThemeInformation
>()
static {
for (const th of ThemeSearch.officialThemes.themes ?? []) {
ThemeSearch.officialThemesById.set(th.id, th)
}
}
private readonly _knownHiddenThemes: Store<Set<string>>
private readonly _layersToIgnore: string[]
private readonly _otherThemes: MinimalThemeInformation[]
constructor(state: {osmConnection: OsmConnection, theme: ThemeConfig}) {
this._layersToIgnore = state.theme.layers.filter(l => l.isNormal()).map(l => l.id)
this._knownHiddenThemes = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection).map(list => new Set(list))
this._otherThemes = ThemeSearch.officialThemes.themes
.filter(th => th.id !== state.theme.id)
constructor(state: { osmConnection: OsmConnection; theme: ThemeConfig }) {
this._layersToIgnore = state.theme.layers.filter((l) => l.isNormal()).map((l) => l.id)
this._knownHiddenThemes = UserRelatedState.initDiscoveredHiddenThemes(
state.osmConnection
).map((list) => new Set(list))
this._otherThemes = ThemeSearch.officialThemes.themes.filter(
(th) => th.id !== state.theme.id
)
}
public search(query: string, limit: number, threshold: number = 3): MinimalThemeInformation[] {
if (query.length < 1) {
return []
}
const sorted = ThemeSearch.sortedByLowestScores(query, this._otherThemes, this._layersToIgnore)
const sorted = ThemeSearch.sortedByLowestScores(
query,
this._otherThemes,
this._layersToIgnore
)
return sorted
.filter(sorted => sorted.lowest < threshold)
.map(th => th.theme)
.filter(th => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id))
.filter((sorted) => sorted.lowest < threshold)
.map((th) => th.theme)
.filter((th) => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id))
.slice(0, limit)
}
public static createUrlFor(
layout: { id: string },
state?: { layoutToUse?: { id } },
): string {
public static createUrlFor(layout: { id: string }, state?: { layoutToUse?: { id } }): string {
if (layout === undefined) {
return undefined
}
@ -88,7 +90,6 @@ export default class ThemeSearch {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
return `${linkPrefix}`
}
@ -101,17 +102,21 @@ export default class ThemeSearch {
* @param ignoreLayers
* @private
*/
private static scoreThemes(query: string, themes: MinimalThemeInformation[], ignoreLayers: string[] = undefined): Record<string, ThemeSearchScore> {
private static scoreThemes(
query: string,
themes: MinimalThemeInformation[],
ignoreLayers: string[] = undefined
): Record<string, ThemeSearchScore> {
if (query?.length < 1) {
return undefined
}
themes = Utils.NoNullInplace(themes)
let options : {blacklist: Set<string>} = undefined
if(ignoreLayers?.length > 0){
let options: { blacklist: Set<string> } = undefined
if (ignoreLayers?.length > 0) {
options = { blacklist: new Set(ignoreLayers) }
}
const layerScores = query.length < 3 ? {} : LayerSearch.scoreLayers(query, options)
const layerScores = query.length < 3 ? {} : LayerSearch.scoreLayers(query, options)
const results: Record<string, ThemeSearchScore> = {}
for (const layoutInfo of themes) {
const theme = layoutInfo.id
@ -122,20 +127,22 @@ export default class ThemeSearch {
results[theme] = {
theme: layoutInfo,
lowest: -1,
other: 0
other: 0,
}
continue
}
const perLayer = Utils.asRecord(
layoutInfo.layers ?? [], layer => layerScores[layer],
)
const perLayer = Utils.asRecord(layoutInfo.layers ?? [], (layer) => layerScores[layer])
const language = Locale.language.data
const keywords = Utils.NoNullInplace([layoutInfo.shortDescription, layoutInfo.title])
.map(item => typeof item === "string" ? item : (item[language] ?? item["*"]))
const keywords = Utils.NoNullInplace([
layoutInfo.shortDescription,
layoutInfo.title,
]).map((item) => (typeof item === "string" ? item : item[language] ?? item["*"]))
const other = Math.min(SearchUtils.scoreKeywords(query, keywords), SearchUtils.scoreKeywords(query, layoutInfo.keywords))
const other = Math.min(
SearchUtils.scoreKeywords(query, keywords),
SearchUtils.scoreKeywords(query, layoutInfo.keywords)
)
const lowest = Math.min(other, ...Object.values(perLayer))
results[theme] = {
theme: layoutInfo,
@ -147,15 +154,21 @@ export default class ThemeSearch {
return results
}
public static sortedByLowestScores(search: string, themes: MinimalThemeInformation[], ignoreLayers: string[] = []): ThemeSearchScore[] {
public static sortedByLowestScores(
search: string,
themes: MinimalThemeInformation[],
ignoreLayers: string[] = []
): ThemeSearchScore[] {
const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers))
scored.sort((a, b) => a.lowest - b.lowest)
return scored
}
public static sortedByLowest(search: string, themes: MinimalThemeInformation[], ignoreLayers: string[] = []): MinimalThemeInformation[] {
return this.sortedByLowestScores(search, themes, ignoreLayers)
.map(th => th.theme)
public static sortedByLowest(
search: string,
themes: MinimalThemeInformation[],
ignoreLayers: string[] = []
): MinimalThemeInformation[] {
return this.sortedByLowestScores(search, themes, ignoreLayers).map((th) => th.theme)
}
}

View file

@ -45,7 +45,6 @@ export class OsmConnectionFeatureSwitches {
}
export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
public readonly featureSwitchEnableLogin: UIEventSource<boolean>
public readonly featureSwitchSearch: UIEventSource<boolean>
public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>

View file

@ -11,8 +11,8 @@ import FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import Constants from "../../Models/Constants"
export type ActiveFilter = {
layer: LayerConfig,
filter: FilterConfig,
layer: LayerConfig
filter: FilterConfig
control: UIEventSource<string | number | undefined>
}
/**
@ -36,9 +36,13 @@ export default class LayerState {
private readonly _activeFilters: UIEventSource<ActiveFilter[]> = new UIEventSource([])
public readonly activeFilters: Store<ActiveFilter[]> = this._activeFilters
private readonly _activeLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(undefined)
private readonly _activeLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<
FilteredLayer[]
>(undefined)
public readonly activeLayers: Store<FilteredLayer[]> = this._activeLayers
private readonly _nonactiveLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(undefined)
private readonly _nonactiveLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<
FilteredLayer[]
>(undefined)
public readonly nonactiveLayers: Store<FilteredLayer[]> = this._nonactiveLayers
private readonly osmConnection: OsmConnection
@ -71,7 +75,7 @@ export default class LayerState {
this.filteredLayers = filteredLayers
layers.forEach((l) => LayerState.linkFilterStates(l, filteredLayers))
this.filteredLayers.forEach(fl => {
this.filteredLayers.forEach((fl) => {
fl.isDisplayed.addCallback(() => this.updateActiveFilters())
for (const [_, appliedFilter] of fl.appliedFilters) {
appliedFilter.addCallback(() => this.updateActiveFilters())
@ -80,27 +84,27 @@ export default class LayerState {
this.updateActiveFilters()
}
private updateActiveFilters(){
private updateActiveFilters() {
const filters: ActiveFilter[] = []
const activeLayers: FilteredLayer[] = []
const nonactiveLayers: FilteredLayer[] = []
this.filteredLayers.forEach(fl => {
if(!fl.isDisplayed.data){
const nonactiveLayers: FilteredLayer[] = []
this.filteredLayers.forEach((fl) => {
if (!fl.isDisplayed.data) {
nonactiveLayers.push(fl)
return
}
activeLayers.push(fl)
if(fl.layerDef.filterIsSameAs){
if (fl.layerDef.filterIsSameAs) {
return
}
for (const [filtername, appliedFilter] of fl.appliedFilters) {
if (appliedFilter.data === undefined) {
continue
}
const filter = fl.layerDef.filters.find(f => f.id === filtername)
if(typeof appliedFilter.data === "number"){
if(filter.options[appliedFilter.data].osmTags === undefined){
const filter = fl.layerDef.filters.find((f) => f.id === filtername)
if (typeof appliedFilter.data === "number") {
if (filter.options[appliedFilter.data].osmTags === undefined) {
// This is probably the first, generic option which doesn't _actually_ filter
continue
}

View file

@ -18,7 +18,6 @@ import { Feature } from "geojson"
import OpenLocationCodeSearch from "../Search/OpenLocationCodeSearch"
export default class SearchState {
public readonly feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
public readonly searchTerm: UIEventSource<string> = new UIEventSource<string>("")
public readonly searchIsFocused = new UIEventSource(false)
@ -47,53 +46,62 @@ export default class SearchState {
]
const bounds = state.mapProperties.bounds
const suggestionsList = this.searchTerm.stabilized(250).mapD(search => {
const suggestionsList = this.searchTerm.stabilized(250).mapD(
(search) => {
if (search.length === 0) {
return undefined
}
return this.locationSearchers.map(ls => ls.suggest(search, { bbox: bounds.data }))
}, [bounds]
return this.locationSearchers.map((ls) => ls.suggest(search, { bbox: bounds.data }))
},
[bounds]
)
this.suggestionsSearchRunning = suggestionsList.bind(suggestions => {
this.suggestionsSearchRunning = suggestionsList.bind((suggestions) => {
if (suggestions === undefined) {
return new ImmutableStore(true)
}
return Stores.concat(suggestions).map(suggestions => suggestions.some(list => list === undefined))
return Stores.concat(suggestions).map((suggestions) =>
suggestions.some((list) => list === undefined)
)
})
this.suggestions = suggestionsList.bindD(suggestions =>
Stores.concat(suggestions).map(suggestions => CombinedSearcher.merge(suggestions))
this.suggestions = suggestionsList.bindD((suggestions) =>
Stores.concat(suggestions).map((suggestions) => CombinedSearcher.merge(suggestions))
)
const themeSearch = new ThemeSearch(state)
this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.search(query, 3))
this.themeSuggestions = this.searchTerm.mapD((query) => themeSearch.search(query, 3))
const layerSearch = new LayerSearch(state.theme)
this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.search(query, 5))
this.layerSuggestions = this.searchTerm.mapD((query) => layerSearch.search(query, 5))
const filterSearch = new FilterSearch(state)
this.filterSuggestions = this.searchTerm.stabilized(50)
.mapD(query => filterSearch.search(query))
.mapD(filterResult => {
const active = state.layerState.activeFilters.data
return filterResult.filter(({ filter, index, layer }) => {
const foundMatch = active.some(active =>
active.filter.id === filter.id && layer.id === active.layer.id && active.control.data === index)
this.filterSuggestions = this.searchTerm
.stabilized(50)
.mapD((query) => filterSearch.search(query))
.mapD(
(filterResult) => {
const active = state.layerState.activeFilters.data
return filterResult.filter(({ filter, index, layer }) => {
const foundMatch = active.some(
(active) =>
active.filter.id === filter.id &&
layer.id === active.layer.id &&
active.control.data === index
)
return !foundMatch
})
}, [state.layerState.activeFilters])
return !foundMatch
})
},
[state.layerState.activeFilters]
)
this.locationResults = new GeocodingFeatureSource(this.suggestions.stabilized(250))
this.showSearchDrawer = new UIEventSource(false)
this.searchIsFocused.addCallbackAndRunD(sugg => {
this.searchIsFocused.addCallbackAndRunD((sugg) => {
if (sugg) {
this.showSearchDrawer.set(true)
}
})
}
public async apply(result: FilterSearchResult[] | LayerConfig) {
@ -112,7 +120,7 @@ export default class SearchState {
private async applyFilter(payload: FilterSearchResult[]) {
const state = this.state
const layersToShow = payload.map(fsr => fsr.layer.id)
const layersToShow = payload.map((fsr) => fsr.layer.id)
console.log("Layers to show are", layersToShow)
for (const [name, otherLayer] of state.layerState.filteredLayers) {
const layer = otherLayer.layerDef
@ -148,7 +156,7 @@ export default class SearchState {
}
// This feature might not be loaded because we zoomed out
const object = await this.state.osmObjectDownloader.DownloadObjectAsync(osmid)
if(object === "deleted"){
if (object === "deleted") {
return
}
const f = object.asGeoJson()

View file

@ -22,9 +22,7 @@ import Showdown from "showdown"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { GeocodeResult } from "../Search/GeocodingProvider"
export class OptionallySyncedHistory<T> {
public readonly syncPreference: UIEventSource<"sync" | "local" | "no">
public readonly value: Store<T[]>
private readonly synced: UIEventSource<T[]>
@ -34,18 +32,26 @@ export class OptionallySyncedHistory<T> {
private readonly _isSame: (a: T, b: T) => boolean
private osmconnection: OsmConnection
constructor(key: string, osmconnection: OsmConnection, maxHistory: number = 20, isSame?: (a: T, b: T) => boolean) {
constructor(
key: string,
osmconnection: OsmConnection,
maxHistory: number = 20,
isSame?: (a: T, b: T) => boolean
) {
this.osmconnection = osmconnection
this._maxHistory = maxHistory
this._isSame = isSame
this.syncPreference = osmconnection.getPreference(
"preference-" + key + "-history",
"sync",
)
const synced = this.synced = UIEventSource.asObject<T[]>(osmconnection.getPreference(key + "-history"), [])
const local = this.local = LocalStorageSource.getParsed<T[]>(key + "-history", [])
const thisSession = this.thisSession = new UIEventSource<T[]>([], "optionally-synced:" + key + "(session only)")
this.syncPreference.addCallback(syncmode => {
this.syncPreference = osmconnection.getPreference("preference-" + key + "-history", "sync")
const synced = (this.synced = UIEventSource.asObject<T[]>(
osmconnection.getPreference(key + "-history"),
[]
))
const local = (this.local = LocalStorageSource.getParsed<T[]>(key + "-history", []))
const thisSession = (this.thisSession = new UIEventSource<T[]>(
[],
"optionally-synced:" + key + "(session only)"
))
this.syncPreference.addCallback((syncmode) => {
if (syncmode === "sync") {
let list = [...thisSession.data, ...synced.data].slice(0, maxHistory)
if (this._isSame) {
@ -67,9 +73,7 @@ export class OptionallySyncedHistory<T> {
}
})
this.value = this.syncPreference.bind(syncPref => this.getAppropriateStore(syncPref))
this.value = this.syncPreference.bind((syncPref) => this.getAppropriateStore(syncPref))
}
private getAppropriateStore(syncPref?: string) {
@ -87,7 +91,7 @@ export class OptionallySyncedHistory<T> {
const store = this.getAppropriateStore()
let oldList = store.data ?? []
if (this._isSame) {
oldList = oldList.filter(x => !this._isSame(t, x))
oldList = oldList.filter((x) => !this._isSame(t, x))
}
store.set([t, ...oldList].slice(0, this._maxHistory))
}
@ -100,14 +104,13 @@ export class OptionallySyncedHistory<T> {
if (t === undefined) {
return
}
this.osmconnection.isLoggedIn.addCallbackAndRun(loggedIn => {
this.osmconnection.isLoggedIn.addCallbackAndRun((loggedIn) => {
if (!loggedIn) {
return
}
this.add(t)
return true
})
}
clear() {
@ -157,7 +160,7 @@ export default class UserRelatedState {
*/
public readonly gpsLocationHistoryRetentionTime = new UIEventSource(
7 * 24 * 60 * 60,
"gps_location_retention",
"gps_location_retention"
)
public readonly addNewFeatureMode = new UIEventSource<
@ -180,18 +183,17 @@ export default class UserRelatedState {
public readonly recentlyVisitedThemes: OptionallySyncedHistory<string>
public readonly recentlyVisitedSearch: OptionallySyncedHistory<GeocodeResult>
constructor(
osmConnection: OsmConnection,
layout?: ThemeConfig,
featureSwitches?: FeatureSwitchState,
mapProperties?: MapProperties,
mapProperties?: MapProperties
) {
this.osmConnection = osmConnection
this._mapProperties = mapProperties
this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
this.osmConnection.getPreference("show-all-questions", "false"),
this.osmConnection.getPreference("show-all-questions", "false")
)
this.language = this.osmConnection.getPreference("language")
this.showTags = this.osmConnection.getPreference("show_tags")
@ -203,15 +205,19 @@ export default class UserRelatedState {
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.getPreference("identity", undefined, "mangrove"),
this.osmConnection.getPreference("identity-creation-date", undefined, "mangrove"),
this.osmConnection.getPreference("identity-creation-date", undefined, "mangrove")
)
this.preferredBackgroundLayer = this.osmConnection.getPreference(
"preferred-background-layer"
)
this.preferredBackgroundLayer = this.osmConnection.getPreference("preferred-background-layer")
this.addNewFeatureMode = this.osmConnection.getPreference(
"preferences-add-new-mode",
"button_click_right",
"button_click_right"
)
this.showScale = UIEventSource.asBoolean(
this.osmConnection.GetPreference("preference-show-scale", "false")
)
this.showScale = UIEventSource.asBoolean(this.osmConnection.GetPreference("preference-show-scale", "false"))
this.imageLicense = this.osmConnection.getPreference("pictures-license", "CC0")
this.installedUserThemes = UserRelatedState.initInstalledUserThemes(osmConnection)
@ -224,12 +230,13 @@ export default class UserRelatedState {
"theme",
this.osmConnection,
10,
(a, b) => a === b,
(a, b) => a === b
)
this.recentlyVisitedSearch = new OptionallySyncedHistory<GeocodeResult>("places",
this.recentlyVisitedSearch = new OptionallySyncedHistory<GeocodeResult>(
"places",
this.osmConnection,
15,
(a, b) => a.osm_id === b.osm_id && a.osm_type === b.osm_type,
(a, b) => a.osm_id === b.osm_id && a.osm_type === b.osm_type
)
this.syncLanguage()
this.recentlyVisitedThemes.addDefferred(layout?.id)
@ -279,9 +286,7 @@ export default class UserRelatedState {
*/
public addUnofficialTheme(themeInfo: MinimalThemeInformation) {
const pref = this.osmConnection.getPreference("unofficial-theme-" + themeInfo.id)
this.osmConnection.isLoggedIn.when(
() => pref.set(JSON.stringify(themeInfo))
)
this.osmConnection.isLoggedIn.when(() => pref.set(JSON.stringify(themeInfo)))
}
public getUnofficialTheme(id: string): MinimalThemeInformation | undefined {
@ -298,9 +303,9 @@ export default class UserRelatedState {
} catch (e) {
console.warn(
"Removing theme " +
id +
" as it could not be parsed from the preferences; the content is:",
str,
id +
" as it could not be parsed from the preferences; the content is:",
str
)
pref.setData(null)
return undefined
@ -330,7 +335,7 @@ export default class UserRelatedState {
title: layout.title.translations,
shortDescription: layout.shortDescription.translations,
definition: layout["definition"],
}),
})
)
}
}
@ -340,7 +345,7 @@ export default class UserRelatedState {
return osmConnection.preferencesHandler.allPreferences.map((prefs) =>
Object.keys(prefs)
.filter((k) => k.startsWith(prefix))
.map((k) => k.substring(prefix.length)),
.map((k) => k.substring(prefix.length))
)
}
@ -354,7 +359,7 @@ export default class UserRelatedState {
return userPreferences.map((preferences) =>
Object.keys(preferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length)),
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
)
}
@ -370,7 +375,7 @@ export default class UserRelatedState {
return undefined
}
return [home.lon, home.lat]
}),
})
).map((homeLonLat) => {
if (homeLonLat === undefined) {
return empty
@ -400,7 +405,7 @@ export default class UserRelatedState {
* */
private initAmendedPrefs(
layout?: ThemeConfig,
featureSwitches?: FeatureSwitchState,
featureSwitches?: FeatureSwitchState
): UIEventSource<Record<string, string>> {
const amendedPrefs = new UIEventSource<Record<string, string>>({
_theme: layout?.id,
@ -446,19 +451,19 @@ export default class UserRelatedState {
const missingLayers = Utils.Dedup(
untranslated
.filter((k) => k.startsWith("layers:"))
.map((k) => k.slice("layers:".length).split(".")[0]),
.map((k) => k.slice("layers:".length).split(".")[0])
)
const zenLinks: { link: string; id: string }[] = Utils.NoNull([
hasMissingTheme
? {
id: "theme:" + layout.id,
link: LinkToWeblate.hrefToWeblateZen(
language,
"themes",
layout.id,
),
}
id: "theme:" + layout.id,
link: LinkToWeblate.hrefToWeblateZen(
language,
"themes",
layout.id
),
}
: undefined,
...missingLayers.map((id) => ({
id: "layer:" + id,
@ -475,7 +480,7 @@ export default class UserRelatedState {
}
amendedPrefs.ping()
},
[this.translationMode],
[this.translationMode]
)
this.mangroveIdentity.getKeyId().addCallbackAndRun((kid) => {
@ -494,7 +499,7 @@ export default class UserRelatedState {
.makeHtml(userDetails.description)
?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<")
?.replace(/\n/g, ""),
?.replace(/\n/g, "")
)
}
@ -505,7 +510,7 @@ export default class UserRelatedState {
(c: { contributor: string; commits: number }) => {
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
return replaced === simplifiedName
},
}
)
if (isTranslator) {
amendedPrefs.data["_translation_contributions"] = "" + isTranslator.commits
@ -514,7 +519,7 @@ export default class UserRelatedState {
(c: { contributor: string; commits: number }) => {
const replaced = c.contributor.toLowerCase().replace(/\s+/g, "")
return replaced === simplifiedName
},
}
)
if (isCodeContributor) {
amendedPrefs.data["_code_contributions"] = "" + isCodeContributor.commits

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

@ -19,7 +19,7 @@ export class Tag extends TagsFilter {
if (value === undefined) {
throw `Invalid value while constructing a Tag with key '${key}': value is undefined`
}
if(value.length > 255 || key.length > 255){
if (value.length > 255 || key.length > 255) {
throw "Invalid tag: length is over 255"
}
if (value === "*") {

View file

@ -23,7 +23,7 @@ export class Stores {
}
public static FromPromiseWithErr<T>(
promise: Promise<T>,
promise: Promise<T>
): Store<{ success: T } | { error: any }> {
return UIEventSource.FromPromiseWithErr(promise)
}
@ -99,7 +99,7 @@ export class Stores {
*/
static holdDefined<T>(store: Store<T | undefined>): Store<T | undefined> {
const newStore = new UIEventSource(store.data)
store.addCallbackD(t => {
store.addCallbackD((t) => {
newStore.setData(t)
})
return newStore
@ -133,23 +133,27 @@ export abstract class Store<T> implements Readable<T> {
abstract map<J>(
f: (t: T) => J,
extraStoresToWatch: Store<any>[],
callbackDestroyFunction: (f: () => void) => void,
callbackDestroyFunction: (f: () => void) => void
): Store<J>
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[],
callbackDestroyFunction?: (f: () => void) => void,
callbackDestroyFunction?: (f: () => void) => void
): Store<J> {
return this.map((t) => {
if (t === undefined) {
return undefined
}
if (t === null) {
return null
}
return f(<Exclude<T, undefined | null>>t)
}, extraStoresToWatch, callbackDestroyFunction)
return this.map(
(t) => {
if (t === undefined) {
return undefined
}
if (t === null) {
return null
}
return f(<Exclude<T, undefined | null>>t)
},
extraStoresToWatch,
callbackDestroyFunction
)
}
/**
@ -176,7 +180,7 @@ export abstract class Store<T> implements Readable<T> {
abstract addCallbackAndRun(callback: (data: T) => void): () => void
public withEqualityStabilized(
comparator: (t: T | undefined, t1: T | undefined) => boolean,
comparator: (t: T | undefined, t1: T | undefined) => boolean
): Store<T> {
let oldValue = undefined
return this.map((v) => {
@ -269,7 +273,10 @@ export abstract class Store<T> implements Readable<T> {
return sink
}
public bindD<X>(f: (t: Exclude<T, undefined | null>) => Store<X>, extraSources: UIEventSource<object>[] = []): Store<X> {
public bindD<X>(
f: (t: Exclude<T, undefined | null>) => Store<X>,
extraSources: UIEventSource<object>[] = []
): Store<X> {
return this.bind((t) => {
if (t === null) {
return null
@ -343,10 +350,10 @@ export abstract class Store<T> implements Readable<T> {
public abstract destroy()
when(callback: () => void, condition?: (v:T) => boolean) {
condition ??= v => v === true
this.addCallbackAndRunD(v => {
if ( condition(v)) {
when(callback: () => void, condition?: (v: T) => boolean) {
condition ??= (v) => v === true
this.addCallbackAndRunD((v) => {
if (condition(v)) {
callback()
return true
}
@ -364,8 +371,7 @@ export class ImmutableStore<T> extends Store<T> {
this.data = data
}
private static readonly pass: () => void = () => {
}
private static readonly pass: () => void = () => {}
addCallback(_: (data: T) => void): () => void {
// pass: data will never change
@ -394,7 +400,7 @@ export class ImmutableStore<T> extends Store<T> {
map<J>(
f: (t: T) => J,
extraStores: Store<any>[] = undefined,
ondestroyCallback?: (f: () => void) => void,
ondestroyCallback?: (f: () => void) => void
): ImmutableStore<J> {
if (extraStores?.length > 0) {
return new MappedStore(this, f, extraStores, undefined, f(this.data), ondestroyCallback)
@ -464,7 +470,7 @@ class ListenerTracker<T> {
let endTime = new Date().getTime() / 1000
if (endTime - startTime > 500) {
console.trace(
"Warning: a ping took more then 500ms; this is probably a performance issue",
"Warning: a ping took more then 500ms; this is probably a performance issue"
)
}
if (toDelete !== undefined) {
@ -506,7 +512,7 @@ class MappedStore<TIn, T> extends Store<T> {
extraStores: Store<any>[],
upstreamListenerHandler: ListenerTracker<TIn> | undefined,
initialState: T,
onDestroy?: (f: () => void) => void,
onDestroy?: (f: () => void) => void
) {
super()
this._upstream = upstream
@ -546,7 +552,7 @@ class MappedStore<TIn, T> extends Store<T> {
map<J>(
f: (t: T) => J,
extraStores: Store<any>[] = undefined,
ondestroyCallback?: (f: () => void) => void,
ondestroyCallback?: (f: () => void) => void
): Store<J> {
let stores: Store<any>[] = undefined
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
@ -568,7 +574,7 @@ class MappedStore<TIn, T> extends Store<T> {
stores,
this._callbacks,
f(this.data),
ondestroyCallback,
ondestroyCallback
)
}
@ -624,7 +630,7 @@ class MappedStore<TIn, T> extends Store<T> {
this._unregisterFromUpstream = this._upstream.addCallback((_) => self.update())
this._unregisterFromExtraStores = this._extraStores?.map((store) =>
store?.addCallback((_) => self.update()),
store?.addCallback((_) => self.update())
)
this._callbacksAreRegistered = true
}
@ -645,8 +651,7 @@ class MappedStore<TIn, T> extends Store<T> {
}
export class UIEventSource<T> extends Store<T> implements Writable<T> {
private static readonly pass: (() => void) = () => {
}
private static readonly pass: () => void = () => {}
public data: T
_callbacks: ListenerTracker<T> = new ListenerTracker<T>()
@ -661,7 +666,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static flatten<X>(
source: Store<Store<X>>,
possibleSources?: Store<object>[],
possibleSources?: Store<object>[]
): UIEventSource<X> {
const sink = new UIEventSource<X>(source.data?.data)
@ -690,7 +695,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
*/
public static FromPromise<T>(
promise: Promise<T>,
onError: (e) => void = undefined,
onError: (e) => void = undefined
): UIEventSource<T> {
const src = new UIEventSource<T>(undefined)
promise?.then((d) => src.setData(d))
@ -711,7 +716,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
* @constructor
*/
public static FromPromiseWithErr<T>(
promise: Promise<T>,
promise: Promise<T>
): UIEventSource<{ success: T } | { error: any } | undefined> {
const src = new UIEventSource<{ success: T } | { error: any }>(undefined)
promise
@ -743,7 +748,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return undefined
}
return "" + fl
},
}
)
}
@ -774,7 +779,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return undefined
}
return "" + fl
},
}
)
}
@ -782,11 +787,14 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
return stringUIEventSource.sync(
(str) => str === "true",
[],
(b) => "" + b,
(b) => "" + b
)
}
static asObject<T extends object>(stringUIEventSource: UIEventSource<string>, defaultV: T): UIEventSource<T> {
static asObject<T extends object>(
stringUIEventSource: UIEventSource<string>,
defaultV: T
): UIEventSource<T> {
return stringUIEventSource.sync(
(str) => {
if (str === undefined || str === null || str === "") {
@ -800,7 +808,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
}
},
[],
(b) => JSON.stringify(b) ?? "",
(b) => JSON.stringify(b) ?? ""
)
}
@ -890,7 +898,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public map<J>(
f: (t: T) => J,
extraSources: Store<any>[] = [],
onDestroy?: (f: () => void) => void,
onDestroy?: (f: () => void) => void
): Store<J> {
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data), onDestroy)
}
@ -902,7 +910,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J,
extraSources: Store<any>[] = [],
callbackDestroyFunction?: (f: () => void) => void,
callbackDestroyFunction?: (f: () => void) => void
): Store<J | undefined> {
return new MappedStore(
this,
@ -920,7 +928,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
this.data === undefined || this.data === null
? <undefined | null>this.data
: f(<any>this.data),
callbackDestroyFunction,
callbackDestroyFunction
)
}
@ -940,7 +948,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
f: (t: T) => J,
extraSources: Store<any>[],
g: (j: J, t: T) => T,
allowUnregister = false,
allowUnregister = false
): UIEventSource<J> {
const self = this
@ -949,7 +957,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
const newSource = new UIEventSource<J>(f(this.data), "map(" + this.tag + ")@" + callee)
const update = function() {
const update = function () {
newSource.setData(f(self.data))
return allowUnregister && newSource._callbacks.length() === 0
}

View file

@ -5,7 +5,6 @@ import { Utils } from "../../Utils"
* UIEventsource-wrapper around localStorage
*/
export class LocalStorageSource {
private static readonly _cache: Record<string, UIEventSource<string>> = {}
static getParsed<T>(key: string, defaultValue: T): UIEventSource<T> {
@ -21,7 +20,7 @@ export class LocalStorageSource {
}
},
[],
(value) => JSON.stringify(value),
(value) => JSON.stringify(value)
)
}
@ -32,7 +31,6 @@ export class LocalStorageSource {
}
let saved = defaultValue
if (!Utils.runningFromConsole) {
try {
saved = localStorage.getItem(key)
if (saved === "undefined") {

View file

@ -22,7 +22,7 @@ export class MangroveIdentity {
this.mangroveIdentity = mangroveIdentity
this._mangroveIdentityCreationDate = mangroveIdentityCreationDate
mangroveIdentity.addCallbackAndRunD(async (data) => {
if(data === ""){
if (data === "") {
return
}
await this.setKeypair(data)

View file

@ -297,15 +297,13 @@ export default class NameSuggestionIndex {
return true
}
if (
i.locationSet.include.some((c) => countries.indexOf(c) >= 0)
) {
if (i.locationSet.include.some((c) => countries.indexOf(c) >= 0)) {
// We prefer the countries provided by lonlat2country, they are more precise and are loaded already anyway (cheap)
// Country might contain multiple countries, separated by ';'
return true
}
if (i.locationSet.exclude?.some(c => countries.indexOf(c) >= 0)) {
if (i.locationSet.exclude?.some((c) => countries.indexOf(c) >= 0)) {
return false
}
@ -313,18 +311,20 @@ export default class NameSuggestionIndex {
return true
}
const hasSpecial = i.locationSet.include?.some(i => i.endsWith(".geojson") || Array.isArray(i)) || i.locationSet.exclude?.some(i => i.endsWith(".geojson") || Array.isArray(i))
const hasSpecial =
i.locationSet.include?.some((i) => i.endsWith(".geojson") || Array.isArray(i)) ||
i.locationSet.exclude?.some((i) => i.endsWith(".geojson") || Array.isArray(i))
if (!hasSpecial) {
return false
}
const key = i.locationSet.include?.join(";") + "-" + i.locationSet.exclude?.join(";")
const fromCache = NameSuggestionIndex.resolvedSets[key]
const resolvedSet = fromCache ?? NameSuggestionIndex.loco.resolveLocationSet(i.locationSet)
const resolvedSet =
fromCache ?? NameSuggestionIndex.loco.resolveLocationSet(i.locationSet)
if (!fromCache) {
NameSuggestionIndex.resolvedSets[key] = resolvedSet
}
if (resolvedSet) {
// We actually have a location set, so we can check if the feature is in it, by determining if our point is inside the MultiPolygon using @turf/boolean-point-in-polygon
// This might occur for some extra boundaries, such as counties, ...

View file

@ -58,7 +58,7 @@ export interface P4CPicture {
author?
license?
detailsUrl?: string
direction?: number,
direction?: number
osmTags?: object /*To copy straight into OSM!*/
thumbUrl: string
details: {
@ -103,7 +103,7 @@ class P4CImageFetcher implements ImageFetcher {
{
mindate: new Date().getTime() - maxAgeSeconds,
towardscenter: false,
},
}
)
} catch (e) {
console.log("P4C image fetcher failed with", e)
@ -172,16 +172,13 @@ class ImagesFromPanoramaxFetcher implements ImageFetcher {
constructor(url?: string, radius: number = 100) {
this._radius = radius
if (url) {
this._panoramax = new Panoramax(url)
} else {
this._panoramax = new PanoramaxXYZ()
}
}
public async fetchImages(lat: number, lon: number): Promise<P4CPicture[]> {
const bboxObj = new BBox([
GeoOperations.destination([lon, lat], this._radius * Math.sqrt(2), -45),
GeoOperations.destination([lon, lat], this._radius * Math.sqrt(2), 135),
@ -189,16 +186,16 @@ class ImagesFromPanoramaxFetcher implements ImageFetcher {
const bbox: [number, number, number, number] = bboxObj.toLngLatFlat()
const images = await this._panoramax.search({ bbox, limit: 1000 })
return images.map(i => {
return images.map((i) => {
const [lng, lat] = i.geometry.coordinates
return ({
return {
pictureUrl: i.assets.sd.href,
coordinates: { lng, lat },
provider: "panoramax",
direction: i.properties["view:azimuth"],
osmTags: {
"panoramax": i.id,
panoramax: i.id,
},
thumbUrl: i.assets.thumb.href,
date: new Date(i.properties.datetime).getTime(),
@ -206,9 +203,10 @@ class ImagesFromPanoramaxFetcher implements ImageFetcher {
author: i.providers.at(-1).name,
detailsUrl: i.id,
details: {
isSpherical: i.properties["exif"]["Xmp.GPano.ProjectionType"] === "equirectangular",
isSpherical:
i.properties["exif"]["Xmp.GPano.ProjectionType"] === "equirectangular",
},
})
}
})
}
}
@ -236,7 +234,7 @@ class ImagesFromCacheServerFetcher implements ImageFetcher {
async fetchImagesForType(
targetlat: number,
targetlon: number,
type: "lines" | "pois" | "polygons",
type: "lines" | "pois" | "polygons"
): Promise<P4CPicture[]> {
const { x, y, z } = Tiles.embedded_tile(targetlat, targetlon, 14)
@ -253,7 +251,7 @@ class ImagesFromCacheServerFetcher implements ImageFetcher {
}),
x,
y,
z,
z
)
await src.updateAsync()
return src.features.data
@ -427,7 +425,7 @@ export class CombinedFetcher {
lat: number,
lon: number,
state: UIEventSource<Record<string, "loading" | "done" | "error">>,
sink: UIEventSource<P4CPicture[]>,
sink: UIEventSource<P4CPicture[]>
): Promise<void> {
try {
const pics = await source.fetchImages(lat, lon)
@ -460,7 +458,7 @@ export class CombinedFetcher {
public getImagesAround(
lon: number,
lat: number,
lat: number
): {
images: Store<P4CPicture[]>
state: Store<Record<string, "loading" | "done" | "error">>

View file

@ -1,6 +1,6 @@
import { Utils } from "../../Utils"
import { Store, UIEventSource } from "../UIEventSource"
import { WBK} from "wikibase-sdk"
import { WBK } from "wikibase-sdk"
export class WikidataResponse {
public readonly id: string
@ -128,10 +128,9 @@ interface SparqlResult {
* Utility functions around wikidata
*/
export default class Wikidata {
public static wds = WBK({
instance: "https://wikidata.org",
sparqlEndpoint: "https://query.wikidata.org/bigdata/namespace/wdq/sparql"
sparqlEndpoint: "https://query.wikidata.org/bigdata/namespace/wdq/sparql",
})
public static readonly neededUrls = [
@ -211,7 +210,7 @@ export default class Wikidata {
${instanceOf}
${minusPhrases.join("\n ")}
} ORDER BY ASC(?num) LIMIT ${options?.maxCount ?? 20}`
const url = Wikidata. wds.sparqlQuery(sparql)
const url = Wikidata.wds.sparqlQuery(sparql)
const result = await Utils.downloadJson<SparqlResult>(url)
/*The full uri of the wikidata-item*/
@ -252,7 +251,7 @@ export default class Wikidata {
lang +
"&type=item&origin=*" +
"&props=" // props= removes some unused values in the result
const response = await Utils.downloadJsonCached<{search: any[]}>(url, 10000)
const response = await Utils.downloadJsonCached<{ search: any[] }>(url, 10000)
const result = response.search
@ -401,7 +400,7 @@ export default class Wikidata {
"}"
const url = Wikidata.wds.sparqlQuery(query)
const result = await Utils.downloadJsonCached<SparqlResult>(url, 24 * 60 * 60 * 1000)
return <any> result.results.bindings
return <any>result.results.bindings
}
private static _cache = new Map<string, Promise<WikidataResponse>>()