From e01f753f145c4d5cab12f0658c7037d25ff4e491 Mon Sep 17 00:00:00 2001 From: Dlurak <84224239+Dlurak@users.noreply.github.com> Date: Fri, 20 Sep 2024 16:40:20 +0200 Subject: [PATCH] SearchBox: Refactoring and searchable stars (#552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Pavel Zbytovský --- .../Directions/DirectionsAutocomplete.tsx | 33 ++----- src/components/Directions/DirectionsBox.tsx | 18 ++-- src/components/Directions/helpers.tsx | 16 ++-- .../Map/behaviour/PersistedScaleControl.ts | 6 +- .../SearchBox/AutocompleteInput.tsx | 66 ++++++-------- src/components/SearchBox/SearchBox.tsx | 7 +- src/components/SearchBox/getOptionLabel.tsx | 21 +++++ .../SearchBox/getOptionToLonLat.tsx | 17 ++++ .../SearchBox/onHighlightFactory.ts | 36 ++++---- src/components/SearchBox/onSelectedFactory.ts | 70 +++++++++------ src/components/SearchBox/options/coords.tsx | 10 +++ src/components/SearchBox/options/geocoder.tsx | 38 +++++--- src/components/SearchBox/options/overpass.tsx | 5 +- src/components/SearchBox/options/preset.tsx | 24 ++++-- src/components/SearchBox/options/stars.tsx | 32 ++++++- .../SearchBox/renderOptionFactory.tsx | 44 +++++----- src/components/SearchBox/types.ts | 86 ++++++++++++++----- .../{useOptions.tsx => useGetOptions.tsx} | 13 ++- src/components/SearchBox/utils.tsx | 18 ++-- src/helpers/hooks.ts | 32 +++++++ src/helpers/theme.tsx | 5 +- src/services/mapStorage.ts | 2 +- 22 files changed, 382 insertions(+), 217 deletions(-) create mode 100644 src/components/SearchBox/getOptionLabel.tsx create mode 100644 src/components/SearchBox/getOptionToLonLat.tsx create mode 100644 src/components/SearchBox/options/coords.tsx rename src/components/SearchBox/{useOptions.tsx => useGetOptions.tsx} (73%) create mode 100644 src/helpers/hooks.ts diff --git a/src/components/Directions/DirectionsAutocomplete.tsx b/src/components/Directions/DirectionsAutocomplete.tsx index 98b70f476..e0198b8c8 100644 --- a/src/components/Directions/DirectionsAutocomplete.tsx +++ b/src/components/Directions/DirectionsAutocomplete.tsx @@ -3,28 +3,19 @@ import { useStarsContext } from '../utils/StarsContext'; import React, { useEffect, useRef, useState } from 'react'; import { abortFetch } from '../../services/fetch'; import { - buildPhotonAddress, fetchGeocoderOptions, GEOCODER_ABORTABLE_QUEUE, useInputValueState, } from '../SearchBox/options/geocoder'; import { getStarsOptions } from '../SearchBox/options/stars'; import styled from '@emotion/styled'; -import { - Autocomplete, - InputAdornment, - InputBase, - TextField, -} from '@mui/material'; +import { Autocomplete, InputAdornment, TextField } from '@mui/material'; import { useMapCenter } from '../SearchBox/utils'; import { useUserThemeContext } from '../../helpers/theme'; import { renderOptionFactory } from '../SearchBox/renderOptionFactory'; import PlaceIcon from '@mui/icons-material/Place'; -import { SearchOption } from '../SearchBox/types'; -import { useRouter } from 'next/router'; -import { destroyRouting } from './routing/handleRouting'; -import { Option, splitByFirstTilda } from './helpers'; -import { LonLat } from '../../services/types'; +import { Option } from '../SearchBox/types'; +import { getOptionLabel } from '../SearchBox/getOptionLabel'; const StyledTextField = styled(TextField)` input::placeholder { @@ -78,11 +69,11 @@ const useOptions = (inputValue: string, setOptions) => { abortFetch(GEOCODER_ABORTABLE_QUEUE); if (inputValue === '') { - setOptions(getStarsOptions(stars)); + setOptions(getStarsOptions(stars, inputValue)); return; } - fetchGeocoderOptions(inputValue, view, setOptions, [], []); + await fetchGeocoderOptions(inputValue, view, setOptions, [], []); })(); }, [inputValue, stars]); // eslint-disable-line react-hooks/exhaustive-deps }; @@ -90,20 +81,6 @@ const Row = styled.div` width: 100%; `; -export const getOptionLabel = (option) => - option == null - ? '' - : option.properties?.name || - (option.star && option.star.label) || - (option.properties && buildPhotonAddress(option.properties)) || - ''; - -export const getOptionToLonLat = (option) => { - const lonLat = - (option.star && option.star.center) || option.geometry.coordinates; - return lonLat as LonLat; -}; - type Props = { label: string; value: Option; diff --git a/src/components/Directions/DirectionsBox.tsx b/src/components/Directions/DirectionsBox.tsx index 5577706e2..8c2af9aaa 100644 --- a/src/components/Directions/DirectionsBox.tsx +++ b/src/components/Directions/DirectionsBox.tsx @@ -1,8 +1,5 @@ import styled from '@emotion/styled'; -import { - DirectionsAutocomplete, - getOptionToLonLat, -} from './DirectionsAutocomplete'; +import { DirectionsAutocomplete } from './DirectionsAutocomplete'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Stack } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; @@ -17,19 +14,16 @@ import { import { getLabel } from '../../helpers/featureLabel'; import { getLastFeature } from '../../services/lastFeatureStorage'; import { Result, StyledPaper } from './Result'; -import { - buildUrl, - CloseButton, - getStarOption, - Option, - parseUrlParts, -} from './helpers'; +import { buildUrl, CloseButton, parseUrlParts } from './helpers'; import { PointsTooFarError, Profile, RoutingResult } from './routing/types'; import { useBoolState, useMobileMode } from '../helpers'; import { LoadingButton } from '@mui/lab'; import { type Severity, useSnackbar } from '../utils/SnackbarContext'; import { FetchError } from '../../services/helpers'; import * as Sentry from '@sentry/nextjs'; +import { Option } from '../SearchBox/types'; +import { getCoordsOption } from '../SearchBox/options/coords'; +import { getOptionToLonLat } from '../SearchBox/getOptionToLonLat'; const Wrapper = styled(Stack)` position: absolute; @@ -86,7 +80,7 @@ const useReactToUrl = ( const lastFeature = getLastFeature(); if (lastFeature) { - setTo(getStarOption(lastFeature.center, getLabel(lastFeature))); + setTo(getCoordsOption(lastFeature.center, getLabel(lastFeature))); } } diff --git a/src/components/Directions/helpers.tsx b/src/components/Directions/helpers.tsx index ee218844a..a977e1f70 100644 --- a/src/components/Directions/helpers.tsx +++ b/src/components/Directions/helpers.tsx @@ -1,10 +1,13 @@ import { LonLat } from '../../services/types'; import { encodeUrl } from '../../helpers/utils'; -import { getOptionLabel, getOptionToLonLat } from './DirectionsAutocomplete'; import Router from 'next/router'; import { IconButton } from '@mui/material'; import CloseIcon from '@mui/icons-material/Close'; import React from 'react'; +import { Option } from '../SearchBox/types'; +import { getCoordsOption } from '../SearchBox/options/coords'; +import { getOptionToLonLat } from '../SearchBox/getOptionToLonLat'; +import { getOptionLabel } from '../SearchBox/getOptionLabel'; export const splitByFirstTilda = (str: string) => { if (!str) { @@ -17,15 +20,6 @@ export const splitByFirstTilda = (str: string) => { return [str.slice(0, index), str.slice(index + 1)]; }; -export type Option = Record; // TODO once we have types in SearchBox - -export const getStarOption = (center: LonLat, label?: string): Option => ({ - star: { - center, - label: label || center, - }, -}); - const getOptionToUrl = (point: Option) => { const lonLat = getOptionToLonLat(point); return `${lonLat.join(',')}~${getOptionLabel(point)}`; @@ -42,7 +36,7 @@ const urlCoordsToLonLat = (coords: string): LonLat => export const parseUrlParts = (urlParts: string[]): Option[] => urlParts.map((urlPart) => { const [coords, label] = splitByFirstTilda(urlPart); - return getStarOption(urlCoordsToLonLat(coords), label); + return getCoordsOption(urlCoordsToLonLat(coords), label); }); const close = () => { diff --git a/src/components/Map/behaviour/PersistedScaleControl.ts b/src/components/Map/behaviour/PersistedScaleControl.ts index f0a136dee..1f54d0b3d 100644 --- a/src/components/Map/behaviour/PersistedScaleControl.ts +++ b/src/components/Map/behaviour/PersistedScaleControl.ts @@ -3,10 +3,10 @@ import { isImperial, toggleImperial } from '../../helpers'; // https://github.com/maplibre/maplibre-gl-js/blob/afe4377706429a6b4e708e62a3c39a795ae8f28e/src/ui/control/scale_control.js#L36-L83 -class ClickableScaleControl extends (ScaleControl as any) { - private onClick; +class ClickableScaleControl extends ScaleControl { + private onClick: () => void; - private getHoverText; + private getHoverText: () => string; constructor({ onClick, getHoverText, ...options }) { super(options); diff --git a/src/components/SearchBox/AutocompleteInput.tsx b/src/components/SearchBox/AutocompleteInput.tsx index 92986ccfc..6b9fa2ce4 100644 --- a/src/components/SearchBox/AutocompleteInput.tsx +++ b/src/components/SearchBox/AutocompleteInput.tsx @@ -7,32 +7,25 @@ import { onSelectedFactory } from './onSelectedFactory'; import { useUserThemeContext } from '../../helpers/theme'; import { useMapStateContext } from '../utils/MapStateContext'; import { onHighlightFactory } from './onHighlightFactory'; -import { buildPhotonAddress } from './options/geocoder'; import { useMapCenter } from './utils'; import { useSnackbar } from '../utils/SnackbarContext'; +import { useKeyDown } from '../../helpers/hooks'; +import { Option } from './types'; +import { getOptionLabel } from './getOptionLabel'; -const useFocusOnSlash = () => { +const SearchBoxInput = ({ params, setInputValue, autocompleteRef }) => { const inputRef = React.useRef(null); - useEffect(() => { - const onKeydown = (e) => { - if (e.key === '/') { - e.preventDefault(); - inputRef.current?.focus(); - } - }; - window.addEventListener('keydown', onKeydown); - - return () => { - window.removeEventListener('keydown', onKeydown); - }; - }, []); - - return inputRef; -}; + useKeyDown('/', (e) => { + const isInput = e.target instanceof HTMLInputElement; + const isTextarea = e.target instanceof HTMLTextAreaElement; + if (isInput || isTextarea) { + return; + } + e.preventDefault(); + inputRef.current?.focus(); + }); -const SearchBoxInput = ({ params, setInputValue, autocompleteRef }) => { - // const inputRef = useFocusOnSlash(); const { InputLabelProps, InputProps, ...restParams } = params; useEffect(() => { @@ -40,24 +33,29 @@ const SearchBoxInput = ({ params, setInputValue, autocompleteRef }) => { params.InputProps.ref(autocompleteRef.current); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const onChange = (e) => setInputValue(e.target.value); - const onFocus = (e) => e.target.select(); - return ( setInputValue(target.value)} + onFocus={({ target }) => target.select()} /> ); }; -export const AutocompleteInput = ({ +type AutocompleteInputProps = { + inputValue: string; + setInputValue: (value: string) => void; + options: Option[]; + autocompleteRef: React.MutableRefObject; + setOverpassLoading: React.Dispatch>; +}; + +export const AutocompleteInput: React.FC = ({ inputValue, setInputValue, options, @@ -75,16 +73,8 @@ export const AutocompleteInput = ({ options={options} // we need null to be able to select the same again (eg. category search) value={null} - filterOptions={(x) => x} - getOptionLabel={(option) => - option.properties?.name || - option.preset?.presetForSearch?.name || - option.overpass?.inputValue || - (option.star && option.star.label) || - (option.loader && '') || - (option.properties && buildPhotonAddress(option.properties)) || - '' - } + filterOptions={(o) => o} + getOptionLabel={getOptionLabel} getOptionKey={(option) => JSON.stringify(option)} onChange={onSelectedFactory( setFeature, @@ -94,7 +84,7 @@ export const AutocompleteInput = ({ setOverpassLoading, )} onHighlightChange={onHighlightFactory(setPreview)} - getOptionDisabled={(o) => o.loader} + getOptionDisabled={(o) => o.type === 'loader'} autoComplete disableClearable autoHighlight diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index 180022ec9..783e08e54 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -7,9 +7,9 @@ import { useFeatureContext } from '../utils/FeatureContext'; import { AutocompleteInput } from './AutocompleteInput'; import { t } from '../../services/intl'; import { ClosePanelButton } from '../utils/ClosePanelButton'; -import { useMobileMode, useToggleState } from '../helpers'; +import { useMobileMode } from '../helpers'; import { useInputValueState } from './options/geocoder'; -import { useOptions } from './useOptions'; +import { useGetOptions } from './useGetOptions'; import { HamburgerMenu } from '../Map/TopMenu/HamburgerMenu'; import { UserMenu } from '../Map/TopMenu/UserMenu'; import { DirectionsButton } from '../Directions/DirectionsButton'; @@ -44,12 +44,11 @@ const SearchBox = () => { const isMobileMode = useMobileMode(); const { featureShown } = useFeatureContext(); const { inputValue, setInputValue } = useInputValueState(); - const [options, setOptions] = useState([]); const [overpassLoading, setOverpassLoading] = useState(false); const autocompleteRef = useRef(); const onClosePanel = useOnClosePanel(); - useOptions(inputValue, setOptions); + const options = useGetOptions(inputValue); return ( diff --git a/src/components/SearchBox/getOptionLabel.tsx b/src/components/SearchBox/getOptionLabel.tsx new file mode 100644 index 000000000..57ccfaf08 --- /dev/null +++ b/src/components/SearchBox/getOptionLabel.tsx @@ -0,0 +1,21 @@ +import { Option } from './types'; +import { buildPhotonAddress } from './options/geocoder'; + +export const getOptionLabel = (option: Option | undefined) => { + if (option == null) { + return ''; + } + + return ( + (option.type === 'geocoder' && option.geocoder.properties?.name) || + (option.type === 'preset' && option.preset?.presetForSearch?.name) || + (option.type === 'overpass' && option.overpass?.inputValue) || + (option.type === 'star' && option.star.label) || + (option.type === 'coords' && option.coords.label) || + (option.type === 'loader' && '') || + (option.type === 'geocoder' && + option.geocoder.properties && + buildPhotonAddress(option.geocoder.properties)) || + '' + ); +}; diff --git a/src/components/SearchBox/getOptionToLonLat.tsx b/src/components/SearchBox/getOptionToLonLat.tsx new file mode 100644 index 000000000..18961afb1 --- /dev/null +++ b/src/components/SearchBox/getOptionToLonLat.tsx @@ -0,0 +1,17 @@ +import { Option } from './types'; + +export const getOptionToLonLat = (option: Option) => { + if (option.type === 'coords') { + return option.coords.center; + } + + if (option.type === 'star') { + return option.star.center; + } + + if (option.type === 'geocoder') { + return option.geocoder.geometry.coordinates; + } + + throw new Error(`Unsupported option type: ${option.type}`); +}; diff --git a/src/components/SearchBox/onHighlightFactory.ts b/src/components/SearchBox/onHighlightFactory.ts index 8d67a1cf5..be04d0666 100644 --- a/src/components/SearchBox/onHighlightFactory.ts +++ b/src/components/SearchBox/onHighlightFactory.ts @@ -1,6 +1,7 @@ import { Feature } from '../../services/types'; +import { GeocoderOption, Option } from './types'; -const getElementType = (osmType) => { +const getElementType = (osmType: string) => { switch (osmType) { case 'R': return 'relation'; @@ -13,31 +14,32 @@ const getElementType = (osmType) => { } }; -export const getSkeleton = (option): Feature => { - const center = option.geometry.coordinates; - const { osm_id: id, osm_type: osmType, name } = option.properties; +export const getSkeleton = ({ geocoder }: GeocoderOption): Feature => { + const center = geocoder.geometry.coordinates; + const { osm_id: id, osm_type: osmType, name } = geocoder.properties; const type = getElementType(osmType); - const [lon, lat] = center; return { + center, type: 'Feature', skeleton: true, nonOsmObject: false, osmMeta: { type, id: parseInt(id, 10) }, - center: [parseFloat(lon), parseFloat(lat)], tags: { name }, - properties: { class: option.class, subclass: '' }, + properties: { class: geocoder.properties.class, subclass: '' }, }; }; -export const onHighlightFactory = (setPreview) => (e, option) => { - if (option?.star?.center) { - const { center } = option.star; - setPreview({ center }); - return; - } +export const onHighlightFactory = + (setPreview: (feature: unknown) => void) => (_: never, option: Option) => { + if (!option) return; + if (option.type === 'star' && option.star.center) { + const { center } = option.star; + setPreview({ center }); + return; + } - if (option?.geometry?.coordinates) { - setPreview(getSkeleton(option)); - } -}; + if (option.type === 'geocoder' && option.geocoder.geometry?.coordinates) { + setPreview(getSkeleton(option)); + } + }; diff --git a/src/components/SearchBox/onSelectedFactory.ts b/src/components/SearchBox/onSelectedFactory.ts index 12f82d95a..ce9441836 100644 --- a/src/components/SearchBox/onSelectedFactory.ts +++ b/src/components/SearchBox/onSelectedFactory.ts @@ -6,19 +6,28 @@ import { performOverpassSearch } from '../../services/overpassSearch'; import { t } from '../../services/intl'; import { fitBounds } from './utils'; import { getSkeleton } from './onHighlightFactory'; -import { SnackbarContextType } from '../utils/SnackbarContext'; +import { Severity } from '../utils/SnackbarContext'; import { addOverpassQueryHistory } from './options/overpass'; +import { Feature } from '../../services/types'; +import { Bbox } from '../utils/MapStateContext'; +import { + GeocoderOption, + Option, + OverpassOption, + PresetOption, + StarOption, +} from './types'; const overpassOptionSelected = ( - option, - setOverpassLoading, - bbox, - showToast: SnackbarContextType['showToast'], + option: OverpassOption | PresetOption, + setOverpassLoading: React.Dispatch>, + bbox: Bbox, + showToast: (message: string, severity?: Severity) => void, ) => { const tagsOrQuery = - option.preset?.presetForSearch.tags ?? - option.overpass.tags ?? - option.overpass.query; + option.type === 'preset' + ? option.preset?.presetForSearch.tags + : (option.overpass.tags ?? option.overpass.query); const timeout = setTimeout(() => { setOverpassLoading(true); @@ -31,7 +40,7 @@ const overpassOptionSelected = ( showToast(content); getOverpassSource()?.setData(geojson); - if (option.overpass?.query) { + if (option.type === 'overpass') { addOverpassQueryHistory(option.overpass.query); } }) @@ -47,13 +56,18 @@ const overpassOptionSelected = ( }); }; -const starOptionSelected = (option) => { - const apiId = getApiId(option.star.shortId); +const starOptionSelected = ({ star }: StarOption) => { + const apiId = getApiId(star.shortId); Router.push(`/${getUrlOsmId(apiId)}`); }; -const geocoderOptionSelected = (option, setFeature) => { - if (!option?.geometry?.coordinates) return; +type SetFeature = (feature: Feature | null) => void; + +const geocoderOptionSelected = ( + option: GeocoderOption, + setFeature: SetFeature, +) => { + if (!option?.geocoder.geometry?.coordinates) return; const skeleton = getSkeleton(option); console.log('Search item selected:', { location: option, skeleton }); // eslint-disable-line no-console @@ -66,19 +80,25 @@ const geocoderOptionSelected = (option, setFeature) => { }; export const onSelectedFactory = - (setFeature, setPreview, bbox, showToast, setOverpassLoading) => - (_, option) => { + ( + setFeature: SetFeature, + setPreview: SetFeature, + bbox: Bbox, + showToast: (message: string, severity?: Severity) => void, + setOverpassLoading: React.Dispatch>, + ) => + (_: never, option: Option) => { setPreview(null); // it could be stuck from onHighlight - if (option.star) { - starOptionSelected(option); - return; + switch (option.type) { + case 'star': + starOptionSelected(option); + break; + case 'overpass': + case 'preset': + overpassOptionSelected(option, setOverpassLoading, bbox, showToast); + break; + case 'geocoder': + geocoderOptionSelected(option, setFeature); } - - if (option.overpass || option.preset) { - overpassOptionSelected(option, setOverpassLoading, bbox, showToast); - return; - } - - geocoderOptionSelected(option, setFeature); }; diff --git a/src/components/SearchBox/options/coords.tsx b/src/components/SearchBox/options/coords.tsx new file mode 100644 index 000000000..e57ea6629 --- /dev/null +++ b/src/components/SearchBox/options/coords.tsx @@ -0,0 +1,10 @@ +import { LonLat } from '../../../services/types'; +import { Option } from '../types'; + +export const getCoordsOption = (center: LonLat, label?: string): Option => ({ + type: 'coords', + coords: { + center, + label: label || center.toReversed().join(','), + }, +}); diff --git a/src/components/SearchBox/options/geocoder.tsx b/src/components/SearchBox/options/geocoder.tsx index 4300d5697..fc9aff148 100644 --- a/src/components/SearchBox/options/geocoder.tsx +++ b/src/components/SearchBox/options/geocoder.tsx @@ -7,14 +7,17 @@ import { getPoiClass } from '../../../services/getPoiClass'; import Maki from '../../utils/Maki'; import { fetchJson } from '../../../services/fetch'; import { intl } from '../../../services/intl'; +import { Theme } from '../../../helpers/theme'; +import { GeocoderOption, Option, PresetOption } from '../types'; +import { View } from '../../utils/MapStateContext'; import { LonLat } from '../../../services/types'; const PHOTON_SUPPORTED_LANGS = ['en', 'de', 'fr']; const DEFAULT = 'en'; // this was 'default' but it throws away some results, using 'en' was suggested https://github.com/zbycz/osmapp/issues/226 -const getApiUrl = (inputValue, view) => { +const getApiUrl = (inputValue: string, view: View) => { const [zoom, lat, lon] = view; - const lvl = Math.max(0, Math.min(16, Math.round(zoom))); + const lvl = Math.max(0, Math.min(16, Math.round(parseFloat(zoom)))); const q = encodeURIComponent(inputValue); const lang = intl.lang in PHOTON_SUPPORTED_LANGS ? intl.lang : DEFAULT; return `https://photon.komoot.io/api/?q=${q}&lon=${lon}&lat=${lat}&zoom=${lvl}&lang=${lang}`; @@ -27,7 +30,7 @@ export const useInputValueState = () => { const [inputValue, setInputValue] = useState(''); return { inputValue, - setInputValue: useCallback((value) => { + setInputValue: useCallback((value: string) => { currentInput = value; setInputValue(value); }, []), @@ -35,11 +38,17 @@ export const useInputValueState = () => { }; export const fetchGeocoderOptions = debounce( - async (inputValue, view, setOptions, before, after) => { + async ( + inputValue: string, + view: View, + setOptions: React.Dispatch>, + before: PresetOption[], + after: PresetOption[], + ) => { try { - const searchResponse = await fetchJson(getApiUrl(inputValue, view), { + const searchResponse = (await fetchJson(getApiUrl(inputValue, view), { abortableQueueName: GEOCODER_ABORTABLE_QUEUE, - }); + })) as { features: GeocoderOption['geocoder'][] }; // This blocks rendering of old result, when user already changed input if (inputValue !== currentInput) { @@ -48,7 +57,14 @@ export const fetchGeocoderOptions = debounce( const options = searchResponse?.features || []; - setOptions([...before, ...options, ...after]); + setOptions([ + ...before, + ...options.map((feature) => ({ + type: 'geocoder' as const, + geocoder: feature, + })), + ...after, + ]); } catch (e) { if (!(e instanceof DOMException && e.name === 'AbortError')) { throw e; @@ -126,12 +142,12 @@ export const buildPhotonAddress = ({ ]; */ export const renderGeocoder = ( - option, - currentTheme, - inputValue, + { geocoder }: GeocoderOption, + currentTheme: Theme, + inputValue: string, mapCenter: LonLat, ) => { - const { geometry, properties } = option; + const { geometry, properties } = geocoder; const { name, osm_key: tagKey, osm_value: tagValue } = properties; const distance = getHumanDistance(mapCenter, geometry.coordinates); diff --git a/src/components/SearchBox/options/overpass.tsx b/src/components/SearchBox/options/overpass.tsx index ca3ed9f83..e72a23fd1 100644 --- a/src/components/SearchBox/options/overpass.tsx +++ b/src/components/SearchBox/options/overpass.tsx @@ -22,6 +22,7 @@ export const getOverpassOptions = (inputValue: string): OverpassOption[] => { if (inputValue.match(/^(op|overpass):/)) { return [ { + type: 'overpass', overpass: { query: inputValue.replace(/^(op|overpass):/, ''), label: t('searchbox.overpass_custom_query'), @@ -29,6 +30,7 @@ export const getOverpassOptions = (inputValue: string): OverpassOption[] => { }, }, ...getOverpassQueryHistory().map((query) => ({ + type: 'overpass' as const, overpass: { query, label: query, @@ -42,6 +44,7 @@ export const getOverpassOptions = (inputValue: string): OverpassOption[] => { const [key, value] = inputValue.split('=', 2); return [ { + type: 'overpass', overpass: { tags: { [key]: value || '*' }, label: `${key}=${value || '*'}`, @@ -54,7 +57,7 @@ export const getOverpassOptions = (inputValue: string): OverpassOption[] => { return []; }; -export const renderOverpass = (overpass) => ( +export const renderOverpass = ({ overpass }: OverpassOption) => ( <> diff --git a/src/components/SearchBox/options/preset.tsx b/src/components/SearchBox/options/preset.tsx index 752fc6c2f..d0fd0b85c 100644 --- a/src/components/SearchBox/options/preset.tsx +++ b/src/components/SearchBox/options/preset.tsx @@ -12,7 +12,13 @@ import { PresetOption } from '../types'; import { t } from '../../../services/intl'; import { highlightText, IconPart } from '../utils'; -let presetsForSearch; +let presetsForSearch: { + key: string; + name: string; + tags: Record; + tagsAsOneString: string; + texts: string[]; +}[]; const getPresetsForSearch = async () => { if (presetsForSearch) { return presetsForSearch; @@ -43,7 +49,7 @@ const getPresetsForSearch = async () => { return presetsForSearch; }; -const num = (text, inputValue) => +const num = (text: string, inputValue: string) => // TODO match function not always good - consider text.toLowerCase().includes(inputValue.toLowerCase()); match(text, inputValue, { insideWords: true, @@ -55,7 +61,7 @@ type PresetOptions = Promise<{ after: PresetOption[]; }>; -export const getPresetOptions = async (inputValue): PresetOptions => { +export const getPresetOptions = async (inputValue: string): PresetOptions => { if (inputValue.length <= 2) { return { before: [], after: [] }; } @@ -69,11 +75,17 @@ export const getPresetOptions = async (inputValue): PresetOptions => { const nameMatches = results .filter((result) => result.name > 0) - .map((result) => ({ preset: result })); + .map((result) => ({ + type: 'preset' as const, + preset: result, + })); const rest = results .filter((result) => result.name === 0 && result.sum > 0) - .map((result) => ({ preset: result })); + .map((result) => ({ + type: 'preset' as const, + preset: result, + })); const allResults = [...nameMatches, ...rest]; const before = allResults.slice(0, 2); @@ -82,7 +94,7 @@ export const getPresetOptions = async (inputValue): PresetOptions => { return { before, after }; }; -export const renderPreset = (preset, inputValue) => { +export const renderPreset = ({ preset }: PresetOption, inputValue: string) => { const { name } = preset.presetForSearch; const additionalText = preset.name === 0 diff --git a/src/components/SearchBox/options/stars.tsx b/src/components/SearchBox/options/stars.tsx index bd0fa320b..9e279efd1 100644 --- a/src/components/SearchBox/options/stars.tsx +++ b/src/components/SearchBox/options/stars.tsx @@ -1,14 +1,38 @@ import StarIcon from '@mui/icons-material/Star'; +import sortBy from 'lodash/sortBy'; import { Grid, Typography } from '@mui/material'; import React from 'react'; import { getHumanDistance, IconPart } from '../utils'; import type { Star } from '../../utils/StarsContext'; +import { StarOption } from '../types'; +import match from 'autosuggest-highlight/match'; +import { LonLat } from '../../../services/types'; -// TODO filter stars by inputValue -export const getStarsOptions = (stars: Star[]) => - stars.map((star) => ({ star })); +export const getStarsOptions = ( + stars: Star[], + inputValue: string, +): StarOption[] => { + const ratedStars = sortBy( + stars + .map((star) => ({ + star, + // TODO matching is not optimal, maybe Sørensen–Dice coefficient + // https://www.npmjs.com/package/dice-coefficient + matching: + inputValue === '' + ? Infinity + : match(star.label, inputValue, { + insideWords: true, + findAllOccurrences: true, + }).length, + })) + .filter(({ matching }) => matching > 0), + ({ matching }) => matching, + ); + return ratedStars.map(({ star }) => ({ type: 'star', star })); +}; -export const renderStar = (star, mapCenter) => { +export const renderStar = ({ star }: StarOption, mapCenter: LonLat) => { // Note: for compatibility, `center` is optional const distance = star.center ? getHumanDistance(mapCenter, star.center) diff --git a/src/components/SearchBox/renderOptionFactory.tsx b/src/components/SearchBox/renderOptionFactory.tsx index 1c011e38c..9a06915c3 100644 --- a/src/components/SearchBox/renderOptionFactory.tsx +++ b/src/components/SearchBox/renderOptionFactory.tsx @@ -4,36 +4,36 @@ import { renderPreset } from './options/preset'; import { renderLoader } from './utils'; import { renderStar } from './options/stars'; import { renderGeocoder } from './options/geocoder'; +import { Option } from './types'; +import { Theme } from '../../helpers/theme'; import { LonLat } from '../../services/types'; -// TODO refactor to use components, so they can use hooks -const renderOption = (inputValue, currentTheme, mapCenter: LonLat, option) => { - const { preset, overpass, star, loader } = option; - if (overpass) { - return renderOverpass(overpass); - } - - if (star) { - return renderStar(star, mapCenter); - } - - if (loader) { - return renderLoader(); - } - - if (preset) { - return renderPreset(preset, inputValue); +const renderOption = ( + inputValue: string, + currentTheme: Theme, + mapCenter: LonLat, + option: Option, +) => { + switch (option.type) { + case 'overpass': + return renderOverpass(option); + case 'star': + return renderStar(option, mapCenter); + case 'loader': + return renderLoader(); + case 'preset': + return renderPreset(option, inputValue); + case 'geocoder': + return renderGeocoder(option, currentTheme, inputValue, mapCenter); } - - return renderGeocoder(option, currentTheme, inputValue, mapCenter); }; export const renderOptionFactory = ( - inputValue, - currentTheme, + inputValue: string, + currentTheme: Theme, mapCenter: LonLat, ) => { - const Option = ({ key, ...props }, option) => ( + const Option = ({ key, ...props }, option: Option) => (
  • {renderOption(inputValue, currentTheme, mapCenter, option)}
  • diff --git a/src/components/SearchBox/types.ts b/src/components/SearchBox/types.ts index 6e3273b28..889e28710 100644 --- a/src/components/SearchBox/types.ts +++ b/src/components/SearchBox/types.ts @@ -1,24 +1,53 @@ -// TODO export type OptionGeocoder = { -// loading: true; -// skeleton: true; -// nonOsmObject: true; -// osmMeta: { type: string; id: number }; -// center: LonLat; -// tags: Record; -// properties: { class: string }; // ?? is really used -// }; - -export type OverpassOption = { - overpass: { +import { LonLat } from '../../services/types'; +import { Star } from '../utils/StarsContext'; + +type GenericOption = { + type: T; +} & (U extends null ? {} : Record); + +export type GeocoderOption = GenericOption< + 'geocoder', + { + loading: true; + skeleton: true; + nonOsmObject: true; + osmMeta: { type: string; id: number }; + center: LonLat; + tags: Record; + properties?: { + class: string | null; + place: string | null | undefined; + street: string | null | undefined; + city: string | null | undefined; + housenumber: string | null | undefined; + streetnumber: string | null | undefined; + osm_key: string; + osm_value: string; + osm_type: string; + osm_id: string; + name: string; + /** Either a string or an array */ + extent: any; + }; + geometry: { + coordinates: LonLat; + }; + } +>; + +export type OverpassOption = GenericOption< + 'overpass', + { query?: string; tags?: Record; inputValue: string; label: string; - }; -}; + } +>; -export type PresetOption = { - preset: { +export type PresetOption = GenericOption< + 'preset', + { name: number; textsByOne: number[]; sum: number; @@ -29,8 +58,25 @@ export type PresetOption = { tagsAsOneString: string; texts: string[]; }; - }; -}; + } +>; + +export type StarOption = GenericOption<'star', Star>; + +type LoaderOption = GenericOption<'loader', null>; + +export type CoordsOption = GenericOption< + 'coords', + { center: LonLat; label: string } +>; -// TODO not used anywhere yet, typescript cant identify options by the key (overpass, preset, loader) -export type SearchOption = OverpassOption | PresetOption; +/* + * A option for the searchbox + */ +export type Option = + | StarOption + | CoordsOption + | OverpassOption + | PresetOption + | LoaderOption + | GeocoderOption; diff --git a/src/components/SearchBox/useOptions.tsx b/src/components/SearchBox/useGetOptions.tsx similarity index 73% rename from src/components/SearchBox/useOptions.tsx rename to src/components/SearchBox/useGetOptions.tsx index 627553988..87a0d17c3 100644 --- a/src/components/SearchBox/useOptions.tsx +++ b/src/components/SearchBox/useGetOptions.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useMapStateContext } from '../utils/MapStateContext'; import { useStarsContext } from '../utils/StarsContext'; import { abortFetch } from '../../services/fetch'; @@ -9,17 +9,21 @@ import { import { getStarsOptions } from './options/stars'; import { getOverpassOptions } from './options/overpass'; import { getPresetOptions } from './options/preset'; +import { Option } from './types'; -export const useOptions = (inputValue: string, setOptions) => { +export const useGetOptions = (inputValue: string) => { const { view } = useMapStateContext(); const { stars } = useStarsContext(); + const [options, setOptions] = useState([]); useEffect(() => { (async () => { abortFetch(GEOCODER_ABORTABLE_QUEUE); + const starOptions = getStarsOptions(stars, inputValue); + if (inputValue === '') { - setOptions(getStarsOptions(stars)); + setOptions(starOptions); return; } @@ -30,9 +34,10 @@ export const useOptions = (inputValue: string, setOptions) => { } const { before, after } = await getPresetOptions(inputValue); - setOptions([...before, { loader: true }]); + setOptions([...starOptions, ...before, { type: 'loader' }]); fetchGeocoderOptions(inputValue, view, setOptions, before, after); })(); }, [inputValue, stars]); // eslint-disable-line react-hooks/exhaustive-deps + return options; }; diff --git a/src/components/SearchBox/utils.tsx b/src/components/SearchBox/utils.tsx index f1edbd81f..6deb9b940 100644 --- a/src/components/SearchBox/utils.tsx +++ b/src/components/SearchBox/utils.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; import { Grid, Typography } from '@mui/material'; import React from 'react'; -import maplibregl from 'maplibre-gl'; +import maplibregl, { LngLatLike } from 'maplibre-gl'; import match from 'autosuggest-highlight/match'; import parse from 'autosuggest-highlight/parse'; import { useMapStateContext } from '../utils/MapStateContext'; @@ -9,6 +9,7 @@ import { t } from '../../services/intl'; import { getGlobalMap } from '../../services/mapStorage'; import { LonLat } from '../../services/types'; import { DotLoader, isImperial } from '../helpers'; +import { GeocoderOption } from './types'; export const IconPart = styled.div` width: 50px; @@ -64,10 +65,11 @@ export const renderLoader = () => ( ); -export const fitBounds = (option) => { +export const fitBounds = ({ geocoder }: GeocoderOption) => { // this condition is maybe not used in current API photon - if (option.properties.extent) { - const [w, s, e, n] = option.properties.extent; + const { properties } = geocoder; + if (properties.extent) { + const [w, s, e, n] = properties.extent; const bbox = new maplibregl.LngLatBounds([w, s], [e, n]); const panelWidth = window.innerWidth > 700 ? 410 : 0; getGlobalMap()?.fitBounds(bbox, { @@ -76,19 +78,19 @@ export const fitBounds = (option) => { return; } - const coords = option.geometry.coordinates; + const coords = geocoder.geometry.coordinates; if (coords.length === 2 && coords.every((num) => !Number.isNaN(num))) { - getGlobalMap()?.flyTo({ center: coords, zoom: 17 }); + getGlobalMap()?.flyTo({ center: coords as LngLatLike, zoom: 17 }); } else { // eslint-disable-next-line no-console console.warn( 'fitBounds(): option has no extent or coordinates', - JSON.stringify(option), + JSON.stringify(geocoder), ); } }; -export const highlightText = (resultText, inputValue) => { +export const highlightText = (resultText: string, inputValue: string) => { const matches = match(resultText, inputValue, { insideWords: true, findAllOccurrences: true, diff --git a/src/helpers/hooks.ts b/src/helpers/hooks.ts new file mode 100644 index 000000000..8779720fd --- /dev/null +++ b/src/helpers/hooks.ts @@ -0,0 +1,32 @@ +import { useEffect, DependencyList } from 'react'; + +/** + * Custom hook to listen for a specific keyDownEvent and trigger a callback. + * + * @param key - The key to listen for (e.g., "Enter", "Escape"). + * @param listener - The callback function to be invoked when the key is pressed. + * + * @example + * ```typescript + * useKeyDown('Escape', (e) => { + * console.log('Escape key was pressed'); + * }); + * ``` + */ +export const useKeyDown = ( + key: string, + listener: (e: KeyboardEvent) => void, +) => { + useEffect(() => { + const onKeydown = (e: KeyboardEvent) => { + if (e.key === key) { + listener(e); + } + }; + + window.addEventListener('keydown', onKeydown); + return () => { + window.removeEventListener('keydown', onKeydown); + }; + }, [key, listener]); +}; diff --git a/src/helpers/theme.tsx b/src/helpers/theme.tsx index 5ce0ebaff..68b90a128 100644 --- a/src/helpers/theme.tsx +++ b/src/helpers/theme.tsx @@ -89,13 +89,14 @@ const darkTheme = createTheme({ }, }); -export type UserTheme = 'system' | 'light' | 'dark'; +export type Theme = 'light' | 'dark'; +export type UserTheme = 'system' | Theme; type UserThemeContextType = { userTheme: UserTheme; setUserTheme: (choice: UserTheme) => void; theme: typeof lightTheme | typeof darkTheme; - currentTheme: 'light' | 'dark'; + currentTheme: Theme; }; export const UserThemeContext = createContext(undefined); diff --git a/src/services/mapStorage.ts b/src/services/mapStorage.ts index ac645a22c..292e7db15 100644 --- a/src/services/mapStorage.ts +++ b/src/services/mapStorage.ts @@ -5,7 +5,7 @@ export const mapIdlePromise = new Promise((resolve) => { mapIsIdle = resolve; }); -let map: maplibregl.Map; +let map: maplibregl.Map | undefined = undefined; export const setGlobalMap = (newMap: maplibregl.Map) => { map = newMap; map?.on('idle', () => mapIsIdle(newMap));