Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ REFRESH_TOKEN_SECRET="I ALSO WANT TO BE CHANGED"
REFRESH_TOKEN_ALGORITHM="sha256"
REFRESH_TOKEN_VALIDITY_MS=604800000 # 1 week

NOMINATIM_SEARCH_API="https://nominatim.openstreetmap.org/search"

OSEM_GITHUB_URL="https://github.com/openSenseMap/frontend"
OSEM_API_URL="https://api.opensensemap.org/"
DIRECTUS_URL="https://coelho.opensensemap.org"
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,10 @@ instructions:
You can configure the API endpoint and/or map tiles using the following
environmental variables:

| ENV | Default value |
| ------------------- | ------------------------------------ |
| OSEM_API_URL | https://api.testing.opensensemap.org |
| DATABASE_URL | <YOUR_POSTGRES_URL> |
| MAPBOX_ACCESS_TOKEN | <YOUR_MAPBOX_ACCESS_TOKEN> |
| ENV | Default value |
| ------------ | ------------------------------------ |
| OSEM_API_URL | https://api.testing.opensensemap.org |
| DATABASE_URL | <YOUR_POSTGRES_URL> |

You can create a copy of `.env.example`, rename it to `.env` and set the values.

Expand Down
4 changes: 2 additions & 2 deletions app/components/header/nav-bar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import { useTranslation } from 'react-i18next'
import { useMap } from 'react-map-gl/maplibre'
import NavbarHandler from './nav-bar-handler'
import FilterVisualization from '~/components/map/filter-visualization'
import { type Device } from '~/db/schema'
import { DeviceFeatureCollection } from '~/components/search/search-types'
import { cn } from '~/lib/utils'
import { topbarSurface } from '~/components/map/topbar-styles'

interface NavBarProps {
devices: Device[]
devices: DeviceFeatureCollection
}

export const NavbarContext = createContext({
Expand Down
4 changes: 2 additions & 2 deletions app/components/header/nav-bar/nav-bar-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import FilterOptions from './filter-options/filter-options'
import FilterTags from './filter-options/filter-tags'
import useKeyboardNav from './use-keyboard-nav'
import Search from '~/components/search'
import { type Device } from '~/db/schema'
import { cn } from '~/lib/utils'
import { DeviceFeatureCollection } from '~/components/search/search-types'

interface NavBarHandlerProps {
devices: Device[]
devices: DeviceFeatureCollection
searchString: string
}

Expand Down
205 changes: 96 additions & 109 deletions app/components/search/index.tsx
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()
Comment thread
scheidtdav marked this conversation as resolved.

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>
)
}
70 changes: 70 additions & 0 deletions app/components/search/nominatim.ts
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)
}
Loading
Loading