More search functionality

This commit is contained in:
Pieter Vander Vennet 2024-08-22 22:50:37 +02:00
parent 5d0de8520b
commit 1c46a65c84
25 changed files with 962 additions and 846 deletions

View file

@ -90,11 +90,11 @@
"minzoom": 6, "minzoom": 6,
"title": { "title": {
"render": { "render": {
"en": "Cycle highway", "en": "cycle highway",
"de": "Radschnellweg", "de": "Radschnellweg",
"ca": "Via ciclista", "ca": "via ciclista",
"fr": "Aménagement cyclable", "fr": "Aménagement cyclable",
"nl": "Fietssnelweg", "nl": "fietssnelweg",
"es": "autovía ciclista", "es": "autovía ciclista",
"nb_NO": "sykkelmotorvei", "nb_NO": "sykkelmotorvei",
"da": "cykelmotorvej", "da": "cykelmotorvej",

View file

@ -257,6 +257,9 @@
{ {
"builtin": "id_presets.shop_types", "builtin": "id_presets.shop_types",
"override": { "override": {
"labels": [
"description"
],
"question": { "question": {
"en": "What kind of shop is this?", "en": "What kind of shop is this?",
"nl": "Wat voor soort winkel is dit?", "nl": "Wat voor soort winkel is dit?",

View file

@ -74,7 +74,6 @@
] ]
} }
}, },
"cycle_highways" "cycle_highways"
], ],
"overpassTimeout": 60, "overpassTimeout": 60,

File diff suppressed because it is too large Load diff

View file

@ -35,23 +35,6 @@
"1": { "1": {
"title": "un mupi" "title": "un mupi"
}, },
"10": {
"description": "Una pieza de tela impermeable con un mensaje impreso, anclada permanentemente en una pared",
"title": "una lona"
},
"11": {
"title": "un tótem"
},
"12": {
"description": "Se utiliza para carteles publicitarios, letreros de neón, logotipos y carteles en entradas institucionales",
"title": "un señal"
},
"13": {
"title": "una escultura"
},
"14": {
"title": "una pared pintada"
},
"2": { "2": {
"title": "un mupi sobre la pared" "title": "un mupi sobre la pared"
}, },
@ -77,6 +60,23 @@
}, },
"9": { "9": {
"title": "una pantalla montada en una marquesina de tránsito" "title": "una pantalla montada en una marquesina de tránsito"
},
"10": {
"description": "Una pieza de tela impermeable con un mensaje impreso, anclada permanentemente en una pared",
"title": "una lona"
},
"11": {
"title": "un tótem"
},
"12": {
"description": "Se utiliza para carteles publicitarios, letreros de neón, logotipos y carteles en entradas institucionales",
"title": "un señal"
},
"13": {
"title": "una escultura"
},
"14": {
"title": "una pared pintada"
} }
}, },
"tagRenderings": { "tagRenderings": {
@ -171,9 +171,6 @@
"1": { "1": {
"then": "Esto es un tablón de anuncios" "then": "Esto es un tablón de anuncios"
}, },
"10": {
"then": "Esto es una pared pintada"
},
"2": { "2": {
"then": "Esto es una columna" "then": "Esto es una columna"
}, },
@ -197,6 +194,9 @@
}, },
"9": { "9": {
"then": "Esto es un tótem" "then": "Esto es un tótem"
},
"10": {
"then": "Esto es una pared pintada"
} }
}, },
"question": "¿Qué tipo de elemento publicitario es?", "question": "¿Qué tipo de elemento publicitario es?",
@ -211,9 +211,6 @@
"1": { "1": {
"then": "Tablon de anuncios" "then": "Tablon de anuncios"
}, },
"10": {
"then": "Pared Pintada"
},
"2": { "2": {
"then": "Mupi" "then": "Mupi"
}, },
@ -237,6 +234,9 @@
}, },
"9": { "9": {
"then": "Tótem" "then": "Tótem"
},
"10": {
"then": "Pared Pintada"
} }
} }
} }
@ -375,15 +375,6 @@
"1": { "1": {
"then": "Mural" "then": "Mural"
}, },
"10": {
"then": "Azulejo (Baldosas decorativas Españolas y Portuguesas)"
},
"11": {
"then": "Cerámica"
},
"12": {
"then": "Tallado en madera"
},
"2": { "2": {
"then": "Pintura" "then": "Pintura"
}, },
@ -407,6 +398,15 @@
}, },
"9": { "9": {
"then": "Relieve" "then": "Relieve"
},
"10": {
"then": "Azulejo (Baldosas decorativas Españolas y Portuguesas)"
},
"11": {
"then": "Cerámica"
},
"12": {
"then": "Tallado en madera"
} }
}, },
"question": "¿Qué tipo de obra es esta pieza?", "question": "¿Qué tipo de obra es esta pieza?",
@ -1788,12 +1788,6 @@
"1": { "1": {
"then": "Este carril bici está pavimentado" "then": "Este carril bici está pavimentado"
}, },
"10": {
"then": "Este carril bici está hecho de gravilla"
},
"12": {
"then": "Este carril bici está hecho de tierra natural"
},
"2": { "2": {
"then": "Este carril bici está hecho de asfalto" "then": "Este carril bici está hecho de asfalto"
}, },
@ -1808,6 +1802,12 @@
}, },
"9": { "9": {
"then": "Este carril bici está hecho de grava" "then": "Este carril bici está hecho de grava"
},
"10": {
"then": "Este carril bici está hecho de gravilla"
},
"12": {
"then": "Este carril bici está hecho de tierra natural"
} }
}, },
"question": "¿De qué superficie está hecho este carril bici?", "question": "¿De qué superficie está hecho este carril bici?",
@ -1853,9 +1853,6 @@
"1": { "1": {
"then": "Este carril bici está pavimentado" "then": "Este carril bici está pavimentado"
}, },
"10": {
"then": "Este carril bici está hecho de gravilla"
},
"2": { "2": {
"then": "Este carril bici está hecho de asfalto" "then": "Este carril bici está hecho de asfalto"
}, },
@ -1867,6 +1864,9 @@
}, },
"9": { "9": {
"then": "Este carril bici está hecho de grava" "then": "Este carril bici está hecho de grava"
},
"10": {
"then": "Este carril bici está hecho de gravilla"
} }
}, },
"question": "¿De qué esta hecha la superficie de esta calle?", "question": "¿De qué esta hecha la superficie de esta calle?",
@ -2508,18 +2508,6 @@
"0": { "0": {
"then": "Esto es una pizzería" "then": "Esto es una pizzería"
}, },
"10": {
"then": "Aquí se sirven platos Chinos"
},
"11": {
"then": "Aquí se sirven platos Griegos"
},
"12": {
"then": "Aquí se sirven platos Indios"
},
"13": {
"then": "Aquí se sirven platos Turcos"
},
"2": { "2": {
"then": "Principalmente sirve pasta" "then": "Principalmente sirve pasta"
}, },
@ -2540,6 +2528,18 @@
}, },
"9": { "9": {
"then": "Aquí se sirven platos Franceses" "then": "Aquí se sirven platos Franceses"
},
"10": {
"then": "Aquí se sirven platos Chinos"
},
"11": {
"then": "Aquí se sirven platos Griegos"
},
"12": {
"then": "Aquí se sirven platos Indios"
},
"13": {
"then": "Aquí se sirven platos Turcos"
} }
}, },
"question": "¿Qué tipo de comida sirven aquí?", "question": "¿Qué tipo de comida sirven aquí?",
@ -2850,11 +2850,11 @@
"tagRenderings": { "tagRenderings": {
"memorial-type": { "memorial-type": {
"mappings": { "mappings": {
"10": {
"then": "Es una cruz"
},
"9": { "9": {
"then": "Es un obelisco" "then": "Es un obelisco"
},
"10": {
"then": "Es una cruz"
} }
} }
} }
@ -2945,19 +2945,6 @@
} }
} }
}, },
"10": {
"options": {
"0": {
"question": "Todas las notas"
},
"1": {
"question": "Ocultar las notas de importación"
},
"2": {
"question": "Solo mostrar las notas de importación"
}
}
},
"2": { "2": {
"options": { "options": {
"0": { "0": {
@ -3013,6 +3000,19 @@
"question": "Solo mostrar las notas abiertas" "question": "Solo mostrar las notas abiertas"
} }
} }
},
"10": {
"options": {
"0": {
"question": "Todas las notas"
},
"1": {
"question": "Ocultar las notas de importación"
},
"2": {
"question": "Solo mostrar las notas de importación"
}
}
} }
}, },
"name": "Notas de OpenStreetMap", "name": "Notas de OpenStreetMap",
@ -3615,6 +3615,24 @@
"1": { "1": {
"question": "Reciclaje de baterías" "question": "Reciclaje de baterías"
}, },
"3": {
"question": "Reciclaje de latas"
},
"4": {
"question": "Reciclaje de ropa"
},
"5": {
"question": "Reciclaje de aceite de cocina"
},
"6": {
"question": "Reciclaje de aceite de motor"
},
"8": {
"question": "Reciclaje de residuos orgánicos"
},
"9": {
"question": "Reciclaje de botellas de cristal"
},
"10": { "10": {
"question": "Reciclaje de cristal" "question": "Reciclaje de cristal"
}, },
@ -3641,24 +3659,6 @@
}, },
"18": { "18": {
"question": "Reciclaje de pequeños electrodomésticos" "question": "Reciclaje de pequeños electrodomésticos"
},
"3": {
"question": "Reciclaje de latas"
},
"4": {
"question": "Reciclaje de ropa"
},
"5": {
"question": "Reciclaje de aceite de cocina"
},
"6": {
"question": "Reciclaje de aceite de motor"
},
"8": {
"question": "Reciclaje de residuos orgánicos"
},
"9": {
"question": "Reciclaje de botellas de cristal"
} }
} }
} }
@ -3701,6 +3701,27 @@
"0": { "0": {
"then": "Aquí se pueden reciclar baterías" "then": "Aquí se pueden reciclar baterías"
}, },
"2": {
"then": "Aquí se pueden reciclar latas"
},
"3": {
"then": "Aquí se puede reciclar ropa"
},
"4": {
"then": "Aquí se puede reciclar aceite de cocina"
},
"5": {
"then": "Aquí se puede reciclar aceite de motor"
},
"7": {
"then": "Los residuos orgánicos pueden reciclarse aquí"
},
"8": {
"then": "Aquí se pueden reciclar residuos orgánicos"
},
"9": {
"then": "Aquí se pueden reciclar botellas de cristal"
},
"10": { "10": {
"then": "Aquí se puede reciclar cristal" "then": "Aquí se puede reciclar cristal"
}, },
@ -3725,29 +3746,8 @@
"19": { "19": {
"then": "Aquí se puede reciclar chatarra" "then": "Aquí se puede reciclar chatarra"
}, },
"2": {
"then": "Aquí se pueden reciclar latas"
},
"20": { "20": {
"then": "El calzado se puede reciclar aquí" "then": "El calzado se puede reciclar aquí"
},
"3": {
"then": "Aquí se puede reciclar ropa"
},
"4": {
"then": "Aquí se puede reciclar aceite de cocina"
},
"5": {
"then": "Aquí se puede reciclar aceite de motor"
},
"7": {
"then": "Los residuos orgánicos pueden reciclarse aquí"
},
"8": {
"then": "Aquí se pueden reciclar residuos orgánicos"
},
"9": {
"then": "Aquí se pueden reciclar botellas de cristal"
} }
}, },
"question": "¿Qué se puede reciclar aquí?" "question": "¿Qué se puede reciclar aquí?"
@ -4168,12 +4168,6 @@
"1": { "1": {
"then": "Esta lámpara utiliza LEDs" "then": "Esta lámpara utiliza LEDs"
}, },
"10": {
"then": "Esta lámpara utiliza lámparas de sodio de alta presión (naranja con blanco)"
},
"11": {
"then": "Esta lampara se ilumina con gas"
},
"2": { "2": {
"then": "Esta lámpara utiliza iluminación incandescente" "then": "Esta lámpara utiliza iluminación incandescente"
}, },
@ -4194,6 +4188,12 @@
}, },
"9": { "9": {
"then": "Esta lámpara utiliza lámparas de sodio de baja presión (naranja monocromo)" "then": "Esta lámpara utiliza lámparas de sodio de baja presión (naranja monocromo)"
},
"10": {
"then": "Esta lámpara utiliza lámparas de sodio de alta presión (naranja con blanco)"
},
"11": {
"then": "Esta lampara se ilumina con gas"
} }
}, },
"question": "¿Qué tipo de iluminación utiliza esta lámpara?" "question": "¿Qué tipo de iluminación utiliza esta lámpara?"
@ -4774,6 +4774,9 @@
}, },
"vending": { "vending": {
"mappings": { "mappings": {
"8": {
"then": "Aquí se venden cámaras de aire para bicicletas"
},
"22": { "22": {
"then": "Las luces para bicicletas se venden aquí" "then": "Las luces para bicicletas se venden aquí"
}, },
@ -4788,9 +4791,6 @@
}, },
"26": { "26": {
"then": "Aquí se venden candados para bicicletas" "then": "Aquí se venden candados para bicicletas"
},
"8": {
"then": "Aquí se venden cámaras de aire para bicicletas"
} }
} }
} }
@ -4878,4 +4878,4 @@
} }
} }
} }
} }

View file

@ -711,14 +711,6 @@ video {
top: 2.5rem; top: 2.5rem;
} }
.top-2 {
top: 0.5rem;
}
.right-2 {
right: 0.5rem;
}
.left-1\/4 { .left-1\/4 {
left: 25%; left: 25%;
} }
@ -787,6 +779,10 @@ video {
top: 0.25rem; top: 0.25rem;
} }
.top-2 {
top: 0.5rem;
}
.top-\[calc\(100\%\+1rem\)\] { .top-\[calc\(100\%\+1rem\)\] {
top: calc(100% + 1rem); top: calc(100% + 1rem);
} }
@ -1077,10 +1073,6 @@ video {
margin-left: -1.5rem; margin-left: -1.5rem;
} }
.mt-12 {
margin-top: 3rem;
}
.mb-3 { .mb-3 {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@ -1448,6 +1440,10 @@ video {
max-height: 3rem; max-height: 3rem;
} }
.max-h-96 {
max-height: 24rem;
}
.max-h-24 { .max-h-24 {
max-height: 6rem; max-height: 6rem;
} }
@ -5298,11 +5294,6 @@ svg.apply-fill path {
border-color: rgb(209 213 219 / var(--tw-border-opacity)); border-color: rgb(209 213 219 / var(--tw-border-opacity));
} }
.hover\:bg-stone-200:hover {
--tw-bg-opacity: 1;
background-color: rgb(231 229 228 / var(--tw-bg-opacity));
}
.hover\:bg-indigo-200:hover { .hover\:bg-indigo-200:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(199 210 254 / var(--tw-bg-opacity)); background-color: rgb(199 210 254 / var(--tw-bg-opacity));
@ -7243,6 +7234,10 @@ svg.apply-fill path {
width: 50%; width: 50%;
} }
.sm\:w-96 {
width: 24rem;
}
.sm\:w-11 { .sm\:w-11 {
width: 2.75rem; width: 2.75rem;
} }
@ -7259,10 +7254,6 @@ svg.apply-fill path {
width: 1.5rem; width: 1.5rem;
} }
.sm\:w-96 {
width: 24rem;
}
.sm\:grid-cols-2 { .sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View file

@ -909,6 +909,7 @@ export class GeoOperations {
/** /**
* GeoOperations.distanceToHuman(52.3) // => "50m" * GeoOperations.distanceToHuman(52.3) // => "50m"
* GeoOperations.distanceToHuman(999) // => "1.0km"
* GeoOperations.distanceToHuman(2800) // => "2.8km" * GeoOperations.distanceToHuman(2800) // => "2.8km"
* GeoOperations.distanceToHuman(12800) // => "13km" * GeoOperations.distanceToHuman(12800) // => "13km"
* GeoOperations.distanceToHuman(128000) // => "130km" * GeoOperations.distanceToHuman(128000) // => "130km"
@ -920,7 +921,7 @@ export class GeoOperations {
if (meters === undefined) { if (meters === undefined) {
return "" return ""
} }
meters = Math.round(meters) meters = Utils.roundHuman( Math.round(meters))
if (meters < 1000) { if (meters < 1000) {
return Utils.roundHuman(meters) + "m" return Utils.roundHuman(meters) + "m"
} }

View file

@ -1,5 +1,6 @@
import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider" import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Store, Stores } from "../UIEventSource"
export default class CombinedSearcher implements GeocodingProvider { export default class CombinedSearcher implements GeocodingProvider {
private _providers: ReadonlyArray<GeocodingProvider> private _providers: ReadonlyArray<GeocodingProvider>
@ -16,13 +17,13 @@ export default class CombinedSearcher implements GeocodingProvider {
* @param geocoded * @param geocoded
* @private * @private
*/ */
private merge(geocoded: GeoCodeResult[][]): GeoCodeResult[]{ private merge(geocoded: GeoCodeResult[][]): GeoCodeResult[] {
const results : GeoCodeResult[] = [] const results: GeoCodeResult[] = []
const seenIds = new Set<string>() const seenIds = new Set<string>()
for (const geocodedElement of geocoded) { for (const geocodedElement of geocoded) {
for (const entry of geocodedElement) { for (const entry of geocodedElement) {
const id = entry.osm_type+ entry.osm_id const id = entry.osm_type + entry.osm_id
if(seenIds.has(id)){ if (seenIds.has(id)) {
continue continue
} }
seenIds.add(id) seenIds.add(id)
@ -33,12 +34,12 @@ export default class CombinedSearcher implements GeocodingProvider {
} }
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
const results = await Promise.all(this._providers.map(pr => pr.search(query, options))) const results = (await Promise.all(this._providers.map(pr => pr.search(query, options))))
return this.merge(results) return results.flatMap(x => x)
} }
async suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { suggest(query: string, options?: GeocodingOptions): Store<GeoCodeResult[]> {
const results = await Promise.all(this._providersWithSuggest.map(pr => pr.suggest(query, options))) return Stores.concat(this._providersWithSuggest.map(pr => pr.suggest(query, options)))
return this.merge(results)
} }
} }

View file

@ -1,5 +1,6 @@
import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider" import GeocodingProvider, { GeoCodeResult, GeocodingOptions } from "./GeocodingProvider"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { ImmutableStore, Store } from "../UIEventSource"
/** /**
* A simple search-class which interprets possible locations * A simple search-class which interprets possible locations
@ -17,28 +18,25 @@ export default class CoordinateSearch implements GeocodingProvider {
] ]
/** /**
*
* @param query
* @param options
* *
* const ls = new CoordinateSearch() * const ls = new CoordinateSearch()
* const results = await ls.search("https://www.openstreetmap.org/search?query=Brugge#map=11/51.2611/3.2217") * const results = ls.directSearch("https://www.openstreetmap.org/search?query=Brugge#map=11/51.2611/3.2217")
* results.length // => 1 * results.length // => 1
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate","source": "coordinateSearch"} * results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate","source": "coordinateSearch"}
* *
* const ls = new CoordinateSearch() * const ls = new CoordinateSearch()
* const results = await ls.search("https://www.openstreetmap.org/#map=11/51.2611/3.2217") * const results = ls.directSearch("https://www.openstreetmap.org/#map=11/51.2611/3.2217")
* results.length // => 1 * results.length // => 1
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate","source": "coordinateSearch"} * results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate","source": "coordinateSearch"}
* *
* const ls = new CoordinateSearch() * const ls = new CoordinateSearch()
* const results = await ls.search("51.2611 3.2217") * const results = ls.directSearch("51.2611 3.2217")
* results.length // => 2 * results.length // => 2
* results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate", "source": "coordinateSearch"} * results[0] // => {lat: 51.2611, lon: 3.2217, display_name: "lon: 3.2217, lat: 51.2611", "category": "coordinate", "source": "coordinateSearch"}
* results[1] // => {lon: 51.2611, lat: 3.2217, display_name: "lon: 51.2611, lat: 3.2217", "category": "coordinate", "source": "coordinateSearch"} * results[1] // => {lon: 51.2611, lat: 3.2217, display_name: "lon: 51.2611, lat: 3.2217", "category": "coordinate", "source": "coordinateSearch"}
* *
*/ */
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { private directSearch(query: string): GeoCodeResult[] {
const matches = Utils.NoNull(CoordinateSearch.latLonRegexes.map(r => query.match(r))).map(m => <GeoCodeResult>{ const matches = Utils.NoNull(CoordinateSearch.latLonRegexes.map(r => query.match(r))).map(m => <GeoCodeResult>{
lat: Number(m[1]), lat: Number(m[1]),
@ -49,8 +47,7 @@ export default class CoordinateSearch implements GeocodingProvider {
}) })
const matchesLonLat = Utils.NoNull(CoordinateSearch.lonLatRegexes.map(r => query.match(r)))
const matchesLonLat = Utils.NoNull(CoordinateSearch.lonLatRegexes.map(r => query.match(r)))
.map(m => <GeoCodeResult>{ .map(m => <GeoCodeResult>{
lat: Number(m[2]), lat: Number(m[2]),
lon: Number(m[1]), lon: Number(m[1]),
@ -58,12 +55,15 @@ export default class CoordinateSearch implements GeocodingProvider {
source: "coordinateSearch", source: "coordinateSearch",
category: "coordinate" category: "coordinate"
}) })
return matches.concat(matchesLonLat) return matches.concat(matchesLonLat)
} }
suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { suggest(query: string): Store<GeoCodeResult[]> {
return this.search(query, options) return new ImmutableStore(this.directSearch(query))
}
async search (query: string): Promise<GeoCodeResult[]> {
return this.directSearch(query)
} }
} }

View file

@ -1,6 +1,7 @@
import { BBox } from "../BBox" import { BBox } from "../BBox"
import { Feature, Geometry } from "geojson" import { Feature, Geometry } from "geojson"
import { DefaultPinIcon } from "../../Models/Constants" import { DefaultPinIcon } from "../../Models/Constants"
import { Store } from "../UIEventSource"
export type GeocodingCategory = "coordinate" | "city" | "house" | "street" | "locality" | "country" | "train_station" | "county" | "airport" export type GeocodingCategory = "coordinate" | "city" | "house" | "street" | "locality" | "country" | "train_station" | "county" | "airport"
@ -42,7 +43,7 @@ export default interface GeocodingProvider {
* @param query * @param query
* @param options * @param options
*/ */
suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> suggest?(query: string, options?: GeocodingOptions): Store<GeoCodeResult[]>
} }
export type ReverseGeocodingResult = Feature<Geometry,{ export type ReverseGeocodingResult = Feature<Geometry,{

View file

@ -3,7 +3,19 @@ import ThemeViewState from "../../Models/ThemeViewState"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import { Feature } from "geojson" import { Feature } from "geojson"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
import { ImmutableStore, Store, Stores } from "../UIEventSource"
type IntermediateResult = {
feature: Feature,
/**
* Lon, lat
*/
center: [number, number],
levehnsteinD: number,
physicalDistance: number,
searchTerms: string[],
description: string
}
export default class LocalElementSearch implements GeocodingProvider { export default class LocalElementSearch implements GeocodingProvider {
private readonly _state: ThemeViewState private readonly _state: ThemeViewState
private readonly _limit: number private readonly _limit: number
@ -15,86 +27,91 @@ export default class LocalElementSearch implements GeocodingProvider {
} }
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
return this.searchEntries(query, options, false) return this.searchEntries(query, options, false).data
} }
searchEntries(query: string, options?: GeocodingOptions, matchStart?: boolean): GeoCodeResult[] { private getPartialResult(query: string, matchStart: boolean, centerpoint: [number, number], features: Feature[]): IntermediateResult[] {
const results: IntermediateResult [] = []
for (const feature of features) {
const props = feature.properties
const searchTerms: string[] = Utils.NoNull([props.name, props.alt_name, props.local_name,
(props["addr:street"] && props["addr:number"]) ?
props["addr:street"] + props["addr:number"] : undefined])
const levehnsteinD = Math.min(...searchTerms.flatMap(entry => entry.split(/ /)).map(entry => {
let simplified = Utils.simplifyStringForSearch(entry)
if (matchStart) {
simplified = simplified.slice(0, query.length)
}
return Utils.levenshteinDistance(query, simplified)
}))
const center = GeoOperations.centerpointCoordinates(feature)
if (levehnsteinD <= 2) {
let description = ""
if (feature.properties["addr:street"]) {
description += "" + feature.properties["addr:street"]
}
if (feature.properties["addr:housenumber"]) {
description += " " + feature.properties["addr:housenumber"]
}
results.push({
feature,
center,
physicalDistance: GeoOperations.distanceBetween(centerpoint, center),
levehnsteinD,
searchTerms,
description: description !== "" ? description : undefined
})
}
}
return results
}
searchEntries(query: string, options?: GeocodingOptions, matchStart?: boolean): Store<GeoCodeResult[]> {
if (query.length < 3) { if (query.length < 3) {
return [] return new ImmutableStore([])
} }
const center: { lon: number; lat: number } = this._state.mapProperties.location.data const center: { lon: number; lat: number } = this._state.mapProperties.location.data
const centerPoint: [number, number] = [center.lon, center.lat] const centerPoint: [number, number] = [center.lon, center.lat]
let results: {
feature: Feature,
/**
* Lon, lat
*/
center: [number, number],
levehnsteinD: number,
physicalDistance: number,
searchTerms: string[],
description: string
}[] = []
const properties = this._state.perLayer const properties = this._state.perLayer
query = Utils.simplifyStringForSearch(query) query = Utils.simplifyStringForSearch(query)
const partials: Store<IntermediateResult[]>[] = []
for (const [_, geoIndexedStore] of properties) { for (const [_, geoIndexedStore] of properties) {
for (const feature of geoIndexedStore.features.data) { const partialResult = geoIndexedStore.features.map(features => this.getPartialResult(query, matchStart, centerPoint, features))
const props = feature.properties partials.push(partialResult)
const searchTerms: string[] = Utils.NoNull([props.name, props.alt_name, props.local_name, }
(props["addr:street"] && props["addr:number"]) ?
props["addr:street"] + props["addr:number"] : undefined])
const listed: Store<IntermediateResult[]> = Stores.concat(partials)
const levehnsteinD = Math.min(...searchTerms.flatMap(entry => entry.split(/ /)).map(entry => { return listed.mapD(results => {
let simplified = Utils.simplifyStringForSearch(entry) results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
if (matchStart) { if (this._limit || options?.limit) {
simplified = simplified.slice(0, query.length) results = results.slice(0, Math.min(this._limit ?? options?.limit, options?.limit ?? this._limit))
} }
return Utils.levenshteinDistance(query, simplified) return results.map(entry => {
})) const id = entry.feature.properties.id.split("/")
const center = GeoOperations.centerpointCoordinates(feature) return <GeoCodeResult>{
if (levehnsteinD <= 2) { lon: entry.center[0],
lat: entry.center[1],
let description = "" osm_type: id[0],
function ifDef(prefix: string, key: string){ osm_id: id[1],
if(feature.properties[key]){ display_name: entry.searchTerms[0],
description += prefix+ feature.properties[key] source: "localElementSearch",
} feature: entry.feature,
} importance: 1,
ifDef("", "addr:street") description: entry.description
ifDef(" ", "addr:housenumber")
results.push({
feature,
center,
physicalDistance: GeoOperations.distanceBetween(centerPoint, center),
levehnsteinD,
searchTerms,
description: description !== "" ? description : undefined
})
} }
} })
}
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
if (this._limit || options?.limit) {
results = results.slice(0, Math.min(this._limit ?? options?.limit, options?.limit ?? this._limit))
}
return results.map(entry => {
const id = entry.feature.properties.id.split("/")
return <GeoCodeResult>{
lon: entry.center[0],
lat: entry.center[1],
osm_type: id[0],
osm_id: id[1],
display_name: entry.searchTerms[0],
source: "localElementSearch",
feature: entry.feature,
importance: 1,
description: entry.description
}
}) })
} }
async suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { suggest(query: string, options?: GeocodingOptions): Store<GeoCodeResult[]> {
return this.searchEntries(query, options, true) return this.searchEntries(query, options, true)
} }

View file

@ -3,9 +3,10 @@ import { BBox } from "../BBox"
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import { FeatureCollection } from "geojson" import { FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale" import Locale from "../../UI/i18n/Locale"
import GeocodingProvider, { GeoCodeResult, ReverseGeocodingProvider } from "./GeocodingProvider" import GeocodingProvider, { GeoCodeResult } from "./GeocodingProvider"
import { Store, UIEventSource } from "../UIEventSource"
export class NominatimGeocoding implements GeocodingProvider, ReverseGeocodingProvider { export class NominatimGeocoding implements GeocodingProvider {
private readonly _host ; private readonly _host ;
@ -13,14 +14,14 @@ export class NominatimGeocoding implements GeocodingProvider, ReverseGeocodingPr
this._host = host this._host = host
} }
public async search(query: string, options?: { bbox?: BBox; limit?: number }): Promise<GeoCodeResult[]> { public search(query: string, options?: { bbox?: BBox; limit?: number }): Promise<GeoCodeResult[]> {
const b = options?.bbox ?? BBox.global const b = options?.bbox ?? BBox.global
const url = `${ const url = `${
this._host this._host
}search?format=json&limit=${options?.limit ?? 1}&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${ }search?format=json&limit=${options?.limit ?? 1}&viewbox=${b.getEast()},${b.getNorth()},${b.getWest()},${b.getSouth()}&accept-language=${
Locale.language.data Locale.language.data
}&q=${query}` }&q=${query}`
return await Utils.downloadJson(url) return Utils.downloadJson(url)
} }

View file

@ -1,6 +1,7 @@
import Constants from "../../Models/Constants" import Constants from "../../Models/Constants"
import GeocodingProvider, { import GeocodingProvider, {
GeoCodeResult, GeocodingCategory, GeoCodeResult,
GeocodingCategory,
GeocodingOptions, GeocodingOptions,
ReverseGeocodingProvider, ReverseGeocodingProvider,
ReverseGeocodingResult ReverseGeocodingResult
@ -9,6 +10,7 @@ import { Utils } from "../../Utils"
import { Feature, FeatureCollection } from "geojson" import { Feature, FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale" import Locale from "../../UI/i18n/Locale"
import { GeoOperations } from "../GeoOperations" import { GeoOperations } from "../GeoOperations"
import { Store, Stores } from "../UIEventSource"
export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider { export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider {
private _endpoint: string private _endpoint: string
@ -52,8 +54,8 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
} }
search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { suggest(query: string, options?: GeocodingOptions): Store<GeoCodeResult[]> {
return this.suggest(query, options) return Stores.FromPromise(this.search(query, options))
} }
private buildDescription(entry: Feature) { private buildDescription(entry: Feature) {
@ -71,7 +73,7 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
case "house": { case "house": {
const addr = ifdef("", p.street) + ifdef(" ", p.housenumber) const addr = ifdef("", p.street) + ifdef(" ", p.housenumber)
if(!addr){ if (!addr) {
return p.city return p.city
} }
return addr + ifdef(", ", p.city) return addr + ifdef(", ", p.city)
@ -81,8 +83,8 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
return p.city ?? p.country return p.city ?? p.country
case "city": case "city":
case "locality": case "locality":
if(p.state){ if (p.state) {
return p.state + ifdef(", ", p.country) return p.state + ifdef(", ", p.country)
} }
return p.country return p.country
case "country": case "country":
@ -91,18 +93,18 @@ export default class PhotonSearch implements GeocodingProvider, ReverseGeocoding
} }
private getCategory(entry: Feature){ private getCategory(entry: Feature) {
const p = entry.properties const p = entry.properties
if(p.osm_value === "train_station" || p.osm_key === "railway"){ if (p.osm_value === "train_station" || p.osm_key === "railway") {
return "train_station" return "train_station"
} }
if(p.osm_value === "aerodrome" || p.osm_key === "aeroway"){ if (p.osm_value === "aerodrome" || p.osm_key === "aeroway") {
return "airport" return "airport"
} }
return p.type return p.type
} }
async suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
if (query.length < 3) { if (query.length < 3) {
return [] return []
} }

View file

@ -18,12 +18,15 @@ export class RecentSearch {
state.selectedElement.addCallbackAndRunD(selected => { state.selectedElement.addCallbackAndRunD(selected => {
const [osm_type, osm_id] = selected.properties.id.split("/") const [osm_type, osm_id] = selected.properties.id.split("/")
if(!osm_id){
return
}
const [lon, lat] = GeoOperations.centerpointCoordinates(selected) const [lon, lat] = GeoOperations.centerpointCoordinates(selected)
const entry = <GeoCodeResult>{ const entry = <GeoCodeResult>{
feature: selected, feature: selected,
osm_id, osm_type, osm_id, osm_type,
description: "Viewed recently",
lon, lat lon, lat
} }
this.addSelected(entry) this.addSelected(entry)

View file

@ -4,7 +4,7 @@ import { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
import MoreScreen from "../../UI/BigComponents/MoreScreen" import MoreScreen from "../../UI/BigComponents/MoreScreen"
import { Store } from "../UIEventSource" import { ImmutableStore, Store } from "../UIEventSource"
export default class ThemeSearch implements GeocodingProvider { export default class ThemeSearch implements GeocodingProvider {
@ -17,11 +17,15 @@ export default class ThemeSearch implements GeocodingProvider {
this._knownHiddenThemes = MoreScreen.knownHiddenThemes(this._state.osmConnection) this._knownHiddenThemes = MoreScreen.knownHiddenThemes(this._state.osmConnection)
} }
search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
return this.suggest(query, options) return this.searchDirect(query, options)
} }
async suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> { suggest(query: string, options?: GeocodingOptions): Store<GeoCodeResult[]> {
return new ImmutableStore(this.searchDirect(query, options))
}
private searchDirect(query: string, options?: GeocodingOptions): GeoCodeResult[] {
if(query.length < 1){ if(query.length < 1){
return [] return []
} }
@ -33,10 +37,10 @@ export default class ThemeSearch implements GeocodingProvider {
.filter(th => MoreScreen.MatchesLayout(th, query)) .filter(th => MoreScreen.MatchesLayout(th, query))
.slice(0, limit + 1) .slice(0, limit + 1)
return withMatch.map(match => (<GeoCodeResult> { return withMatch.map(match => <GeoCodeResult> {
payload: match, payload: match,
osm_id: match.id osm_id: match.id
})) })
} }

View file

@ -1,42 +1,14 @@
import { Utils } from "../../Utils" import { Utils } from "../../Utils"
/** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */ /** This code is autogenerated - do not edit. Edit ./assets/layers/usersettings/usersettings.json instead */
export class ThemeMetaTagging { export class ThemeMetaTagging {
public static readonly themeName = "usersettings" public static readonly themeName = "usersettings"
public metaTaggging_for_usersettings(feat: { properties: Record<string, string> }) { public metaTaggging_for_usersettings(feat: {properties: Record<string, string>}) {
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_md", () => Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_md', () => feat.properties._description.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/)?.at(1) )
feat.properties._description Utils.AddLazyProperty(feat.properties, '_d', () => feat.properties._description?.replace(/&lt;/g,'<')?.replace(/&gt;/g,'>') ?? '' )
.match(/\[[^\]]*\]\((.*(mastodon|en.osm.town).*)\).*/) Utils.AddLazyProperty(feat.properties, '_mastodon_candidate_a', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.href.match(/mastodon|en.osm.town/) !== null)[0]?.href }) (feat) )
?.at(1) Utils.AddLazyProperty(feat.properties, '_mastodon_link', () => (feat => {const e = document.createElement('div');e.innerHTML = feat.properties._d;return Array.from(e.getElementsByTagName("a")).filter(a => a.getAttribute("rel")?.indexOf('me') >= 0)[0]?.href})(feat) )
) Utils.AddLazyProperty(feat.properties, '_mastodon_candidate', () => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a )
Utils.AddLazyProperty( feat.properties['__current_backgroun'] = 'initial_value'
feat.properties, }
"_d", }
() => feat.properties._description?.replace(/&lt;/g, "<")?.replace(/&gt;/g, ">") ?? ""
)
Utils.AddLazyProperty(feat.properties, "_mastodon_candidate_a", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.href.match(/mastodon|en.osm.town/) !== null
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(feat.properties, "_mastodon_link", () =>
((feat) => {
const e = document.createElement("div")
e.innerHTML = feat.properties._d
return Array.from(e.getElementsByTagName("a")).filter(
(a) => a.getAttribute("rel")?.indexOf("me") >= 0
)[0]?.href
})(feat)
)
Utils.AddLazyProperty(
feat.properties,
"_mastodon_candidate",
() => feat.properties._mastodon_candidate_md ?? feat.properties._mastodon_candidate_a
)
feat.properties["__current_backgroun"] = "initial_value"
}
}

View file

@ -41,8 +41,26 @@ export class Stores {
return src return src
} }
public static flatten<X>(source: Store<Store<X>>, possibleSources?: Store<any>[]): Store<X> { public static concat<T>(stores: Store<T[]>[]): Store<T[]> {
return UIEventSource.flatten(source, possibleSources) const newStore = new UIEventSource<T[]>([])
function update(){
if(newStore._callbacks.isDestroyed){
return true // unregister
}
const results: T[] = []
for (const store of stores) {
if(store.data){
results.push(...store.data)
}
}
newStore.setData(results)
}
for (const store of stores) {
store.addCallback(() => update())
}
update()
return newStore
} }
/** /**
@ -105,8 +123,6 @@ export abstract class Store<T> implements Readable<T> {
callbackDestroyFunction: (f: () => void) => void callbackDestroyFunction: (f: () => void) => void
): Store<J> ): Store<J>
M
public mapD<J>( public mapD<J>(
f: (t: Exclude<T, undefined | null>) => J, f: (t: Exclude<T, undefined | null>) => J,
extraStoresToWatch?: Store<any>[], extraStoresToWatch?: Store<any>[],
@ -120,7 +136,7 @@ export abstract class Store<T> implements Readable<T> {
return null return null
} }
return f(<Exclude<T, undefined | null>>t) return f(<Exclude<T, undefined | null>>t)
}, extraStoresToWatch) }, extraStoresToWatch, callbackDestroyFunction)
} }
/** /**
@ -231,6 +247,9 @@ export abstract class Store<T> implements Readable<T> {
if (mapped.data === newEventSource) { if (mapped.data === newEventSource) {
sink.setData(resultData) sink.setData(resultData)
} }
if(sink._callbacks.isDestroyed){
return true // unregister
}
}) })
}) })
@ -308,6 +327,8 @@ export abstract class Store<T> implements Readable<T> {
run(v) run(v)
}) })
} }
public abstract destroy()
} }
export class ImmutableStore<T> extends Store<T> { export class ImmutableStore<T> extends Store<T> {
@ -361,6 +382,10 @@ export class ImmutableStore<T> extends Store<T> {
bind<X>(f: (t: T) => Store<X>): Store<X> { bind<X>(f: (t: T) => Store<X>): Store<X> {
return f(this.data) return f(this.data)
} }
destroy() {
// pass
}
} }
/** /**
@ -369,7 +394,7 @@ export class ImmutableStore<T> extends Store<T> {
class ListenerTracker<T> { class ListenerTracker<T> {
public pingCount = 0 public pingCount = 0
private readonly _callbacks: ((t: T) => boolean | void | any)[] = [] private readonly _callbacks: ((t: T) => boolean | void | any)[] = []
public isDestroyed = false
/** /**
* Adds a callback which can be called; a function to unregister is returned * Adds a callback which can be called; a function to unregister is returned
*/ */
@ -429,6 +454,11 @@ class ListenerTracker<T> {
length() { length() {
return this._callbacks.length return this._callbacks.length
} }
public destroy(){
this.isDestroyed= true
this._callbacks.splice(0, this._callbacks.length)
}
} }
/** /**
@ -584,10 +614,14 @@ class MappedStore<TIn, T> extends Store<T> {
this._data = newData this._data = newData
this._callbacks.ping(this._data) this._callbacks.ping(this._data)
} }
destroy() {
this.unregisterFromUpstream()
}
} }
export class UIEventSource<T> extends Store<T> implements Writable<T> { export class UIEventSource<T> extends Store<T> implements Writable<T> {
private static readonly pass: () => {} private static readonly pass: (() => void) = () => {};
public data: T public data: T
_callbacks: ListenerTracker<T> = new ListenerTracker<T>() _callbacks: ListenerTracker<T> = new ListenerTracker<T>()
@ -596,9 +630,13 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
this.data = data this.data = data
} }
public destroy(){
this._callbacks.destroy()
}
public static flatten<X>( public static flatten<X>(
source: Store<Store<X>>, source: Store<Store<X>>,
possibleSources?: Store<any>[] possibleSources?: Store<object>[]
): UIEventSource<X> { ): UIEventSource<X> {
const sink = new UIEventSource<X>(source.data?.data) const sink = new UIEventSource<X>(source.data?.data)
@ -627,7 +665,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
*/ */
public static FromPromise<T>( public static FromPromise<T>(
promise: Promise<T>, promise: Promise<T>,
onError: (e: any) => void = undefined onError: (e) => void = undefined
): UIEventSource<T> { ): UIEventSource<T> {
const src = new UIEventSource<T>(undefined) const src = new UIEventSource<T>(undefined)
promise?.then((d) => src.setData(d)) promise?.then((d) => src.setData(d))
@ -671,7 +709,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static asInt(source: UIEventSource<string>): UIEventSource<number> { public static asInt(source: UIEventSource<string>): UIEventSource<number> {
return source.sync( return source.sync(
(str) => { (str) => {
let parsed = parseInt(str) const parsed = parseInt(str)
return isNaN(parsed) ? undefined : parsed return isNaN(parsed) ? undefined : parsed
}, },
[], [],
@ -702,7 +740,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
public static asFloat(source: UIEventSource<string>): UIEventSource<number> { public static asFloat(source: UIEventSource<string>): UIEventSource<number> {
return source.sync( return source.sync(
(str) => { (str) => {
let parsed = parseFloat(str) const parsed = parseFloat(str)
return isNaN(parsed) ? undefined : parsed return isNaN(parsed) ? undefined : parsed
}, },
[], [],

View file

@ -601,6 +601,9 @@ export default class ThemeViewState implements SpecialVisualizationState {
) { ) {
return return
} }
if(document.activeElement.tagName === "button" || document.activeElement.tagName === "input"){
return
}
this.selectClosestAtCenter(0) this.selectClosestAtCenter(0)
} }
) )

View file

@ -13,7 +13,7 @@
autofocus autofocus
class="normal-background absolute top-0 right-0 flex h-screen w-full flex-col overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12" class="normal-background absolute top-0 right-0 flex h-screen w-full flex-col overflow-y-auto drop-shadow-2xl md:w-6/12 lg:w-5/12 xl:w-4/12"
role="dialog" role="dialog"
style="max-width: 100vw; max-height: 100vh" style="max-width: 100vw; max-height: 100vh; z-index: 11"
tabindex="-1" tabindex="-1"
id="modal-right" id="modal-right"
> >

View file

@ -12,11 +12,15 @@
import { ariaLabel } from "../../Utils/ariaLabel" import { ariaLabel } from "../../Utils/ariaLabel"
import { GeoLocationState } from "../../Logic/State/GeoLocationState" import { GeoLocationState } from "../../Logic/State/GeoLocationState"
import { NominatimGeocoding } from "../../Logic/Geocoding/NominatimGeocoding" import { NominatimGeocoding } from "../../Logic/Geocoding/NominatimGeocoding"
import type GeocodingProvider from "../../Logic/Geocoding/GeocodingProvider" import { GeocodingUtils } from "../../Logic/Geocoding/GeocodingProvider"
import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider" import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider"
import type GeocodingProvider from "../../Logic/Geocoding/GeocodingProvider"
import SearchResults from "./SearchResults.svelte" import SearchResults from "./SearchResults.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import MoreScreen from "./MoreScreen"
import type { MinimalLayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { focusWithArrows } from "../../Utils/focusWithArrows"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox> export let bounds: UIEventSource<BBox>
@ -82,13 +86,27 @@
return return
} }
const poi = result[0] const poi = result[0]
const [lat0, lat1, lon0, lon1] = poi.boundingbox if (poi.payload !== undefined) {
bounds.set( // This is a theme
new BBox([ const theme = <MinimalLayoutInformation>poi.payload
[lon0, lat0], const url = MoreScreen.createUrlFor(theme, false)
[lon1, lat1] console.log("Found a theme, going to", url)
]).pad(0.01) // @ts-ignore
) window.location = url
return
}
if (poi.boundingbox) {
const [lat0, lat1, lon0, lon1] = poi.boundingbox
bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1]
]).pad(0.01)
)
} else if (poi.lon && poi.lat) {
state.mapProperties.flyTo(poi.lon, poi.lat, GeocodingUtils.categoryToZoomLevel[poi.category] ?? 16)
}
if (perLayer !== undefined) { if (perLayer !== undefined) {
const id = poi.osm_type + "/" + poi.osm_id const id = poi.osm_type + "/" + poi.osm_id
const layers = Array.from(perLayer?.values() ?? []) const layers = Array.from(perLayer?.values() ?? [])
@ -107,6 +125,7 @@
} }
dispatch("searchIsValid", false) dispatch("searchIsValid", false)
dispatch("searchCompleted") dispatch("searchCompleted")
isFocused.setData(false)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
feedback = Translations.t.general.search.error.txt feedback = Translations.t.general.search.error.txt
@ -116,45 +135,73 @@
} }
} }
let suggestions: Store<{success: GeoCodeResult[]} | {error}> = searchContents.stabilized(250).bindD(search => let suggestions: Store<GeoCodeResult[]> = searchContents.stabilized(250).bindD(search => {
UIEventSource.FromPromiseWithErr(searcher.suggest(search)) if (search.length === 0) {
return undefined
}
return searcher.suggest(search, { bbox: bounds.data })
}
) )
let geosearch: HTMLDivElement
function checkFocus() {
window.requestAnimationFrame(() => {
if (geosearch.contains(document.activeElement)) {
return
}
isFocused.setData(false)
})
}
document.addEventListener("focus",() => {
checkFocus()
}, true /* use 'capturing' instead of bubbling, needed for focus-events*/)
</script> </script>
<div class="normal-background flex justify-between rounded-full pl-2 w-full"> <div bind:this={geosearch} use:focusWithArrows={"searchresult"}>
<form class="flex w-full flex-wrap">
{#if isRunning} <div class="normal-background flex justify-between rounded-full pl-2 w-full">
<Loading>{Translations.t.general.search.searching}</Loading> <form class="flex w-full flex-wrap">
{:else} {#if isRunning}
<input <Loading>{Translations.t.general.search.searching}</Loading>
type="search" {:else}
class="w-full outline-none" <input
bind:this={inputElement} type="search"
on:keypress={(keypr) => { class="w-full outline-none"
bind:this={inputElement}
on:keypress={(keypr) => {
feedback = undefined feedback = undefined
return keypr.key === "Enter" ? performSearch() : undefined if(keypr.key === "Enter"){
performSearch()
keypr.preventDefault()
}
return undefined
}} }}
on:focus={() => {isFocused.setData(true)}} on:focus={() => {isFocused.setData(true)}}
on:blur={() => {isFocused.setData(false)}} on:blur={() => {checkFocus()}}
bind:value={$searchContents} bind:value={$searchContents}
use:placeholder={Translations.t.general.search.search} use:placeholder={Translations.t.general.search.search}
use:ariaLabel={Translations.t.general.search.search} use:ariaLabel={Translations.t.general.search.search}
/> />
{#if feedback !== undefined} {#if feedback !== undefined}
<!-- The feedback is _always_ shown for screenreaders and to make sure that the searchfield can still be selected by tabbing--> <!-- The feedback is _always_ shown for screenreaders and to make sure that the searchfield can still be selected by tabbing-->
<div class="alert" role="alert" aria-live="assertive"> <div class="alert" role="alert" aria-live="assertive">
{feedback} {feedback}
</div> </div>
{/if}
{/if} {/if}
{/if} </form>
</form> <SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} />
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} /> </div>
</div>
<div class="relative h-0" style="z-index: 10"> <div class="relative h-0" style="z-index: 10">
<div class="absolute right-0 w-full sm:w-96 h-fit max-h-96">
<div class="absolute right-0" style="width: 25rem; max-width: 98vw"> <SearchResults {isFocused} {state} results={$suggestions} searchTerm={searchContents}
<SearchResults {isFocused} {state} results={$suggestions} searchTerm={searchContents} on:select={() => {searchContents.set("")}}/> on:select={() => {searchContents.set(""); isFocused.setData(false)}} />
</div> </div>
</div>
</div> </div>

View file

@ -16,14 +16,17 @@
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import { Translation } from "../i18n/Translation" import { Translation } from "../i18n/Translation"
import MoreScreen from "./MoreScreen" import MoreScreen from "./MoreScreen"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
export let entry: GeoCodeResult export let entry: GeoCodeResult
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
let layer: LayerConfig let layer: LayerConfig
let tags: UIEventSource<Record<string, string>> let tags: UIEventSource<Record<string, string>>
let descriptionTr: TagRenderingConfig = undefined
if (entry.feature?.properties?.id) { if (entry.feature?.properties?.id) {
layer = state.layout.getMatchingLayer(entry.feature.properties) layer = state.layout.getMatchingLayer(entry.feature.properties)
tags = state.featureProperties.getStore(entry.feature.properties.id) tags = state.featureProperties.getStore(entry.feature.properties.id)
descriptionTr = layer.tagRenderings.find(tr => tr.labels.indexOf("description") >= 0)
} }
let dispatch = createEventDispatcher<{ select }>() let dispatch = createEventDispatcher<{ select }>()
@ -35,7 +38,6 @@
let otherTheme: MinimalLayoutInformation | undefined = <MinimalLayoutInformation>entry.payload let otherTheme: MinimalLayoutInformation | undefined = <MinimalLayoutInformation>entry.payload
function select() { function select() {
console.log("Selected search entry", entry)
if (entry.boundingbox) { if (entry.boundingbox) {
const [lat0, lat1, lon0, lon1] = entry.boundingbox const [lat0, lat1, lon0, lon1] = entry.boundingbox
state.mapProperties.bounds.set( state.mapProperties.bounds.set(
@ -56,7 +58,8 @@
</script> </script>
{#if otherTheme} {#if otherTheme}
<a href={ MoreScreen.createUrlFor(otherTheme, false)} class="flex items-center p-2 w-full gap-y-2 rounded-xl" > <a href={ MoreScreen.createUrlFor(otherTheme, false)}
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" />
<div class="flex flex-col"> <div class="flex flex-col">
@ -68,7 +71,7 @@
</a> </a>
{:else} {:else}
<button class="unstyled w-full link-no-underline" on:click={() => select() }> <button class="unstyled w-full link-no-underline searchresult" on:click={() => select() }>
<div class="p-2 flex items-center w-full gap-y-2"> <div class="p-2 flex items-center w-full gap-y-2">
{#if layer} {#if layer}
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} /> <ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} />
@ -84,20 +87,32 @@
{entry.display_name ?? entry.osm_id} {entry.display_name ?? entry.osm_id}
{/if} {/if}
</b> </b>
<div class="flex gap-x-1 items-center"> {#if $distance > 50}
{#if $bearing && !$inView} <div class="flex gap-x-1 items-center">
<ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} /> {#if $bearing && !$inView}
{/if} <ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} />
{#if $distance} {/if}
{GeoOperations.distanceToHuman($distance)} {#if $distance}
{/if} {GeoOperations.distanceToHuman($distance)}
</div> {/if}
</div>
{/if}
</div>
<div class="flex flex-wrap gap-x-2">
{#if descriptionTr}
<TagRenderingAnswer defaultSize="subtle" noIcons={true} config={descriptionTr} {tags} {state}
selectedElement={entry.feature} {layer} />
{/if}
{#if descriptionTr && entry.description}
{/if}
{#if entry.description}
<div class="subtle flex justify-between w-full">
{entry.description}
</div>
{/if}
</div> </div>
{#if entry.description}
<div class="subtle flex justify-between w-full">
{entry.description}
</div>
{/if}
</div> </div>
</div> </div>

View file

@ -2,15 +2,15 @@
import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider" import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider"
import SearchResult from "./SearchResult.svelte" import SearchResult from "./SearchResult.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import { XMarkIcon } from "@babeard/svelte-heroicons/solid"
import { Store, UIEventSource } from "../../Logic/UIEventSource" import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import MoreScreen from "./MoreScreen" import MoreScreen from "./MoreScreen"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let results: { success: GeoCodeResult[] } | { error } export let results: GeoCodeResult[]
export let searchTerm: Store<string> export let searchTerm: Store<string>
export let isFocused: UIEventSource<boolean> export let isFocused: UIEventSource<boolean>
@ -19,63 +19,59 @@
let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview let allowOtherThemes = state.featureSwitches.featureSwitchBackToThemeOverview
</script> </script>
<div class="w-full collapsable" style="height: 50rem;" class:collapsed={!$isFocused}> <div class="relative w-full h-full collapsable " class:collapsed={!$isFocused}>
{#if results?.["error"] !== undefined} <button class="absolute right-0 top-0 border-none p-0" on:click={() => isFocused.setData(false)} tabindex="-1">
<div class="searchbox normal-background items-center"> <XCircleIcon class="w-6 h-6" />
An error occured </button>
</div>
{:else if $searchTerm.length > 0 && results === undefined} <div class="searchbox normal-background">
<div class="searchbox normal-background items-center">
<Loading />
</div>
{:else if results?.["success"]?.length > 0}
<div class="relative w-full h-full">
<div class="absolute top-0 right-0 searchbox normal-background"
style="width: 25rem">
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto">
{#each results["success"] as entry (entry)} {#if $searchTerm.length > 0 && results === undefined}
<SearchResult on:select {entry} {state} /> <div class="flex justify-center m-4 my-8">
{/each} <Loading />
</div>
</div> </div>
{:else if results?.length > 0}
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto p-2" tabindex="-1">
{#each results as entry (entry)}
<div class="absolute top-2 right-2 cursor-pointer" on:click={() => isFocused.setData(false)}> <SearchResult on:select {entry} {state} />
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" /> {/each}
</div> </div>
</div> {:else if $searchTerm.length > 0 || $recentlySeen?.length > 0 || $recentThemes?.length > 0}
{:else if $searchTerm.length > 0 || $recentlySeen?.length > 0 || $recentThemes?.length > 0} <div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto p-2 flex flex-col gap-y-8" tabindex="-1">
{#if $searchTerm.length > 0}
<b class="flex justify-center p-4">
<Tr t={Translations.t.general.search.nothingFor.Subs({term: $searchTerm})} />
</b>
{/if}
<div class="searchbox normal-background overflow-y-auto h-full"> {#if $recentlySeen?.length > 0}
{#if $searchTerm.length > 0} <div>
<b class="flex justify-center p-4"> <h3 class="m-2">
<Tr t={Translations.t.general.search.nothingFor.Subs({term: $searchTerm})} /> <Tr t={Translations.t.general.search.recents} />
</b> </h3>
{/if} {#each $recentlySeen as entry}
<SearchResult {entry} {state} on:select />
{/each}
</div>
{/if}
{#if $recentlySeen?.length > 0} {#if $recentThemes?.length > 0 && $allowOtherThemes}
<h3 class="mx-2"> <div>
<Tr t={Translations.t.general.search.recents} /> <h3 class="m-2">
</h3> <Tr t={Translations.t.general.search.recentThemes} />
{#each $recentlySeen as entry} </h3>
<SearchResult {entry} {state} on:select /> {#each $recentThemes as themeId (themeId)}
{/each} <SearchResult
{/if} entry={{payload: MoreScreen.officialThemesById.get(themeId), display_name: themeId, lat: 0, lon: 0}}
{state}
{#if $recentThemes?.length > 0 && $allowOtherThemes} on:select />
<h3 class="mx-2"> {/each}
<Tr t={Translations.t.general.search.recentThemes} /> </div>
</h3> {/if}
{#each $recentThemes as themeId (themeId)} </div>
<SearchResult {/if}
entry={{payload: MoreScreen.officialThemesById.get(themeId), display_name: themeId, lat: 0, lon: 0}} {state} </div>
on:select />
{/each}
{/if}
</div>
{/if}
</div> </div>
<style> <style>
@ -91,7 +87,7 @@
.collapsable { .collapsable {
max-height: 50vh; max-height: 50vh;
transition: max-height 400ms linear; transition: max-height 400ms linear;
transition-delay: 500ms; transition-delay: 100ms;
overflow: hidden; overflow: hidden;
padding: 0 !important; padding: 0 !important;
} }

View file

@ -16,6 +16,8 @@
export let layer: LayerConfig export let layer: LayerConfig
export let config: TagRenderingConfig export let config: TagRenderingConfig
export let extraClasses: string | undefined = undefined export let extraClasses: string | undefined = undefined
export let defaultSize = "w-full"
export let noIcons = false
export let id: string = undefined export let id: string = undefined
@ -28,7 +30,7 @@
</script> </script>
{#if config !== undefined && (config?.condition === undefined || config.condition.matchesProperties($tags))} {#if config !== undefined && (config?.condition === undefined || config.condition.matchesProperties($tags))}
<div {id} class={twMerge("link-underline flex h-full w-full flex-col", extraClasses)}> <div {id} class={twMerge("link-underline flex h-full flex-col", defaultSize, extraClasses)}>
{#if $trs.length === 1} {#if $trs.length === 1}
<TagRenderingMapping <TagRenderingMapping
mapping={$trs[0]} mapping={$trs[0]}
@ -37,13 +39,14 @@
{selectedElement} {selectedElement}
{layer} {layer}
clss={config?.classes?.join(" ") ?? ""} clss={config?.classes?.join(" ") ?? ""}
{noIcons}
/> />
{/if} {/if}
{#if $trs.length > 1} {#if $trs.length > 1}
<ul> <ul>
{#each $trs as mapping} {#each $trs as mapping}
<li> <li>
<TagRenderingMapping {mapping} {tags} {state} {selectedElement} {layer} /> <TagRenderingMapping {mapping} {tags} {state} {selectedElement} {layer} {noIcons}/>
</li> </li>
{/each} {/each}
</ul> </ul>

View file

@ -12,6 +12,7 @@
export let tags: UIEventSource<Record<string, string>> export let tags: UIEventSource<Record<string, string>>
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let layer: LayerConfig export let layer: LayerConfig
export let noIcons = false
/** /**
* Css classes to apply * Css classes to apply
@ -32,7 +33,7 @@
} }
</script> </script>
{#if mapping.icon !== undefined} {#if mapping.icon !== undefined && !noIcons}
<div class="inline-flex items-center"> <div class="inline-flex items-center">
<Marker <Marker
icons={mapping.icon} icons={mapping.icon}

View file

@ -0,0 +1,21 @@
export function focusWithArrows(htmlElement: HTMLDivElement, classname: string) {
const destroy: () => void = undefined
htmlElement.addEventListener("keyup", e => {
const currentElement = document.activeElement
const canBeSelected = <HTMLElement[]> Array.from(htmlElement.getElementsByClassName(classname))
const i = canBeSelected.findIndex(el => el.contains(currentElement) || el === currentElement)
const l = canBeSelected.length
if (e.key === "ArrowDown") {
canBeSelected.at((i + 1) % l).focus()
return
}
if (e.key === "ArrowUp") {
canBeSelected.at((i - 1) ).focus()
return
}
})
return { destroy }
}