-
-
Notifications
You must be signed in to change notification settings - Fork 5
Feat/migrate to nominatim for search #926
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
e6e63e7
feat: add public nominatim search api to env
jona159 6d76b6d
fix: rm mapbox references
jona159 8eaff5c
feat: add types
jona159 7f7bf5a
feat: nominatim search method
jona159 be730a9
feat: refactor search for debounced nominatim search
jona159 5dce979
feat: adjust search list and helpers for normalized result shapes
jona159 edd8c1e
fix: types
jona159 7aa8418
fix: format
jona159 6bb1d64
fix: type
jona159 d1a8e65
chore: rm unused dependency
jona159 3c60c36
refactor: simplify type
jona159 7307af0
fix: magic numbers, pass app language not browser lang
jona159 3e60bd6
Merge branch 'dev' into feat/migrate-to-nominatim-for-search
jona159 eb1829a
Merge branch 'dev' into feat/migrate-to-nominatim-for-search
scheidtdav File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,127 +1,114 @@ | ||
| import getUserLocale from 'get-user-locale' | ||
| import { useEffect, useState } from 'react' | ||
| import { useTranslation, Trans } from 'react-i18next' | ||
| import { Trans, useTranslation } from 'react-i18next' | ||
| import SearchList from './search-list' | ||
| import { searchNominatimLocations } from './nominatim' | ||
| import { | ||
| DeviceFeatureCollection, | ||
| DeviceSearchResult, | ||
| LocationSearchResult, | ||
| } from './search-types' | ||
|
|
||
| interface SearchProps { | ||
| searchString: string | ||
| devices: any | ||
| devices: DeviceFeatureCollection | ||
| } | ||
|
|
||
| const MIN_QUERY_LENGTH = 2 | ||
| const QUERY_DEBOUNCE_MS = 500 | ||
|
|
||
| function getDeviceResults( | ||
| searchString: string, | ||
| devices: DeviceFeatureCollection, | ||
| ): DeviceSearchResult[] { | ||
| const normalizedSearch = searchString.toLowerCase().trim() | ||
|
|
||
| if (!normalizedSearch) return [] | ||
|
|
||
| return devices.features | ||
| .filter((device) => { | ||
| const name = device.properties.name.toLowerCase() | ||
| const id = device.properties.id.toLowerCase() | ||
|
|
||
| return name.includes(normalizedSearch) || id.includes(normalizedSearch) | ||
| }) | ||
| .slice(0, 4) | ||
| .map((device) => ({ | ||
| type: 'device', | ||
| id: device.properties.id, | ||
| displayName: device.properties.name, | ||
| deviceId: device.properties.id, | ||
| lng: device.properties.longitude, | ||
| lat: device.properties.latitude, | ||
| })) | ||
| } | ||
|
|
||
| export default function Search(props: SearchProps) { | ||
| let { t } = useTranslation('search') | ||
|
|
||
| const userLocaleString = getUserLocale()?.toString() || 'en' | ||
| const [searchResultsLocation, setSearchResultsLocation] = useState<any[]>([]) | ||
| const [searchResultsDevice, setSearchResultsDevice] = useState<any[]>([]) | ||
|
|
||
| /** | ||
| * One of the functions that is called when the user types in the search bar. It returns the search results for locations, retrived from the mapbox geocode API. | ||
| * | ||
| * @param searchstring string to search for locations on mapbox geocode API | ||
| */ | ||
| function getLocations(searchstring: string) { | ||
| var url: URL = new URL(ENV.MAPBOX_GEOCODING_API + `${searchstring}.json`) | ||
|
|
||
| url.search = new URLSearchParams({ | ||
| access_token: `${ENV.MAPBOX_ACCESS_TOKEN}`, | ||
| limit: '4', | ||
| language: userLocaleString, | ||
| }).toString() | ||
|
|
||
| var requestOptions: RequestInit = { | ||
| method: 'GET', | ||
| redirect: 'follow', | ||
| } | ||
| const { t, i18n } = useTranslation('search') | ||
|
|
||
| fetch(url, requestOptions) | ||
| .then((response) => response.json()) | ||
| .then((data) => { | ||
| if (data.features.length === 0) { | ||
| setSearchResultsLocation([]) | ||
| } else { | ||
| data.features.forEach((feature: any) => { | ||
| feature.type = 'location' | ||
| }) | ||
| setSearchResultsLocation(data.features) | ||
| } | ||
| }) | ||
| .catch((error) => console.log('error', error)) | ||
| } | ||
| const userLocaleString = i18n.language || 'en' | ||
|
|
||
| /** | ||
| * One of the functions that is called when the user types in the search bar. It returns the search results for devices, retrived from the device list. The device list is proviided by the database in the /explore route and passed down to the search component. | ||
| * | ||
| * @param searchString string to search for devices in the device list | ||
| */ | ||
| function getDevices(searchString: string) { | ||
| var results: any[] = [] | ||
| var deviceResults = 0 | ||
|
|
||
| for (let index = 0; index < props.devices.features.length; index++) { | ||
| const device = props.devices.features[index] | ||
| if (deviceResults === 4) { | ||
| setSearchResultsDevice(results) | ||
| return | ||
| } | ||
| if ( | ||
| device.properties.name | ||
| .toLowerCase() | ||
| .includes(searchString.toLowerCase()) || | ||
| device.properties.id.toLowerCase().includes(searchString.toLowerCase()) | ||
| ) { | ||
| var newStructured = { | ||
| display_name: device.properties.name, | ||
| deviceId: device.properties.id, | ||
| lng: device.properties.longitude, | ||
| lat: device.properties.latitude, | ||
| type: 'device', | ||
| } | ||
| results.push(newStructured) | ||
| deviceResults++ | ||
| setSearchResultsDevice(results) | ||
| } | ||
| if (deviceResults === 0) { | ||
| setSearchResultsDevice([]) | ||
| } | ||
| } | ||
| } | ||
| const [searchResultsLocation, setSearchResultsLocation] = useState< | ||
| LocationSearchResult[] | ||
| >([]) | ||
| const [searchResultsDevice, setSearchResultsDevice] = useState< | ||
| DeviceSearchResult[] | ||
| >([]) | ||
|
|
||
| /** | ||
| * useEffect hook that is called when the search string changes. It calls the getLocations and getDevices functions to get the search results for locations and devices. | ||
| */ | ||
| useEffect(() => { | ||
| if (props.searchString.length >= 2) { | ||
| getLocations(props.searchString) | ||
| getDevices(props.searchString) | ||
| } | ||
| if (props.searchString.length < 2) { | ||
| const query = props.searchString.trim() | ||
|
|
||
| if (query.length < MIN_QUERY_LENGTH) { | ||
| setSearchResultsLocation([]) | ||
| setSearchResultsDevice([]) | ||
| return | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [props.searchString]) | ||
|
|
||
| if (searchResultsLocation.length > 0 || searchResultsDevice.length > 0) | ||
| return ( | ||
| <div className="mt-2"> | ||
| <SearchList | ||
| searchResultsLocation={searchResultsLocation} | ||
| searchResultsDevice={searchResultsDevice} | ||
| /> | ||
| <div className="flex"> | ||
| <div className="text-center text-sm text-gray-500"> | ||
| <p> | ||
| <Trans | ||
| t={t} | ||
| i18nKey={'hint_select_result'} | ||
| components={[<kbd key="select_result"></kbd>]} | ||
| /> | ||
| </p> | ||
| </div> | ||
|
|
||
| setSearchResultsDevice(getDeviceResults(query, props.devices)) | ||
|
|
||
| const controller = new AbortController() | ||
|
|
||
| const timeoutId = window.setTimeout(() => { | ||
| void searchNominatimLocations({ | ||
| query, | ||
| locale: userLocaleString, | ||
| signal: controller.signal, | ||
| }) | ||
| .then(setSearchResultsLocation) | ||
| .catch((error) => { | ||
| if ((error as Error).name !== 'AbortError') { | ||
| console.error('Location search error', error) | ||
| setSearchResultsLocation([]) | ||
| } | ||
| }) | ||
| }, QUERY_DEBOUNCE_MS) | ||
|
|
||
| return () => { | ||
| window.clearTimeout(timeoutId) | ||
| controller.abort() | ||
| } | ||
| }, [props.searchString, props.devices, userLocaleString]) | ||
|
|
||
| if (searchResultsLocation.length === 0 && searchResultsDevice.length === 0) { | ||
| return null | ||
| } | ||
|
|
||
| return ( | ||
| <div className="mt-2"> | ||
| <SearchList | ||
| searchResultsLocation={searchResultsLocation} | ||
| searchResultsDevice={searchResultsDevice} | ||
| /> | ||
| <div className="flex"> | ||
| <div className="text-center text-sm text-gray-500"> | ||
| <p> | ||
| <Trans | ||
| t={t} | ||
| i18nKey="hint_select_result" | ||
| components={[<kbd key="select_result"></kbd>]} | ||
| /> | ||
| </p> | ||
| </div> | ||
| </div> | ||
| ) | ||
|
|
||
| return null | ||
| </div> | ||
| ) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| import { type LocationSearchResult } from './search-types' | ||
|
|
||
| type NominatimPlace = { | ||
| place_id: number | string | ||
| osm_type?: string | ||
| osm_id?: number | string | ||
| display_name: string | ||
| lat: string | ||
| lon: string | ||
| boundingbox?: [string, string, string, string] // [south, north, west, east] | ||
| class?: string | ||
| type?: string | ||
| addresstype?: string | ||
| } | ||
|
|
||
| export function nominatimToSearchResult( | ||
| place: NominatimPlace, | ||
| ): LocationSearchResult { | ||
| const lat = Number(place.lat) | ||
| const lng = Number(place.lon) | ||
|
|
||
| const bbox = place.boundingbox | ||
| ? ([ | ||
| [Number(place.boundingbox[2]), Number(place.boundingbox[0])], | ||
| [Number(place.boundingbox[3]), Number(place.boundingbox[1])], | ||
| ] satisfies LocationSearchResult['bbox']) | ||
| : undefined | ||
|
|
||
| return { | ||
| type: 'location', | ||
| id: String(place.place_id ?? `${place.osm_type}-${place.osm_id}`), | ||
| displayName: place.display_name, | ||
| center: [lng, lat], | ||
| bbox, | ||
| locationKind: place.addresstype ?? place.type ?? place.class, | ||
| } | ||
| } | ||
|
|
||
| export async function searchNominatimLocations({ | ||
| query, | ||
| locale, | ||
| signal, | ||
| }: { | ||
| query: string | ||
| locale: string | ||
| signal?: AbortSignal | ||
| }): Promise<LocationSearchResult[]> { | ||
| const url = new URL(ENV.NOMINATIM_SEARCH_API) | ||
|
|
||
| url.search = new URLSearchParams({ | ||
| q: query, | ||
| format: 'jsonv2', | ||
| limit: '4', | ||
| addressdetails: '1', | ||
| 'accept-language': locale, | ||
| }).toString() | ||
|
|
||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| signal, | ||
| }) | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Nominatim search failed with ${response.status}`) | ||
| } | ||
|
|
||
| const data = (await response.json()) as NominatimPlace[] | ||
|
|
||
| return data.map(nominatimToSearchResult) | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.