diff --git a/examples/iceberg/components/IcebergMap.vue b/examples/iceberg/components/IcebergMap.vue index 5933cf3fa9..3a05bbde85 100644 --- a/examples/iceberg/components/IcebergMap.vue +++ b/examples/iceberg/components/IcebergMap.vue @@ -142,6 +142,7 @@ watch(map, (map) => { toZoomLevel: 7, }), pluginReverseGeocoder({ + type: 'wps', url: 'https://geodienste.hamburg.de/HH_WPS', coordinateSources: [ { diff --git a/examples/snowbox/index.js b/examples/snowbox/index.js index 764b14f1e5..1fc53745e9 100644 --- a/examples/snowbox/index.js +++ b/examples/snowbox/index.js @@ -359,7 +359,10 @@ addPlugin( addPlugin( map, pluginReverseGeocoder({ - url: 'https://geodienste.hamburg.de/HH_WPS', + // type: 'wps', + // url: 'https://geodienste.hamburg.de/HH_WPS', + type: 'nominatim', + url: 'https://polar.dataport.de/nominatim/reverse', coordinateSources: [ { plugin: 'pins', @@ -520,6 +523,7 @@ addPlugin( displayComponent: true, layoutTag: 'TOP_LEFT', searchMethods: [ + /* { queryParameters: { searchStreets: true, @@ -528,6 +532,11 @@ addPlugin( type: 'mpapi', url: 'https://geodienste.hamburg.de/HH_WFS_GAGES?service=WFS&request=GetFeature&version=2.0.0', }, + */ + { + type: 'nominatim', + url: 'https://polar.dataport.de/nominatim/search', + }, ], minLength: 3, waitMs: 300, diff --git a/src/client.ts b/src/client.ts index 6fc6cd943b..a643d01b1a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -68,7 +68,7 @@ function addPlugins(map: typeof PolarContainer, enabledPlugins: string[]) { layoutTag: 'BOTTOM_LEFT', }), enabledPlugins.includes('reverseGeocoder') && - ReverseGeocoder({ url: '' }), + ReverseGeocoder({ url: '', type: 'wps' }), enabledPlugins.includes('scale') && Scale({ displayComponent: true, diff --git a/src/lib/getFeatures/bkg.ts b/src/lib/getFeatures/bkg.ts index 1b794b3ec5..9390c96b09 100644 --- a/src/lib/getFeatures/bkg.ts +++ b/src/lib/getFeatures/bkg.ts @@ -3,7 +3,8 @@ import type { PolarGeoJsonFeature, PolarGeoJsonFeatureCollection } from '@/core' import type { BKGParameters } from './types' import { toMerged } from 'es-toolkit' -import { transform as transformCoordinates } from 'ol/proj' + +import { transformGeometry } from '@/lib/transformGeometry' import { errorCheck } from './errorCheck' @@ -56,16 +57,11 @@ export default async function ( features: featureCollection.features.map( (feature) => toMerged(feature, { - geometry: toMerged(feature.geometry, { - coordinates: - queryParameters.epsg === 'EPSG:4326' - ? (feature.geometry as Point).coordinates - : transformCoordinates( - (feature.geometry as Point).coordinates, - 'EPSG:4326', - queryParameters.epsg - ), - }), + geometry: transformGeometry( + feature.geometry, + 'EPSG:4326', + queryParameters.epsg + ), // @ts-expect-error | It is always defined in this case title: feature.properties.text, }) as PolarGeoJsonFeature diff --git a/src/lib/getFeatures/nominatim.ts b/src/lib/getFeatures/nominatim.ts new file mode 100644 index 0000000000..61b9e9667c --- /dev/null +++ b/src/lib/getFeatures/nominatim.ts @@ -0,0 +1,43 @@ +import type { GeoJSONFeatureCollection } from 'ol/format/GeoJSON' +import type { PolarGeoJsonFeatureCollection } from '@/core' +import type { NominatimParameters } from './types' + +import { transformGeometry } from '@/lib/transformGeometry' + +export default async function ( + signal: AbortSignal, + url: string, + inputValue: string, + queryParameters: NominatimParameters +): Promise { + const { epsg, maxFeatures, ...native } = queryParameters + + const fetchUrl = new URL(url) + for (const [key, value] of Object.entries(native)) { + fetchUrl.searchParams.set( + key, + Array.isArray(value) ? value.join(',') : String(value) + ) + } + if (maxFeatures) { + fetchUrl.searchParams.set('limit', String(maxFeatures)) + } + fetchUrl.searchParams.set('q', inputValue) + fetchUrl.searchParams.set('format', 'geojson') + // Setting this to 1 is currently not supported well by addressSearch plugin. The plugin expects a point geometry. + fetchUrl.searchParams.set('polygon_geojson', '0') + fetchUrl.searchParams.set('polygon_threshold', '5') + + return await fetch(fetchUrl, { + signal, + }) + .then(async (response) => await response.json()) + .then((featureCollection) => ({ + ...featureCollection, + features: featureCollection.features.map((feature) => ({ + ...feature, + title: feature.properties?.display_name, + geometry: transformGeometry(feature.geometry, 'EPSG:4326', epsg), + })), + })) +} diff --git a/src/lib/getFeatures/types.ts b/src/lib/getFeatures/types.ts index d8f3b85be5..becc799033 100644 --- a/src/lib/getFeatures/types.ts +++ b/src/lib/getFeatures/types.ts @@ -195,7 +195,7 @@ export interface WfsParameters extends QueryParameters { /** XML feature prefix from xmlns namespace without :; e.g. 'ave'. */ featurePrefix: string - /** Feature type to search for by name (see ?service=wfs&request=DescribeFeatureType). */ + /** Feature type to search for by name (see `?service=wfs&request=DescribeFeatureType`). */ typeName: string /** XML namespace of feature type to use in search. */ @@ -288,3 +288,85 @@ export interface WfsParameters extends QueryParameters { */ useRightHandWildcard?: boolean } + +export interface NominatimParameters extends QueryParameters { + /** + * Whether to include address details in the result. + */ + addressdetails?: boolean + + /** + * Name and/or type of POI. + */ + amenity?: string + + /** + * Whether to consider the viewbox as exact instead of fuzzy filter. + */ + bounded?: boolean + + /** + * City. + */ + city?: string + + /** + * Country. + */ + country?: string + + /** + * Country code (ISO 3166-1alpha2). + */ + countrycodes?: string[] + + /** + * County. + */ + county?: string + + /** + * Whether to include entrances to buildings in the results. + */ + entrances?: boolean + + /** + * Whether to add extra tags to the result. + */ + extratags?: boolean + + /** + * Which feature types to include. + */ + featureType?: 'country' | 'state' | 'city' | 'settlement' + + /** + * Which type(s) of results to return. + */ + layer?: ('address' | 'poi' | 'railway' | 'natural' | 'manmade')[] + + /** + * Whether to include more names in alternate languages. + */ + namedetails?: boolean + + /** + * Postal code. + */ + postalcode?: string + + /** + * State. + */ + state?: string + + /** + * House number and street name. + */ + street?: string + + /** + * Viewbox to prefer for searching the given term. + */ + viewbox?: [number, number, number, number] +} diff --git a/src/lib/getFeatures/wfs/parse.ts b/src/lib/getFeatures/wfs/parse.ts index c4747cf740..7fb47bdeed 100644 --- a/src/lib/getFeatures/wfs/parse.ts +++ b/src/lib/getFeatures/wfs/parse.ts @@ -1,8 +1,8 @@ import type { PolarGeoJsonFeature, PolarGeoJsonFeatureCollection } from '@/core' -import { toMerged } from 'es-toolkit' import { GeoJSON, WFS } from 'ol/format' -import { transform as transformCoordinates } from 'ol/proj' + +import { transformGeometry } from '@/lib/transformGeometry' import { getFeatureTitleFromPattern } from './getFeatureTitleFromPattern' @@ -53,13 +53,11 @@ export async function parseWfsResponse( } } if (epsgCode) { - featureObject.geometry = toMerged(featureObject.geometry, { - coordinates: transformCoordinates( - featureObject.geometry.coordinates, - `EPSG:${epsgCode}`, - epsg - ), - }) + featureObject.geometry = transformGeometry( + featureObject.geometry, + `EPSG:${epsgCode}`, + epsg + ) } features.push(featureObject) }) diff --git a/src/lib/transformGeometry.ts b/src/lib/transformGeometry.ts new file mode 100644 index 0000000000..13f9433020 --- /dev/null +++ b/src/lib/transformGeometry.ts @@ -0,0 +1,40 @@ +import type { Geometry, Point, Polygon } from 'geojson' + +import { transform as transformCoordinates } from 'ol/proj' + +/** + * Transforms the coordinates of a GeoJSON geometry from one EPSG projection to another. + * + * @remarks + * Currently, supports only Point and Polygon geometries. + * For unsupported geometry types, an error is thrown. + * + * @param geometry - The GeoJSON geometry to transform. + * @param sourceEpsg - The EPSG code of the source projection. + * @param targetEpsg - The EPSG code of the target projection. + * @returns The transformed GeoJSON geometry. + */ +export function transformGeometry( + geometry: Geometry, + sourceEpsg: string, + targetEpsg: string +): Point | Polygon { + if (geometry.type === 'Point') { + return { + ...geometry, + coordinates: transformCoordinates( + geometry.coordinates, + sourceEpsg, + targetEpsg + ), + } + } else if (geometry.type === 'Polygon') { + return { + ...geometry, + coordinates: geometry.coordinates.map((ring) => + ring.map((coord) => transformCoordinates(coord, sourceEpsg, targetEpsg)) + ), + } + } + throw new Error(`Unsupported geometry type: ${geometry.type}`) +} diff --git a/src/plugins/addressSearch/types.ts b/src/plugins/addressSearch/types.ts index 989824e6f4..89daffbfd4 100644 --- a/src/plugins/addressSearch/types.ts +++ b/src/plugins/addressSearch/types.ts @@ -126,7 +126,7 @@ export interface AddressSearchPluginOptions extends PluginOptions { /** Possible search methods by type. */ // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -export type SearchType = 'bkg' | 'wfs' | 'mpapi' | string +export type SearchType = 'bkg' | 'wfs' | 'mpapi' | 'nominatim' | string export type SearchDisplayMode = 'mixed' | 'categorized' @@ -176,7 +176,7 @@ export interface SearchMethodConfiguration { /** * The object further describes details for the search request. - * Its contents vary by service type, see {@link BKGParameters}, {@link MpapiParameters} or {@link WfsParameters}. + * Its contents vary by service type, see {@link BKGParameters}, {@link MpapiParameters}, {@link WfsParameters} or {@link NominatimParameters}. */ queryParameters?: QueryParameters diff --git a/src/plugins/addressSearch/utils/methodContainer.ts b/src/plugins/addressSearch/utils/methodContainer.ts index e9c6d78d77..b0ccb01ab7 100644 --- a/src/plugins/addressSearch/utils/methodContainer.ts +++ b/src/plugins/addressSearch/utils/methodContainer.ts @@ -2,10 +2,11 @@ import type { SearchMethodFunction } from '../types' import bkg from '@/lib/getFeatures/bkg' import mpapi from '@/lib/getFeatures/mpapi' +import nominatim from '@/lib/getFeatures/nominatim' import { getWfsFeatures } from '@/lib/getFeatures/wfs' export function getMethodContainer() { - const methods = { bkg, mpapi, wfs: getWfsFeatures } + const methods = { bkg, mpapi, nominatim, wfs: getWfsFeatures } return { registerSearchMethods: ( diff --git a/src/plugins/reverseGeocoder/store.ts b/src/plugins/reverseGeocoder/store.ts index a867aa744e..2f4f83b63a 100644 --- a/src/plugins/reverseGeocoder/store.ts +++ b/src/plugins/reverseGeocoder/store.ts @@ -21,7 +21,8 @@ import { useCoreStore } from '@/core/stores' import { indicateLoading } from '@/lib/indicateLoading' import { PluginId } from './types' -import { reverseGeocode as reverseGeocodeUtil } from './utils/reverseGeocode' +import { reverseGeocodeNominatim } from './utils/reverseGeocodeNominatim' +import { reverseGeocodeWps } from './utils/reverseGeocodeWps' /* eslint-disable tsdoc/syntax */ /** @@ -78,11 +79,20 @@ export const useReverseGeocoderStore = defineStore( abortController.value = new AbortController() const signal = toRaw(abortController.value.signal) try { - const feature = await reverseGeocodeUtil( - configuration.value.url, + const reverseGeocodeUtil = { + wps: (params) => + reverseGeocodeWps({ + ...params, + serviceEpsg: configuration.value.epsg || 'EPSG:25832', + }), + nominatim: reverseGeocodeNominatim, + }[configuration.value.type] + const feature = await reverseGeocodeUtil({ + url: configuration.value.url, coordinate, - signal - ) + epsg: coreStore.configuration.epsg, + signal, + }) if (configuration.value.addressTarget) { passFeatureToTarget(configuration.value.addressTarget, feature) } @@ -130,12 +140,12 @@ if (import.meta.vitest) { const { createPinia, setActivePinia } = await import('pinia') const { reactive } = await import('vue') const useCoreStoreFile = await import('@/core/stores') - const reverseGeocodeUtilFile = await import('./utils/reverseGeocode') + const reverseGeocodeUtilFile = await import('./utils/reverseGeocodeWps') const indicateLoadingFile = await import('@/lib/indicateLoading') /* eslint-disable no-empty-pattern */ const test = _test.extend<{ - reverseGeocodeUtil: Mock + reverseGeocodeUtil: Mock indicateLoading: Mock coreStore: Reactive> store: ReturnType @@ -143,7 +153,7 @@ if (import.meta.vitest) { reverseGeocodeUtil: [ async ({}, use) => { const reverseGeocodeUtil = vi - .spyOn(reverseGeocodeUtilFile, 'reverseGeocode') + .spyOn(reverseGeocodeUtilFile, 'reverseGeocodeWps') .mockResolvedValue(null as unknown as ReverseGeocoderFeature) await use(reverseGeocodeUtil) }, @@ -168,7 +178,9 @@ if (import.meta.vitest) { } const coreStore = reactive({ configuration: { + epsg: 'EPSG:25832', [PluginId]: { + type: 'wps', url: 'https://wps.example', coordinateSources: [{ plugin: 'pins', key: 'coordinate' }], addressTarget: { key: 'addressTarget' }, @@ -212,11 +224,13 @@ if (import.meta.vitest) { } pluginStore.pins.coordinate = [1, 2] await new Promise((resolve) => setTimeout(resolve)) - expect(reverseGeocodeUtil).toHaveBeenCalledWith( - 'https://wps.example', - [1, 2], - store.abortController?.signal - ) + expect(reverseGeocodeUtil).toHaveBeenCalledExactlyOnceWith({ + url: 'https://wps.example', + coordinate: [1, 2], + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: store.abortController?.signal, + }) }) test('passes geocoding result to address target', async ({ @@ -229,8 +243,7 @@ if (import.meta.vitest) { feature as unknown as ReverseGeocoderFeature ) await store.reverseGeocode([3, 4]) - expect(coreStore.addressTarget).toHaveBeenCalledOnce() - expect(coreStore.addressTarget).toHaveBeenCalledWith(feature) + expect(coreStore.addressTarget).toHaveBeenCalledExactlyOnceWith(feature) }) test('zooms to input coordinate', async ({ diff --git a/src/plugins/reverseGeocoder/types.ts b/src/plugins/reverseGeocoder/types.ts index 9a00697cf7..a57f01ca0b 100644 --- a/src/plugins/reverseGeocoder/types.ts +++ b/src/plugins/reverseGeocoder/types.ts @@ -10,6 +10,11 @@ export const PluginId = 'reverseGeocoder' * Plugin options for reverse geocoder plugin. */ export interface ReverseGeocoderPluginOptions extends PluginOptions { + /** + * Type of reverse geocoding service. + */ + type: 'wps' | 'nominatim' + /** * URL of a WPS service to use for reverse geocoding. */ @@ -26,6 +31,14 @@ export interface ReverseGeocoderPluginOptions extends PluginOptions { */ coordinateSources?: StoreReference[] + /** + * EPSG code of the coordinate system used by the service. + * Considered only if {@link ReverseGeocoderPluginOptions.type | type} is set to `'wps'`. + * + * @defaultValue `'EPSG:25832'` + */ + epsg?: string + /** * Zoom level to zoom to when a successful answer was received. */ diff --git a/src/plugins/reverseGeocoder/utils/reverseGeocodeNominatim.ts b/src/plugins/reverseGeocoder/utils/reverseGeocodeNominatim.ts new file mode 100644 index 0000000000..775d7c404b --- /dev/null +++ b/src/plugins/reverseGeocoder/utils/reverseGeocodeNominatim.ts @@ -0,0 +1,98 @@ +import type { FeatureCollection, Point } from 'geojson' +import type { ReverseGeocoderFeature } from '../types' + +import { transform as transformCoordinate } from 'ol/proj' + +interface NominatimReverseGeocodeProperties { + address: { + house_number?: string + road?: string + hamlet?: string + village?: string + town?: string + suburb?: string + city_district?: string + city?: string + county?: string + state_district?: string + state?: string + // eslint-disable-next-line @typescript-eslint/naming-convention + 'ISO3166-2-lvl4'?: string + postcode?: string + country?: string + country_code?: string + } + category: string + display_name: string + importance: number + licence: string + name: string + osm_id: string + osm_type: string + place_id: number + type: string + extratags?: Record + icon?: string + place_rank?: number +} + +export async function reverseGeocodeNominatim({ + url, + coordinate, + epsg, + signal, +}: { + url: string + coordinate: [number, number] + epsg: string + signal: AbortSignal +}): Promise { + const searchCoordinate = transformCoordinate( + coordinate, + epsg, + 'EPSG:4326' + ) as [number, number] + + const fetchUrl = new URL(url) + fetchUrl.searchParams.set('lat', searchCoordinate[1].toString()) + fetchUrl.searchParams.set('lon', searchCoordinate[0].toString()) + fetchUrl.searchParams.set('format', 'geojson') + + const result: FeatureCollection = + await fetch(fetchUrl, { signal }).then((response) => response.json()) + + const feature = result.features[0] + if (!feature) { + throw new Error('No features returned from Nominatim reverse geocode') + } + const { properties } = feature + + return { + type: 'reverse_geocoded', + title: [ + [properties.address.road, properties.address.house_number] + .filter((x) => x) + .join(' '), + properties.address.town || + properties.address.city || + properties.address.village, + ] + .filter((x) => x) + .join(', '), + properties, + geometry: { + // as clicked by user - usually want to keep this since user is pointing at something + coordinates: coordinate, + type: 'Point', + }, + addressGeometry: { + // as returned by reverse geocoder + coordinates: transformCoordinate( + feature.geometry.coordinates as [number, number], + 'EPSG:4326', + epsg + ), + type: 'Point', + }, + } +} diff --git a/src/plugins/reverseGeocoder/utils/reverseGeocode.ts b/src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts similarity index 86% rename from src/plugins/reverseGeocoder/utils/reverseGeocode.ts rename to src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts index ba68c53620..f30d280672 100644 --- a/src/plugins/reverseGeocoder/utils/reverseGeocode.ts +++ b/src/plugins/reverseGeocoder/utils/reverseGeocodeWps.ts @@ -1,6 +1,9 @@ +import type { Coordinate } from 'ol/coordinate' import type { ReverseGeocoderFeature } from '../types' -const buildPostBody = ([x, y]: [number, number]) => ` ` { +}): Promise { const response = await fetch(url, { method: 'POST', - body: buildPostBody(coordinate), + body: buildPostBody(transformCoordinate(coordinate, epsg, serviceEpsg)), signal, }) @@ -85,9 +96,16 @@ export async function reverseGeocode( if (import.meta.vitest) { const { beforeEach, expect, test, vi } = import.meta.vitest + const { + default: { registerProjections }, + } = await import('@masterportal/masterportalapi/src/crs') + const { + default: { namedProjections }, + } = await import('@/core/utils/defaults') beforeEach(() => { vi.restoreAllMocks() + registerProjections(namedProjections) }) const testUrl = 'https://wps.example' @@ -143,7 +161,13 @@ if (import.meta.vitest) { const abortController = new AbortController() await expect( - reverseGeocode(testUrl, testCoordinates, abortController.signal) + reverseGeocodeWps({ + url: testUrl, + coordinate: testCoordinates, + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: abortController.signal, + }) ).rejects.toThrow('Failed to parse XML response') }) @@ -154,7 +178,13 @@ if (import.meta.vitest) { const abortController = new AbortController() await expect( - reverseGeocode(testUrl, testCoordinates, abortController.signal) + reverseGeocodeWps({ + url: testUrl, + coordinate: testCoordinates, + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: abortController.signal, + }) ).rejects.toThrow('Response does not contain an "Adresse" element.') }) @@ -162,12 +192,15 @@ if (import.meta.vitest) { const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce({ text: () => Promise.resolve(testResponse), } as Response) + const abortController = new AbortController() - const feature = await reverseGeocode( - testUrl, - testCoordinates, - abortController.signal - ) + const feature = await reverseGeocodeWps({ + url: testUrl, + coordinate: testCoordinates, + epsg: 'EPSG:25832', + serviceEpsg: 'EPSG:25832', + signal: abortController.signal, + }) expect(fetchMock).toHaveBeenCalledOnce() expect(fetchMock).toHaveBeenCalledWith(testUrl, {