forked from MapComplete/MapComplete
		
	Search feature: refactor, add translations
This commit is contained in:
		
							parent
							
								
									b3492930b8
								
							
						
					
					
						commit
						bd3bddc89c
					
				
					 21 changed files with 499 additions and 507 deletions
				
			
		|  | @ -397,11 +397,21 @@ | |||
|         "save": "Save", | ||||
|         "screenToSmall": "Open <i>{theme}</i> in a new window", | ||||
|         "search": { | ||||
|             "activeFilters": "Active filters", | ||||
|             "clearFilters": "Clear filters", | ||||
|             "deleteSearchHistory": "Delete location history", | ||||
|             "deleteThemeHistory": "Delete earlier visited themes", | ||||
|             "editSearchSyncSettings": "Edit sync settings", | ||||
|             "editThemeSync": "Edit sync settings", | ||||
|             "error": "Something went wrong…", | ||||
|             "instructions": "Use the search bar above to search for locations, filters or other thematic maps", | ||||
|             "locations": "Locations", | ||||
|             "nothing": "Nothing found…", | ||||
|             "nothingFor": "No results found for {term}", | ||||
|             "otherMaps": "Other maps", | ||||
|             "pickFilter": "Pick a filter", | ||||
|             "recentThemes": "Recently visited maps", | ||||
|             "recents": "Recent searches", | ||||
|             "recents": "Recently seen places", | ||||
|             "search": "Search a location", | ||||
|             "searchShort": "Search…", | ||||
|             "searching": "Searching…" | ||||
|  | @ -877,4 +887,4 @@ | |||
|             "startsWithQ": "A wikidata identifier starts with Q and is followed by a number" | ||||
|         } | ||||
|     } | ||||
| } | ||||
| } | ||||
|  | @ -333,9 +333,21 @@ | |||
|         "save": "Opslaan", | ||||
|         "screenToSmall": "Open {theme} in een nieuw venster", | ||||
|         "search": { | ||||
|             "activeFilters": "Actieve filters", | ||||
|             "clearFilters": "Verwijder filters", | ||||
|             "deleteSearchHistory": "Verwijder geschiedenis", | ||||
|             "deleteThemeHistory": "Verwijder geschiedenis", | ||||
|             "editSearchSyncSettings": "Stel je geschiedenis-voorkeuren in", | ||||
|             "editThemeSync": "Stel je geschiedenis-voorkeuren in", | ||||
|             "error": "Niet gelukt…", | ||||
|             "instructions": "Gebruik de zoekbalk om locaties, filters of om andere kaarten te zoeken", | ||||
|             "locations": "Plaatsen", | ||||
|             "nothing": "Niets gevonden…", | ||||
|             "search": "Zoek naar een locatie", | ||||
|             "otherMaps": "Andere kaarten", | ||||
|             "pickFilter": "Kies een filter", | ||||
|             "recentThemes": "Recent bezochte kaarten", | ||||
|             "recents": "Recent bekeken plaatsen", | ||||
|             "search": "Zoek naar een locatie, filter of kaart", | ||||
|             "searchShort": "Zoek…", | ||||
|             "searching": "Aan het zoeken…" | ||||
|         }, | ||||
|  |  | |||
|  | @ -2,11 +2,11 @@ import GeocodingProvider, { SearchResult, GeocodingOptions, GeocodeResult } from | |||
| import { Utils } from "../../Utils" | ||||
| import { Store, Stores } from "../UIEventSource" | ||||
| 
 | ||||
| export default class CombinedSearcher implements GeocodingProvider <GeocodeResult> { | ||||
|     private _providers: ReadonlyArray<GeocodingProvider<GeocodeResult>> | ||||
|     private _providersWithSuggest: ReadonlyArray<GeocodingProvider<GeocodeResult>> | ||||
| export default class CombinedSearcher implements GeocodingProvider { | ||||
|     private _providers: ReadonlyArray<GeocodingProvider> | ||||
|     private _providersWithSuggest: ReadonlyArray<GeocodingProvider> | ||||
| 
 | ||||
|     constructor(...providers: ReadonlyArray<GeocodingProvider<GeocodeResult>>) { | ||||
|     constructor(...providers: ReadonlyArray<GeocodingProvider>) { | ||||
|         this._providers = Utils.NoNull(providers) | ||||
|         this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined) | ||||
|     } | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import { ImmutableStore, Store } from "../UIEventSource" | |||
| /** | ||||
|  * A simple search-class which interprets possible locations | ||||
|  */ | ||||
| export default class CoordinateSearch implements GeocodingProvider<GeocodeResult> { | ||||
| export default class CoordinateSearch implements GeocodingProvider { | ||||
|     private static readonly latLonRegexes: ReadonlyArray<RegExp> = [ | ||||
|         /^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/, | ||||
|         /lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/, | ||||
|  |  | |||
|  | @ -1,24 +1,24 @@ | |||
| import { ImmutableStore, Store } from "../UIEventSource" | ||||
| import GeocodingProvider, { FilterPayload, FilterResult, GeocodingOptions, SearchResult } from "./GeocodingProvider" | ||||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| import { Utils } from "../../Utils" | ||||
| import Locale from "../../UI/i18n/Locale" | ||||
| import Constants from "../../Models/Constants" | ||||
| import FilterConfig, { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| 
 | ||||
| export type FilterSearchResult = { option: FilterConfigOption, filter: FilterConfig, layer: LayerConfig, index: number } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Searches matching filters | ||||
|  */ | ||||
| export default class FilterSearch implements GeocodingProvider { | ||||
| export default class FilterSearch { | ||||
|     private readonly _state: SpecialVisualizationState | ||||
| 
 | ||||
|     constructor(state: SpecialVisualizationState) { | ||||
|         this._state = state | ||||
|     } | ||||
| 
 | ||||
|     async search(query: string): Promise<SearchResult[]> { | ||||
|         return this.searchDirectly(query) | ||||
|     } | ||||
|     public searchDirectly(query: string): FilterResult[] { | ||||
|     public search(query: string): FilterSearchResult[] { | ||||
|         if (query.length === 0) { | ||||
|             return [] | ||||
|         } | ||||
|  | @ -28,7 +28,7 @@ export default class FilterSearch implements GeocodingProvider { | |||
|             } | ||||
|             return query | ||||
|         }).filter(q => q.length > 0) | ||||
|         const possibleFilters: FilterResult[] = [] | ||||
|         const possibleFilters: FilterSearchResult[] = [] | ||||
|         for (const layer of this._state.layout.layers) { | ||||
|             if (!Array.isArray(layer.filters)) { | ||||
|                 continue | ||||
|  | @ -61,13 +61,9 @@ export default class FilterSearch implements GeocodingProvider { | |||
|                     if (levehnsteinD > 0.25) { | ||||
|                         continue | ||||
|                     } | ||||
|                     possibleFilters.push(<FilterResult>{ | ||||
|                         category: "filter", | ||||
|                         osm_id: layer.id + "/" + filter.id + "/" + i, | ||||
|                         payload: { | ||||
|                     possibleFilters.push({ | ||||
|                             option, layer, filter, index: | ||||
|                             i, | ||||
|                         }, | ||||
|                     }) | ||||
|                 } | ||||
|             } | ||||
|  | @ -75,16 +71,11 @@ export default class FilterSearch implements GeocodingProvider { | |||
|         return possibleFilters | ||||
|     } | ||||
| 
 | ||||
|     suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> { | ||||
|         return new ImmutableStore(this.searchDirectly(query)) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Create a random list of filters | ||||
|      */ | ||||
|     getSuggestions(): FilterPayload[] { | ||||
|         const result: FilterPayload[] = [] | ||||
|     getSuggestions(): FilterSearchResult[] { | ||||
|         const result: FilterSearchResult[] = [] | ||||
|         for (const [id, filteredLayer] of this._state.layerState.filteredLayers) { | ||||
|             if (!Array.isArray(filteredLayer.layerDef.filters)) { | ||||
|                 continue | ||||
|  | @ -93,7 +84,7 @@ export default class FilterSearch implements GeocodingProvider { | |||
|                 continue | ||||
|             } | ||||
|             for (const filter of filteredLayer.layerDef.filters) { | ||||
|                 const singleFilterResults: FilterPayload[] = [] | ||||
|                 const singleFilterResults: FilterSearchResult[] = [] | ||||
|                 for (let i = 0; i < Math.min(filter.options.length, 5); i++) { | ||||
|                     const option = filter.options[i] | ||||
|                     if (option.osmTags === undefined) { | ||||
|  |  | |||
|  | @ -5,8 +5,6 @@ import { Store } from "../UIEventSource" | |||
| import * as search from "../../assets/generated/layers/search.json" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" | ||||
| import FilterConfig, { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig" | ||||
| import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { GeoOperations } from "../GeoOperations" | ||||
| 
 | ||||
| export type GeocodingCategory = | ||||
|  | @ -44,13 +42,7 @@ export type GeocodeResult =  { | |||
|     payload?: object, | ||||
|     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 LayerResult = {category: "layer", osm_id: string, payload: LayerConfig} | ||||
| export type SearchResult = | ||||
|     | FilterResult | ||||
|     | { category: "theme", osm_id: string, payload: MinimalLayoutInformation } | ||||
|     | LayerResult | ||||
|     | GeocodeResult | ||||
| 
 | ||||
| export interface GeocodingOptions { | ||||
|  | @ -58,16 +50,16 @@ export interface GeocodingOptions { | |||
| } | ||||
| 
 | ||||
| 
 | ||||
| export default interface GeocodingProvider<T extends SearchResult = SearchResult> { | ||||
| export default interface GeocodingProvider { | ||||
| 
 | ||||
| 
 | ||||
|     search(query: string, options?: GeocodingOptions): Promise<T[]> | ||||
|     search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]> | ||||
| 
 | ||||
|     /** | ||||
|      * @param query | ||||
|      * @param options | ||||
|      */ | ||||
|     suggest?(query: string, options?: GeocodingOptions): Store<T[]> | ||||
|     suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]> | ||||
| } | ||||
| 
 | ||||
| export type ReverseGeocodingResult = Feature<Geometry, { | ||||
|  |  | |||
|  | @ -1,44 +1,41 @@ | |||
| import GeocodingProvider, { GeocodingOptions, LayerResult, SearchResult } from "./GeocodingProvider" | ||||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| import MoreScreen from "../../UI/BigComponents/MoreScreen" | ||||
| import { ImmutableStore, Store } from "../UIEventSource" | ||||
| import Constants from "../../Models/Constants" | ||||
| import SearchUtils from "./SearchUtils" | ||||
| import ThemeSearch from "./ThemeSearch" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| 
 | ||||
| export default class LayerSearch implements GeocodingProvider<LayerResult> { | ||||
| export default class LayerSearch { | ||||
| 
 | ||||
|     private readonly _state: SpecialVisualizationState | ||||
|     private readonly _suggestionLimit: number | ||||
|     private readonly _layerWhitelist : Set<string> | ||||
|     constructor(state: SpecialVisualizationState, suggestionLimit: number) { | ||||
|     constructor(state: SpecialVisualizationState) { | ||||
|         this._state = state | ||||
|         this._layerWhitelist = new Set(state.layout.layers.map(l => l.id).filter(id => Constants.added_by_default.indexOf(<any> id) < 0)) | ||||
|         this._suggestionLimit = suggestionLimit | ||||
|     } | ||||
| 
 | ||||
|     async search(query: string): Promise<LayerResult[]> { | ||||
|         return this.searchWrapped(query, 99) | ||||
|     } | ||||
| 
 | ||||
|     suggest(query: string, options?: GeocodingOptions): Store<LayerResult[]> { | ||||
|         return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4)) | ||||
|     static scoreLayers(query: string, layerWhitelist?: Set<string>): Record<string, number> { | ||||
|         const result: Record<string, number> = {} | ||||
|         for (const id in ThemeSearch.officialThemes.layers) { | ||||
|             if(layerWhitelist !== undefined && !layerWhitelist.has(id)){ | ||||
|                 continue | ||||
|             } | ||||
|             const keywords = ThemeSearch.officialThemes.layers[id] | ||||
|             const distance = SearchUtils.scoreKeywords(query, keywords) | ||||
|             result[id] = distance | ||||
|         } | ||||
|         return result | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     private searchWrapped(query: string, limit: number): LayerResult[] { | ||||
|         return this.searchDirect(query, limit) | ||||
|     } | ||||
| 
 | ||||
|     public searchDirect(query: string, limit: number): LayerResult[] { | ||||
|     public search(query: string, limit: number): LayerConfig[] { | ||||
|         if (query.length < 1) { | ||||
|             return [] | ||||
|         } | ||||
|         const scores = MoreScreen.scoreLayers(query, this._layerWhitelist) | ||||
|         const asList:(LayerResult & {score:number})[] = [] | ||||
|         const scores = LayerSearch.scoreLayers(query, this._layerWhitelist) | ||||
|         const asList:({layer: LayerConfig, score:number})[] = [] | ||||
|         for (const layer in scores) { | ||||
|             asList.push({ | ||||
|                 category: "layer", | ||||
|                 payload: this._state.layout.getLayer(layer), | ||||
|                 osm_id: layer, | ||||
|                 layer: this._state.layout.getLayer(layer), | ||||
|                 score: scores[layer] | ||||
|             }) | ||||
|         } | ||||
|  | @ -47,6 +44,7 @@ export default class LayerSearch implements GeocodingProvider<LayerResult> { | |||
|         return asList | ||||
|             .filter(sorted => sorted.score < 2) | ||||
|             .slice(0, limit) | ||||
|             .map(l => l.layer) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ import GeocodingProvider, { GeocodingOptions, GeocodeResult } from "./GeocodingP | |||
| import { OsmId } from "../../Models/OsmFeature" | ||||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| 
 | ||||
| export default class OpenStreetMapIdSearch implements GeocodingProvider<GeocodeResult> { | ||||
| export default class OpenStreetMapIdSearch implements GeocodingProvider { | ||||
|     private static readonly regex = /((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/ | ||||
| 
 | ||||
|     private static readonly types: Readonly<Record<string, "node" | "way" | "relation">> = { | ||||
|  |  | |||
							
								
								
									
										75
									
								
								src/Logic/Search/SearchUtils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/Logic/Search/SearchUtils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| import Locale from "../../UI/i18n/Locale" | ||||
| import { Utils } from "../../Utils" | ||||
| import ThemeSearch from "./ThemeSearch" | ||||
| 
 | ||||
| export default class SearchUtils { | ||||
| 
 | ||||
| 
 | ||||
|     /** Applies special search terms, such as 'studio', 'osmcha', ... | ||||
|      * Returns 'false' if nothing is matched. | ||||
|      * Doesn't return control flow if a match is found (navigates to another page in this case) | ||||
|      */ | ||||
|     public static applySpecialSearch(searchTerm: string, ) { | ||||
|         searchTerm = searchTerm.toLowerCase() | ||||
|         if (!searchTerm) { | ||||
|             return false | ||||
|         } | ||||
|         if (searchTerm === "personal") { | ||||
|             window.location.href = ThemeSearch.createUrlFor({ id: "personal" }, undefined) | ||||
|         } | ||||
|         if (searchTerm === "bugs" || searchTerm === "issues") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete/issues" | ||||
|         } | ||||
|         if (searchTerm === "source") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete" | ||||
|         } | ||||
|         if (searchTerm === "docs") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs" | ||||
|         } | ||||
|         if (searchTerm === "osmcha" || searchTerm === "stats") { | ||||
|             window.location.href = Utils.OsmChaLinkFor(7) | ||||
|         } | ||||
|         if (searchTerm === "studio") { | ||||
|             window.location.href = "./studio.html" | ||||
|         } | ||||
|         return false | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Searches for the smallest distance in words; will split both the query and the terms | ||||
|      * | ||||
|      * SearchUtils.scoreKeywords("drinking water", {"en": ["A layer with drinking water points"]}, "en") // => 0
 | ||||
|      * SearchUtils.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
 | ||||
|      * | ||||
|      */ | ||||
|     public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number { | ||||
|         if(!keywords){ | ||||
|             return Infinity | ||||
|         } | ||||
|         language ??= Locale.language.data | ||||
|         const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q)) | ||||
|         let terms: string[] | ||||
|         if (Array.isArray(keywords)) { | ||||
|             terms = keywords | ||||
|         } else { | ||||
|             terms = (keywords[language] ?? []).concat(keywords["*"]) | ||||
|         } | ||||
|         const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" ")) | ||||
| 
 | ||||
|         let distanceSummed = 0 | ||||
|         for (let i = 0; i < queryParts.length; i++) { | ||||
|             const q = queryParts[i] | ||||
|             let minDistance: number = 99 | ||||
|             for (const term of termsAll) { | ||||
|                 const d = Utils.levenshteinDistance(q, Utils.simplifyStringForSearch(term)) | ||||
|                 if (d < minDistance) { | ||||
|                     minDistance = d | ||||
|                 } | ||||
|             } | ||||
|             distanceSummed += minDistance | ||||
|         } | ||||
|         return distanceSummed | ||||
|     } | ||||
| } | ||||
|  | @ -1,49 +1,55 @@ | |||
| import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider" | ||||
| import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| import MoreScreen from "../../UI/BigComponents/MoreScreen" | ||||
| import { ImmutableStore, Store } from "../UIEventSource" | ||||
| import { Store } from "../UIEventSource" | ||||
| import UserRelatedState from "../State/UserRelatedState" | ||||
| import { Utils } from "../../Utils" | ||||
| import Locale from "../../UI/i18n/Locale" | ||||
| import themeOverview from "../../assets/generated/theme_overview.json" | ||||
| import LayerSearch from "./LayerSearch" | ||||
| import SearchUtils from "./SearchUtils" | ||||
| 
 | ||||
| 
 | ||||
| type ThemeSearchScore = { | ||||
|     theme: MinimalLayoutInformation, | ||||
|     lowest: number, | ||||
|     perLayer?: Record<string, number>, | ||||
|     other: number | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| export default class ThemeSearch { | ||||
| 
 | ||||
|     public static readonly officialThemes: { | ||||
|         themes: MinimalLayoutInformation[], | ||||
|         layers: Record<string, Record<string, string[]>> | ||||
|     } = themeOverview | ||||
|     public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>() | ||||
|     static { | ||||
|         for (const th of ThemeSearch.officialThemes.themes ?? []) { | ||||
|             ThemeSearch.officialThemesById.set(th.id, th) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| export default class ThemeSearch implements GeocodingProvider { | ||||
| 
 | ||||
|     private readonly _state: SpecialVisualizationState | ||||
|     private readonly _knownHiddenThemes: Store<Set<string>> | ||||
|     private readonly _suggestionLimit: number | ||||
|     private readonly _layersToIgnore: string[] | ||||
|     private readonly _otherThemes: MinimalLayoutInformation[] | ||||
| 
 | ||||
|     constructor(state: SpecialVisualizationState, suggestionLimit: number) { | ||||
|     constructor(state: SpecialVisualizationState) { | ||||
|         this._state = state | ||||
|         this._layersToIgnore = state.layout.layers.map(l => l.id) | ||||
|         this._suggestionLimit = suggestionLimit | ||||
|         this._knownHiddenThemes = UserRelatedState.initDiscoveredHiddenThemes(this._state.osmConnection).map(list => new Set(list)) | ||||
|         this._otherThemes = MoreScreen.officialThemes.themes | ||||
|         this._otherThemes = ThemeSearch.officialThemes.themes | ||||
|             .filter(th => th.id !== state.layout.id) | ||||
|     } | ||||
| 
 | ||||
|     async search(query: string): Promise<SearchResult[]> { | ||||
|         return this.searchWrapped(query, 99) | ||||
|     } | ||||
| 
 | ||||
|     suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> { | ||||
|         return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4)) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     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[] { | ||||
|     public search(query: string, limit: number): MinimalLayoutInformation[] { | ||||
|         if (query.length < 1) { | ||||
|             return [] | ||||
|         } | ||||
|         const sorted = MoreScreen.sortedByLowest(query, this._otherThemes, this._layersToIgnore) | ||||
|         const sorted = ThemeSearch.sortedByLowestScores(query, this._otherThemes, this._layersToIgnore) | ||||
|         return sorted | ||||
|             .filter(sorted => sorted.lowest < 2) | ||||
|             .map(th => th.theme) | ||||
|  | @ -51,5 +57,96 @@ export default class ThemeSearch implements GeocodingProvider { | |||
|             .slice(0, limit) | ||||
|     } | ||||
| 
 | ||||
|     public static createUrlFor( | ||||
|         layout: { id: string }, | ||||
|         state?: { layoutToUse?: { id } }, | ||||
|     ): string { | ||||
|         if (layout === undefined) { | ||||
|             return undefined | ||||
|         } | ||||
|         if (layout.id === undefined) { | ||||
|             console.error("ID is undefined for layout", layout) | ||||
|             return undefined | ||||
|         } | ||||
| 
 | ||||
|         if (layout.id === state?.layoutToUse?.id) { | ||||
|             return undefined | ||||
|         } | ||||
| 
 | ||||
|         let path = window.location.pathname | ||||
|         // Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
 | ||||
|         path = path.substr(0, path.lastIndexOf("/")) | ||||
|         // Path will now contain '/dir/dir', or empty string in case of nothing
 | ||||
|         if (path === "") { | ||||
|             path = "." | ||||
|         } | ||||
| 
 | ||||
|         let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?` | ||||
|         if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { | ||||
|             linkPrefix = `${path}/theme.html?layout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
|         if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) { | ||||
|             linkPrefix = `${path}/theme.html?userlayout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return `${linkPrefix}` | ||||
|     } | ||||
| 
 | ||||
|     private static scoreThemes(query: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): Record<string, ThemeSearchScore> { | ||||
|         if (query?.length < 1) { | ||||
|             return undefined | ||||
|         } | ||||
|         themes = Utils.NoNullInplace(themes) | ||||
|         const layerScores = LayerSearch.scoreLayers(query) | ||||
|         for (const ignoreLayer of ignoreLayers) { | ||||
|             delete layerScores[ignoreLayer] | ||||
|         } | ||||
|         const results: Record<string, ThemeSearchScore> = {} | ||||
|         for (const layoutInfo of themes) { | ||||
|             const theme = layoutInfo.id | ||||
|             if (theme === "personal") { | ||||
|                 continue | ||||
|             } | ||||
|             if (Utils.simplifyStringForSearch(theme) === query) { | ||||
|                 results[theme] = { | ||||
|                     theme: layoutInfo, | ||||
|                     lowest: -1, | ||||
|                     other: 0, | ||||
|                 } | ||||
|                 continue | ||||
|             } | ||||
|             const perLayer = Utils.asRecord( | ||||
|                 layoutInfo.layers ?? [], layer => layerScores[layer], | ||||
|             ) | ||||
|             const language = Locale.language.data | ||||
| 
 | ||||
|             const keywords = Utils.NoNullInplace([layoutInfo.shortDescription, layoutInfo.title]) | ||||
|                 .map(item => typeof item === "string" ? item : (item[language] ?? item["*"])) | ||||
| 
 | ||||
| 
 | ||||
|             const other = Math.min(SearchUtils.scoreKeywords(query, keywords), SearchUtils.scoreKeywords(query, layoutInfo.keywords)) | ||||
|             const lowest = Math.min(other, ...Object.values(perLayer)) | ||||
|             results[theme] = { | ||||
|                 theme: layoutInfo, | ||||
|                 perLayer, | ||||
|                 other, | ||||
|                 lowest, | ||||
|             } | ||||
|         } | ||||
|         return results | ||||
|     } | ||||
| 
 | ||||
|     public static sortedByLowestScores(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): ThemeSearchScore[] { | ||||
|         const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers)) | ||||
|         scored.sort((a, b) => a.lowest - b.lowest) | ||||
|         return scored | ||||
|     } | ||||
| 
 | ||||
|     public static sortedByLowest(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = [], maxDiff: number): MinimalLayoutInformation[] { | ||||
|         return this.sortedByLowestScores(search, themes, ignoreLayers) | ||||
|             .map(th => th.theme) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -1,38 +1,30 @@ | |||
| import GeocodingProvider, { | ||||
|     FilterPayload, FilterResult, | ||||
|     GeocodeResult, | ||||
|     GeocodingUtils, LayerResult, | ||||
|     type SearchResult, | ||||
| } from "../Search/GeocodingProvider" | ||||
| import GeocodingProvider, { GeocodingUtils, type SearchResult } from "../Search/GeocodingProvider" | ||||
| import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource" | ||||
| import CombinedSearcher from "../Search/CombinedSearcher" | ||||
| import FilterSearch from "../Search/FilterSearch" | ||||
| import FilterSearch, { FilterSearchResult } from "../Search/FilterSearch" | ||||
| import LocalElementSearch from "../Search/LocalElementSearch" | ||||
| import CoordinateSearch from "../Search/CoordinateSearch" | ||||
| import ThemeSearch from "../Search/ThemeSearch" | ||||
| import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch" | ||||
| import PhotonSearch from "../Search/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 "../Search/GeocodingFeatureSource" | ||||
| import ShowDataLayer from "../../UI/Map/ShowDataLayer" | ||||
| import LayerSearch from "../Search/LayerSearch" | ||||
| import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| 
 | ||||
| export default class SearchState { | ||||
| 
 | ||||
|     public readonly isSearching = new UIEventSource(false) | ||||
|     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<FilterResult[]> | ||||
|     public readonly filterSuggestions: Store<FilterSearchResult[]> | ||||
|     public readonly themeSuggestions: Store<MinimalLayoutInformation[]> | ||||
|     public readonly layerSuggestions: Store<LayerResult[]> | ||||
|     public readonly locationSearchers: ReadonlyArray<GeocodingProvider<GeocodeResult>> | ||||
|     public readonly layerSuggestions: Store<LayerConfig[]> | ||||
|     public readonly locationSearchers: ReadonlyArray<GeocodingProvider> | ||||
| 
 | ||||
|     private readonly state: ThemeViewState | ||||
|     public readonly showSearchDrawer: UIEventSource<boolean> | ||||
|  | @ -42,7 +34,7 @@ export default class SearchState { | |||
|         this.state = state | ||||
| 
 | ||||
|         this.locationSearchers = [ | ||||
|             // new LocalElementSearch(state, 5),
 | ||||
|             new LocalElementSearch(state, 5), | ||||
|             new CoordinateSearch(), | ||||
|             new OpenStreetMapIdSearch(state), | ||||
|             new PhotonSearch(), // new NominatimGeocoding(),
 | ||||
|  | @ -67,18 +59,18 @@ export default class SearchState { | |||
|             Stores.concat(suggestions).map(suggestions => CombinedSearcher.merge(suggestions)), | ||||
|         ) | ||||
| 
 | ||||
|         const themeSearch = new ThemeSearch(state, 3) | ||||
|         this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.searchDirect(query, 3)) | ||||
|         const themeSearch = new ThemeSearch(state) | ||||
|         this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.search(query, 3)) | ||||
| 
 | ||||
|         const layerSearch = new LayerSearch(state, 5) | ||||
|         this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.searchDirect(query, 5)) | ||||
|         const layerSearch = new LayerSearch(state) | ||||
|         this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.search(query, 5)) | ||||
| 
 | ||||
|         const filterSearch = new FilterSearch(state) | ||||
|         this.filterSuggestions = this.searchTerm.stabilized(50) | ||||
|             .mapD(query => filterSearch.searchDirectly(query)) | ||||
|             .mapD(query => filterSearch.search(query)) | ||||
|             .mapD(filterResult => { | ||||
|                 const active = state.layerState.activeFilters.data | ||||
|                 return filterResult.filter(({ payload: { filter, index, layer } }) => { | ||||
|                 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) | ||||
| 
 | ||||
|  | @ -108,22 +100,20 @@ export default class SearchState { | |||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public async apply(result: FilterResult | LayerResult) { | ||||
|         if (result.category === "filter") { | ||||
|             return this.applyFilter(result.payload) | ||||
|         } | ||||
|         if (result.category === "layer") { | ||||
|     public async apply(result: FilterSearchResult | LayerConfig) { | ||||
|         if (result instanceof LayerConfig) { | ||||
|             return this.applyLayer(result) | ||||
|         } | ||||
|         return this.applyFilter(result) | ||||
|     } | ||||
| 
 | ||||
|     private async applyLayer(layer: LayerResult) { | ||||
|     private async applyLayer(layer: LayerConfig) { | ||||
|         for (const [name, otherLayer] of this.state.layerState.filteredLayers) { | ||||
|             otherLayer.isDisplayed.setData(name === layer.osm_id) | ||||
|             otherLayer.isDisplayed.setData(name === layer.id) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private async applyFilter(payload: FilterPayload) { | ||||
|     private async applyFilter(payload: FilterSearchResult) { | ||||
|         const state = this.state | ||||
| 
 | ||||
|         const { layer, filter, index } = payload | ||||
|  |  | |||
|  | @ -7,7 +7,6 @@ | |||
|   import Translations from "./i18n/Translations" | ||||
|   import Logo from "../assets/svg/Logo.svelte" | ||||
|   import Tr from "./Base/Tr.svelte" | ||||
|   import MoreScreen from "./BigComponents/MoreScreen" | ||||
|   import LoginToggle from "./Base/LoginToggle.svelte" | ||||
|   import Pencil from "../assets/svg/Pencil.svelte" | ||||
|   import Constants from "../Models/Constants" | ||||
|  | @ -24,6 +23,8 @@ | |||
|   import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" | ||||
|   import Searchbar from "./Base/Searchbar.svelte" | ||||
|   import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight" | ||||
|   import ThemeSearch from "../Logic/Search/ThemeSearch" | ||||
|   import SearchUtils from "../Logic/Search/SearchUtils" | ||||
| 
 | ||||
|   const featureSwitches = new OsmConnectionFeatureSwitches() | ||||
|   const osmConnection = new OsmConnection({ | ||||
|  | @ -43,8 +44,8 @@ | |||
|   let search: UIEventSource<string | undefined> = new UIEventSource<string>("") | ||||
|   let searchStable = search.stabilized(100) | ||||
| 
 | ||||
|   const officialThemes: MinimalLayoutInformation[] = MoreScreen.officialThemes.themes.filter(th => th.hideFromOverview === false) | ||||
|   const hiddenThemes: MinimalLayoutInformation[] = MoreScreen.officialThemes.themes.filter(th => th.hideFromOverview === true) | ||||
|   const officialThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === false) | ||||
|   const hiddenThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === true) | ||||
|   let visitedHiddenThemes: Store<MinimalLayoutInformation[]> = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection) | ||||
|     .map((knownIds) => hiddenThemes.filter((theme) => | ||||
|       knownIds.indexOf(theme.id) >= 0 || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet" | ||||
|  | @ -60,7 +61,7 @@ | |||
|       if (!search) { | ||||
|         return themes | ||||
|       } | ||||
|       const scores = MoreScreen.sortedByLowest(search, themes) | ||||
|       const scores = ThemeSearch.sortedByLowestScores(search, themes) | ||||
|       const strict = scores.filter(sc => sc.lowest < 2) | ||||
|       if (strict.length > 0) { | ||||
|         return strict.map(sc => sc.theme) | ||||
|  | @ -84,7 +85,7 @@ | |||
|   }) | ||||
| 
 | ||||
|   function applySearch() { | ||||
|     const didRedirect = MoreScreen.applySearch(search.data) | ||||
|     const didRedirect = SearchUtils.applySpecialSearch(search.data) | ||||
|     console.log("Did redirect?", didRedirect) | ||||
|     if (didRedirect) { | ||||
|       // Just for style and readability; won't _actually_ reach this | ||||
|  | @ -96,7 +97,7 @@ | |||
|       return | ||||
|     } | ||||
| 
 | ||||
|     window.location.href = MoreScreen.createUrlFor(candidate) | ||||
|     window.location.href = ThemeSearch.createUrlFor(candidate, undefined) | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,194 +0,0 @@ | |||
| import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { Store } from "../../Logic/UIEventSource" | ||||
| import { Utils } from "../../Utils" | ||||
| import themeOverview from "../../assets/generated/theme_overview.json" | ||||
| import Locale from "../i18n/Locale" | ||||
| import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
| 
 | ||||
| export  type ThemeSearchScore = { | ||||
|     theme: MinimalLayoutInformation, | ||||
|     lowest: number, | ||||
|     perLayer?: Record<string, number>, | ||||
|     other: number | ||||
| } | ||||
| export default class MoreScreen { | ||||
|     public static readonly officialThemes: { | ||||
|         themes: MinimalLayoutInformation[], | ||||
|         layers: Record<string, Record<string, string[]>> | ||||
|     } = themeOverview | ||||
|     public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>() | ||||
|     static { | ||||
|         for (const th of MoreScreen.officialThemes.themes ?? []) { | ||||
|             MoreScreen.officialThemesById.set(th.id, th) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** Applies special search terms, such as 'studio', 'osmcha', ... | ||||
|      * Returns 'false' if nothing is matched. | ||||
|      * Doesn't return control flow if a match is found (navigates to another page in this case) | ||||
|      */ | ||||
|     public static applySearch(searchTerm: string, ) { | ||||
|         searchTerm = searchTerm.toLowerCase() | ||||
|         if (!searchTerm) { | ||||
|             return false | ||||
|         } | ||||
|         if (searchTerm === "personal") { | ||||
|             window.location.href = MoreScreen.createUrlFor({ id: "personal" }) | ||||
|         } | ||||
|         if (searchTerm === "bugs" || searchTerm === "issues") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete/issues" | ||||
|         } | ||||
|         if (searchTerm === "source") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete" | ||||
|         } | ||||
|         if (searchTerm === "docs") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs" | ||||
|         } | ||||
|         if (searchTerm === "osmcha" || searchTerm === "stats") { | ||||
|             window.location.href = Utils.OsmChaLinkFor(7) | ||||
|         } | ||||
|         if (searchTerm === "studio") { | ||||
|             window.location.href = "./studio.html" | ||||
|         } | ||||
|         return false | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Searches for the smallest distance in words; will split both the query and the terms | ||||
|      * | ||||
|      * MoreScreen.scoreKeywords("drinking water", {"en": ["A layer with drinking water points"]}, "en") // => 0
 | ||||
|      * MoreScreen.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
 | ||||
|      * | ||||
|      */ | ||||
|     public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number { | ||||
|         if(!keywords){ | ||||
|             return Infinity | ||||
|         } | ||||
|         language ??= Locale.language.data | ||||
|         const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q)) | ||||
|         let terms: string[] | ||||
|         if (Array.isArray(keywords)) { | ||||
|             terms = keywords | ||||
|         } else { | ||||
|             terms = (keywords[language] ?? []).concat(keywords["*"]) | ||||
|         } | ||||
|         const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" ")) | ||||
| 
 | ||||
|         let distanceSummed = 0 | ||||
|         for (let i = 0; i < queryParts.length; i++) { | ||||
|             const q = queryParts[i] | ||||
|             let minDistance: number = 99 | ||||
|             for (const term of termsAll) { | ||||
|                 const d = Utils.levenshteinDistance(q, Utils.simplifyStringForSearch(term)) | ||||
|                 if (d < minDistance) { | ||||
|                     minDistance = d | ||||
|                 } | ||||
|             } | ||||
|             distanceSummed += minDistance | ||||
|         } | ||||
|         return distanceSummed | ||||
|     } | ||||
| 
 | ||||
|     public static scoreLayers(query: string, layerWhitelist?: Set<string>): Record<string, number> { | ||||
|         const result: Record<string, number> = {} | ||||
|         for (const id in this.officialThemes.layers) { | ||||
|             if(layerWhitelist !== undefined && !layerWhitelist.has(id)){ | ||||
|                 continue | ||||
|             } | ||||
|             const keywords = this.officialThemes.layers[id] | ||||
|             const distance = this.scoreKeywords(query, keywords) | ||||
|             result[id] = distance | ||||
|         } | ||||
|         return result | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     public static scoreThemes(query: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): Record<string, ThemeSearchScore> { | ||||
|         if (query?.length < 1) { | ||||
|             return undefined | ||||
|         } | ||||
|         themes = Utils.NoNullInplace(themes) | ||||
|         const layerScores = this.scoreLayers(query) | ||||
|         for (const ignoreLayer of ignoreLayers) { | ||||
|             delete layerScores[ignoreLayer] | ||||
|         } | ||||
|         const results: Record<string, ThemeSearchScore> = {} | ||||
|         for (const layoutInfo of themes) { | ||||
|             const theme = layoutInfo.id | ||||
|             if (theme === "personal") { | ||||
|                 continue | ||||
|             } | ||||
|             if (Utils.simplifyStringForSearch(theme) === query) { | ||||
|                 results[theme] = { | ||||
|                     theme: layoutInfo, | ||||
|                     lowest: -1, | ||||
|                     other: 0 | ||||
|                 } | ||||
|                 continue | ||||
|             } | ||||
|             const perLayer = Utils.asRecord( | ||||
|                 layoutInfo.layers ?? [], layer => layerScores[layer] | ||||
|             ) | ||||
|             const language = Locale.language.data | ||||
| 
 | ||||
|             const keywords =Utils.NoNullInplace( [layoutInfo.shortDescription, layoutInfo.title]) | ||||
|                 .map(item => typeof item === "string" ? item : (item[language] ?? item["*"])) | ||||
| 
 | ||||
| 
 | ||||
|             const other = Math.min(this.scoreKeywords(query, keywords), this.scoreKeywords(query, layoutInfo.keywords)) | ||||
|             const lowest = Math.min(other, ...Object.values(perLayer)) | ||||
|             results[theme] = { | ||||
|                 theme:layoutInfo, | ||||
|                 perLayer, | ||||
|                 other, | ||||
|                 lowest | ||||
|             } | ||||
|         } | ||||
|         return results | ||||
|     } | ||||
| 
 | ||||
|     public static sortedByLowest(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []){ | ||||
|         const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers )) | ||||
|         scored.sort((a,b) => a.lowest - b.lowest) | ||||
|         return scored | ||||
|     } | ||||
| 
 | ||||
|     public static createUrlFor( | ||||
|         layout: { id: string }, | ||||
|         state?: { layoutToUse?: { id } } | ||||
|     ): string { | ||||
|         if (layout === undefined) { | ||||
|             return undefined | ||||
|         } | ||||
|         if (layout.id === undefined) { | ||||
|             console.error("ID is undefined for layout", layout) | ||||
|             return undefined | ||||
|         } | ||||
| 
 | ||||
|         if (layout.id === state?.layoutToUse?.id) { | ||||
|             return undefined | ||||
|         } | ||||
| 
 | ||||
|         let path = window.location.pathname | ||||
|         // Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
 | ||||
|         path = path.substr(0, path.lastIndexOf("/")) | ||||
|         // Path will now contain '/dir/dir', or empty string in case of nothing
 | ||||
|         if (path === "") { | ||||
|             path = "." | ||||
|         } | ||||
| 
 | ||||
|         let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?` | ||||
|         if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { | ||||
|             linkPrefix = `${path}/theme.html?layout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
|         if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) { | ||||
|             linkPrefix = `${path}/theme.html?userlayout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return `${linkPrefix}` | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -48,7 +48,7 @@ export class BingRasterLayerProperties implements Partial<RasterLayerProperties> | |||
|         // "imageHeight": 256, "imageWidth": 256,
 | ||||
|         // "imageUrlSubdomains": ["t0","t1","t2","t3"],
 | ||||
|         // "zoomMax": 21,
 | ||||
|         const imageryResource = metadata.resourceSets[0].resources[0] | ||||
|         const imageryResource = metadata["resourceSets"][0].resources[0] | ||||
|         const template = new URL(imageryResource.imageUrl) | ||||
|         // Add tile image strictness param (n=)
 | ||||
|         // • n=f -> (Fail) returns a 404
 | ||||
|  |  | |||
|  | @ -1,13 +1,16 @@ | |||
| <script lang="ts"> | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import type { FilterPayload, FilterResult, LayerResult } from "../../Logic/Search/GeocodingProvider" | ||||
|   import { createEventDispatcher } from "svelte" | ||||
|   import Icon from "../Map/Icon.svelte" | ||||
|   import Marker from "../Map/Marker.svelte" | ||||
|   import ToSvelte from "../Base/ToSvelte.svelte" | ||||
|   import type { FilterSearchResult } from "../../Logic/Search/FilterSearch" | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
| 
 | ||||
|   export let entry: FilterResult | LayerResult | ||||
|   export let entry: FilterSearchResult | LayerConfig | ||||
|   let isLayer = entry instanceof LayerConfig | ||||
|   let asLayer = <LayerConfig> entry | ||||
|   let asFilter = <FilterSearchResult> entry | ||||
|   export let state: SpecialVisualizationState | ||||
|   let dispatch = createEventDispatcher<{ select }>() | ||||
| 
 | ||||
|  | @ -20,16 +23,16 @@ | |||
| <button on:click={() => apply()}> | ||||
|   <div class="flex flex-col items-start"> | ||||
|     <div class="flex items-center gap-x-1"> | ||||
|       {#if entry.category === "layer"} | ||||
|       {#if isLayer} | ||||
|         <div class="w-8 h-8 p-1"> | ||||
|           <ToSvelte construct={entry.payload.defaultIcon()} /> | ||||
|           <ToSvelte construct={asLayer.defaultIcon()} /> | ||||
|         </div> | ||||
|         <b> | ||||
|           <Tr t={entry.payload.name} /> | ||||
|           <Tr t={asLayer.name} /> | ||||
|         </b> | ||||
|       {:else} | ||||
|         <Icon icon={entry.payload.option.icon ?? entry.payload. option.emoji} clss="w-4 h-4" emojiHeight="14px" /> | ||||
|         <Tr cls="whitespace-nowrap" t={entry.payload.option.question} /> | ||||
|         <Icon icon={asFilter.option.icon ?? asFilter.option.emoji} clss="w-4 h-4" emojiHeight="14px" /> | ||||
|         <Tr cls="whitespace-nowrap" t={asFilter.option.question} /> | ||||
|       {/if} | ||||
|     </div> | ||||
|   </div> | ||||
|  |  | |||
							
								
								
									
										51
									
								
								src/UI/Search/FilterResults.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/UI/Search/FilterResults.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| <script lang="ts"> | ||||
|   import { default as FilterResultSvelte } from "./FilterResult.svelte" | ||||
|   import SidebarUnit from "../Base/SidebarUnit.svelte" | ||||
| 
 | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import LayerConfig from "../../Models/ThemeConfig/LayerConfig" | ||||
|   import type { FilterSearchResult } from "../../Logic/Search/FilterSearch" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
| 
 | ||||
|   export let state: SpecialVisualizationState | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
|   let activeLayers = state.layerState.activeLayers | ||||
|   let filterResults = state.searchState.filterSuggestions | ||||
| 
 | ||||
| 
 | ||||
|   let layerResults = state.searchState.layerSuggestions.map(layers => { | ||||
|     const nowActive = activeLayers.data.filter(al => al.layerDef.isNormal()) | ||||
|     if (nowActive.length === 1) { | ||||
|       const shownInActiveFiltersView = nowActive[0] | ||||
|       layers = layers.filter(l => l.id !== shownInActiveFiltersView.layerDef.id) | ||||
|     } | ||||
|     return layers | ||||
|   }, [activeLayers]) | ||||
|   let filterResultsClipped = filterResults.mapD(filters => { | ||||
|     let layers = layerResults.data | ||||
|     const ls: (FilterSearchResult | LayerConfig)[] = [].concat(layers, filters) | ||||
|     if (ls.length <= 6) { | ||||
|       return ls | ||||
|     } | ||||
|     return ls.slice(0, 4) | ||||
|   }, [layerResults, activeLayers]) | ||||
| </script> | ||||
| 
 | ||||
| {#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} | ||||
|   <SidebarUnit> | ||||
| 
 | ||||
|     <h3><Tr t={Translations.t.general.search.pickFilter} /></h3> | ||||
| 
 | ||||
|     <div class="flex flex-wrap"> | ||||
|       {#each $filterResultsClipped 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} | ||||
|   </SidebarUnit> | ||||
| {/if} | ||||
							
								
								
									
										75
									
								
								src/UI/Search/GeocodeResults.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/UI/Search/GeocodeResults.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | |||
| <script lang="ts"> | ||||
|   /** | ||||
|    * Shows all the location-results | ||||
|    */ | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import { Store } from "../../Logic/UIEventSource" | ||||
|   import SidebarUnit from "../Base/SidebarUnit.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import Loading from "../Base/Loading.svelte" | ||||
|   import { default as GeocodeResultSvelte } from "./GeocodeResult.svelte" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import DotMenu from "../Base/DotMenu.svelte" | ||||
|   import { CogIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import { TrashIcon } from "@babeard/svelte-heroicons/mini" | ||||
|   import type { GeocodeResult } from "../../Logic/Search/GeocodingProvider" | ||||
| 
 | ||||
|   export let state: SpecialVisualizationState | ||||
| 
 | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
|   let results = state.searchState.suggestions | ||||
|   let isSearching = state.searchState.suggestionsSearchRunning | ||||
|   let recentlySeen: Store<GeocodeResult[]> = state.userRelatedState.recentlyVisitedSearch.value | ||||
| const t = Translations.t.general.search | ||||
| </script> | ||||
| 
 | ||||
| {#if $searchTerm.length > 0} | ||||
|   <SidebarUnit> | ||||
| 
 | ||||
|     <h3><Tr t={t.locations}/></h3> | ||||
| 
 | ||||
| 
 | ||||
|     {#if $results?.length > 0} | ||||
|       {#each $results as entry (entry)} | ||||
|         <GeocodeResultSvelte on:select {entry} {state} /> | ||||
|       {/each} | ||||
|     {/if} | ||||
| 
 | ||||
|     {#if $isSearching} | ||||
|       <div class="flex justify-center m-4 my-8"> | ||||
|         <Loading> | ||||
|           <Tr t={t.searching} /> | ||||
|         </Loading> | ||||
|       </div> | ||||
|     {/if} | ||||
| 
 | ||||
|     {#if !$isSearching && $results.length === 0} | ||||
|       <b class="flex justify-center p-4"> | ||||
|         <Tr t={t.nothingFor.Subs({term: "<i>"+$searchTerm+"</i>"})} /> | ||||
|       </b> | ||||
|     {/if} | ||||
|   </SidebarUnit> | ||||
| 
 | ||||
| {:else if $recentlySeen?.length > 0} | ||||
|   <SidebarUnit> | ||||
|     <div class="flex justify-between"> | ||||
| 
 | ||||
|       <h3 class="m-2"> | ||||
|         <Tr t={t.recents} /> | ||||
|       </h3> | ||||
|       <DotMenu> | ||||
|         <button on:click={() => {state.userRelatedState.recentlyVisitedSearch.clear()}}> | ||||
|           <TrashIcon /> | ||||
|           <Tr t={t.deleteSearchHistory}/> | ||||
|         </button> | ||||
|         <button on:click={() => state.guistate.openUsersettings("sync-visited-locations")}> | ||||
|           <CogIcon /> | ||||
|           <Tr t={t.editSearchSyncSettings}/> | ||||
|         </button> | ||||
|       </DotMenu> | ||||
|     </div> | ||||
|     {#each $recentlySeen as entry (entry)} | ||||
|       <GeocodeResultSvelte {entry} {state} on:select /> | ||||
|     {/each} | ||||
|   </SidebarUnit> | ||||
| {/if} | ||||
|  | @ -1,19 +0,0 @@ | |||
| <script lang="ts"> | ||||
|   import type { SearchResult } from "../../Logic/Search/GeocodingProvider" | ||||
| 
 | ||||
|   import ThemeResult from "../Search/ThemeResult.svelte" | ||||
|   import FilterResult from "./FilterResult.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import GeocodeResult from "./GeocodeResult.svelte" | ||||
| 
 | ||||
|   export let entry: SearchResult | ||||
|   export let state: SpecialVisualizationState | ||||
| </script> | ||||
| 
 | ||||
| {#if entry.category === "theme"} | ||||
|   <ThemeResult entry={entry.payload} on:select  /> | ||||
| {:else if entry.category === "filter"} | ||||
|   <FilterResult entry={entry.payload} {state} on:select /> | ||||
| {:else} | ||||
|   <GeocodeResult {entry} {state} on:select /> | ||||
| {/if} | ||||
|  | @ -1,181 +1,35 @@ | |||
| <script lang="ts"> | ||||
|   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 { FilterResult, GeocodeResult, LayerResult } from "../../Logic/Search/GeocodingProvider" | ||||
| 
 | ||||
|   import ActiveFilters from "./ActiveFilters.svelte" | ||||
|   import Constants from "../../Models/Constants" | ||||
|   import type { ActiveFilter } from "../../Logic/State/LayerState" | ||||
|   import ThemeViewState from "../../Models/ThemeViewState" | ||||
|   import {default as FilterResultSvelte} from "./FilterResult.svelte" | ||||
|   import ThemeResult from "./ThemeResult.svelte" | ||||
|   import SidebarUnit from "../Base/SidebarUnit.svelte" | ||||
|   import { TrashIcon } from "@babeard/svelte-heroicons/mini" | ||||
|   import DotMenu from "../Base/DotMenu.svelte" | ||||
|   import { CogIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|    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" | ||||
| 
 | ||||
|   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 recentlySeen: Store<GeocodeResult[]> = state.userRelatedState.recentlyVisitedSearch.value | ||||
|   let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map(themes => themes.filter(th => th !== state.layout.id).slice(0, 6)) | ||||
|   let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
|   let results = state.searchState.suggestions | ||||
|   let isSearching = state.searchState.suggestionsSearchRunning | ||||
|   let filterResults = state.searchState.filterSuggestions | ||||
|   let activeLayers = state.layerState.activeLayers | ||||
|   let layerResults = state.searchState.layerSuggestions.map(layers => { | ||||
|     const nowActive = activeLayers.data.filter(al => al.layerDef.isNormal()) | ||||
|     if(nowActive.length === 1){ | ||||
|       const shownInActiveFiltersView = nowActive[0] | ||||
|       layers = layers.filter(l => l.payload.id !== shownInActiveFiltersView.layerDef.id) | ||||
|     } | ||||
|     return layers | ||||
|   }, [activeLayers]) | ||||
| 
 | ||||
| 
 | ||||
|   let filterResultsClipped = filterResults.mapD(filters => { | ||||
|     let layers = layerResults.data | ||||
|     const ls : (FilterResult | LayerResult)[] = [].concat(layers, filters) | ||||
|     if (ls.length <= 8) { | ||||
|       return ls | ||||
|     } | ||||
|     return ls.slice(0, 6) | ||||
|   }, [layerResults, activeLayers]) | ||||
|   let themeResults = state.searchState.themeSuggestions | ||||
| 
 | ||||
| </script> | ||||
| <div class="p-4 low-interaction flex gap-y-2 flex-col"> | ||||
| 
 | ||||
|   <ActiveFilters {state} activeFilters={$activeFilters} /> | ||||
| 
 | ||||
|   {#if $searchTerm.length === 0 && $filterResults.length === 0 && $activeFilters.length === 0 && $recentThemes.length === 0} | ||||
|   {#if $searchTerm.length === 0 && $activeFilters.length === 0 } | ||||
|     <div class="p-8 items-center text-center"> | ||||
|       <b>Use the search bar above to search for locations, filters and other maps</b> | ||||
|       <b><Tr t={Translations.t.general.search.instructions}/></b> | ||||
|     </div> | ||||
|   {/if} | ||||
| 
 | ||||
|   {#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} | ||||
|     <SidebarUnit> | ||||
|   <FilterResults {state}/> | ||||
| 
 | ||||
|       <h3>Pick a filter below</h3> | ||||
|   <GeocodeResults {state}/> | ||||
| 
 | ||||
|       <div class="flex flex-wrap"> | ||||
|         {#each $filterResultsClipped 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} | ||||
|     </SidebarUnit> | ||||
|   {#if $allowOtherThemes} | ||||
|     <ThemeResults {state} /> | ||||
|   {/if} | ||||
| 
 | ||||
|   <!-- Actual search results (or ""loading"", or ""no results"")--> | ||||
|   {#if $searchTerm.length > 0} | ||||
|     <SidebarUnit> | ||||
| 
 | ||||
|       <h3>Locations</h3> | ||||
| 
 | ||||
|       {#if $isSearching} | ||||
|         <div class="flex justify-center m-4 my-8"> | ||||
|           <Loading /> | ||||
|         </div> | ||||
|       {/if} | ||||
| 
 | ||||
|       {#if $results?.length > 0} | ||||
|         {#each $results as entry (entry)} | ||||
|           <SearchResultSvelte on:select {entry} {state} /> | ||||
|         {/each} | ||||
| 
 | ||||
|       {:else if !$isSearching} | ||||
|         <b class="flex justify-center p-4"> | ||||
|           <Tr t={Translations.t.general.search.nothingFor.Subs({term: "<i>"+$searchTerm+"</i>"})} /> | ||||
|         </b> | ||||
|       {/if} | ||||
|     </SidebarUnit> | ||||
| 
 | ||||
|   {/if} | ||||
| 
 | ||||
| 
 | ||||
|   <!-- Other maps which match the search term--> | ||||
|   {#if $themeResults.length > 0} | ||||
|     <SidebarUnit> | ||||
|       <h3> | ||||
|         Other maps | ||||
|       </h3> | ||||
|       {#each $themeResults as entry (entry.id)} | ||||
|         <ThemeResult {entry} /> | ||||
|       {/each} | ||||
|     </SidebarUnit> | ||||
|   {/if} | ||||
| 
 | ||||
|   {#if $searchTerm.length === 0 && $recentlySeen?.length === 0 && $recentThemes.length === 0} | ||||
|     <SidebarUnit> | ||||
|       <h3> | ||||
| 
 | ||||
|         Suggestions | ||||
|       </h3> | ||||
| 
 | ||||
|     </SidebarUnit> | ||||
|   {/if} | ||||
|   {#if $searchTerm.length === 0 && $recentlySeen?.length > 0} | ||||
|     <SidebarUnit> | ||||
|       <div class="flex justify-between"> | ||||
| 
 | ||||
|         <h3 class="m-2"> | ||||
|           <Tr t={Translations.t.general.search.recents} /> | ||||
|         </h3> | ||||
|         <DotMenu> | ||||
|           <button on:click={() => {state.userRelatedState.recentlyVisitedSearch.clear()}}> | ||||
|             <TrashIcon /> | ||||
|             Delete search history | ||||
|           </button> | ||||
|           <button on:click={() => state.guistate.openUsersettings("sync-visited-locations")}> | ||||
|             <CogIcon /> | ||||
|             Edit sync settings | ||||
|           </button> | ||||
|         </DotMenu> | ||||
|       </div> | ||||
|       {#each $recentlySeen as entry (entry)} | ||||
|         <SearchResultSvelte {entry} {state} on:select /> | ||||
|       {/each} | ||||
|     </SidebarUnit> | ||||
|   {/if} | ||||
| 
 | ||||
|   {#if $searchTerm.length === 0 && $recentThemes?.length > 0 && $allowOtherThemes} | ||||
|     <SidebarUnit> | ||||
|       <div class="flex w-full justify-between"> | ||||
| 
 | ||||
|         <h3 class="m-2"> | ||||
|           <Tr t={Translations.t.general.search.recentThemes} /> | ||||
|         </h3> | ||||
|         <DotMenu> | ||||
|           <button on:click={() => {state.userRelatedState.recentlyVisitedThemes.clear()}}> | ||||
|             <TrashIcon /> | ||||
|             Delete earlier visited themes | ||||
|           </button> | ||||
|           <button on:click={() => state.guistate.openUsersettings("sync-visited-themes")}> | ||||
|             <CogIcon /> | ||||
|             Edit sync settings | ||||
|           </button> | ||||
|         </DotMenu> | ||||
|       </div> | ||||
|       {#each $recentThemes as themeId (themeId)} | ||||
|         <SearchResultSvelte | ||||
|           entry={{payload: MoreScreen.officialThemesById.get(themeId), osm_id: themeId, category: "theme"}} | ||||
|           {state} | ||||
|           on:select /> | ||||
|       {/each} | ||||
| 
 | ||||
|     </SidebarUnit> | ||||
|   {/if} | ||||
| 
 | ||||
| 
 | ||||
| </div> | ||||
|  |  | |||
|  | @ -1,15 +1,15 @@ | |||
| <script lang="ts"> | ||||
|   import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
|   import MoreScreen from "../BigComponents/MoreScreen" | ||||
|   import { Translation } from "../i18n/Translation" | ||||
|   import Icon from "../Map/Icon.svelte" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import ThemeSearch from "../../Logic/Search/ThemeSearch" | ||||
| 
 | ||||
|   export let entry: MinimalLayoutInformation | ||||
|   let otherTheme = entry | ||||
| </script> | ||||
| {#if entry} | ||||
|   <a href={MoreScreen.createUrlFor(otherTheme)} | ||||
|   <a href={ThemeSearch.createUrlFor(otherTheme)} | ||||
|      class="flex items-center p-2 w-full gap-y-2 rounded-xl searchresult"> | ||||
| 
 | ||||
|     <Icon icon={otherTheme.icon} clss="w-6 h-6 m-1" /> | ||||
|  | @ -17,7 +17,6 @@ | |||
|       <b> | ||||
|         <Tr t={new Translation(otherTheme.title)} /> | ||||
|       </b> | ||||
|       <!--<Tr t={new Translation(otherTheme.shortDescription)} /> --> | ||||
|     </div> | ||||
|   </a> | ||||
| {/if} | ||||
|  |  | |||
							
								
								
									
										57
									
								
								src/UI/Search/ThemeResults.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								src/UI/Search/ThemeResults.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,57 @@ | |||
| <script lang="ts"> | ||||
|   /** | ||||
|    * Either shows the 'recent' themes (if search string is empty) or shows matching theme results | ||||
|    */ | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import ThemeSearch from "../../Logic/Search/ThemeSearch" | ||||
|   import SidebarUnit from "../Base/SidebarUnit.svelte" | ||||
|   import ThemeResult from "./ThemeResult.svelte" | ||||
|   import type { SpecialVisualizationState } from "../SpecialVisualization" | ||||
|   import DotMenu from "../Base/DotMenu.svelte" | ||||
|   import { TrashIcon } from "@babeard/svelte-heroicons/mini" | ||||
|   import { CogIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
| 
 | ||||
|   export let state: SpecialVisualizationState | ||||
|   let searchTerm = state.searchState.searchTerm | ||||
|   let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map(themes => themes.filter(th => th !== state.layout.id).slice(0, 6)) | ||||
|   let themeResults = state.searchState.themeSuggestions | ||||
| 
 | ||||
|   const t =Translations.t.general.search | ||||
| </script> | ||||
| 
 | ||||
| 
 | ||||
| {#if $themeResults.length > 0} | ||||
|   <SidebarUnit> | ||||
|     <h3> | ||||
|       <Tr t={t.otherMaps}/> | ||||
|     </h3> | ||||
|     {#each $themeResults as entry (entry.id)} | ||||
|       <ThemeResult {entry} /> | ||||
|     {/each} | ||||
|   </SidebarUnit> | ||||
| {/if} | ||||
| 
 | ||||
| {#if $searchTerm.length === 0 && $recentThemes?.length > 0} | ||||
|   <SidebarUnit> | ||||
|     <div class="flex w-full justify-between"> | ||||
| 
 | ||||
|       <h3 class="m-2"> | ||||
|         <Tr t={t.recentThemes} /> | ||||
|       </h3> | ||||
|       <DotMenu> | ||||
|         <button on:click={() => {state.userRelatedState.recentlyVisitedThemes.clear()}}> | ||||
|           <TrashIcon /> | ||||
|           <Tr t={t.deleteThemeHistory}/> | ||||
|         </button> | ||||
|         <button on:click={() => state.guistate.openUsersettings("sync-visited-themes")}> | ||||
|           <CogIcon /> | ||||
|           <Tr t={t.editThemeSync}/> | ||||
|         </button> | ||||
|       </DotMenu> | ||||
|     </div> | ||||
|     {#each $recentThemes as themeId (themeId)} | ||||
|       <ThemeResult entry={ ThemeSearch.officialThemesById.get(themeId)} /> | ||||
|     {/each} | ||||
|   </SidebarUnit> | ||||
| {/if} | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue