forked from MapComplete/MapComplete
UX: more work on a search function
This commit is contained in:
parent
3cd04df60b
commit
00ad21d5ef
30 changed files with 636 additions and 138 deletions
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
137
src/Logic/Geocoding/PhotonSearch.ts
Normal file
137
src/Logic/Geocoding/PhotonSearch.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
49
src/Logic/Geocoding/RecentSearch.ts
Normal file
49
src/Logic/Geocoding/RecentSearch.ts
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue