Search: similar filters in different layers are now merged, fix #

This commit is contained in:
Pieter Vander Vennet 2024-09-17 02:16:25 +02:00
parent 6ebc0632a3
commit 48186aa530
15 changed files with 156 additions and 63 deletions

View file

@ -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)

View file

@ -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",

View file

@ -7208,7 +7208,7 @@
},
"description": "Obchod",
"filter": {
"1": {
"0": {
"options": {
"0": {
"question": "Zobrazit pouze obchody prodávající použité zboží"

View file

@ -9087,7 +9087,7 @@
},
"description": "Ein Geschäft",
"filter": {
"1": {
"0": {
"options": {
"0": {
"question": "Nur Second-Hand-Geschäfte anzeigen"

View file

@ -9122,7 +9122,7 @@
},
"description": "A shop",
"filter": {
"1": {
"0": {
"options": {
"0": {
"question": "Only show shops selling second-hand items"

View file

@ -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);
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -10,6 +10,7 @@
background: var(--background-color);
padding: 0.5rem;
border-radius: 0.5rem;
overflow-y: auto;
}
:global(.sidebar-unit > h3) {

View file

@ -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">

View file

@ -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}

View file

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

View file

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

View file

@ -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}

View file

@ -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} />