Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SearchBox: add custom overpass search #371

Merged
merged 8 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<query>`)
- **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
Expand Down
6 changes: 2 additions & 4 deletions src/components/SearchBox/AutocompleteInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
139 changes: 40 additions & 99 deletions src/components/SearchBox/SearchBox.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 },
Expand All @@ -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]);
};
Expand All @@ -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 (
<TopPanel>
Expand Down
14 changes: 8 additions & 6 deletions src/components/SearchBox/onSelectedFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 });
Expand Down
33 changes: 33 additions & 0 deletions src/components/SearchBox/options/overpass.ts
Original file line number Diff line number Diff line change
@@ -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 [];
};
78 changes: 78 additions & 0 deletions src/components/SearchBox/options/preset.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
7 changes: 1 addition & 6 deletions src/components/SearchBox/renderOptionFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,9 @@ export const renderOptionFactory =
<SearchIcon />
</IconPart>
<Grid item xs>
<span style={{ fontWeight: 700 }}>
{Object.entries(overpass)
.map(([k, v]) => `${k} = ${v}`)
.join(' ')}
</span>
<span style={{ fontWeight: 700 }}>{overpass.label}</span>
<Typography variant="body2" color="textSecondary">
overpass search
{/* {t('searchbox.category')} */}
</Typography>
</Grid>
</>
Expand Down
Loading
Loading