forked from MapComplete/MapComplete
Merge develop
This commit is contained in:
commit
b5669f6bf8
786 changed files with 42904 additions and 35985 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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((_) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | "*") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
52
src/Logic/Search/OpenLocationCodeSearch.ts
Normal file
52
src/Logic/Search/OpenLocationCodeSearch.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(/>/g, ">")
|
||||
?.replace(/</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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, ...
|
||||
|
|
|
|||
|
|
@ -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">>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue