diff --git a/langs/en.json b/langs/en.json index a2da017bd..975312a76 100644 --- a/langs/en.json +++ b/langs/en.json @@ -397,11 +397,21 @@ "save": "Save", "screenToSmall": "Open {theme} 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" } } -} +} \ No newline at end of file diff --git a/langs/nl.json b/langs/nl.json index d409d2502..77634755b 100644 --- a/langs/nl.json +++ b/langs/nl.json @@ -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…" }, diff --git a/src/Logic/Search/CombinedSearcher.ts b/src/Logic/Search/CombinedSearcher.ts index 9b6a422f1..2ab9a1843 100644 --- a/src/Logic/Search/CombinedSearcher.ts +++ b/src/Logic/Search/CombinedSearcher.ts @@ -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 { - private _providers: ReadonlyArray> - private _providersWithSuggest: ReadonlyArray> +export default class CombinedSearcher implements GeocodingProvider { + private _providers: ReadonlyArray + private _providersWithSuggest: ReadonlyArray - constructor(...providers: ReadonlyArray>) { + constructor(...providers: ReadonlyArray) { this._providers = Utils.NoNull(providers) this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined) } diff --git a/src/Logic/Search/CoordinateSearch.ts b/src/Logic/Search/CoordinateSearch.ts index a9c4127de..55f796358 100644 --- a/src/Logic/Search/CoordinateSearch.ts +++ b/src/Logic/Search/CoordinateSearch.ts @@ -5,7 +5,7 @@ import { ImmutableStore, Store } from "../UIEventSource" /** * A simple search-class which interprets possible locations */ -export default class CoordinateSearch implements GeocodingProvider { +export default class CoordinateSearch implements GeocodingProvider { private static readonly latLonRegexes: ReadonlyArray = [ /^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/, /lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/, diff --git a/src/Logic/Search/FilterSearch.ts b/src/Logic/Search/FilterSearch.ts index 196fcc3cc..208a18f53 100644 --- a/src/Logic/Search/FilterSearch.ts +++ b/src/Logic/Search/FilterSearch.ts @@ -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 { - 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({ - 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 { - 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) { diff --git a/src/Logic/Search/GeocodingProvider.ts b/src/Logic/Search/GeocodingProvider.ts index 245884d5f..c0ad756ca 100644 --- a/src/Logic/Search/GeocodingProvider.ts +++ b/src/Logic/Search/GeocodingProvider.ts @@ -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 { +export default interface GeocodingProvider { - search(query: string, options?: GeocodingOptions): Promise + search(query: string, options?: GeocodingOptions): Promise /** * @param query * @param options */ - suggest?(query: string, options?: GeocodingOptions): Store + suggest?(query: string, options?: GeocodingOptions): Store } export type ReverseGeocodingResult = Feature { +export default class LayerSearch { private readonly _state: SpecialVisualizationState - private readonly _suggestionLimit: number private readonly _layerWhitelist : Set - 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( id) < 0)) - this._suggestionLimit = suggestionLimit } - async search(query: string): Promise { - return this.searchWrapped(query, 99) - } - - suggest(query: string, options?: GeocodingOptions): Store { - return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4)) + static scoreLayers(query: string, layerWhitelist?: Set): Record { + const result: Record = {} + 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 { return asList .filter(sorted => sorted.score < 2) .slice(0, limit) + .map(l => l.layer) } diff --git a/src/Logic/Search/OpenStreetMapIdSearch.ts b/src/Logic/Search/OpenStreetMapIdSearch.ts index f3e496170..cc026ae28 100644 --- a/src/Logic/Search/OpenStreetMapIdSearch.ts +++ b/src/Logic/Search/OpenStreetMapIdSearch.ts @@ -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 { +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> = { diff --git a/src/Logic/Search/SearchUtils.ts b/src/Logic/Search/SearchUtils.ts new file mode 100644 index 000000000..7b0f5a42e --- /dev/null +++ b/src/Logic/Search/SearchUtils.ts @@ -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[], 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 + } +} diff --git a/src/Logic/Search/ThemeSearch.ts b/src/Logic/Search/ThemeSearch.ts index 96b0577cd..a9c5ea40f 100644 --- a/src/Logic/Search/ThemeSearch.ts +++ b/src/Logic/Search/ThemeSearch.ts @@ -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, + other: number +} + + +export default class ThemeSearch { + + public static readonly officialThemes: { + themes: MinimalLayoutInformation[], + layers: Record> + } = themeOverview + public static readonly officialThemesById: Map = new Map() + 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> - 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 { - return this.searchWrapped(query, 99) - } - - suggest(query: string, options?: GeocodingOptions): Store { - return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4)) - } - - private searchWrapped(query: string, limit: number): SearchResult[] { - return this.searchDirect(query, limit).map(match => { - 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 { + 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 = {} + 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) + } } diff --git a/src/Logic/State/SearchState.ts b/src/Logic/State/SearchState.ts index 4daca15d6..62529bcf6 100644 --- a/src/Logic/State/SearchState.ts +++ b/src/Logic/State/SearchState.ts @@ -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 = new UIEventSource(undefined) public readonly searchTerm: UIEventSource = new UIEventSource("") public readonly searchIsFocused = new UIEventSource(false) public readonly suggestions: Store - public readonly filterSuggestions: Store + public readonly filterSuggestions: Store public readonly themeSuggestions: Store - public readonly layerSuggestions: Store - public readonly locationSearchers: ReadonlyArray> + public readonly layerSuggestions: Store + public readonly locationSearchers: ReadonlyArray private readonly state: ThemeViewState public readonly showSearchDrawer: UIEventSource @@ -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 diff --git a/src/UI/AllThemesGui.svelte b/src/UI/AllThemesGui.svelte index 6f9c644bb..1642305ce 100644 --- a/src/UI/AllThemesGui.svelte +++ b/src/UI/AllThemesGui.svelte @@ -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 = new UIEventSource("") 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 = 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) } diff --git a/src/UI/BigComponents/MoreScreen.ts b/src/UI/BigComponents/MoreScreen.ts deleted file mode 100644 index c8f79e354..000000000 --- a/src/UI/BigComponents/MoreScreen.ts +++ /dev/null @@ -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, - other: number -} -export default class MoreScreen { - public static readonly officialThemes: { - themes: MinimalLayoutInformation[], - layers: Record> - } = themeOverview - public static readonly officialThemesById: Map = new Map() - 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[], 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): Record { - const result: Record = {} - 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 { - 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 = {} - 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}` - } - -} diff --git a/src/UI/Map/BingRasterLayer.ts b/src/UI/Map/BingRasterLayer.ts index 8f138fa52..918b57791 100644 --- a/src/UI/Map/BingRasterLayer.ts +++ b/src/UI/Map/BingRasterLayer.ts @@ -48,7 +48,7 @@ export class BingRasterLayerProperties implements Partial // "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 diff --git a/src/UI/Search/FilterResult.svelte b/src/UI/Search/FilterResult.svelte index 90fac40c1..18317f508 100644 --- a/src/UI/Search/FilterResult.svelte +++ b/src/UI/Search/FilterResult.svelte @@ -1,13 +1,16 @@ + +{#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} + + +

+ +
+ {#each $filterResultsClipped as filterResult (filterResult)} + + {/each} +
+ {#if $filterResults.length + $layerResults.length > $filterResultsClipped.length} +
+ ... and {$filterResults.length + $layerResults.length - $filterResultsClipped.length} more ... +
+ {/if} +
+{/if} diff --git a/src/UI/Search/GeocodeResults.svelte b/src/UI/Search/GeocodeResults.svelte new file mode 100644 index 000000000..130bc4986 --- /dev/null +++ b/src/UI/Search/GeocodeResults.svelte @@ -0,0 +1,75 @@ + + +{#if $searchTerm.length > 0} + + +

+ + + {#if $results?.length > 0} + {#each $results as entry (entry)} + + {/each} + {/if} + + {#if $isSearching} +
+ + + +
+ {/if} + + {#if !$isSearching && $results.length === 0} + + "+$searchTerm+""})} /> + + {/if} +
+ +{:else if $recentlySeen?.length > 0} + +
+ +

+ +

+ + + + +
+ {#each $recentlySeen as entry (entry)} + + {/each} +
+{/if} diff --git a/src/UI/Search/SearchResult.svelte b/src/UI/Search/SearchResult.svelte deleted file mode 100644 index 39d09e01c..000000000 --- a/src/UI/Search/SearchResult.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -{#if entry.category === "theme"} - -{:else if entry.category === "filter"} - -{:else} - -{/if} diff --git a/src/UI/Search/SearchResults.svelte b/src/UI/Search/SearchResults.svelte index 092a272b8..c95014c65 100644 --- a/src/UI/Search/SearchResults.svelte +++ b/src/UI/Search/SearchResults.svelte @@ -1,181 +1,35 @@
- {#if $searchTerm.length === 0 && $filterResults.length === 0 && $activeFilters.length === 0 && $recentThemes.length === 0} + {#if $searchTerm.length === 0 && $activeFilters.length === 0 }
- Use the search bar above to search for locations, filters and other maps +
{/if} - {#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} - - -

Pick a filter below

- -
- {#each $filterResultsClipped as filterResult (filterResult)} - - {/each} -
- {#if $filterResults.length + $layerResults.length > $filterResultsClipped.length} -
- ... and {$filterResults.length + $layerResults.length - $filterResultsClipped.length} more ... -
- {/if} -
- {/if} - - - {#if $searchTerm.length > 0} - - -

Locations

- - {#if $isSearching} -
- -
- {/if} - - {#if $results?.length > 0} - {#each $results as entry (entry)} - - {/each} - - {:else if !$isSearching} - - "+$searchTerm+""})} /> - - {/if} -
- - {/if} - + - - {#if $themeResults.length > 0} - -

- Other maps -

- {#each $themeResults as entry (entry.id)} - - {/each} -
- {/if} - - {#if $searchTerm.length === 0 && $recentlySeen?.length === 0 && $recentThemes.length === 0} - -

+ - Suggestions -

- -
+ {#if $allowOtherThemes} + {/if} - {#if $searchTerm.length === 0 && $recentlySeen?.length > 0} - -
- -

- -

- - - - -
- {#each $recentlySeen as entry (entry)} - - {/each} -
- {/if} - - {#if $searchTerm.length === 0 && $recentThemes?.length > 0 && $allowOtherThemes} - -
- -

- -

- - - - -
- {#each $recentThemes as themeId (themeId)} - - {/each} - -
- {/if} - -
diff --git a/src/UI/Search/ThemeResult.svelte b/src/UI/Search/ThemeResult.svelte index 389dd36bb..9e1f93127 100644 --- a/src/UI/Search/ThemeResult.svelte +++ b/src/UI/Search/ThemeResult.svelte @@ -1,15 +1,15 @@ {#if entry} - @@ -17,7 +17,6 @@ - {/if} diff --git a/src/UI/Search/ThemeResults.svelte b/src/UI/Search/ThemeResults.svelte new file mode 100644 index 000000000..6b87516d9 --- /dev/null +++ b/src/UI/Search/ThemeResults.svelte @@ -0,0 +1,57 @@ + + + +{#if $themeResults.length > 0} + +

+ +

+ {#each $themeResults as entry (entry.id)} + + {/each} +
+{/if} + +{#if $searchTerm.length === 0 && $recentThemes?.length > 0} + +
+ +

+ +

+ + + + +
+ {#each $recentThemes as themeId (themeId)} + + {/each} +
+{/if}