diff --git a/.env.example b/.env.example index cde2cdf6..df07d61a 100644 --- a/.env.example +++ b/.env.example @@ -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" diff --git a/README.md b/README.md index 38a11a9c..29dd93dd 100644 --- a/README.md +++ b/README.md @@ -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 | | -| MAPBOX_ACCESS_TOKEN | | +| ENV | Default value | +| ------------ | ------------------------------------ | +| OSEM_API_URL | https://api.testing.opensensemap.org | +| DATABASE_URL | | You can create a copy of `.env.example`, rename it to `.env` and set the values. diff --git a/app/components/header/nav-bar/index.tsx b/app/components/header/nav-bar/index.tsx index db0d280d..28604e74 100644 --- a/app/components/header/nav-bar/index.tsx +++ b/app/components/header/nav-bar/index.tsx @@ -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({ diff --git a/app/components/header/nav-bar/nav-bar-handler.tsx b/app/components/header/nav-bar/nav-bar-handler.tsx index f469b704..2bd46faf 100644 --- a/app/components/header/nav-bar/nav-bar-handler.tsx +++ b/app/components/header/nav-bar/nav-bar-handler.tsx @@ -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 } diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index bbac1859..28d33505 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -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([]) - const [searchResultsDevice, setSearchResultsDevice] = useState([]) - - /** - * 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 ( -
- -
-
-

- ]} - /> -

-
+ + 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 ( +
+ +
+
+

+ ]} + /> +

- ) - - return null +
+ ) } diff --git a/app/components/search/nominatim.ts b/app/components/search/nominatim.ts new file mode 100644 index 00000000..9f6e81c9 --- /dev/null +++ b/app/components/search/nominatim.ts @@ -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 { + 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) +} diff --git a/app/components/search/search-list.tsx b/app/components/search/search-list.tsx index 44b92d1c..5ba9911e 100644 --- a/app/components/search/search-list.tsx +++ b/app/components/search/search-list.tsx @@ -1,5 +1,5 @@ import { Cpu, Globe, MapPin } from 'lucide-react' -import { useState, useEffect, useCallback, useContext } from 'react' +import { useCallback, useContext, useEffect, useMemo } from 'react' import { useMap } from 'react-map-gl/maplibre' import { useMatches, useNavigate, useSearchParams } from 'react-router' import { useGlobalCompareMode } from '../device-detail/useGlobalCompareMode' @@ -7,160 +7,161 @@ import { NavbarContext } from '../header/nav-bar' import useKeyboardNav from '../header/nav-bar/use-keyboard-nav' import SearchListItem from './search-list-item' import { goTo } from '~/lib/search-map-helper' +import { + type DeviceSearchResult, + type LocationSearchResult, + type SearchResult, +} from './search-types' interface SearchListProps { - searchResultsLocation: any[] - searchResultsDevice: any[] + searchResultsLocation: LocationSearchResult[] + searchResultsDevice: DeviceSearchResult[] } export default function SearchList(props: SearchListProps) { const { osem } = useMap() const navigate = useNavigate() + const matches = useMatches() + const [searchParams] = useSearchParams() const { setOpen } = useContext(NavbarContext) const [compareMode] = useGlobalCompareMode() - const matches = useMatches() + + const searchResultsAll = useMemo( + () => [...props.searchResultsDevice, ...props.searchResultsLocation], + [props.searchResultsDevice, props.searchResultsLocation], + ) + + const length = searchResultsAll.length const { cursor, setCursor, enterPress, controlPress } = useKeyboardNav( 0, 0, - props.searchResultsDevice.length + props.searchResultsLocation.length, + length, ) - const length = - props.searchResultsDevice.length + props.searchResultsLocation.length - const searchResultsAll = props.searchResultsDevice.concat( - props.searchResultsLocation, - ) - const [selected, setSelected] = useState(searchResultsAll[cursor]) - const [searchParams] = useSearchParams() - const [navigateTo, setNavigateTo] = useState( - compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` - : selected.type === 'device' - ? `/explore/${selected.deviceId + '?' + searchParams.toString()}}` - : `/explore?${searchParams.toString()}`, - ) + const selected = searchResultsAll[cursor] + + const closeSearch = useCallback(() => { + setOpen(false) + }, [setOpen]) const handleNavigate = useCallback( - (result: any) => { - return compareMode - ? `/explore/${matches[2].params.deviceId}/compare/${selected.deviceId}` - : result.type === 'device' - ? `/explore/${result.deviceId + '?' + searchParams.toString()}` - : `/explore?${searchParams.toString()}` + (result: SearchResult) => { + const params = searchParams.toString() + const suffix = params ? `?${params}` : '' + + if (result.type === 'device') { + const baseDeviceId = matches[2]?.params?.deviceId + + if (compareMode && baseDeviceId) { + return `/explore/${baseDeviceId}/compare/${result.deviceId}` + } + + return `/explore/${result.deviceId}${suffix}` + } + + return `/explore${suffix}` }, - [searchParams, compareMode, matches, selected], + [searchParams, compareMode, matches], ) - const setShowSearchCallback = useCallback( - (state: boolean) => { - setOpen(state) + const selectResult = useCallback( + (result: SearchResult) => { + goTo(osem, result) + closeSearch() + void navigate(handleNavigate(result)) }, - [setOpen], + [osem, closeSearch, navigate, handleNavigate], ) const handleDigitPress = useCallback( - (event: any) => { + (event: KeyboardEvent) => { + const digit = Number(event.key) + if ( - typeof Number(event.key) === 'number' && - Number(event.key) <= length && - controlPress + !controlPress || + !Number.isInteger(digit) || + digit < 1 || + digit > length ) { - event.preventDefault() - setCursor(Number(event.key) - 1) - goTo(osem, searchResultsAll[Number(event.key) - 1]) - setTimeout(() => { - setShowSearchCallback(false) - void navigate(handleNavigate(searchResultsAll[Number(event.key) - 1])) - }, 500) + return } + + event.preventDefault() + + const result = searchResultsAll[digit - 1] + + setCursor(digit - 1) + goTo(osem, result) + + window.setTimeout(() => { + closeSearch() + void navigate(handleNavigate(result)) + }, 500) }, [ controlPress, length, - navigate, - osem, searchResultsAll, setCursor, - setShowSearchCallback, + osem, + closeSearch, + navigate, handleNavigate, ], ) useEffect(() => { - setSelected(searchResultsAll[cursor]) - }, [cursor, searchResultsAll]) - - useEffect(() => { - const navigate = handleNavigate(selected) - setNavigateTo(navigate) - }, [selected, handleNavigate]) - - useEffect(() => { - if (length !== 0 && enterPress) { - goTo(osem, selected) - setShowSearchCallback(false) - void navigate(navigateTo) + if (length !== 0 && enterPress && selected) { + selectResult(selected) } - }, [ - enterPress, - osem, - navigate, - selected, - setShowSearchCallback, - navigateTo, - length, - ]) + }, [enterPress, length, selected, selectResult]) useEffect(() => { - // attach the event listener window.addEventListener('keydown', handleDigitPress) - // remove the event listener return () => { window.removeEventListener('keydown', handleDigitPress) } - }) + }, [handleDigitPress]) return (
{props.searchResultsDevice.length > 0 && (
)} - {props.searchResultsDevice.map((device: any, i) => ( + + {props.searchResultsDevice.map((device, index) => ( setCursor(i)} - onClick={() => { - goTo(osem, device) - setShowSearchCallback(false) - void navigate(navigateTo) - }} + onMouseEnter={() => setCursor(index)} + onClick={() => selectResult(device)} /> ))} + {props.searchResultsLocation.length > 0 && (
)} - {props.searchResultsLocation.map((location: any, i) => { + + {props.searchResultsLocation.map((location, index) => { + const globalIndex = index + props.searchResultsDevice.length + const Icon = location.locationKind === 'country' ? Globe : MapPin + return ( setCursor(i + props.searchResultsDevice.length)} - onClick={() => { - goTo(osem, location) - setShowSearchCallback(false) - void navigate(navigateTo) - }} + onMouseEnter={() => setCursor(globalIndex)} + onClick={() => selectResult(location)} /> ) })} diff --git a/app/components/search/search-types.ts b/app/components/search/search-types.ts new file mode 100644 index 00000000..3d7194b6 --- /dev/null +++ b/app/components/search/search-types.ts @@ -0,0 +1,26 @@ +import { FeatureCollection, Point } from "geojson" +import { Device } from "~/db/schema" + +export type SearchBBox = [[number, number], [number, number]] + +export type DeviceSearchResult = { + type: 'device' + id: string + displayName: string + deviceId: string + lng: number + lat: number +} + +export type LocationSearchResult = { + type: 'location' + id: string + displayName: string + center: [number, number] + bbox?: SearchBBox + locationKind?: string +} + +export type SearchResult = DeviceSearchResult | LocationSearchResult + +export type DeviceFeatureCollection = FeatureCollection diff --git a/app/lib/env.server.ts b/app/lib/env.server.ts index 39ce0993..6d80ad9f 100644 --- a/app/lib/env.server.ts +++ b/app/lib/env.server.ts @@ -5,8 +5,7 @@ const schema = z.object({ DATABASE_URL: z.string(), PG_CLIENT_SSL: z.string(), SESSION_SECRET: z.string(), - MAPBOX_GEOCODING_API: z.string().url(), - MAPBOX_ACCESS_TOKEN: z.string(), + NOMINATIM_SEARCH_API: z.string(), OSEM_API_URL: z.string().url(), DIRECTUS_URL: z.string().url(), SENSORWIKI_API_URL: z.string().url(), @@ -39,9 +38,8 @@ export function init() { export function getEnv() { return { + NOMINATIM_SEARCH_API: process.env.NOMINATIM_SEARCH_API, MODE: process.env.NODE_ENV, - MAPBOX_GEOCODING_API: process.env.MAPBOX_GEOCODING_API, - MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN, DIRECTUS_URL: process.env.DIRECTUS_URL, MYBADGES_API_URL: process.env.MYBADGES_API_URL, MYBADGES_URL: process.env.MYBADGES_URL, diff --git a/app/lib/search-map-helper.ts b/app/lib/search-map-helper.ts index d614a43e..07ae7357 100644 --- a/app/lib/search-map-helper.ts +++ b/app/lib/search-map-helper.ts @@ -1,17 +1,9 @@ -import { - type LngLatBounds, - type LngLatLike, - type MapRef, -} from 'react-map-gl/maplibre' +import { type LngLatLike, type MapRef } from 'react-map-gl/maplibre' +import { SearchResult, type SearchBBox } from '~/components/search/search-types' -/** - * The function that is called when the user clicks on a location without bbox property in the search results. It flies the map to the location and closes the search results. - * - * @param center the coordinate of the center of the location to fly to - */ export const goToLocation = (map: MapRef | undefined, center: LngLatLike) => { map?.flyTo({ - center: center, + center, animate: true, speed: 1.6, zoom: 20, @@ -19,7 +11,6 @@ export const goToLocation = (map: MapRef | undefined, center: LngLatLike) => { }) } -//function to zoom back out of map export const zoomOut = (map: MapRef | undefined) => { map?.flyTo({ center: [0, 0], @@ -30,33 +21,18 @@ export const zoomOut = (map: MapRef | undefined) => { }) } -/** - * The function that is called when the user clicks on a location with the bbox property in the search results. It flies the map to the location and closes the search results. - * - * @param bbox - */ -export const goToLocationBBox = ( - map: MapRef | undefined, - bbox: LngLatBounds, -) => { +export const goToLocationBBox = (map: MapRef | undefined, bbox: SearchBBox) => { map?.fitBounds(bbox, { animate: true, speed: 1.6, + padding: 48, }) } -/** - * The function that is called when the user clicks on a device in the search results. It flies the map to the device and closes the search results. - * - * @param lng longitude of the device - * @param lat latitude of the device - * @param _id id of the device - */ export const goToDevice = ( map: MapRef | undefined, lng: number, lat: number, - _id: string, ) => { map?.flyTo({ center: [lng, lat], @@ -67,14 +43,16 @@ export const goToDevice = ( }) } -export const goTo = (map: MapRef | undefined, item: any) => { +export const goTo = (map: MapRef | undefined, item: SearchResult) => { if (item.type === 'device') { - goToDevice(map, item.lng, item.lat, item.deviceId) - } else if (item.type === 'location') { - if (item.bbox) { - goToLocationBBox(map, item.bbox) - } else { - goToLocation(map, item.center) - } + goToDevice(map, item.lng, item.lat) + return } + + if (item.bbox) { + goToLocationBBox(map, item.bbox) + return + } + + goToLocation(map, item.center) } diff --git a/app/styles/app.css b/app/styles/app.css index 890a3a46..bf241a72 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -177,10 +177,6 @@ kbd { white-space: nowrap; } -.mapboxgl-ctrl-geocoder { - width: 500px !important; -} - .map-space-shell { position: fixed; inset: 0; diff --git a/package-lock.json b/package-lock.json index 16a7ad43..9818ecff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,6 @@ "i18next-http-backend": "^3.0.5", "isbot": "^5.1.39", "jsonwebtoken": "^9.0.3", - "lodash.debounce": "^4.0.8", "lucide-react": "^1.8.0", "luxon": "^3.7.2", "maplibre-gl": "^5.23.0", @@ -1172,6 +1171,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1765,6 +1765,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -1813,6 +1814,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -1841,27 +1843,6 @@ "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", "license": "Apache-2.0" }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1943,6 +1924,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -4269,6 +4251,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-5.10.5.tgz", "integrity": "sha512-hFQp71QZDfivPzfIUOQZfMKLiOL/Cn2EnzacRlbUr55myteTfzYN8YMt+nzniE/6c4IRopFHEAdbKEtfyQc6kg==", "license": "MIT", + "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -5009,6 +4992,7 @@ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.59.1" }, @@ -6694,6 +6678,7 @@ "resolved": "https://registry.npmjs.org/@react-router/serve/-/serve-7.15.0.tgz", "integrity": "sha512-0XtYmwc11vWdYn2zeEXx9E3u0I6TH3bm4uDaMdsyI09S6hl6uc98vBkTSXg7Znm3qR82R/jjtn3LvV2QEZ193w==", "license": "MIT", + "peer": true, "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.15.0", @@ -6762,6 +6747,7 @@ "resolved": "https://registry.npmjs.org/@rjsf/utils/-/utils-6.5.1.tgz", "integrity": "sha512-c+x2VJNEp0BsamxX+Ryy9sEmwJ/7V9WFsVWjhADwyEU53r7DaVd7a7hmtx0bz464kJ8oJYZ6XghrmXXH2y7l8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@x0k/json-schema-merge": "^1.0.3", "fast-uri": "^3.1.0", @@ -8755,18 +8741,6 @@ } } }, - "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz", - "integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.3.0", - "node-gyp-build": "^4.8.4" - } - }, "node_modules/@swagger-api/apidom-reference": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.10.2.tgz", @@ -9327,6 +9301,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9644,6 +9619,7 @@ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9671,6 +9647,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9681,6 +9658,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -9823,6 +9801,7 @@ "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.5", @@ -10551,6 +10530,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -10815,6 +10795,7 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -11585,6 +11566,7 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -13017,6 +12999,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -14042,6 +14025,7 @@ } ], "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^5 || ^6" }, @@ -14112,6 +14096,7 @@ "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.3.tgz", "integrity": "sha512-AUY/VyX0E5XlibOmWt10uabJzam1zlYjwiEgQSDc5+UIkFNaF9WM0JxXKaNMGf+F/ffUF+7kRKXM9A7C0xXqMg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14767,6 +14752,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -14994,6 +14980,7 @@ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -15497,6 +15484,7 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", @@ -16486,7 +16474,8 @@ "version": "12.1.3", "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/outvariant": { "version": "1.4.3", @@ -16519,6 +16508,7 @@ "integrity": "sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinypool": "2.1.0" }, @@ -16813,6 +16803,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -17082,6 +17073,7 @@ "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", "license": "Unlicense", + "peer": true, "engines": { "node": ">=12" }, @@ -17314,6 +17306,7 @@ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/ramda" @@ -17386,6 +17379,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -17440,6 +17434,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18038,6 +18033,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.73.1.tgz", "integrity": "sha512-VAfVYOPcx3piiEVQy95vyFmBwbVUsP/AUIN+mpFG8h11yshDd444nn0VyfaGWSRnhOLVgiDu7HIuBtAIzxn9dA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -18054,6 +18050,7 @@ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", @@ -18241,6 +18238,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -18408,7 +18406,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-immutable": { "version": "4.0.0", @@ -18670,6 +18669,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -20006,7 +20006,8 @@ "version": "4.2.4", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -20035,7 +20036,8 @@ "version": "0.184.0", "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-conic-polygon-geometry": { "version": "2.1.2", @@ -20306,18 +20308,6 @@ "node": ">=20" } }, - "node_modules/tree-sitter": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", - "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-addon-api": "^8.0.0", - "node-gyp-build": "^4.8.0" - } - }, "node_modules/tree-sitter-json": { "version": "0.24.8", "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.24.8.tgz", @@ -20507,6 +20497,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20878,6 +20869,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -21233,6 +21225,7 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", @@ -21835,6 +21828,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 972b6251..b80ed3fb 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,6 @@ "i18next-http-backend": "^3.0.5", "isbot": "^5.1.39", "jsonwebtoken": "^9.0.3", - "lodash.debounce": "^4.0.8", "lucide-react": "^1.8.0", "luxon": "^3.7.2", "maplibre-gl": "^5.23.0",