Skip to content

Commit

Permalink
feat: unify functionality of useWfs and useNominatim into on useSearc…
Browse files Browse the repository at this point in the history
…h hook

BREAKING CHANGE: The `useWfs` and `useNominatim` hooks are removed. You can instead now use the `useSearch` hook with the needed search functions for example like this:

```
const [searchTerm, setSearchTerm] = useState<string>('');
const searchFunction = useCallback(createNominatimSearchFunction({}), []);
const {
  featureCollection,
  loading
} = useSearch(searchFunction, searchTerm);
```
  • Loading branch information
simonseyock committed May 29, 2024
1 parent 270bbad commit 9d37d20
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 323 deletions.
45 changes: 19 additions & 26 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@terrestris/ol-util": "^19.0.0",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^15.0.7",
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
Expand Down
128 changes: 128 additions & 0 deletions src/Hooks/search/createNominatimSearchFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// See https://nominatim.org/release-docs/develop/api/Output/ for some more information
import { UrlUtil } from '@terrestris/base-util';
import { Feature, Geometry } from 'geojson';
import { Extent } from 'ol/extent';

import { SearchFunction } from './useSearch/useSearch';

export type NominatimPlace = {
// eslint-disable-next-line camelcase
place_id: number;
// eslint-disable-next-line camelcase
osm_type: string;
// eslint-disable-next-line camelcase
osm_id: number;
boundingbox: string[];
// eslint-disable-next-line camelcase
display_name: string;
category: string;
type: string;
importance: number;
icon?: string;
address?: any;
extratags?: any;
namedetails?: any;
geojson: Geometry;
licence: string;
};

export type NominatimArgs = {
/**
* The Nominatim Base URL. See https://wiki.openstreetmap.org/wiki/Nominatim
*/
nominatimBaseUrl?: string;
/**
* The preferred area to find search results in [left],[top],[right],[bottom].
*/
viewBox?: string;
/**
* Restrict the results to only items contained with the bounding box.
* Restricting the results to the bounding box also enables searching by
* amenity only. For example a search query of just "[pub]" would normally be
* rejected but with bounded=1 will result in a list of items matching within
* the bounding box.
*/
bounded?: number;
/**
* Include a breakdown of the address into elements.
*/
addressDetails?: number;
/**
* Limit the number of returned results.
*/
limit?: number;
/**
* Limit search results to a specific country (or a list of countries).
* [countrycode] should be the ISO 3166-1alpha2 code, e.g. gb for the United
* Kingdom, de for Germany, etc.
*/
countryCodes?: string;
/**
* Preferred language order for showing search results, overrides the value
* specified in the "Accept-Language" HTTP header. Either use a standard RFC2616
* accept-language string or a simple comma-separated list of language codes.
*/
searchResultLanguage?: string;
};

export const createNominatimSearchFunction = ({
addressDetails = 1,
bounded = 1,
countryCodes = 'de',
limit = 10,
nominatimBaseUrl = 'https://nominatim.openstreetmap.org/search?',
searchResultLanguage,
viewBox = '-180,90,180,-90'
}: NominatimArgs = {}): SearchFunction<Geometry, NominatimPlace> => {
return async (searchTerm) => {
const baseParams = {
format: 'json',
viewbox: viewBox,
bounded: bounded,
// eslint-disable-next-line camelcase
polygon_geojson: '1',
addressdetails: addressDetails,
limit: limit,
countrycodes: countryCodes,
q: searchTerm
};

const getRequestParams = UrlUtil.objectToRequestString(baseParams);

let fetchOpts: RequestInit = {};
if (searchResultLanguage) {
fetchOpts = {
headers: {
'accept-language': searchResultLanguage
}
};
}
const response = await fetch(`${nominatimBaseUrl}${getRequestParams}`, fetchOpts);
if (!response.ok) {
throw new Error(`Return code: ${response.status}`);
}
const places = await response.json() as NominatimPlace[];
return {
type: 'FeatureCollection',
features: places.map(p => ({
type: 'Feature',
geometry: p.geojson,
properties: p
}))
};
};
};

export const createNominatimGetValueFunction = () =>
(feature: Feature<Geometry, NominatimPlace>) => feature.properties.display_name;

export const createNominatimGetExtentFunction = () =>
(feature: Feature<Geometry, NominatimPlace>) => {
const bbox: number[] = feature.properties.boundingbox.map(parseFloat);
return [
bbox[2],
bbox[0],
bbox[3],
bbox[1]
] as Extent;
};
70 changes: 70 additions & 0 deletions src/Hooks/search/createWfsSearchFunction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import WfsFilterUtil, { SearchConfig } from '@terrestris/ol-util/dist/WfsFilterUtil/WfsFilterUtil';
import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import _isNil from 'lodash/isNil';
import OlFormatGeoJSON from 'ol/format/GeoJSON';
import OlFormatGml3 from 'ol/format/GML3';

import { SearchFunction } from './useSearch/useSearch';

export type WfsArgs = {
additionalFetchOptions?: Partial<RequestInit>;
baseUrl: string;
} & Omit<SearchConfig, 'olFilterOnly'|'filter'|'wfsFormatOptions'>;

export const createWfsSearchFunction = <
G extends Geometry = Geometry,
T extends GeoJsonProperties = Record<string, any>,
C extends FeatureCollection<G, T> = FeatureCollection<G, T>
>({
additionalFetchOptions = {},
baseUrl,
featureNS,
featurePrefix,
featureTypes,
geometryName,
maxFeatures,
outputFormat = 'application/json',
srsName = 'EPSG:4326',
attributeDetails,
propertyNames
}: WfsArgs): SearchFunction<G, T, C> => {

return async searchTerm => {
const request = WfsFilterUtil.getCombinedRequests({
featureNS,
featurePrefix,
featureTypes,
geometryName,
maxFeatures,
outputFormat,
srsName,
attributeDetails,
propertyNames
}, searchTerm) as Element;
const requestBody = (new XMLSerializer()).serializeToString(request);
if (!_isNil(request)) {
const response = await fetch(`${baseUrl}`, {
method: 'POST',
credentials: additionalFetchOptions?.credentials ?? 'same-origin',
body: requestBody,
...additionalFetchOptions
});

if (outputFormat.includes('json')) {
return response.json() as unknown as C;
} else {
const responseString = await response.text();

const formatGml = new OlFormatGml3({
featureNS,
srsName
});

const formatGeoJson = new OlFormatGeoJSON();
return formatGeoJson.writeFeaturesObject(formatGml.readFeatures(responseString)) as C;
}
} else {
throw new Error('WFS request is empty/null');
}
};
};
72 changes: 72 additions & 0 deletions src/Hooks/search/useSearch/useSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { FeatureCollection, GeoJsonProperties, Geometry } from 'geojson';
import _isNil from 'lodash/isNil';
import { useEffect, useState } from 'react';

export type SearchFunction<
G extends Geometry = Geometry,
T extends GeoJsonProperties = Record<string, any>,
C extends FeatureCollection<G, T> = FeatureCollection<G, T>
> =
(searchTerm: string) => Promise<C|undefined>;

export type SearchOptions<
G extends Geometry = Geometry,
T extends NonNullable<GeoJsonProperties> = Record<string, any>,
C extends FeatureCollection<G, T> = FeatureCollection<G, T>
> = {
minChars?: number;
debounceTime?: number;
onFetchError?: (error: any) => void;
onFetchSuccess?: (featureCollection: C|undefined) => void;
};

export const useSearch = <
G extends Geometry = Geometry,
T extends NonNullable<GeoJsonProperties> = Record<string, any>,
C extends FeatureCollection<G, T> = FeatureCollection<G, T>
>(
searchFunction: SearchFunction<G, T, C>,
searchTerm: string | undefined,
{
minChars = 3,
debounceTime = 100,
onFetchError = () => {},
onFetchSuccess = () => {}
}: SearchOptions<G, T, C>
) => {
const [featureCollection, setFeatureCollection] = useState<C>();
const [loading, setLoading] = useState<boolean>(false);

useEffect(() => {
if (!_isNil(searchTerm) && searchTerm.length >= minChars) {
setLoading(true);

const timeout = setTimeout(async () => {
try {
const collection = await searchFunction(searchTerm);
setFeatureCollection(collection);
onFetchSuccess(collection);
} catch (error) {
onFetchError(error);
} finally {
setLoading(false);
}
}, debounceTime);

return () => {
clearTimeout(timeout);
};

} else {
setFeatureCollection(undefined);
setLoading(false);

return undefined;
}
}, [searchFunction, searchTerm]);

return {
loading,
featureCollection
};
};
7 changes: 0 additions & 7 deletions src/Hooks/useNominatim/useNominatim.spec.tsx

This file was deleted.

Loading

0 comments on commit 9d37d20

Please sign in to comment.