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
				
			
		|  | @ -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" | ||||
|  |  | |||
|  | @ -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%; | ||||
|   } | ||||
|  |  | |||
|  | @ -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 [] | ||||
|         } | ||||
|         const queries = query.split(" ").map(query => { | ||||
|             if (!Utils.isEmoji(query)) { | ||||
|             query = Utils.simplifyStringForSearch(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> { | ||||
|  |  | |||
|  | @ -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 | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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>){ | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										43
									
								
								src/UI/Base/DrawerRight.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								src/UI/Base/DrawerRight.svelte
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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()} | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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]) | ||||
|   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={() => control.setData(undefined)}> | ||||
| 
 | ||||
|     <button on:click={() => clear()}> | ||||
|       <XMarkIcon class="w-5 h-5 pl-1" color="gray" /> | ||||
|     </button> | ||||
|   </div> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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)} | ||||
|  |  | |||
|  | @ -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>); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 --> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue