forked from MapComplete/MapComplete
		
	Search: refactoring searching for themes, refactor allThemesGui, incidentally fix #1679
This commit is contained in:
		
							parent
							
								
									9b8c300e77
								
							
						
					
					
						commit
						d90b6d82d0
					
				
					 18 changed files with 421 additions and 334 deletions
				
			
		|  | @ -4,14 +4,14 @@ | |||
|     "en": "Changes made with MapComplete", | ||||
|     "de": "Änderungen mit MapComplete vorgenommen" | ||||
|   }, | ||||
|   "description": { | ||||
|     "en": "This maps shows all the changes made with MapComplete", | ||||
|     "de": "Diese Karte zeigt alle mit MapComplete vorgenommenen Änderungen" | ||||
|   }, | ||||
|   "shortDescription": { | ||||
|     "en": "Shows changes made by MapComplete", | ||||
|     "de": "Änderungen von MapComplete anzeigen" | ||||
|   }, | ||||
|   "description": { | ||||
|     "en": "This maps shows all the changes made with MapComplete", | ||||
|     "de": "Diese Karte zeigt alle mit MapComplete vorgenommenen Änderungen" | ||||
|   }, | ||||
|   "icon": "./assets/svg/logo.svg", | ||||
|   "hideFromOverview": true, | ||||
|   "startLat": 0, | ||||
|  | @ -730,4 +730,4 @@ | |||
|       } | ||||
|     } | ||||
|   ] | ||||
| } | ||||
| } | ||||
|  | @ -1413,11 +1413,6 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   margin-right: auto; | ||||
| } | ||||
| 
 | ||||
| .mx-3 { | ||||
|   margin-left: 0.75rem; | ||||
|   margin-right: 0.75rem; | ||||
| } | ||||
| 
 | ||||
| .my-4 { | ||||
|   margin-top: 1rem; | ||||
|   margin-bottom: 1rem; | ||||
|  | @ -1511,8 +1506,8 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   margin-left: 0.5rem; | ||||
| } | ||||
| 
 | ||||
| .ml-4 { | ||||
|   margin-left: 1rem; | ||||
| .mr-3 { | ||||
|   margin-right: 0.75rem; | ||||
| } | ||||
| 
 | ||||
| .mb-2 { | ||||
|  | @ -1531,6 +1526,10 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   margin-bottom: 0.25rem; | ||||
| } | ||||
| 
 | ||||
| .ml-4 { | ||||
|   margin-left: 1rem; | ||||
| } | ||||
| 
 | ||||
| .mt-8 { | ||||
|   margin-top: 2rem; | ||||
| } | ||||
|  | @ -3913,6 +3912,11 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   padding-bottom: 0.25rem; | ||||
| } | ||||
| 
 | ||||
| .px-0 { | ||||
|   padding-left: 0px; | ||||
|   padding-right: 0px; | ||||
| } | ||||
| 
 | ||||
| .px-3 { | ||||
|   padding-left: 0.75rem; | ||||
|   padding-right: 0.75rem; | ||||
|  | @ -3978,11 +3982,6 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   padding-bottom: 0.875rem; | ||||
| } | ||||
| 
 | ||||
| .px-0 { | ||||
|   padding-left: 0px; | ||||
|   padding-right: 0px; | ||||
| } | ||||
| 
 | ||||
| .\!px-0 { | ||||
|   padding-left: 0px !important; | ||||
|   padding-right: 0px !important; | ||||
|  | @ -4005,10 +4004,6 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   padding-left: 0.5rem; | ||||
| } | ||||
| 
 | ||||
| .pl-1 { | ||||
|   padding-left: 0.25rem; | ||||
| } | ||||
| 
 | ||||
| .pr-1 { | ||||
|   padding-right: 0.25rem; | ||||
| } | ||||
|  | @ -4037,6 +4032,10 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   padding-right: 1rem; | ||||
| } | ||||
| 
 | ||||
| .pl-1 { | ||||
|   padding-left: 0.25rem; | ||||
| } | ||||
| 
 | ||||
| .pb-1\.5 { | ||||
|   padding-bottom: 0.375rem; | ||||
| } | ||||
|  | @ -4664,6 +4663,10 @@ input[type="range"].range-lg::-moz-range-thumb { | |||
|   color: rgb(200 30 30 / var(--tw-placeholder-opacity)); | ||||
| } | ||||
| 
 | ||||
| .accent-gray-600 { | ||||
|   accent-color: #4B5563; | ||||
| } | ||||
| 
 | ||||
| .opacity-50 { | ||||
|   opacity: 0.5; | ||||
| } | ||||
|  | @ -8113,10 +8116,6 @@ svg.apply-fill path { | |||
|     margin-right: 0.25rem; | ||||
|   } | ||||
| 
 | ||||
|   .sm\:mt-0 { | ||||
|     margin-top: 0px; | ||||
|   } | ||||
| 
 | ||||
|   .sm\:mt-2 { | ||||
|     margin-top: 0.5rem; | ||||
|   } | ||||
|  |  | |||
|  | @ -29,13 +29,12 @@ import LayerConfig from "../src/Models/ThemeConfig/LayerConfig" | |||
| import PointRenderingConfig from "../src/Models/ThemeConfig/PointRenderingConfig" | ||||
| import { ConversionContext } from "../src/Models/ThemeConfig/Conversion/ConversionContext" | ||||
| import { GenerateFavouritesLayer } from "./generateFavouritesLayer" | ||||
| import LayoutConfig from "../src/Models/ThemeConfig/LayoutConfig" | ||||
| import LayoutConfig, { MinimalLayoutInformation } from "../src/Models/ThemeConfig/LayoutConfig" | ||||
| import Translations from "../src/UI/i18n/Translations" | ||||
| import { Translatable } from "../src/Models/ThemeConfig/Json/Translatable" | ||||
| import { ValidateThemeAndLayers } from "../src/Models/ThemeConfig/Conversion/ValidateThemeAndLayers" | ||||
| import { ExtractImages } from "../src/Models/ThemeConfig/Conversion/FixImages" | ||||
| import { | ||||
|     MinimalTagRenderingConfigJson, | ||||
|     TagRenderingConfigJson, | ||||
| } from "../src/Models/ThemeConfig/Json/TagRenderingConfigJson" | ||||
| 
 | ||||
|  | @ -189,7 +188,7 @@ class LayerOverviewUtils extends Script { | |||
|         return publicLayerIds | ||||
|     } | ||||
| 
 | ||||
|     public static cleanTranslation(t: Record<string, string> | Translation): Translatable { | ||||
|     public static cleanTranslation(t: string | Record<string, string> | Translation): Translatable { | ||||
|         return Translations.T(t).OnEveryLanguage((s) => parse_html(s).textContent).translations | ||||
|     } | ||||
| 
 | ||||
|  | @ -212,11 +211,71 @@ class LayerOverviewUtils extends Script { | |||
|         return false | ||||
|     } | ||||
| 
 | ||||
|     static mergeKeywords(into: Record<string, string[]>, source: Readonly<Record<string, string[]>>){ | ||||
|         for (const key in source) { | ||||
|             if(into[key]){ | ||||
|                 into[key].push(...source[key]) | ||||
|             }else{ | ||||
|                 into[key] = source[key] | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private layerKeywords(l: LayerConfigJson): Record<string, string[]> { | ||||
|         const keywords: Record<string, string[]> = {} | ||||
| 
 | ||||
|         function addWord(language: string, word: string | string[]) { | ||||
|             if(Array.isArray(word)){ | ||||
|                 word.forEach(w => addWord(language, w)) | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             word = Utils.SubstituteKeys(word, {}).trim() | ||||
|             if(!word){ | ||||
|                 return | ||||
|             } | ||||
|             if (!keywords[language]) { | ||||
|                 keywords[language] = [] | ||||
|             } | ||||
|             keywords[language].push(word) | ||||
|         } | ||||
| 
 | ||||
|         function addWords(tr: string | Record<string, string> | Record<string, string[]> | TagRenderingConfigJson) { | ||||
|             if(!tr){ | ||||
|                 return | ||||
|             } | ||||
|             if (typeof tr === "string") { | ||||
|                 addWord("*", tr) | ||||
|                 return | ||||
|             } | ||||
|             if (tr["render"] !== undefined || tr["mappings"] !== undefined) { | ||||
|                 tr = <TagRenderingConfigJson>tr | ||||
|                 addWords(<Translatable>tr.render) | ||||
|                 for (const mapping of tr.mappings ?? []) { | ||||
|                     if (typeof mapping === "string") { | ||||
|                         addWords(mapping) | ||||
|                         continue | ||||
|                     } | ||||
|                     addWords(mapping.then) | ||||
|                 } | ||||
|                 return | ||||
|             } | ||||
|             for (const lang in tr) { | ||||
|                 addWord(lang, tr[lang]) | ||||
|             } | ||||
|         } | ||||
|         addWord("*", l.id) | ||||
|         addWords(l.title) | ||||
|         addWords(l.description) | ||||
|         addWords(l.searchTerms) | ||||
|         return keywords | ||||
|     } | ||||
| 
 | ||||
|     writeSmallOverview( | ||||
|         themes: { | ||||
|             id: string | ||||
|             title: any | ||||
|             shortDescription: any | ||||
|             title: Translatable | ||||
|             shortDescription: Translatable | ||||
|             icon: string | ||||
|             hideFromOverview: boolean | ||||
|             mustHaveLanguage: boolean | ||||
|  | @ -228,62 +287,31 @@ class LayerOverviewUtils extends Script { | |||
|             } | ||||
|                 )[] | ||||
|         }[], | ||||
|         sharedLayers: Map<string, LayerConfigJson> | ||||
|     ) { | ||||
|         const perId = new Map<string, any>() | ||||
|         const layerKeywords : Record<string, Record<string, string[]>> = {} | ||||
| 
 | ||||
|         sharedLayers.forEach((layer, id) => { | ||||
|             layerKeywords[id] =  this.layerKeywords(layer) | ||||
|         }) | ||||
| 
 | ||||
|         const perId = new Map<string, MinimalLayoutInformation>() | ||||
|         for (const theme of themes) { | ||||
| 
 | ||||
|             const keywords: Record<string, string[]> = {} | ||||
| 
 | ||||
|             function addWord(language: string, word: string | string[]) { | ||||
|                 if(Array.isArray(word)){ | ||||
|                     word.forEach(w => addWord(language, w)) | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 word = Utils.SubstituteKeys(word, {}).trim() | ||||
|                 if(!word){ | ||||
|                     return | ||||
|                 } | ||||
|                 console.log(language, "--->", word) | ||||
|                 if (!keywords[language]) { | ||||
|                     keywords[language] = [] | ||||
|                 } | ||||
|                 keywords[language].push(word) | ||||
|             } | ||||
| 
 | ||||
|             function addWords(tr: string | Record<string, string> | Record<string, string[]> | TagRenderingConfigJson) { | ||||
|                 if(!tr){ | ||||
|                     return | ||||
|                 } | ||||
|                 if (typeof tr === "string") { | ||||
|                     addWord("*", tr) | ||||
|                     return | ||||
|                 } | ||||
|                 if (tr["render"] !== undefined || tr["mappings"] !== undefined) { | ||||
|                     tr = <TagRenderingConfigJson>tr | ||||
|                     addWords(<Translatable>tr.render) | ||||
|                     for (let mapping of tr.mappings ?? []) { | ||||
|                         if (typeof mapping === "string") { | ||||
|                             addWords(mapping) | ||||
|                             continue | ||||
|                         } | ||||
|                         addWords(mapping.then) | ||||
|                     } | ||||
|                     return | ||||
|                 } | ||||
|                 for (const lang in tr) { | ||||
|                     addWord(lang, tr[lang]) | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             for (const layer of theme.layers ?? []) { | ||||
|                 const l = <LayerConfigJson>layer | ||||
|                 addWord("*", l.id) | ||||
|                 addWords(l.title) | ||||
|                 addWords(l.description) | ||||
|                 addWords(l.searchTerms) | ||||
|                 if(sharedLayers.has(l.id)){ | ||||
|                     continue | ||||
|                 } | ||||
|                 if(l.id.startsWith("note_import")){ | ||||
|                     continue | ||||
|                 } | ||||
|                 LayerOverviewUtils.mergeKeywords(keywords, this.layerKeywords(l)) | ||||
| 
 | ||||
|             } | ||||
| 
 | ||||
|             const data = { | ||||
|             const data = <MinimalLayoutInformation> { | ||||
|                 id: theme.id, | ||||
|                 title: theme.title, | ||||
|                 shortDescription: LayerOverviewUtils.cleanTranslation(theme.shortDescription), | ||||
|  | @ -291,6 +319,7 @@ class LayerOverviewUtils extends Script { | |||
|                 hideFromOverview: theme.hideFromOverview, | ||||
|                 mustHaveLanguage: theme.mustHaveLanguage, | ||||
|                 keywords, | ||||
|                 layers: theme.layers.filter(l => sharedLayers.has(l["id"])).map(l => l["id"]) | ||||
|             } | ||||
|             perId.set(theme.id, data) | ||||
|         } | ||||
|  | @ -311,7 +340,7 @@ class LayerOverviewUtils extends Script { | |||
| 
 | ||||
|         writeFileSync( | ||||
|             "./src/assets/generated/theme_overview.json", | ||||
|             JSON.stringify(sorted, null, "  "), | ||||
|             JSON.stringify({ layers: layerKeywords, themes: sorted }, null, "  "), | ||||
|             { encoding: "utf8" }, | ||||
|         ) | ||||
|     } | ||||
|  | @ -927,7 +956,7 @@ class LayerOverviewUtils extends Script { | |||
|         if (whitelist.size == 0) { | ||||
|             this.writeSmallOverview( | ||||
|                 Array.from(fixed.values()).map((t) => { | ||||
|                     return { | ||||
|                     return <any> { | ||||
|                         ...t, | ||||
|                         hideFromOverview: t.hideFromOverview ?? false, | ||||
|                         shortDescription: | ||||
|  | @ -935,6 +964,7 @@ class LayerOverviewUtils extends Script { | |||
|                         mustHaveLanguage: t.mustHaveLanguage?.length > 0, | ||||
|                     } | ||||
|                 }), | ||||
|                 sharedLayers | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
|  | @ -7,11 +7,9 @@ import Constants from "../../Models/Constants" | |||
| 
 | ||||
| export default class FilterSearch implements GeocodingProvider { | ||||
|     private readonly _state: SpecialVisualizationState | ||||
|     private readonly suggestions | ||||
| 
 | ||||
|     constructor(state: SpecialVisualizationState) { | ||||
|         this._state = state | ||||
|         this.suggestions = this.getSuggestions() | ||||
|     } | ||||
| 
 | ||||
|     async search(query: string): Promise<SearchResult[]> { | ||||
|  | @ -58,7 +56,6 @@ export default class FilterSearch implements GeocodingProvider { | |||
|                     Utils.NoNullInplace(terms) | ||||
|                     const distances = queries.flatMap(query => terms.map(entry => { | ||||
|                         const d = Utils.levenshteinDistance(query, entry.slice(0, query.length)) | ||||
|                         console.log(query, "?  +", terms, "=", d) | ||||
|                         const dRelative = d / query.length | ||||
|                         return dRelative | ||||
|                     })) | ||||
|  | @ -79,10 +76,10 @@ export default class FilterSearch implements GeocodingProvider { | |||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Create a random list of filters | ||||
|      */ | ||||
|     getSuggestions(): FilterPayload[] { | ||||
|         if (this.suggestions) { | ||||
|        //     return this.suggestions
 | ||||
|         } | ||||
|         const result: FilterPayload[] = [] | ||||
|         for (const [id, filteredLayer] of this._state.layerState.filteredLayers) { | ||||
|             if (!Array.isArray(filteredLayer.layerDef.filters)) { | ||||
|  |  | |||
|  | @ -13,7 +13,6 @@ export class RecentSearch { | |||
| 
 | ||||
|     constructor(state: { layout: LayoutConfig, osmConnection: OsmConnection, selectedElement: Store<Feature> }) { | ||||
|         const prefs = state.osmConnection.preferencesHandler.GetLongPreference("previous-searches") | ||||
|         prefs.set(null) | ||||
|         this._seenThisSession = new UIEventSource<GeocodeResult[]>([])//UIEventSource.asObject<GeoCodeResult[]>(prefs, [])
 | ||||
|         this.seenThisSession = this._seenThisSession | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,8 +1,7 @@ | |||
| import GeocodingProvider, { SearchResult, GeocodingOptions } from "./GeocodingProvider" | ||||
| import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider" | ||||
| import * as themeOverview from "../../assets/generated/theme_overview.json" | ||||
| import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { SpecialVisualizationState } from "../../UI/SpecialVisualization" | ||||
| import { Utils } from "../../Utils" | ||||
| import MoreScreen from "../../UI/BigComponents/MoreScreen" | ||||
| import { ImmutableStore, Store } from "../UIEventSource" | ||||
| 
 | ||||
|  | @ -12,11 +11,16 @@ 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) { | ||||
|         this._state = state | ||||
|         this._layersToIgnore = state.layout.layers.map(l => l.id) | ||||
|         this._suggestionLimit = suggestionLimit | ||||
|         this._knownHiddenThemes = MoreScreen.knownHiddenThemes(this._state.osmConnection) | ||||
|         this._otherThemes = MoreScreen.officialThemes.themes | ||||
|             .filter(th => th.id !== state.layout.id) | ||||
|     } | ||||
| 
 | ||||
|     async search(query: string): Promise<SearchResult[]> { | ||||
|  | @ -40,11 +44,11 @@ export default class ThemeSearch implements GeocodingProvider { | |||
|         if (query.length < 1) { | ||||
|             return [] | ||||
|         } | ||||
|         query = Utils.simplifyStringForSearch(query) | ||||
|         return ThemeSearch.allThemes | ||||
|         const sorted = MoreScreen.sortedByLowest(query, this._otherThemes, this._layersToIgnore) | ||||
|         console.log(">>>", sorted) | ||||
|         return sorted | ||||
|             .map(th => th.theme) | ||||
|             .filter(th => !th.hideFromOverview || this._knownHiddenThemes.data.has(th.id)) | ||||
|             .filter(th => th.id !== this._state.layout.id) | ||||
|             .filter(th => MoreScreen.MatchesLayout(th, query)) | ||||
|             .slice(0, limit) | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -154,7 +154,7 @@ export default class SearchState { | |||
|             const poi = result[0] | ||||
|             if (poi.category === "theme") { | ||||
|                 const theme = <MinimalLayoutInformation>poi.payload | ||||
|                 const url = MoreScreen.createUrlFor(theme, false) | ||||
|                 const url = MoreScreen.createUrlFor(theme) | ||||
|                 window.location = <any>url | ||||
|                 return true | ||||
|             } | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import LayoutConfig, { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
| import { OsmConnection } from "../Osm/OsmConnection" | ||||
| import { MangroveIdentity } from "../Web/MangroveReviews" | ||||
| import { Store, Stores, UIEventSource } from "../UIEventSource" | ||||
|  | @ -141,8 +141,9 @@ export default class UserRelatedState { | |||
|         this._recentlyVisitedThemes = UIEventSource.asObject(prefs.GetLongPreference("recently-visited-themes"), []) | ||||
|         this.recentlyVisitedThemes = this._recentlyVisitedThemes | ||||
|         if (layout) { | ||||
|             const osmConn =this.osmConnection | ||||
|             const osmConn = this.osmConnection | ||||
|             const recentlyVisited = this._recentlyVisitedThemes | ||||
| 
 | ||||
|             function update() { | ||||
|                 if (!osmConn.isLoggedIn.data) { | ||||
|                     return | ||||
|  | @ -203,16 +204,7 @@ export default class UserRelatedState { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public GetUnofficialTheme(id: string): | ||||
|         | { | ||||
|         id: string | ||||
|         icon: string | ||||
|         title: any | ||||
|         shortDescription: any | ||||
|         definition?: any | ||||
|         isOfficial: boolean | ||||
|     } | ||||
|         | undefined { | ||||
|     public getUnofficialTheme(id: string): (MinimalLayoutInformation & { definition }) | undefined { | ||||
|         const pref = this.osmConnection.GetLongPreference("unofficial-theme-" + id) | ||||
|         const str = pref.data | ||||
| 
 | ||||
|  | @ -222,16 +214,7 @@ export default class UserRelatedState { | |||
|         } | ||||
| 
 | ||||
|         try { | ||||
|             const value: { | ||||
|                 id: string | ||||
|                 icon: string | ||||
|                 title: any | ||||
|                 shortDescription: any | ||||
|                 definition?: any | ||||
|                 isOfficial: boolean | ||||
|             } = JSON.parse(str) | ||||
|             value.isOfficial = false | ||||
|             return value | ||||
|             return <MinimalLayoutInformation & { definition: string }>JSON.parse(str) | ||||
|         } catch (e) { | ||||
|             console.warn( | ||||
|                 "Removing theme " + | ||||
|  | @ -464,7 +447,7 @@ export default class UserRelatedState { | |||
|                 } | ||||
|                 if (tags[key + "-combined-0"]) { | ||||
|                     // A combined value exists
 | ||||
|                     if(tags[key].startsWith("undefined")){ | ||||
|                     if (tags[key].startsWith("undefined")) { | ||||
|                         // Sometimes, a long string of 'undefined' will show up, we ignore them
 | ||||
|                         continue | ||||
|                     } | ||||
|  |  | |||
|  | @ -22,10 +22,10 @@ export class MinimalLayoutInformation { | |||
|     icon: string | ||||
|     title: Translatable | ||||
|     shortDescription: Translatable | ||||
|     definition?: Translatable | ||||
|     mustHaveLanguage?: boolean | ||||
|     hideFromOverview?: boolean | ||||
|     keywords?: Record<string, string[]> | ||||
|     layers: string[] | ||||
| } | ||||
| /** | ||||
|  * Minimal information about a theme | ||||
|  |  | |||
|  | @ -11,16 +11,11 @@ | |||
|   import LoginToggle from "./Base/LoginToggle.svelte" | ||||
|   import Pencil from "../assets/svg/Pencil.svelte" | ||||
|   import Constants from "../Models/Constants" | ||||
|   import { Store, UIEventSource } from "../Logic/UIEventSource" | ||||
|   import { placeholder } from "../Utils/placeholder" | ||||
|   import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import { Store, Stores, UIEventSource } from "../Logic/UIEventSource" | ||||
|   import ThemesList from "./BigComponents/ThemesList.svelte" | ||||
|   import { LayoutInformation } from "../Models/ThemeConfig/LayoutConfig" | ||||
|   import * as themeOverview from "../assets/generated/theme_overview.json" | ||||
|   import UnofficialThemeList from "./BigComponents/UnofficialThemeList.svelte" | ||||
|   import { MinimalLayoutInformation } from "../Models/ThemeConfig/LayoutConfig" | ||||
|   import Eye from "../assets/svg/Eye.svelte" | ||||
|   import LoginButton from "./Base/LoginButton.svelte" | ||||
|   import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight" | ||||
|   import Mastodon from "../assets/svg/Mastodon.svelte" | ||||
|   import Liberapay from "../assets/svg/Liberapay.svelte" | ||||
|   import Bug from "../assets/svg/Bug.svelte" | ||||
|  | @ -28,6 +23,7 @@ | |||
|   import { Utils } from "../Utils" | ||||
|   import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" | ||||
|   import Searchbar from "./Base/Searchbar.svelte" | ||||
|   import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight" | ||||
| 
 | ||||
|   const featureSwitches = new OsmConnectionFeatureSwitches() | ||||
|   const osmConnection = new OsmConnection({ | ||||
|  | @ -40,27 +36,71 @@ | |||
|   }) | ||||
|   const state = new UserRelatedState(osmConnection) | ||||
|   const t = Translations.t.index | ||||
|   const tu = Translations.t.general | ||||
|   const tr = Translations.t.general.morescreen | ||||
| 
 | ||||
|   let userLanguages = osmConnection.userDetails.map((ud) => ud.languages) | ||||
|   let themeSearchText: UIEventSource<string | undefined> = new UIEventSource<string>("") | ||||
|   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) | ||||
|   let visitedHiddenThemes: Store<MinimalLayoutInformation[]> = MoreScreen.knownHiddenThemes(state.osmConnection) | ||||
|     .map((knownIds) => hiddenThemes.filter((theme) => | ||||
|       knownIds.has(theme.id) || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet" | ||||
|     )) | ||||
| 
 | ||||
| 
 | ||||
|   const customThemes: Store<MinimalLayoutInformation[]> = Stores.ListStabilized<string>(state.installedUserThemes) | ||||
|     .mapD(stableIds => stableIds.map(id => state.getUnofficialTheme(id))) | ||||
| 
 | ||||
| 
 | ||||
|   function filtered(themes: MinimalLayoutInformation[]): Store<MinimalLayoutInformation[]> { | ||||
|     return searchStable.map(search => { | ||||
|       if (!search) { | ||||
|         return themes | ||||
|       } | ||||
|       const scores = MoreScreen.sortedByLowest(search, themes) | ||||
|       const strict = scores.filter(sc => sc.lowest < 2) | ||||
|       if (strict.length > 0) { | ||||
|         return strict.map(sc => sc.theme) | ||||
|       } | ||||
|       return scores.filter(sc => sc.lowest < 4).slice(0, 6).map(sc => sc.theme) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   let officialSearched = filtered(officialThemes) | ||||
|   let hiddenSearched = visitedHiddenThemes.bindD(visited => filtered(visited)) | ||||
|   let customSearched = customThemes.bindD(customThemes => filtered(customThemes)) | ||||
| 
 | ||||
| 
 | ||||
|   let searchIsFocussed = new UIEventSource(false) | ||||
|   document.addEventListener("keydown", function(event) { | ||||
|     if (event.ctrlKey && event.code === "KeyF") { | ||||
|       document.getElementById("theme-search")?.focus() | ||||
|       searchIsFocussed.set(true) | ||||
|       event.preventDefault() | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   let visitedHiddenThemes: Store<LayoutInformation[]> | ||||
|   const hiddenThemes: LayoutInformation[] = | ||||
|     (themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? [] | ||||
|   { | ||||
|     visitedHiddenThemes = MoreScreen.knownHiddenThemes(state.osmConnection) | ||||
|       .map((knownIds) => hiddenThemes.filter((theme) => | ||||
|         knownIds.has(theme.id) || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet" | ||||
|       )) | ||||
|   function applySearch() { | ||||
|     const didRedirect = MoreScreen.applySearch(search.data) | ||||
|     console.log("Did redirect?", didRedirect) | ||||
|     if (didRedirect) { | ||||
|       // Just for style and readability; won't _actually_ reach this | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     const candidate = officialSearched.data[0] ?? hiddenSearched.data[0] ?? customSearched.data[0] | ||||
|     if (!candidate) { | ||||
|       return | ||||
|     } | ||||
| 
 | ||||
|     window.location.href = MoreScreen.createUrlFor(candidate) | ||||
| 
 | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
| </script> | ||||
| 
 | ||||
| <main> | ||||
|  | @ -92,20 +132,19 @@ | |||
|       </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <Searchbar value={themeSearchText} placeholder={tr.searchForATheme} on:search={() => MoreScreen.applySearch(themeSearchText.data)}/> | ||||
|     <Searchbar value={search} placeholder={tr.searchForATheme} on:search={() => applySearch()} isFocused={searchIsFocussed} /> | ||||
| 
 | ||||
|     <ThemesList search={themeSearchText} {state} themes={MoreScreen.officialThemes} /> | ||||
|     <ThemesList {search} {state} themes={$officialSearched} /> | ||||
| 
 | ||||
|     <LoginToggle {state}> | ||||
|       <LoginButton clss="primary" {osmConnection} slot="not-logged-in"> | ||||
|         <Tr t={t.logIn} /> | ||||
|       </LoginButton> | ||||
|       <ThemesList | ||||
|         hideThemes={false} | ||||
|         isCustom={false} | ||||
|         search={themeSearchText} | ||||
|         {search} | ||||
|         {state} | ||||
|         themes={$visitedHiddenThemes} | ||||
|         themes={$hiddenSearched} | ||||
|         hasSelection={$officialSearched.length === 0} | ||||
|       > | ||||
|         <svelte:fragment slot="title"> | ||||
|           <h3> | ||||
|  | @ -122,7 +161,19 @@ | |||
|         </svelte:fragment> | ||||
|       </ThemesList> | ||||
| 
 | ||||
|       <UnofficialThemeList search={themeSearchText} {state} /> | ||||
|       {#if $customThemes.length > 0} | ||||
|         <ThemesList {search} {state} themes={$customSearched} | ||||
|                     hasSelection={$officialSearched.length === 0 && $hiddenSearched.length === 0} | ||||
|         > | ||||
|           <svelte:fragment slot="title"> | ||||
|             <h3> | ||||
|               <Tr t={tu.customThemeTitle} /> | ||||
|             </h3> | ||||
|             <Tr t={tu.customThemeIntro} /> | ||||
|           </svelte:fragment> | ||||
|         </ThemesList> | ||||
|       {/if} | ||||
| 
 | ||||
|     </LoginToggle> | ||||
| 
 | ||||
|     <a | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
|   import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid" | ||||
|   import { ariaLabel } from "../../Utils/ariaLabel" | ||||
|   import { Translation } from "../i18n/Translation" | ||||
|   import Backspace from "@babeard/svelte-heroicons/outline/Backspace" | ||||
| 
 | ||||
|   export let value: UIEventSource<string> | ||||
|   let _value = value.data ?? "" | ||||
|  | @ -23,8 +24,8 @@ | |||
|     if (focussed) { | ||||
|       requestAnimationFrame(() => { | ||||
|         if (document.activeElement !== inputElement) { | ||||
|           inputElement.focus() | ||||
|           inputElement.select() | ||||
|           inputElement?.focus() | ||||
|           inputElement?.select() | ||||
|         } | ||||
|       }) | ||||
|     } | ||||
|  | @ -38,15 +39,17 @@ | |||
|   on:submit|preventDefault={() => dispatch("search")} | ||||
| > | ||||
|   <label | ||||
|     class="neutral-label normal-background flex w-full items-center rounded-full border-2 border-black box-shadow" | ||||
|     class="neutral-label normal-background flex w-full items-center rounded-full border border-black box-shadow" | ||||
|   > | ||||
|     <SearchIcon aria-hidden="true" class="h-8 w-8 ml-2" /> | ||||
| 
 | ||||
|     <input | ||||
|       bind:this={inputElement} | ||||
|       on:focus={() => {isFocused?.setData(true)}} | ||||
|       on:blur={() => {isFocused?.setData(false)}} | ||||
|       type="search" | ||||
|       style=" --tw-ring-color: rgb(0 0 0 / 0) !important;" | ||||
|       class="ml-4 pl-1 w-full outline-none border-none" | ||||
|       class="px-0 ml-1 w-full outline-none border-none" | ||||
|       on:keypress={(keypr) => { | ||||
|           return keypr.key === "Enter" ? dispatch("search") : undefined | ||||
|         }} | ||||
|  | @ -54,7 +57,11 @@ | |||
|       use:set_placeholder={placeholder} | ||||
|       use:ariaLabel={placeholder} | ||||
|     /> | ||||
|     <SearchIcon aria-hidden="true" class="h-8 w-8 mx-3" /> | ||||
| 
 | ||||
|     {#if $value.length > 0} | ||||
|       <Backspace on:click={() => value.set("")} color="var(--button-background)" class="w-6 h-6 mr-3 cursor-pointer" /> | ||||
|     {:else} | ||||
|       <div class="w-6 mr-3" /> | ||||
|     {/if} | ||||
|   </label> | ||||
| </form> | ||||
|  |  | |||
|  | @ -3,26 +3,37 @@ import { Store } from "../../Logic/UIEventSource" | |||
| import { Utils } from "../../Utils" | ||||
| import themeOverview from "../../assets/generated/theme_overview.json" | ||||
| import Locale from "../i18n/Locale" | ||||
| import { Translatable } from "../../Models/ThemeConfig/Json/Translatable" | ||||
| import { TagRenderingConfigJson } from "../../Models/ThemeConfig/Json/TagRenderingConfigJson" | ||||
| 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: MinimalLayoutInformation[] = themeOverview | ||||
|     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) { | ||||
|         for (const th of MoreScreen.officialThemes.themes) { | ||||
|             MoreScreen.officialThemesById.set(th.id, th) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static applySearch(searchTerm: string) { | ||||
|     /** 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 | ||||
|             return false | ||||
|         } | ||||
|         if (searchTerm === "personal") { | ||||
|             window.location.href = MoreScreen.createUrlFor({ id: "personal" }, false) | ||||
|             window.location.href = MoreScreen.createUrlFor({ id: "personal" }) | ||||
|         } | ||||
|         if (searchTerm === "bugs" || searchTerm === "issues") { | ||||
|             window.location.href = "https://github.com/pietervdvn/MapComplete/issues" | ||||
|  | @ -39,77 +50,110 @@ export default class MoreScreen { | |||
|         if (searchTerm === "studio") { | ||||
|             window.location.href = "./studio.html" | ||||
|         } | ||||
|         // Enter pressed -> search the first _official_ matchin theme and open it
 | ||||
|         const publicTheme = MoreScreen.officialThemes.find( | ||||
|             (th) => | ||||
|                 th.hideFromOverview == false && | ||||
|                 th.id !== "personal" && | ||||
|                 MoreScreen.MatchesLayout(th, searchTerm), | ||||
|         ) | ||||
|         if (publicTheme !== undefined) { | ||||
|             window.location.href = MoreScreen.createUrlFor(publicTheme, false) | ||||
|         } | ||||
|         const hiddenTheme = MoreScreen.officialThemes.find( | ||||
|             (th) => th.id !== "personal" && MoreScreen.MatchesLayout(th, searchTerm), | ||||
|         ) | ||||
|         if (hiddenTheme !== undefined) { | ||||
|             window.location.href = MoreScreen.createUrlFor(hiddenTheme, false) | ||||
|         } | ||||
|         return false | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public static MatchesLayout( | ||||
|         layout: MinimalLayoutInformation, | ||||
|         search: string, | ||||
|         language?: string, | ||||
|     ): boolean { | ||||
|         if (search === undefined) { | ||||
|             return true | ||||
|         } | ||||
|         search = Utils.simplifyStringForSearch(search.toLocaleLowerCase()) // See #1729
 | ||||
|         if (search.length > 3 && layout.id.toLowerCase().indexOf(search) >= 0) { | ||||
|             return true | ||||
|         } | ||||
|         if (layout.id === "personal") { | ||||
|             return false | ||||
|         } | ||||
|         if (Utils.simplifyStringForSearch(layout.id) === Utils.simplifyStringForSearch(search)) { | ||||
|             return true | ||||
|     /** | ||||
|      * 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.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(" ")) | ||||
| 
 | ||||
|         const entitiesToSearch: (string | Record<string, string> | Record<string, string[]>)[] = [layout.shortDescription, layout.title, layout.keywords] | ||||
|         for (const entity of entitiesToSearch) { | ||||
|             if (entity === undefined) { | ||||
|                 continue | ||||
|             } | ||||
| 
 | ||||
|             let term: string[] | ||||
|             if (typeof entity === "string") { | ||||
|                 term = [entity] | ||||
|             } else { | ||||
|                 const terms = [].concat(entity["*"], entity[language]) | ||||
|                 if (Array.isArray(terms)) { | ||||
|                     term = terms | ||||
|                 } else { | ||||
|                     term = [terms] | ||||
|         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 | ||||
|     } | ||||
| 
 | ||||
|             const minLevehnstein = Math.min(...Utils.NoNull(term).map(t => Utils.levenshteinDistance(search, | ||||
|                 Utils.simplifyStringForSearch(t).slice(0, search.length)))) | ||||
|     public static scoreLayers(query: string): Record<string, number> { | ||||
|         const result: Record<string, number> = {} | ||||
|         for (const id in this.officialThemes.layers) { | ||||
|             const keywords = this.officialThemes.layers[id] | ||||
|             const distance = this.scoreKeywords(query, keywords) | ||||
|             result[id] = distance | ||||
|         } | ||||
|         return result | ||||
|     } | ||||
| 
 | ||||
|             if (minLevehnstein < 1 || minLevehnstein / search.length < 0.2) { | ||||
|                 return true | ||||
| 
 | ||||
|     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 | ||||
|     } | ||||
| 
 | ||||
|         return false | ||||
|     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 }, | ||||
|         isCustom: boolean, | ||||
|         state?: { layoutToUse?: { id } }, | ||||
|         state?: { layoutToUse?: { id } } | ||||
|     ): string { | ||||
|         if (layout === undefined) { | ||||
|             return undefined | ||||
|  | @ -136,7 +180,7 @@ export default class MoreScreen { | |||
|             linkPrefix = `${path}/theme.html?layout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
|         if (isCustom) { | ||||
|         if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) { | ||||
|             linkPrefix = `${path}/theme.html?userlayout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
|  | @ -155,7 +199,7 @@ export default class MoreScreen { | |||
|             new Set<string>( | ||||
|                 Object.keys(preferences) | ||||
|                     .filter((key) => key.startsWith(prefix)) | ||||
|                     .map((key) => key.substring(prefix.length, key.length - "-enabled".length)), | ||||
|                     .map((key) => key.substring(prefix.length, key.length - "-enabled".length)) | ||||
|             )) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,31 +1,14 @@ | |||
| <script lang="ts"> | ||||
|   import * as personal from "../../../assets/themes/personal/personal.json" | ||||
|   import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
|   import Constants from "../../Models/Constants" | ||||
|   import { ImmutableStore, Store } from "../../Logic/UIEventSource" | ||||
|   import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
|   import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource" | ||||
|   import Marker from "../Map/Marker.svelte" | ||||
| 
 | ||||
|   export let theme: MinimalLayoutInformation | ||||
|   export let isCustom: boolean = false | ||||
|   export let userDetails: UIEventSource<UserDetails> | ||||
|   export let theme: MinimalLayoutInformation & {isOfficial?: boolean} | ||||
|   let isCustom: boolean = theme.id.startsWith("https://") || theme.id.startsWith("http://") | ||||
|   export let state: { layoutToUse?: { id: string }; osmConnection: OsmConnection } | ||||
|   export let selected: boolean = false | ||||
| 
 | ||||
|   let unlockedPersonal = LocalStorageSource.GetParsed("unlocked_personal_theme", false) | ||||
| 
 | ||||
|   userDetails.addCallbackAndRunD((userDetails) => { | ||||
|     if (!userDetails.loggedIn) { | ||||
|       return | ||||
|     } | ||||
|     if (userDetails.csCount > Constants.userJourney.personalLayoutUnlock) { | ||||
|       unlockedPersonal.setData(true) | ||||
|     } | ||||
|     return true | ||||
|   }) | ||||
| 
 | ||||
|   $: title = Translations.T( | ||||
|     theme.title, | ||||
|  | @ -33,7 +16,6 @@ | |||
|   ) | ||||
|   $: description = Translations.T(theme.shortDescription) | ||||
| 
 | ||||
|   // TODO: Improve this function | ||||
|   function createUrl( | ||||
|     layout: { id: string; definition?: string }, | ||||
|     isCustom: boolean, | ||||
|  | @ -84,19 +66,12 @@ | |||
|   let href = createUrl(theme, isCustom, state) | ||||
| </script> | ||||
| 
 | ||||
| {#if theme.id !== personal.id || $unlockedPersonal} | ||||
|   <a class="low-interaction my-1 flex w-full items-center text-ellipsis rounded p-1" href={$href}> | ||||
|     <Marker icons={theme.icon} size="block h-8 w-8 sm:h-11 sm:w-11 m-1 sm:mx-2 md:mx-4 shrink-0" /> | ||||
| 
 | ||||
|     <span class="flex flex-col overflow-hidden text-ellipsis text-xl font-bold"> | ||||
|       <Tr cls="" t={title} /> | ||||
|       <Tr cls="subtle text-base" t={description} /> | ||||
| 
 | ||||
|       {#if selected} | ||||
|         <span class="thanks hidden-on-mobile" aria-hidden="true"> | ||||
|           <Tr t={Translations.t.general.morescreen.enterToOpen} /> | ||||
|         </span> | ||||
|       {/if} | ||||
|       <slot/> | ||||
|     </span> | ||||
|   </a> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -4,46 +4,36 @@ | |||
|   import { OsmConnection } from "../../Logic/Osm/OsmConnection" | ||||
|   import { UIEventSource } from "../../Logic/UIEventSource" | ||||
|   import ThemeButton from "./ThemeButton.svelte" | ||||
|   import { LayoutInformation, MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
|   import MoreScreen from "./MoreScreen" | ||||
|   import themeOverview from "../../assets/generated/theme_overview.json" | ||||
|   import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" | ||||
|   import Translations from "../i18n/Translations" | ||||
|   import Tr from "../Base/Tr.svelte" | ||||
| 
 | ||||
|   export let search: UIEventSource<string> | ||||
|   export let themes: MinimalLayoutInformation[] | ||||
|   export let state: { osmConnection: OsmConnection } | ||||
|   export let isCustom: boolean = false | ||||
|   export let hideThemes: boolean = true | ||||
| 
 | ||||
|   // Filter theme based on search value | ||||
|   $: filteredThemes = themes.filter((theme) => MoreScreen.MatchesLayout(theme, $search)) | ||||
|   export let hasSelection : boolean = true | ||||
| 
 | ||||
|   // Determine which is the first theme, after the search, using all themes | ||||
|   $: allFilteredThemes = themeOverview.filter((theme) => MoreScreen.MatchesLayout(theme, $search)) | ||||
|   $: firstTheme = allFilteredThemes[0] | ||||
| </script> | ||||
| 
 | ||||
| <section class="w-full"> | ||||
|   <slot name="title" /> | ||||
|   <div class="theme-list my-2 gap-4 md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3"> | ||||
|     {#each filteredThemes as theme (theme.id)} | ||||
|       {#if theme !== undefined && !(hideThemes && theme?.hideFromOverview)} | ||||
|         <!-- TODO: doesn't work if first theme is hidden --> | ||||
|         {#if theme === firstTheme && !isCustom && $search !== "" && $search !== undefined} | ||||
|           <ThemeButton | ||||
|             {theme} | ||||
|             {isCustom} | ||||
|             userDetails={state.osmConnection.userDetails} | ||||
|             {state} | ||||
|             selected={true} | ||||
|           /> | ||||
|         {:else} | ||||
|           <ThemeButton {theme} {isCustom} userDetails={state.osmConnection.userDetails} {state} /> | ||||
|     {#each themes as theme (theme.id)} | ||||
|       <ThemeButton | ||||
|         {theme} | ||||
|         {state} | ||||
|       > | ||||
|         {#if $search && hasSelection && themes[0] === theme} | ||||
|         <span class="thanks hidden-on-mobile" aria-hidden="true"> | ||||
|           <Tr t={Translations.t.general.morescreen.enterToOpen} /> | ||||
|         </span> | ||||
|         {/if} | ||||
|       {/if} | ||||
|       </ThemeButton> | ||||
|     {/each} | ||||
|   </div> | ||||
| 
 | ||||
|   {#if filteredThemes.length === 0} | ||||
|   {#if themes.length === 0} | ||||
|     <NoThemeResultButton {search} /> | ||||
|   {/if} | ||||
| </section> | ||||
|  |  | |||
|  | @ -12,21 +12,8 @@ | |||
|     osmConnection: OsmConnection | ||||
|   } | ||||
| 
 | ||||
|   const t = Translations.t.general | ||||
|   const currentIds: Store<string[]> = state.installedUserThemes | ||||
|   const stableIds = Stores.ListStabilized<string>(currentIds) | ||||
| 
 | ||||
|   let customThemes | ||||
|   $: customThemes = Utils.NoNull($stableIds.map((id) => state.GetUnofficialTheme(id))) | ||||
|   $: console.log("Custom themes are", customThemes) | ||||
| </script> | ||||
| 
 | ||||
| {#if customThemes.length > 0} | ||||
|   <ThemesList {search} {state} themes={customThemes} isCustom={true} hideThemes={false}> | ||||
|     <svelte:fragment slot="title"> | ||||
|       <h3> | ||||
|         <Tr t={t.customThemeTitle} /> | ||||
|       </h3> | ||||
|       <Tr t={t.customThemeIntro} /> | ||||
|     </svelte:fragment> | ||||
|   </ThemesList> | ||||
| {/if} | ||||
| 
 | ||||
|  |  | |||
|  | @ -29,8 +29,6 @@ | |||
| </script> | ||||
| <div class="p-4 low-interaction flex gap-y-2 flex-col"> | ||||
| 
 | ||||
|   <h3>Search results</h3> | ||||
| 
 | ||||
|   <ActiveFilters activeFilters={$activeFilters} /> | ||||
| 
 | ||||
|   {#if $searchTerm.length > 0 && $filterResults.length > 0} | ||||
|  | @ -79,7 +77,7 @@ | |||
|       <h3> | ||||
|         Other maps | ||||
|       </h3> | ||||
|       {#each $themeResults as entry} | ||||
|       {#each $themeResults as entry (entry.id)} | ||||
|         <ThemeResult {entry} /> | ||||
|       {/each} | ||||
|     </SidebarUnit> | ||||
|  |  | |||
|  | @ -47,14 +47,34 @@ | |||
|   import { CloseButton } from "flowbite-svelte" | ||||
|   import Hash from "../Logic/Web/Hash" | ||||
|   import Searchbar from "./Base/Searchbar.svelte" | ||||
|   import ChevronRight from "@babeard/svelte-heroicons/mini/ChevronRight" | ||||
|   import ChevronLeft from "@babeard/svelte-heroicons/solid/ChevronLeft" | ||||
| 
 | ||||
|   export let state: ThemeViewState | ||||
| 
 | ||||
| 
 | ||||
|   let layout = state.layout | ||||
|   let maplibremap: UIEventSource<MlMap> = state.map | ||||
|   let state_selectedElement = state.selectedElement | ||||
|   let selectedElement: UIEventSource<Feature> = new UIEventSource<Feature>(undefined) | ||||
|   let compass = Orientation.singleton.alpha | ||||
|   let compassLoaded = Orientation.singleton.gotMeasurement | ||||
|   let hash = Hash.hash | ||||
|   let previewedImage = state.previewedImage | ||||
|   let addNewFeatureMode = state.userRelatedState.addNewFeatureMode | ||||
|   let gpsAvailable = state.geolocation.geolocationState.gpsAvailable | ||||
|   let gpsButtonAriaLabel = state.geolocation.geolocationState.gpsStateExplanation | ||||
|   let debug = state.featureSwitches.featureSwitchIsDebugging | ||||
|   let featureSwitches: FeatureSwitchState = state.featureSwitches | ||||
|   let currentViewLayer: LayerConfig = layout.layers.find((l) => l.id === "current_view") | ||||
|   let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer | ||||
|   let currentZoom = state.mapProperties.zoom | ||||
|   let showCrosshair = state.userRelatedState.showCrosshair | ||||
|   let visualFeedback = state.visualFeedback | ||||
|   let viewport: UIEventSource<HTMLDivElement> = new UIEventSource<HTMLDivElement>(undefined) | ||||
|   let mapproperties: MapProperties = state.mapProperties | ||||
|   let searchOpened = state.searchState.showSearchDrawer | ||||
| 
 | ||||
|   Orientation.singleton.startMeasurements() | ||||
| 
 | ||||
|   state.selectedElement.addCallback((selected) => { | ||||
|  | @ -73,6 +93,8 @@ | |||
|     }) | ||||
|   }) | ||||
| 
 | ||||
|   state.mapProperties.installCustomKeyboardHandler(viewport) | ||||
| 
 | ||||
| 
 | ||||
|   let selectedLayer: Store<LayerConfig> = state.selectedElement.mapD((element) => { | ||||
|     if (element.properties.id.startsWith("current_view")) { | ||||
|  | @ -80,21 +102,35 @@ | |||
|     } | ||||
|     return state.getMatchingLayer(element.properties) | ||||
|   }) | ||||
|   let currentZoom = state.mapProperties.zoom | ||||
|   let showCrosshair = state.userRelatedState.showCrosshair | ||||
|   let visualFeedback = state.visualFeedback | ||||
|   let viewport: UIEventSource<HTMLDivElement> = new UIEventSource<HTMLDivElement>(undefined) | ||||
|   let mapproperties: MapProperties = state.mapProperties | ||||
|   state.mapProperties.installCustomKeyboardHandler(viewport) | ||||
| 
 | ||||
|   let canZoomIn = mapproperties.maxzoom.map( | ||||
|     (mz) => mapproperties.zoom.data < mz, | ||||
|     [mapproperties.zoom], | ||||
|     [mapproperties.zoom] | ||||
|   ) | ||||
|   let canZoomOut = mapproperties.minzoom.map( | ||||
|     (mz) => mapproperties.zoom.data > mz, | ||||
|     [mapproperties.zoom], | ||||
|     [mapproperties.zoom] | ||||
|   ) | ||||
| 
 | ||||
|   let rasterLayerName = | ||||
|     rasterLayer.data?.properties?.name ?? | ||||
|     AvailableRasterLayers.defaultBackgroundLayer.properties.name | ||||
|   onDestroy( | ||||
|     rasterLayer.addCallbackAndRunD((l) => { | ||||
|       rasterLayerName = l.properties.name | ||||
|     }) | ||||
|   ) | ||||
| 
 | ||||
| 
 | ||||
|   debug.addCallbackAndRun((dbg) => { | ||||
|     if (dbg) { | ||||
|       document.body.classList.add("debug") | ||||
|     } else { | ||||
|       document.body.classList.remove("debug") | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
| 
 | ||||
|   function updateViewport() { | ||||
|     const rect = viewport.data?.getBoundingClientRect() | ||||
|     if (!rect) { | ||||
|  | @ -108,7 +144,7 @@ | |||
|     const bottomRight = mlmap.unproject([rect.right, rect.bottom]) | ||||
|     const bbox = new BBox([ | ||||
|       [topLeft.lng, topLeft.lat], | ||||
|       [bottomRight.lng, bottomRight.lat], | ||||
|       [bottomRight.lng, bottomRight.lat] | ||||
|     ]) | ||||
|     state.visualFeedbackViewportBounds.setData(bbox) | ||||
|   } | ||||
|  | @ -119,31 +155,6 @@ | |||
|   mapproperties.bounds.addCallbackAndRunD(() => { | ||||
|     updateViewport() | ||||
|   }) | ||||
|   let featureSwitches: FeatureSwitchState = state.featureSwitches | ||||
|   let currentViewLayer: LayerConfig = layout.layers.find((l) => l.id === "current_view") | ||||
|   let rasterLayer: Store<RasterLayerPolygon> = state.mapProperties.rasterLayer | ||||
|   let rasterLayerName = | ||||
|     rasterLayer.data?.properties?.name ?? | ||||
|     AvailableRasterLayers.defaultBackgroundLayer.properties.name | ||||
|   onDestroy( | ||||
|     rasterLayer.addCallbackAndRunD((l) => { | ||||
|       rasterLayerName = l.properties.name | ||||
|     }), | ||||
|   ) | ||||
|   let previewedImage = state.previewedImage | ||||
|   let addNewFeatureMode = state.userRelatedState.addNewFeatureMode | ||||
|   let gpsAvailable = state.geolocation.geolocationState.gpsAvailable | ||||
|   let gpsButtonAriaLabel = state.geolocation.geolocationState.gpsStateExplanation | ||||
|   let debug = state.featureSwitches.featureSwitchIsDebugging | ||||
| 
 | ||||
| 
 | ||||
|   debug.addCallbackAndRun((dbg) => { | ||||
|     if (dbg) { | ||||
|       document.body.classList.add("debug") | ||||
|     } else { | ||||
|       document.body.classList.remove("debug") | ||||
|     } | ||||
|   }) | ||||
| 
 | ||||
|   function forwardEventToMap(e: KeyboardEvent) { | ||||
|     const mlmap = state.map.data | ||||
|  | @ -157,7 +168,6 @@ | |||
|     animation?.cameraAnimation(mlmap) | ||||
|   } | ||||
| 
 | ||||
|   let hash = Hash.hash | ||||
| </script> | ||||
| 
 | ||||
| <main> | ||||
|  | @ -303,20 +313,11 @@ | |||
| 
 | ||||
| 
 | ||||
|   <DrawerRight shown={state.searchState.showSearchDrawer}> | ||||
|     <div class="relative"> | ||||
|       <div class="absolute right-0 top-0 "> | ||||
|         <div class="mr-4 mt-4"> | ||||
|           <CloseButton on:click={() => state.searchState.showSearchDrawer.set(false)} /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <SearchResults {state} /> | ||||
|     </div> | ||||
|     <SearchResults {state} /> | ||||
|   </DrawerRight> | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     <!-- Top components --> | ||||
|   <!-- Top components --> | ||||
|   <div class="pointer-events-none absolute top-0 left-0 w-full"> | ||||
| 
 | ||||
|     <div | ||||
|  | @ -356,9 +357,22 @@ | |||
|       {/if} | ||||
| 
 | ||||
|       <If condition={state.featureSwitches.featureSwitchSearch}> | ||||
|         <div class="w-full sm:w-64"> | ||||
|           <Searchbar value={state.searchState.searchTerm} isFocused={state.searchState.searchIsFocused}/> | ||||
|         <div class="flex items-center"> | ||||
|           <div class="w-full sm:w-64"> | ||||
|             <Searchbar value={state.searchState.searchTerm} isFocused={state.searchState.searchIsFocused} /> | ||||
|           </div> | ||||
|           <MapControlButton on:keydown={forwardEventToMap} on:click={() =>{ | ||||
|             if(searchOpened.data){ | ||||
|               searchOpened.set(false) | ||||
|             }else{ | ||||
|               state.searchState.searchIsFocused.set(true) | ||||
|             } | ||||
|             }}> | ||||
|             <ChevronRight class="w-7 h-7 p-0 m-0 transition-all" | ||||
|                           style={"rotate: " + ($searchOpened ?  "0deg" : "180deg" ) } /> | ||||
|           </MapControlButton> | ||||
|         </div> | ||||
| 
 | ||||
|       </If> | ||||
| 
 | ||||
|     </div> | ||||
|  |  | |||
							
								
								
									
										11
									
								
								src/Utils.ts
									
										
									
									
									
								
							
							
						
						
									
										11
									
								
								src/Utils.ts
									
										
									
									
									
								
							|  | @ -1287,6 +1287,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
|         return withDistance.map((n) => n[0]) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     public static levenshteinDistance(str1: string, str2: string): number { | ||||
|         const track: number[][] = Array(str2.length + 1) | ||||
|             .fill(null) | ||||
|  | @ -1437,6 +1438,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
|         return d | ||||
|     } | ||||
| 
 | ||||
|     public static asRecord<K extends string | number | symbol, V>(keys: K[], f: ((k: K) => V)): Record<K, V> { | ||||
|         const results = <Record<K, V>> {} | ||||
|         for (const key of keys) { | ||||
|             results[key] = f(key) | ||||
|         } | ||||
|         return results | ||||
|     } | ||||
| 
 | ||||
|     static toIdRecord<T extends { id: string }>(ts: T[]): Record<string, T> { | ||||
|         const result: Record<string, T> = {} | ||||
|         for (const t of ts) { | ||||
|  | @ -1781,7 +1790,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be | |||
| 
 | ||||
|     public static NoNullInplace<T>(items: T[]): T[] { | ||||
|         for (let i = items.length - 1; i >= 0; i--) { | ||||
|             if (items[i] === null || items[i] === undefined) { | ||||
|             if (items[i] === null || items[i] === undefined || items[i] === "") { | ||||
|                 items.splice(i, 1) | ||||
|             } | ||||
|         } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue