From b8b4cd5d99f53dfd3c3048b1a4992bb8d3a07987 Mon Sep 17 00:00:00 2001 From: Peter Date: Mon, 12 Aug 2024 17:02:15 +0200 Subject: [PATCH] poi search (#394) * poi search on every key stroke * new autocomplete item, better usability * fix poi search without location keywords * proper marker Select * change scale, not color * keyboard support * show popup and POI being used in route * do not zoom if no location; fix bbox if single point; minor layout fix * translation; move AddressParseResult into separate class and inject distanceFormat to reduce jest problems * fetch more information from OSM * minor fixes * use overpass instead osm * fetch on click; more i18n; minor fixes * reorder * one more exclusion * added a few more POIs * photon does not know public_transport:station (?) * rename * use overpass API * use out center and let overpass do the work; fix a few bugs * translate 'nearby' * make 'AND NOT' possible to exclude military airports * fix a few POI queries and i18n * allow generic query in simplified 'photon' format * workaround for initial search * bug fix in merge * support simple 'or' and 'and' queries with 'not'. use more overpass wizard query style * special case for all value query * avoid POI layer setup in most cases * minor cosmetics * simplify and avoid hack. UX is more clear too. * formatting * minor fix --- README.md | 1 + package-lock.json | 24 +- package.json | 2 +- src/App.tsx | 22 +- src/Converters.ts | 5 +- src/NavBar.ts | 44 ++- src/actions/Actions.ts | 17 ++ src/api/Api.ts | 88 ++++++ src/api/graphhopper.d.ts | 15 +- src/index.tsx | 11 +- src/layers/MapFeaturePopup.module.css | 64 +++++ src/layers/POIPopup.tsx | 110 ++++++++ src/layers/UsePOIsLayer.tsx | 145 ++++++++++ src/layers/createMarkerSVG.ts | 14 + src/map/MapPopups.tsx | 8 +- src/pois/AddressParseResult.ts | 250 ++++++++++++++++++ src/pois/img/charger.svg | 1 + src/pois/img/flight_takeoff.svg | 3 + src/pois/img/home_and_garden.svg | 1 + src/pois/img/hotel.svg | 3 + src/pois/img/local_atm.svg | 1 + src/pois/img/local_gas_station.svg | 1 + src/pois/img/local_hospital.svg | 3 + src/pois/img/local_parking.svg | 3 + src/pois/img/local_pharmacy.svg | 3 + src/pois/img/local_post_office.svg | 1 + src/pois/img/luggage.svg | 3 + src/pois/img/museum.svg | 3 + src/pois/img/police.svg | 1 + src/pois/img/restaurant.svg | 3 + src/pois/img/school.svg | 3 + src/pois/img/sports_handball.svg | 3 + src/pois/img/store.svg | 3 + src/pois/img/train.svg | 3 + src/pois/img/universal_currency_alt.svg | 3 + src/pois/img/water_drop.svg | 1 + src/sidebar/MobileSidebar.tsx | 8 +- src/sidebar/RoutingResults.tsx | 1 + src/sidebar/search/AddressInput.tsx | 122 ++++++++- .../AddressInputAutocomplete.module.css | 13 + .../search/AddressInputAutocomplete.tsx | 32 ++- src/sidebar/search/Search.tsx | 9 +- src/stores/POIsStore.ts | 43 +++ src/stores/Stores.ts | 5 + test/DummyApi.ts | 15 +- test/NavBar.test.ts | 2 +- test/poi/AddressParseResult.test.ts | 62 +++++ test/routing/Api.test.ts | 2 +- test/stores/QueryStore.test.ts | 16 +- test/stores/RouteStore.test.ts | 17 +- 50 files changed, 1142 insertions(+), 71 deletions(-) create mode 100644 src/layers/POIPopup.tsx create mode 100644 src/layers/UsePOIsLayer.tsx create mode 100644 src/pois/AddressParseResult.ts create mode 100644 src/pois/img/charger.svg create mode 100644 src/pois/img/flight_takeoff.svg create mode 100644 src/pois/img/home_and_garden.svg create mode 100644 src/pois/img/hotel.svg create mode 100644 src/pois/img/local_atm.svg create mode 100644 src/pois/img/local_gas_station.svg create mode 100644 src/pois/img/local_hospital.svg create mode 100644 src/pois/img/local_parking.svg create mode 100644 src/pois/img/local_pharmacy.svg create mode 100644 src/pois/img/local_post_office.svg create mode 100644 src/pois/img/luggage.svg create mode 100644 src/pois/img/museum.svg create mode 100644 src/pois/img/police.svg create mode 100644 src/pois/img/restaurant.svg create mode 100644 src/pois/img/school.svg create mode 100644 src/pois/img/sports_handball.svg create mode 100644 src/pois/img/store.svg create mode 100644 src/pois/img/train.svg create mode 100644 src/pois/img/universal_currency_alt.svg create mode 100644 src/pois/img/water_drop.svg create mode 100644 src/stores/POIsStore.ts create mode 100644 test/poi/AddressParseResult.test.ts diff --git a/README.md b/README.md index e5832f63..0d3dfcc3 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,4 @@ This project uses * the [codemirror](https://codemirror.net/) code editor for the custom model editor. * many icons from Google's [open source font library](https://fonts.google.com/icons). * many more open source projects - see the package.json + diff --git a/package-lock.json b/package-lock.json index 82aec628..5c71ee94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "prettier": "2.8.8", "source-map-loader": "^4.0.0", "style-loader": "^3.3.1", - "ts-jest": "^29.0.5", + "ts-jest": "^29.1.4", "ts-loader": "^9.4.2", "typescript": "^5.1.3", "webpack": "^5.75.0", @@ -11954,9 +11954,9 @@ } }, "node_modules/ts-jest": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.0.tgz", - "integrity": "sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==", + "version": "29.1.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", + "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "dev": true, "dependencies": { "bs-logger": "0.x", @@ -11965,17 +11965,18 @@ "json5": "^2.2.3", "lodash.memoize": "4.x", "make-error": "1.x", - "semver": "7.x", + "semver": "^7.5.3", "yargs-parser": "^21.0.1" }, "bin": { "ts-jest": "cli.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", "@jest/types": "^29.0.0", "babel-jest": "^29.0.0", "jest": "^29.0.0", @@ -11985,6 +11986,9 @@ "@babel/core": { "optional": true }, + "@jest/transform": { + "optional": true + }, "@jest/types": { "optional": true }, @@ -21859,9 +21863,9 @@ } }, "ts-jest": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.0.tgz", - "integrity": "sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==", + "version": "29.1.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", + "integrity": "sha512-YiHwDhSvCiItoAgsKtoLFCuakDzDsJ1DLDnSouTaTmdOcOwIkSzbLXduaQ6M5DRVhuZC/NYaaZ/mtHbWMv/S6Q==", "dev": true, "requires": { "bs-logger": "0.x", @@ -21870,7 +21874,7 @@ "json5": "^2.2.3", "lodash.memoize": "4.x", "make-error": "1.x", - "semver": "7.x", + "semver": "^7.5.3", "yargs-parser": "^21.0.1" }, "dependencies": { diff --git a/package.json b/package.json index 1f8a0d63..f08fc0ee 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "prettier": "2.8.8", "source-map-loader": "^4.0.0", "style-loader": "^3.3.1", - "ts-jest": "^29.0.5", + "ts-jest": "^29.1.4", "ts-loader": "^9.4.2", "typescript": "^5.1.3", "webpack": "^5.75.0", diff --git a/src/App.tsx b/src/App.tsx index 40acc8bb..1fdf6098 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { getMapFeatureStore, getMapOptionsStore, getPathDetailsStore, + getPOIsStore, getQueryStore, getRouteStore, getSettingsStore, @@ -42,7 +43,8 @@ import PlainButton from '@/PlainButton' import useAreasLayer from '@/layers/UseAreasLayer' import useExternalMVTLayer from '@/layers/UseExternalMVTLayer' import LocationButton from '@/map/LocationButton' -import { SettingsContext } from './contexts/SettingsContext' +import { SettingsContext } from '@/contexts/SettingsContext' +import usePOIsLayer from '@/layers/UsePOIsLayer' export const POPUP_CONTAINER_ID = 'popup-container' export const SIDEBAR_CONTENT_ID = 'sidebar-content' @@ -56,6 +58,7 @@ export default function App() { const [mapOptions, setMapOptions] = useState(getMapOptionsStore().state) const [pathDetails, setPathDetails] = useState(getPathDetailsStore().state) const [mapFeatures, setMapFeatures] = useState(getMapFeatureStore().state) + const [pois, setPOIs] = useState(getPOIsStore().state) const map = getMap() @@ -68,6 +71,7 @@ export default function App() { const onMapOptionsChanged = () => setMapOptions(getMapOptionsStore().state) const onPathDetailsChanged = () => setPathDetails(getPathDetailsStore().state) const onMapFeaturesChanged = () => setMapFeatures(getMapFeatureStore().state) + const onPOIsChanged = () => setPOIs(getPOIsStore().state) getSettingsStore().register(onSettingsChanged) getQueryStore().register(onQueryChanged) @@ -77,6 +81,7 @@ export default function App() { getMapOptionsStore().register(onMapOptionsChanged) getPathDetailsStore().register(onPathDetailsChanged) getMapFeatureStore().register(onMapFeaturesChanged) + getPOIsStore().register(onPOIsChanged) onQueryChanged() onInfoChanged() @@ -85,6 +90,7 @@ export default function App() { onMapOptionsChanged() onPathDetailsChanged() onMapFeaturesChanged() + onPOIsChanged() return () => { getSettingsStore().register(onSettingsChanged) @@ -95,6 +101,7 @@ export default function App() { getMapOptionsStore().deregister(onMapOptionsChanged) getPathDetailsStore().deregister(onPathDetailsChanged) getMapFeatureStore().deregister(onMapFeaturesChanged) + getPOIsStore().deregister(onPOIsChanged) } }, []) @@ -108,11 +115,19 @@ export default function App() { usePathsLayer(map, route.routingResult.paths, route.selectedPath, query.queryPoints) useQueryPointsLayer(map, query.queryPoints) usePathDetailsLayer(map, pathDetails) + usePOIsLayer(map, pois) + const isSmallScreen = useMediaQuery({ query: '(max-width: 44rem)' }) return (
- + {isSmallScreen ? ( )} - +
{!error.isDismissed && }
diff --git a/src/Converters.ts b/src/Converters.ts index 361c7e46..6d0e1be4 100644 --- a/src/Converters.ts +++ b/src/Converters.ts @@ -12,7 +12,10 @@ export function milliSecondsToText(ms: number) { return (hourText ? hourText + ' ' : '') + minutes + ' min' } -const distanceFormat = new Intl.NumberFormat(navigator.language, { maximumFractionDigits: 1 }) +let distanceFormat: Intl.NumberFormat = new Intl.NumberFormat('en', { maximumFractionDigits: 1 }) +export function setDistanceFormat(_distanceFormat: Intl.NumberFormat) { + distanceFormat = _distanceFormat +} export function metersToText(meters: number, showDistanceInMiles: boolean, forceSmallUnits: boolean = false) { if (showDistanceInMiles) { diff --git a/src/NavBar.ts b/src/NavBar.ts index f1d9d4f3..a55b6e73 100644 --- a/src/NavBar.ts +++ b/src/NavBar.ts @@ -1,18 +1,13 @@ import { coordinateToText } from '@/Converters' -import { Bbox } from '@/api/graphhopper' import Dispatcher from '@/stores/Dispatcher' import { ClearPoints, SelectMapLayer, SetBBox, SetQueryPoints, SetVehicleProfile } from '@/actions/Actions' // import the window like this so that it can be mocked during testing import { window } from '@/Window' -import QueryStore, { - Coordinate, - getBBoxFromCoord, - QueryPoint, - QueryPointType, - QueryStoreState, -} from '@/stores/QueryStore' +import QueryStore, { getBBoxFromCoord, QueryPoint, QueryPointType, QueryStoreState } from '@/stores/QueryStore' import MapOptionsStore, { MapOptionsStoreState } from './stores/MapOptionsStore' -import { getApi } from '@/api/Api' +import { ApiImpl, getApi } from '@/api/Api' +import { AddressParseResult } from '@/pois/AddressParseResult' +import { getQueryStore } from '@/stores/Stores' export default class NavBar { private readonly queryStore: QueryStore @@ -116,6 +111,19 @@ export default class NavBar { if (parsedPoints.some(p => !p.isInitialized && p.queryText.length > 0)) { const promises = parsedPoints.map(p => { if (p.isInitialized) return Promise.resolve(p) + const result = AddressParseResult.parse(p.queryText, false) + if (result.hasPOIs() && result.location) { + // two stage POI search: 1. use extracted location to get coordinates 2. do reverse geocoding with this coordinates + return getApi() + .geocode(result.location, 'nominatim') + .then(res => { + if (res.hits.length == 0) return p + getApi() + .reverseGeocode(result.query, res.hits[0].extent) + .then(res => AddressParseResult.handleGeocodingResponse(res, result)) + return p + }) + } return ( getApi() .geocode(p.queryText, 'nominatim') @@ -129,7 +137,7 @@ export default class NavBar { } }) // if the geocoding request fails we just keep the point as it is, just as if no results were found - .catch(() => Promise.resolve(p)) + .catch(() => p) ) }) const points = await Promise.all(promises) @@ -151,7 +159,7 @@ export default class NavBar { const bbox = initializedPoints.length == 1 ? getBBoxFromCoord(initializedPoints[0].coordinate) - : NavBar.getBBoxFromUrlPoints(initializedPoints.map(p => p.coordinate)) + : ApiImpl.getBBoxPoints(initializedPoints.map(p => p.coordinate)) if (bbox) Dispatcher.dispatch(new SetBBox(bbox)) return Dispatcher.dispatch(new SetQueryPoints(points)) } @@ -169,18 +177,4 @@ export default class NavBar { this.mapStore.state ).toString() } - - private static getBBoxFromUrlPoints(urlPoints: Coordinate[]): Bbox | null { - const bbox: Bbox = urlPoints.reduce( - (res: Bbox, c) => [ - Math.min(res[0], c.lng), - Math.min(res[1], c.lat), - Math.max(res[2], c.lng), - Math.max(res[3], c.lat), - ], - [180, 90, -180, -90] as Bbox - ) - // return null if the bbox is not valid, e.g. if no url points were given at all - return bbox[0] < bbox[2] && bbox[1] < bbox[3] ? bbox : null - } } diff --git a/src/actions/Actions.ts b/src/actions/Actions.ts index 1a0f0a5a..73e2c42d 100644 --- a/src/actions/Actions.ts +++ b/src/actions/Actions.ts @@ -2,6 +2,7 @@ import { Action } from '@/stores/Dispatcher' import { Coordinate, QueryPoint } from '@/stores/QueryStore' import { ApiInfo, Bbox, Path, RoutingArgs, RoutingProfile, RoutingResult } from '@/api/graphhopper' import { PathDetailsPoint } from '@/stores/PathDetailsStore' +import { POI } from '@/stores/POIsStore' import { Settings } from '@/stores/SettingsStore' export class InfoReceived implements Action { @@ -246,3 +247,19 @@ export class UpdateSettings implements Action { this.updatedSettings = updatedSettings } } + +export class SelectPOI implements Action { + readonly selected: POI | null + + constructor(selected: POI | null) { + this.selected = selected + } +} + +export class SetPOIs implements Action { + readonly pois: POI[] + + constructor(pois: POI[]) { + this.pois = pois + } +} diff --git a/src/api/Api.ts b/src/api/Api.ts index 9b450eee..f43aab28 100644 --- a/src/api/Api.ts +++ b/src/api/Api.ts @@ -8,6 +8,7 @@ import { Path, RawPath, RawResult, + ReverseGeocodingHit, RoutingArgs, RoutingProfile, RoutingRequest, @@ -16,6 +17,8 @@ import { import { LineString } from 'geojson' import { getTranslation, tr } from '@/translation/Translation' import * as config from 'config' +import { Coordinate } from '@/stores/QueryStore' +import { POIPhrase, POIAndQuery, POIQuery } from '@/pois/AddressParseResult' interface ApiProfile { name: string @@ -30,6 +33,8 @@ export default interface Api { geocode(query: string, provider: string, additionalOptions?: Record): Promise + reverseGeocode(query: POIQuery, bbox: Bbox): Promise + supportsGeocoding(): boolean } @@ -117,6 +122,68 @@ export class ApiImpl implements Api { } } + async reverseGeocode(query: POIQuery, bbox: Bbox): Promise { + if (!this.supportsGeocoding()) return [] + // why is main overpass api so much faster? + // const url = 'https://overpass.kumi.systems/api/interpreter' + const url = 'https://overpass-api.de/api/interpreter' + + // bbox of overpass is minLat, minLon, maxLat, maxLon + let minLat = bbox[1], + minLon = bbox[0], + maxLat = bbox[3], + maxLon = bbox[2] + + // Reduce the bbox to improve overpass response time for larger cities or areas. + // This might lead to empty responses for POI queries with a small result set. + if (maxLat - minLat > 0.3) { + const centerLat = (maxLat + minLat) / 2 + maxLat = centerLat + 0.15 + minLat = centerLat - 0.15 + } + if (maxLon - minLon > 0.3) { + const centerLon = (maxLon + minLon) / 2 + maxLon = centerLon + 0.15 + minLon = centerLon - 0.15 + } + + let queryString = '' + for (const q of query.queries) { + // nwr means it searches for nodes, ways and relations + queryString += 'nwr' + for (const p of q.phrases) { + if (p.sign == '=' && p.v == '*') queryString += `["${p.k}"]` + else queryString += `["${p.k}"${p.sign}"${p.v}"]` + } + queryString += `;\n` + } + + try { + const data = `[out:json][timeout:15][bbox:${minLat}, ${minLon}, ${maxLat}, ${maxLon}];\n(${queryString});\nout center 100;` + console.log(data) + const result = await fetch(url, { + method: 'POST', + body: 'data=' + encodeURIComponent(data), + }) + const json = await result.json() + if (json.elements) { + const res = (json.elements as any[]) + .map(e => { + if (e.center) { + return { ...e, point: { lat: e.center.lat, lng: e.center.lon } } as ReverseGeocodingHit + } else { + return { ...e, point: { lat: e.lat, lng: e.lon } } as ReverseGeocodingHit + } + }) + .filter(p => !!p.tags && p.point) + return res + } else return [] + } catch (error) { + console.warn('error occured ' + error) + return [] + } + } + supportsGeocoding(): boolean { return this.geocodingApi !== '' } @@ -385,4 +452,25 @@ export class ApiImpl implements Api { public static isTruck(profile: string) { return profile.includes('truck') } + + public static getBBoxPoints(points: Coordinate[]): Bbox | null { + const bbox: Bbox = points.reduce( + (res: Bbox, c) => [ + Math.min(res[0], c.lng), + Math.min(res[1], c.lat), + Math.max(res[2], c.lng), + Math.max(res[3], c.lat), + ], + [180, 90, -180, -90] as Bbox + ) + if (points.length == 1) { + bbox[0] = bbox[0] - 0.001 + bbox[1] = bbox[1] - 0.001 + bbox[2] = bbox[2] + 0.001 + bbox[3] = bbox[3] + 0.001 + } + + // return null if the bbox is not valid, e.g. if no url points were given at all + return bbox[0] < bbox[2] && bbox[1] < bbox[3] ? bbox : null + } } diff --git a/src/api/graphhopper.d.ts b/src/api/graphhopper.d.ts index fd7a5229..d75670bb 100644 --- a/src/api/graphhopper.d.ts +++ b/src/api/graphhopper.d.ts @@ -1,5 +1,5 @@ import { LineString } from 'geojson' -import { CustomModel } from '@/stores/QueryStore' +import { Coordinate, CustomModel } from '@/stores/QueryStore' // minLon, minLat, maxLon, maxLat export type Bbox = [number, number, number, number] @@ -112,13 +112,24 @@ interface Details { readonly hike_rating: [number, number, boolean][] } +export interface TagHash { + [key: string]: string +} + +export interface ReverseGeocodingHit { + readonly tags: TagHash + readonly type: string + readonly id: number + readonly point: Coordinate +} + export interface GeocodingResult { readonly hits: GeocodingHit[] readonly took: number } export interface GeocodingHit { - readonly point: { lat: number; lng: number } + readonly point: Coordinate readonly extent: Bbox readonly osm_id: string readonly osm_type: string diff --git a/src/index.tsx b/src/index.tsx index a18708cf..ee8937a4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import { createRoot } from 'react-dom/client' -import { setTranslation } from '@/translation/Translation' +import { getTranslation, setTranslation } from '@/translation/Translation' import App from '@/App' import { getApiInfoStore, @@ -9,6 +9,7 @@ import { getMapFeatureStore, getMapOptionsStore, getPathDetailsStore, + getPOIsStore, getQueryStore, getRouteStore, getSettingsStore, @@ -29,6 +30,9 @@ import { createMap, getMap, setMap } from '@/map/map' import MapFeatureStore from '@/stores/MapFeatureStore' import SettingsStore from '@/stores/SettingsStore' import { ErrorAction, InfoReceived } from '@/actions/Actions' +import POIsStore from '@/stores/POIsStore' +import { setDistanceFormat } from '@/Converters' +import { AddressParseResult } from '@/pois/AddressParseResult' console.log(`Source code: https://github.com/graphhopper/graphhopper-maps/tree/${GIT_SHA}`) @@ -36,6 +40,9 @@ const url = new URL(window.location.href) const locale = url.searchParams.get('locale') setTranslation(locale || navigator.language) +setDistanceFormat(new Intl.NumberFormat(navigator.language, { maximumFractionDigits: 1 })) +AddressParseResult.setPOITriggerPhrases(getTranslation()) + // use graphhopper api key from url or try using one from the config const apiKey = url.searchParams.has('key') ? url.searchParams.get('key') : config.keys.graphhopper setApi(config.routingApi, config.geocodingApi, apiKey || '') @@ -53,6 +60,7 @@ setStores({ mapOptionsStore: new MapOptionsStore(), pathDetailsStore: new PathDetailsStore(), mapFeatureStore: new MapFeatureStore(), + poisStore: new POIsStore(), }) setMap(createMap()) @@ -66,6 +74,7 @@ Dispatcher.register(getErrorStore()) Dispatcher.register(getMapOptionsStore()) Dispatcher.register(getPathDetailsStore()) Dispatcher.register(getMapFeatureStore()) +Dispatcher.register(getPOIsStore()) // register map action receiver const smallScreenMediaQuery = window.matchMedia('(max-width: 44rem)') diff --git a/src/layers/MapFeaturePopup.module.css b/src/layers/MapFeaturePopup.module.css index fd957eef..ae20b28f 100644 --- a/src/layers/MapFeaturePopup.module.css +++ b/src/layers/MapFeaturePopup.module.css @@ -5,3 +5,67 @@ padding: 5px 10px 5px 10px; border-radius: 10px; } + +.poiPopup { + position: relative; + top: -40px; + left: 20px; + max-width: 300px; + + background-color: white; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + padding: 5px 10px 5px 10px; + border-radius: 10px; +} + +.poiPopup div:first-child { + font-weight: bold; + padding-bottom: 5px; +} + +.poiPopupButton { + display: flex; + flex-direction: row; + gap: 7px; + padding-top: 12px; + padding-bottom: 8px; +} + +.poiPopupButton svg { + margin-top: 3px; +} + +.poiPopup a { + color: gray; + text-decoration: none; +} + +.poiPopup a:hover { + color: black; + text-decoration: underline; +} + +.poiPopupTable { + font-size: small; + padding: 6px 0; +} + +.osmLink { + padding-bottom: 7px; +} + +.osmLink a { + font-size: small; +} + +.poiPopupTable th, +.poiPopupTable td { + border-bottom: 1px solid #ccc; + padding: 3px 1px 3px 0; + text-align: left; + + /* strange, why do we need the following? */ + word-break: break-all; + min-width: 120px; +} diff --git a/src/layers/POIPopup.tsx b/src/layers/POIPopup.tsx new file mode 100644 index 00000000..5d1e7e46 --- /dev/null +++ b/src/layers/POIPopup.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import styles from '@/layers/MapFeaturePopup.module.css' +import MapPopup from '@/layers/MapPopup' +import { POI, POIsStoreState } from '@/stores/POIsStore' +import { tr } from '@/translation/Translation' +import Dispatcher from '@/stores/Dispatcher' +import { SelectPOI, SetPoint, SetPOIs } from '@/actions/Actions' +import PlainButton from '@/PlainButton' +import { MarkerComponent } from '@/map/Marker' +import QueryStore, { QueryPoint, QueryPointType } from '@/stores/QueryStore' +import { Map } from 'ol' + +function POITable(props: { poi: POI }) { + return ( + + + {Object.entries(props.poi.tags).map(([key, value]) => { + const url = value.startsWith('https://') || value.startsWith('http://') + const tel = key.toLowerCase().includes('phone') + const email = key.toLowerCase().includes('email') + const valueArr = value.split(':').map(v => v.trim()) + const wiki = key.toLowerCase().includes('wikipedia') && valueArr.length == 2 + const wikiUrl = wiki + ? 'https://' + valueArr[0] + '.wikipedia.org/wiki/' + encodeURIComponent(valueArr[1]) + : '' + // tags like amenity:restaurant should not be shown if it is a restaurant + const poiInfoRepeated = props.poi.query.queries + ? props.poi.query.queries.some(q => q.phrases.some(q => q.k == key && q.v === value)) + : false + // prettier-ignore + const showRow = !poiInfoRepeated && key !== 'source' && key !== 'image' && key !== 'check_data' + && !key.includes('fax') && !key.startsWith('addr') && !key.startsWith('name') && !key.startsWith('building') + return ( + showRow && ( + + + + + ) + ) + })} + +
{key} + {url && ( + + {value} + + )} + {tel && {value}} + {email && {value}} + {wiki && ( + + {value} + + )} + {!url && !tel && !email && !wiki && value} +
+ ) +} + +interface POIStatePopupProps { + map: Map + poiState: POIsStoreState + points: QueryPoint[] +} + +/** + * The popup shown when certain map features are hovered. For example a road of the routing graph layer. + */ +export default function POIStatePopup({ map, poiState, points }: POIStatePopupProps) { + const selectedPOI = poiState.selected + const type = selectedPOI?.osm_type + + function fire(index: number) { + if (selectedPOI && index < points.length) { + const queryPoint = { + ...points[index], + queryText: selectedPOI?.name, + coordinate: selectedPOI?.coordinate, + isInitialized: true, + } + Dispatcher.dispatch(new SetPoint(queryPoint, false)) + Dispatcher.dispatch(new SelectPOI(null)) + Dispatcher.dispatch(new SetPOIs([])) + } + } + + return ( + +
+
{selectedPOI?.name}
+
{selectedPOI?.address}
+
fire(0)}> + + {tr('as_start')} +
+
fire(points.length - 1)}> + + {tr('as_destination')} +
+ {selectedPOI && } + +
+
+ ) +} diff --git a/src/layers/UsePOIsLayer.tsx b/src/layers/UsePOIsLayer.tsx new file mode 100644 index 00000000..6e55dbfd --- /dev/null +++ b/src/layers/UsePOIsLayer.tsx @@ -0,0 +1,145 @@ +import { Feature, Map } from 'ol' +import { useEffect } from 'react' +import { Point } from 'ol/geom' +import { fromLonLat } from 'ol/proj' +import VectorLayer from 'ol/layer/Vector' +import VectorSource from 'ol/source/Vector' +import { Icon, Style } from 'ol/style' +import { POI, POIsStoreState } from '@/stores/POIsStore' + +import flight_takeoff_svg from '/src/pois/img/flight_takeoff.svg' +import hotel_svg from '/src/pois/img/hotel.svg' +import local_hospital_svg from '/src/pois/img/local_hospital.svg' +import local_parking_svg from '/src/pois/img/local_parking.svg' +import local_pharmacy_svg from '/src/pois/img/local_pharmacy.svg' +import luggage_svg from '/src/pois/img/luggage.svg' +import museum_svg from '/src/pois/img/museum.svg' +import restaurant_svg from '/src/pois/img/restaurant.svg' +import school_svg from '/src/pois/img/school.svg' +import sports_handball_svg from '/src/pois/img/sports_handball.svg' +import store_svg from '/src/pois/img/store.svg' +import train_svg from '/src/pois/img/train.svg' +import home_and_garden from '/src/pois/img/home_and_garden.svg' +import universal_currency_alt_svg from '/src/pois/img/universal_currency_alt.svg' +import local_atm from '/src/pois/img/local_atm.svg' +import local_gas_station from '/src/pois/img/local_gas_station.svg' +import local_post_office from '/src/pois/img/local_post_office.svg' +import police from '/src/pois/img/police.svg' +import charger from '/src/pois/img/charger.svg' +import water_drop from '../pois/img/water_drop.svg' +import { createPOIMarker } from '@/layers/createMarkerSVG' +import { Select } from 'ol/interaction' +import Dispatcher from '@/stores/Dispatcher' +import { SelectPOI } from '@/actions/Actions' +import { ObjectEvent } from 'ol/Object' +import { getMap } from '@/map/map' + +const svgStrings: { [id: string]: string } = {} + +const svgObjects: { [id: string]: any } = { + flight_takeoff: flight_takeoff_svg(), + hotel: hotel_svg(), + local_hospital: local_hospital_svg(), + local_parking: local_parking_svg(), + local_pharmacy: local_pharmacy_svg(), + luggage: luggage_svg(), + museum: museum_svg(), + restaurant: restaurant_svg(), + school: school_svg(), + sports_handball: sports_handball_svg(), + store: store_svg(), + train: train_svg(), + universal_currency_alt: universal_currency_alt_svg(), + home_and_garden: home_and_garden(), + local_atm: local_atm(), + local_gas_station: local_gas_station(), + local_post_office: local_post_office(), + police: police(), + charger: charger(), + water_drop: water_drop(), +} + +// -300 -1260 1560 1560 +// +for (const k in svgObjects) { + const svgObj = svgObjects[k] + svgStrings[k] = createPOIMarker(svgObj.props.children.props.d) + // console.log(svgStrings[k]) +} + +export default function usePOIsLayer(map: Map, poisState: POIsStoreState) { + useEffect(() => { + removePOIs(map) + let select: Select | null = null + if (addPOIsLayer(map, poisState.pois)) select = addPOISelection(map) + return () => { + removePOIs(map) + if (select) map.removeInteraction(select) + } + }, [map, poisState.pois]) +} + +function removePOIs(map: Map) { + map.getLayers() + .getArray() + .filter(l => l.get('gh:pois')) + .forEach(l => map.removeLayer(l)) +} + +function addPOISelection(map: Map) { + const select = new Select() + map.addInteraction(select) + select.on('select', event => { + const selectedFeatures = event.selected + if (selectedFeatures.length > 0) { + const feature = selectedFeatures[0] + const props = feature.get('gh:marker_props') + feature.setStyle( + new Style({ + zIndex: 2, + image: new Icon({ + scale: [1.4, 1.4], + src: 'data:image/svg+xml;utf8,' + svgStrings[props.icon], + displacement: [0, 18], + }), + }) + ) + Dispatcher.dispatch(new SelectPOI(props.poi)) + } else Dispatcher.dispatch(new SelectPOI(null)) + }) + return select +} + +function addPOIsLayer(map: Map, pois: POI[]) { + if (pois.length == 0) return false + + const features = pois.map((poi, i) => { + const feature = new Feature({ + geometry: new Point(fromLonLat([poi.coordinate.lng, poi.coordinate.lat])), + }) + feature.set('gh:marker_props', { icon: poi.icon, poi: poi }) + return feature + }) + const poisLayer = new VectorLayer({ + source: new VectorSource({ + features: features, + }), + }) + poisLayer.set('gh:pois', true) + const cachedStyles: { [id: string]: Style } = {} + poisLayer.setStyle(feature => { + const props = feature.get('gh:marker_props') + let style = cachedStyles[props.icon] + if (style) return style + style = new Style({ + image: new Icon({ + src: 'data:image/svg+xml;utf8,' + svgStrings[props.icon], + displacement: [0, 18], + }), + }) + cachedStyles[props.icon] = style + return style + }) + map.addLayer(poisLayer) + return true +} diff --git a/src/layers/createMarkerSVG.ts b/src/layers/createMarkerSVG.ts index 7ffa6505..527c7e6b 100644 --- a/src/layers/createMarkerSVG.ts +++ b/src/layers/createMarkerSVG.ts @@ -8,6 +8,20 @@ interface MarkerProps { size?: number } +export function createPOI(pathD: string) { + return ` + + + ` +} + +export function createPOIMarker(pathD: string) { + return ` + + + ` +} + // todo: this is mostly duplicated from `Marker.tsx`, but we use a more elongated shape (MARKER_PATH). // To use `Marker.tsx` we would probably need to add ol.Overlays, i.e. create a div for each marker and insert the svg from `Marker.tsx`. export function createSvg({ color, number, size = 0 }: MarkerProps) { diff --git a/src/map/MapPopups.tsx b/src/map/MapPopups.tsx index 4578dd83..033be02c 100644 --- a/src/map/MapPopups.tsx +++ b/src/map/MapPopups.tsx @@ -5,14 +5,19 @@ import InstructionPopup from '@/layers/InstructionPopup' import React from 'react' import { PathDetailsStoreState } from '@/stores/PathDetailsStore' import { MapFeatureStoreState } from '@/stores/MapFeatureStore' +import { POI, POIsStoreState } from '@/stores/POIsStore' +import POIStatePopup from '@/layers/POIPopup' +import { QueryStoreState } from '@/stores/QueryStore' interface MapPopupProps { map: Map pathDetails: PathDetailsStoreState mapFeatures: MapFeatureStoreState + poiState: POIsStoreState + query: QueryStoreState } -export default function MapPopups({ map, pathDetails, mapFeatures }: MapPopupProps) { +export default function MapPopups({ map, pathDetails, mapFeatures, poiState, query }: MapPopupProps) { return ( <> @@ -26,6 +31,7 @@ export default function MapPopups({ map, pathDetails, mapFeatures }: MapPopupPro instructionText={mapFeatures.instructionText} coordinate={mapFeatures.instructionCoordinate} /> + ) } diff --git a/src/pois/AddressParseResult.ts b/src/pois/AddressParseResult.ts new file mode 100644 index 00000000..ee96c051 --- /dev/null +++ b/src/pois/AddressParseResult.ts @@ -0,0 +1,250 @@ +import { ApiImpl } from '@/api/Api' +import Dispatcher from '@/stores/Dispatcher' +import { SetBBox, SetPOIs } from '@/actions/Actions' +import { hitToItem } from '@/Converters' +import { GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper' +import { QueryPoint } from '@/stores/QueryStore' +import { tr, Translation } from '@/translation/Translation' +import { POI } from '@/stores/POIsStore' + +export class AddressParseResult { + location: string + query: POIQuery + icon: string + poi: string + static TRIGGER_VALUES: PoiTriggerPhrases[] + static REMOVE_VALUES: string[] + + constructor(location: string, query: POIQuery, icon: string, poi: string) { + this.location = location + this.query = query + this.icon = icon + this.poi = poi + } + + hasPOIs(): boolean { + return this.query.queries.length > 0 + } + + text(prefix: string) { + return this.location ? tr('poi_in', [prefix, this.location]) : tr('poi_nearby', [prefix]) + } + + /* it is a bit ugly that we have to inject the translated values here, but jest goes crazy otherwise */ + static parse(query: string, incomplete: boolean): AddressParseResult { + query = query.toLowerCase() + + const smallWords = AddressParseResult.REMOVE_VALUES // e.g. 'restaurants in this area' or 'restaurants in berlin' + const queryTokens: string[] = query.split(' ').filter(token => !smallWords.includes(token)) + const res = AddressParseResult.getGeneric(queryTokens) + if (res.hasPOIs()) return res + + const cleanQuery = queryTokens.join(' ') + const bigrams: string[] = [] + for (let i = 0; i < queryTokens.length - 1; i++) { + bigrams.push(queryTokens[i] + ' ' + queryTokens[i + 1]) + } + + for (const val of AddressParseResult.TRIGGER_VALUES) { + // two word phrases like 'public transit' must be checked before single word phrases + for (const keyword of val.k) { + const i = bigrams.indexOf(keyword) + if (i >= 0) + return new AddressParseResult(cleanQuery.replace(bigrams[i], '').trim(), val.q, val.i, val.k[0]) + } + + for (const keyword of val.k) { + const i = queryTokens.indexOf(keyword) + if (i >= 0) + return new AddressParseResult(cleanQuery.replace(queryTokens[i], '').trim(), val.q, val.i, val.k[0]) + } + } + + return new AddressParseResult('', new POIQuery([]), '', '') + } + + public static getGeneric(tokens: string[]) { + const locations = [] + const orPhrases = [] + const notPhrases = [] + const singleAndQuery = new POIQuery([new POIAndQuery([])]) + const singleAnd = tokens.includes('and') + for (const token of tokens) { + const indexNot = token.indexOf('!') + const index = token.indexOf('=') + if (indexNot >= 0) { + const sign = token.includes('~') ? '!~' : '!=' + const p = new POIPhrase(token.substring(0, indexNot), sign, token.substring(indexNot + 2)) + singleAndQuery.queries[0].phrases.push(p) + notPhrases.push(p) + } else if (index >= 0) { + const p = new POIPhrase(token.substring(0, index), '=', token.substring(index + 1)) + singleAndQuery.queries[0].phrases.push(p) + orPhrases.push(p) + } else if (token == 'and') { + } else { + locations.push(token) + } + } + + if (singleAnd) + return new AddressParseResult(locations.join(' '), singleAndQuery, 'store', singleAndQuery.toString()) + + const queries = [] + for (const p of orPhrases) { + queries.push(new POIAndQuery([p, ...notPhrases])) + } + const poiQuery = new POIQuery(queries) + return new AddressParseResult(locations.join(' '), poiQuery, 'store', poiQuery.toString()) + } + + public static handleGeocodingResponse(hits: ReverseGeocodingHit[], parseResult: AddressParseResult) { + if (hits.length == 0) { + Dispatcher.dispatch(new SetPOIs([])) + return + } + const pois = hits + .filter(hit => !!hit.point) + .map(hit => { + const res = hitToItem({ + name: hit.tags.name ? hit.tags.name : '', + country: hit.tags['addr:country'], + city: hit.tags['addr:city'], + state: hit.tags['addr:state'], + street: hit.tags['addr:street'], + housenumber: hit.tags['addr:housenumer'], + postcode: hit.tags['addr:postcode'], + } as GeocodingHit) + return { + name: res.mainText, + osm_id: '' + hit.id, + osm_type: hit.type, + query: parseResult.query, + tags: hit.tags, + icon: parseResult.icon, + coordinate: hit.point, + address: res.secondText, + } as POI + }) + const bbox = ApiImpl.getBBoxPoints(pois.map(p => p.coordinate)) + if (bbox) { + if (parseResult.location) Dispatcher.dispatch(new SetBBox(bbox)) + Dispatcher.dispatch(new SetPOIs(pois)) + } else { + console.warn( + 'invalid bbox for points ' + JSON.stringify(pois) + ' result was: ' + JSON.stringify(parseResult) + ) + } + } + + // because of the static method we need to inject the Translation object as otherwise jest has a problem + static setPOITriggerPhrases(translation: Translation) { + const t = (s: string) => + translation + .get(s) + .split(',') + .map(s => s.trim().toLowerCase()) + AddressParseResult.REMOVE_VALUES = t('poi_removal_words') + AddressParseResult.TRIGGER_VALUES = [ + { k: 'poi_airports', q: ['aeroway=aerodrome and landuse!=military and military!~.*'], i: 'flight_takeoff' }, + { k: 'poi_atm', q: ['amenity=atm', 'amenity=bank'], i: 'local_atm' }, + { k: 'poi_banks', q: ['amenity=bank'], i: 'universal_currency_alt' }, + { k: 'poi_bus_stops', q: ['highway=bus_stop'], i: 'train' }, + { k: 'poi_education', q: ['amenity=school', 'building=school', 'building=university'], i: 'school' }, + { k: 'poi_gas_station', q: ['amenity=fuel'], i: 'local_gas_station' }, + { k: 'poi_hospitals', q: ['amenity=hospital', 'building=hospital'], i: 'local_hospital' }, + { k: 'poi_hotels', q: ['amenity=hotel', 'building=hotel', 'tourism=hotel'], i: 'hotel' }, + { k: 'poi_leisure', q: ['leisure=*'], i: 'sports_handball' }, + { k: 'poi_museums', q: ['tourism=museum', 'building=museum'], i: 'museum' }, + { k: 'poi_parking', q: ['amenity=parking'], i: 'local_parking' }, + { k: 'poi_parks', q: ['leisure=park'], i: 'sports_handball' }, + { k: 'poi_pharmacies', q: ['amenity=pharmacy'], i: 'local_pharmacy' }, + { k: 'poi_playgrounds', q: ['leisure=playground'], i: 'sports_handball' }, + { k: 'poi_police', q: ['amenity=police'], i: 'police' }, + // important to have this before "post" + { + k: 'poi_post_box', + q: ['amenity=post_box', 'amenity=post_office', 'amenity=post_depot'], + i: 'local_post_office', + }, + { k: 'poi_post', q: ['amenity=post_office', 'amenity=post_depot'], i: 'local_post_office' }, + { + k: 'poi_public_transit', + q: ['public_transport=station', 'railway=station', 'highway=bus_stop'], + i: 'train', + }, + { k: 'poi_railway_station', q: ['railway=station', 'railway=halt'], i: 'train' }, + { k: 'poi_restaurants', q: ['amenity=restaurant'], i: 'restaurant' }, + { k: 'poi_schools', q: ['amenity=school', 'building=school'], i: 'school' }, + { k: 'poi_shopping', q: ['shop=*'], i: 'store' }, + { k: 'poi_super_markets', q: ['shop=supermarket', 'building=supermarket'], i: 'store' }, + { k: 'poi_toilets', q: ['amenity=toilets'], i: 'home_and_garden' }, + { k: 'poi_tourism', q: ['tourism=*'], i: 'luggage' }, + { k: 'poi_water', q: ['amenity=drinking_water'], i: 'water_drop' }, + { k: 'poi_charging_station', q: ['amenity=charging_station'], i: 'charger' }, + ].map(v => { + const queries = v.q.map(val => { + return new POIAndQuery( + val.split(' and ').map(v => { + let kv = v.split('!=') + if (kv.length > 1) return new POIPhrase(kv[0], '!=', kv[1]) + kv = v.split('!~') + if (kv.length > 1) return new POIPhrase(kv[0], '!~', kv[1]) + kv = v.split('=') + return new POIPhrase(kv[0], '=', kv[1]) + }) + ) + }) + return { + k: t(v.k), + q: new POIQuery(queries), + i: v.i, + } as PoiTriggerPhrases + }) + } +} + +export class POIQuery { + public queries: POIAndQuery[] + + constructor(queries: POIAndQuery[]) { + this.queries = queries + } + + toString(): string { + return this.queries.join(' ') + } +} + +export class POIAndQuery { + public phrases: POIPhrase[] + + constructor(phrases: POIPhrase[]) { + this.phrases = phrases + } + + toString(): string { + return this.phrases + .map(p => p.toString()) + .join(' and ') + .trim() + } +} + +export class POIPhrase { + public k: string + public sign: string + public v: string + + constructor(k: string, sign: string, v: string) { + this.k = k + this.sign = sign + this.v = v + } + + toString(): string { + return this.k + this.sign + this.v + } +} + +export type PoiTriggerPhrases = { k: string[]; q: POIQuery; i: string } diff --git a/src/pois/img/charger.svg b/src/pois/img/charger.svg new file mode 100644 index 00000000..889ec6f4 --- /dev/null +++ b/src/pois/img/charger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/flight_takeoff.svg b/src/pois/img/flight_takeoff.svg new file mode 100644 index 00000000..b54db32d --- /dev/null +++ b/src/pois/img/flight_takeoff.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/home_and_garden.svg b/src/pois/img/home_and_garden.svg new file mode 100644 index 00000000..ecdbf0a5 --- /dev/null +++ b/src/pois/img/home_and_garden.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/hotel.svg b/src/pois/img/hotel.svg new file mode 100644 index 00000000..97ad41c2 --- /dev/null +++ b/src/pois/img/hotel.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/local_atm.svg b/src/pois/img/local_atm.svg new file mode 100644 index 00000000..64126bee --- /dev/null +++ b/src/pois/img/local_atm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/local_gas_station.svg b/src/pois/img/local_gas_station.svg new file mode 100644 index 00000000..8a8c08c8 --- /dev/null +++ b/src/pois/img/local_gas_station.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/local_hospital.svg b/src/pois/img/local_hospital.svg new file mode 100644 index 00000000..e8539f47 --- /dev/null +++ b/src/pois/img/local_hospital.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/local_parking.svg b/src/pois/img/local_parking.svg new file mode 100644 index 00000000..e976212c --- /dev/null +++ b/src/pois/img/local_parking.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/local_pharmacy.svg b/src/pois/img/local_pharmacy.svg new file mode 100644 index 00000000..9a1adf94 --- /dev/null +++ b/src/pois/img/local_pharmacy.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/local_post_office.svg b/src/pois/img/local_post_office.svg new file mode 100644 index 00000000..ce3ac78f --- /dev/null +++ b/src/pois/img/local_post_office.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/luggage.svg b/src/pois/img/luggage.svg new file mode 100644 index 00000000..ea0e04a0 --- /dev/null +++ b/src/pois/img/luggage.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/museum.svg b/src/pois/img/museum.svg new file mode 100644 index 00000000..0504b1b4 --- /dev/null +++ b/src/pois/img/museum.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/police.svg b/src/pois/img/police.svg new file mode 100644 index 00000000..8a6acfb9 --- /dev/null +++ b/src/pois/img/police.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/pois/img/restaurant.svg b/src/pois/img/restaurant.svg new file mode 100644 index 00000000..ec17a3c5 --- /dev/null +++ b/src/pois/img/restaurant.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/school.svg b/src/pois/img/school.svg new file mode 100644 index 00000000..c537f254 --- /dev/null +++ b/src/pois/img/school.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/sports_handball.svg b/src/pois/img/sports_handball.svg new file mode 100644 index 00000000..8f86e4d1 --- /dev/null +++ b/src/pois/img/sports_handball.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/store.svg b/src/pois/img/store.svg new file mode 100644 index 00000000..2775e7b3 --- /dev/null +++ b/src/pois/img/store.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/train.svg b/src/pois/img/train.svg new file mode 100644 index 00000000..ec15bd80 --- /dev/null +++ b/src/pois/img/train.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/universal_currency_alt.svg b/src/pois/img/universal_currency_alt.svg new file mode 100644 index 00000000..3fe4ba65 --- /dev/null +++ b/src/pois/img/universal_currency_alt.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/pois/img/water_drop.svg b/src/pois/img/water_drop.svg new file mode 100644 index 00000000..98d0dab8 --- /dev/null +++ b/src/pois/img/water_drop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/sidebar/MobileSidebar.tsx b/src/sidebar/MobileSidebar.tsx index e4d17318..390ac194 100644 --- a/src/sidebar/MobileSidebar.tsx +++ b/src/sidebar/MobileSidebar.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { QueryPoint, QueryPointType, QueryStoreState, RequestState } from '@/stores/QueryStore' +import { Coordinate, QueryPoint, QueryPointType, QueryStoreState, RequestState } from '@/stores/QueryStore' import { RouteStoreState } from '@/stores/RouteStore' import { ErrorStoreState } from '@/stores/ErrorStore' import styles from './MobileSidebar.module.css' @@ -11,6 +11,7 @@ import RoutingProfiles from '@/sidebar/search/routingProfiles/RoutingProfiles' import OpenInputsIcon from './unfold.svg' import CloseInputsIcon from './unfold_less.svg' import CustomModelBox from '@/sidebar/CustomModelBox' +import { Map } from 'ol' type MobileSidebarProps = { query: QueryStoreState @@ -18,9 +19,10 @@ type MobileSidebarProps = { error: ErrorStoreState encodedValues: object[] drawAreas: boolean + map: Map } -export default function ({ query, route, error, encodedValues, drawAreas }: MobileSidebarProps) { +export default function ({ query, route, error, encodedValues, drawAreas, map }: MobileSidebarProps) { const [showCustomModelBox, setShowCustomModelBox] = useState(false) // the following three elements control, whether the small search view is displayed const isShortScreen = useMediaQuery({ query: '(max-height: 55rem)' }) @@ -72,7 +74,7 @@ export default function ({ query, route, error, encodedValues, drawAreas }: Mobi drawAreas={drawAreas} /> )} - +
)} {!error.isDismissed && } diff --git a/src/sidebar/RoutingResults.tsx b/src/sidebar/RoutingResults.tsx index d39efcac..e370a9c5 100644 --- a/src/sidebar/RoutingResults.tsx +++ b/src/sidebar/RoutingResults.tsx @@ -463,6 +463,7 @@ function toCoordinate(pos: Position): Coordinate { } function toBBox(segment: Coordinate[]): Bbox { + // TODO replace with ApiImpl.getBBoxPoints const bbox = getBBoxFromCoord(segment[0], 0.002) if (segment.length == 1) bbox segment.forEach(c => { diff --git a/src/sidebar/search/AddressInput.tsx b/src/sidebar/search/AddressInput.tsx index ab83a670..89dd6811 100644 --- a/src/sidebar/search/AddressInput.tsx +++ b/src/sidebar/search/AddressInput.tsx @@ -1,23 +1,27 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { Coordinate, getBBoxFromCoord, QueryPoint, QueryPointType } from '@/stores/QueryStore' -import { Bbox, GeocodingHit } from '@/api/graphhopper' +import { Bbox, GeocodingHit, ReverseGeocodingHit } from '@/api/graphhopper' import Autocomplete, { AutocompleteItem, GeocodingItem, + POIQueryItem, SelectCurrentLocationItem, } from '@/sidebar/search/AddressInputAutocomplete' import ArrowBack from './arrow_back.svg' import Cross from '@/sidebar/times-solid-thin.svg' import styles from './AddressInput.module.css' -import Api, { getApi } from '@/api/Api' +import Api, { ApiImpl, getApi } from '@/api/Api' import { tr } from '@/translation/Translation' import { coordinateToText, hitToItem, nominatimHitToItem, textToCoordinate } from '@/Converters' import { useMediaQuery } from 'react-responsive' import PopUp from '@/sidebar/search/PopUp' import PlainButton from '@/PlainButton' import { onCurrentLocationSelected } from '@/map/MapComponent' -import { toLonLat } from 'ol/proj' +import { toLonLat, transformExtent } from 'ol/proj' +import { calcDist } from '@/distUtils' +import { Map } from 'ol' +import { AddressParseResult } from '@/pois/AddressParseResult' import { getMap } from '@/map/map' export interface AddressInputProps { @@ -30,6 +34,7 @@ export interface AddressInputProps { moveStartIndex: number dropPreviewIndex: number index: number + map: Map } export default function AddressInput(props: AddressInputProps) { @@ -49,13 +54,19 @@ export default function AddressInput(props: AddressInputProps) { const [autocompleteItems, setAutocompleteItems] = useState([]) const [geocoder] = useState( new Geocoder(getApi(), (query, provider, hits) => { - const items: AutocompleteItem[] = hits.map(hit => { + const items: AutocompleteItem[] = [] + const parseResult = AddressParseResult.parse(query, true) + if (parseResult.hasPOIs()) items.push(new POIQueryItem(parseResult)) + + hits.forEach(hit => { const obj = hitToItem(hit) - return new GeocodingItem( - obj.mainText, - obj.secondText, - hit.point, - hit.extent ? hit.extent : getBBoxFromCoord(hit.point) + items.push( + new GeocodingItem( + obj.mainText, + obj.secondText, + hit.point, + hit.extent ? hit.extent : getBBoxFromCoord(hit.point) + ) ) }) @@ -63,6 +74,9 @@ export default function AddressInput(props: AddressInputProps) { setAutocompleteItems(items) }) ) + + const [poiSearch] = useState(new ReverseGeocoder(getApi(), props.point, AddressParseResult.handleGeocodingResponse)) + // if item is selected we need to clear the autocompletion list useEffect(() => setAutocompleteItems([]), [props.point]) // if no items but input is selected show current location item @@ -130,7 +144,10 @@ export default function AddressInput(props: AddressInputProps) { } else if (autocompleteItems.length > 0) { const index = highlightedResult >= 0 ? highlightedResult : 0 const item = autocompleteItems[index] - if (highlightedResult < 0) { + if (item instanceof POIQueryItem) { + handlePoiSearch(poiSearch, item.result, props.map) + props.onAddressSelected(item.result.text(item.result.poi), undefined) + } else if (highlightedResult < 0) { // by default use the first result, otherwise the highlighted one getApi() .geocode(text, 'nominatim') @@ -195,10 +212,11 @@ export default function AddressInput(props: AddressInputProps) { ref={searchInput} autoComplete="off" onChange={e => { - setText(e.target.value) - const coordinate = textToCoordinate(e.target.value) + const query = e.target.value + setText(query) + const coordinate = textToCoordinate(query) if (!coordinate) geocoder.request(e.target.value, biasCoord, getMap().getView().getZoom()) - props.onChange(e.target.value) + props.onChange(query) }} onKeyDown={onKeypress} onFocus={() => { @@ -244,6 +262,10 @@ export default function AddressInput(props: AddressInputProps) { } else if (item instanceof SelectCurrentLocationItem) { hideSuggestions() onCurrentLocationSelected(props.onAddressSelected) + } else if (item instanceof POIQueryItem) { + hideSuggestions() + handlePoiSearch(poiSearch, item.result, props.map) + setText(item.result.text(item.result.poi)) } searchInput.current!.blur() }} @@ -255,6 +277,14 @@ export default function AddressInput(props: AddressInputProps) { ) } +function handlePoiSearch(poiSearch: ReverseGeocoder, result: AddressParseResult, map: Map) { + if (!result.hasPOIs()) return + + const origExtent = map.getView().calculateExtent(map.getSize()) + const extent = transformExtent(origExtent, 'EPSG:3857', 'EPSG:4326') + poiSearch.request(result, extent as Bbox) +} + function ResponsiveAutocomplete({ inputRef, children, @@ -334,7 +364,7 @@ class Geocoder { return this.requestId } - private static filterDuplicates(hits: GeocodingHit[]) { + static filterDuplicates(hits: GeocodingHit[]) { const set: Set = new Set() return hits.filter(hit => { if (!set.has(hit.osm_id)) { @@ -346,6 +376,70 @@ class Geocoder { } } +export class ReverseGeocoder { + private requestId = 0 + private readonly timeout = new Timout(200) + private readonly api: Api + private readonly onSuccess: ( + hits: ReverseGeocodingHit[], + parseResult: AddressParseResult, + queryPoint: QueryPoint + ) => void + private readonly queryPoint: QueryPoint + + constructor( + api: Api, + queryPoint: QueryPoint, + onSuccess: (hits: ReverseGeocodingHit[], parseResult: AddressParseResult, queryPoint: QueryPoint) => void + ) { + this.api = api + this.onSuccess = onSuccess + this.queryPoint = queryPoint + } + + cancel() { + // invalidates last request if there is one + this.getNextId() + } + + request(query: AddressParseResult, bbox: Bbox) { + this.requestAsync(query, bbox).then(() => {}) + } + + async requestAsync(parseResult: AddressParseResult, bbox: Bbox) { + const currentId = this.getNextId() + this.timeout.cancel() + await this.timeout.wait() + try { + let hits: ReverseGeocodingHit[] = [] + if (parseResult.location) { + let options: Record = { + point: coordinateToText({ lat: (bbox[1] + bbox[3]) / 2, lng: (bbox[0] + bbox[2]) / 2 }), + location_bias_scale: '0.5', + zoom: '9', + } + const fwdSearch = await this.api.geocode(parseResult.location, 'default', options) + if (fwdSearch.hits.length > 0) { + const bbox = fwdSearch.hits[0].extent + ? fwdSearch.hits[0].extent + : getBBoxFromCoord(fwdSearch.hits[0].point, 0.01) + if (bbox) hits = await this.api.reverseGeocode(parseResult.query, bbox) + } + } else { + hits = await this.api.reverseGeocode(parseResult.query, bbox) + } + if (currentId === this.requestId) this.onSuccess(hits, parseResult, this.queryPoint) + } catch (reason) { + throw Error('Could not get geocoding results because: ' + reason) + } + } + + private getNextId() { + this.requestId++ + return this.requestId + } +} + class Timout { private readonly delay: number private handle: number = 0 diff --git a/src/sidebar/search/AddressInputAutocomplete.module.css b/src/sidebar/search/AddressInputAutocomplete.module.css index ffe61366..c3ffe51c 100644 --- a/src/sidebar/search/AddressInputAutocomplete.module.css +++ b/src/sidebar/search/AddressInputAutocomplete.module.css @@ -47,6 +47,19 @@ width: 100%; } +.poiEntry { + padding: 0.5em 0; + display: flex; + flex-direction: row; + gap: 0.4rem; + text-align: start; + margin: 0.4rem 0.5rem; +} + +.poiEntryPrimaryText { + font-weight: bold; +} + .geocodingEntry { display: flex; flex-direction: column; diff --git a/src/sidebar/search/AddressInputAutocomplete.tsx b/src/sidebar/search/AddressInputAutocomplete.tsx index fcc0131e..7f0e0329 100644 --- a/src/sidebar/search/AddressInputAutocomplete.tsx +++ b/src/sidebar/search/AddressInputAutocomplete.tsx @@ -2,7 +2,7 @@ import styles from './AddressInputAutocomplete.module.css' import CurrentLocationIcon from './current-location.svg' import { tr } from '@/translation/Translation' import { Bbox } from '@/api/graphhopper' -import { useState } from 'react' +import { AddressParseResult } from '@/pois/AddressParseResult' export interface AutocompleteItem {} @@ -26,6 +26,14 @@ export class GeocodingItem implements AutocompleteItem { export class SelectCurrentLocationItem implements AutocompleteItem {} +export class POIQueryItem implements AutocompleteItem { + result: AddressParseResult + + constructor(result: AddressParseResult) { + this.result = result + } +} + export interface AutocompleteProps { items: AutocompleteItem[] highlightedItem: AutocompleteItem @@ -49,9 +57,31 @@ function mapToComponent(item: AutocompleteItem, isHighlighted: boolean, onSelect return else if (item instanceof SelectCurrentLocationItem) return + else if (item instanceof POIQueryItem) + return else throw Error('Unsupported item type: ' + typeof item) } +export function POIQueryEntry({ + item, + isHighlighted, + onSelect, +}: { + item: POIQueryItem + isHighlighted: boolean + onSelect: (item: POIQueryItem) => void +}) { + const poi = item.result.poi ? item.result.poi : '' + return ( + onSelect(item)}> +
+ {poi.charAt(0).toUpperCase() + poi.slice(1)} + {item.result.text('')} +
+
+ ) +} + export function SelectCurrentLocation({ item, isHighlighted, diff --git a/src/sidebar/search/Search.tsx b/src/sidebar/search/Search.tsx index dc2f6b0f..d2d8bf8a 100644 --- a/src/sidebar/search/Search.tsx +++ b/src/sidebar/search/Search.tsx @@ -1,19 +1,20 @@ import { useEffect, useRef, useState } from 'react' import Dispatcher from '@/stores/Dispatcher' import styles from '@/sidebar/search/Search.module.css' -import { getBBoxFromCoord, QueryPoint } from '@/stores/QueryStore' +import { Coordinate, getBBoxFromCoord, QueryPoint } from '@/stores/QueryStore' import { AddPoint, ClearRoute, InvalidatePoint, MovePoint, RemovePoint, SetBBox, SetPoint } from '@/actions/Actions' import RemoveIcon from './minus-circle-solid.svg' import AddIcon from './plus-circle-solid.svg' import TargetIcon from './send.svg' import PlainButton from '@/PlainButton' +import { Map } from 'ol' import AddressInput from '@/sidebar/search/AddressInput' import { MarkerComponent } from '@/map/Marker' import { tr } from '@/translation/Translation' import SettingsBox from '@/sidebar/SettingsBox' -export default function Search({ points }: { points: QueryPoint[] }) { +export default function Search({ points, map }: { points: QueryPoint[]; map: Map }) { const [showSettings, setShowSettings] = useState(false) const [showTargetIcons, setShowTargetIcons] = useState(true) const [moveStartIndex, onMoveStartSelect] = useState(-1) @@ -40,6 +41,7 @@ export default function Search({ points }: { points: QueryPoint[] }) { }} dropPreviewIndex={dropPreviewIndex} onDropPreviewSelect={onDropPreviewSelect} + map={map} /> ))} @@ -75,6 +77,7 @@ const SearchBox = ({ onMoveStartSelect, dropPreviewIndex, onDropPreviewSelect, + map, }: { index: number points: QueryPoint[] @@ -85,6 +88,7 @@ const SearchBox = ({ onMoveStartSelect: (index: number, showTargetIcon: boolean) => void dropPreviewIndex: number onDropPreviewSelect: (index: number) => void + map: Map }) => { const point = points[index] @@ -162,6 +166,7 @@ const SearchBox = ({
{ + constructor() { + super({ pois: [], selected: null }) + } + + reduce(state: POIsStoreState, action: Action): POIsStoreState { + if (action instanceof SetPOIs) { + return { + pois: action.pois, + selected: null, + } + } else if (action instanceof SelectPOI) { + return { + ...state, + selected: action.selected, + } + } + return state + } +} diff --git a/src/stores/Stores.ts b/src/stores/Stores.ts index 018e8b42..b7ba16d3 100644 --- a/src/stores/Stores.ts +++ b/src/stores/Stores.ts @@ -6,6 +6,7 @@ import MapOptionsStore from '@/stores/MapOptionsStore' import PathDetailsStore from '@/stores/PathDetailsStore' import MapFeatureStore from '@/stores/MapFeatureStore' import SettingsStore from '@/stores/SettingsStore' +import POIsStore from '@/stores/POIsStore' let settingsStore: SettingsStore let queryStore: QueryStore @@ -15,6 +16,7 @@ let errorStore: ErrorStore let mapOptionsStore: MapOptionsStore let pathDetailsStore: PathDetailsStore let mapFeatureStore: MapFeatureStore +let poisStore: POIsStore interface StoresInput { settingsStore: SettingsStore @@ -25,6 +27,7 @@ interface StoresInput { mapOptionsStore: MapOptionsStore pathDetailsStore: PathDetailsStore mapFeatureStore: MapFeatureStore + poisStore: POIsStore } export const setStores = function (stores: StoresInput) { @@ -36,6 +39,7 @@ export const setStores = function (stores: StoresInput) { mapOptionsStore = stores.mapOptionsStore pathDetailsStore = stores.pathDetailsStore mapFeatureStore = stores.mapFeatureStore + poisStore = stores.poisStore } export const getSettingsStore = () => settingsStore @@ -46,3 +50,4 @@ export const getErrorStore = () => errorStore export const getMapOptionsStore = () => mapOptionsStore export const getPathDetailsStore = () => pathDetailsStore export const getMapFeatureStore = () => mapFeatureStore +export const getPOIsStore = () => poisStore diff --git a/test/DummyApi.ts b/test/DummyApi.ts index 5513b562..6c63dfc1 100644 --- a/test/DummyApi.ts +++ b/test/DummyApi.ts @@ -1,5 +1,14 @@ import Api from '../src/api/Api' -import { ApiInfo, GeocodingResult, RoutingArgs, RoutingResult, RoutingResultInfo } from '../src/api/graphhopper' +import { + ApiInfo, + GeocodingResult, + ReverseGeocodingHit, + RoutingArgs, + RoutingResult, + RoutingResultInfo, + Bbox, +} from '../src/api/graphhopper' +import { POIAndQuery, POIQuery } from '@/pois/AddressParseResult' export default class DummyApi implements Api { geocode(query: string): Promise { @@ -9,6 +18,10 @@ export default class DummyApi implements Api { }) } + reverseGeocode(query: POIQuery, bbox: Bbox): Promise { + return Promise.resolve([]) + } + info(): Promise { return Promise.resolve({ bbox: [0, 0, 0, 0], diff --git a/test/NavBar.test.ts b/test/NavBar.test.ts index 8487e852..9dfb8f86 100644 --- a/test/NavBar.test.ts +++ b/test/NavBar.test.ts @@ -7,7 +7,7 @@ import { coordinateToText } from '@/Converters' // import the window and mock it with jest import { window } from '@/Window' -import MapOptionsStore, { StyleOption } from '@/stores/MapOptionsStore' +import MapOptionsStore from '@/stores/MapOptionsStore' import * as config from 'config' import { RoutingProfile } from '@/api/graphhopper' diff --git a/test/poi/AddressParseResult.test.ts b/test/poi/AddressParseResult.test.ts new file mode 100644 index 00000000..d6711954 --- /dev/null +++ b/test/poi/AddressParseResult.test.ts @@ -0,0 +1,62 @@ +import { AddressParseResult, POIPhrase } from '@/pois/AddressParseResult' +import fetchMock from 'jest-fetch-mock' +import { getTranslation, setTranslation } from '@/translation/Translation' + +beforeAll(() => { + setTranslation('en', false) + AddressParseResult.setPOITriggerPhrases(getTranslation()) +}) + +describe('reverse geocoder', () => { + it('should parse fully', async () => { + let res = AddressParseResult.parse('dresden restaurant', false) + expect(res.location).toEqual('dresden') + expect(res.icon).toEqual('restaurant') + + res = AddressParseResult.parse('restaurant', false) + expect(res.location).toEqual('') + expect(res.icon).toEqual('restaurant') + + res = AddressParseResult.parse('restaurant in dresden', false) + expect(res.location).toEqual('dresden') + expect(res.icon).toEqual('restaurant') + + res = AddressParseResult.parse('airports around some thing else', false) + expect(res.location).toEqual('some thing else') + expect(res.icon).toEqual('flight_takeoff') + + res = AddressParseResult.parse('dresden super market', false) + expect(res.location).toEqual('dresden') + expect(res.poi).toEqual('super markets') + + res = AddressParseResult.parse('dresden park', false) + expect(res.location).toEqual('dresden') + expect(res.poi).toEqual('parks') + + res = AddressParseResult.parse('dresden parking', false) + expect(res.location).toEqual('dresden') + expect(res.poi).toEqual('parking') + + res = AddressParseResult.parse('restaurants in this area', false) + expect(res.location).toEqual('') + expect(res.poi).toEqual('restaurants') + }) + + it('should parse generic', async () => { + let res = AddressParseResult.parse('dresden amenity=bar', false) + expect(res.location).toEqual('dresden') + expect(res.query.toString()).toEqual('amenity=bar') + + res = AddressParseResult.parse('dresden amenity=bar military!~.*', false) + expect(res.location).toEqual('dresden') + expect(res.query.toString()).toEqual('amenity=bar and military!~.*') + + res = AddressParseResult.parse('amenity=restaurant and wheelchair=yes in dresden', false) + expect(res.location).toEqual('dresden') + expect(res.query.toString()).toEqual('amenity=restaurant and wheelchair=yes') + + // no "select query", only 'not' queries => leads currently to no match + res = AddressParseResult.parse('dresden amenity!=bar military!~.*', false) + expect(res.hasPOIs()).toEqual(false) + }) +}) diff --git a/test/routing/Api.test.ts b/test/routing/Api.test.ts index cc026e62..5f2304ba 100644 --- a/test/routing/Api.test.ts +++ b/test/routing/Api.test.ts @@ -2,7 +2,7 @@ * @jest-environment node */ import fetchMock from 'jest-fetch-mock' -import { ErrorAction, InfoReceived, RouteRequestFailed, RouteRequestSuccess } from '@/actions/Actions' +import { RouteRequestFailed, RouteRequestSuccess } from '@/actions/Actions' import { setTranslation } from '@/translation/Translation' import Dispatcher from '@/stores/Dispatcher' diff --git a/test/stores/QueryStore.test.ts b/test/stores/QueryStore.test.ts index 3ed53258..5410c08f 100644 --- a/test/stores/QueryStore.test.ts +++ b/test/stores/QueryStore.test.ts @@ -1,5 +1,13 @@ import Api from '@/api/Api' -import { ApiInfo, GeocodingResult, RoutingArgs, RoutingResult, RoutingResultInfo } from '@/api/graphhopper' +import { + ApiInfo, + Bbox, + GeocodingResult, + ReverseGeocodingHit, + RoutingArgs, + RoutingResult, + RoutingResultInfo, +} from '@/api/graphhopper' import QueryStore, { QueryPoint, QueryPointType, QueryStoreState, RequestState, SubRequest } from '@/stores/QueryStore' import { AddPoint, @@ -12,7 +20,7 @@ import { SetPoint, SetVehicleProfile, } from '@/actions/Actions' -import { tr } from '@/translation/Translation' +import { POIAndQuery, POIQuery } from '@/pois/AddressParseResult' class ApiMock implements Api { private readonly callback: { (args: RoutingArgs): void } @@ -25,6 +33,10 @@ class ApiMock implements Api { throw Error('not implemented') } + reverseGeocode(query: POIQuery, bbox: Bbox): Promise { + throw Error('not implemented') + } + info(): Promise { throw Error('not implemented') } diff --git a/test/stores/RouteStore.test.ts b/test/stores/RouteStore.test.ts index 6d5f028b..e333d7ee 100644 --- a/test/stores/RouteStore.test.ts +++ b/test/stores/RouteStore.test.ts @@ -1,9 +1,18 @@ import RouteStore from '@/stores/RouteStore' -import QueryStore, { QueryPoint, QueryPointType } from '@/stores/QueryStore' +import { QueryPoint, QueryPointType } from '@/stores/QueryStore' import Api from '@/api/Api' -import { ApiInfo, GeocodingResult, Path, RoutingArgs, RoutingResult } from '@/api/graphhopper' +import { + ApiInfo, + Bbox, + GeocodingResult, + Path, + ReverseGeocodingHit, + RoutingArgs, + RoutingResult, +} from '@/api/graphhopper' import Dispatcher, { Action } from '@/stores/Dispatcher' import { ClearPoints, ClearRoute, RemovePoint, SetPoint, SetSelectedPath } from '@/actions/Actions' +import { POIAndQuery, POIQuery } from '@/pois/AddressParseResult' describe('RouteStore', () => { afterEach(() => { @@ -69,6 +78,10 @@ class DummyApi implements Api { throw Error('not implemented') } + reverseGeocode(query: POIQuery, bbox: Bbox): Promise { + throw Error('not implemented') + } + info(): Promise { throw Error('not implemented') }