forked from MapComplete/MapComplete
Improve search UI
This commit is contained in:
parent
3be286c2b1
commit
93f03ddbaf
22 changed files with 564 additions and 499 deletions
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
189
src/Logic/State/SearchState.ts
Normal file
189
src/Logic/State/SearchState.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue