UX: more work on a search function

This commit is contained in:
Pieter Vander Vennet 2024-08-21 14:06:42 +02:00
parent 3cd04df60b
commit 00ad21d5ef
30 changed files with 636 additions and 138 deletions

View file

@ -6,7 +6,7 @@ import { Feature, Polygon } from "geojson"
export class BBox {
static global: BBox = new BBox([
[-180, -90],
[180, 90],
[180, 90]
])
readonly maxLat: number
readonly maxLon: number
@ -53,7 +53,7 @@ export class BBox {
static fromLeafletBounds(bounds) {
return new BBox([
[bounds.getWest(), bounds.getNorth()],
[bounds.getEast(), bounds.getSouth()],
[bounds.getEast(), bounds.getSouth()]
])
}
@ -74,7 +74,7 @@ export class BBox {
// Note: x is longitude
f["bbox"] = new BBox([
[minX, minY],
[maxX, maxY],
[maxX, maxY]
])
}
return f["bbox"]
@ -94,7 +94,7 @@ export class BBox {
}
return new BBox([
[maxLon, maxLat],
[minLon, minLat],
[minLon, minLat]
])
}
@ -121,7 +121,7 @@ export class BBox {
public unionWith(other: BBox) {
return new BBox([
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)]
])
}
@ -174,7 +174,7 @@ export class BBox {
return new BBox([
[lon - s / 2, lat - s / 2],
[lon + s / 2, lat + s / 2],
[lon + s / 2, lat + s / 2]
])
}
@ -231,21 +231,21 @@ export class BBox {
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
return new BBox([
[this.minLon - lonDiff, this.minLat - latDiff],
[this.maxLon + lonDiff, this.maxLat + latDiff],
[this.maxLon + lonDiff, this.maxLat + latDiff]
])
}
padAbsolute(degrees: number): BBox {
return new BBox([
[this.minLon - degrees, this.minLat - degrees],
[this.maxLon + degrees, this.maxLat + degrees],
[this.maxLon + degrees, this.maxLat + degrees]
])
}
toLngLat(): [[number, number], [number, number]] {
return [
[this.minLon, this.minLat],
[this.maxLon, this.maxLat],
[this.maxLon, this.maxLat]
]
}
@ -260,7 +260,7 @@ export class BBox {
return {
type: "Feature",
properties: properties,
geometry: this.asGeometry(),
geometry: this.asGeometry()
}
}
@ -273,9 +273,9 @@ export class BBox {
[this.maxLon, this.minLat],
[this.maxLon, this.maxLat],
[this.minLon, this.maxLat],
[this.minLon, this.minLat],
],
],
[this.minLon, this.minLat]
]
]
}
}
@ -302,7 +302,7 @@ export class BBox {
minLon,
maxLon,
minLat,
maxLat,
maxLat
}
}
@ -316,4 +316,8 @@ export class BBox {
public overlapsWithFeature(f: Feature) {
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
}
center() {
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2]
}
}

View file

@ -908,7 +908,7 @@ export class GeoOperations {
}
/**
* GeoOperations.distanceToHuman(52.8) // => "53m"
* GeoOperations.distanceToHuman(52.8) // => "50m"
* GeoOperations.distanceToHuman(2800) // => "2.8km"
* GeoOperations.distanceToHuman(12800) // => "13km"
*
@ -920,11 +920,11 @@ export class GeoOperations {
}
meters = Math.round(meters)
if (meters < 1000) {
return meters + "m"
return Utils.roundHuman(meters) + "m"
}
if (meters >= 10000) {
const km = Math.round(meters / 1000)
const km = Utils.roundHuman(meters / 1000)
return km + "km"
}

View file

@ -9,13 +9,35 @@ export default class CombinedSearcher implements GeocodingProvider {
this._providersWithSuggest = providers.filter(pr => pr.suggest !== undefined)
}
/**
* Merges the geocode-results from various sources.
* If the same osm-id is mentioned multiple times, only the first result will be kept
* @param geocoded
* @private
*/
private merge(geocoded: GeoCodeResult[][]): GeoCodeResult[]{
const results : GeoCodeResult[] = []
const seenIds = new Set<string>()
for (const geocodedElement of geocoded) {
for (const entry of geocodedElement) {
const id = entry.osm_type+ entry.osm_id
if(seenIds.has(id)){
continue
}
seenIds.add(id)
results.push(entry)
}
}
return results
}
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
const results = await Promise.all(this._providers.map(pr => pr.search(query, options)))
return results.flatMap(x => x)
return this.merge(results)
}
async suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
const results = await Promise.all(this._providersWithSuggest.map(pr => pr.suggest(query, options)))
return results.flatMap(x => x)
return this.merge(results)
}
}

View file

@ -44,7 +44,8 @@ export default class CoordinateSearch implements GeocodingProvider {
lat: Number(m[1]),
lon: Number(m[2]),
display_name: "lon: " + m[2] + ", lat: " + m[1],
source: "coordinateSearch"
source: "coordinateSearch",
category: "coordinate"
})
@ -54,7 +55,8 @@ export default class CoordinateSearch implements GeocodingProvider {
lat: Number(m[2]),
lon: Number(m[1]),
display_name: "lon: " + m[1] + ", lat: " + m[2],
source: "coordinateSearch"
source: "coordinateSearch",
category: "coordinate"
})
return matches.concat(matchesLonLat)

View file

@ -1,8 +1,18 @@
import { BBox } from "../BBox"
import { Feature, FeatureCollection } from "geojson"
import { Feature, Geometry } from "geojson"
import { DefaultPinIcon } from "../../Models/Constants"
export type GeocodingCategory = "coordinate" | "city" | "house" | "street" | "locality" | "country" | "train_station" | "county" | "airport"
export type GeoCodeResult = {
/**
* The name of the feature being displayed
*/
display_name: string
/**
* Some optional, extra information
*/
description?: string | Promise<string>,
feature?: Feature,
lat: number
lon: number
@ -12,7 +22,9 @@ export type GeoCodeResult = {
*/
boundingbox?: number[]
osm_type?: "node" | "way" | "relation"
osm_id?: string
osm_id?: string,
category?: GeocodingCategory,
importance?: number
}
export interface GeocodingOptions {
@ -33,11 +45,52 @@ export default interface GeocodingProvider {
suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]>
}
export type ReverseGeocodingResult = Feature<Geometry,{
osm_id: number,
osm_type: "node" | "way" | "relation",
country: string,
city: string,
countrycode: string,
type: GeocodingCategory,
street: string
} >
export interface ReverseGeocodingProvider {
reverseSearch(
coordinate: { lon: number; lat: number },
zoom: number,
language?: string
): Promise<FeatureCollection> ;
): Promise<ReverseGeocodingResult[]> ;
}
export class GeocodingUtils {
public static categoryToZoomLevel: Record<GeocodingCategory, number> = {
city: 12,
county: 10,
coordinate: 16,
country: 8,
house: 16,
locality: 14,
street: 15,
train_station: 14,
airport: 13
}
public static categoryToIcon: Record<GeocodingCategory, DefaultPinIcon> = {
city: "building_office_2",
coordinate: "globe_alt",
country: "globe_alt",
house: "house",
locality: "building_office_2",
street: "globe_alt",
train_station: "train",
county: "building_office_2",
airport: "airport"
}
}

View file

@ -6,9 +6,11 @@ import { GeoOperations } from "../GeoOperations"
export default class LocalElementSearch implements GeocodingProvider {
private readonly _state: ThemeViewState
private readonly _limit: number
constructor(state: ThemeViewState) {
constructor(state: ThemeViewState, limit: number) {
this._state = state
this._limit = limit
}
@ -30,7 +32,8 @@ export default class LocalElementSearch implements GeocodingProvider {
center: [number, number],
levehnsteinD: number,
physicalDistance: number,
searchTerms: string[]
searchTerms: string[],
description: string
}[] = []
const properties = this._state.perLayer
query = Utils.simplifyStringForSearch(query)
@ -51,19 +54,29 @@ export default class LocalElementSearch implements GeocodingProvider {
}))
const center = GeoOperations.centerpointCoordinates(feature)
if (levehnsteinD <= 2) {
let description = ""
function ifDef(prefix: string, key: string){
if(feature.properties[key]){
description += prefix+ feature.properties[key]
}
}
ifDef("", "addr:street")
ifDef(" ", "addr:housenumber")
results.push({
feature,
center,
physicalDistance: GeoOperations.distanceBetween(centerPoint, center),
levehnsteinD,
searchTerms
searchTerms,
description: description !== "" ? description : undefined
})
}
}
}
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
if (options?.limit) {
results = results.slice(0, options.limit)
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("/")
@ -74,7 +87,9 @@ export default class LocalElementSearch implements GeocodingProvider {
osm_id: id[1],
display_name: entry.searchTerms[0],
source: "localElementSearch",
feature: entry.feature
feature: entry.feature,
importance: 1,
description: entry.description
}
})
}

View file

@ -0,0 +1,137 @@
import Constants from "../../Models/Constants"
import GeocodingProvider, {
GeoCodeResult, GeocodingCategory,
GeocodingOptions,
ReverseGeocodingProvider,
ReverseGeocodingResult
} from "./GeocodingProvider"
import { Utils } from "../../Utils"
import { Feature, FeatureCollection } from "geojson"
import Locale from "../../UI/i18n/Locale"
import { GeoOperations } from "../GeoOperations"
export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider {
private _endpoint: string
private supportedLanguages = ["en", "de", "fr"]
private static readonly types = {
"R": "relation",
"W": "way",
"N": "node"
}
constructor(endpoint?: string) {
this._endpoint = endpoint ?? Constants.photonEndpoint ?? "https://photon.komoot.io/"
}
async reverseSearch(coordinate: {
lon: number;
lat: number
}, zoom: number, language?: string): Promise<ReverseGeocodingResult[]> {
const url = `${this._endpoint}/reverse?lon=${coordinate.lon}&lat=${coordinate.lat}&${this.getLanguage(language)}`
const result = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
for (const f of result.features) {
f.properties.osm_type = PhotonSearch.types[f.properties.osm_type]
}
return <ReverseGeocodingResult[]>result.features
}
/**
* Gets a `&lang=en` if the current/requested language is supported
* @param language
* @private
*/
private getLanguage(language?: string): string {
language ??= Locale.language.data
if (this.supportedLanguages.indexOf(language) < 0) {
return ""
}
return `&lang=${language}`
}
search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
return this.suggest(query, options)
}
private buildDescription(entry: Feature) {
const p = entry.properties
const type = <GeocodingCategory>p.type
function ifdef(prefix: string, str: string) {
if (str) {
return prefix + str
}
return ""
}
switch (type) {
case "house": {
const addr = ifdef("", p.street) + ifdef(" ", p.housenumber)
if(!addr){
return p.city
}
return addr + ifdef(", ", p.city)
}
case "coordinate":
case "street":
return p.city ?? p.country
case "city":
case "locality":
if(p.state){
return p.state + ifdef(", ", p.country)
}
return p.country
case "country":
return undefined
}
}
private getCategory(entry: Feature){
const p = entry.properties
if(p.osm_value === "train_station" || p.osm_key === "railway"){
return "train_station"
}
if(p.osm_value === "aerodrome" || p.osm_key === "aeroway"){
return "airport"
}
return p.type
}
async suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
if (query.length < 3) {
return []
}
const limit = options?.limit ?? 5
let bbox = ""
if (options?.bbox) {
const [lon, lat] = options.bbox.center()
bbox = `&lon=${lon}&lat=${lat}`
}
const url = `${this._endpoint}/api/?q=${encodeURIComponent(query)}&limit=${limit}${this.getLanguage()}${bbox}`
const results = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
return results.features.map(f => {
const [lon, lat] = GeoOperations.centerpointCoordinates(f)
let boundingbox: number[] = undefined
if (f.properties.extent) {
const [lon0, lat0, lon1, lat1] = f.properties.extent
boundingbox = [lat0, lat1, lon0, lon1]
}
return <GeoCodeResult>{
feature: f,
osm_id: f.properties.osm_id,
display_name: f.properties.name,
description: this.buildDescription(f),
osm_type: PhotonSearch.types[f.properties.osm_type],
category: this.getCategory(f),
boundingbox,
lon, lat
}
})
}
}

View file

@ -0,0 +1,49 @@
import { Store, UIEventSource } from "../UIEventSource"
import { Feature } from "geojson"
import { OsmConnection } from "../Osm/OsmConnection"
import { GeoCodeResult } from "./GeocodingProvider"
import { GeoOperations } from "../GeoOperations"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export class RecentSearch {
private readonly _recentSearches: UIEventSource<string[]>
public readonly recentSearches: Store<string[]>
private readonly _seenThisSession: UIEventSource<GeoCodeResult[]> = new UIEventSource<GeoCodeResult[]>([])
public readonly seenThisSession: Store<GeoCodeResult[]> = this._seenThisSession
constructor(state: { layout: LayoutConfig, osmConnection: OsmConnection, selectedElement: Store<Feature> }) {
const longPref = state.osmConnection.preferencesHandler.GetLongPreference("recent-searches")
this._recentSearches = longPref.sync(str => !str ? [] : <string[]>JSON.parse(str), [], strs => JSON.stringify(strs))
this.recentSearches = this._recentSearches
state.selectedElement.addCallbackAndRunD(selected => {
const [osm_type, osm_id] = selected.properties.id.split("/")
const [lon, lat] = GeoOperations.centerpointCoordinates(selected)
const entry = <GeoCodeResult> {
feature: selected,
osm_id, osm_type,
description: "Viewed recently",
lon, lat
}
this.addSelected(entry)
})
}
addSelected(entry: GeoCodeResult) {
const arr = [...this.seenThisSession.data.slice(0, 20), entry]
const seenIds = new Set<string>()
for (let i = arr.length - 1; i >= 0; i--) {
const id = arr[i].osm_type + arr[i].osm_id
if (seenIds.has(id)) {
arr.splice(i, 1)
} else {
seenIds.add(id)
}
}
this._seenThisSession.set(arr)
}
}

View file

@ -424,9 +424,11 @@ export default class MetaTagging {
}
}
console.warn(
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)
if (!window.location.pathname.endsWith("theme.html")) {
console.warn(
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
)
}
const calculatedTags: [string, string, boolean][] = layer?.calculatedTags ?? []
if (calculatedTags === undefined || calculatedTags.length === 0) {

View file

@ -617,7 +617,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
}
/**
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
* Converts a promise into a UIventsource, sets the UIeventSource when the result is calculated.
* If the promise fails, the value will stay undefined, but 'onError' will be called
*/
public static FromPromise<T>(