From d7651e99dfb24e6957c0d725b91047b968f8933f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Zbytovsk=C3=BD?= Date: Sat, 30 Sep 2023 18:30:26 +0200 Subject: [PATCH] SearchBox: add search by categories + overpass (#186) --- .gitpod.yml | 3 + package.json | 3 +- src/components/FeaturePanel/OsmError.tsx | 8 + .../Map/behaviour/useUpdateStyle.tsx | 4 +- src/components/Map/consts.ts | 8 + src/components/Map/helpers.ts | 7 +- src/components/Map/styles/basicStyle.ts | 2 + .../Map/styles/layers/overpassLayers.ts | 120 ++++++++++ src/components/Map/styles/layers/poiLayers.ts | 5 +- src/components/Map/styles/outdoorStyle.ts | 2 + src/components/Map/styles/rasterStyle.ts | 2 + .../SearchBox/AutocompleteInput.tsx | 45 +++- src/components/SearchBox/SearchBox.tsx | 133 ++++++++++- src/components/SearchBox/highlightText.tsx | 6 +- src/components/SearchBox/onSelectedFactory.ts | 20 +- .../SearchBox/renderOptionFactory.tsx | 125 ++++++++--- src/components/utils/Maki.tsx | 1 + src/components/utils/MapStateContext.tsx | 24 ++ src/locales/cs.js | 4 + src/locales/vocabulary.js | 4 + src/services/__tests__/overpassSearch.test.ts | 206 ++++++++++++++++++ src/services/fetch.ts | 31 ++- src/services/intl.tsx | 4 +- src/services/osmApi.ts | 11 +- src/services/overpassSearch.ts | 96 ++++++++ src/services/tagging/translations.ts | 5 +- src/services/types.ts | 2 +- yarn.lock | 12 +- 28 files changed, 818 insertions(+), 75 deletions(-) create mode 100644 .gitpod.yml create mode 100644 src/components/Map/styles/layers/overpassLayers.ts create mode 100644 src/services/__tests__/overpassSearch.test.ts create mode 100644 src/services/overpassSearch.ts diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 00000000..a98171fc --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,3 @@ +tasks: + - init: yarn + command: yarn dev diff --git a/package.json b/package.json index 0071da75..884b8af2 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ }, "scripts": { "dev": "next", + "test": "jest", "lint": "eslint . --report-unused-disable-directives", "lintfix": "prettier . --write && eslint ./src ./pages --report-unused-disable-directives --fix", "prettify": "prettier . --write", @@ -34,7 +35,6 @@ "@openstreetmap/id-tagging-schema": "^6.1.0", "@sentry/browser": "^6.5.1", "@sentry/node": "^6.5.1", - "@types/maplibre-gl": "^1.13.1", "accept-language-parser": "^1.5.0", "autosuggest-highlight": "^3.1.1", "isomorphic-unfetch": "^3.1.0", @@ -57,6 +57,7 @@ "styled-jsx": "^3.4.4" }, "devDependencies": { + "@types/autosuggest-highlight": "^3.2.0", "@types/jest": "^26.0.23", "@typescript-eslint/eslint-plugin": "^4.26.0", "babel-eslint": "^10.1.0", diff --git a/src/components/FeaturePanel/OsmError.tsx b/src/components/FeaturePanel/OsmError.tsx index 85a63d1b..bd8ff871 100644 --- a/src/components/FeaturePanel/OsmError.tsx +++ b/src/components/FeaturePanel/OsmError.tsx @@ -52,5 +52,13 @@ export const OsmError = () => { ); } + if (Object.keys(feature.tags).length === 0 && !feature.point) { + return ( + + {t('featurepanel.info_no_tags')} + + ); + } + return null; }; diff --git a/src/components/Map/behaviour/useUpdateStyle.tsx b/src/components/Map/behaviour/useUpdateStyle.tsx index b2cb60b0..dbc6c3a9 100644 --- a/src/components/Map/behaviour/useUpdateStyle.tsx +++ b/src/components/Map/behaviour/useUpdateStyle.tsx @@ -5,7 +5,7 @@ import { osmappLayers } from '../../LayerSwitcher/osmappLayers'; import { rasterStyle } from '../styles/rasterStyle'; import { DEFAULT_MAP } from '../../../config'; -export const getRasterLayer = (key) => { +export const getRasterStyle = (key) => { const url = osmappLayers[key]?.url ?? key; // if `key` not found, it contains tiles URL return rasterStyle(key, url); }; @@ -18,6 +18,6 @@ export const useUpdateStyle = useMapEffect((map, activeLayers) => { ? basicStyle : key === 'outdoor' ? outdoorStyle - : getRasterLayer(key), + : getRasterStyle(key), ); }); diff --git a/src/components/Map/consts.ts b/src/components/Map/consts.ts index 09871320..d43126b2 100644 --- a/src/components/Map/consts.ts +++ b/src/components/Map/consts.ts @@ -1,4 +1,5 @@ // https://cloud.maptiler.com/account + const apiKey = '7dlhLl3hiXQ1gsth0kGu'; export const OSMAPP_SPRITE = `${window.location.protocol}//${window.location.host}/sprites/osmapp`; @@ -28,6 +29,13 @@ export const OSMAPP_SOURCES = { url: `https://api.maptiler.com/tiles/outdoor/tiles.json?key=${apiKey}`, type: 'vector' as const, }, + overpass: { + type: 'geojson' as const, + data: { + type: 'FeatureCollection', + features: [], + }, + }, }; export const BACKGROUND = [ diff --git a/src/components/Map/helpers.ts b/src/components/Map/helpers.ts index d63ccd8d..67216302 100644 --- a/src/components/Map/helpers.ts +++ b/src/components/Map/helpers.ts @@ -5,7 +5,7 @@ const isOsmLayer = (id) => { if (id.startsWith('place-country-')) return false; // https://github.com/zbycz/osmapp/issues/35 if (id === 'place-continent') return false; if (id === 'water-name-ocean') return false; - const prefixes = ['water-name-', 'poi-', 'place-']; + const prefixes = ['water-name-', 'poi-', 'place-', 'overpass-']; return prefixes.some((prefix) => id.startsWith(prefix)); }; @@ -23,6 +23,11 @@ export const getIsOsmObject = ({ id, layer }) => { if (layer.id === 'water-name-other' && id < 10e5) { return false; } + + if (layer.id?.startsWith('overpass')) { + return true; + } + return layersWithOsmId.includes(layer.id); }; diff --git a/src/components/Map/styles/basicStyle.ts b/src/components/Map/styles/basicStyle.ts index e57fb32a..7ad848bf 100644 --- a/src/components/Map/styles/basicStyle.ts +++ b/src/components/Map/styles/basicStyle.ts @@ -9,6 +9,7 @@ import { poiLayers } from './layers/poiLayers'; import { addHoverPaint } from '../behaviour/featureHover'; import { BACKGROUND, GLYPHS, OSMAPP_SOURCES, OSMAPP_SPRITE } from '../consts'; import { motorwayConstruction } from './layers/contruction'; +import { overpassLayers } from './layers/overpassLayers'; export const basicStyle = addHoverPaint({ version: 8, @@ -2835,6 +2836,7 @@ export const basicStyle = addHoverPaint({ }, }, ...poiLayers, + ...overpassLayers, ], id: 'ciw6czz2n00242kmg6hw20box', }); diff --git a/src/components/Map/styles/layers/overpassLayers.ts b/src/components/Map/styles/layers/overpassLayers.ts new file mode 100644 index 00000000..b9ff5278 --- /dev/null +++ b/src/components/Map/styles/layers/overpassLayers.ts @@ -0,0 +1,120 @@ +import type { LayerSpecification } from '@maplibre/maplibre-gl-style-spec'; + +export const overpassLayers: LayerSpecification[] = [ + { + id: 'overpass-line-casing', + type: 'line', + source: 'overpass', + paint: { + 'line-color': '#f8f4f0', + 'line-width': 6, + }, + }, + { + id: 'overpass-line', + type: 'line', + source: 'overpass', + paint: { + 'line-color': '#f00', + 'line-width': 2, + 'line-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + }, + }, + { + id: 'overpass-line-text', + type: 'symbol', + source: 'overpass', + layout: { + 'symbol-placement': 'line', + 'text-font': ['Noto Sans Regular'], + 'text-field': '{name}', + 'text-size': 12, + 'text-rotation-alignment': 'map', + }, + paint: { + 'text-color': '#000000', + 'text-halo-width': 1.5, + 'text-halo-color': 'rgba(255,255,255,0.7)', + 'text-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + }, + }, + { + id: 'overpass-fill', + type: 'fill', + source: 'overpass', + filter: ['all', ['==', '$type', 'Polygon']], + paint: { + 'fill-color': '#f00', + 'fill-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.2, + 0.5, + ], + }, + }, + { + id: 'overpass-circle', + type: 'circle', + source: 'overpass', + filter: ['all', ['==', '$type', 'Point']], + paint: { + 'circle-color': 'rgba(255,255,255,0.9)', + 'circle-radius': 12, + 'circle-stroke-width': 1, + 'circle-stroke-color': 'rgba(255,0,0,0.9)', + 'circle-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + }, + }, + { + id: 'overpass-symbol', + type: 'symbol', + source: 'overpass', + filter: ['all', ['==', '$type', 'Point']], + layout: { + 'text-padding': 2, + 'text-font': ['Noto Sans Regular'], + 'text-anchor': 'top', + 'icon-image': '{class}_11', + 'text-field': '{name}', + 'text-offset': [0, 0.6], + 'text-size': 12, + 'text-max-width': 9, + 'icon-allow-overlap': true, + 'icon-ignore-placement': true, + }, + paint: { + // 'text-halo-blur': 0.5, + 'text-color': '#000', + 'text-halo-width': 1.5, + 'text-halo-color': 'rgba(255,255,255,0.7)', + 'icon-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + 'text-opacity': [ + 'case', + ['boolean', ['feature-state', 'hover'], false], + 0.5, + 1, + ], + }, + }, +]; diff --git a/src/components/Map/styles/layers/poiLayers.ts b/src/components/Map/styles/layers/poiLayers.ts index 4e7af7f1..a97a9e47 100644 --- a/src/components/Map/styles/layers/poiLayers.ts +++ b/src/components/Map/styles/layers/poiLayers.ts @@ -1,6 +1,7 @@ -// originaly from basicStyle +import type { LayerSpecification } from '@maplibre/maplibre-gl-style-spec'; -export const poiLayers = [ +// originaly from basicStyle +export const poiLayers: LayerSpecification[] = [ { id: 'poi-level-3', type: 'symbol', diff --git a/src/components/Map/styles/outdoorStyle.ts b/src/components/Map/styles/outdoorStyle.ts index f9ce465a..f683d3dd 100644 --- a/src/components/Map/styles/outdoorStyle.ts +++ b/src/components/Map/styles/outdoorStyle.ts @@ -6,6 +6,7 @@ import { poiLayers } from './layers/poiLayers'; import { addHoverPaint } from '../behaviour/featureHover'; import { BACKGROUND, GLYPHS, OSMAPP_SOURCES, OSMAPP_SPRITE } from '../consts'; import { motorwayConstruction } from './layers/contruction'; +import { overpassLayers } from './layers/overpassLayers'; // TODO add icons for outdoor to our sprite (guideposts, benches, etc) // https://api.maptiler.com/maps/outdoor/sprite.png?key=7dlhLl3hiXQ1gsth0kGu @@ -3884,6 +3885,7 @@ export const outdoorStyle = addHoverPaint({ 'source-layer': 'place', }, ...poiLayers, + ...overpassLayers, ], bearing: 0, sources: OSMAPP_SOURCES, diff --git a/src/components/Map/styles/rasterStyle.ts b/src/components/Map/styles/rasterStyle.ts index 5f8ac514..06f94591 100644 --- a/src/components/Map/styles/rasterStyle.ts +++ b/src/components/Map/styles/rasterStyle.ts @@ -1,5 +1,6 @@ import type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec'; import { GLYPHS, OSMAPP_SOURCES, OSMAPP_SPRITE } from '../consts'; +import { overpassLayers } from './layers/overpassLayers'; const getSource = (url) => { if (url.match('{bingSubdomains}')) { @@ -38,6 +39,7 @@ export const rasterStyle = (id, url): StyleSpecification => { minzoom: 0, }, // ...poiLayers, // TODO maybe add POIs + ...overpassLayers, ], sources: { ...OSMAPP_SOURCES, // keep default sources for faster switching diff --git a/src/components/SearchBox/AutocompleteInput.tsx b/src/components/SearchBox/AutocompleteInput.tsx index 6d23948e..797f66d2 100644 --- a/src/components/SearchBox/AutocompleteInput.tsx +++ b/src/components/SearchBox/AutocompleteInput.tsx @@ -7,8 +7,30 @@ import { t } from '../../services/intl'; import { onHighlightFactory, onSelectedFactory } from './onSelectedFactory'; import { useMobileMode } from '../helpers'; import { useUserThemeContext } from '../../helpers/theme'; +import { useMapStateContext } from '../utils/MapStateContext'; + +const useFocusOnSlash = () => { + 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; +}; const SearchBoxInput = ({ params, setInputValue, autocompleteRef }) => { + const inputRef = useFocusOnSlash(); const { InputLabelProps, InputProps, ...restParams } = params; useEffect(() => { @@ -21,8 +43,9 @@ const SearchBoxInput = ({ params, setInputValue, autocompleteRef }) => { return ( @@ -36,18 +59,34 @@ export const AutocompleteInput = ({ autocompleteRef, }) => { const { setFeature, setPreview } = useFeatureContext(); + const { bbox, showToast } = useMapStateContext(); const mobileMode = useMobileMode(); const { currentTheme } = useUserThemeContext(); return ( x} getOptionLabel={(option) => - option.properties.name || buildPhotonAddress(option.properties) + option.properties?.name || + option.preset?.presetForSearch?.name || + (option.overpass && + Object.entries(option.overpass) + ?.map(([k, v]) => `${k}=${v}`) + .join(' ')) || + (option.loader ? '' : buildPhotonAddress(option.properties)) } - onChange={onSelectedFactory(setFeature, setPreview, mobileMode)} + onChange={onSelectedFactory( + setFeature, + setPreview, + mobileMode, + bbox, + showToast, + )} onHighlightChange={onHighlightFactory(setPreview)} + getOptionDisabled={(o) => o.loader} autoComplete disableClearable autoHighlight diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index b334f8ed..f2a1d9a8 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -1,17 +1,25 @@ import React, { useRef, useState } from 'react'; import styled from 'styled-components'; -import throttle from 'lodash/throttle'; +import debounce from 'lodash/debounce'; import SearchIcon from '@material-ui/icons/Search'; import Paper from '@material-ui/core/Paper'; import IconButton from '@material-ui/core/IconButton'; import Router from 'next/router'; +import match from 'autosuggest-highlight/match'; + import { fetchJson } from '../../services/fetch'; import { useMapStateContext } from '../utils/MapStateContext'; import { useFeatureContext } from '../utils/FeatureContext'; import { AutocompleteInput } from './AutocompleteInput'; -import { t } from '../../services/intl'; +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'; const TopPanel = styled.div` position: absolute; @@ -47,18 +55,112 @@ const SearchIconButton = styled(IconButton)` } `; +const PHOTON_SUPPORTED_LANGS = ['en', 'de', 'fr']; + const getApiUrl = (inputValue, view) => { const [zoom, lat, lon] = view; const lvl = Math.max(0, Math.min(16, Math.round(zoom))); const q = encodeURIComponent(inputValue); - return `https://photon.komoot.io/api/?q=${q}&lon=${lon}&lat=${lat}&zoom=${lvl}`; + 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}`; +}; + +// https://docs.mapbox.com/help/troubleshooting/working-with-large-geojson-data/ + +let presetsForSearch = []; + +fetchSchemaTranslations().then(() => { + // resolve symlinks to {landuse...} etc + presetsForSearch = Object.values(presets) + .filter(({ searchable }) => searchable === undefined || searchable) + .filter(({ locationSet }) => !locationSet?.include) + .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, + ], + }; + }); +}); + +const num = (text, inputValue) => + match(text, inputValue, { + insideWords: true, + findAllOccurrences: true, + }).length; +// return text.toLowerCase().includes(inputValue.toLowerCase()); + +const findInPresets = (inputValue) => { + // const start = performance.now(); + + const results = presetsForSearch.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 })); + + // // experiment with sorting by number of matches + // const options = results + // .filter((result) => result.sum > 0) + // .sort((a, b) => { + // // by number of matches + // if (a.sum > b.sum) return -1; + // if (a.sum < b.sum) return 1; + // return 0; + // }) + // .map((result) => ({ preset: result })); + // console.log('results time', performance.now() - start, options); + + return nameMatches.length + ? { nameMatches, rest } + : { nameMatches: rest, rest: [] }; }; -const fetchOptions = throttle(async (inputValue, view, setOptions) => { - const searchResponse = await fetchJson(getApiUrl(inputValue, view)); - const options = searchResponse.features; - setOptions(options || []); -}, 400); +const getOverpassQuery = (inputValue: string) => { + if (inputValue.match(/^[-:_a-zA-Z]+=/)) { + const [key, value] = inputValue.split('=', 2); + + return [{ overpass: { [key]: value || '*' } }]; + } + + return []; +}; + +const fetchOptions = debounce( + async (inputValue, view, setOptions, nameMatches = [], rest = []) => { + try { + const searchResponse = await fetchJson(getApiUrl(inputValue, view), { + abortableQueueName: 'search', + }); + const options = searchResponse.features; + + const before = nameMatches.slice(0, 2); + const after = [...nameMatches.slice(2), ...rest]; + + setOptions([...before, ...(options || []), ...after]); + } catch (e) { + // eslint-disable-next-line no-console + console.log('search aborted', e); + } + }, + 400, +); const SearchBox = () => { const { featureShown, feature, setFeature, setPreview } = useFeatureContext(); @@ -73,7 +175,20 @@ const SearchBox = () => { setOptions([]); return; } - fetchOptions(inputValue, view, setOptions); + if (inputValue.length > 2) { + const overpassQuery = getOverpassQuery(inputValue); + const { nameMatches, rest } = findInPresets(inputValue); + setOptions([ + ...overpassQuery, + ...nameMatches.slice(0, 2), + { loader: true }, + ]); + const before = [...overpassQuery, ...nameMatches]; + fetchOptions(inputValue, view, setOptions, before, rest); + } else { + setOptions([{ loader: true }]); + fetchOptions(inputValue, view, setOptions); + } }, [inputValue]); const closePanel = () => { diff --git a/src/components/SearchBox/highlightText.tsx b/src/components/SearchBox/highlightText.tsx index d40f2270..aa4f6a89 100644 --- a/src/components/SearchBox/highlightText.tsx +++ b/src/components/SearchBox/highlightText.tsx @@ -3,7 +3,11 @@ import match from 'autosuggest-highlight/match'; import parse from 'autosuggest-highlight/parse'; export const highlightText = (resultText, inputValue) => { - const parts = parse(resultText, match(resultText, inputValue)); + const matches = match(resultText, inputValue, { + insideWords: true, + findAllOccurrences: true, + }); + const parts = parse(resultText, matches); const map = parts.map((part, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/src/components/SearchBox/onSelectedFactory.ts b/src/components/SearchBox/onSelectedFactory.ts index 312cd94a..5df9372a 100644 --- a/src/components/SearchBox/onSelectedFactory.ts +++ b/src/components/SearchBox/onSelectedFactory.ts @@ -3,6 +3,8 @@ import maplibregl from 'maplibre-gl'; import { getShortId, getUrlOsmId } from '../../services/helpers'; import { addFeatureCenterToCache } from '../../services/osmApi'; import { getGlobalMap } from '../../services/mapStorage'; +import { performOverpassSearch } from '../../services/overpassSearch'; +import { t } from '../../services/intl'; const getElementType = (osmType) => { switch (osmType) { @@ -54,7 +56,23 @@ const fitBounds = (option, panelShown = false) => { }; export const onSelectedFactory = - (setFeature, setPreview, mobileMode) => (e, option) => { + (setFeature, setPreview, mobileMode, bbox, showToast) => (_, option) => { + if (option.overpass || option.preset) { + const tags = option.overpass || option.preset.presetForSearch.tags; + performOverpassSearch(bbox, tags) + .then((geojson) => { + const count = geojson.features.length; + const content = t('searchbox.overpass_success', { count }); + showToast({ content }); + getGlobalMap().getSource('overpass')?.setData(geojson); + }) + .catch((e) => { + const message = `${e}`.substring(0, 100); + const content = t('searchbox.overpass_error', { message }); + showToast({ content, type: 'error' }); + }); + return; + } if (!option?.geometry.coordinates) return; const skeleton = getSkeleton(option); diff --git a/src/components/SearchBox/renderOptionFactory.tsx b/src/components/SearchBox/renderOptionFactory.tsx index a5d423b4..a6fe9f76 100644 --- a/src/components/SearchBox/renderOptionFactory.tsx +++ b/src/components/SearchBox/renderOptionFactory.tsx @@ -2,11 +2,14 @@ import Grid from '@material-ui/core/Grid'; import Typography from '@material-ui/core/Typography'; import React from 'react'; import styled from 'styled-components'; +import FolderIcon from '@material-ui/icons/Folder'; +import SearchIcon from '@material-ui/icons/Search'; import { useMapStateContext } from '../utils/MapStateContext'; import Maki from '../utils/Maki'; import { highlightText } from './highlightText'; import { join } from '../../utils'; import { getPoiClass } from '../../services/getPoiClass'; +import { t } from '../../services/intl'; /** photon { @@ -115,36 +118,98 @@ export const buildPhotonAddress = ({ streetnumber: snum, }) => join(street ?? place ?? city, ' ', hnum ? hnum.replace(' ', '/') : snum); -export const renderOptionFactory = (inputValue, currentTheme) => (option) => { - const { properties, geometry } = option; - const { name, osm_key: tagKey, osm_value: tagValue } = properties; +export const renderOptionFactory = + (inputValue, currentTheme) => + ({ geometry, preset, properties, overpass, loader }) => { + if (overpass) { + return ( + <> + + + + + + {Object.entries(overpass) + .map(([k, v]) => `${k} = ${v}`) + .join(' ')} + + + overpass search + {/* {t('searchbox.category')} */} + + + + ); + } - const [lon, lat] = geometry.coordinates; - const mapCenter = useMapCenter(); - const dist = getDistance(mapCenter, { lon, lat }) / 1000; - const distKm = dist < 10 ? Math.round(dist * 10) / 10 : Math.round(dist); // TODO save imperial to mapState and multiply 0.621371192 + if (loader) { + return ( + <> + + + + {t('loading')} + . + . + . + + + + ); + } - const text = name || buildPhotonAddress(properties); - const additionalText = getAdditionalText(properties); - const poiClass = getPoiClass({ [tagKey]: tagValue }); + if (preset) { + const { name } = preset.presetForSearch; + const additionalText = + preset.name === 0 + ? ` (${preset.presetForSearch.texts.find( + (_, idx) => preset.textsByOne[idx] > 0, + )}…)` + : ''; - return ( - <> - - -
{distKm} km
-
- - {highlightText(text, inputValue)} - - {additionalText} - - - - ); -}; + return ( + <> + + + + + {highlightText(`${name}${additionalText}`, inputValue)} + + {t('searchbox.category')} + + + + ); + } + + const { name, osm_key: tagKey, osm_value: tagValue } = properties; + + const [lon, lat] = geometry.coordinates; + const mapCenter = useMapCenter(); + const dist = getDistance(mapCenter, { lon, lat }) / 1000; + const distKm = dist < 10 ? Math.round(dist * 10) / 10 : Math.round(dist); // TODO save imperial to mapState and multiply 0.621371192 + + const text = name || buildPhotonAddress(properties); + const additionalText = getAdditionalText(properties); + const poiClass = getPoiClass({ [tagKey]: tagValue }); + + return ( + <> + + +
{distKm} km
+
+ + {highlightText(text, inputValue)} + + {additionalText} + + + + ); + }; diff --git a/src/components/utils/Maki.tsx b/src/components/utils/Maki.tsx index 06b5721e..eb26a03d 100644 --- a/src/components/utils/Maki.tsx +++ b/src/components/utils/Maki.tsx @@ -16,6 +16,7 @@ const Maki = ({ middle = undefined, }) => { const icon = icons.includes(ico) ? ico : 'information'; + // console.log(icon, ' was: ',ico) return ( { setViewForMap(newView); }, []); + const [open, setOpen] = React.useState(false); + const [msg, setMsg] = React.useState(undefined); + + const handleClose = (event?: React.SyntheticEvent, reason?: string) => { + if (reason === 'clickaway') { + return; + } + + setOpen(false); + }; + + const showToast = (message) => { + setMsg(message); + setOpen(true); + }; + const mapState = { bbox, setBbox, @@ -52,11 +70,17 @@ export const MapStateProvider = ({ children, initialMapView }) => { setViewFromMap: setView, activeLayers, setActiveLayers, + showToast, }; return ( {children} + + + {msg?.content} + + ); }; diff --git a/src/locales/cs.js b/src/locales/cs.js index 97adebcd..0e1727d8 100644 --- a/src/locales/cs.js +++ b/src/locales/cs.js @@ -43,6 +43,9 @@ export default { 'homepage.maptiler': 'MapTiler – za skvělé vektorové mapy
a za podporu tohoto projektu ❤️ ', 'searchbox.placeholder': 'Prohledat OpenStreetMap', + 'searchbox.category': 'kategorie', + 'searchbox.overpass_success': 'Nalezeno výsledků: __count__', + 'searchbox.overpass_error': 'Chyba při načítání výsledků. __message__', 'featurepanel.no_name': 'beze jména', 'featurepanel.share_button': 'Sdílet', @@ -52,6 +55,7 @@ export default { 'featurepanel.error_unknown': 'Při stahování prvku z OpenStreetMap se stala neznámá chyba.', 'featurepanel.error_network': 'Nelze stáhnout prvek, zkontrolujte připojení k internetu.', 'featurepanel.error_deleted': 'Tento prvek je v OpenStreetMap označen jako smazaný.', + 'featurepanel.info_no_tags': 'Tento prvek nemá žádné vlastnosti (tagy). Obvykle to znamená, že nese pouze geometrii/polohu pro nadřazený objekt.', 'featurepanel.history_button': 'Historie »', 'featurepanel.other_info_heading': 'Další informace', 'featurepanel.edit_button_title': 'Upravit v databázi OpenStreetMap', diff --git a/src/locales/vocabulary.js b/src/locales/vocabulary.js index 727e0a86..0bd40743 100644 --- a/src/locales/vocabulary.js +++ b/src/locales/vocabulary.js @@ -50,6 +50,9 @@ export default { 'homepage.maptiler': 'MapTiler – for awesome vector maps
and for supporting this project ❤️ ', 'searchbox.placeholder': 'Search OpenStreetMap', + 'searchbox.category': 'category', + 'searchbox.overpass_success': 'Results found: __count__', + 'searchbox.overpass_error': 'Error fetching results. __message__', 'featurepanel.no_name': 'No name', 'featurepanel.share_button': 'Share', @@ -59,6 +62,7 @@ export default { 'featurepanel.error_unknown': 'Unknown error while fetching feature from OpenStreetMap.', 'featurepanel.error_network': "Can't get the feature, check your network cable.", 'featurepanel.error_deleted': 'This object is marked as deleted in OpenStreetMap.', + 'featurepanel.info_no_tags': 'This object has no tags. Usually it means that it only carries geometry/location for a parent object.', 'featurepanel.history_button': 'History »', 'featurepanel.other_info_heading': 'Details', 'featurepanel.edit_button_title': 'Edit in OpenStreetMap database', diff --git a/src/services/__tests__/overpassSearch.test.ts b/src/services/__tests__/overpassSearch.test.ts new file mode 100644 index 00000000..e95e9fcf --- /dev/null +++ b/src/services/__tests__/overpassSearch.test.ts @@ -0,0 +1,206 @@ +import { osmJsonToSkeletons } from '../overpassSearch'; + +/* +[out:json][timeout:25]; + ( + node["type"="route"]["route"="bus"](49.93841,14.28715,49.97035,14.34812); + way["type"="route"]["route"="bus"](49.93841,14.28715,49.97035,14.34812); + relation["type"="route"]["route"="bus"](49.93841,14.28715,49.97035,14.34812); + ); + out body geom; +*/ + +const response = { + version: 0.6, + generator: 'Overpass API 0.7.61.5 4133829e', + osm3s: { + timestamp_osm_base: '2023-09-28T10:25:11Z', + copyright: + 'The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.', + }, + elements: [ + { + type: 'relation', + id: 8337908, + bounds: { + minlat: 49.9510996, + minlon: 14.3394962, + maxlat: 49.9602015, + maxlon: 14.3530042, + }, + members: [ + { + type: 'node', + ref: 4303193142, + role: 'platform', + lat: 49.9511921, + lon: 14.3409309, + }, + { + type: 'node', + ref: 4303193147, + role: 'platform', + lat: 49.9560895, + lon: 14.3420546, + }, + { + type: 'node', + ref: 5650107055, + role: 'platform', + lat: 49.95976, + lon: 14.35261, + }, + { + type: 'way', + ref: 143079042, + role: '', + geometry: [ + { lat: 49.9510996, lon: 14.3406283 }, + { lat: 49.9512392, lon: 14.340966 }, + { lat: 49.9512882, lon: 14.3409961 }, + ], + }, + { + type: 'way', + ref: 431070311, + role: '', + geometry: [ + { lat: 49.9512882, lon: 14.3409961 }, + { lat: 49.9513048, lon: 14.3408764 }, + { lat: 49.9512958, lon: 14.3406756 }, + { lat: 49.9512723, lon: 14.3405453 }, + ], + }, + { + type: 'way', + ref: 143079039, + role: '', + geometry: [ + { lat: 49.9512723, lon: 14.3405453 }, + { lat: 49.9512769, lon: 14.3403348 }, + { lat: 49.9512638, lon: 14.3402265 }, + { lat: 49.9512376, lon: 14.3399046 }, + { lat: 49.9512538, lon: 14.3397224 }, + { lat: 49.9513307, lon: 14.3396011 }, + { lat: 49.9514674, lon: 14.339525 }, + { lat: 49.9516502, lon: 14.3394962 }, + { lat: 49.9519514, lon: 14.3395323 }, + { lat: 49.9524812, lon: 14.3397353 }, + { lat: 49.9528361, lon: 14.3398737 }, + { lat: 49.9529293, lon: 14.3399175 }, + { lat: 49.9533275, lon: 14.3401047 }, + { lat: 49.9536045, lon: 14.3402959 }, + { lat: 49.9543101, lon: 14.340794 }, + { lat: 49.9555376, lon: 14.3415765 }, + { lat: 49.9564591, lon: 14.3422582 }, + { lat: 49.9569866, lon: 14.3425944 }, + { lat: 49.9574162, lon: 14.3429583 }, + { lat: 49.9576284, lon: 14.3431366 }, + { lat: 49.9578467, lon: 14.3434496 }, + { lat: 49.9582484, lon: 14.3441399 }, + { lat: 49.9588316, lon: 14.3454072 }, + { lat: 49.9589787, lon: 14.3459137 }, + { lat: 49.9590815, lon: 14.3465416 }, + ], + }, + { + type: 'way', + ref: 538959927, + role: '', + geometry: [ + { lat: 49.9590815, lon: 14.3465416 }, + { lat: 49.9596598, lon: 14.3496964 }, + { lat: 49.9598834, lon: 14.3509159 }, + { lat: 49.959959, lon: 14.3513285 }, + { lat: 49.9600528, lon: 14.3518402 }, + { lat: 49.9600898, lon: 14.3520419 }, + { lat: 49.9602015, lon: 14.3522125 }, + ], + }, + { + type: 'way', + ref: 311389592, + role: '', + geometry: [ + { lat: 49.9598062, lon: 14.3530042 }, + { lat: 49.9598857, lon: 14.3529381 }, + { lat: 49.9599703, lon: 14.3528484 }, + { lat: 49.9600301, lon: 14.3527557 }, + { lat: 49.9600872, lon: 14.3526668 }, + { lat: 49.9601506, lon: 14.3525137 }, + { lat: 49.9602015, lon: 14.3522125 }, + ], + }, + { + type: 'way', + ref: 166349501, + role: '', + geometry: [ + { lat: 49.9598062, lon: 14.3530042 }, + { lat: 49.9597388, lon: 14.3526991 }, + { lat: 49.9596846, lon: 14.3526796 }, + { lat: 49.9595677, lon: 14.3527257 }, + ], + }, + ], + tags: { + from: 'Kazín', + name: '243: Kazín ⇒ Lipence', + network: 'PID', + operator: 'cz:DPP', + 'public_transport:version': '2', + ref: '243', + route: 'bus', + source: 'survey', + to: 'Lipence', + type: 'route', + website: 'https://pid.cz/linka/243', + }, + }, + ], +}; + +const skeletons = [ + { + geometry: { + type: 'LineString', + }, + osmMeta: { + id: 8337908, + type: 'relation', + }, + properties: { + class: 'bus', + from: 'Kazín', + name: '243: Kazín ⇒ Lipence', + network: 'PID', + operator: 'cz:DPP', + 'public_transport:version': '2', + ref: '243', + route: 'bus', + source: 'survey', + subclass: 'bus', + to: 'Lipence', + type: 'route', + website: 'https://pid.cz/linka/243', + }, + tags: { + from: 'Kazín', + name: '243: Kazín ⇒ Lipence', + network: 'PID', + operator: 'cz:DPP', + 'public_transport:version': '2', + ref: '243', + route: 'bus', + source: 'survey', + to: 'Lipence', + type: 'route', + website: 'https://pid.cz/linka/243', + }, + type: 'Feature', + }, +]; + +test('conversion', () => { + expect(osmJsonToSkeletons(response)).toEqual(skeletons); +}); diff --git a/src/services/fetch.ts b/src/services/fetch.ts index 9980e060..62a95b59 100644 --- a/src/services/fetch.ts +++ b/src/services/fetch.ts @@ -18,14 +18,10 @@ export class FetchError extends Error { } // TODO cancel request in map.on('click', ...) -const noRequestRunning = { - abort: () => {}, - signal: null, -}; -let abortController = noRequestRunning; +const abortableQueues: Record = {}; interface FetchOpts extends RequestInit { - putInAbortableQueue?: boolean; + abortableQueueName?: string; nocache?: boolean; } @@ -34,18 +30,21 @@ export const fetchText = async (url, opts: FetchOpts = {}) => { const item = getCache(key); if (item) return item; - if (isBrowser() && opts?.putInAbortableQueue) { - abortController.abort(); - abortController = new AbortController(); + const name = isBrowser() ? opts?.abortableQueueName : undefined; + if (name) { + abortableQueues[name]?.abort(); + abortableQueues[name] = new AbortController(); } try { const res = await fetch(url, { ...opts, - signal: abortController.signal, + signal: abortableQueues[name]?.signal, }); - abortController = noRequestRunning; + if (name) { + delete abortableQueues[name]; + } if (!res.ok || res.status < 200 || res.status >= 300) { const data = await res.text(); @@ -62,15 +61,23 @@ export const fetchText = async (url, opts: FetchOpts = {}) => { } return text; } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + throw e; + } + throw new FetchError(`${e.message} at ${url}`, e.code || 'network', e.data); // TODO how to tell network error from code exception? } }; -export const fetchJson = async (url, opts = {}) => { +export const fetchJson = async (url, opts: FetchOpts = {}) => { const text = await fetchText(url, opts); 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)}..."`); } }; diff --git a/src/services/intl.tsx b/src/services/intl.tsx index 9e417086..73512c35 100644 --- a/src/services/intl.tsx +++ b/src/services/intl.tsx @@ -6,7 +6,7 @@ import { isBrowser, isServer } from '../components/helpers'; import { getServerIntl } from './intlServer'; import { publishDbgObject } from '../utils'; -type Values = { [variable: string]: string }; +type Values = { [variable: string]: string | number }; interface Intl { lang: string; @@ -23,7 +23,7 @@ const VARIABLE_REGEX = /__(?[a-zA-Z_]+)__/g; const replaceValues = (text: string, values: Values) => text.replace(VARIABLE_REGEX, (match, variableName) => { const value = values && values[variableName]; - return value != null ? value : '?'; + return value != null ? `${value}` : '?'; }); export const t = (id: TranslationId, values?: Values) => { diff --git a/src/services/osmApi.ts b/src/services/osmApi.ts index 442a4ce2..3c39aaed 100644 --- a/src/services/osmApi.ts +++ b/src/services/osmApi.ts @@ -76,8 +76,15 @@ export const clearFeatureCache = (apiId) => { }; const osmToFeature = (element): Feature => { - const { tags, lat, lon, nodes, members, osmappDeletedMarker, ...osmMeta } = - element; + const { + tags = {}, + lat, + lon, + nodes, + members, + osmappDeletedMarker, + ...osmMeta + } = element; return { type: 'Feature' as const, geometry: undefined, diff --git a/src/services/overpassSearch.ts b/src/services/overpassSearch.ts new file mode 100644 index 00000000..17c58eec --- /dev/null +++ b/src/services/overpassSearch.ts @@ -0,0 +1,96 @@ +import { Feature, LineString, Point } from './types'; +import { getPoiClass } from './getPoiClass'; +import { getCenter } from './getCenter'; +import { OsmApiId } from './helpers'; +import { fetchJson } from './fetch'; + +const overpassQuery = (bbox, tags) => { + const query = tags + .map(([k, v]) => (v === '*' ? `["${k}"]` : `["${k}"="${v}"]`)) + .join(''); + + return `[out:json][timeout:25]; + ( + node${query}(${bbox}); + way${query}(${bbox}); + relation${query}(${bbox}); + ); + out body; + >; + out skel qt;`; + // consider: out body geom +}; + +const getOverpassUrl = ([a, b, c, d], tags) => + `https://overpass-api.de/api/interpreter?data=${encodeURIComponent( + overpassQuery([d, a, b, c], tags), + )}`; + +const notNull = (x) => x != null; + +// maybe take inspiration from https://github.com/tyrasd/osmtogeojson/blob/gh-pages/index.js +export const osmJsonToSkeletons = (response: any): Feature[] => { + const nodesById = response.elements + .filter((element) => element.type === 'node') + .reduce((acc, node) => { + acc[node.id] = node; + return acc; + }, {}); + + const getGeometry2 = { + node: ({ lat, lon }): Point => ({ type: 'Point', coordinates: [lon, lat] }), + way: (way): LineString => { + const { nodes } = way; + return { + type: 'LineString', // TODO distinguish area - match id-presets, then add icon for polygons + coordinates: nodes + ?.map((nodeId) => nodesById[nodeId]) + .map(({ lat, lon }) => [lon, lat]), + }; + }, + relation: ({ members }): LineString => ({ + type: 'LineString', + coordinates: members[0]?.geometry // TODO make proper relation handling + ?.filter(notNull) + ?.map(({ lat, lon }) => [lon, lat]), + }), + }; + + return response.elements.map((element) => { + const { type, id, tags = {} } = element; + const geometry = getGeometry2[type]?.(element); + return { + type: 'Feature', + osmMeta: { type, id }, + tags, + properties: { ...getPoiClass(tags), ...tags }, + geometry, + center: getCenter(geometry) ?? undefined, + }; + }); +}; + +const convertOsmIdToMapId = (apiId: OsmApiId) => { + const osmToMapType = { node: 0, way: 1, relation: 4 }; + return parseInt(`${apiId.id}${osmToMapType[apiId.type]}`, 10); +}; + +export async function performOverpassSearch( + bbox, + tags: Record, +) { + console.log('seaching overpass for tags: ', tags); // eslint-disable-line no-console + const overpass = await fetchJson(getOverpassUrl(bbox, Object.entries(tags))); + console.log('overpass result:', overpass); // eslint-disable-line no-console + + const features = osmJsonToSkeletons(overpass) + .filter((feature) => feature.center && Object.keys(feature.tags).length > 0) + .map((feature) => ({ + ...feature, + id: convertOsmIdToMapId(feature.osmMeta), + })); + + console.log('overpass geojson', features); // eslint-disable-line no-console + + return { type: 'FeatureCollection', features }; +} diff --git a/src/services/tagging/translations.ts b/src/services/tagging/translations.ts index 16800b41..7cce21eb 100644 --- a/src/services/tagging/translations.ts +++ b/src/services/tagging/translations.ts @@ -26,7 +26,10 @@ export const mockSchemaTranslations = (mockTranslations) => { }; export const getPresetTranslation = (key: string) => - translations ? translations[intl.lang].presets.presets[key].name : undefined; + translations?.[intl.lang]?.presets?.presets?.[key]?.name; + +export const getPresetTermsTranslation = (key: string) => + translations?.[intl.lang]?.presets?.presets?.[key]?.terms; export const getFieldTranslation = (field: Field) => { if (!translations) return undefined; diff --git a/src/services/types.ts b/src/services/types.ts index e4810b70..29421836 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -50,7 +50,7 @@ interface RelationMember { // TODO split in two types /extend/ export interface Feature { - point?: boolean; + point?: boolean; // TODO rename to isMarker or isCoords type: 'Feature'; geometry?: FeatureGeometry; osmMeta: { diff --git a/yarn.lock b/yarn.lock index e1263690..6675bc96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1941,6 +1941,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/autosuggest-highlight@^3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@types/autosuggest-highlight/-/autosuggest-highlight-3.2.0.tgz#716a1408b8d987a6b636cf30048cba101d9f1cae" + integrity sha512-bTcsL4YYypjhKfPaImxuoMPiTyiUp7VGKytMr15/413IoazrOIfV/gca2ysI/IW0ftZYCPI5xppRm6IVX1Efqw== + "@types/babel__core@^7.0.0": version "7.1.13" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.13.tgz#bc6eea53975fdf163aff66c086522c6f293ae4cf" @@ -2076,13 +2081,6 @@ "@types/mapbox__point-geometry" "*" "@types/pbf" "*" -"@types/maplibre-gl@^1.13.1": - version "1.13.1" - resolved "https://registry.yarnpkg.com/@types/maplibre-gl/-/maplibre-gl-1.13.1.tgz#d043f48e9f08e1b6a9aa7e924d2b86306f843f2b" - integrity sha512-AD7gFneLUYOWKhiwUvfD3LmQy4agJ2k2fe2V6XWCmt6R41d1q1H+BuyHJxFg31OTtDCag4u9ZZpQbjUmUG0gjQ== - dependencies: - "@types/geojson" "*" - "@types/minimatch@*": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"