Merge develop

This commit is contained in:
Pieter Vander Vennet 2024-11-18 20:45:16 +01:00
commit b5669f6bf8
786 changed files with 42904 additions and 35985 deletions

View file

@ -183,7 +183,7 @@ export default class GeoLocationHandler {
}
private initUserLocationTrail() {
const features = LocalStorageSource.GetParsed<Feature[]>("gps_location_history", [])
const features = LocalStorageSource.getParsed<Feature[]>("gps_location_history", [])
const now = new Date().getTime()
features.data = features.data.filter((ff) => {
if (ff.properties === undefined) {

View file

@ -1,5 +1,5 @@
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import { QueryParameters } from "../Web/QueryParameters"
import Hash from "../Web/Hash"
@ -25,13 +25,13 @@ export default class InitialMapPositioning {
public location: UIEventSource<{ lon: number; lat: number }>
public useTerrain: Store<boolean>
constructor(layoutToUse: LayoutConfig, geolocationState: GeoLocationState) {
constructor(layoutToUse: ThemeConfig, geolocationState: GeoLocationState) {
function localStorageSynced(
key: string,
deflt: number,
docs: string
): UIEventSource<number> {
const localStorage = LocalStorageSource.Get(key)
const localStorage = LocalStorageSource.get(key)
const previousValue = localStorage.data
const src = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(key, "" + deflt, docs).syncWith(localStorage)

View file

@ -14,9 +14,8 @@ export default class NoElementsInViewDetector {
constructor(themeViewState: ThemeViewState) {
const state = themeViewState
const minZoom = Math.min(
...themeViewState.layout.layers
...themeViewState.theme.layers
.filter((l) => Constants.priviliged_layers.indexOf(<any>l.id) < 0)
.filter((l) => !l.id.startsWith("note_import"))
.map((l) => l.minzoom)
)
const mapProperties = themeViewState.mapProperties
@ -44,7 +43,7 @@ export default class NoElementsInViewDetector {
// Nope, no data loaded
continue
}
const layer = themeViewState.layout.getLayer(layerName)
const layer = themeViewState.theme.getLayer(layerName)
if (mapProperties.zoom.data < layer.minzoom) {
minzoomWithData = Math.min(layer.minzoom)
continue
@ -68,7 +67,7 @@ export default class NoElementsInViewDetector {
continue
}
const layer = themeViewState.layout.getLayer(layerName)
const layer = themeViewState.theme.getLayer(layerName)
if (mapProperties.zoom.data < layer.minzoom) {
continue
}

View file

@ -34,7 +34,7 @@ export default class SelectedElementTagsUpdater {
public static applyUpdate(latestTags: OsmTags, id: string, state: SpecialVisualizationState) {
try {
const leftRightSensitive = state.layout.isLeftRightSensitive()
const leftRightSensitive = state.theme.isLeftRightSensitive()
if (leftRightSensitive) {
SimpleMetaTagger.removeBothTagging(latestTags)
@ -111,7 +111,7 @@ export default class SelectedElementTagsUpdater {
}
private invalidateCache(s: Feature) {
const state = this.state
const wasPartOfLayer = state.layout.getMatchingLayer(s.properties)
const wasPartOfLayer = state.theme.getMatchingLayer(s.properties)
state.toCacheSavers?.get(wasPartOfLayer.id)?.invalidateCacheAround(BBox.get(s))
}
private installCallback() {

View file

@ -5,18 +5,15 @@ 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
const defaultTitle = state.layout?.title?.textFor(lng) ?? "MapComplete"
const defaultTitle = state.theme?.title?.textFor(lng) ?? "MapComplete"
if (selected === undefined) {
return defaultTitle
}
const layer = state.layout.getMatchingLayer(selected.properties)
const layer = state.theme.getMatchingLayer(selected.properties)
if (layer === undefined) {
return defaultTitle
}
@ -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

@ -1,10 +1,8 @@
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
import { QueryParameters } from "./Web/QueryParameters"
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import { Utils } from "../Utils"
import { UIEventSource } from "./UIEventSource"
import { LocalStorageSource } from "./Web/LocalStorageSource"
import LZString from "lz-string"
import { FixLegacyTheme } from "../Models/ThemeConfig/Conversion/LegacyJsonConvert"
import { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
@ -19,42 +17,30 @@ import { DesugaringContext } from "../Models/ThemeConfig/Conversion/Conversion"
import { TagRenderingConfigJson } from "../Models/ThemeConfig/Json/TagRenderingConfigJson"
import Hash from "./Web/Hash"
import { QuestionableTagRenderingConfigJson } from "../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import { LayoutConfigJson } from "../Models/ThemeConfig/Json/LayoutConfigJson"
import { ThemeConfigJson } from "../Models/ThemeConfig/Json/ThemeConfigJson"
import { ValidateThemeAndLayers } from "../Models/ThemeConfig/Conversion/ValidateThemeAndLayers"
export default class DetermineLayout {
export default class DetermineTheme {
private static readonly _knownImages = new Set(Array.from(licenses).map((l) => l.path))
private static readonly loadCustomThemeParam = QueryParameters.GetQueryParameter(
"userlayout",
"false",
"If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways: \n\n- The hash of the URL contains a base64-encoded .json-file containing the theme definition\n- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator\n- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme"
"If the parameter is an URL, it should point to a .json of a theme which will be loaded and used"
)
public static getCustomDefinition(): string {
const layoutFromBase64 = decodeURIComponent(DetermineLayout.loadCustomThemeParam.data)
const layoutFromBase64 = decodeURIComponent(DetermineTheme.loadCustomThemeParam.data)
if (layoutFromBase64.startsWith("http")) {
return layoutFromBase64
}
if (layoutFromBase64 !== "false") {
// We have to load something from the hash (or from disk)
const hash = Hash.hash.data
try {
JSON.parse(atob(hash))
return atob(hash)
} catch (e) {
// We try to decode with lz-string
JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash)))
return Utils.UnMinify(LZString.decompressFromBase64(hash))
}
}
return undefined
}
private static async expandRemoteLayers(
layoutConfig: LayoutConfigJson
): Promise<LayoutConfigJson> {
layoutConfig: ThemeConfigJson
): Promise<ThemeConfigJson> {
if (!layoutConfig.layers) {
// This is probably a layer in 'layer-only-mode'
return layoutConfig
@ -67,8 +53,7 @@ export default class DetermineLayout {
try {
new URL(l)
console.log("Downloading remote layer " + l)
const layerConfig = <LayerConfigJson>await Utils.downloadJson(l)
layoutConfig.layers[i] = layerConfig
layoutConfig.layers[i] = <LayerConfigJson>await Utils.downloadJson(l)
} catch (_) {
continue
}
@ -79,16 +64,11 @@ export default class DetermineLayout {
/**
* Gets the correct layout for this website
*/
public static async GetLayout(): Promise<LayoutConfig | undefined> {
const layoutFromBase64 = decodeURIComponent(DetermineLayout.loadCustomThemeParam.data)
public static async getTheme(): Promise<ThemeConfig | undefined> {
const layoutFromBase64 = decodeURIComponent(DetermineTheme.loadCustomThemeParam.data)
if (layoutFromBase64.startsWith("http")) {
return await DetermineLayout.LoadRemoteTheme(layoutFromBase64)
}
if (layoutFromBase64 !== "false") {
// We have to load something from the hash (or from disk)
return await DetermineLayout.LoadLayoutFromHash(DetermineLayout.loadCustomThemeParam)
return await DetermineTheme.LoadRemoteTheme(layoutFromBase64)
}
let layoutId: string = undefined
@ -125,48 +105,11 @@ export default class DetermineLayout {
return layouts.get(id)
}
public static async LoadLayoutFromHash(
userLayoutParam: UIEventSource<string>
): Promise<LayoutConfig | null> {
let hash = location.hash.substr(1)
let json: any
// layoutFromBase64 contains the name of the theme. This is partly to do tracking with goat counter
const dedicatedHashFromLocalStorage = LocalStorageSource.Get(
"user-layout-" + userLayoutParam.data?.replace(" ", "_")
)
if (dedicatedHashFromLocalStorage.data?.length < 10) {
dedicatedHashFromLocalStorage.setData(undefined)
}
const hashFromLocalStorage = LocalStorageSource.Get("last-loaded-user-layout")
if (hash.length < 10) {
hash = dedicatedHashFromLocalStorage.data ?? hashFromLocalStorage.data
} else {
console.log("Saving hash to local storage")
hashFromLocalStorage.setData(hash)
dedicatedHashFromLocalStorage.setData(hash)
}
try {
json = JSON.parse(atob(hash))
} catch (e) {
// We try to decode with lz-string
json = JSON.parse(Utils.UnMinify(LZString.decompressFromBase64(hash)))
}
json = await this.expandRemoteLayers(json)
const layoutToUse = DetermineLayout.prepCustomTheme(json)
userLayoutParam.setData(layoutToUse.id)
return layoutToUse
}
private static getSharedTagRenderings(): Map<string, QuestionableTagRenderingConfigJson> {
const dict = new Map<string, QuestionableTagRenderingConfigJson>()
for (const tagRendering of questions.tagRenderings) {
dict.set(tagRendering.id, tagRendering)
dict.set(tagRendering.id, <QuestionableTagRenderingConfigJson>tagRendering)
}
return dict
@ -176,7 +119,7 @@ export default class DetermineLayout {
return questions.tagRenderings.map((tr) => tr.id)
}
private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): LayoutConfig {
private static prepCustomTheme(json: any, sourceUrl?: string, forceId?: string): ThemeConfig {
if (json.layers === undefined && json.tagRenderings !== undefined) {
// We got fed a layer instead of a theme
const layerConfig = <LayerConfigJson>json
@ -224,15 +167,15 @@ export default class DetermineLayout {
knownLayersDict.set(layer.id, <LayerConfigJson>layer)
}
const convertState: DesugaringContext = {
tagRenderings: DetermineLayout.getSharedTagRenderings(),
tagRenderingOrder: DetermineLayout.getSharedTagRenderingOrder(),
tagRenderings: DetermineTheme.getSharedTagRenderings(),
tagRenderingOrder: DetermineTheme.getSharedTagRenderingOrder(),
sharedLayers: knownLayersDict,
publicLayers: new Set<string>(),
}
json = new FixLegacyTheme().convertStrict(json)
const raw = json
json = new FixImages(DetermineLayout._knownImages).convertStrict(json)
json = new FixImages(DetermineTheme._knownImages).convertStrict(json)
json.enableNoteImports = json.enableNoteImports ?? false
json = new PrepareTheme(convertState).convertStrict(json)
console.log("The layoutconfig is ", json)
@ -249,20 +192,20 @@ export default class DetermineLayout {
false
).convertStrict(json)
}
return new LayoutConfig(json, false, {
return new ThemeConfig(json, false, {
definitionRaw: JSON.stringify(raw, null, " "),
definedAtUrl: sourceUrl,
})
}
private static async LoadRemoteTheme(link: string): Promise<LayoutConfig | null> {
private static async LoadRemoteTheme(link: string): Promise<ThemeConfig | null> {
console.log("Downloading map theme from ", link)
new FixedUiElement(`Downloading the theme from the <a href="${link}">link</a>...`).AttachTo(
"maindiv"
)
let parsed = <LayoutConfigJson>await Utils.downloadJson(link)
let parsed = <ThemeConfigJson>await Utils.downloadJson(link)
let forcedId = parsed.id
const url = new URL(link)
if (!(url.hostname === "localhost" || url.hostname === "127.0.0.1")) {
@ -270,6 +213,6 @@ export default class DetermineLayout {
}
console.log("Loaded remote link:", link)
parsed = await this.expandRemoteLayers(parsed)
return DetermineLayout.prepCustomTheme(parsed, link, forcedId)
return DetermineTheme.prepCustomTheme(parsed, link, forcedId)
}
}

View file

@ -49,7 +49,7 @@ export default class FavouritesFeatureSource extends StaticFeatureSource {
const featuresWithoutAlreadyPresent = features.map((features) =>
features.filter(
(feat) => !state.layout.layers.some((l) => l.id === feat.properties._orig_layer)
(feat) => !state.theme.layers.some((l) => l.id === feat.properties._orig_layer)
)
)

View file

@ -2,6 +2,7 @@ import { Store, UIEventSource } from "../../UIEventSource"
import { FeatureSource, IndexedFeatureSource, UpdatableFeatureSource } from "../FeatureSource"
import { Feature } from "geojson"
import { Utils } from "../../../Utils"
import { OsmFeature } from "../../../Models/OsmFeature"
/**
* The featureSourceMerger receives complete geometries from various sources.
@ -50,6 +51,24 @@ export default class FeatureSourceMerger<Src extends FeatureSource = FeatureSour
this.addData(sources.map((s) => s.features.data))
}
/**
* Add the given feature if it isn't in the dictionary yet.
* Returns 'true' if this was a previously unseen item.
* If the item was already present, nothing will happen
*/
public addItem(f: OsmFeature): boolean {
const id = f.properties.id
const all = this._featuresById.data
if (!all.has(id)) {
all.set(id, f)
this._featuresById.ping()
this.features.data.push(f)
this.features.ping()
return true
}
}
protected addData(sources: Feature[][]) {
sources = Utils.NoNull(sources)
let somethingChanged = false
@ -108,6 +127,7 @@ export class UpdatableFeatureSourceMerger<
constructor(...sources: Src[]) {
super(...sources)
}
async updateAsync() {
await Promise.all(this._sources.map((src) => src.updateAsync()))
}

View file

@ -30,6 +30,9 @@ export default class FilteringFeatureSource implements FeatureSource {
upstream.features.addCallback(() => {
self.update()
})
layer.isDisplayed.addCallback(() => {
self.update()
})
layer.appliedFilters.forEach((value) =>
value.addCallback((_) => {

View file

@ -1,4 +1,4 @@
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../../Models/ThemeConfig/ThemeConfig"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import { Feature, Point } from "geojson"
import { TagUtils } from "../../Tags/TagUtils"
@ -22,7 +22,7 @@ export class LastClickFeatureSource implements FeatureSource {
private _usermode: UIEventSource<string>
private _enabledAddMorePoints: UIEventSource<boolean>
constructor(
layout: LayoutConfig,
layout: ThemeConfig,
clickSource: Store<{ lon: number; lat: number; mode: "left" | "right" | "middle" }>,
usermode?: UIEventSource<string>,
enabledAddMorePoints?: UIEventSource<boolean>

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

@ -49,14 +49,15 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
},
options?: {
padToTiles?: Store<number>
isActive?: Store<boolean>
isActive?: Store<boolean>,
ignoreZoom?: boolean
}
) {
this.state = state
this._isActive = options?.isActive ?? new ImmutableStore(true)
this.padToZoomLevel = options?.padToTiles
const self = this
this._layersToDownload = state.zoom.map((zoom) => this.layersToDownload(zoom))
this._layersToDownload = options?.ignoreZoom? new ImmutableStore(state.layers) : state.zoom.map((zoom) => this.layersToDownload(zoom))
state.bounds.mapD(
(_) => {
@ -103,7 +104,7 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
* Download the relevant data from overpass. Attempt to use a different server if one fails; only downloads the relevant layers
* @private
*/
public async updateAsync(): Promise<void> {
public async updateAsync(overrideBounds?: BBox): Promise<void> {
let data: any = undefined
let lastUsed = 0
const start = new Date()
@ -122,7 +123,7 @@ export default class OverpassFeatureSource implements UpdatableFeatureSource {
let bounds: BBox
do {
try {
bounds = this.state.bounds.data
bounds = overrideBounds ?? this.state.bounds.data
?.pad(this.state.widenFactor)
?.expandToTileBounds(this.padToZoomLevel?.data)
if (!bounds) {

View file

@ -1,10 +1,10 @@
import GeoJsonSource from "./GeoJsonSource"
import LayerConfig from "../../../Models/ThemeConfig/LayerConfig"
import { UpdatableFeatureSource } from "../FeatureSource"
import { FeatureSource, UpdatableFeatureSource } from "../FeatureSource"
import { Or } from "../../Tags/Or"
import FeatureSwitchState from "../../State/FeatureSwitchState"
import OverpassFeatureSource from "./OverpassFeatureSource"
import { Store, UIEventSource } from "../../UIEventSource"
import { ImmutableStore, Store, UIEventSource } from "../../UIEventSource"
import OsmFeatureSource from "./OsmFeatureSource"
import DynamicGeoJsonTileSource from "../TiledFeatureSource/DynamicGeoJsonTileSource"
import { BBox } from "../../BBox"
@ -18,7 +18,7 @@ import FeatureSourceMerger from "./FeatureSourceMerger"
*
* Note that special layers (with `source=null` will be ignored)
*/
export default class LayoutSource extends FeatureSourceMerger {
export default class ThemeSource extends FeatureSourceMerger {
/**
* Indicates if a data source is loading something
*/
@ -28,6 +28,13 @@ export default class LayoutSource extends FeatureSourceMerger {
public static readonly fromCacheZoomLevel = 15
/**
* This source is _only_ triggered when the data is downloaded for CSV export
* @private
*/
private readonly _downloadAll: OverpassFeatureSource
private readonly _mapBounds: Store<BBox>
constructor(
layers: LayerConfig[],
featureSwitches: FeatureSwitchState,
@ -35,7 +42,7 @@ export default class LayoutSource extends FeatureSourceMerger {
backend: string,
isDisplayed: (id: string) => Store<boolean>,
mvtAvailableLayers: Set<string>,
fullNodeDatabaseSource?: FullNodeDatabaseSource
fullNodeDatabaseSource?: FullNodeDatabaseSource,
) {
const supportsForceDownload: UpdatableFeatureSource[] = []
@ -51,31 +58,31 @@ export default class LayoutSource extends FeatureSourceMerger {
const src = new LocalStorageFeatureSource(
backend,
layer,
LayoutSource.fromCacheZoomLevel,
ThemeSource.fromCacheZoomLevel,
mapProperties,
{
isActive: isDisplayed(layer.id),
maxAge: layer.maxAgeOfCache,
}
},
)
fromCache.set(layer.id, src)
}
}
const mvtSources: UpdatableFeatureSource[] = osmLayers
.filter((f) => mvtAvailableLayers.has(f.id))
.map((l) => LayoutSource.setupMvtSource(l, mapProperties, isDisplayed(l.id)))
const nonMvtSources = []
const nonMvtLayers = osmLayers.filter((l) => !mvtAvailableLayers.has(l.id))
.map((l) => ThemeSource.setupMvtSource(l, mapProperties, isDisplayed(l.id)))
const nonMvtSources: FeatureSource[] = []
const nonMvtLayers: LayerConfig[] = osmLayers.filter((l) => !mvtAvailableLayers.has(l.id))
const isLoading = new UIEventSource(false)
const osmApiSource = LayoutSource.setupOsmApiSource(
const osmApiSource = ThemeSource.setupOsmApiSource(
osmLayers,
bounds,
zoom,
backend,
featureSwitches,
fullNodeDatabaseSource
fullNodeDatabaseSource,
)
nonMvtSources.push(osmApiSource)
@ -84,13 +91,14 @@ export default class LayoutSource extends FeatureSourceMerger {
console.log(
"Layers ",
nonMvtLayers.map((l) => l.id),
" cannot be fetched from the cache server, defaulting to overpass/OSM-api"
" cannot be fetched from the cache server, defaulting to overpass/OSM-api",
)
overpassSource = LayoutSource.setupOverpass(osmLayers, bounds, zoom, featureSwitches)
overpassSource = ThemeSource.setupOverpass(osmLayers, bounds, zoom, featureSwitches)
nonMvtSources.push(overpassSource)
supportsForceDownload.push(overpassSource)
}
function setIsLoading() {
const loading = overpassSource?.runningQuery?.data || osmApiSource?.isRunning?.data
isLoading.setData(loading)
@ -100,21 +108,40 @@ export default class LayoutSource extends FeatureSourceMerger {
osmApiSource?.isRunning?.addCallbackAndRun(() => setIsLoading())
const geojsonSources: UpdatableFeatureSource[] = geojsonlayers.map((l) =>
LayoutSource.setupGeojsonSource(l, mapProperties, isDisplayed(l.id))
ThemeSource.setupGeojsonSource(l, mapProperties, isDisplayed(l.id)),
)
super(...geojsonSources, ...Array.from(fromCache.values()), ...mvtSources, ...nonMvtSources)
const downloadAllBounds: UIEventSource<BBox> = new UIEventSource<BBox>(undefined)
const downloadAll= new OverpassFeatureSource({
layers: layers.filter(l => l.isNormal()),
bounds: mapProperties.bounds,
zoom: mapProperties.zoom,
overpassUrl: featureSwitches.overpassUrl,
overpassTimeout: featureSwitches.overpassTimeout,
overpassMaxZoom: new ImmutableStore(99),
widenFactor: 0,
},{
ignoreZoom: true
})
super(...geojsonSources, ...Array.from(fromCache.values()), ...mvtSources, ...nonMvtSources, downloadAll)
this.isLoading = isLoading
supportsForceDownload.push(...geojsonSources)
supportsForceDownload.push(...mvtSources) // Non-mvt sources are handled by overpass
this._mapBounds = mapProperties.bounds
this._downloadAll = downloadAll
this.supportsForceDownload = supportsForceDownload
}
private static setupMvtSource(
layer: LayerConfig,
mapProperties: { zoom: Store<number>; bounds: Store<BBox> },
isActive?: Store<boolean>
isActive?: Store<boolean>,
): UpdatableFeatureSource {
return new DynamicMvtileSource(layer, mapProperties, { isActive })
}
@ -122,12 +149,12 @@ export default class LayoutSource extends FeatureSourceMerger {
private static setupGeojsonSource(
layer: LayerConfig,
mapProperties: { zoom: Store<number>; bounds: Store<BBox> },
isActiveByFilter?: Store<boolean>
isActiveByFilter?: Store<boolean>,
): UpdatableFeatureSource {
const source = layer.source
const isActive = mapProperties.zoom.map(
(z) => (isActiveByFilter?.data ?? true) && z >= layer.minzoom,
[isActiveByFilter]
[isActiveByFilter],
)
if (source.geojsonZoomLevel === undefined) {
// This is a 'load everything at once' geojson layer
@ -143,7 +170,7 @@ export default class LayoutSource extends FeatureSourceMerger {
zoom: Store<number>,
backend: string,
featureSwitches: FeatureSwitchState,
fullNodeDatabase: FullNodeDatabaseSource
fullNodeDatabase: FullNodeDatabaseSource,
): OsmFeatureSource | undefined {
if (osmLayers.length == 0) {
return undefined
@ -177,7 +204,7 @@ export default class LayoutSource extends FeatureSourceMerger {
osmLayers: LayerConfig[],
bounds: Store<BBox>,
zoom: Store<number>,
featureSwitches: FeatureSwitchState
featureSwitches: FeatureSwitchState,
): OverpassFeatureSource | undefined {
if (osmLayers.length == 0) {
return undefined
@ -198,7 +225,7 @@ export default class LayoutSource extends FeatureSourceMerger {
zoom,
bounds,
layers: osmLayers,
widenFactor: featureSwitches.layoutToUse.widenFactor,
widenFactor: 1.5,
overpassUrl: featureSwitches.overpassUrl,
overpassTimeout: featureSwitches.overpassTimeout,
overpassMaxZoom: featureSwitches.overpassMaxZoom,
@ -206,13 +233,14 @@ export default class LayoutSource extends FeatureSourceMerger {
{
padToTiles: zoom.map((zoom) => Math.min(15, zoom + 1)),
isActive,
}
},
)
}
public async downloadAll() {
console.log("Downloading all data")
await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
console.log("Downloading all data:")
await this._downloadAll.updateAsync(this._mapBounds.data)
// await Promise.all(this.supportsForceDownload.map((i) => i.updateAsync()))
console.log("Done")
}
}

View file

@ -23,9 +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.layerDef.id.startsWith("note_import")
(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,
@ -66,17 +66,21 @@ export default class AllImageProviders {
return AllImageProviders.genericImageProvider
}
private static readonly _cachedImageStores: Record<string, Store<ProvidedImage[]>> = {}
/**
* Tries to extract all image data for this image
* Tries to extract all image data for this image. Cachedon tags?.data?.id
*/
public static LoadImagesFor(
tags: Store<Record<string, string>>,
tagKey?: string[],
tagKey?: string[]
): Store<ProvidedImage[]> {
if (tags?.data?.id === undefined) {
return undefined
}
const id = tags?.data?.id
if (this._cachedImageStores[id]) {
return this._cachedImageStores[id]
}
const source = new UIEventSource([])
const allSources: Store<ProvidedImage[]>[] = []
@ -86,14 +90,15 @@ 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)
})
}
this._cachedImageStores[id] = source
return source
}
@ -103,7 +108,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
@ -74,4 +88,12 @@ export default abstract class ImageProvider {
}): Promise<LicenseInfo>
public abstract apiUrls(): string[]
public static async offerImageAsDownload(image: ProvidedImage) {
const response = await fetch(image.url_hd ?? image.url)
const blob = await response.blob()
Utils.offerContentsAsDownloadableFile(blob, new URL(image.url).pathname.split("/").at(-1), {
mimetype: "image/jpg",
})
}
}

View file

@ -2,7 +2,7 @@ import { ImageUploader, UploadResult } from "./ImageUploader"
import LinkImageAction from "../Osm/Actions/LinkImageAction"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { OsmId, OsmTags } from "../../Models/OsmFeature"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Store, UIEventSource } from "../UIEventSource"
import { OsmConnection } from "../Osm/OsmConnection"
import { Changes } from "../Osm/Changes"
@ -10,6 +10,7 @@ import Translations from "../../UI/i18n/Translations"
import { Translation } from "../../UI/i18n/Translation"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations"
import { Feature } from "geojson"
/**
* The ImageUploadManager has a
@ -17,7 +18,7 @@ import { GeoOperations } from "../GeoOperations"
export class ImageUploadManager {
private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore
private readonly _layout: LayoutConfig
private readonly _theme: ThemeConfig
private readonly _indexedFeatures: IndexedFeatureSource
private readonly _gps: Store<GeolocationCoordinates | undefined>
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
@ -28,23 +29,32 @@ 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>
constructor(
layout: LayoutConfig,
layout: ThemeConfig,
uploader: ImageUploader,
featureProperties: FeaturePropertiesStore,
osmConnection: OsmConnection,
changes: Changes,
gpsLocation: Store<GeolocationCoordinates | undefined>,
allFeatures: IndexedFeatureSource,
reportError: (
message: string | Error | XMLHttpRequest,
extramessage?: string
) => Promise<void>
) {
this._uploader = uploader
this._featureProperties = featureProperties
this._layout = layout
this._theme = layout
this._osmConnection = osmConnection
this._changes = changes
this._indexedFeatures = allFeatures
this._gps = gpsLocation
this._reportError = reportError
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
@ -53,7 +63,7 @@ export class ImageUploadManager {
(startedCount) => {
return startedCount > failed.data + done.data
},
[failed, done],
[failed, done]
)
}
@ -81,11 +91,10 @@ export class ImageUploadManager {
public canBeUploaded(file: File): true | { error: Translation } {
const sizeInBytes = file.size
const self = this
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
const error = Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: self._uploader.maxFileSizeInMegabytes + "MB",
max_size: this._uploader.maxFileSizeInMegabytes + "MB",
})
return { error }
}
@ -102,7 +111,8 @@ export class ImageUploadManager {
public async uploadImageAndApply(
file: File,
tagsStore: UIEventSource<OsmTags>,
targetKey?: string,
targetKey: string,
noblur: boolean
): Promise<void> {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
@ -120,16 +130,23 @@ export class ImageUploadManager {
author,
file,
targetKey,
noblur
)
if (!uploadResult) {
return
}
const properties = this._featureProperties.getStore(featureId)
const action = new LinkImageAction(featureId, uploadResult. key, uploadResult . value, properties, {
theme: tags?.data?.["_orig_theme"] ?? this._layout.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)
}
@ -139,6 +156,8 @@ export class ImageUploadManager {
author: string,
blob: File,
targetKey: string | undefined,
noblur: boolean,
feature?: Feature
): Promise<UploadResult> {
this.increaseCountFor(this._uploadStarted, featureId)
let key: string
@ -148,33 +167,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)) {
const feature = this._indexedFeatures.featuresById.data.get(featureId)
if (location === undefined || location?.some((l) => l === undefined)) {
feature ??= this._indexedFeatures.featuresById.data.get(featureId)
location = GeoOperations.centerpointCoordinates(feature)
}
try {
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(blob, location, author))
;({ 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))
;({ 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,
})
)
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,9 +6,14 @@ export interface ImageUploader {
*/
uploadImage(
blob: File,
currentGps: [number,number],
author: string
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

@ -11,24 +11,36 @@ import SvelteUIElement from "../../UI/Base/SvelteUIElement"
import Panoramax_bw from "../../assets/svg/Panoramax_bw.svelte"
import Link from "../../UI/Base/Link"
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)
private static defaultPanoramax = new AuthorizedPanoramax(
Constants.panoramax.url,
Constants.panoramax.token,
3000
)
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) {
@ -40,25 +52,22 @@ export default class PanoramaxImageProvider extends ImageProvider {
* @param id
* @private
*/
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData, url: string }> {
const sequence = "6e702976-580b-419c-8fb3-cf7bd364e6f8" // We always reuse this sequence
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData; url: string }> {
const url = `https://panoramax.mapcomplete.org/`
const data = await PanoramaxImageProvider.defaultPanoramax.imageInfo(id, sequence)
const data = await PanoramaxImageProvider.defaultPanoramax.imageInfo(id)
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
@ -79,8 +88,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"],
@ -89,14 +99,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 }
}
}
@ -117,10 +126,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))
@ -128,24 +136,34 @@ 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" &&
img?.status !== "hidden"
)
}
Stores.Chronic(1500, () =>
hasLoading(source.data),
).addCallback(_ => {
console.log("UPdating... ")
super.getRelevantUrlsFor(tags, prefixes).then(data => {
console.log("New panoramax data is", data, hasLoading(data))
Stores.Chronic(1500, () => hasLoading(source.data)).addCallback((_) => {
console.log(
"Testing panoramax URLS again as some were loading",
source.data,
hasLoading(source.data)
)
super.getRelevantUrlsFor(tags, prefixes).then((data) => {
source.set(data)
return !hasLoading(data)
})
})
return source
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 {
@ -158,37 +176,76 @@ export default class PanoramaxImageProvider extends ImageProvider {
public apiUrls(): string[] {
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
}
public static getPanoramaxInstance(host: string) {
host = new URL(host).host
if (new URL(this.defaultPanoramax.host).host === host) {
return this.defaultPanoramax
}
if (new URL(this.xyz.host).host === host) {
return this.xyz
}
return new Panoramax(host)
}
}
export class PanoramaxUploader implements ImageUploader {
private readonly _panoramax: AuthorizedPanoramax
public readonly panoramax: AuthorizedPanoramax
maxFileSizeInMegabytes = 100 * 1000 * 1000 // 100MB
private readonly _targetSequence: Store<string>
constructor(url: string, token: string) {
this._panoramax = new AuthorizedPanoramax(url, token)
constructor(url: string, token: string, targetSequence: Store<string>) {
this._targetSequence = targetSequence
this.panoramax = new AuthorizedPanoramax(url, token)
}
async uploadImage(blob: File, currentGps: [number, number], author: 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#/
const tags = await ExifReader.load(blob)
const hasDate = tags.DateTime !== undefined
const hasGPS = tags.GPSLatitude !== undefined && tags.GPSLongitude !== undefined
let [lon, lat] = currentGps
let datetime = new Date().toISOString()
try {
const tags = await ExifReader.load(blob)
const [[latD], [latM], [latS, latSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLatitude.value
const [[lonD], [lonM], [lonS, lonSDenom]] = <
[[number, number], [number, number], [number, number]]
>tags?.GPSLongitude.value
lat = latD + latM / 60 + latS / (3600 * latSDenom)
lon = lonD + lonM / 60 + lonS / (3600 * lonSDenom)
const [lon, lat] = currentGps
const [date, time] = tags.DateTime.value[0].split(" ")
datetime = new Date(date.replaceAll(":", "-") + "T" + time).toISOString()
const p = this._panoramax
const defaultSequence = (await p.mySequences())[0]
const img = <ImageData>await p.addImage(blob, defaultSequence, {
lat: !hasGPS ? lat : undefined,
lon: !hasGPS ? lon : undefined,
datetime: !hasDate ? new Date().toISOString() : undefined,
console.log("Tags are", tags)
} catch (e) {
console.error("Could not read EXIF-tags")
}
const p = this.panoramax
sequenceId ??= this._targetSequence?.data ?? Constants.panoramax.sequence
const sequence: { id: string; "stats:items": { count: number } } = (
await p.mySequences()
).find((s) => s.id === sequenceId)
const img = <ImageData>await p.addImage(blob, sequence, {
lon,
lat,
datetime,
isBlurred: noblur,
exifOverride: {
Artist: author,
},
})
PanoramaxImageProvider.singleton.addKnownMeta(img)
return {
@ -197,5 +254,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

@ -3,7 +3,7 @@ import { ExtraFuncParams, ExtraFunctions, ExtraFuncType } from "./ExtraFunctions
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { Feature } from "geojson"
import FeaturePropertiesStore from "./FeatureSource/Actors/FeaturePropertiesStore"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
import { GeoIndexedStoreForLayer } from "./FeatureSource/Actors/GeoIndexedStore"
import { IndexedFeatureSource } from "./FeatureSource/FeatureSource"
import OsmObjectDownloader from "./Osm/OsmObjectDownloader"
@ -27,7 +27,7 @@ export default class MetaTagging {
>()
private state: {
readonly selectedElement: Store<Feature>
readonly layout: LayoutConfig
readonly theme: ThemeConfig
readonly osmObjectDownloader: OsmObjectDownloader
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
readonly indexedFeatures: IndexedFeatureSource
@ -40,7 +40,7 @@ export default class MetaTagging {
constructor(state: {
readonly selectedElement: Store<Feature>
readonly layout: LayoutConfig
readonly theme: ThemeConfig
readonly osmObjectDownloader: OsmObjectDownloader
readonly perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer>
readonly indexedFeatures: IndexedFeatureSource
@ -48,7 +48,7 @@ export default class MetaTagging {
}) {
this.state = state
const params = (this.params = MetaTagging.createExtraFuncParams(state))
for (const layer of state.layout.layers) {
for (const layer of state.theme.layers) {
if (layer.source === null) {
continue
}
@ -69,7 +69,7 @@ export default class MetaTagging {
features,
params,
layer,
state.layout,
state.theme,
state.osmObjectDownloader,
state.featureProperties
)
@ -115,7 +115,7 @@ export default class MetaTagging {
return
}
const state = this.state
const layer = state.layout.getMatchingLayer(feature.properties)
const layer = state.theme.getMatchingLayer(feature.properties)
if (!layer) {
return
}
@ -124,7 +124,7 @@ export default class MetaTagging {
[feature],
this.params,
layer,
state.layout,
state.theme,
state.osmObjectDownloader,
state.featureProperties,
{
@ -161,7 +161,7 @@ export default class MetaTagging {
features: Feature[],
params: ExtraFuncParams,
layer: LayerConfig,
layout: LayoutConfig,
theme: ThemeConfig,
osmObjectDownloader: OsmObjectDownloader,
featurePropertiesStores?: FeaturePropertiesStore,
options?: {
@ -190,7 +190,7 @@ export default class MetaTagging {
// The calculated functions - per layer - which add the new keys
// Calculated functions are defined by the layer
const layerFuncs = this.createRetaggingFunc(layer, ExtraFunctions.constructHelpers(params))
const state: MetataggingState = { layout, osmObjectDownloader }
const state: MetataggingState = { theme: theme, osmObjectDownloader }
let atLeastOneFeatureChanged = false
let strictlyEvaluated = 0
@ -424,7 +424,7 @@ export default class MetaTagging {
}
}
if (!window.location.pathname.endsWith("theme.html")) {
if (!Utils.runningFromConsole && !window.location.pathname.endsWith("theme.html")) {
console.warn(
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)

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

@ -7,7 +7,7 @@ import CreateWayWithPointReuseAction, { MergePointConfig } from "./CreateWayWith
import { And } from "../../Tags/And"
import { TagUtils } from "../../Tags/TagUtils"
import { FeatureSource, IndexedFeatureSource } from "../../FeatureSource/FeatureSource"
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../../Models/ThemeConfig/ThemeConfig"
import { Position } from "geojson"
import FullNodeDatabaseSource from "../../FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
@ -32,7 +32,7 @@ export default class CreateMultiPolygonWithPointReuseAction
outerRingCoordinates: Position[],
innerRingsCoordinates: Position[][],
state: {
layout: LayoutConfig
theme: ThemeConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
fullNodeDatabase?: FullNodeDatabaseSource
@ -43,7 +43,7 @@ export default class CreateMultiPolygonWithPointReuseAction
super(null, true)
this._tags = [...tags, new Tag("type", "multipolygon")]
this.changeType = changeType
this.theme = state?.layout?.id ?? ""
this.theme = state?.theme?.id ?? ""
this.createOuterWay = new CreateWayWithPointReuseAction(
[],
<[number, number][]>outerRingCoordinates,
@ -55,7 +55,7 @@ export default class CreateMultiPolygonWithPointReuseAction
new CreateNewWayAction(
[],
ringCoordinates.map(([lon, lat]) => ({ lat, lon })),
{ theme: state?.layout?.id }
{ theme: state?.theme?.id }
)
)

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

@ -9,7 +9,7 @@ import { FeatureSource, IndexedFeatureSource } from "../../FeatureSource/Feature
import StaticFeatureSource from "../../FeatureSource/Sources/StaticFeatureSource"
import CreateNewNodeAction from "./CreateNewNodeAction"
import CreateNewWayAction from "./CreateNewWayAction"
import LayoutConfig from "../../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../../Models/ThemeConfig/ThemeConfig"
import FullNodeDatabaseSource from "../../FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"
import { Position } from "geojson"
@ -69,7 +69,7 @@ export default class CreateWayWithPointReuseAction
*/
private readonly _coordinateInfo: CoordinateInfo[]
private readonly _state: {
layout: LayoutConfig
theme: ThemeConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
fullNodeDatabase?: FullNodeDatabaseSource
@ -80,7 +80,7 @@ export default class CreateWayWithPointReuseAction
tags: Tag[],
coordinates: Position[],
state: {
layout: LayoutConfig
theme: ThemeConfig
changes: Changes
indexedFeatures: IndexedFeatureSource
fullNodeDatabase?: FullNodeDatabaseSource
@ -203,7 +203,7 @@ export default class CreateWayWithPointReuseAction
}
public async CreateChangeDescriptions(changes: Changes): Promise<ChangeDescription[]> {
const theme = this._state?.layout?.id
const theme = this._state?.theme?.id
const allChanges: ChangeDescription[] = []
const nodeIdsToUse: { lat: number; lon: number; nodeId?: number }[] = []
for (let i = 0; i < this._coordinateInfo.length; i++) {

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(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

@ -24,7 +24,7 @@ import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesSto
*/
export class Changes {
public readonly pendingChanges: UIEventSource<ChangeDescription[]> =
LocalStorageSource.GetParsed<ChangeDescription[]>("pending-changes", [])
LocalStorageSource.getParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
public readonly state: {
allElements?: IndexedFeatureSource
@ -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,15 +64,18 @@ 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
this.backend = state.osmConnection.Backend()
this._reportError = reportError
this._changesetHandler = new ChangesetHandler(
state.featureSwitches.featureSwitchIsTesting,
state.featureSwitches?.featureSwitchIsTesting ?? new ImmutableStore(false),
state.osmConnection,
state.featureProperties,
this,
@ -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),
},
})
}
@ -297,6 +300,23 @@ export class Changes {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} {
return Changes.createChangesetObjectsStatic(
changes,
downloadedOsmObjects,
ignoreNoCreate,
this.previouslyCreated
)
}
public static createChangesetObjectsStatic(
changes: ChangeDescription[],
downloadedOsmObjects: OsmObject[],
ignoreNoCreate: boolean = false,
previouslyCreated: OsmObject[]
): {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} {
/**
* This is a rather complicated method which does a lot of stuff.
@ -322,7 +342,7 @@ export class Changes {
states.set(o.type + "/" + o.id, "unchanged")
}
for (const o of this.previouslyCreated) {
for (const o of previouslyCreated) {
objects.set(o.type + "/" + o.id, o)
states.set(o.type + "/" + o.id, "unchanged")
}
@ -372,7 +392,7 @@ export class Changes {
throw "Hmm? This is a bug"
}
objects.set(id, osmObj)
this.previouslyCreated.push(osmObj)
previouslyCreated.push(osmObj)
}
const state = states.get(id)
@ -837,7 +857,16 @@ export class Changes {
)
// We keep all the refused changes to try them again
this.pendingChanges.setData(refusedChanges.flatMap((c) => c))
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)
}
@ -210,7 +212,7 @@ export class OsmConnection {
console.log("Trying to log in...")
this.updateAuthObject()
LocalStorageSource.Get("location_before_login").setData(
LocalStorageSource.get("location_before_login").setData(
Utils.runningFromConsole ? undefined : window.location.href
)
this.auth.xhr(
@ -521,7 +523,7 @@ export class OsmConnection {
this.auth.authenticate(function () {
// Fully authed at this point
console.log("Authentication successful!")
const previousLocation = LocalStorageSource.Get("location_before_login")
const previousLocation = LocalStorageSource.get("location_before_login")
callback(previousLocation.data)
})
}
@ -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,8 +5,11 @@ import OSMAuthInstance = OSMAuth.osmAuth
import { Utils } from "../../Utils"
export class OsmPreferences {
private preferences: Record<string, UIEventSource<string>> = {}
/**
* A 'cache' of all the preference stores
* @private
*/
private readonly preferences: Record<string, UIEventSource<string>> = {}
private localStorageInited: Set<string> = new Set()
/**
@ -15,6 +18,10 @@ export class OsmPreferences {
*/
private seenKeys: string[] = []
/**
* Contains a dictionary which has all preferences
* @private
*/
private readonly _allPreferences: UIEventSource<Record<string, string>> = new UIEventSource({})
public readonly allPreferences: Store<Readonly<Record<string, string>>> = this._allPreferences
private readonly _fakeUser: boolean
@ -31,7 +38,6 @@ export class OsmPreferences {
})
}
private setPreferencesAll(key: string, value: string) {
if (this._allPreferences.data[key] !== value) {
this._allPreferences.data[key] = value
@ -46,11 +52,12 @@ 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)
})
@ -62,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) {
@ -73,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 })
}
@ -91,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)
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)
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
@ -125,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)
}
@ -139,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
*
@ -171,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 = ""
@ -186,7 +191,6 @@ export class OsmPreferences {
return merged
}
/**
* Bulk-downloads all preferences
* @private
@ -212,10 +216,9 @@ export class OsmPreferences {
dict[k] = pref.getAttribute("v")
}
resolve(dict)
},
}
)
})
}
/**
@ -229,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
}
@ -240,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
@ -256,7 +257,6 @@ export class OsmPreferences {
v = v.slice(255)
i++
}
}
/**
@ -274,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()
}
)
})
}
/**
@ -319,7 +317,6 @@ export class OsmPreferences {
}
return new Promise<void>((resolve, reject) => {
this.auth.xhr(
{
method: "PUT",
@ -334,17 +331,18 @@ export class OsmPreferences {
return
}
resolve()
},
}
)
})
}
async removeAllWithPrefix(prefix: string) {
const keys = this.seenKeys
for (const key in keys) {
for (const key of keys) {
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

@ -1,13 +1,15 @@
import GeocodingProvider, { GeocodeResult } from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { ImmutableStore, Store } from "../UIEventSource"
import CoordinateParser from "coordinate-parser"
/**
* A simple search-class which interprets possible locations
*/
export default class CoordinateSearch implements GeocodingProvider {
private static readonly latLonRegexes: ReadonlyArray<RegExp> = [
/^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/^ *(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/^ *(-?[0-9]+,[0-9]+)[ ;/\\]+(-?[0-9]+,[0-9]+)/,
/lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lng[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
@ -17,9 +19,10 @@ export default class CoordinateSearch implements GeocodingProvider {
private static readonly lonLatRegexes: ReadonlyArray<RegExp> = [
/^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/^ *(-?[0-9]+,[0-9]+)[ ;/\\]+(-?[0-9]+,[0-9]+)/,
/lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
/lng[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,
]
/**
@ -58,21 +61,48 @@ export default class CoordinateSearch implements GeocodingProvider {
* const results = ls.directSearch(' lat="-57.5802905" lon="-12.7202538"')
* results.length // => 1
* results[0] // => {lat: -57.5802905, lon: -12.7202538, "display_name": "lon: -12.720254, lat: -57.58029", "category": "coordinate","osm_id": "-12.720254/-57.58029", "source": "coordinate:latlon"}
*
* // Should work with commas
* const ls = new CoordinateSearch()
* const results = ls.directSearch('51,047977 3,51184')
* results.length // => 2
* 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"))
return matches.concat(matchesLonLat)
const matchesLonLat = Utils.NoNull(
CoordinateSearch.lonLatRegexes.map((r) => query.match(r))
).map((m) => CoordinateSearch.asResult(m[1], m[2], "lonlat"))
const init = matches.concat(matchesLonLat)
if (init.length > 0) {
return init
}
try {
const c = new CoordinateParser(query)
return [
CoordinateSearch.asResult(
"" + c.getLongitude(),
"" + c.getLatitude(),
"coordinateParser"
),
]
} catch {
return []
}
}
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 {
lonIn = lonIn.replaceAll(",", ".")
latIn = latIn.replaceAll(",", ".")
const lon = Number(lonIn)
const lat = Number(latIn)
const lonStr = CoordinateSearch.round6(lon)
@ -82,7 +112,7 @@ export default class CoordinateSearch implements GeocodingProvider {
lon,
display_name: "lon: " + lonStr + ", lat: " + latStr,
category: "coordinate",
source: "coordinate:"+source,
source: "coordinate:" + source,
osm_id: lonStr + "/" + latStr,
}
}
@ -94,5 +124,4 @@ export default class CoordinateSearch implements GeocodingProvider {
async search(query: string): Promise<GeocodeResult[]> {
return this.directSearch(query)
}
}

View file

@ -4,18 +4,22 @@ import Constants from "../../Models/Constants"
import FilterConfig, { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayerState from "../State/LayerState"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export type FilterSearchResult = { option: FilterConfigOption, filter: FilterConfig, layer: LayerConfig, index: number }
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
export type FilterSearchResult = {
option: FilterConfigOption
filter: FilterConfig
layer: LayerConfig
index: number
}
/**
* Searches matching filters
*/
export default class FilterSearch {
private readonly _state: {layerState: LayerState, layout: LayoutConfig}
private readonly _state: { layerState: LayerState; theme: ThemeConfig }
constructor(state: {layerState: LayerState, layout: LayoutConfig}) {
constructor(state: { layerState: LayerState; theme: ThemeConfig }) {
this._state = state
}
@ -23,14 +27,17 @@ 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.layout.layers) {
for (const layer of this._state.theme.layers) {
if (!Array.isArray(layer.filters)) {
continue
}
@ -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

@ -1,26 +1,30 @@
import SearchUtils from "./SearchUtils"
import ThemeSearch from "./ThemeSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Utils } from "../../Utils"
export default class LayerSearch {
private readonly _layout: LayoutConfig
private readonly _theme: ThemeConfig
private readonly _layerWhitelist: Set<string>
constructor(layout: LayoutConfig) {
this._layout = layout
this._layerWhitelist = new Set(layout.layers
.filter(l => l.isNormal())
.map(l => l.id))
constructor(theme: ThemeConfig) {
this._theme = theme
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,32 +33,28 @@ export default class LayerSearch {
continue
}
const keywords = ThemeSearch.officialThemes.layers[id]
const distance = Math.min(...queryParts.map(q => SearchUtils.scoreKeywords(q, keywords)))
result[id] = distance
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._layout.getLayer(layer),
layer: this._theme.getLayer(layer),
score: scores[layer],
})
}
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

@ -0,0 +1,52 @@
import { Store, Stores, UIEventSource } from "../UIEventSource"
import GeocodingProvider, {
GeocodeResult,
GeocodingOptions,
ReverseGeocodingProvider,
ReverseGeocodingResult,
} from "./GeocodingProvider"
import { decode as pluscode_decode } from "pluscodes"
export default class OpenLocationCodeSearch implements GeocodingProvider {
/**
* A regex describing all plus-codes
*/
public static readonly _isPlusCode =
/^([2-9CFGHJMPQRVWX]{2}|00){2,4}\+([2-9CFGHJMPQRVWX]{2,3})?$/
/**
*
* OpenLocationCodeSearch.isPlusCode("9FFW84J9+XG") // => true
* OpenLocationCodeSearch.isPlusCode("9FFW84J9+") // => true
* OpenLocationCodeSearch.isPlusCode("9AFW84J9+") // => false
* OpenLocationCodeSearch.isPlusCode("9FFW+") // => true
* OpenLocationCodeSearch.isPlusCode("9FFW0000+") // => true
* OpenLocationCodeSearch.isPlusCode("9FFw0000+") // => true
* OpenLocationCodeSearch.isPlusCode("9FFW000+") // => false
*
*/
public static isPlusCode(str: string) {
return str.toUpperCase().match(this._isPlusCode) !== null
}
async search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> {
if (!OpenLocationCodeSearch.isPlusCode(query)) {
return undefined
}
const { latitude, longitude } = pluscode_decode(query)
return [
{
lon: longitude,
lat: latitude,
description: "Open Location Code",
osm_id: query,
display_name: query.toUpperCase(),
},
]
}
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return Stores.FromPromise(this.search(query, options))
}
}

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"
@ -13,34 +14,45 @@ import { GeoOperations } from "../GeoOperations"
import { Store, Stores } from "../UIEventSource"
export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider {
private _endpoint: string
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(suggestionLimit:number = 5, searchLimit:number = 1, endpoint?: string) {
constructor(
ignoreBounds: boolean = false,
suggestionLimit: number = 5,
searchLimit: number = 1,
endpoint?: string
) {
this.ignoreBounds = ignoreBounds
this.suggestionLimit = suggestionLimit
this.searchLimit = searchLimit
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
}
/**
@ -49,17 +61,15 @@ 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[]> {
return Stores.FromPromise(this.search(query, options, this.suggestionLimit))
return Stores.FromPromise(this.search(query, options))
}
private buildDescription(entry: Feature) {
@ -75,7 +85,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
@ -94,7 +103,6 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
case "country":
return undefined
}
}
private getCategory(entry: Feature) {
@ -111,19 +119,21 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
return p.type
}
async search(query: string, options?: GeocodingOptions, limit?: number): Promise<GeocodeResult[]> {
async search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> {
if (query.length < 3) {
return []
}
limit ??= this.searchLimit
const limit = this.searchLimit
let bbox = ""
if (options?.bbox) {
if (options?.bbox && !this.ignoreBounds) {
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) {
@ -138,11 +148,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,40 +3,42 @@ 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
}
if (searchTerm === "personal") {
window.location.href = ThemeSearch.createUrlFor({ id: "personal" }, undefined)
return true
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
return true
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
return true
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
return true
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
return true
}
if (searchTerm === "studio") {
window.location.href = "./studio.html"
return true
}
return false
}
/**
* Searches for the smallest distance in words; will split both the query and the terms
*
@ -44,19 +46,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

@ -1,4 +1,4 @@
import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig, { MinimalThemeInformation } from "../../Models/ThemeConfig/ThemeConfig"
import { Store } from "../UIEventSource"
import UserRelatedState from "../State/UserRelatedState"
import { Utils } from "../../Utils"
@ -8,57 +8,59 @@ import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
import { OsmConnection } from "../Osm/OsmConnection"
type ThemeSearchScore = {
theme: MinimalLayoutInformation,
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: MinimalLayoutInformation[],
themes: MinimalThemeInformation[]
layers: Record<string, Record<string, string[]>>
} = <any> themeOverview
public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>()
} = <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: MinimalLayoutInformation[]
private readonly _otherThemes: MinimalThemeInformation[]
constructor(state: {osmConnection: OsmConnection, layout: LayoutConfig}) {
this._layersToIgnore = state.layout.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.layout.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): MinimalLayoutInformation[] {
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: MinimalLayoutInformation[], 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: MinimalLayoutInformation[], 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: MinimalLayoutInformation[], ignoreLayers: string[] = []): MinimalLayoutInformation[] {
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

@ -8,7 +8,7 @@ import { TagUtils } from "./Tags/TagUtils"
import { Feature, LineString } from "geojson"
import { OsmTags } from "../Models/OsmFeature"
import { UIEventSource } from "./UIEventSource"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../Models/ThemeConfig/ThemeConfig"
import OsmObjectDownloader from "./Osm/OsmObjectDownloader"
import countryToCurrency from "country-to-currency"
@ -16,7 +16,7 @@ import countryToCurrency from "country-to-currency"
* All elements that are needed to perform metatagging
*/
export interface MetataggingState {
layout: LayoutConfig
theme: ThemeConfig
osmObjectDownloader: OsmObjectDownloader
}
@ -399,7 +399,7 @@ export default class SimpleMetaTaggers {
},
(feature, _, __, state) => {
const units = Utils.NoNull(
[].concat(...(state?.layout?.layers?.map((layer) => layer.units) ?? []))
[].concat(...(state?.theme?.layers?.map((layer) => layer.units) ?? []))
)
if (units.length == 0) {
return

View file

@ -1,7 +1,7 @@
/**
* The part of the global state which initializes the feature switches, based on default values and on the layoutToUse
* The part of the global state which initializes the feature switches, based on default values and on the theme
*/
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { UIEventSource } from "../UIEventSource"
import { QueryParameters } from "../Web/QueryParameters"
import Constants from "../../Models/Constants"
@ -45,11 +45,6 @@ export class OsmConnectionFeatureSwitches {
}
export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
/**
* The layout that is being used in this run
*/
public readonly layoutToUse: LayoutConfig
public readonly featureSwitchEnableLogin: UIEventSource<boolean>
public readonly featureSwitchSearch: UIEventSource<boolean>
public readonly featureSwitchBackgroundSelection: UIEventSource<boolean>
@ -74,9 +69,8 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
public readonly featureSwitchMorePrivacy: UIEventSource<boolean>
public readonly featureSwitchLayerDefault: UIEventSource<boolean>
public constructor(layoutToUse?: LayoutConfig) {
public constructor(theme?: ThemeConfig) {
super()
this.layoutToUse = layoutToUse
const legacyRewrite: Record<string, string | string[]> = {
"fs-userbadge": "fs-enable-login",
@ -102,7 +96,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.featureSwitchEnableLogin = FeatureSwitchUtils.initSwitch(
"fs-enable-login",
layoutToUse?.enableUserBadge ?? true,
theme?.enableUserBadge ?? true,
"Disables/Enables logging in and thus disables editing all together. This effectively puts MapComplete into read-only mode."
)
{
@ -117,18 +111,18 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.featureSwitchSearch = FeatureSwitchUtils.initSwitch(
"fs-search",
layoutToUse?.enableSearch ?? true,
theme?.enableSearch ?? true,
"Disables/Enables the search bar"
)
this.featureSwitchBackgroundSelection = FeatureSwitchUtils.initSwitch(
"fs-background",
layoutToUse?.enableBackgroundLayerSelection ?? true,
theme?.enableBackgroundLayerSelection ?? true,
"Disables/Enables the background layer control where a user can enable e.g. aerial imagery"
)
this.featureSwitchFilter = FeatureSwitchUtils.initSwitch(
"fs-filter",
layoutToUse?.enableLayers ?? true,
theme?.enableLayers ?? true,
"Disables/Enables the filter view where a user can enable/disable MapComplete-layers or filter for certain properties"
)
@ -149,17 +143,17 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
)
this.featureSwitchBackToThemeOverview = FeatureSwitchUtils.initSwitch(
"fs-homepage-link",
layoutToUse?.enableMoreQuests ?? true,
theme?.enableMoreQuests ?? true,
"Disables/Enables the various links which go back to the index page with the theme overview"
)
this.featureSwitchShareScreen = FeatureSwitchUtils.initSwitch(
"fs-share-screen",
layoutToUse?.enableShareScreen ?? true,
theme?.enableShareScreen ?? true,
"Disables/Enables the 'Share-screen'-tab in the welcome message"
)
this.featureSwitchGeolocation = FeatureSwitchUtils.initSwitch(
"fs-geolocation",
layoutToUse?.enableGeolocation ?? true,
theme?.enableGeolocation ?? true,
"Disables/Enables the geolocation button"
)
@ -170,19 +164,19 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
)
this.featureSwitchShowAllQuestions = FeatureSwitchUtils.initSwitch(
"fs-all-questions",
layoutToUse?.enableShowAllQuestions ?? false,
theme?.enableShowAllQuestions ?? false,
"Always show all questions"
)
this.featureSwitchEnableExport = FeatureSwitchUtils.initSwitch(
"fs-export",
layoutToUse?.enableExportButton ?? true,
theme?.enableExportButton ?? true,
"Enable the export as GeoJSON and CSV button"
)
this.featureSwitchCache = FeatureSwitchUtils.initSwitch(
"fs-cache",
layoutToUse?.enableCache ?? true,
theme?.enableCache ?? true,
"Enable/disable caching from localStorage"
)
@ -209,13 +203,13 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.featureSwitchMorePrivacy = QueryParameters.GetBooleanQueryParameter(
"moreprivacy",
layoutToUse.enableMorePrivacy,
theme.enableMorePrivacy,
"If true, the location distance indication will not be written to the changeset and other privacy enhancing measures might be taken."
)
this.overpassUrl = QueryParameters.GetQueryParameter(
"overpassUrl",
(layoutToUse?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
(theme?.overpassUrl ?? Constants.defaultOverpassUrls).join(","),
"Point mapcomplete to a different overpass-instance. Example: https://overpass-api.de/api/interpreter"
).sync(
(param) => param?.split(","),
@ -226,7 +220,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.overpassTimeout = UIEventSource.asInt(
QueryParameters.GetQueryParameter(
"overpassTimeout",
"" + layoutToUse?.overpassTimeout,
"" + theme?.overpassTimeout,
"Set a different timeout (in seconds) for queries in overpass"
)
)
@ -234,7 +228,7 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.overpassMaxZoom = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"overpassMaxZoom",
"" + layoutToUse?.overpassMaxZoom,
"" + theme?.overpassMaxZoom,
" point to switch between OSM-api and overpass"
)
)
@ -242,14 +236,14 @@ export default class FeatureSwitchState extends OsmConnectionFeatureSwitches {
this.osmApiTileSize = UIEventSource.asInt(
QueryParameters.GetQueryParameter(
"osmApiTileSize",
"" + layoutToUse?.osmApiTileSize,
"" + theme?.osmApiTileSize,
"Tilesize when the OSM-API is used to fetch data within a BBOX"
)
)
this.backgroundLayerId = QueryParameters.GetQueryParameter(
"background",
layoutToUse?.defaultBackgroundId,
theme?.defaultBackgroundId,
[
"When set, load this raster layer (or a layer of this category) as background layer instead of using the default background. This is as if the user opened the background selection menu and selected the layer with the given id or category.",
"Most raster layers are based on the [editor layer index](https://github.com/osmlab/editor-layer-index)",

View file

@ -58,7 +58,7 @@ export class GeoLocationState {
* @private
*/
private readonly _previousLocationGrant: UIEventSource<boolean> =
LocalStorageSource.GetParsed<boolean>("geolocation-permissions", false)
LocalStorageSource.getParsed<boolean>("geolocation-permissions", false)
/**
* Used to detect a permission retraction

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

@ -8,22 +8,22 @@ import ThemeSearch from "../Search/ThemeSearch"
import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch"
import PhotonSearch from "../Search/PhotonSearch"
import ThemeViewState from "../../Models/ThemeViewState"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import type { MinimalThemeInformation } from "../../Models/ThemeConfig/ThemeConfig"
import { Translation } from "../../UI/i18n/Translation"
import GeocodingFeatureSource from "../Search/GeocodingFeatureSource"
import LayerSearch from "../Search/LayerSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FeatureSource } from "../FeatureSource/FeatureSource"
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)
public readonly suggestions: Store<SearchResult[]>
public readonly filterSuggestions: Store<FilterSearchResult[]>
public readonly themeSuggestions: Store<MinimalLayoutInformation[]>
public readonly themeSuggestions: Store<MinimalThemeInformation[]>
public readonly layerSuggestions: Store<LayerConfig[]>
public readonly locationSearchers: ReadonlyArray<GeocodingProvider>
@ -38,58 +38,70 @@ export default class SearchState {
this.locationSearchers = [
new LocalElementSearch(state, 5),
new CoordinateSearch(),
new OpenLocationCodeSearch(),
new OpenStreetMapIdSearch(state),
new PhotonSearch() // new NominatimGeocoding(),
new PhotonSearch(true, 2),
new PhotonSearch(),
// new NominatimGeocoding(),
]
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.layout)
this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.search(query, 5))
const layerSearch = new LayerSearch(state.theme)
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) {
@ -108,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
@ -135,12 +147,21 @@ export default class SearchState {
}
}
clickedOnMap(feature: Feature) {
async clickedOnMap(feature: Feature) {
const osmid = feature.properties.osm_id
const localElement = this.state.indexedFeatures.featuresById.data.get(osmid)
if (localElement) {
this.state.selectedElement.set(localElement)
return
}
// This feature might not be loaded because we zoomed out
const object = await this.state.osmObjectDownloader.DownloadObjectAsync(osmid)
if (object === "deleted") {
return
}
const f = object.asGeoJson()
this.state.indexedFeatures.addItem(f)
this.state.featureProperties.trackFeature(f)
this.state.selectedElement.set(f)
}
}

View file

@ -1,4 +1,4 @@
import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import ThemeConfig, { MinimalThemeInformation } from "../../Models/ThemeConfig/ThemeConfig"
import { OsmConnection } from "../Osm/OsmConnection"
import { MangroveIdentity } from "../Web/MangroveReviews"
import { Store, Stores, UIEventSource } from "../UIEventSource"
@ -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,14 +160,14 @@ export default class UserRelatedState {
*/
public readonly gpsLocationHistoryRetentionTime = new UIEventSource(
7 * 24 * 60 * 60,
"gps_location_retention",
"gps_location_retention"
)
public readonly addNewFeatureMode = new UIEventSource<
"button" | "button_click_right" | "button_click" | "click" | "click_right"
>("button_click_right")
public readonly showScale : UIEventSource<boolean>
public readonly showScale: UIEventSource<boolean>
/**
* Preferences as tags exposes many preferences and state properties as record.
@ -180,18 +183,17 @@ export default class UserRelatedState {
public readonly recentlyVisitedThemes: OptionallySyncedHistory<string>
public readonly recentlyVisitedSearch: OptionallySyncedHistory<GeocodeResult>
constructor(
osmConnection: OsmConnection,
layout?: LayoutConfig,
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")
@ -202,16 +204,20 @@ export default class UserRelatedState {
this.a11y = this.osmConnection.getPreference("a11y")
this.mangroveIdentity = new MangroveIdentity(
this.osmConnection.getPreference("identity", undefined,"mangrove"),
this.osmConnection.getPreference("identity-creation-date", undefined,"mangrove"),
this.osmConnection.getPreference("identity", 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)
@ -272,7 +279,17 @@ export default class UserRelatedState {
}
}
public getUnofficialTheme(id: string): (MinimalLayoutInformation & { definition }) | undefined {
/**
* Adds a newly visited unofficial theme (or update the info).
*
* @param themeInfo note that themeInfo.id should be the URL where it was found
*/
public addUnofficialTheme(themeInfo: MinimalThemeInformation) {
const pref = this.osmConnection.getPreference("unofficial-theme-" + themeInfo.id)
this.osmConnection.isLoggedIn.when(() => pref.set(JSON.stringify(themeInfo)))
}
public getUnofficialTheme(id: string): MinimalThemeInformation | undefined {
const pref = this.osmConnection.getPreference("unofficial-theme-" + id)
const str = pref.data
@ -282,20 +299,20 @@ export default class UserRelatedState {
}
try {
return <MinimalLayoutInformation & { definition: string }>JSON.parse(str)
return JSON.parse(str)
} 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
}
}
public markLayoutAsVisited(layout: LayoutConfig) {
public markLayoutAsVisited(layout: ThemeConfig) {
if (!layout) {
console.error("Trying to mark a layout as visited, but ", layout, " got passed")
return
@ -318,7 +335,7 @@ export default class UserRelatedState {
title: layout.title.translations,
shortDescription: layout.shortDescription.translations,
definition: layout["definition"],
}),
})
)
}
}
@ -328,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))
)
}
@ -342,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))
)
}
@ -358,7 +375,7 @@ export default class UserRelatedState {
return undefined
}
return [home.lon, home.lat]
}),
})
).map((homeLonLat) => {
if (homeLonLat === undefined) {
return empty
@ -387,8 +404,8 @@ export default class UserRelatedState {
* This is inherently a dirty and chaotic method, as it shoves many properties into this EventSource
* */
private initAmendedPrefs(
layout?: LayoutConfig,
featureSwitches?: FeatureSwitchState,
layout?: ThemeConfig,
featureSwitches?: FeatureSwitchState
): UIEventSource<Record<string, string>> {
const amendedPrefs = new UIEventSource<Record<string, string>>({
_theme: layout?.id,
@ -434,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,
@ -463,7 +480,7 @@ export default class UserRelatedState {
}
amendedPrefs.ping()
},
[this.translationMode],
[this.translationMode]
)
this.mangroveIdentity.getKeyId().addCallbackAndRun((kid) => {
@ -482,7 +499,7 @@ export default class UserRelatedState {
.makeHtml(userDetails.description)
?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<")
?.replace(/\n/g, ""),
?.replace(/\n/g, "")
)
}
@ -493,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
@ -502,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
@ -516,10 +533,10 @@ export default class UserRelatedState {
// Language is managed separately
continue
}
if(tags[key] === null){
if (tags[key] === null) {
continue
}
let pref = this.osmConnection.GetPreference(key, undefined, {prefix: ""})
let pref = this.osmConnection.GetPreference(key, undefined, { prefix: "" })
pref.set(tags[key])
}

View file

@ -106,14 +106,31 @@ export class RegexTag extends TagsFilter {
*
* const t = TagUtils.Tag("a=")
* t.asJson() // => "a="
*
* const t = TagUtils.Tag("a~i~b")
* t.asJson() // => "a~i~b"
*
* const t = TagUtils.Tag("service:bicycle:.*~~*")
* t.asJson() // => "service:bicycle:.*~~.+"
*/
asJson(): TagConfigJson {
const v = RegexTag.source(this.value, false)
const valueIsString = typeof this.value === "string"
const caseInvariant = typeof this.value !== "string" && this.value.ignoreCase
const invert = this.invert ? "!" : ""
if (typeof this.key === "string") {
const oper = typeof this.value === "string" ? "=" : "~"
return `${this.key}${this.invert ? "!" : ""}${oper}${v}`
if (valueIsString) {
return `${this.key}${invert}=${v}`
}
if (!caseInvariant) {
return `${this.key}${invert}~${v}`
}
return `${this.key}${invert}~i~${v}`
}
return `${this.key.source}${this.invert ? "!" : ""}~~${v}`
const key: string = RegexTag.source(this.key, false)
return `${key}${invert}~${caseInvariant ? "i~" : ""}~${v}`
}
isUsableAsAnswer(): boolean {

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 === "*") {
@ -170,8 +170,12 @@ export class Tag extends TagsFilter {
return <any>this
}
/**
* new Tag("panoramax", "").isNegative() // => true
* new Tag("x","y").isNegative() // => false
*/
isNegative(): boolean {
return false
return this.value === ""
}
visit(f: (tagsFilter: TagsFilter) => void) {

View file

@ -99,6 +99,16 @@ export class TagUtils {
overpassSupport: true,
docs: "Both the `key` and `value` part of this specification are interpreted as regexes, both the key and value musth completely match their respective regexes",
},
"~i~~": {
name: "Key and value should match a given regex; value is case-invariant",
overpassSupport: true,
docs: "Similar to ~~, except that the value is case-invariant",
},
"!~i~~": {
name: "Key and value should match a given regex; value is case-invariant",
overpassSupport: true,
docs: "Similar to !~~, except that the value is case-invariant",
},
":=": {
name: "Substitute `... {some_key} ...` and match `key`",
overpassSupport: false,
@ -469,7 +479,7 @@ export class TagUtils {
* TagUtils.Tag("survey:date:={_date:now}") // => new SubstitutingTag("survey:date", "{_date:now}")
* TagUtils.Tag("xyz!~\\[\\]") // => new RegexTag("xyz", /^(\[\])$/s, true)
* TagUtils.Tag("tags~(.*;)?amenity=public_bookcase(;.*)?") // => new RegexTag("tags", /^((.*;)?amenity=public_bookcase(;.*)?)$/s)
* TagUtils.Tag("service:bicycle:.*~~*") // => new RegexTag(/^(service:bicycle:.*)$/, /.+/si)
* TagUtils.Tag("service:bicycle:.*~i~~*") // => new RegexTag(/^(service:bicycle:.*)$/, /.+/s)
* TagUtils.Tag("_first_comment~.*{search}.*") // => new RegexTag('_first_comment', /^(.*{search}.*)$/s)
*
* TagUtils.Tag("xyz<5").matchesProperties({xyz: 4}) // => true
@ -572,7 +582,7 @@ export class TagUtils {
}
/**
* Parses the various parts of a regex tag
* Parses the various parts of a regex tag. The key is never considered a regex
*
* TagUtils.parseRegexOperator("key~value") // => {invert: false, key: "key", value: "value", modifier: ""}
* TagUtils.parseRegexOperator("key!~value") // => {invert: true, key: "key", value: "value", modifier: ""}
@ -590,7 +600,7 @@ export class TagUtils {
value: string
modifier: "i" | ""
} | null {
const match = tag.match(/^([_|a-zA-Z0-9: -]+)(!)?~([i]~)?(.*)$/)
const match = tag.match(/^([_|a-zA-Z0-9.: -]+)(!)?~([i]~)?(.*)$/)
if (match == null) {
return null
}
@ -790,8 +800,9 @@ export class TagUtils {
}
}
if (tag.indexOf("~~") >= 0) {
const split = Utils.SplitFirst(tag, "~~")
if (tag.indexOf("~~") >= 0 || tag.indexOf("~i~~") >= 0) {
const caseInvariant = tag.indexOf("~i~~") >= 0
const split = Utils.SplitFirst(tag, caseInvariant ? "~i~~" : "~~")
let keyRegex: RegExp
if (split[0] === "*") {
keyRegex = new RegExp(".+", "i")
@ -800,9 +811,9 @@ export class TagUtils {
}
let valueRegex: RegExp
if (split[1] === "*") {
valueRegex = new RegExp(".+", "si")
valueRegex = new RegExp(".+", "s")
} else {
valueRegex = new RegExp("^(" + split[1] + ")$", "s")
valueRegex = new RegExp("^(" + split[1] + ")$", caseInvariant ? "si" : "s")
}
return new RegexTag(keyRegex, valueRegex)
}

View file

@ -9,13 +9,14 @@ export class Stores {
const source = new UIEventSource<Date>(undefined)
function run() {
if (asLong !== undefined && !asLong()) {
return
}
source.setData(new Date())
if (Utils.runningFromConsole) {
return
}
if (asLong === undefined || asLong()) {
window.setTimeout(run, millis)
}
window.setTimeout(run, millis)
}
run()
@ -99,7 +100,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
@ -141,15 +142,19 @@ export abstract class Store<T> implements Readable<T> {
extraStoresToWatch?: Store<any>[],
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
)
}
/**
@ -269,7 +274,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
@ -342,6 +350,16 @@ 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)) {
callback()
return true
}
})
}
}
export class ImmutableStore<T> extends Store<T> {
@ -354,8 +372,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
@ -635,8 +652,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>()
@ -776,7 +792,10 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
)
}
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 === "") {
@ -939,7 +958,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

@ -371,6 +371,10 @@ export default class LinkedDataLoader {
const match = maxstay.match(/P([0-9]+)D/)
if (match) {
const days = Number(match[1])
if(days === 30){
// 30 is the default which is set if velopark didn't know the actual value
return undefined
}
if (days === 1) {
return "1 day"
}

View file

@ -1,11 +1,14 @@
import { UIEventSource } from "../UIEventSource"
import { Utils } from "../../Utils"
/**
* UIEventsource-wrapper around localStorage
*/
export class LocalStorageSource {
static GetParsed<T>(key: string, defaultValue: T): UIEventSource<T> {
return LocalStorageSource.Get(key).sync(
private static readonly _cache: Record<string, UIEventSource<string>> = {}
static getParsed<T>(key: string, defaultValue: T): UIEventSource<T> {
return LocalStorageSource.get(key).sync(
(str) => {
if (str === undefined) {
return defaultValue
@ -21,16 +24,27 @@ export class LocalStorageSource {
)
}
static Get(key: string, defaultValue: string = undefined): UIEventSource<string> {
try {
let saved = localStorage.getItem(key)
if (saved === "undefined") {
saved = undefined
static get(key: string, defaultValue: string = undefined): UIEventSource<string> {
const cached = LocalStorageSource._cache[key]
if (cached) {
return cached
}
let saved = defaultValue
if (!Utils.runningFromConsole) {
try {
saved = localStorage.getItem(key)
if (saved === "undefined") {
saved = undefined
}
} catch (e) {
console.error("Could not get value", key, "from local storage")
}
const source = new UIEventSource<string>(saved ?? defaultValue, "localstorage:" + key)
}
const source = new UIEventSource<string>(saved ?? defaultValue, "localstorage:" + key)
if (!Utils.runningFromConsole) {
source.addCallback((data) => {
if(data === undefined || data === "" || data === null){
if (data === undefined || data === "" || data === null) {
localStorage.removeItem(key)
return
}
@ -38,13 +52,12 @@ export class LocalStorageSource {
localStorage.setItem(key, data)
} catch (e) {
// Probably exceeded the quota with this item!
// Lets nuke everything
// Let's nuke everything
localStorage.clear()
}
})
return source
} catch (e) {
return new UIEventSource<string>(defaultValue)
}
LocalStorageSource._cache[key] = source
return source
}
}

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

@ -17,7 +17,7 @@ export default class VeloparkLoader {
private static readonly coder = new CountryCoder(
Constants.countryCoderEndpoint,
Utils.downloadJson
Utils.downloadJson,
)
public static convert(veloparkData: VeloparkData): Feature {
@ -46,14 +46,14 @@ export default class VeloparkLoader {
if (veloparkData.contactPoint?.email) {
properties["operator:email"] = VeloparkLoader.emailReformatting.reformat(
veloparkData.contactPoint?.email
veloparkData.contactPoint?.email,
)
}
if (veloparkData.contactPoint?.telephone) {
properties["operator:phone"] = VeloparkLoader.phoneValidator.reformat(
veloparkData.contactPoint?.telephone,
() => "be"
() => "be",
)
}
@ -78,9 +78,12 @@ export default class VeloparkLoader {
) {
const duration = g.maximumParkingDuration.substring(
1,
g.maximumParkingDuration.length - 1
g.maximumParkingDuration.length - 1,
)
properties.maxstay = duration + " days"
if (duration !== "30") {
// We don't set maxstay if it is 30, they are the default value that velopark chose for "unknown"
properties.maxstay = duration + " days"
}
}
properties.access = g.publicAccess ?? "yes" ? "yes" : "no"
const prefix = "http://schema.org/"
@ -94,11 +97,11 @@ export default class VeloparkLoader {
const startHour = spec.opens
const endHour = spec.closes === "23:59" ? "24:00" : spec.closes
const merged = OH.MergeTimes(
OH.ParseRule(dayOfWeek + " " + startHour + "-" + endHour)
OH.ParseRule(dayOfWeek + " " + startHour + "-" + endHour),
)
return OH.ToString(merged)
})
.join("; ")
.join("; "),
)
properties.opening_hours = oh
}

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>>()