Search feature: refactor, add translations

This commit is contained in:
Pieter Vander Vennet 2024-09-11 17:31:38 +02:00
parent b3492930b8
commit bd3bddc89c
21 changed files with 499 additions and 507 deletions

View file

@ -397,11 +397,21 @@
"save": "Save", "save": "Save",
"screenToSmall": "Open <i>{theme}</i> in a new window", "screenToSmall": "Open <i>{theme}</i> in a new window",
"search": { "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…", "error": "Something went wrong…",
"instructions": "Use the search bar above to search for locations, filters or other thematic maps",
"locations": "Locations",
"nothing": "Nothing found…", "nothing": "Nothing found…",
"nothingFor": "No results found for {term}", "nothingFor": "No results found for {term}",
"otherMaps": "Other maps",
"pickFilter": "Pick a filter",
"recentThemes": "Recently visited maps", "recentThemes": "Recently visited maps",
"recents": "Recent searches", "recents": "Recently seen places",
"search": "Search a location", "search": "Search a location",
"searchShort": "Search…", "searchShort": "Search…",
"searching": "Searching…" "searching": "Searching…"

View file

@ -333,9 +333,21 @@
"save": "Opslaan", "save": "Opslaan",
"screenToSmall": "Open {theme} in een nieuw venster", "screenToSmall": "Open {theme} in een nieuw venster",
"search": { "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…", "error": "Niet gelukt…",
"instructions": "Gebruik de zoekbalk om locaties, filters of om andere kaarten te zoeken",
"locations": "Plaatsen",
"nothing": "Niets gevonden…", "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…", "searchShort": "Zoek…",
"searching": "Aan het zoeken…" "searching": "Aan het zoeken…"
}, },

View file

@ -2,11 +2,11 @@ import GeocodingProvider, { SearchResult, GeocodingOptions, GeocodeResult } from
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Store, Stores } from "../UIEventSource" import { Store, Stores } from "../UIEventSource"
export default class CombinedSearcher implements GeocodingProvider <GeocodeResult> { export default class CombinedSearcher implements GeocodingProvider {
private _providers: ReadonlyArray<GeocodingProvider<GeocodeResult>> private _providers: ReadonlyArray<GeocodingProvider>
private _providersWithSuggest: ReadonlyArray<GeocodingProvider<GeocodeResult>> private _providersWithSuggest: ReadonlyArray<GeocodingProvider>
constructor(...providers: ReadonlyArray<GeocodingProvider<GeocodeResult>>) { constructor(...providers: ReadonlyArray<GeocodingProvider>) {
this._providers = Utils.NoNull(providers) this._providers = Utils.NoNull(providers)
this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined) this._providersWithSuggest = this._providers.filter(pr => pr.suggest !== undefined)
} }

View file

@ -5,7 +5,7 @@ import { ImmutableStore, Store } from "../UIEventSource"
/** /**
* A simple search-class which interprets possible locations * A simple search-class which interprets possible locations
*/ */
export default class CoordinateSearch implements GeocodingProvider<GeocodeResult> { export default class CoordinateSearch implements GeocodingProvider {
private static readonly latLonRegexes: ReadonlyArray<RegExp> = [ private static readonly latLonRegexes: ReadonlyArray<RegExp> = [
/^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/, /^(-?[0-9]+\.[0-9]+)[ ,;/\\]+(-?[0-9]+\.[0-9]+)/,
/lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/, /lat[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?[ ,;&]+lon[:=]? *['"]?(-?[0-9]+\.[0-9]+)['"]?/,

View file

@ -1,24 +1,24 @@
import { ImmutableStore, Store } from "../UIEventSource"
import GeocodingProvider, { FilterPayload, FilterResult, GeocodingOptions, SearchResult } from "./GeocodingProvider"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale" import Locale from "../../UI/i18n/Locale"
import Constants from "../../Models/Constants" 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 * Searches matching filters
*/ */
export default class FilterSearch implements GeocodingProvider { export default class FilterSearch {
private readonly _state: SpecialVisualizationState private readonly _state: SpecialVisualizationState
constructor(state: SpecialVisualizationState) { constructor(state: SpecialVisualizationState) {
this._state = state this._state = state
} }
async search(query: string): Promise<SearchResult[]> { public search(query: string): FilterSearchResult[] {
return this.searchDirectly(query)
}
public searchDirectly(query: string): FilterResult[] {
if (query.length === 0) { if (query.length === 0) {
return [] return []
} }
@ -28,7 +28,7 @@ export default class FilterSearch implements GeocodingProvider {
} }
return query return query
}).filter(q => q.length > 0) }).filter(q => q.length > 0)
const possibleFilters: FilterResult[] = [] const possibleFilters: FilterSearchResult[] = []
for (const layer of this._state.layout.layers) { for (const layer of this._state.layout.layers) {
if (!Array.isArray(layer.filters)) { if (!Array.isArray(layer.filters)) {
continue continue
@ -61,13 +61,9 @@ export default class FilterSearch implements GeocodingProvider {
if (levehnsteinD > 0.25) { if (levehnsteinD > 0.25) {
continue continue
} }
possibleFilters.push(<FilterResult>{ possibleFilters.push({
category: "filter",
osm_id: layer.id + "/" + filter.id + "/" + i,
payload: {
option, layer, filter, index: option, layer, filter, index:
i, i,
},
}) })
} }
} }
@ -75,16 +71,11 @@ export default class FilterSearch implements GeocodingProvider {
return possibleFilters return possibleFilters
} }
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> {
return new ImmutableStore(this.searchDirectly(query))
}
/** /**
* Create a random list of filters * Create a random list of filters
*/ */
getSuggestions(): FilterPayload[] { getSuggestions(): FilterSearchResult[] {
const result: FilterPayload[] = [] const result: FilterSearchResult[] = []
for (const [id, filteredLayer] of this._state.layerState.filteredLayers) { for (const [id, filteredLayer] of this._state.layerState.filteredLayers) {
if (!Array.isArray(filteredLayer.layerDef.filters)) { if (!Array.isArray(filteredLayer.layerDef.filters)) {
continue continue
@ -93,7 +84,7 @@ export default class FilterSearch implements GeocodingProvider {
continue continue
} }
for (const filter of filteredLayer.layerDef.filters) { 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++) { for (let i = 0; i < Math.min(filter.options.length, 5); i++) {
const option = filter.options[i] const option = filter.options[i]
if (option.osmTags === undefined) { if (option.osmTags === undefined) {

View file

@ -5,8 +5,6 @@ import { Store } from "../UIEventSource"
import * as search from "../../assets/generated/layers/search.json" import * as search from "../../assets/generated/layers/search.json"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson" import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import FilterConfig, { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
export type GeocodingCategory = export type GeocodingCategory =
@ -44,13 +42,7 @@ export type GeocodeResult = {
payload?: object, payload?: object,
source?: string 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 = export type SearchResult =
| FilterResult
| { category: "theme", osm_id: string, payload: MinimalLayoutInformation }
| LayerResult
| GeocodeResult | GeocodeResult
export interface GeocodingOptions { export interface GeocodingOptions {
@ -58,16 +50,16 @@ export interface GeocodingOptions {
} }
export default interface GeocodingProvider<T extends SearchResult = SearchResult> { export default interface GeocodingProvider {
search(query: string, options?: GeocodingOptions): Promise<T[]> search(query: string, options?: GeocodingOptions): Promise<GeocodeResult[]>
/** /**
* @param query * @param query
* @param options * @param options
*/ */
suggest?(query: string, options?: GeocodingOptions): Store<T[]> suggest?(query: string, options?: GeocodingOptions): Store<GeocodeResult[]>
} }
export type ReverseGeocodingResult = Feature<Geometry, { export type ReverseGeocodingResult = Feature<Geometry, {

View file

@ -1,44 +1,41 @@
import GeocodingProvider, { GeocodingOptions, LayerResult, SearchResult } from "./GeocodingProvider"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import MoreScreen from "../../UI/BigComponents/MoreScreen"
import { ImmutableStore, Store } from "../UIEventSource"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import SearchUtils from "./SearchUtils"
import ThemeSearch from "./ThemeSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export default class LayerSearch implements GeocodingProvider<LayerResult> { export default class LayerSearch {
private readonly _state: SpecialVisualizationState private readonly _state: SpecialVisualizationState
private readonly _suggestionLimit: number
private readonly _layerWhitelist : Set<string> private readonly _layerWhitelist : Set<string>
constructor(state: SpecialVisualizationState, suggestionLimit: number) { constructor(state: SpecialVisualizationState) {
this._state = state this._state = state
this._layerWhitelist = new Set(state.layout.layers.map(l => l.id).filter(id => Constants.added_by_default.indexOf(<any> id) < 0)) this._layerWhitelist = new Set(state.layout.layers.map(l => l.id).filter(id => Constants.added_by_default.indexOf(<any> id) < 0))
this._suggestionLimit = suggestionLimit
} }
async search(query: string): Promise<LayerResult[]> { static scoreLayers(query: string, layerWhitelist?: Set<string>): Record<string, number> {
return this.searchWrapped(query, 99) const result: Record<string, number> = {}
for (const id in ThemeSearch.officialThemes.layers) {
if(layerWhitelist !== undefined && !layerWhitelist.has(id)){
continue
} }
const keywords = ThemeSearch.officialThemes.layers[id]
suggest(query: string, options?: GeocodingOptions): Store<LayerResult[]> { const distance = SearchUtils.scoreKeywords(query, keywords)
return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4)) result[id] = distance
}
return result
} }
private searchWrapped(query: string, limit: number): LayerResult[] { public search(query: string, limit: number): LayerConfig[] {
return this.searchDirect(query, limit)
}
public searchDirect(query: string, limit: number): LayerResult[] {
if (query.length < 1) { if (query.length < 1) {
return [] return []
} }
const scores = MoreScreen.scoreLayers(query, this._layerWhitelist) const scores = LayerSearch.scoreLayers(query, this._layerWhitelist)
const asList:(LayerResult & {score:number})[] = [] const asList:({layer: LayerConfig, score:number})[] = []
for (const layer in scores) { for (const layer in scores) {
asList.push({ asList.push({
category: "layer", layer: this._state.layout.getLayer(layer),
payload: this._state.layout.getLayer(layer),
osm_id: layer,
score: scores[layer] score: scores[layer]
}) })
} }
@ -47,6 +44,7 @@ export default class LayerSearch implements GeocodingProvider<LayerResult> {
return asList return asList
.filter(sorted => sorted.score < 2) .filter(sorted => sorted.score < 2)
.slice(0, limit) .slice(0, limit)
.map(l => l.layer)
} }

View file

@ -3,7 +3,7 @@ import GeocodingProvider, { GeocodingOptions, GeocodeResult } from "./GeocodingP
import { OsmId } from "../../Models/OsmFeature" import { OsmId } from "../../Models/OsmFeature"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
export default class OpenStreetMapIdSearch implements GeocodingProvider<GeocodeResult> { export default class OpenStreetMapIdSearch implements GeocodingProvider {
private static readonly regex = /((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/ private static readonly regex = /((https?:\/\/)?(www.)?(osm|openstreetmap).org\/)?(n|node|w|way|r|relation)[/ ]?([0-9]+)/
private static readonly types: Readonly<Record<string, "node" | "way" | "relation">> = { private static readonly types: Readonly<Record<string, "node" | "way" | "relation">> = {

View file

@ -0,0 +1,75 @@
import Locale from "../../UI/i18n/Locale"
import { Utils } from "../../Utils"
import ThemeSearch from "./ThemeSearch"
export default class SearchUtils {
/** Applies special search terms, such as 'studio', 'osmcha', ...
* Returns 'false' if nothing is matched.
* Doesn't return control flow if a match is found (navigates to another page in this case)
*/
public static applySpecialSearch(searchTerm: string, ) {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return false
}
if (searchTerm === "personal") {
window.location.href = ThemeSearch.createUrlFor({ id: "personal" }, undefined)
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
if (searchTerm === "studio") {
window.location.href = "./studio.html"
}
return false
}
/**
* Searches for the smallest distance in words; will split both the query and the terms
*
* SearchUtils.scoreKeywords("drinking water", {"en": ["A layer with drinking water points"]}, "en") // => 0
* SearchUtils.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
*
*/
public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number {
if(!keywords){
return Infinity
}
language ??= Locale.language.data
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
let terms: string[]
if (Array.isArray(keywords)) {
terms = keywords
} else {
terms = (keywords[language] ?? []).concat(keywords["*"])
}
const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" "))
let distanceSummed = 0
for (let i = 0; i < queryParts.length; i++) {
const q = queryParts[i]
let minDistance: number = 99
for (const term of termsAll) {
const d = Utils.levenshteinDistance(q, Utils.simplifyStringForSearch(term))
if (d < minDistance) {
minDistance = d
}
}
distanceSummed += minDistance
}
return distanceSummed
}
}

View file

@ -1,49 +1,55 @@
import GeocodingProvider, { GeocodingOptions, SearchResult } from "./GeocodingProvider"
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import MoreScreen from "../../UI/BigComponents/MoreScreen" import { Store } from "../UIEventSource"
import { ImmutableStore, Store } from "../UIEventSource"
import UserRelatedState from "../State/UserRelatedState" import UserRelatedState from "../State/UserRelatedState"
import { Utils } from "../../Utils"
import Locale from "../../UI/i18n/Locale"
import themeOverview from "../../assets/generated/theme_overview.json"
import LayerSearch from "./LayerSearch"
import SearchUtils from "./SearchUtils"
type ThemeSearchScore = {
theme: MinimalLayoutInformation,
lowest: number,
perLayer?: Record<string, number>,
other: number
}
export default class ThemeSearch {
public static readonly officialThemes: {
themes: MinimalLayoutInformation[],
layers: Record<string, Record<string, string[]>>
} = themeOverview
public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>()
static {
for (const th of ThemeSearch.officialThemes.themes ?? []) {
ThemeSearch.officialThemesById.set(th.id, th)
}
}
export default class ThemeSearch implements GeocodingProvider {
private readonly _state: SpecialVisualizationState private readonly _state: SpecialVisualizationState
private readonly _knownHiddenThemes: Store<Set<string>> private readonly _knownHiddenThemes: Store<Set<string>>
private readonly _suggestionLimit: number
private readonly _layersToIgnore: string[] private readonly _layersToIgnore: string[]
private readonly _otherThemes: MinimalLayoutInformation[] private readonly _otherThemes: MinimalLayoutInformation[]
constructor(state: SpecialVisualizationState, suggestionLimit: number) { constructor(state: SpecialVisualizationState) {
this._state = state this._state = state
this._layersToIgnore = state.layout.layers.map(l => l.id) 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._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) .filter(th => th.id !== state.layout.id)
} }
async search(query: string): Promise<SearchResult[]> {
return this.searchWrapped(query, 99)
}
suggest(query: string, options?: GeocodingOptions): Store<SearchResult[]> { public search(query: string, limit: number): MinimalLayoutInformation[] {
return new ImmutableStore(this.searchWrapped(query, this._suggestionLimit ?? 4))
}
private searchWrapped(query: string, limit: number): SearchResult[] {
return this.searchDirect(query, limit).map(match => <SearchResult>{
payload: match,
category: "theme",
osm_id: match.id
})
}
public searchDirect(query: string, limit: number): MinimalLayoutInformation[] {
if (query.length < 1) { if (query.length < 1) {
return [] return []
} }
const sorted = MoreScreen.sortedByLowest(query, this._otherThemes, this._layersToIgnore) const sorted = ThemeSearch.sortedByLowestScores(query, this._otherThemes, this._layersToIgnore)
return sorted return sorted
.filter(sorted => sorted.lowest < 2) .filter(sorted => sorted.lowest < 2)
.map(th => th.theme) .map(th => th.theme)
@ -51,5 +57,96 @@ export default class ThemeSearch implements GeocodingProvider {
.slice(0, limit) .slice(0, limit)
} }
public static createUrlFor(
layout: { id: string },
state?: { layoutToUse?: { id } },
): string {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
return `${linkPrefix}`
}
private static scoreThemes(query: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): Record<string, ThemeSearchScore> {
if (query?.length < 1) {
return undefined
}
themes = Utils.NoNullInplace(themes)
const layerScores = LayerSearch.scoreLayers(query)
for (const ignoreLayer of ignoreLayers) {
delete layerScores[ignoreLayer]
}
const results: Record<string, ThemeSearchScore> = {}
for (const layoutInfo of themes) {
const theme = layoutInfo.id
if (theme === "personal") {
continue
}
if (Utils.simplifyStringForSearch(theme) === query) {
results[theme] = {
theme: layoutInfo,
lowest: -1,
other: 0,
}
continue
}
const perLayer = Utils.asRecord(
layoutInfo.layers ?? [], layer => layerScores[layer],
)
const language = Locale.language.data
const keywords = Utils.NoNullInplace([layoutInfo.shortDescription, layoutInfo.title])
.map(item => typeof item === "string" ? item : (item[language] ?? item["*"]))
const other = Math.min(SearchUtils.scoreKeywords(query, keywords), SearchUtils.scoreKeywords(query, layoutInfo.keywords))
const lowest = Math.min(other, ...Object.values(perLayer))
results[theme] = {
theme: layoutInfo,
perLayer,
other,
lowest,
}
}
return results
}
public static sortedByLowestScores(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): ThemeSearchScore[] {
const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers))
scored.sort((a, b) => a.lowest - b.lowest)
return scored
}
public static sortedByLowest(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = [], maxDiff: number): MinimalLayoutInformation[] {
return this.sortedByLowestScores(search, themes, ignoreLayers)
.map(th => th.theme)
}
} }

View file

@ -1,38 +1,30 @@
import GeocodingProvider, { import GeocodingProvider, { GeocodingUtils, type SearchResult } from "../Search/GeocodingProvider"
FilterPayload, FilterResult,
GeocodeResult,
GeocodingUtils, LayerResult,
type SearchResult,
} from "../Search/GeocodingProvider"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource" import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
import CombinedSearcher from "../Search/CombinedSearcher" import CombinedSearcher from "../Search/CombinedSearcher"
import FilterSearch from "../Search/FilterSearch" import FilterSearch, { FilterSearchResult } from "../Search/FilterSearch"
import LocalElementSearch from "../Search/LocalElementSearch" import LocalElementSearch from "../Search/LocalElementSearch"
import CoordinateSearch from "../Search/CoordinateSearch" import CoordinateSearch from "../Search/CoordinateSearch"
import ThemeSearch from "../Search/ThemeSearch" import ThemeSearch from "../Search/ThemeSearch"
import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch" import OpenStreetMapIdSearch from "../Search/OpenStreetMapIdSearch"
import PhotonSearch from "../Search/PhotonSearch" import PhotonSearch from "../Search/PhotonSearch"
import ThemeViewState from "../../Models/ThemeViewState" import ThemeViewState from "../../Models/ThemeViewState"
import Translations from "../../UI/i18n/Translations"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import MoreScreen from "../../UI/BigComponents/MoreScreen"
import { BBox } from "../BBox"
import { Translation } from "../../UI/i18n/Translation" import { Translation } from "../../UI/i18n/Translation"
import GeocodingFeatureSource from "../Search/GeocodingFeatureSource" import GeocodingFeatureSource from "../Search/GeocodingFeatureSource"
import ShowDataLayer from "../../UI/Map/ShowDataLayer" import ShowDataLayer from "../../UI/Map/ShowDataLayer"
import LayerSearch from "../Search/LayerSearch" import LayerSearch from "../Search/LayerSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export default class SearchState { export default class SearchState {
public readonly isSearching = new UIEventSource(false)
public readonly feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined) public readonly feedback: UIEventSource<Translation> = new UIEventSource<Translation>(undefined)
public readonly searchTerm: UIEventSource<string> = new UIEventSource<string>("") public readonly searchTerm: UIEventSource<string> = new UIEventSource<string>("")
public readonly searchIsFocused = new UIEventSource(false) public readonly searchIsFocused = new UIEventSource(false)
public readonly suggestions: Store<SearchResult[]> public readonly suggestions: Store<SearchResult[]>
public readonly filterSuggestions: Store<FilterResult[]> public readonly filterSuggestions: Store<FilterSearchResult[]>
public readonly themeSuggestions: Store<MinimalLayoutInformation[]> public readonly themeSuggestions: Store<MinimalLayoutInformation[]>
public readonly layerSuggestions: Store<LayerResult[]> public readonly layerSuggestions: Store<LayerConfig[]>
public readonly locationSearchers: ReadonlyArray<GeocodingProvider<GeocodeResult>> public readonly locationSearchers: ReadonlyArray<GeocodingProvider>
private readonly state: ThemeViewState private readonly state: ThemeViewState
public readonly showSearchDrawer: UIEventSource<boolean> public readonly showSearchDrawer: UIEventSource<boolean>
@ -42,7 +34,7 @@ export default class SearchState {
this.state = state this.state = state
this.locationSearchers = [ this.locationSearchers = [
// new LocalElementSearch(state, 5), new LocalElementSearch(state, 5),
new CoordinateSearch(), new CoordinateSearch(),
new OpenStreetMapIdSearch(state), new OpenStreetMapIdSearch(state),
new PhotonSearch(), // new NominatimGeocoding(), new PhotonSearch(), // new NominatimGeocoding(),
@ -67,18 +59,18 @@ export default class SearchState {
Stores.concat(suggestions).map(suggestions => CombinedSearcher.merge(suggestions)), Stores.concat(suggestions).map(suggestions => CombinedSearcher.merge(suggestions)),
) )
const themeSearch = new ThemeSearch(state, 3) const themeSearch = new ThemeSearch(state)
this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.searchDirect(query, 3)) this.themeSuggestions = this.searchTerm.mapD(query => themeSearch.search(query, 3))
const layerSearch = new LayerSearch(state, 5) const layerSearch = new LayerSearch(state)
this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.searchDirect(query, 5)) this.layerSuggestions = this.searchTerm.mapD(query => layerSearch.search(query, 5))
const filterSearch = new FilterSearch(state) const filterSearch = new FilterSearch(state)
this.filterSuggestions = this.searchTerm.stabilized(50) this.filterSuggestions = this.searchTerm.stabilized(50)
.mapD(query => filterSearch.searchDirectly(query)) .mapD(query => filterSearch.search(query))
.mapD(filterResult => { .mapD(filterResult => {
const active = state.layerState.activeFilters.data const active = state.layerState.activeFilters.data
return filterResult.filter(({ payload: { filter, index, layer } }) => { return filterResult.filter(({ filter, index, layer }) => {
const foundMatch = active.some(active => const foundMatch = active.some(active =>
active.filter.id === filter.id && layer.id === active.layer.id && active.control.data === index) 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) { public async apply(result: FilterSearchResult | LayerConfig) {
if (result.category === "filter") { if (result instanceof LayerConfig) {
return this.applyFilter(result.payload)
}
if (result.category === "layer") {
return this.applyLayer(result) 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) { 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 state = this.state
const { layer, filter, index } = payload const { layer, filter, index } = payload

View file

@ -7,7 +7,6 @@
import Translations from "./i18n/Translations" import Translations from "./i18n/Translations"
import Logo from "../assets/svg/Logo.svelte" import Logo from "../assets/svg/Logo.svelte"
import Tr from "./Base/Tr.svelte" import Tr from "./Base/Tr.svelte"
import MoreScreen from "./BigComponents/MoreScreen"
import LoginToggle from "./Base/LoginToggle.svelte" import LoginToggle from "./Base/LoginToggle.svelte"
import Pencil from "../assets/svg/Pencil.svelte" import Pencil from "../assets/svg/Pencil.svelte"
import Constants from "../Models/Constants" import Constants from "../Models/Constants"
@ -24,6 +23,8 @@
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
import Searchbar from "./Base/Searchbar.svelte" import Searchbar from "./Base/Searchbar.svelte"
import ChevronDoubleRight from "@babeard/svelte-heroicons/mini/ChevronDoubleRight" 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 featureSwitches = new OsmConnectionFeatureSwitches()
const osmConnection = new OsmConnection({ const osmConnection = new OsmConnection({
@ -43,8 +44,8 @@
let search: UIEventSource<string | undefined> = new UIEventSource<string>("") let search: UIEventSource<string | undefined> = new UIEventSource<string>("")
let searchStable = search.stabilized(100) let searchStable = search.stabilized(100)
const officialThemes: MinimalLayoutInformation[] = MoreScreen.officialThemes.themes.filter(th => th.hideFromOverview === false) const officialThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === false)
const hiddenThemes: MinimalLayoutInformation[] = MoreScreen.officialThemes.themes.filter(th => th.hideFromOverview === true) const hiddenThemes: MinimalLayoutInformation[] = ThemeSearch.officialThemes.themes.filter(th => th.hideFromOverview === true)
let visitedHiddenThemes: Store<MinimalLayoutInformation[]> = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection) let visitedHiddenThemes: Store<MinimalLayoutInformation[]> = UserRelatedState.initDiscoveredHiddenThemes(state.osmConnection)
.map((knownIds) => hiddenThemes.filter((theme) => .map((knownIds) => hiddenThemes.filter((theme) =>
knownIds.indexOf(theme.id) >= 0 || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet" knownIds.indexOf(theme.id) >= 0 || state.osmConnection.userDetails.data.name === "Pieter Vander Vennet"
@ -60,7 +61,7 @@
if (!search) { if (!search) {
return themes return themes
} }
const scores = MoreScreen.sortedByLowest(search, themes) const scores = ThemeSearch.sortedByLowestScores(search, themes)
const strict = scores.filter(sc => sc.lowest < 2) const strict = scores.filter(sc => sc.lowest < 2)
if (strict.length > 0) { if (strict.length > 0) {
return strict.map(sc => sc.theme) return strict.map(sc => sc.theme)
@ -84,7 +85,7 @@
}) })
function applySearch() { function applySearch() {
const didRedirect = MoreScreen.applySearch(search.data) const didRedirect = SearchUtils.applySpecialSearch(search.data)
console.log("Did redirect?", didRedirect) console.log("Did redirect?", didRedirect)
if (didRedirect) { if (didRedirect) {
// Just for style and readability; won't _actually_ reach this // Just for style and readability; won't _actually_ reach this
@ -96,7 +97,7 @@
return return
} }
window.location.href = MoreScreen.createUrlFor(candidate) window.location.href = ThemeSearch.createUrlFor(candidate, undefined)
} }

View file

@ -1,194 +0,0 @@
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { Store } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import themeOverview from "../../assets/generated/theme_overview.json"
import Locale from "../i18n/Locale"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
export type ThemeSearchScore = {
theme: MinimalLayoutInformation,
lowest: number,
perLayer?: Record<string, number>,
other: number
}
export default class MoreScreen {
public static readonly officialThemes: {
themes: MinimalLayoutInformation[],
layers: Record<string, Record<string, string[]>>
} = themeOverview
public static readonly officialThemesById: Map<string, MinimalLayoutInformation> = new Map<string, MinimalLayoutInformation>()
static {
for (const th of MoreScreen.officialThemes.themes ?? []) {
MoreScreen.officialThemesById.set(th.id, th)
}
}
/** Applies special search terms, such as 'studio', 'osmcha', ...
* Returns 'false' if nothing is matched.
* Doesn't return control flow if a match is found (navigates to another page in this case)
*/
public static applySearch(searchTerm: string, ) {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return false
}
if (searchTerm === "personal") {
window.location.href = MoreScreen.createUrlFor({ id: "personal" })
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
if (searchTerm === "studio") {
window.location.href = "./studio.html"
}
return false
}
/**
* Searches for the smallest distance in words; will split both the query and the terms
*
* MoreScreen.scoreKeywords("drinking water", {"en": ["A layer with drinking water points"]}, "en") // => 0
* MoreScreen.scoreKeywords("waste", {"en": ["A layer with drinking water points"]}, "en") // => 2
*
*/
public static scoreKeywords(query: string, keywords: Record<string, string[]> | string[], language?: string): number {
if(!keywords){
return Infinity
}
language ??= Locale.language.data
const queryParts = query.trim().split(" ").map(q => Utils.simplifyStringForSearch(q))
let terms: string[]
if (Array.isArray(keywords)) {
terms = keywords
} else {
terms = (keywords[language] ?? []).concat(keywords["*"])
}
const termsAll = Utils.NoNullInplace(terms).flatMap(t => t.split(" "))
let distanceSummed = 0
for (let i = 0; i < queryParts.length; i++) {
const q = queryParts[i]
let minDistance: number = 99
for (const term of termsAll) {
const d = Utils.levenshteinDistance(q, Utils.simplifyStringForSearch(term))
if (d < minDistance) {
minDistance = d
}
}
distanceSummed += minDistance
}
return distanceSummed
}
public static scoreLayers(query: string, layerWhitelist?: Set<string>): Record<string, number> {
const result: Record<string, number> = {}
for (const id in this.officialThemes.layers) {
if(layerWhitelist !== undefined && !layerWhitelist.has(id)){
continue
}
const keywords = this.officialThemes.layers[id]
const distance = this.scoreKeywords(query, keywords)
result[id] = distance
}
return result
}
public static scoreThemes(query: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []): Record<string, ThemeSearchScore> {
if (query?.length < 1) {
return undefined
}
themes = Utils.NoNullInplace(themes)
const layerScores = this.scoreLayers(query)
for (const ignoreLayer of ignoreLayers) {
delete layerScores[ignoreLayer]
}
const results: Record<string, ThemeSearchScore> = {}
for (const layoutInfo of themes) {
const theme = layoutInfo.id
if (theme === "personal") {
continue
}
if (Utils.simplifyStringForSearch(theme) === query) {
results[theme] = {
theme: layoutInfo,
lowest: -1,
other: 0
}
continue
}
const perLayer = Utils.asRecord(
layoutInfo.layers ?? [], layer => layerScores[layer]
)
const language = Locale.language.data
const keywords =Utils.NoNullInplace( [layoutInfo.shortDescription, layoutInfo.title])
.map(item => typeof item === "string" ? item : (item[language] ?? item["*"]))
const other = Math.min(this.scoreKeywords(query, keywords), this.scoreKeywords(query, layoutInfo.keywords))
const lowest = Math.min(other, ...Object.values(perLayer))
results[theme] = {
theme:layoutInfo,
perLayer,
other,
lowest
}
}
return results
}
public static sortedByLowest(search: string, themes: MinimalLayoutInformation[], ignoreLayers: string[] = []){
const scored = Object.values(this.scoreThemes(search, themes, ignoreLayers ))
scored.sort((a,b) => a.lowest - b.lowest)
return scored
}
public static createUrlFor(
layout: { id: string },
state?: { layoutToUse?: { id } }
): string {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (layout.id.startsWith("http://") || layout.id.startsWith("https://")) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
return `${linkPrefix}`
}
}

View file

@ -48,7 +48,7 @@ export class BingRasterLayerProperties implements Partial<RasterLayerProperties>
// "imageHeight": 256, "imageWidth": 256, // "imageHeight": 256, "imageWidth": 256,
// "imageUrlSubdomains": ["t0","t1","t2","t3"], // "imageUrlSubdomains": ["t0","t1","t2","t3"],
// "zoomMax": 21, // "zoomMax": 21,
const imageryResource = metadata.resourceSets[0].resources[0] const imageryResource = metadata["resourceSets"][0].resources[0]
const template = new URL(imageryResource.imageUrl) const template = new URL(imageryResource.imageUrl)
// Add tile image strictness param (n=) // Add tile image strictness param (n=)
// • n=f -> (Fail) returns a 404 // • n=f -> (Fail) returns a 404

View file

@ -1,13 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import type { FilterPayload, FilterResult, LayerResult } from "../../Logic/Search/GeocodingProvider"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import Icon from "../Map/Icon.svelte" import Icon from "../Map/Icon.svelte"
import Marker from "../Map/Marker.svelte"
import ToSvelte from "../Base/ToSvelte.svelte" import ToSvelte from "../Base/ToSvelte.svelte"
import type { FilterSearchResult } from "../../Logic/Search/FilterSearch"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export let entry: FilterResult | LayerResult export let entry: FilterSearchResult | LayerConfig
let isLayer = entry instanceof LayerConfig
let asLayer = <LayerConfig> entry
let asFilter = <FilterSearchResult> entry
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
let dispatch = createEventDispatcher<{ select }>() let dispatch = createEventDispatcher<{ select }>()
@ -20,16 +23,16 @@
<button on:click={() => apply()}> <button on:click={() => apply()}>
<div class="flex flex-col items-start"> <div class="flex flex-col items-start">
<div class="flex items-center gap-x-1"> <div class="flex items-center gap-x-1">
{#if entry.category === "layer"} {#if isLayer}
<div class="w-8 h-8 p-1"> <div class="w-8 h-8 p-1">
<ToSvelte construct={entry.payload.defaultIcon()} /> <ToSvelte construct={asLayer.defaultIcon()} />
</div> </div>
<b> <b>
<Tr t={entry.payload.name} /> <Tr t={asLayer.name} />
</b> </b>
{:else} {:else}
<Icon icon={entry.payload.option.icon ?? entry.payload. option.emoji} clss="w-4 h-4" emojiHeight="14px" /> <Icon icon={asFilter.option.icon ?? asFilter.option.emoji} clss="w-4 h-4" emojiHeight="14px" />
<Tr cls="whitespace-nowrap" t={entry.payload.option.question} /> <Tr cls="whitespace-nowrap" t={asFilter.option.question} />
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import { default as FilterResultSvelte } from "./FilterResult.svelte"
import SidebarUnit from "../Base/SidebarUnit.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { FilterSearchResult } from "../../Logic/Search/FilterSearch"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
export let state: SpecialVisualizationState
let searchTerm = state.searchState.searchTerm
let activeLayers = state.layerState.activeLayers
let filterResults = state.searchState.filterSuggestions
let layerResults = state.searchState.layerSuggestions.map(layers => {
const nowActive = activeLayers.data.filter(al => al.layerDef.isNormal())
if (nowActive.length === 1) {
const shownInActiveFiltersView = nowActive[0]
layers = layers.filter(l => l.id !== shownInActiveFiltersView.layerDef.id)
}
return layers
}, [activeLayers])
let filterResultsClipped = filterResults.mapD(filters => {
let layers = layerResults.data
const ls: (FilterSearchResult | LayerConfig)[] = [].concat(layers, filters)
if (ls.length <= 6) {
return ls
}
return ls.slice(0, 4)
}, [layerResults, activeLayers])
</script>
{#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)}
<SidebarUnit>
<h3><Tr t={Translations.t.general.search.pickFilter} /></h3>
<div class="flex flex-wrap">
{#each $filterResultsClipped as filterResult (filterResult)}
<FilterResultSvelte {state} entry={filterResult} />
{/each}
</div>
{#if $filterResults.length + $layerResults.length > $filterResultsClipped.length}
<div class="flex justify-center">
... and {$filterResults.length + $layerResults.length - $filterResultsClipped.length} more ...
</div>
{/if}
</SidebarUnit>
{/if}

View file

@ -0,0 +1,75 @@
<script lang="ts">
/**
* Shows all the location-results
*/
import Translations from "../i18n/Translations"
import { Store } from "../../Logic/UIEventSource"
import SidebarUnit from "../Base/SidebarUnit.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Loading from "../Base/Loading.svelte"
import { default as GeocodeResultSvelte } from "./GeocodeResult.svelte"
import Tr from "../Base/Tr.svelte"
import DotMenu from "../Base/DotMenu.svelte"
import { CogIcon } from "@rgossiaux/svelte-heroicons/solid"
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import type { GeocodeResult } from "../../Logic/Search/GeocodingProvider"
export let state: SpecialVisualizationState
let searchTerm = state.searchState.searchTerm
let results = state.searchState.suggestions
let isSearching = state.searchState.suggestionsSearchRunning
let recentlySeen: Store<GeocodeResult[]> = state.userRelatedState.recentlyVisitedSearch.value
const t = Translations.t.general.search
</script>
{#if $searchTerm.length > 0}
<SidebarUnit>
<h3><Tr t={t.locations}/></h3>
{#if $results?.length > 0}
{#each $results as entry (entry)}
<GeocodeResultSvelte on:select {entry} {state} />
{/each}
{/if}
{#if $isSearching}
<div class="flex justify-center m-4 my-8">
<Loading>
<Tr t={t.searching} />
</Loading>
</div>
{/if}
{#if !$isSearching && $results.length === 0}
<b class="flex justify-center p-4">
<Tr t={t.nothingFor.Subs({term: "<i>"+$searchTerm+"</i>"})} />
</b>
{/if}
</SidebarUnit>
{:else if $recentlySeen?.length > 0}
<SidebarUnit>
<div class="flex justify-between">
<h3 class="m-2">
<Tr t={t.recents} />
</h3>
<DotMenu>
<button on:click={() => {state.userRelatedState.recentlyVisitedSearch.clear()}}>
<TrashIcon />
<Tr t={t.deleteSearchHistory}/>
</button>
<button on:click={() => state.guistate.openUsersettings("sync-visited-locations")}>
<CogIcon />
<Tr t={t.editSearchSyncSettings}/>
</button>
</DotMenu>
</div>
{#each $recentlySeen as entry (entry)}
<GeocodeResultSvelte {entry} {state} on:select />
{/each}
</SidebarUnit>
{/if}

View file

@ -1,19 +0,0 @@
<script lang="ts">
import type { SearchResult } from "../../Logic/Search/GeocodingProvider"
import ThemeResult from "../Search/ThemeResult.svelte"
import FilterResult from "./FilterResult.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import GeocodeResult from "./GeocodeResult.svelte"
export let entry: SearchResult
export let state: SpecialVisualizationState
</script>
{#if entry.category === "theme"}
<ThemeResult entry={entry.payload} on:select />
{:else if entry.category === "filter"}
<FilterResult entry={entry.payload} {state} on:select />
{:else}
<GeocodeResult {entry} {state} on:select />
{/if}

View file

@ -1,181 +1,35 @@
<script lang="ts"> <script lang="ts">
import { Store } from "../../Logic/UIEventSource" import { Store } from "../../Logic/UIEventSource"
import Loading from "../Base/Loading.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { default as SearchResultSvelte } from "./SearchResult.svelte"
import MoreScreen from "../BigComponents/MoreScreen"
import type { FilterResult, GeocodeResult, LayerResult } from "../../Logic/Search/GeocodingProvider"
import ActiveFilters from "./ActiveFilters.svelte" import ActiveFilters from "./ActiveFilters.svelte"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import type { ActiveFilter } from "../../Logic/State/LayerState" import type { ActiveFilter } from "../../Logic/State/LayerState"
import ThemeViewState from "../../Models/ThemeViewState" import ThemeViewState from "../../Models/ThemeViewState"
import {default as FilterResultSvelte} from "./FilterResult.svelte" import ThemeResults from "./ThemeResults.svelte"
import ThemeResult from "./ThemeResult.svelte" import GeocodeResults from "./GeocodeResults.svelte"
import SidebarUnit from "../Base/SidebarUnit.svelte" import FilterResults from "./FilterResults.svelte"
import { TrashIcon } from "@babeard/svelte-heroicons/mini" import Tr from "../Base/Tr.svelte"
import DotMenu from "../Base/DotMenu.svelte" import Translations from "../i18n/Translations"
import { CogIcon } from "@rgossiaux/svelte-heroicons/solid"
export let state: ThemeViewState export let state: ThemeViewState
let activeFilters: Store<ActiveFilter[]> = state.layerState.activeFilters.map(fs => fs.filter(f => Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0)) let activeFilters: Store<ActiveFilter[]> = state.layerState.activeFilters.map(fs => fs.filter(f => Constants.priviliged_layers.indexOf(<any>f.layer.id) < 0))
let recentlySeen: Store<GeocodeResult[]> = state.userRelatedState.recentlyVisitedSearch.value
let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map(themes => themes.filter(th => th !== state.layout.id).slice(0, 6))
let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview
let searchTerm = state.searchState.searchTerm let searchTerm = state.searchState.searchTerm
let results = state.searchState.suggestions
let isSearching = state.searchState.suggestionsSearchRunning
let filterResults = state.searchState.filterSuggestions
let activeLayers = state.layerState.activeLayers
let layerResults = state.searchState.layerSuggestions.map(layers => {
const nowActive = activeLayers.data.filter(al => al.layerDef.isNormal())
if(nowActive.length === 1){
const shownInActiveFiltersView = nowActive[0]
layers = layers.filter(l => l.payload.id !== shownInActiveFiltersView.layerDef.id)
}
return layers
}, [activeLayers])
let filterResultsClipped = filterResults.mapD(filters => {
let layers = layerResults.data
const ls : (FilterResult | LayerResult)[] = [].concat(layers, filters)
if (ls.length <= 8) {
return ls
}
return ls.slice(0, 6)
}, [layerResults, activeLayers])
let themeResults = state.searchState.themeSuggestions
</script> </script>
<div class="p-4 low-interaction flex gap-y-2 flex-col"> <div class="p-4 low-interaction flex gap-y-2 flex-col">
<ActiveFilters {state} activeFilters={$activeFilters} /> <ActiveFilters {state} activeFilters={$activeFilters} />
{#if $searchTerm.length === 0 && $filterResults.length === 0 && $activeFilters.length === 0 && $recentThemes.length === 0} {#if $searchTerm.length === 0 && $activeFilters.length === 0 }
<div class="p-8 items-center text-center"> <div class="p-8 items-center text-center">
<b>Use the search bar above to search for locations, filters and other maps</b> <b><Tr t={Translations.t.general.search.instructions}/></b>
</div> </div>
{/if} {/if}
{#if $searchTerm.length > 0 && ($filterResults.length > 0 || $layerResults.length > 0)} <FilterResults {state}/>
<SidebarUnit>
<h3>Pick a filter below</h3> <GeocodeResults {state}/>
<div class="flex flex-wrap"> {#if $allowOtherThemes}
{#each $filterResultsClipped as filterResult (filterResult)} <ThemeResults {state} />
<FilterResultSvelte {state} entry={filterResult} />
{/each}
</div>
{#if $filterResults.length + $layerResults.length > $filterResultsClipped.length}
<div class="flex justify-center">
... and {$filterResults.length + $layerResults.length - $filterResultsClipped.length} more ...
</div>
{/if} {/if}
</SidebarUnit>
{/if}
<!-- Actual search results (or ""loading"", or ""no results"")-->
{#if $searchTerm.length > 0}
<SidebarUnit>
<h3>Locations</h3>
{#if $isSearching}
<div class="flex justify-center m-4 my-8">
<Loading />
</div>
{/if}
{#if $results?.length > 0}
{#each $results as entry (entry)}
<SearchResultSvelte on:select {entry} {state} />
{/each}
{:else if !$isSearching}
<b class="flex justify-center p-4">
<Tr t={Translations.t.general.search.nothingFor.Subs({term: "<i>"+$searchTerm+"</i>"})} />
</b>
{/if}
</SidebarUnit>
{/if}
<!-- Other maps which match the search term-->
{#if $themeResults.length > 0}
<SidebarUnit>
<h3>
Other maps
</h3>
{#each $themeResults as entry (entry.id)}
<ThemeResult {entry} />
{/each}
</SidebarUnit>
{/if}
{#if $searchTerm.length === 0 && $recentlySeen?.length === 0 && $recentThemes.length === 0}
<SidebarUnit>
<h3>
Suggestions
</h3>
</SidebarUnit>
{/if}
{#if $searchTerm.length === 0 && $recentlySeen?.length > 0}
<SidebarUnit>
<div class="flex justify-between">
<h3 class="m-2">
<Tr t={Translations.t.general.search.recents} />
</h3>
<DotMenu>
<button on:click={() => {state.userRelatedState.recentlyVisitedSearch.clear()}}>
<TrashIcon />
Delete search history
</button>
<button on:click={() => state.guistate.openUsersettings("sync-visited-locations")}>
<CogIcon />
Edit sync settings
</button>
</DotMenu>
</div>
{#each $recentlySeen as entry (entry)}
<SearchResultSvelte {entry} {state} on:select />
{/each}
</SidebarUnit>
{/if}
{#if $searchTerm.length === 0 && $recentThemes?.length > 0 && $allowOtherThemes}
<SidebarUnit>
<div class="flex w-full justify-between">
<h3 class="m-2">
<Tr t={Translations.t.general.search.recentThemes} />
</h3>
<DotMenu>
<button on:click={() => {state.userRelatedState.recentlyVisitedThemes.clear()}}>
<TrashIcon />
Delete earlier visited themes
</button>
<button on:click={() => state.guistate.openUsersettings("sync-visited-themes")}>
<CogIcon />
Edit sync settings
</button>
</DotMenu>
</div>
{#each $recentThemes as themeId (themeId)}
<SearchResultSvelte
entry={{payload: MoreScreen.officialThemesById.get(themeId), osm_id: themeId, category: "theme"}}
{state}
on:select />
{/each}
</SidebarUnit>
{/if}
</div> </div>

View file

@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig" import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import MoreScreen from "../BigComponents/MoreScreen"
import { Translation } from "../i18n/Translation" import { Translation } from "../i18n/Translation"
import Icon from "../Map/Icon.svelte" import Icon from "../Map/Icon.svelte"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import ThemeSearch from "../../Logic/Search/ThemeSearch"
export let entry: MinimalLayoutInformation export let entry: MinimalLayoutInformation
let otherTheme = entry let otherTheme = entry
</script> </script>
{#if entry} {#if entry}
<a href={MoreScreen.createUrlFor(otherTheme)} <a href={ThemeSearch.createUrlFor(otherTheme)}
class="flex items-center p-2 w-full gap-y-2 rounded-xl searchresult"> class="flex items-center p-2 w-full gap-y-2 rounded-xl searchresult">
<Icon icon={otherTheme.icon} clss="w-6 h-6 m-1" /> <Icon icon={otherTheme.icon} clss="w-6 h-6 m-1" />
@ -17,7 +17,6 @@
<b> <b>
<Tr t={new Translation(otherTheme.title)} /> <Tr t={new Translation(otherTheme.title)} />
</b> </b>
<!--<Tr t={new Translation(otherTheme.shortDescription)} /> -->
</div> </div>
</a> </a>
{/if} {/if}

View file

@ -0,0 +1,57 @@
<script lang="ts">
/**
* Either shows the 'recent' themes (if search string is empty) or shows matching theme results
*/
import Translations from "../i18n/Translations"
import ThemeSearch from "../../Logic/Search/ThemeSearch"
import SidebarUnit from "../Base/SidebarUnit.svelte"
import ThemeResult from "./ThemeResult.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import DotMenu from "../Base/DotMenu.svelte"
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import { CogIcon } from "@rgossiaux/svelte-heroicons/solid"
import Tr from "../Base/Tr.svelte"
export let state: SpecialVisualizationState
let searchTerm = state.searchState.searchTerm
let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map(themes => themes.filter(th => th !== state.layout.id).slice(0, 6))
let themeResults = state.searchState.themeSuggestions
const t =Translations.t.general.search
</script>
{#if $themeResults.length > 0}
<SidebarUnit>
<h3>
<Tr t={t.otherMaps}/>
</h3>
{#each $themeResults as entry (entry.id)}
<ThemeResult {entry} />
{/each}
</SidebarUnit>
{/if}
{#if $searchTerm.length === 0 && $recentThemes?.length > 0}
<SidebarUnit>
<div class="flex w-full justify-between">
<h3 class="m-2">
<Tr t={t.recentThemes} />
</h3>
<DotMenu>
<button on:click={() => {state.userRelatedState.recentlyVisitedThemes.clear()}}>
<TrashIcon />
<Tr t={t.deleteThemeHistory}/>
</button>
<button on:click={() => state.guistate.openUsersettings("sync-visited-themes")}>
<CogIcon />
<Tr t={t.editThemeSync}/>
</button>
</DotMenu>
</div>
{#each $recentThemes as themeId (themeId)}
<ThemeResult entry={ ThemeSearch.officialThemesById.get(themeId)} />
{/each}
</SidebarUnit>
{/if}