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

@ -537,7 +537,7 @@
},
{
"if": "cuisine=mexican ",
"icon": "\uD83C\uDDF2\uD83C\uDDFD",
"icon": "🇲🇽",
"then": {
"en": "Mexican dishes are served here",
"nl": "Dit is een mexicaans restaurant"

View file

@ -1913,6 +1913,10 @@ input[type="range"].range-lg::-moz-range-thumb {
max-height: 3rem;
}
.max-h-screen {
max-height: 100vh;
}
.max-h-full {
max-height: 100%;
}
@ -1925,10 +1929,6 @@ input[type="range"].range-lg::-moz-range-thumb {
max-height: 16rem;
}
.max-h-96 {
max-height: 24rem;
}
.max-h-60 {
max-height: 15rem;
}
@ -8168,12 +8168,12 @@ svg.apply-fill path {
width: 16rem;
}
.sm\:w-11 {
width: 2.75rem;
.sm\:w-80 {
width: 20rem;
}
.sm\:w-96 {
width: 24rem;
.sm\:w-11 {
width: 2.75rem;
}
.sm\:w-auto {
@ -8188,6 +8188,10 @@ svg.apply-fill path {
width: 1.5rem;
}
.sm\:w-96 {
width: 24rem;
}
.sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -8373,6 +8377,10 @@ svg.apply-fill path {
height: 100%;
}
.md\:w-96 {
width: 24rem;
}
.md\:w-6\/12 {
width: 50%;
}

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> {

View file

@ -340,7 +340,7 @@ export default class LayoutConfig implements LayoutInformation {
}
}
}
console.trace("Fallthrough: could not find the appropraite layer for an object with tags", tags, "within layout", this)
console.trace("Fallthrough: could not find the appropriate layer for an object with tags", tags, "within layout", this)
return undefined
}

View file

@ -83,6 +83,7 @@ import PhotonSearch from "../Logic/Geocoding/PhotonSearch"
import ThemeSearch from "../Logic/Geocoding/ThemeSearch"
import OpenStreetMapIdSearch from "../Logic/Geocoding/OpenStreetMapIdSearch"
import FilterSearch from "../Logic/Geocoding/FilterSearch"
import SearchState from "../Logic/State/SearchState"
/**
*
@ -164,8 +165,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
public readonly nearbyImageSearcher: CombinedFetcher
public readonly geosearch: GeocodingProvider
public readonly recentlySearched: RecentSearch
public readonly searchState: SearchState
constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) {
Utils.initDomPurify()
@ -390,16 +390,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
this.featureSummary = this.setupSummaryLayer()
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
this.geosearch = new CombinedSearcher(
new FilterSearch(this),
new LocalElementSearch(this, 5),
new CoordinateSearch(),
this.featureSwitches.featureSwitchBackToThemeOverview.data ? new ThemeSearch(this, 3) : undefined,
new OpenStreetMapIdSearch(this),
new PhotonSearch(), // new NominatimGeocoding(),
)
this.recentlySearched = new RecentSearch(this)
this.searchState = new SearchState(this)
this.initActors()
this.drawSpecialLayers()
@ -931,7 +922,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
/**
* Searches the appropriate layer - will first try if a special layer matches; if not, a normal layer will be used by delegating to the layout
* @param tags
*/
public getMatchingLayer(properties: Record<string, string>){

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { Drawer } from "flowbite-svelte"
import { sineIn } from "svelte/easing"
import { Store } from "../../Logic/UIEventSource.js"
import { onMount } from "svelte"
export let shown: Store<boolean>
let transitionParams = {
x: 640,
duration: 200,
easing: sineIn
}
let hidden = !shown.data
shown.addCallback(sh => {
hidden = !sh
})
let height = 0
onMount(() => {
let topbar = document.getElementById("top-bar")
height = topbar.clientHeight
})
</script>
<Drawer placement="right"
transitionType="fly" {transitionParams}
activateClickOutside={false}
divClass="overflow-y-auto"
backdrop={false}
id="drawer-right"
width="w-full sm:w-80 md:w-96"
rightOffset="inset-y-0 right-0"
bind:hidden={hidden}>
<div class="normal-background h-screen">
<div class="h-full" style={`padding-top: ${height}px`}>
<div class="flex flex-col h-full overflow-y-auto">
<slot />
</div>
</div>
</div>
</Drawer>

View file

@ -2,17 +2,12 @@
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import NextButton from "../Base/NextButton.svelte"
import Geosearch from "../Search/Geosearch.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { twJoin } from "tailwind-merge"
import { Utils } from "../../Utils"
import { Store } from "../../Logic/UIEventSource"
import type { GeolocationPermissionState } from "../../Logic/State/GeoLocationState"
import { GeoLocationState } from "../../Logic/State/GeoLocationState"
import If from "../Base/If.svelte"
import { ExclamationTriangleIcon } from "@babeard/svelte-heroicons/mini"
import ChevronDoubleLeft from "@babeard/svelte-heroicons/solid/ChevronDoubleLeft"
import GeolocationIndicator from "./GeolocationIndicator.svelte"
/**
@ -20,10 +15,6 @@
*/
export let state: ThemeViewState
let layout = state.layout
let selectedElement = state.selectedElement
let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
let searchEnabled = false
let geolocation = state.geolocation.geolocationState
let geopermission: Store<GeolocationPermissionState> = geolocation.permission
@ -35,7 +26,7 @@
state.geolocationControl.handleClick()
const glstate = state.geolocation.geolocationState
if (glstate.currentGPSLocation.data !== undefined) {
const c: GeolocationCoordinates = glstate.currentGPSLocation.data
const c = glstate.currentGPSLocation.data
state.guistate.pageStates.about_theme.setData(false)
const coor = { lon: c.longitude, lat: c.latitude }
state.mapProperties.location.setData(coor)
@ -86,38 +77,6 @@
<Tr t={$gpsExplanation} />
</button>
</If>
<If condition={state.featureSwitches.featureSwitchSearch}>
<div
class=".button low-interaction m-1 flex h-fit w-full flex-wrap items-center justify-end gap-x-2 gap-y-2 rounded border p-1"
>
<div style="min-width: 16rem; " class="grow">
<Geosearch
bounds={state.mapProperties.bounds}
on:searchCompleted={() => state.guistate.pageStates.about_theme.setData(false)}
on:searchIsValid={(event) => {
searchEnabled = event.detail
}}
perLayer={state.perLayer}
{selectedElement}
{triggerSearch}
geolocationState={state.geolocation.geolocationState}
searcher={state.geosearch}
{state}
/>
</div>
<button
class={twJoin(
"small flex w-fit shrink-0 items-center justify-between gap-x-2",
!searchEnabled && "disabled"
)}
on:click={() => triggerSearch.ping()}
>
<Tr t={Translations.t.general.search.searchShort} />
<SearchIcon class="h-6 w-6" />
</button>
</div>
</If>
</div>
{#if $currentGPSLocation === undefined && $geopermission === "requested" && GeoLocationState.isSafari()}

View file

@ -104,8 +104,7 @@
</div>
{#if $reason.includeSearch}
searcher={state.geosearch}
<Geosearch bounds={currentMapProperties.bounds} clearAfterView={false} searcher={state.geosearch} {state}/>
<Geosearch {state}/>
{/if}
<div class="flex flex-wrap">

View file

@ -1,20 +1,30 @@
<script lang="ts">
import type { ActiveFilter } from "../../Logic/State/LayerState"
import Tr from "../Base/Tr.svelte"
import Icon from "../Map/Icon.svelte"
import { Badge } from "flowbite-svelte"
import FilterOption from "./FilterOption.svelte"
import { XMarkIcon } from "@babeard/svelte-heroicons/mini"
import Loading from "../Base/Loading.svelte"
export let activeFilter: ActiveFilter
let { control, layer, filter } = activeFilter
let { control, filter } = activeFilter
let option = control.map(c => filter.options[c] ?? filter.options[0])
</script>
<div class="badge">
<FilterOption option={$option} />
<button on:click={() => control.setData(undefined)}>
let loading = false
function clear() {
loading = true
requestIdleCallback(() => {
control.setData(undefined)
loading = false
})
}
</script>
{#if loading}
<Loading />
{:else }
<div class="badge">
<FilterOption option={$option} />
<button on:click={() => clear()}>
<XMarkIcon class="w-5 h-5 pl-1" color="gray" />
</button>
</div>
</div>
{/if}

View file

@ -1,17 +1,29 @@
<script lang="ts">
import { default as ActiveFilterSvelte } from "./ActiveFilter.svelte"
import type { ActiveFilter } from "../../Logic/State/LayerState"
import Loading from "../Base/Loading.svelte"
export let activeFilters: ActiveFilter[]
let loading = false
function clear() {
loading = true
requestIdleCallback(() => {
for (const activeFilter of activeFilters) {
activeFilter.control.setData(undefined)
}
loading = false
})
}
</script>
{#if activeFilters.length > 0}
<div class="flex flex-wrap gap-y-1 gap-x-1 button-unstyled">
<h3>Active filters</h3>
{#if loading}
<Loading />
{:else}
{#each activeFilters as activeFilter (activeFilter)}
<ActiveFilterSvelte {activeFilter} />
{/each}
@ -19,6 +31,7 @@
<button class="as-link subtle" on:click={() => clear()}>
Clear filters
</button>
{/if}
</div>
{/if}

View file

@ -4,19 +4,15 @@
import type { FilterPayload } from "../../Logic/Geocoding/GeocodingProvider"
import { createEventDispatcher } from "svelte"
import Icon from "../Map/Icon.svelte"
import SearchResultUtils from "./SearchResultUtils"
export let entry: {
category: "filter",
payload: FilterPayload
}
let { option, filter, layer, index } = entry.payload
export let entry: FilterPayload
let { option } = entry
export let state: SpecialVisualizationState
let dispatch = createEventDispatcher<{ select }>()
function apply() {
SearchResultUtils.apply(entry.payload, state)
state.searchState.apply(entry)
dispatch("select")
}
</script>

View file

@ -1,53 +1,28 @@
<script lang="ts">
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading.svelte"
import Hotkeys from "../Base/Hotkeys"
import { BBox } from "../../Logic/BBox"
import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { createEventDispatcher, onDestroy } from "svelte"
import { placeholder } from "../../Utils/placeholder"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { ariaLabel } from "../../Utils/ariaLabel"
import { GeoLocationState } from "../../Logic/State/GeoLocationState"
import { NominatimGeocoding } from "../../Logic/Geocoding/NominatimGeocoding"
import { GeocodingUtils } from "../../Logic/Geocoding/GeocodingProvider"
import type { SearchResult } from "../../Logic/Geocoding/GeocodingProvider"
import type GeocodingProvider from "../../Logic/Geocoding/GeocodingProvider"
import SearchResults from "./SearchResults.svelte"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { focusWithArrows } from "../../Utils/focusWithArrows"
import ShowDataLayer from "../Map/ShowDataLayer"
import ThemeViewState from "../../Models/ThemeViewState"
import GeocodingFeatureSource from "../../Logic/Geocoding/GeocodingFeatureSource"
import MoreScreen from "../BigComponents/MoreScreen"
import SearchResultUtils from "./SearchResultUtils"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox>
export let selectedElement: UIEventSource<Feature> | undefined = undefined
export let geolocationState: GeoLocationState | undefined = undefined
export let clearAfterView: boolean = true
export let searcher: GeocodingProvider = new NominatimGeocoding()
export let state: ThemeViewState
let searchContents: UIEventSource<string> = new UIEventSource<string>("")
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
onDestroy(
triggerSearch.addCallback(() => {
performSearch()
})
)
export let searchContents: UIEventSource<string> = new UIEventSource<string>("")
let isRunning: boolean = false
function performSearch() {
state.searchState.performSearch()
}
let isRunning = state.searchState.isSearching
let inputElement: HTMLInputElement
let feedback: string = undefined
let isFocused = new UIEventSource(false)
export let isFocused = new UIEventSource(false)
function focusOnSearch() {
requestAnimationFrame(() => {
@ -57,7 +32,7 @@
}
Hotkeys.RegisterHotkey({ ctrl: "F" }, Translations.t.hotkeyDocumentation.selectSearch, () => {
feedback = undefined
state.searchState.feedback.set(undefined)
focusOnSearch()
})
@ -70,95 +45,6 @@
}
}
async function performSearch() {
try {
isRunning = 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 searchContentsData = $searchContents?.trim() ?? ""
if (searchContentsData === "") {
return
}
const result = await searcher.search(searchContentsData, { bbox: bounds.data, limit: 10 })
if (result.length == 0) {
feedback = Translations.t.general.search.nothing.txt
focusOnSearch()
return
}
const poi = result[0]
if (poi.category === "theme") {
const theme = <MinimalLayoutInformation>poi.payload
const url = MoreScreen.createUrlFor(theme, false)
// @ts-ignore
window.location = url
return
}
if(poi.category === "filter"){
SearchResultUtils.apply(poi.payload, state)
}
if(poi.category === "filter"){
return // Should not happen
}
if (poi.boundingbox) {
const [lat0, lat1, lon0, lon1] = poi.boundingbox
bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1]
]).pad(0.01)
)
} else if (poi.lon && poi.lat) {
state.mapProperties.flyTo(poi.lon, poi.lat, GeocodingUtils.categoryToZoomLevel[poi.category] ?? 16)
}
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
}
selectedElement?.setData(found)
break
}
}
if (clearAfterView) {
searchContents.setData("")
}
dispatch("searchIsValid", false)
dispatch("searchCompleted")
isFocused.setData(false)
} catch (e) {
console.error(e)
feedback = Translations.t.general.search.error.txt
focusOnSearch()
} finally {
isRunning = false
}
}
let suggestions: Store<SearchResult[]> = searchContents.stabilized(250).bindD(search => {
if (search.length === 0) {
return undefined
}
return Stores.holdDefined(bounds.bindD(bbox => searcher.suggest(search, { bbox, limit: 15 })))
}
)
let geocededFeatures= new GeocodingFeatureSource(suggestions.stabilized(250))
state.featureProperties.trackFeatureSource(geocededFeatures)
new ShowDataLayer(
state.map,
{
layer: GeocodingUtils.searchLayer,
features: geocededFeatures,
selectedElement: state.selectedElement
}
)
let geosearch: HTMLDivElement
function checkFocus() {
@ -181,7 +67,7 @@
<div class="normal-background flex justify-between rounded-full pl-2 w-full">
<form class="flex w-full flex-wrap">
{#if isRunning}
{#if $isRunning}
<Loading>{Translations.t.general.search.searching}</Loading>
{:else}
<input
@ -189,7 +75,6 @@
class="w-full outline-none"
bind:this={inputElement}
on:keypress={(keypr) => {
feedback = undefined
if(keypr.key === "Enter"){
performSearch()
keypr.preventDefault()
@ -202,21 +87,9 @@
use:placeholder={Translations.t.general.search.search}
use:ariaLabel={Translations.t.general.search.search}
/>
{#if feedback !== undefined}
<!-- The feedback is _always_ shown for screenreaders and to make sure that the searchfield can still be selected by tabbing-->
<div class="alert" role="alert" aria-live="assertive">
{feedback}
</div>
{/if}
{/if}
</form>
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} />
</div>
<div class="relative h-0" style="z-index: 10">
<div class="absolute right-0 w-full sm:w-96 h-fit max-h-96">
<SearchResults {isFocused} {state} results={$suggestions} searchTerm={searchContents}
on:select={() => {searchContents.set(""); isFocused.setData(false)}} />
</div>
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={() => performSearch()} />
</div>
</div>

View file

@ -11,9 +11,9 @@
</script>
{#if entry.category === "theme"}
<ThemeResult {entry} on:select />
<ThemeResult entry={entry.payload} on:select />
{:else if entry.category === "filter"}
<FilterResult {entry} {state} on:select />
<FilterResult entry={entry.payload} {state} on:select />
{:else}
<GeocodeResult {entry} {state} on:select />
{/if}

View file

@ -1,25 +0,0 @@
import { SpecialVisualizationState } from "../SpecialVisualization"
import { FilterPayload } from "../../Logic/Geocoding/GeocodingProvider"
export default class SearchResultUtils {
static apply(payload: FilterPayload, state: SpecialVisualizationState) {
const { layer, filter, index, option } = payload
let flayer = state.layerState.filteredLayers.get(layer.id)
let 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)
}
if (filtercontrol.data === index) {
filtercontrol.setData(undefined)
} else {
filtercontrol.setData(index)
}
}
}

View file

@ -1,46 +1,57 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Store } from "../../Logic/UIEventSource"
import Loading from "../Base/Loading.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { default as SearchResultSvelte } from "./SearchResult.svelte"
import MoreScreen from "../BigComponents/MoreScreen"
import type { GeocodeResult, SearchResult } from "../../Logic/Geocoding/GeocodingProvider"
import type { GeocodeResult } from "../../Logic/Geocoding/GeocodingProvider"
import ActiveFilters from "./ActiveFilters.svelte"
import Constants from "../../Models/Constants"
import type { ActiveFilter } from "../../Logic/State/LayerState"
import ThemeViewState from "../../Models/ThemeViewState"
import FilterResult from "./FilterResult.svelte"
import ThemeResult from "./ThemeResult.svelte"
export let state: SpecialVisualizationState
export let results: SearchResult[]
export let searchTerm: Store<string>
export let isFocused: UIEventSource<boolean>
export let state: ThemeViewState
let activeFilters: Store<ActiveFilter[]> = state.layerState.activeFilters.map(fs => fs.filter(f => Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0))
let hasActiveFilters = activeFilters.map(afs => afs.length > 0)
let recentlySeen: Store<GeocodeResult[]> = state.recentlySearched.seenThisSession
let recentlySeen: Store<GeocodeResult[]> = state.searchState.recentlySearched.seenThisSession
let recentThemes = state.userRelatedState.recentlyVisitedThemes.mapD(thms => thms.filter(th => th !== state.layout.id).slice(0, 3))
let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview
</script>
let searchTerm = state.searchState.searchTerm
let results = state.searchState.suggestions
let filterResults = state.searchState.filterSuggestions
let themeResults = state.searchState.themeSuggestions
</script>
<div class="p-4">
<div class="relative w-full h-full collapsable " class:collapsed={!$isFocused && !$hasActiveFilters}>
<div class="searchbox normal-background">
<ActiveFilters activeFilters={$activeFilters} />
{#if $isFocused}
{#if $searchTerm.length > 0 && results === undefined}
{#if $filterResults.length > 0}
<h3>Pick a filter below</h3>
<div class="flex flex-wrap">
{#each $filterResults as filterResult (filterResult)}
<FilterResult {state} entry={filterResult} />
{/each}
</div>
{/if}
{#if $searchTerm.length > 0}
<h3>Locations</h3>
{/if}
{#if $searchTerm.length > 0 && $results === undefined}
<div class="flex justify-center m-4 my-8">
<Loading />
</div>
{:else if results?.length > 0}
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto p-2" tabindex="-1">
{#each results as entry (entry)}
{:else if $results?.length > 0}
{#each $results as entry (entry)}
<SearchResultSvelte on:select {entry} {state} />
{/each}
</div>
{:else if $searchTerm.length > 0 || $recentlySeen?.length > 0 || $recentThemes?.length > 0}
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto p-2 flex flex-col gap-y-8"
<div class="flex flex-col gap-y-8"
tabindex="-1">
{#if $searchTerm.length > 0}
<b class="flex justify-center p-4">
@ -74,31 +85,15 @@
{/if}
</div>
{/if}
{#if $themeResults.length > 0}
<h3>
Other maps
</h3>
{#each $themeResults as entry}
<ThemeResult {state} {entry} />
{/each}
{/if}
</div>
</div>
<style>
.searchbox {
display: flex;
flex-direction: column;
row-gap: 0.5rem;
padding: 0.5rem;
border: 1px solid black;
border-radius: 0.5rem;
}
.collapsable {
max-height: 50vh;
transition: max-height 400ms linear;
transition-delay: 100ms;
overflow: hidden;
padding: 0 !important;
}
.collapsed {
padding-top: 0 !important;
padding-bottom: 0 !important;
max-height: 0 !important;
}
</style>

View file

@ -5,8 +5,8 @@
import Icon from "../Map/Icon.svelte"
import Tr from "../Base/Tr.svelte"
export let entry: { category: "theme", payload: MinimalLayoutInformation }
let otherTheme = entry.payload
export let entry: MinimalLayoutInformation
let otherTheme = entry
</script>
<a href={MoreScreen.createUrlFor(otherTheme, false)}

View file

@ -29,7 +29,7 @@ import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource"
import { Map as MlMap } from "maplibre-gl"
import ShowDataLayer from "./Map/ShowDataLayer"
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
import { RecentSearch } from "../Logic/Geocoding/RecentSearch"
import SearchState from "../Logic/State/SearchState"
/**
* The state needed to render a special Visualisation.
@ -95,7 +95,7 @@ export interface SpecialVisualizationState {
readonly previewedImage: UIEventSource<ProvidedImage>
readonly nearbyImageSearcher: CombinedFetcher
readonly geolocation: GeoLocationHandler
readonly recentlySearched: RecentSearch
readonly searchState: SearchState
getMatchingLayer(properties: Record<string, string>);

View file

@ -13,9 +13,7 @@
import type { MapProperties } from "../Models/MapProperties"
import Geosearch from "./Search/Geosearch.svelte"
import Translations from "./i18n/Translations"
import {
MenuIcon
} from "@rgossiaux/svelte-heroicons/solid"
import { MenuIcon } from "@rgossiaux/svelte-heroicons/solid"
import Tr from "./Base/Tr.svelte"
import FloatOver from "./Base/FloatOver.svelte"
import Constants from "../Models/Constants"
@ -41,13 +39,13 @@
import ReverseGeocoding from "./BigComponents/ReverseGeocoding.svelte"
import { BBox } from "../Logic/BBox"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton.svelte"
import { LastClickFeatureSource } from "../Logic/FeatureSource/Sources/LastClickFeatureSource"
import Marker from "./Map/Marker.svelte"
import SelectedElementPanel from "./Base/SelectedElementPanel.svelte"
import MenuDrawer from "./BigComponents/MenuDrawer.svelte"
import DrawerLeft from "./Base/DrawerLeft.svelte"
import type { LayerConfigJson } from "../Models/ThemeConfig/Json/LayerConfigJson"
import { GeocodingUtils } from "../Logic/Geocoding/GeocodingProvider"
import DrawerRight from "./Base/DrawerRight.svelte"
import SearchResults from "./Search/SearchResults.svelte"
import { CloseButton } from "flowbite-svelte"
export let state: ThemeViewState
let layout = state.layout
@ -174,107 +172,6 @@
/>
</div>
{/if}
<div class="pointer-events-none absolute top-0 left-0 w-full">
<!-- Top components -->
<div
class="flex bg-black-light-transparent pointer-events-auto items-center justify-between px-4 py-1 flex-wrap-reverse">
<!-- Top bar with tools -->
<div class="flex items-center">
<MapControlButton
cls="m-0.5 p-0.5 sm:p-1"
arialabel={Translations.t.general.labels.menu}
on:click={() => {console.log("Opening...."); state.guistate.menuIsOpened.setData(true)}}
on:keydown={forwardEventToMap}
>
<MenuIcon class="h-6 w-6 cursor-pointer" />
</MapControlButton>
<MapControlButton
on:click={() => state.guistate.pageStates.about_theme.set(true)}
on:keydown={forwardEventToMap}
>
<div
class="m-0.5 mx-1 flex cursor-pointer items-center max-[480px]:w-full sm:mx-1 mr-2"
>
<Marker icons={layout.icon} size="h-6 w-6 shrink-0 mr-0.5 sm:mr-1 md:mr-2" />
<b class="mr-1">
<Tr t={layout.title} />
</b>
</div>
</MapControlButton>
</div>
<If condition={state.featureSwitches.featureSwitchSearch}>
<div class="w-full sm:w-64 my-2 sm:mt-0">
<Geosearch
bounds={state.mapProperties.bounds}
on:searchCompleted={() => {
state.map?.data?.getCanvas()?.focus()
}}
perLayer={state.perLayer}
selectedElement={state.selectedElement}
geolocationState={state.geolocation.geolocationState}
searcher={state.geosearch}
{state}
/>
</div>
</If>
</div>
<div class="pointer-events-auto float-right mt-1 flex flex-col px-1 max-[480px]:w-full sm:m-2">
<If condition={state.visualFeedback}>
{#if $selectedElement === undefined}
<div class="w-fit">
<VisualFeedbackPanel {state} />
</div>
{/if}
</If>
</div>
<div class="float-left m-1 flex flex-col sm:mt-2">
<If condition={state.featureSwitches.featureSwitchWelcomeMessage}>
</If>
{#if currentViewLayer?.tagRenderings && currentViewLayer.defaultIcon()}
<MapControlButton
on:click={() => {
state.selectCurrentView()
}}
on:keydown={forwardEventToMap}
>
<div class="h-8 w-8 cursor-pointer">
<ToSvelte construct={() => currentViewLayer.defaultIcon()} />
</div>
</MapControlButton>
{/if}
<ExtraLinkButton {state} />
<UploadingImageCounter featureId="*" showThankYou={false} {state} />
<PendingChangesIndicator {state} />
<If condition={state.featureSwitchIsTesting}>
<div class="alert w-fit">Testmode</div>
</If>
{#if state.osmConnection.Backend().startsWith("https://master.apis.dev.openstreetmap.org")}
<div class="thanks">Testserver</div>
{/if}
<If condition={state.featureSwitches.featureSwitchFakeUser}>
<div class="alert w-fit">Faking a user (Testmode)</div>
</If>
</div>
<div class="flex w-full flex-col items-center justify-center">
<!-- Flex and w-full are needed for the positioning -->
<!-- Centermessage -->
<StateIndicator {state} />
<ReverseGeocoding {state} />
</div>
</div>
<div class="pointer-events-none absolute bottom-0 left-0 mb-4 w-screen">
<!-- bottom controls -->
<div class="flex w-full items-end justify-between px-4">
@ -380,8 +277,110 @@
</If>
</div>
</div>
</div>
<DrawerRight shown={state.searchState.showSearchDrawer} }>
<div class="relative">
<div class="absolute right-0 top-0 ">
<div class="mr-4 mt-4">
<CloseButton on:click={() => state.searchState.showSearchDrawer.set(false)} />
</div>
</div>
<SearchResults {state} />
</div>
</DrawerRight>
<div class="pointer-events-none absolute top-0 left-0 w-full">
<!-- Top components -->
<div
id="top-bar"
class="flex bg-black-light-transparent pointer-events-auto items-center justify-between px-4 py-1 flex-wrap-reverse">
<!-- Top bar with tools -->
<div class="flex items-center">
<MapControlButton
cls="m-0.5 p-0.5 sm:p-1"
arialabel={Translations.t.general.labels.menu}
on:click={() => {state.guistate.menuIsOpened.setData(true)}}
on:keydown={forwardEventToMap}
>
<MenuIcon class="h-6 w-6 cursor-pointer" />
</MapControlButton>
<MapControlButton
on:click={() => state.guistate.pageStates.about_theme.set(true)}
on:keydown={forwardEventToMap}
>
<div
class="m-0.5 mx-1 flex cursor-pointer items-center max-[480px]:w-full sm:mx-1 mr-2"
>
<Marker icons={layout.icon} size="h-6 w-6 shrink-0 mr-0.5 sm:mr-1 md:mr-2" />
<b class="mr-1">
<Tr t={layout.title} />
</b>
</div>
</MapControlButton>
</div>
<If condition={state.featureSwitches.featureSwitchSearch}>
<div class="w-full sm:w-80 md:w-96 my-2 sm:mt-0">
<Geosearch {state} isFocused={state.searchState.searchIsFocused}
searchContents={state.searchState.searchTerm} />
</div>
</If>
</div>
<div class="pointer-events-auto float-right mt-1 flex flex-col px-1 max-[480px]:w-full sm:m-2">
<If condition={state.visualFeedback}>
{#if $selectedElement === undefined}
<div class="w-fit">
<VisualFeedbackPanel {state} />
</div>
{/if}
</If>
</div>
<div class="float-left m-1 flex flex-col sm:mt-2">
{#if currentViewLayer?.tagRenderings && currentViewLayer.defaultIcon()}
<MapControlButton
on:click={() => {
state.selectCurrentView()
}}
on:keydown={forwardEventToMap}
>
<div class="h-8 w-8 cursor-pointer">
<ToSvelte construct={() => currentViewLayer.defaultIcon()} />
</div>
</MapControlButton>
{/if}
<ExtraLinkButton {state} />
<UploadingImageCounter featureId="*" showThankYou={false} {state} />
<PendingChangesIndicator {state} />
<If condition={state.featureSwitchIsTesting}>
<div class="alert w-fit">Testmode</div>
</If>
{#if state.osmConnection.Backend().startsWith("https://master.apis.dev.openstreetmap.org")}
<div class="thanks">Testserver</div>
{/if}
<If condition={state.featureSwitches.featureSwitchFakeUser}>
<div class="alert w-fit">Faking a user (Testmode)</div>
</If>
</div>
<div class="flex w-full flex-col items-center justify-center">
<!-- Flex and w-full are needed for the positioning -->
<!-- Centermessage -->
<StateIndicator {state} />
<ReverseGeocoding {state} />
</div>
</div>
<LoginToggle ignoreLoading={true} {state}>
{#if ($showCrosshair === "yes" && $currentZoom >= 17) || $showCrosshair === "always" || $visualFeedback}
<!-- Don't use h-full: h-full does _not_ include the area under the URL-bar, which offsets the crosshair a bit -->