MapComplete/src/Logic/State/SearchState.ts

261 lines
10 KiB
TypeScript

import GeocodingProvider, { GeocodeResult, GeocodingUtils } from "../Search/GeocodingProvider"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import CombinedSearcher from "../Search/CombinedSearcher"
import FilterSearch, { FilterSearchResult } from "../Search/FilterSearch"
import LocalElementSearch from "../Search/LocalElementSearch"
import CoordinateSearch from "../Search/CoordinateSearch"
import { ThemeSearchIndex } from "../Search/ThemeSearch"
import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch"
import PhotonSearch from "../Search/PhotonSearch"
import ThemeViewState from "../../Models/ThemeViewState"
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"
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)
public readonly searchTerm: UIEventSource<string>
public readonly searchIsFocused = new UIEventSource(false)
public readonly suggestions: Store<GeocodeResult[]>
public readonly filterSuggestions: Store<FilterSearchResult[]>
public readonly themeSuggestions: Store<MinimalThemeInformation[]>
public readonly layerSuggestions: Store<LayerConfig[]>
public readonly locationSearchers: ReadonlyArray<GeocodingProvider>
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
this.showSearchDrawer = state.guistate.pageStates.search
this.searchTerm = QueryParameters.GetQueryParameter("q", "", "The term in the search field")
this.locationSearchers = [
new LocalElementSearch(state, 5),
new CoordinateSearch(),
new OpenLocationCodeSearch(),
new OpenStreetMapIdSearch(state.osmObjectDownloader),
new PhotonSearch(true, 2), // global results
new PhotonSearch(), // local results
new NominatimGeocoding(),
]
const bounds = state.mapProperties.bounds
const suggestionsListWithSource = this.searchTerm.stabilized(250).mapD(
(search) => {
if (search.length === 0) {
return undefined
}
return this.locationSearchers.map((ls) => ({
source: ls,
results: ls.suggest(search, { bbox: bounds.data }),
}))
},
[bounds]
)
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(sugg => sugg.map(maybe => maybe?.["success"])))
.map((suggestions: GeocodeResult[][]) => CombinedSearcher.merge(suggestions))
)
const themeSearch = ThemeSearchIndex.fromState(state)
this.themeSuggestions = this.searchTerm.mapD(
(query) => {
const results = themeSearch.data.search(query, 3)
const deduped: MinimalThemeInformation[] = []
for (const result of results) {
if (deduped.some((th) => th.id === result.id)) {
continue
}
deduped.push(result)
}
return deduped
},
[themeSearch]
)
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
)
return !foundMatch
})
},
[state.layerState.activeFilters]
)
this.locationResults = new GeocodingFeatureSource(this.suggestions.stabilized(250))
this.searchIsFocused.addCallbackAndRunD((sugg) => {
if (sugg) {
this.showSearchDrawer.set(true)
}
})
}
public async apply(result: FilterSearchResult[] | LayerConfig) {
if (result instanceof LayerConfig) {
return this.applyLayer(result)
}
return this.applyFilter(result)
}
private async applyLayer(layer: LayerConfig) {
for (const [name, otherLayer] of this.state.layerState.filteredLayers) {
otherLayer.isDisplayed.setData(name === layer.id)
}
}
private async applyFilter(payload: FilterSearchResult[]) {
const state = this.state
const layersToShow = payload.map((fsr) => fsr.layer.id)
for (const otherLayer of state.layerState.filteredLayers.values()) {
const layer = otherLayer.layerDef
if (!layer.isNormal()) {
continue
}
otherLayer.isDisplayed.setData(layersToShow.indexOf(layer.id) >= 0)
}
for (const { filter, index, layer } of payload) {
const flayer = state.layerState.filteredLayers.get(layer.id)
flayer.isDisplayed.set(true)
const filtercontrol = flayer.appliedFilters.get(filter.id)
if (filtercontrol.data === index) {
filtercontrol.setData(undefined)
} else {
filtercontrol.setData(index)
}
}
}
closeIfFullscreen() {
if (window.innerWidth < 640) {
this.showSearchDrawer.set(false)
}
}
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)
}
public moveToBestMatch() {
const suggestion = this.suggestions.data?.[0]
if (suggestion) {
this.applyGeocodeResult(suggestion)
}
if (this.suggestionsSearchRunning.data) {
this.suggestionsSearchRunning.addCallback(() => {
this.applyGeocodeResult(this.suggestions.data?.[0])
return true // unregister
})
}
}
applyGeocodeResult(entry: GeocodeResult) {
if (!entry) {
console.error("ApplyGeocodeResult got undefined/null")
}
console.log("Moving to", entry.description)
const state = this.state
if (entry.boundingbox) {
const [lat0, lat1, lon0, lon1] = entry.boundingbox
state.mapProperties.bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1],
]).pad(0.01)
)
} else {
state.mapProperties.flyTo(
entry.lon,
entry.lat,
GeocodingUtils.categoryToZoomLevel[entry.category] ?? 17
)
}
if (entry.feature?.properties?.id) {
state.selectedElement.set(entry.feature)
}
state.userRelatedState.recentlyVisitedSearch.add(entry)
this.closeIfFullscreen()
}
}