Merge branch 'develop' into Robin-patch-1

This commit is contained in:
Robin van der Linde 2025-07-24 22:08:58 +02:00
commit 3909ad3079
13 changed files with 159 additions and 63 deletions

View file

@ -7,6 +7,11 @@ export default class CombinedSearcher implements GeocodingProvider {
private _providers: ReadonlyArray<GeocodingProvider>
private _providersWithSuggest: ReadonlyArray<GeocodingProvider>
/**
* Merges the various providers together; ignores errors.
* IF all providers fail, no errors will be given
* @param providers
*/
constructor(...providers: ReadonlyArray<GeocodingProvider>) {
this._providers = Utils.NoNull(providers)
this._providersWithSuggest = this._providers.filter((pr) => pr.suggest !== undefined)
@ -45,9 +50,10 @@ export default class CombinedSearcher implements GeocodingProvider {
return CombinedSearcher.merge(results)
}
suggest(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return Stores.concat(
this._providersWithSuggest.map((pr) => pr.suggest(query, options))
).map((gcrss) => CombinedSearcher.merge(gcrss))
suggest(query: string, options?: GeocodingOptions): Store<{success: GeocodeResult[]}> {
const concatted = Stores.concat(
this._providersWithSuggest.map((pr) => <Store<GeocodeResult[]>> pr.suggest(query, options).map(result => result["success"] ?? []))
);
return concatted.map(gcrss => ({success: CombinedSearcher.merge(gcrss) }))
}
}

View file

@ -119,8 +119,8 @@ export default class CoordinateSearch implements GeocodingProvider {
}
}
suggest(query: string): Store<GeocodeResult[]> {
return new ImmutableStore(this.directSearch(query))
suggest(query: string): Store<{success: GeocodeResult[]}> {
return new ImmutableStore({success: this.directSearch(query)})
}
async search(query: string): Promise<GeocodeResult[]> {

View file

@ -57,11 +57,7 @@ export default interface GeocodingProvider {
*/
search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]>
/**
* @param query
* @param options
*/
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]>
suggest(query: string, options?: GeocodingOptions): Store<{success: GeocodeResult[]} | {error :any}>
}
export type ReverseGeocodingResult = Feature<
@ -93,7 +89,7 @@ export class GeocodingUtils {
// We are resetting the layeroverview; trying to parse is useless
return undefined
}
return new LayerConfig(<LayerConfigJson>search, "search")
return new LayerConfig(<LayerConfigJson><any> search, "search")
}
public static categoryToZoomLevel: Record<GeocodingCategory, number> = {

View file

@ -141,7 +141,7 @@ export default class LocalElementSearch implements GeocodingProvider {
})
}
suggest(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return this.searchEntries(query, options, true)
suggest(query: string, options?: GeocodingOptions): Store<{success: GeocodeResult[]}> {
return this.searchEntries(query, options, true).mapD(r => ({success:r}))
}
}

View file

@ -4,6 +4,7 @@ import Constants from "../../Models/Constants"
import { FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale"
import GeocodingProvider, { GeocodeResult, GeocodingOptions } from "./GeocodingProvider"
import { Store, UIEventSource } from "../UIEventSource"
export class NominatimGeocoding implements GeocodingProvider {
private readonly _host
@ -37,4 +38,8 @@ export class NominatimGeocoding implements GeocodingProvider {
}&zoom=${Math.ceil(zoom) + 1}&accept-language=${language}`
return Utils.downloadJson(url)
}
suggest(query: string, options?: GeocodingOptions): Store<{ success: GeocodeResult[] } | { error: any }> {
return UIEventSource.FromPromiseWithErr(this.search(query, options))
}
}

View file

@ -1,4 +1,4 @@
import { Store, Stores } from "../UIEventSource"
import { ImmutableStore, Store } from "../UIEventSource"
import GeocodingProvider, { GeocodeResult, GeocodingOptions } from "./GeocodingProvider"
import { decode as pluscode_decode } from "pluscodes"
@ -24,24 +24,30 @@ export default class OpenLocationCodeSearch implements GeocodingProvider {
return str.toUpperCase().match(this._isPlusCode) !== null
}
private searchDirectly(query: string): GeocodeResult | undefined {
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(),
}
}
async search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> {
if (!OpenLocationCodeSearch.isPlusCode(query)) {
return [] // Must be an empty list and not "undefined", the latter is interpreted as 'still searching'
}
const { latitude, longitude } = pluscode_decode(query)
return [
{
lon: longitude,
lat: latitude,
description: "Open Location Code",
osm_id: query,
display_name: query.toUpperCase(),
},
]
return [this.searchDirectly(query)]
}
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return Stores.FromPromise(this.search(query, options))
suggest(query: string, options?: GeocodingOptions): Store<{ success: GeocodeResult[] }> {
const result = OpenLocationCodeSearch.isPlusCode(query) ? [this.searchDirectly(query)] : []
return new ImmutableStore({ success: result })
}
}

View file

@ -92,7 +92,7 @@ export default class OpenStreetMapIdSearch implements GeocodingProvider {
return [await this.getInfoAbout(id)]
}
suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return UIEventSource.FromPromise(this.search(query, options))
suggest(query: string, options?: GeocodingOptions): Store<{success: GeocodeResult[]} | {error: any}> {
return UIEventSource.FromPromiseWithErr(this.search(query, options))
}
}

View file

@ -36,6 +36,10 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
this.suggestionLimit = suggestionLimit
this.searchLimit = searchLimit
this._endpoint = endpoint ?? Constants.photonEndpoint ?? "https://photon.komoot.io/"
if(this.ignoreBounds){
this.name += " (global)"
}
}
async reverseSearch(
@ -69,8 +73,8 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
return `&lang=${language}`
}
suggest(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> {
return Stores.FromPromise(this.search(query, options))
suggest(query: string, options?: GeocodingOptions): Store<{success: GeocodeResult[]} | {error: any}> {
return Stores.FromPromiseWithErr(this.search(query, options))
}
private buildDescription(entry: Feature) {

View file

@ -1,5 +1,5 @@
import GeocodingProvider, { GeocodeResult, GeocodingUtils } from "../Search/GeocodingProvider"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import CombinedSearcher from "../Search/CombinedSearcher"
import FilterSearch, { FilterSearchResult } from "../Search/FilterSearch"
import LocalElementSearch from "../Search/LocalElementSearch"
@ -18,6 +18,8 @@ import { Feature } from "geojson"
import OpenLocationCodeSearch from "../Search/OpenLocationCodeSearch"
import { BBox } from "../BBox"
import { QueryParameters } from "../Web/QueryParameters"
import { Utils } from "../../Utils"
import { NominatimGeocoding } from "../Search/NominatimGeocoding"
export default class SearchState {
public readonly feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
@ -32,7 +34,12 @@ export default class SearchState {
private readonly state: ThemeViewState
public readonly showSearchDrawer: UIEventSource<boolean>
public readonly suggestionsSearchRunning: Store<boolean>
public readonly runningEngines: Store<GeocodingProvider[]>
public readonly locationResults: FeatureSource
/**
* Indicates failures in the current search
*/
public readonly failedEngines: Store<{ source: GeocodingProvider; error: any }[]>
constructor(state: ThemeViewState) {
this.state = state
@ -44,31 +51,63 @@ export default class SearchState {
new CoordinateSearch(),
new OpenLocationCodeSearch(),
new OpenStreetMapIdSearch(state.osmObjectDownloader),
new PhotonSearch(true, 2),
new PhotonSearch(),
// new NominatimGeocoding(),
new PhotonSearch(true, 2), // global results
new PhotonSearch(), // local results
new NominatimGeocoding(),
]
const bounds = state.mapProperties.bounds
const suggestionsList = this.searchTerm.stabilized(250).mapD(
const suggestionsListWithSource = this.searchTerm.stabilized(250).mapD(
(search) => {
if (search.length === 0) {
return undefined
}
return this.locationSearchers.map((ls) => ls.suggest(search, { bbox: bounds.data }))
return this.locationSearchers.map((ls) => ({
source: ls,
results: ls.suggest(search, { bbox: bounds.data }),
}))
},
[bounds]
)
this.suggestionsSearchRunning = suggestionsList.bind((suggestions) => {
if (suggestions === undefined) {
return new ImmutableStore(true)
}
return Stores.concat(suggestions).map((suggestions) =>
suggestions.some((list) => list === undefined)
)
})
const suggestionsList = suggestionsListWithSource
.mapD(list => list.map(sugg => sugg.results))
const isRunningPerEngine: Store<Store<GeocodingProvider>[]> =
suggestionsListWithSource.mapD(
allProviders => allProviders.map(provider =>
provider.results.map(result => {
if (result === undefined) {
return provider.source
} else {
return undefined
}
})))
this.runningEngines = isRunningPerEngine.bindD(
listOfSources => Stores.concat(listOfSources).mapD(list => Utils.NoNull(list)))
this.failedEngines = suggestionsListWithSource
.bindD((allProviders: {
source: GeocodingProvider;
results: Store<{ success: GeocodeResult[] } | { error: any }>
}[]) => Stores.concat(
allProviders.map(providerAndResult =>
<Store<{ source: GeocodingProvider, error: any }[]>>providerAndResult.results.map(result => {
let error = result?.["error"]
if (error) {
return [{
source: providerAndResult.source, error,
}]
} else {
return []
}
}),
))).map(list => Utils.NoNull(list?.flatMap(x => x) ?? []))
this.suggestionsSearchRunning = this.runningEngines.map(running => running?.length > 0)
this.suggestions = suggestionsList.bindD((suggestions) =>
Stores.concat(suggestions).map((suggestions) => CombinedSearcher.merge(suggestions))
Stores.concat(suggestions.map(sugg => sugg.map(maybe => maybe?.["success"])))
.map((suggestions: GeocodeResult[][]) => CombinedSearcher.merge(suggestions))
)
const themeSearch = ThemeSearchIndex.fromState(state)

View file

@ -45,15 +45,16 @@ export class Stores {
?.then((d) => src.setData(d))
return src
}
public static concat<T>(stores: Store<T[] | undefined>[]): Store<(T[] | undefined)[]> {
const newStore = new UIEventSource<(T[] | undefined)[]>([])
public static concat<T>(stores: Store<T | undefined>[]): Store<(T | undefined)[]> ;
public static concat<T>(stores: Store<T>[]): Store<T[]> ;
public static concat<T>(stores: Store<T | undefined>[]): Store<(T | undefined)[]> {
const newStore = new UIEventSource<(T | undefined)[]>([])
function update() {
if (newStore._callbacks.isDestroyed) {
return true // unregister
}
const results: (T[] | undefined)[] = []
const results: (T | undefined)[] = []
for (const store of stores) {
results.push(store.data)
}

View file

@ -149,7 +149,7 @@ export default class FeatureReviews {
public readonly average: Store<number | null>
private readonly _reviews: UIEventSource<
(Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
> = new UIEventSource([])
> = new UIEventSource(undefined)
public readonly reviews: Store<(Review & { signature: string, madeByLoggedInUser: Store<boolean> })[]> =
this._reviews
private readonly _lat: number
@ -159,11 +159,6 @@ export default class FeatureReviews {
private readonly _identity: MangroveIdentity
private readonly _testmode: Store<boolean>
public readonly loadingAllowed: UIEventSource<boolean | null>
private readonly _options: Readonly<{
nameKey?: "name" | string
fallbackName?: string
uncertaintyRadius?: number
}>
private readonly _reportError: (msg: string, extra: string) => Promise<void>
private constructor(
@ -186,7 +181,6 @@ export default class FeatureReviews {
this._identity = mangroveIdentity
this._testmode = testmode ?? new ImmutableStore(false)
const nameKey = options?.nameKey ?? "name"
this._options = options
if (options.uncertaintyRadius) {
this._uncertainty = options.uncertaintyRadius
} else if (feature.geometry.type === "Point") {
@ -383,11 +377,18 @@ export default class FeatureReviews {
signature: "",
madeByLoggedInUser: new ImmutableStore(true),
}
this.initReviews()
this._reviews.data.push(reviewWithKid)
this._reviews.ping()
this._identity.addReview(reviewWithKid)
}
private initReviews(){
if(this._reviews.data === undefined){
this._reviews.set([])
}
}
/**
* Adds given reviews to the 'reviews'-UI-eventsource, if they match close enough.
* We assume only geo-reviews are passed in (as they should be queried using the 'geo'-part)
@ -398,9 +399,11 @@ export default class FeatureReviews {
reviews: { payload: Review; kid: string; signature: string }[],
expectedName: string
) {
const alreadyKnown = new Set(this._reviews.data.map((r) => r.rating + " " + r.opinion))
const alreadyKnown = new Set(this._reviews.data?.map((r) => r.rating + " " + r.opinion))
let hasNew = false
this.initReviews()
for (const reviewData of reviews) {
const review = reviewData.payload
@ -457,7 +460,7 @@ export default class FeatureReviews {
public removeReviewLocally(review: Review){
this._reviews.set(
this._reviews.data.filter(r => r !== review)
this._reviews.data?.filter(r => r !== review)
)
}

View file

@ -11,6 +11,7 @@
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Loading from "../Base/Loading.svelte"
/**
* An element showing all reviews
*/
@ -33,22 +34,26 @@
<ReviewPrivacyShield {reviews} guistate={state.guistate}>
<div class="border-2 border-dashed border-gray-300 p-2 flex flex-col gap-y-2">
{#if $allReviews.length > 1}
{#if $allReviews?.length > 1}
<StarsBar score={$average} />
{/if}
{#if $allReviews.length > 0}
{#if !$allReviews}
<div class="flex justify-center">
<Loading/>
</div>
{:else if $allReviews.length > 0}
{#each $allReviews as review}
<SingleReview {review} {state} {tags} {feature} {layer} {reviews}/>
{/each}
{:else}
<div class="subtle m-2 italic">
<div class="subtle m-2 italic flex justify-center">
<Tr t={Translations.t.reviews.no_reviews_yet} />
</div>
{/if}
<div class="flex justify-end">
<a
href={"https://mangrove.reviews" +
($allReviews.length == 0 ? "" : "/search?sub=" + encodeURIComponent($subject))}
($allReviews?.length > 0 ?"/search?sub=" + encodeURIComponent($subject):"")}
target="_blank"
class="subtle text-sm"
>

View file

@ -8,22 +8,36 @@
import { default as GeocodeResultSvelte } from "./GeocodeResult.svelte"
import Tr from "../Base/Tr.svelte"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import type GeocodingProvider from "../../Logic/Search/GeocodingProvider"
import type { GeocodeResult } from "../../Logic/Search/GeocodingProvider"
import type { MapProperties } from "../../Models/MapProperties"
import { ExclamationTriangle } from "@babeard/svelte-heroicons/solid/ExclamationTriangle"
export let state: {
searchState: {
searchTerm: UIEventSource<string>
suggestions: Store<GeocodeResult[]>
suggestionsSearchRunning: Store<boolean>
runningEngines: Store<string[]>
failedEngines: Store<{
source: GeocodingProvider;
error: any
}[]>
}
featureSwitchIsTesting?: Store<boolean>
userRelatedState?: { showTagsB: Store<boolean> }
mapProperties: MapProperties
}
let searchTerm = state.searchState.searchTerm
let results = state.searchState.suggestions
let isSearching = state.searchState.suggestionsSearchRunning ?? new ImmutableStore(false)
let runningEngines = state.searchState.runningEngines ?? new ImmutableStore([])
let failedEngines = state.searchState.failedEngines
let isTesting = state.featureSwitchIsTesting ?? new ImmutableStore(false)
let showTags = state.userRelatedState?.showTagsB ?? new ImmutableStore(false)
const t = Translations.t.general.search
</script>
@ -43,6 +57,11 @@
<div class="m-4 my-8 flex justify-center">
<Loading>
<Tr t={t.searching} />
{#if $isTesting || $showTags}
<div class="subtle">
Querying {$runningEngines?.map(provider => provider.name)?.join(", ")}
</div>
{/if}
</Loading>
</div>
{/if}
@ -52,6 +71,18 @@
<Tr t={t.nothingFor.Subs({ term: "<i>" + $searchTerm + "</i>" })} />
</b>
{/if}
{#if $failedEngines?.length > 0}
{#each $failedEngines as failed}
<div class="warning flex flex-col items-center py-4 overflow-hidden">
<ExclamationTriangle class="w-4" />
Search using {failed.source.name} failed
<div class="subtle text-sm">
{failed.error}
</div>
</div>
{/each}
{/if}
</SidebarUnit>
{:else}
<slot name="if-no-results" />