Improve search UI

This commit is contained in:
Pieter Vander Vennet 2024-08-30 02:18:29 +02:00
parent 3be286c2b1
commit 93f03ddbaf
22 changed files with 564 additions and 499 deletions

View file

@ -1,5 +1,5 @@
import { ImmutableStore, Store } from "../UIEventSource"
import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider"
import GeocodingProvider, { FilterPayload, FilterResult, GeocodingOptions, SearchResult } from "./GeocodingProvider"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"
@ -13,17 +13,29 @@ export default class FilterSearch implements GeocodingProvider {
}
async search(query: string): Promise<SearchResult[]> {
return this.searchDirectly(query)
return this.searchDirectlyWrapped(query)
}
private searchDirectly(query: string): SearchResult[] {
const possibleFilters: SearchResult[] = []
private searchDirectlyWrapped(query: string): FilterResult[] {
return this.searchDirectly(query).map(payload => ({
payload,
category: "filter",
osm_id: payload.layer.id + "/" + payload.filter.id + "/" + payload.option.osmTags?.asHumanString() ?? "none"
}))
}
public searchDirectly(query: string): FilterPayload[] {
if (query.length === 0) {
return []
}
if(!Utils.isEmoji(query)){
query = Utils.simplifyStringForSearch(query)
}
const queries = query.split(" ").map(query => {
if (!Utils.isEmoji(query)) {
return Utils.simplifyStringForSearch(query)
}
return query
}).filter(q => q.length > 0)
console.log("Queries:",queries)
const possibleFilters: FilterPayload[] = []
for (const layer of this._state.layout.layers) {
if (!Array.isArray(layer.filters)) {
continue
@ -34,22 +46,27 @@ export default class FilterSearch implements GeocodingProvider {
if (option === undefined) {
continue
}
if (!option.osmTags) {
continue
}
let terms = ([option.question.txt,
...(option.searchTerms?.[Locale.language.data] ?? option.searchTerms?.["en"] ?? [])]
.flatMap(term => [term, ...term?.split(" ")]))
.flatMap(term => [term, ...(term?.split(" ") ?? [])]))
terms = terms.map(t => Utils.simplifyStringForSearch(t))
terms.push(option.emoji)
Utils.NoNullInplace(terms)
const levehnsteinD = Math.min(...
terms.map(entry => Utils.levenshteinDistance(query, entry.slice(0, query.length))))
if (levehnsteinD / query.length > 0.25) {
const distances = queries.flatMap(query => terms.map(entry => {
const d = Utils.levenshteinDistance(query, entry.slice(0, query.length))
console.log(query,"? +",terms, "=",d)
const dRelative = d / query.length
return dRelative
}))
const levehnsteinD = Math.min(...distances)
if (levehnsteinD > 0.25) {
continue
}
possibleFilters.push({
payload: { option, layer, filter, index: i },
category: "filter",
osm_id: layer.id + "/" + filter.id + "/" + option.osmTags?.asHumanString() ?? "none",
})
possibleFilters.push({ option, layer, filter, index: i })
}
}
}
@ -57,11 +74,7 @@ export default class FilterSearch implements GeocodingProvider {
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
if (Utils.isEmoji(query)) {
return new ImmutableStore(this.searchDirectly(query))
}
query = Utils.simplifyStringForSearch(query)
return new ImmutableStore(this.searchDirectly(query))
return new ImmutableStore(this.searchDirectlyWrapped(query))
}

View file

@ -45,8 +45,9 @@ export type GeocodeResult = {
source?: string
}
export type FilterPayload = { option: FilterConfigOption, filter: FilterConfig, layer: LayerConfig, index: number }
export type FilterResult = { category: "filter", osm_id: string, payload: FilterPayload }
export type SearchResult =
| { category: "filter", osm_id: string, payload: FilterPayload }
| FilterResult
| { category: "theme", osm_id: string, payload: MinimalLayoutInformation }
| GeocodeResult

View file

@ -20,31 +20,33 @@ export default class ThemeSearch implements GeocodingProvider {
}
async search(query: string): Promise<SearchResult[]> {
return this.searchDirect(query, 99)
return this.searchWrapped(query, 99)
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return new ImmutableStore(this.searchDirect(query, this._suggestionLimit ?? 4))
return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4))
}
private searchDirect(query: string, limit: number): SearchResult[] {
if(query.length < 1){
return []
}
query = Utils.simplifyStringForSearch(query)
const withMatch = ThemeSearch.allThemes
.filter(th => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id))
.filter(th => th.id !== this._state.layout.id)
.filter(th => MoreScreen.MatchesLayout(th, query))
.slice(0, limit)
console.log("Matched", withMatch, limit)
return withMatch.map(match => <SearchResult> {
private searchWrapped(query: string, limit: number): SearchResult[] {
return this.searchDirect(query, limit).map(match => <SearchResult>{
payload: match,
category: "theme",
osm_id: match.id
})
}
public searchDirect(query: string, limit: number): MinimalLayoutInformation[] {
if (query.length < 1) {
return []
}
query = Utils.simplifyStringForSearch(query)
return ThemeSearch.allThemes
.filter(th => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id))
.filter(th => th.id !== this._state.layout.id)
.filter(th => MoreScreen.MatchesLayout(th, query))
.slice(0, limit)
}
}

View file

@ -0,0 +1,189 @@
import GeocodingProvider, { FilterPayload, GeocodingUtils, type SearchResult } from "../Geocoding/GeocodingProvider"
import { RecentSearch } from "../Geocoding/RecentSearch"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import CombinedSearcher from "../Geocoding/CombinedSearcher"
import FilterSearch from "../Geocoding/FilterSearch"
import LocalElementSearch from "../Geocoding/LocalElementSearch"
import CoordinateSearch from "../Geocoding/CoordinateSearch"
import ThemeSearch from "../Geocoding/ThemeSearch"
import OpenStreetMapIdSearch from "../Geocoding/OpenStreetMapIdSearch"
import PhotonSearch from "../Geocoding/PhotonSearch"
import ThemeViewState from "../../Models/ThemeViewState"
import Translations from "../../UI/i18n/Translations"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import MoreScreen from "../../UI/BigComponents/MoreScreen"
import { BBox } from "../BBox"
import { Translation } from "../../UI/i18n/Translation"
import GeocodingFeatureSource from "../Geocoding/GeocodingFeatureSource"
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
export default class SearchState {
public readonly isSearching = new UIEventSource(false)
public readonly geosearch: GeocodingProvider
public readonly recentlySearched: RecentSearch
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<FilterPayload[]>
public readonly themeSuggestions: Store<MinimalLayoutInformation[]>
private readonly state: ThemeViewState
public readonly showSearchDrawer: UIEventSource<boolean>
constructor(state: ThemeViewState) {
this.state = state
this.geosearch = new CombinedSearcher(
// new LocalElementSearch(state, 5),
new CoordinateSearch(),
new OpenStreetMapIdSearch(state),
new PhotonSearch() // new NominatimGeocoding(),
)
this.recentlySearched = new RecentSearch(state)
const bounds = state.mapProperties.bounds
this.suggestions = this.searchTerm.stabilized(250).bindD(search => {
if (search.length === 0) {
return undefined
}
return Stores.holdDefined(bounds.bindD(bbox => this.geosearch.suggest(search, { bbox })))
}
)
const themeSearch = new ThemeSearch(state, 3)
this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.searchDirect(query, 3))
const filterSearch = new FilterSearch(state)
this.filterSuggestions = this.searchTerm.stabilized(50).mapD(query =>
filterSearch.searchDirectly(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])
const geocodedFeatures = new GeocodingFeatureSource(this.suggestions.stabilized(250))
state.featureProperties.trackFeatureSource(geocodedFeatures)
new ShowDataLayer(
state.map,
{
layer: GeocodingUtils.searchLayer,
features: geocodedFeatures,
selectedElement: state.selectedElement
}
)
this.showSearchDrawer = new UIEventSource(false)
this.suggestions.addCallbackAndRunD(sugg => {
if (sugg.length > 0) {
this.showSearchDrawer.set(true)
}
})
this.searchIsFocused.addCallbackAndRunD(sugg => {
if (sugg) {
this.showSearchDrawer.set(true)
}
})
}
public async apply(payload: FilterPayload) {
const state = this.state
const { layer, filter, index } = payload
const flayer = state.layerState.filteredLayers.get(layer.id)
const filtercontrol = flayer.appliedFilters.get(filter.id)
for (const [name, otherLayer] of state.layerState.filteredLayers) {
if (name === layer.id) {
otherLayer.isDisplayed.setData(true)
continue
}
otherLayer.isDisplayed.setData(false)
}
console.log("Could not apply", layer.id, ".", filter.id, index)
if (filtercontrol.data === index) {
filtercontrol.setData(undefined)
} else {
filtercontrol.setData(index)
}
}
/**
* Tries to search and goto a given location
* Returns 'false' if search failed
*/
public async performSearch(): Promise<boolean> {
const query = this.searchTerm.data?.trim() ?? ""
if (query === "") {
return
}
const geolocationState = this.state.geolocation.geolocationState
const searcher = this.state.searchState.geosearch
const bounds = this.state.mapProperties.bounds
const bbox = this.state.mapProperties.bounds.data
try {
this.isSearching.set(true)
geolocationState?.allowMoving.setData(true)
geolocationState?.requestMoment.setData(undefined) // If the GPS is still searching for a fix, we say that we don't want tozoom to it anymore
const result = await searcher.search(query, { bbox })
if (result.length == 0) {
this.feedback.set(Translations.t.general.search.nothing)
return false
}
const poi = result[0]
if (poi.category === "theme") {
const theme = <MinimalLayoutInformation>poi.payload
const url = MoreScreen.createUrlFor(theme, false)
window.location = <any>url
return true
}
if (poi.category === "filter") {
await this.apply(poi.payload)
return true
}
if (poi.boundingbox) {
const [lat0, lat1, lon0, lon1] = poi.boundingbox
// Will trigger a 'fly to'
bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1]
]).pad(0.01)
)
} else if (poi.lon && poi.lat) {
this.state.mapProperties.flyTo(poi.lon, poi.lat, GeocodingUtils.categoryToZoomLevel[poi.category] ?? 16)
}
const perLayer = this.state.perLayer
if (perLayer !== undefined) {
const id = poi.osm_type + "/" + poi.osm_id
const layers = Array.from(perLayer?.values() ?? [])
for (const layer of layers) {
const found = layer.features.data.find((f) => f.properties.id === id)
if (found === undefined) {
continue
}
this.state.selectedElement?.setData(found)
}
}
return true
} catch (e) {
console.error(e)
this.feedback.set(Translations.t.general.search.error)
return false
} finally {
this.isSearching.set(false)
}
}
}

View file

@ -238,8 +238,8 @@ export abstract class Store<T> implements Readable<T> {
* src.setData(0)
* lastValue // => "def"
*/
public bind<X>(f: (t: T) => Store<X>): Store<X> {
const mapped = this.map(f)
public bind<X>(f: (t: T) => Store<X>, extraSources: Store<object>[] = []): Store<X> {
const mapped = this.map(f, extraSources)
const sink = new UIEventSource<X>(undefined)
const seenEventSources = new Set<Store<X>>()
mapped.addCallbackAndRun((newEventSource) => {
@ -270,7 +270,7 @@ export abstract class Store<T> implements Readable<T> {
return sink
}
public bindD<X>(f: (t: Exclude<T, undefined | null>) => Store<X>): 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
@ -279,7 +279,7 @@ export abstract class Store<T> implements Readable<T> {
return undefined
}
return f(<Exclude<T, undefined | null>>t)
})
}, extraSources)
}
public stabilized(millisToStabilize): Store<T> {