diff --git a/README.md b/README.md index 6fc8591b..fa82b86d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ You may [add issues](https://github.com/zbycz/osmapp/issues) here on GitHub, or - **clickable map** – poi, cities, localities, ponds (more coming soon) - **info panel** – presets and fields from iD, images from Wikipedia, Mapillary or Fody, line numbers on public transport stops - **editing** – with osm login. For anonymous users a note is inserted. -- **search engine** – try for example "Tesco, London" (powered by Photon). Also category search from iD editor presets. +- **search engine** – try for example "Tesco, London" (powered by Photon). + Category search from [iD editor presets](https://github.com/openstreetmap/id-tagging-schema) + or pure overpass search (eg. `amenity=*` or `op:`) - **vector maps** – with the possibility of tilting to 3D (drag the compass, or do two fingers drag) - **3D terrain** – turned on when tilted (use terrain icon to toggle off) - **tourist map** – from MapTiler: vector, including marked routes diff --git a/src/components/SearchBox/AutocompleteInput.tsx b/src/components/SearchBox/AutocompleteInput.tsx index fe1b3ef8..e5f5e9ad 100644 --- a/src/components/SearchBox/AutocompleteInput.tsx +++ b/src/components/SearchBox/AutocompleteInput.tsx @@ -75,13 +75,11 @@ export const AutocompleteInput = ({ getOptionLabel={(option) => option.properties?.name || option.preset?.presetForSearch?.name || - (option.overpass && - Object.entries(option.overpass) - ?.map(([k, v]) => `${k}=${v}`) - .join(' ')) || + option.overpass?.inputValue || (option.star && option.star.label) || (option.loader ? '' : buildPhotonAddress(option.properties)) } + getOptionKey={(option) => JSON.stringify(option)} onChange={onSelectedFactory( setFeature, setPreview, diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index ad87fe93..2bec55dd 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -1,25 +1,20 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; import debounce from 'lodash/debounce'; import SearchIcon from '@mui/icons-material/Search'; import { CircularProgress, IconButton, Paper } from '@mui/material'; import Router from 'next/router'; -import match from 'autosuggest-highlight/match'; -import { fetchJson } from '../../services/fetch'; +import { abortFetch, fetchJson } from '../../services/fetch'; import { useMapStateContext } from '../utils/MapStateContext'; import { useFeatureContext } from '../utils/FeatureContext'; import { AutocompleteInput } from './AutocompleteInput'; import { intl, t } from '../../services/intl'; import { ClosePanelButton } from '../utils/ClosePanelButton'; import { isDesktop, useMobileMode } from '../helpers'; -import { presets } from '../../services/tagging/data'; -import { - fetchSchemaTranslations, - getPresetTermsTranslation, - getPresetTranslation, -} from '../../services/tagging/translations'; import { SEARCH_BOX_HEIGHT } from './consts'; import { useStarsContext } from '../utils/StarsContext'; +import { getOverpassOptions } from './options/overpass'; +import { getPresetOptions } from './options/preset'; const TopPanel = styled.div` position: absolute; @@ -71,101 +66,52 @@ const getApiUrl = (inputValue, view) => { // https://docs.mapbox.com/help/troubleshooting/working-with-large-geojson-data/ -let presetsForSearch; -const getPresetsForSearch = async () => { - if (presetsForSearch) { - return presetsForSearch; - } - - await fetchSchemaTranslations(); - - // resolve symlinks to {landuse...} etc - presetsForSearch = Object.values(presets) - .filter(({ searchable }) => searchable === undefined || searchable) - .filter(({ locationSet }) => !locationSet?.include) - .filter(({ tags }) => Object.keys(tags).length > 0) - .map(({ name, presetKey, tags, terms }) => { - const tagsAsStrings = Object.entries(tags).map(([k, v]) => `${k}=${v}`); - return { - key: presetKey, - name: getPresetTranslation(presetKey) ?? name ?? 'x', - tags, - tagsAsOneString: tagsAsStrings.join(', '), - texts: [ - ...(getPresetTermsTranslation(presetKey) ?? terms ?? 'x').split(','), - ...tagsAsStrings, - presetKey, - ], - }; - }); - - return presetsForSearch; -}; - -const num = (text, inputValue) => - match(text, inputValue, { - insideWords: true, - findAllOccurrences: true, - }).length; -// return text.toLowerCase().includes(inputValue.toLowerCase()); - -const findInPresets = async (inputValue) => { - const results = (await getPresetsForSearch()).map((preset) => { - const name = num(preset.name, inputValue) * 10; - const textsByOne = preset.texts.map((term) => num(term, inputValue)); - const sum = name + textsByOne.reduce((a, b) => a + b, 0); - return { name, textsByOne, sum, presetForSearch: preset }; // TODO refactor this, not needed anymore - }); - - const nameMatches = results - .filter((result) => result.name > 0) - .map((result) => ({ preset: result })); - - const rest = results - .filter((result) => result.name === 0 && result.sum > 0) - .map((result) => ({ preset: result })); - - return nameMatches.length - ? { nameMatches, rest } - : { nameMatches: rest, rest: [] }; -}; +const GEOCODER_ABORTABLE_QUEUE = 'search'; -const getOverpassQuery = (inputValue: string) => { - if (inputValue.match(/^[-:_a-zA-Z0-9]+=/)) { - const [key, value] = inputValue.split('=', 2); - - return [{ overpass: { [key]: value || '*' } }]; - } - - return []; +let currentInput = ''; +const useInputValueState = () => { + const [inputValue, setInputValue] = useState(''); + return { + inputValue, + setInputValue: useCallback((value) => { + currentInput = value; + setInputValue(value); + }, []), + }; }; -const fetchOptions = debounce( - async (inputValue, view, setOptions, nameMatches = [], rest = []) => { +const getGeocoderOptions = debounce( + async (inputValue, view, setOptions, before, after) => { try { const searchResponse = await fetchJson(getApiUrl(inputValue, view), { - abortableQueueName: 'search', + abortableQueueName: GEOCODER_ABORTABLE_QUEUE, }); - const options = searchResponse.features; - const before = nameMatches.slice(0, 2); - const after = [...nameMatches.slice(2), ...rest]; + // This blocks rendering of old result, when user already changed input + if (inputValue !== currentInput) { + return; + } + + const options = searchResponse?.features || []; - setOptions([...before, ...(options || []), ...after]); + setOptions([...before, ...options, ...after]); } catch (e) { - // eslint-disable-next-line no-console - console.log('search aborted', e); + if (!(e instanceof DOMException && e.name === 'AbortError')) { + throw e; + } } }, 400, ); -const useFetchOptions = (inputValue: string, setOptions) => { +const useOptions = (inputValue: string, setOptions) => { const { view } = useMapStateContext(); const { stars } = useStarsContext(); useEffect(() => { (async () => { + abortFetch(GEOCODER_ABORTABLE_QUEUE); + if (inputValue === '') { const options = stars.map(({ shortId, poiType, label }) => ({ star: { shortId, poiType, label }, @@ -174,21 +120,16 @@ const useFetchOptions = (inputValue: string, setOptions) => { return; } - if (inputValue.length > 2) { - const overpassQuery = getOverpassQuery(inputValue); - const { nameMatches, rest } = await findInPresets(inputValue); - setOptions([ - ...overpassQuery, - ...nameMatches.slice(0, 2), - { loader: true }, - ]); - const before = [...overpassQuery, ...nameMatches]; - fetchOptions(inputValue, view, setOptions, before, rest); + const overpassOptions = getOverpassOptions(inputValue); + if (overpassOptions.length) { + setOptions(overpassOptions); return; } - setOptions([{ loader: true }]); - fetchOptions(inputValue, view, setOptions); + const { before, after } = await getPresetOptions(inputValue); + setOptions([...before, { loader: true }]); + + getGeocoderOptions(inputValue, view, setOptions, before, after); })(); }, [inputValue, stars]); }; @@ -208,13 +149,13 @@ const useOnClosePanel = () => { const SearchBox = () => { const { featureShown } = useFeatureContext(); - const [inputValue, setInputValue] = useState(''); + const { inputValue, setInputValue } = useInputValueState(); const [options, setOptions] = useState([]); const [overpassLoading, setOverpassLoading] = useState(false); const autocompleteRef = useRef(); const onClosePanel = useOnClosePanel(); - useFetchOptions(inputValue, setOptions); + useOptions(inputValue, setOptions); return ( diff --git a/src/components/SearchBox/onSelectedFactory.ts b/src/components/SearchBox/onSelectedFactory.ts index 84e96f79..e4131a71 100644 --- a/src/components/SearchBox/onSelectedFactory.ts +++ b/src/components/SearchBox/onSelectedFactory.ts @@ -36,9 +36,9 @@ const getSkeleton = (option) => { }; }; -export const onHighlightFactory = (setPreview) => (e, location) => { - if (!location?.lat) return; - setPreview({ ...getSkeleton(location), noPreviewButton: true }); +export const onHighlightFactory = (setPreview) => (e, option) => { + if (!option?.geometry?.coordinates) return; + setPreview({ ...getSkeleton(option), noPreviewButton: true }); }; const fitBounds = (option, panelShown = false) => { @@ -61,18 +61,20 @@ export const onSelectedFactory = if (option.star) { const apiId = getApiId(option.star.shortId); Router.push(`/${getUrlOsmId(apiId)}`); - // Router.push(`/${getUrlOsmId(apiId)}${window.location.hash}`); ???? return; } if (option.overpass || option.preset) { - const tags = option.overpass || option.preset.presetForSearch.tags; + const tagsOrQuery = + option.preset?.presetForSearch.tags ?? + option.overpass.tags ?? + option.overpass.query; const timeout = setTimeout(() => { setOverpassLoading(true); }, 300); - performOverpassSearch(bbox, tags) + performOverpassSearch(bbox, tagsOrQuery) .then((geojson) => { const count = geojson.features.length; const content = t('searchbox.overpass_success', { count }); diff --git a/src/components/SearchBox/options/overpass.ts b/src/components/SearchBox/options/overpass.ts new file mode 100644 index 00000000..f2b8f000 --- /dev/null +++ b/src/components/SearchBox/options/overpass.ts @@ -0,0 +1,33 @@ +import type { OverpassOption } from '../types'; +import { t } from '../../../services/intl'; + +export const getOverpassOptions = ( + inputValue: string, +): [OverpassOption] | [] => { + if (inputValue.match(/^(op|overpass):/)) { + return [ + { + overpass: { + query: inputValue.replace(/^(op|overpass):/, ''), + label: t('searchbox.overpass_custom_query'), + inputValue, + }, + }, + ]; + } + + if (inputValue.match(/^[-:_a-zA-Z0-9]+=/)) { + const [key, value] = inputValue.split('=', 2); + return [ + { + overpass: { + tags: { [key]: value || '*' }, + label: `${key}=${value || '*'}`, + inputValue, + }, + }, + ]; + } + + return []; +}; diff --git a/src/components/SearchBox/options/preset.ts b/src/components/SearchBox/options/preset.ts new file mode 100644 index 00000000..51c35599 --- /dev/null +++ b/src/components/SearchBox/options/preset.ts @@ -0,0 +1,78 @@ +import match from 'autosuggest-highlight/match'; +import { + fetchSchemaTranslations, + getPresetTermsTranslation, + getPresetTranslation, +} from '../../../services/tagging/translations'; +import { presets } from '../../../services/tagging/data'; +import { PresetOption } from '../types'; + +let presetsForSearch; +const getPresetsForSearch = async () => { + if (presetsForSearch) { + return presetsForSearch; + } + + await fetchSchemaTranslations(); + + // resolve symlinks to {landuse...} etc + presetsForSearch = Object.values(presets) + .filter(({ searchable }) => searchable === undefined || searchable) + .filter(({ locationSet }) => !locationSet?.include) + .filter(({ tags }) => Object.keys(tags).length > 0) + .map(({ name, presetKey, tags, terms }) => { + const tagsAsStrings = Object.entries(tags).map(([k, v]) => `${k}=${v}`); + return { + key: presetKey, + name: getPresetTranslation(presetKey) ?? name ?? 'x', + tags, + tagsAsOneString: tagsAsStrings.join(', '), + texts: [ + ...(getPresetTermsTranslation(presetKey) ?? terms ?? 'x').split(','), + ...tagsAsStrings, + presetKey, + ], + }; + }); + + return presetsForSearch; +}; + +const num = (text, inputValue) => + // TODO match function not always good - consider text.toLowerCase().includes(inputValue.toLowerCase()); + match(text, inputValue, { + insideWords: true, + findAllOccurrences: true, + }).length; + +type PresetOptions = Promise<{ + before: PresetOption[]; + after: PresetOption[]; +}>; + +export const getPresetOptions = async (inputValue): PresetOptions => { + if (inputValue.length <= 2) { + return { before: [], after: [] }; + } + + const results = (await getPresetsForSearch()).map((preset) => { + const name = num(preset.name, inputValue) * 10; + const textsByOne = preset.texts.map((term) => num(term, inputValue)); + const sum = name + textsByOne.reduce((a, b) => a + b, 0); + return { name, textsByOne, sum, presetForSearch: preset }; + }); + + const nameMatches = results + .filter((result) => result.name > 0) + .map((result) => ({ preset: result })); + + const rest = results + .filter((result) => result.name === 0 && result.sum > 0) + .map((result) => ({ preset: result })); + + const allResults = [...nameMatches, ...rest]; + const before = allResults.slice(0, 2); + const after = allResults.slice(2); + + return { before, after }; +}; diff --git a/src/components/SearchBox/renderOptionFactory.tsx b/src/components/SearchBox/renderOptionFactory.tsx index fdd619aa..f8e2d33a 100644 --- a/src/components/SearchBox/renderOptionFactory.tsx +++ b/src/components/SearchBox/renderOptionFactory.tsx @@ -129,14 +129,9 @@ export const renderOptionFactory = - - {Object.entries(overpass) - .map(([k, v]) => `${k} = ${v}`) - .join(' ')} - + {overpass.label} overpass search - {/* {t('searchbox.category')} */} diff --git a/src/components/SearchBox/types.ts b/src/components/SearchBox/types.ts new file mode 100644 index 00000000..6e3273b2 --- /dev/null +++ b/src/components/SearchBox/types.ts @@ -0,0 +1,36 @@ +// 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: { + query?: string; + tags?: Record; + inputValue: string; + label: string; + }; +}; + +export type PresetOption = { + preset: { + name: number; + textsByOne: number[]; + sum: number; + presetForSearch: { + key: string; + name: string; + tags: Record; + tagsAsOneString: string; + texts: string[]; + }; + }; +}; + +// TODO not used anywhere yet, typescript cant identify options by the key (overpass, preset, loader) +export type SearchOption = OverpassOption | PresetOption; diff --git a/src/locales/cs.js b/src/locales/cs.js index 0cdd1d42..bee95dd8 100644 --- a/src/locales/cs.js +++ b/src/locales/cs.js @@ -59,6 +59,7 @@ export default { 'searchbox.category': 'kategorie', 'searchbox.overpass_success': 'Nalezeno výsledků: __count__', 'searchbox.overpass_error': 'Chyba při načítání výsledků. __message__', + 'searchbox.overpass_custom_query': 'vlastní dotaz', 'featurepanel.no_name': 'beze jména', 'featurepanel.share_button': 'Sdílet', diff --git a/src/locales/vocabulary.js b/src/locales/vocabulary.js index 9799dccc..161226aa 100644 --- a/src/locales/vocabulary.js +++ b/src/locales/vocabulary.js @@ -67,6 +67,7 @@ export default { 'searchbox.category': 'category', 'searchbox.overpass_success': 'Results found: __count__', 'searchbox.overpass_error': 'Error fetching results. __message__', + 'searchbox.overpass_custom_query': 'custom query', 'featurepanel.no_name': 'No name', 'featurepanel.share_button': 'Share', diff --git a/src/services/fetch.ts b/src/services/fetch.ts index cd1014d6..33bd067f 100644 --- a/src/services/fetch.ts +++ b/src/services/fetch.ts @@ -6,6 +6,13 @@ import { FetchError } from './helpers'; // TODO cancel request in map.on('click', ...) const abortableQueues: Record = {}; +export const abortFetch = (queueName: string) => { + abortableQueues[queueName]?.abort( + new DOMException(`Aborted by abortFetch(${queueName})`, 'AbortError'), + ); + delete abortableQueues[queueName]; +}; + interface FetchOpts extends RequestInit { abortableQueueName?: string; nocache?: boolean; @@ -16,20 +23,20 @@ export const fetchText = async (url, opts: FetchOpts = {}) => { const item = getCache(key); if (item) return item; - const name = isBrowser() ? opts?.abortableQueueName : undefined; - if (name) { - abortableQueues[name]?.abort(); - abortableQueues[name] = new AbortController(); + const queueName = isBrowser() ? opts?.abortableQueueName : undefined; + if (queueName) { + abortableQueues[queueName]?.abort(); + abortableQueues[queueName] = new AbortController(); } try { const res = await fetch(url, { ...opts, - signal: abortableQueues[name]?.signal, + signal: abortableQueues[queueName]?.signal, }); - if (name) { - delete abortableQueues[name]; + if (queueName) { + delete abortableQueues[queueName]; } if (!res.ok || res.status < 200 || res.status >= 300) { @@ -60,10 +67,8 @@ export const fetchJson = async (url, opts: FetchOpts = {}) => { try { return JSON.parse(text); } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') { - throw e; - } - - throw new Error(`fetchJson: ${e.message}, in "${text?.substr(0, 30)}..."`); + throw new Error( + `fetchJson: parse error: ${e.message}, in "${text?.substr(0, 30)}..."`, + ); } }; diff --git a/src/services/overpassSearch.ts b/src/services/overpassSearch.ts index f7513b07..d3c13890 100644 --- a/src/services/overpassSearch.ts +++ b/src/services/overpassSearch.ts @@ -4,23 +4,19 @@ import { getCenter } from './getCenter'; import { OsmApiId } from './helpers'; import { fetchJson } from './fetch'; -const overpassQuery = (bbox, tags) => { - const query = tags +const getQueryFromTags = (tags) => { + const selector = tags .map(([k, v]) => (v === '*' ? `["${k}"]` : `["${k}"="${v}"]`)) .join(''); - - return `[out:json][timeout:25]; - ( - node${query}(${bbox}); - way${query}(${bbox}); - relation${query}(${bbox}); - ); - out geom qt;`; // "out geom;>;out geom qt;" to get all full subitems as well + return `nwr${selector}`; }; -const getOverpassUrl = ([a, b, c, d], tags) => +const getOverpassQuery = ([a, b, c, d], query) => + `[out:json][timeout:25][bbox:${[d, a, b, c]}];(${query};);out geom qt;`; + +const getOverpassUrl = (fullQuery) => `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( - overpassQuery([d, a, b, c], tags), + fullQuery, )}`; const GEOMETRY = { @@ -70,10 +66,16 @@ export const overpassGeomToGeojson = (response: any): Feature[] => export const performOverpassSearch = async ( bbox, - tags: Record, + tagsOrQuery: Record | string, ) => { - console.log('seaching overpass for tags: ', tags); // eslint-disable-line no-console - const overpass = await fetchJson(getOverpassUrl(bbox, Object.entries(tags))); + const body = + typeof tagsOrQuery === 'string' + ? tagsOrQuery + : getQueryFromTags(Object.entries(tagsOrQuery)); + const query = getOverpassQuery(bbox, body); + + console.log('seaching overpass for query: ', query); // eslint-disable-line no-console + const overpass = await fetchJson(getOverpassUrl(query)); console.log('overpass result:', overpass); // eslint-disable-line no-console const features = overpassGeomToGeojson(overpass);