forked from MapComplete/MapComplete
		
	Search: similar filters in different layers are now merged, fix #
This commit is contained in:
		
							parent
							
								
									6ebc0632a3
								
							
						
					
					
						commit
						48186aa530
					
				
					 15 changed files with 156 additions and 63 deletions
				
			
		|  | @ -26,5 +26,5 @@ To validate the 'search with filters', the tester was tasked with searching all | |||
| 
 | ||||
| ## To improve | ||||
| 
 | ||||
| [ ] Why are there multiple "Open Now" filters? | ||||
| [x] Why are there multiple "Open Now" filters? Because of different layers with a similar filter, they are now shown merged | ||||
| [x] Special layers (e.g. gps location) are disabled as well (fixed now) | ||||
|  |  | |||
|  | @ -406,6 +406,7 @@ | |||
|             "error": "Something went wrong…", | ||||
|             "instructions": "Use the search bar above to search for locations, filters or other thematic maps", | ||||
|             "locations": "Locations", | ||||
|             "nMoreFilters": "{n} more", | ||||
|             "nothing": "Nothing found…", | ||||
|             "nothingFor": "No results found for {term}", | ||||
|             "otherMaps": "Other maps", | ||||
|  |  | |||
|  | @ -7208,7 +7208,7 @@ | |||
|         }, | ||||
|         "description": "Obchod", | ||||
|         "filter": { | ||||
|             "1": { | ||||
|             "0": { | ||||
|                 "options": { | ||||
|                     "0": { | ||||
|                         "question": "Zobrazit pouze obchody prodávající použité zboží" | ||||
|  |  | |||
|  | @ -9087,7 +9087,7 @@ | |||
|         }, | ||||
|         "description": "Ein Geschäft", | ||||
|         "filter": { | ||||
|             "1": { | ||||
|             "0": { | ||||
|                 "options": { | ||||
|                     "0": { | ||||
|                         "question": "Nur Second-Hand-Geschäfte anzeigen" | ||||
|  |  | |||
|  | @ -9122,7 +9122,7 @@ | |||
|         }, | ||||
|         "description": "A shop", | ||||
|         "filter": { | ||||
|             "1": { | ||||
|             "0": { | ||||
|                 "options": { | ||||
|                     "0": { | ||||
|                         "question": "Only show shops selling second-hand items" | ||||
|  |  | |||
|  | @ -1188,14 +1188,14 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   left: 25%; | ||||
| } | ||||
| 
 | ||||
| .bottom-4 { | ||||
|   bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| .top-6 { | ||||
|   top: 1.5rem; | ||||
| } | ||||
| 
 | ||||
| .bottom-4 { | ||||
|   bottom: 1rem; | ||||
| } | ||||
| 
 | ||||
| .bottom-5 { | ||||
|   bottom: 1.25rem; | ||||
| } | ||||
|  | @ -2760,6 +2760,10 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   overflow-y: auto; | ||||
| } | ||||
| 
 | ||||
| .overflow-x-hidden { | ||||
|   overflow-x: hidden; | ||||
| } | ||||
| 
 | ||||
| .overflow-y-scroll { | ||||
|   overflow-y: scroll; | ||||
| } | ||||
|  | @ -4719,6 +4723,11 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); | ||||
| } | ||||
| 
 | ||||
| .shadow-transparent { | ||||
|   --tw-shadow-color: transparent; | ||||
|   --tw-shadow: var(--tw-shadow-colored); | ||||
| } | ||||
| 
 | ||||
| .shadow-gray-300 { | ||||
|   --tw-shadow-color: #D1D5DB; | ||||
|   --tw-shadow: var(--tw-shadow-colored); | ||||
|  | @ -5353,8 +5362,8 @@ h2.group { | |||
|   align-items: center; | ||||
|   white-space: nowrap; | ||||
|   border-radius: 999rem; | ||||
|   padding-left: 0.5rem; | ||||
|   padding-right: 0.5rem; | ||||
|   padding-left: 0.25rem; | ||||
|   padding-right: 0.25rem; | ||||
|   border: 1px solid var(--subtle-detail-color-light-contrast); | ||||
|   background-color: var(--low-interaction-background); | ||||
| } | ||||
|  |  | |||
|  | @ -1,4 +1,3 @@ | |||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| import { Utils } from "../../Utils" | ||||
| import Locale from "../../UI/i18n/Locale" | ||||
| import Constants from "../../Models/Constants" | ||||
|  | @ -110,4 +109,23 @@ export default class FilterSearch { | |||
|         Utils.shuffle(result) | ||||
|         return result.slice(0, 6) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Partitions the list of filters in such a way that identically appearing filters will be in the same sublist. | ||||
|      * | ||||
|      * Note that this depends on the language and the displayed text. For example, two filters {"en": "A", "nl": "B"} and {"en": "X", "nl": "B"} will be joined for dutch but not for English | ||||
|      * | ||||
|      */ | ||||
|     static mergeSemiIdenticalLayers<T extends FilterSearchResult = FilterSearchResult>(filters: ReadonlyArray<T>, language: string):T[][]  { | ||||
|         const results : Record<string, T[]> = {} | ||||
|         for (const filter of filters) { | ||||
|             const txt = filter.option.question.textFor(language) | ||||
|             if(results[txt]){ | ||||
|                 results[txt].push(filter) | ||||
|             }else{ | ||||
|                 results[txt] = [filter] | ||||
|             } | ||||
|         } | ||||
|         return Object.values(results) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -79,7 +79,7 @@ export default class SearchState { | |||
|                     return !foundMatch | ||||
|                 }) | ||||
|             }, [state.layerState.activeFilters]) | ||||
|         this.locationResults =new GeocodingFeatureSource(this.suggestions.stabilized(250)) | ||||
|         this.locationResults = new GeocodingFeatureSource(this.suggestions.stabilized(250)) | ||||
| 
 | ||||
|         this.showSearchDrawer = new UIEventSource(false) | ||||
| 
 | ||||
|  | @ -92,7 +92,7 @@ export default class SearchState { | |||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public async apply(result: FilterSearchResult | LayerConfig) { | ||||
|     public async apply(result: FilterSearchResult[] | LayerConfig) { | ||||
|         if (result instanceof LayerConfig) { | ||||
|             return this.applyLayer(result) | ||||
|         } | ||||
|  | @ -105,29 +105,35 @@ export default class SearchState { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async applyFilter(payload: FilterSearchResult) { | ||||
|     private async applyFilter(payload: FilterSearchResult[]) { | ||||
|         const state = this.state | ||||
| 
 | ||||
|         const { layer, filter, index } = payload | ||||
|         const layers = payload.map(fsr => fsr.layer.id) | ||||
|         for (const [name, otherLayer] of state.layerState.filteredLayers) { | ||||
|             const layer = otherLayer.layerDef | ||||
|             if(!layer.isNormal()){ | ||||
|             if (!layer.isNormal()) { | ||||
|                 continue | ||||
|             } | ||||
|             otherLayer.isDisplayed.setData(payload.layer.id === layer.id) | ||||
|             if(otherLayer.layerDef.minzoom > state.mapProperties.minzoom.data) { | ||||
|                 // Currently not displayed, we don't hide
 | ||||
|                 continue | ||||
|             } | ||||
|             otherLayer.isDisplayed.setData(layers.indexOf(layer.id) > 0) | ||||
|         } | ||||
|         const flayer = state.layerState.filteredLayers.get(layer.id) | ||||
|         flayer.isDisplayed.set(true) | ||||
|         const filtercontrol = flayer.appliedFilters.get(filter.id) | ||||
|         if (filtercontrol.data === index) { | ||||
|             filtercontrol.setData(undefined) | ||||
|         } else { | ||||
|             filtercontrol.setData(index) | ||||
|         for (const  { filter, index, layer } of payload) { | ||||
|             const flayer = state.layerState.filteredLayers.get(layer.id) | ||||
|             flayer.isDisplayed.set(true) | ||||
|             const filtercontrol = flayer.appliedFilters.get(filter.id) | ||||
|             if (filtercontrol.data === index) { | ||||
|                 filtercontrol.setData(undefined) | ||||
|             } else { | ||||
|                 filtercontrol.setData(index) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     closeIfFullscreen() { | ||||
|         if(window.innerWidth < 640){ | ||||
|         if (window.innerWidth < 640) { | ||||
|             this.showSearchDrawer.set(false) | ||||
|         } | ||||
|     } | ||||
|  | @ -135,7 +141,7 @@ export default class SearchState { | |||
|     clickedOnMap(feature: Feature) { | ||||
|         const osmid = feature.properties.osm_id | ||||
|         const localElement = this.state.indexedFeatures.featuresById.data.get(osmid) | ||||
|         if(localElement){ | ||||
|         if (localElement) { | ||||
|             this.state.selectedElement.set(localElement) | ||||
|             return | ||||
|         } | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ | |||
|         background: var(--background-color); | ||||
|         padding: 0.5rem; | ||||
|         border-radius: 0.5rem; | ||||
|         overflow-y: auto; | ||||
|     } | ||||
| 
 | ||||
|     :global(.sidebar-unit > h3) { | ||||
|  |  | |||
|  | @ -2,11 +2,16 @@ | |||
|   import { Accordion, AccordionItem } from "flowbite-svelte" | ||||
| 
 | ||||
|   export let expanded = false | ||||
|   export let noBorder = false | ||||
| let defaultClass: string = undefined | ||||
|   if(noBorder){ | ||||
|     defaultClass = "unstyled w-full flex-grow" | ||||
|   } | ||||
| </script> | ||||
| 
 | ||||
| <Accordion> | ||||
|   <AccordionItem open={expanded} paddingDefault="p-0" inactiveClass="text-black"> | ||||
|     <span slot="header" class="w-full p-2 text-base"> | ||||
|   <AccordionItem open={expanded} paddingDefault="p-0" inactiveClass="text-black" {defaultClass}> | ||||
|     <span slot="header" class={!noBorder ? "w-full p-2 text-base" : "w-full"}> | ||||
|       <slot name="header" /> | ||||
|     </span> | ||||
|     <div class="low-interaction rounded-b p-2"> | ||||
|  |  | |||
|  | @ -3,25 +3,36 @@ | |||
|   import FilterOption from "./FilterOption.svelte" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import FilterToggle from "./FilterToggle.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
| 
 | ||||
| 
 | ||||
|   export let activeFilter: ActiveFilter | ||||
|   let { control, filter } = activeFilter | ||||
|   export let activeFilter: ActiveFilter[] | ||||
|   let { control, filter } = activeFilter[0] | ||||
|   let option = control.map(c => filter.options[c] ?? filter.options[0]) | ||||
|   let loading = false | ||||
| 
 | ||||
|   function clear() { | ||||
|     loading = true | ||||
|     requestIdleCallback(() => { | ||||
|       control.setData(undefined) | ||||
|       for (const af of activeFilter) { | ||||
|         af.control.setData(undefined) | ||||
|       } | ||||
|       loading = false | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   export let state: SpecialVisualizationState | ||||
|   let debug = state.featureSwitches.featureSwitchIsDebugging | ||||
| </script> | ||||
| {#if loading} | ||||
|   <Loading /> | ||||
| {:else } | ||||
|   <FilterToggle  on:click={() => clear()}> | ||||
|   <FilterToggle on:click={() => clear()}> | ||||
|     <FilterOption option={$option} /> | ||||
|     {#if $debug} | ||||
|       <span class="subtle"> | ||||
|         ({activeFilter.map(af => af.layer.id).join(", ")}) | ||||
|       </span> | ||||
|     {/if} | ||||
|   </FilterToggle> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -8,10 +8,17 @@ | |||
|   import FilterToggle from "./FilterToggle.svelte" | ||||
|   import ToSvelte from "../Base/ToSvelte.svelte" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import { Store } from "../../Logic/UIEventSource" | ||||
|   import { Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import type { FilterSearchResult } from "../../Logic/Search/FilterSearch" | ||||
|   import FilterSearch from "../../Logic/Search/FilterSearch" | ||||
| 
 | ||||
|   export let activeFilters: ActiveFilter[] | ||||
|   import Locale from "../i18n/Locale" | ||||
| 
 | ||||
|   export let activeFilters: ( FilterSearchResult & ActiveFilter)[] | ||||
|   let language = Locale.language | ||||
|   let mergedActiveFilters = FilterSearch.mergeSemiIdenticalLayers(activeFilters, $language) | ||||
|   $:mergedActiveFilters = FilterSearch.mergeSemiIdenticalLayers(activeFilters, $language) | ||||
|   export let state: SpecialVisualizationState | ||||
|   let loading = false | ||||
|   const t =Translations.t.general.search | ||||
|  | @ -33,8 +40,6 @@ | |||
|     loading = true | ||||
|     requestIdleCallback(() => { | ||||
|       enableAllLayers() | ||||
| 
 | ||||
| 
 | ||||
|       for (const activeFilter of activeFilters) { | ||||
|         activeFilter.control.setData(undefined) | ||||
|       } | ||||
|  | @ -44,7 +49,7 @@ | |||
|   } | ||||
| </script> | ||||
| 
 | ||||
| {#if activeFilters.length > 0 || $nonactiveLayers.length > 0} | ||||
| {#if mergedActiveFilters.length > 0 || $nonactiveLayers.length > 0} | ||||
|   <SidebarUnit> | ||||
|     <div class="flex justify-between"> | ||||
|       <h3><Tr t={t.activeFilters}/></h3> | ||||
|  | @ -81,9 +86,9 @@ | |||
|         {/if} | ||||
| 
 | ||||
| 
 | ||||
|         {#each activeFilters as activeFilter (activeFilter)} | ||||
|         {#each mergedActiveFilters as activeFilter (activeFilter)} | ||||
|           <div> | ||||
|             <ActiveFilterSvelte {activeFilter} /> | ||||
|             <ActiveFilterSvelte {activeFilter} {state}/> | ||||
|           </div> | ||||
|         {/each} | ||||
|       </div> | ||||
|  |  | |||
|  | @ -8,13 +8,19 @@ | |||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
| 
 | ||||
|   export let entry: FilterSearchResult | LayerConfig | ||||
|   let isLayer = entry instanceof LayerConfig | ||||
|   let asLayer = <LayerConfig>entry | ||||
|   let asFilter = <FilterSearchResult>entry | ||||
|   export let entry: FilterSearchResult[] | LayerConfig | ||||
|   let asFilter: FilterSearchResult[] | ||||
|   let asLayer: LayerConfig | ||||
|   if(Array.isArray(entry)){ | ||||
|       asFilter = entry | ||||
|   }else{ | ||||
|     asLayer = <LayerConfig>entry | ||||
| 
 | ||||
|   } | ||||
|   export let state: SpecialVisualizationState | ||||
| 
 | ||||
|   let loading = false | ||||
|   let debug = state.featureSwitches.featureSwitchIsDebugging | ||||
| 
 | ||||
|   function apply() { | ||||
|     loading = true | ||||
|  | @ -34,7 +40,7 @@ | |||
|   {/if} | ||||
|   <div class="flex flex-col items-start"> | ||||
|     <div class="flex items-center gap-x-1"> | ||||
|       {#if isLayer} | ||||
|       {#if asLayer} | ||||
|         <div class="w-8 h-8 p-1"> | ||||
|           <ToSvelte construct={asLayer.defaultIcon()} /> | ||||
|         </div> | ||||
|  | @ -42,8 +48,11 @@ | |||
|           <Tr t={asLayer.name} /> | ||||
|         </b> | ||||
|       {:else} | ||||
|         <Icon icon={asFilter.option.icon ?? asFilter.option.emoji} clss="w-4 h-4" emojiHeight="14px" /> | ||||
|         <Tr cls="whitespace-nowrap" t={asFilter.option.question} /> | ||||
|         <Icon icon={asFilter[0].option.icon ?? asFilter[0].option.emoji} clss="w-4 h-4" emojiHeight="14px" /> | ||||
|         <Tr cls="whitespace-nowrap" t={asFilter[0].option.question} /> | ||||
|         {#if $debug} | ||||
|         <span class="subtle">({asFilter.map(f => f.layer.id).join(", ")})</span> | ||||
|           {/if} | ||||
|       {/if} | ||||
|     </div> | ||||
|   </div> | ||||
|  |  | |||
|  | @ -4,15 +4,21 @@ | |||
| 
 | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
|   import FilterSearch from "../../Logic/Search/FilterSearch" | ||||
|   import type { FilterSearchResult } from "../../Logic/Search/FilterSearch" | ||||
| 
 | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import Locale from "../i18n/Locale" | ||||
|   import { Store } from "../../Logic/UIEventSource" | ||||
|   import AccordionSingle from "../Flowbite/AccordionSingle.svelte" | ||||
| 
 | ||||
|   export let state: SpecialVisualizationState | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
|   let activeLayers = state.layerState.activeLayers | ||||
|   let filterResults = state.searchState.filterSuggestions | ||||
| 
 | ||||
|   let filtersMerged = filterResults.map(filters => FilterSearch.mergeSemiIdenticalLayers(filters, Locale.language.data), [Locale.language]) | ||||
| 
 | ||||
|   let layerResults = state.searchState.layerSuggestions.map(layers => { | ||||
|     const nowActive = activeLayers.data.filter(al => al.layerDef.isNormal()) | ||||
|  | @ -22,30 +28,44 @@ | |||
|     } | ||||
|     return layers | ||||
|   }, [activeLayers]) | ||||
|   let filterResultsClipped = filterResults.mapD(filters => { | ||||
|   let filterResultsClipped: Store<{ | ||||
|     clipped: (FilterSearchResult[] | LayerConfig)[], | ||||
|     rest?: (FilterSearchResult[] | LayerConfig)[] | ||||
|   }> = filtersMerged.mapD(filters => { | ||||
|     let layers = layerResults.data | ||||
|     const ls: (FilterSearchResult | LayerConfig)[] = [].concat(layers, filters) | ||||
|     const ls: (FilterSearchResult[] | LayerConfig)[] = [].concat(layers, filters) | ||||
|     if (ls.length <= 6) { | ||||
|       return ls | ||||
|       return { clipped: ls } | ||||
|     } | ||||
|     return ls.slice(0, 4) | ||||
|   }, [layerResults, activeLayers]) | ||||
|     return { clipped: ls.slice(0, 4), rest: ls.slice(4) } | ||||
|   }, [layerResults, activeLayers, Locale.language]) | ||||
| </script> | ||||
| 
 | ||||
| {#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} | ||||
|   <SidebarUnit> | ||||
| 
 | ||||
|     <h3><Tr t={Translations.t.general.search.pickFilter} /></h3> | ||||
|     <h3> | ||||
|       <Tr t={Translations.t.general.search.pickFilter} /> | ||||
|     </h3> | ||||
| 
 | ||||
|     <div class="flex flex-wrap"> | ||||
|       {#each $filterResultsClipped as filterResult (filterResult)} | ||||
|       {#each $filterResultsClipped.clipped as filterResult (filterResult)} | ||||
|         <FilterResultSvelte {state} entry={filterResult} /> | ||||
|       {/each} | ||||
|     </div> | ||||
|     {#if $filterResults.length + $layerResults.length > $filterResultsClipped.length} | ||||
|       <div class="flex justify-center"> | ||||
|         ... and {$filterResults.length + $layerResults.length - $filterResultsClipped.length} more ... | ||||
|       </div> | ||||
|     {#if $filtersMerged.length + $layerResults.length > $filterResultsClipped.clipped.length} | ||||
|       <AccordionSingle noBorder> | ||||
|         <div class="flex justify-end text-sm subtle" slot="header"> | ||||
|           <Tr t={Translations.t.general.search.nMoreFilters.Subs( | ||||
|           {n: $filtersMerged.length + $layerResults.length - $filterResultsClipped.clipped.length} | ||||
|           )}/> | ||||
|         </div> | ||||
|         <div class="flex flex-wrap overflow-y-auto"> | ||||
|           {#each $filterResultsClipped.rest as filterResult (filterResult)} | ||||
|             <FilterResultSvelte {state} entry={filterResult} /> | ||||
|           {/each} | ||||
|         </div> | ||||
|       </AccordionSingle> | ||||
|     {/if} | ||||
|   </SidebarUnit> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -4,16 +4,22 @@ | |||
|   import Constants from "../../Models/Constants" | ||||
|   import type { ActiveFilter } from "../../Logic/State/LayerState" | ||||
|   import ThemeViewState from "../../Models/ThemeViewState" | ||||
|    import ThemeResults from "./ThemeResults.svelte" | ||||
|   import ThemeResults from "./ThemeResults.svelte" | ||||
|   import GeocodeResults from "./GeocodeResults.svelte" | ||||
|   import FilterResults from "./FilterResults.svelte" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import type { FilterSearchResult } from "../../Logic/Search/FilterSearch" | ||||
| 
 | ||||
|   export let state: ThemeViewState | ||||
|   let activeFilters: Store<ActiveFilter[]> = state.layerState.activeFilters.map(fs => fs.filter(f => | ||||
|   let activeFilters: Store<(ActiveFilter & FilterSearchResult)[]> = state.layerState.activeFilters.map(fs => fs.filter(f => | ||||
|     (f.filter.options[0].fields.length === 0) && | ||||
|     Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0)) | ||||
|     Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0) | ||||
|     .map(af => { | ||||
|       const index = <number> af.control.data | ||||
|       const r : FilterSearchResult & ActiveFilter = { ...af, index, option: af.filter.options[index] } | ||||
|       return r | ||||
|     })) | ||||
|   let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
| </script> | ||||
|  | @ -23,13 +29,15 @@ | |||
| 
 | ||||
|   {#if $searchTerm.length === 0 && $activeFilters.length === 0 } | ||||
|     <div class="p-8 items-center text-center"> | ||||
|       <b><Tr t={Translations.t.general.search.instructions}/></b> | ||||
|       <b> | ||||
|         <Tr t={Translations.t.general.search.instructions} /> | ||||
|       </b> | ||||
|     </div> | ||||
|   {/if} | ||||
| 
 | ||||
|   <FilterResults {state}/> | ||||
|   <FilterResults {state} /> | ||||
| 
 | ||||
|   <GeocodeResults {state}/> | ||||
|   <GeocodeResults {state} /> | ||||
| 
 | ||||
|   {#if $allowOtherThemes} | ||||
|     <ThemeResults {state} /> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue