feat: add advanced Overpass search composer

This commit is contained in:
zv
2026-06-23 19:15:01 +02:00
parent b1e1df7e1d
commit c578d41a1e
7 changed files with 820 additions and 70 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MapView } from '../features/map/MapView';
import { ResultsList } from '../features/results/ResultsList';
import {
@@ -7,9 +7,16 @@ import {
} from '../features/search/buildOverpassQuery';
import { fetchOverpass } from '../features/search/overpassClient';
import { parseOverpassResponse } from '../features/search/parseOverpassResponse';
import { searchPresets } from '../features/search/presets';
import {
getPresetById,
getPresetsByCategory,
searchPresetCategories,
searchPresets,
type PresetFilter,
} from '../features/search/presets';
import type {
BBox,
OsmTagFilter,
ParsedOsmResult,
SearchArea,
SearchChoice,
@@ -23,14 +30,41 @@ const DEFAULT_BBOX: BBox = {
east: 21.079,
};
const namedAreas = ['Warsaw', 'Berlin', 'Paris'];
const namedAreas = [
'Warsaw',
'Berlin',
'Paris',
'London',
'Madrid',
'Rome',
'Vienna',
'Amsterdam',
'Prague',
'Lisbon',
'New York',
'Tokyo',
];
const radiusOptions = [50, 100, 250, 500, 1000];
const customChoice = 'custom';
const noNearbyChoice = 'none';
type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string;
export function App() {
const [category, setCategory] = useState(searchPresetCategories[0]);
const [choice, setChoice] = useState<SearchChoice>('drinking_water');
const [customKey, setCustomKey] = useState('');
const [customValue, setCustomValue] = useState('');
const [selectedFilterIds, setSelectedFilterIds] = useState<string[]>([]);
const [extraFilters, setExtraFilters] = useState<OsmTagFilter[]>([]);
const [extraFilterKey, setExtraFilterKey] = useState('');
const [extraFilterValue, setExtraFilterValue] = useState('');
const [nearbyChoice, setNearbyChoice] = useState<NearbyChoice>(noNearbyChoice);
const [nearbyCustomKey, setNearbyCustomKey] = useState('');
const [nearbyCustomValue, setNearbyCustomValue] = useState('');
const [nearbyRadius, setNearbyRadius] = useState(100);
const [areaMode, setAreaMode] = useState<SearchArea['mode']>('bbox');
const [bbox, setBBox] = useState<BBox>(DEFAULT_BBOX);
const [namedArea, setNamedArea] = useState(namedAreas[0]);
@@ -44,6 +78,28 @@ export function App() {
setBBox(nextBBox);
}, []);
const categoryPresets = useMemo(() => getPresetsByCategory(category), [category]);
const selectedPreset = useMemo(
() => (choice === customChoice ? null : getPresetById(choice)),
[choice],
);
const commonFilters = selectedPreset?.commonFilters ?? [];
useEffect(() => {
setSelectedFilterIds([]);
setExtraFilters([]);
}, [choice]);
const activeFilters = useMemo(() => {
const checkedPresetFilters = commonFilters.filter((filter) =>
selectedFilterIds.includes(filter.id),
);
return [...checkedPresetFilters, ...extraFilters];
}, [commonFilters, extraFilters, selectedFilterIds]);
const searchState = useMemo<SearchState>(() => {
const area: SearchArea =
areaMode === 'namedArea'
@@ -54,9 +110,31 @@ export function App() {
choice,
customKey,
customValue,
filters: activeFilters,
nearby:
nearbyChoice === noNearbyChoice
? null
: {
presetId: nearbyChoice === customChoice ? undefined : nearbyChoice,
customKey: nearbyCustomKey,
customValue: nearbyCustomValue,
radiusMeters: nearbyRadius,
},
area,
};
}, [areaMode, bbox, choice, customKey, customValue, namedArea]);
}, [
activeFilters,
areaMode,
bbox,
choice,
customKey,
customValue,
namedArea,
nearbyChoice,
nearbyCustomKey,
nearbyCustomValue,
nearbyRadius,
]);
const queryPreview = useMemo(() => {
try {
@@ -121,6 +199,39 @@ export function App() {
}
}
function handleCategoryChange(nextCategory: string) {
const nextPreset = getPresetsByCategory(nextCategory)[0];
setCategory(nextCategory);
setChoice(nextPreset.id);
}
function handleFilterToggle(filterId: string) {
setSelectedFilterIds((current) =>
current.includes(filterId)
? current.filter((id) => id !== filterId)
: [...current, filterId],
);
}
function handleAddExtraFilter() {
const key = extraFilterKey.trim();
const value = extraFilterValue.trim();
if (!key) {
setMessage('Add a tag key before adding a custom filter.');
return;
}
setExtraFilters((current) => [...current, value ? { key, value } : { key }]);
setExtraFilterKey('');
setExtraFilterValue('');
setMessage(null);
}
function handleRemoveExtraFilter(index: number) {
setExtraFilters((current) => current.filter((_, itemIndex) => itemIndex !== index));
}
function handleClearResults() {
setResults([]);
setSelectedResultId(null);
@@ -155,44 +266,211 @@ export function App() {
</div>
</header>
<div className="control-group">
<label htmlFor="preset">Data preset</label>
<select
id="preset"
value={choice}
onChange={(event) => setChoice(event.target.value as SearchChoice)}
>
{searchPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
<option value="custom">Custom tag</option>
</select>
</div>
<section className="search-section" aria-labelledby="target-heading">
<div className="section-heading">
<h2 id="target-heading">Find</h2>
<span>{searchPresets.length} presets</span>
</div>
<div className="control-group">
<label htmlFor="category">Category</label>
<select
id="category"
value={category}
onChange={(event) => handleCategoryChange(event.target.value)}
>
{searchPresetCategories.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</div>
<div className="control-group">
<label htmlFor="preset">Object type</label>
<select
id="preset"
value={choice}
onChange={(event) => setChoice(event.target.value as SearchChoice)}
>
{categoryPresets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
<option value={customChoice}>Custom tag</option>
</select>
{selectedPreset ? <p className="field-note">{selectedPreset.description}</p> : null}
</div>
{choice === customChoice ? (
<div className="custom-fields">
<div className="control-group">
<label htmlFor="custom-key">Key</label>
<input
id="custom-key"
value={customKey}
onChange={(event) => setCustomKey(event.target.value)}
placeholder="amenity"
/>
</div>
<div className="control-group">
<label htmlFor="custom-value">Value</label>
<input
id="custom-value"
value={customValue}
onChange={(event) => setCustomValue(event.target.value)}
placeholder="Optional"
/>
</div>
</div>
) : null}
</section>
<section className="search-section" aria-labelledby="refine-heading">
<div className="section-heading">
<h2 id="refine-heading">Refine</h2>
</div>
{commonFilters.length > 0 ? (
<div className="checkbox-grid">
{commonFilters.map((filter) => (
<label key={filter.id}>
<input
type="checkbox"
checked={selectedFilterIds.includes(filter.id)}
onChange={() => handleFilterToggle(filter.id)}
/>
{filter.label}
</label>
))}
</div>
) : (
<p className="field-note">Add a custom tag filter for this search.</p>
)}
{choice === 'custom' ? (
<div className="custom-fields">
<div className="control-group">
<label htmlFor="custom-key">Key</label>
<label htmlFor="extra-filter-key">Extra key</label>
<input
id="custom-key"
value={customKey}
onChange={(event) => setCustomKey(event.target.value)}
placeholder="amenity"
id="extra-filter-key"
value={extraFilterKey}
onChange={(event) => setExtraFilterKey(event.target.value)}
placeholder="operator"
/>
</div>
<div className="control-group">
<label htmlFor="custom-value">Value</label>
<label htmlFor="extra-filter-value">Extra value</label>
<input
id="custom-value"
value={customValue}
onChange={(event) => setCustomValue(event.target.value)}
id="extra-filter-value"
value={extraFilterValue}
onChange={(event) => setExtraFilterValue(event.target.value)}
placeholder="Optional"
/>
</div>
</div>
) : null}
<button type="button" onClick={handleAddExtraFilter}>
Add filter
</button>
{extraFilters.length > 0 ? (
<ul className="active-filter-list" aria-label="Active custom filters">
{extraFilters.map((filter, index) => (
<li key={`${filter.key}-${filter.value ?? 'exists'}-${index}`}>
<span>{formatTag(filter)}</span>
<button type="button" onClick={() => handleRemoveExtraFilter(index)}>
Remove
</button>
</li>
))}
</ul>
) : null}
</section>
<section className="search-section" aria-labelledby="near-heading">
<div className="section-heading">
<h2 id="near-heading">Near</h2>
</div>
{selectedPreset?.usefulNearbyPresetIds?.length ? (
<div className="suggestion-row" aria-label="Suggested nearby presets">
{selectedPreset.usefulNearbyPresetIds.slice(0, 5).map((presetId) => {
const preset = getPresetById(presetId);
return (
<button
key={preset.id}
type="button"
className={nearbyChoice === preset.id ? 'suggestion-active' : ''}
onClick={() => setNearbyChoice(preset.id)}
>
{preset.label}
</button>
);
})}
</div>
) : null}
<div className="control-group">
<label htmlFor="nearby-preset">Nearby object</label>
<select
id="nearby-preset"
value={nearbyChoice}
onChange={(event) => setNearbyChoice(event.target.value)}
>
<option value={noNearbyChoice}>No nearby condition</option>
{searchPresetCategories.map((item) => (
<optgroup key={item} label={item}>
{getPresetsByCategory(item).map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</optgroup>
))}
<option value={customChoice}>Custom nearby tag</option>
</select>
</div>
{nearbyChoice === customChoice ? (
<div className="custom-fields">
<div className="control-group">
<label htmlFor="nearby-custom-key">Nearby key</label>
<input
id="nearby-custom-key"
value={nearbyCustomKey}
onChange={(event) => setNearbyCustomKey(event.target.value)}
placeholder="shop"
/>
</div>
<div className="control-group">
<label htmlFor="nearby-custom-value">Nearby value</label>
<input
id="nearby-custom-value"
value={nearbyCustomValue}
onChange={(event) => setNearbyCustomValue(event.target.value)}
placeholder="Optional"
/>
</div>
</div>
) : null}
<div className="control-group">
<label htmlFor="nearby-radius">Radius</label>
<select
id="nearby-radius"
value={nearbyRadius}
disabled={nearbyChoice === noNearbyChoice}
onChange={(event) => setNearbyRadius(Number(event.target.value))}
>
{radiusOptions.map((radius) => (
<option key={radius} value={radius}>
{radius} meters
</option>
))}
</select>
</div>
</section>
<fieldset className="control-group">
<legend>Search area</legend>
@@ -238,7 +516,12 @@ export function App() {
)}
<div className="button-row">
<button type="button" className="primary-button" disabled={!canSearch} onClick={handleSearch}>
<button
type="button"
className="primary-button"
disabled={!canSearch}
onClick={handleSearch}
>
{status === 'loading' ? 'Searching...' : 'Search'}
</button>
<button type="button" onClick={handleClearResults}>
@@ -303,3 +586,7 @@ export function App() {
</div>
);
}
function formatTag(filter: OsmTagFilter | PresetFilter): string {
return filter.value ? `${filter.key}=${filter.value}` : `${filter.key}=*`;
}

View File

@@ -43,6 +43,38 @@ describe('buildOverpassQuery', () => {
expect(query).toContain('node["amenity"="library"](52.100000,20.900000,52.200000,21.100000);');
});
it('adds refinement filters to the target object', () => {
const query = buildOverpassQuery({
choice: 'restaurants',
filters: [
{ key: 'outdoor_seating', value: 'yes' },
{ key: 'opening_hours' },
],
area: { mode: 'bbox', bbox },
});
expect(query).toContain(
'node["amenity"="restaurant"]["outdoor_seating"="yes"]["opening_hours"](52.100000,20.900000,52.200000,21.100000);',
);
});
it('builds a nearby query for benches near shops', () => {
const query = buildOverpassQuery({
choice: 'benches',
nearby: {
presetId: 'shops',
radiusMeters: 100,
},
area: { mode: 'bbox', bbox },
});
expect(query).toContain('node["shop"](52.100000,20.900000,52.200000,21.100000);');
expect(query).toContain(')->.nearby;');
expect(query).toContain(
'node["amenity"="bench"](52.100000,20.900000,52.200000,21.100000)(around.nearby:100);',
);
});
it('builds a named area query', () => {
const query = buildOverpassQuery({
choice: 'hotels',
@@ -74,4 +106,30 @@ describe('buildOverpassQuery', () => {
expect(query).toContain('area["name"="Paris \\"Centre\\""]->.searchArea;');
expect(query).toContain('nwr["name"="Joe \\"Coffee\\""](area.searchArea);');
});
it('builds a named area query with a custom nearby condition', () => {
const query = buildOverpassQuery({
choice: 'cafes',
nearby: {
customKey: 'amenity',
customValue: 'toilets',
radiusMeters: 250,
},
area: { mode: 'namedArea', name: 'Berlin' },
});
expect(query).toBe(
[
'[out:json][timeout:25];',
'area["name"="Berlin"]->.searchArea;',
'(',
' nwr["amenity"="toilets"](area.searchArea);',
')->.nearby;',
'(',
' nwr["amenity"="cafe"](area.searchArea)(around.nearby:250);',
');',
'out center 100;',
].join('\n'),
);
});
});

View File

@@ -11,8 +11,9 @@ export function isBBoxTooLarge(bbox: BBox): boolean {
}
export function buildOverpassQuery(state: SearchState): string {
const tag = resolveTagFilter(state);
const filter = buildTagFilter(tag);
const targetFilter = buildTagFilters([resolveTagFilter(state), ...(state.filters ?? [])]);
const nearby = state.nearby ? resolveNearbyTagFilter(state.nearby) : null;
const nearbyFilter = nearby ? buildTagFilters([nearby]) : null;
if (state.area.mode === 'namedArea') {
const areaName = state.area.name.trim();
@@ -21,11 +22,25 @@ export function buildOverpassQuery(state: SearchState): string {
throw new Error('Named area is required.');
}
if (nearbyFilter) {
return [
'[out:json][timeout:25];',
`area["name"="${escapeOverpassString(areaName)}"]->.searchArea;`,
'(',
` nwr${nearbyFilter}(area.searchArea);`,
')->.nearby;',
'(',
` nwr${targetFilter}(area.searchArea)(around.nearby:${formatRadius(state.nearby?.radiusMeters)});`,
');',
'out center 100;',
].join('\n');
}
return [
'[out:json][timeout:25];',
`area["name"="${escapeOverpassString(areaName)}"]->.searchArea;`,
'(',
` nwr${filter}(area.searchArea);`,
` nwr${targetFilter}(area.searchArea);`,
');',
'out center 100;',
].join('\n');
@@ -33,12 +48,29 @@ export function buildOverpassQuery(state: SearchState): string {
const bbox = formatBBox(state.area.bbox);
if (nearbyFilter) {
return [
'[out:json][timeout:25];',
'(',
` node${nearbyFilter}(${bbox});`,
` way${nearbyFilter}(${bbox});`,
` relation${nearbyFilter}(${bbox});`,
')->.nearby;',
'(',
` node${targetFilter}(${bbox})(around.nearby:${formatRadius(state.nearby?.radiusMeters)});`,
` way${targetFilter}(${bbox})(around.nearby:${formatRadius(state.nearby?.radiusMeters)});`,
` relation${targetFilter}(${bbox})(around.nearby:${formatRadius(state.nearby?.radiusMeters)});`,
');',
'out center 100;',
].join('\n');
}
return [
'[out:json][timeout:25];',
'(',
` node${filter}(${bbox});`,
` way${filter}(${bbox});`,
` relation${filter}(${bbox});`,
` node${targetFilter}(${bbox});`,
` way${targetFilter}(${bbox});`,
` relation${targetFilter}(${bbox});`,
');',
'out center 100;',
].join('\n');
@@ -59,14 +91,47 @@ function resolveTagFilter(state: SearchState): OsmTagFilter {
return value ? { key, value } : { key };
}
function resolveNearbyTagFilter(nearby: NonNullable<SearchState['nearby']>): OsmTagFilter {
if (nearby.presetId) {
return getPresetById(nearby.presetId).tag;
}
const key = nearby.customKey?.trim();
const value = nearby.customValue?.trim();
if (!key) {
throw new Error('Nearby custom tag key is required.');
}
return value ? { key, value } : { key };
}
function buildTagFilters(tags: OsmTagFilter[]): string {
return tags.map(buildTagFilter).join('');
}
function buildTagFilter(tag: OsmTagFilter): string {
const key = escapeOverpassString(tag.key);
const key = escapeOverpassString(tag.key.trim());
if (!key) {
throw new Error('Tag key is required.');
}
if (!tag.value) {
return `["${key}"]`;
}
return `["${key}"="${escapeOverpassString(tag.value)}"]`;
return `["${key}"="${escapeOverpassString(tag.value.trim())}"]`;
}
function formatRadius(radiusMeters: number | undefined): number {
const radius = Math.round(radiusMeters ?? 100);
if (!Number.isFinite(radius) || radius <= 0) {
throw new Error('Nearby radius must be greater than zero.');
}
return radius;
}
function escapeOverpassString(value: string): string {

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { getPresetById, searchPresetCategories, searchPresets } from './presets';
describe('search presets', () => {
it('provides a broad categorized preset catalog', () => {
expect(searchPresets.length).toBeGreaterThanOrEqual(120);
expect(searchPresetCategories).toEqual(
expect.arrayContaining([
'Food & Drink',
'Shopping',
'Transport',
'Amenities',
'Accessibility',
]),
);
});
it('keeps key MVP preset ids available', () => {
expect(getPresetById('benches').tag).toEqual({ key: 'amenity', value: 'bench' });
expect(getPresetById('shops').tag).toEqual({ key: 'shop' });
expect(getPresetById('drinking_water').tag).toEqual({
key: 'amenity',
value: 'drinking_water',
});
});
});

View File

@@ -1,59 +1,263 @@
import type { OsmTagFilter, SearchPresetId } from './types';
export type PresetFilter = OsmTagFilter & {
id: string;
label: string;
};
export type SearchPreset = {
id: SearchPresetId;
label: string;
category: string;
description: string;
tag: OsmTagFilter;
commonFilters?: PresetFilter[];
usefulNearbyPresetIds?: SearchPresetId[];
};
const filters = {
hasName: { id: 'has-name', label: 'Has a name', key: 'name' },
openingHours: { id: 'opening-hours', label: 'Has opening hours', key: 'opening_hours' },
wheelchair: { id: 'wheelchair-yes', label: 'Wheelchair accessible', key: 'wheelchair', value: 'yes' },
outdoorSeating: { id: 'outdoor-seating', label: 'Outdoor seating', key: 'outdoor_seating', value: 'yes' },
internet: { id: 'wifi', label: 'Wi-Fi available', key: 'internet_access', value: 'wlan' },
takeaway: { id: 'takeaway', label: 'Takeaway', key: 'takeaway', value: 'yes' },
delivery: { id: 'delivery', label: 'Delivery', key: 'delivery', value: 'yes' },
feeNo: { id: 'fee-no', label: 'Free access', key: 'fee', value: 'no' },
backrest: { id: 'backrest', label: 'Backrest', key: 'backrest', value: 'yes' },
covered: { id: 'covered', label: 'Covered', key: 'covered', value: 'yes' },
lit: { id: 'lit', label: 'Lit', key: 'lit', value: 'yes' },
accessPublic: { id: 'access-public', label: 'Public access', key: 'access', value: 'public' },
drinkingWater: { id: 'drinking-water', label: 'Drinking water', key: 'drinking_water', value: 'yes' },
changingTable: { id: 'changing-table', label: 'Changing table', key: 'changing_table', value: 'yes' },
supervised: { id: 'supervised', label: 'Supervised', key: 'supervised', value: 'yes' },
organic: { id: 'organic', label: 'Organic goods', key: 'organic' },
} satisfies Record<string, PresetFilter>;
export const searchPresets: SearchPreset[] = [
{
id: 'drinking_water',
label: 'Drinking water',
category: 'Food & Drink',
description: 'Public drinking water points.',
tag: { key: 'amenity', value: 'drinking_water' },
},
{
id: 'toilets',
label: 'Public toilets',
tag: { key: 'amenity', value: 'toilets' },
},
{
id: 'bicycle_parking',
label: 'Bicycle parking',
tag: { key: 'amenity', value: 'bicycle_parking' },
},
{
id: 'hotels',
label: 'Hotels',
tag: { key: 'tourism', value: 'hotel' },
commonFilters: [filters.feeNo, filters.accessPublic],
usefulNearbyPresetIds: ['benches', 'playgrounds', 'parks', 'bicycle_parking'],
},
{
id: 'restaurants',
label: 'Restaurants',
category: 'Food & Drink',
description: 'Restaurants and dining places.',
tag: { key: 'amenity', value: 'restaurant' },
commonFilters: [filters.openingHours, filters.wheelchair, filters.outdoorSeating, filters.takeaway, filters.delivery],
usefulNearbyPresetIds: ['parking', 'toilets', 'bicycle_parking', 'bus_stops'],
},
{
id: 'cafes',
label: 'Cafes',
category: 'Food & Drink',
description: 'Cafes and coffee places.',
tag: { key: 'amenity', value: 'cafe' },
commonFilters: [filters.openingHours, filters.wheelchair, filters.outdoorSeating, filters.internet, filters.takeaway],
usefulNearbyPresetIds: ['bicycle_parking', 'toilets', 'parks', 'bus_stops'],
},
{ id: 'fast_food', label: 'Fast food', category: 'Food & Drink', description: 'Fast food venues.', tag: { key: 'amenity', value: 'fast_food' }, commonFilters: [filters.openingHours, filters.takeaway, filters.delivery], usefulNearbyPresetIds: ['bus_stops', 'parking'] },
{ id: 'bars', label: 'Bars', category: 'Food & Drink', description: 'Bars and casual drinking venues.', tag: { key: 'amenity', value: 'bar' }, commonFilters: [filters.openingHours, filters.outdoorSeating, filters.wheelchair], usefulNearbyPresetIds: ['taxi_stands', 'bus_stops'] },
{ id: 'pubs', label: 'Pubs', category: 'Food & Drink', description: 'Pubs.', tag: { key: 'amenity', value: 'pub' }, commonFilters: [filters.openingHours, filters.outdoorSeating, filters.wheelchair], usefulNearbyPresetIds: ['taxi_stands', 'bus_stops'] },
{ id: 'biergartens', label: 'Biergartens', category: 'Food & Drink', description: 'Beer gardens.', tag: { key: 'amenity', value: 'biergarten' }, commonFilters: [filters.openingHours, filters.outdoorSeating], usefulNearbyPresetIds: ['parks', 'toilets'] },
{ id: 'food_courts', label: 'Food courts', category: 'Food & Drink', description: 'Food courts.', tag: { key: 'amenity', value: 'food_court' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['shopping_malls', 'toilets'] },
{ id: 'ice_cream', label: 'Ice cream', category: 'Food & Drink', description: 'Ice cream shops and kiosks.', tag: { key: 'amenity', value: 'ice_cream' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parks', 'playgrounds'] },
{ id: 'bakeries', label: 'Bakeries', category: 'Food & Drink', description: 'Bakery shops.', tag: { key: 'shop', value: 'bakery' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['bus_stops', 'bicycle_parking'] },
{ id: 'confectionery', label: 'Confectionery', category: 'Food & Drink', description: 'Sweets and confectionery shops.', tag: { key: 'shop', value: 'confectionery' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['cafes'] },
{ id: 'butchers', label: 'Butchers', category: 'Food & Drink', description: 'Butcher shops.', tag: { key: 'shop', value: 'butcher' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'greengrocers', label: 'Greengrocers', category: 'Food & Drink', description: 'Fruit and vegetable shops.', tag: { key: 'shop', value: 'greengrocer' }, commonFilters: [filters.openingHours, filters.organic], usefulNearbyPresetIds: ['marketplaces'] },
{ id: 'deli', label: 'Delis', category: 'Food & Drink', description: 'Delicatessen shops.', tag: { key: 'shop', value: 'deli' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bicycle_parking'] },
{ id: 'beverages', label: 'Beverage shops', category: 'Food & Drink', description: 'Beverage shops.', tag: { key: 'shop', value: 'beverages' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'wine_shops', label: 'Wine shops', category: 'Food & Drink', description: 'Wine shops.', tag: { key: 'shop', value: 'wine' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{
id: 'shops',
label: 'Shops',
label: 'All shops',
category: 'Shopping',
description: 'Any OSM object tagged as a shop.',
tag: { key: 'shop' },
commonFilters: [filters.hasName, filters.openingHours, filters.wheelchair],
usefulNearbyPresetIds: ['benches', 'parking', 'bicycle_parking', 'bus_stops'],
},
{ id: 'supermarkets', label: 'Supermarkets', category: 'Shopping', description: 'Supermarkets.', tag: { key: 'shop', value: 'supermarket' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking', 'bicycle_parking', 'atms'] },
{ id: 'convenience', label: 'Convenience stores', category: 'Shopping', description: 'Convenience stores.', tag: { key: 'shop', value: 'convenience' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops', 'benches'] },
{ id: 'shopping_malls', label: 'Shopping malls', category: 'Shopping', description: 'Shopping malls.', tag: { key: 'shop', value: 'mall' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking', 'bus_stops', 'toilets'] },
{ id: 'department_stores', label: 'Department stores', category: 'Shopping', description: 'Department stores.', tag: { key: 'shop', value: 'department_store' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'clothes', label: 'Clothes shops', category: 'Shopping', description: 'Clothes shops.', tag: { key: 'shop', value: 'clothes' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'shoes', label: 'Shoe shops', category: 'Shopping', description: 'Shoe shops.', tag: { key: 'shop', value: 'shoes' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'jewelry', label: 'Jewelry shops', category: 'Shopping', description: 'Jewelry shops.', tag: { key: 'shop', value: 'jewelry' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'electronics', label: 'Electronics shops', category: 'Shopping', description: 'Electronics shops.', tag: { key: 'shop', value: 'electronics' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'mobile_phone', label: 'Mobile phone shops', category: 'Shopping', description: 'Mobile phone shops.', tag: { key: 'shop', value: 'mobile_phone' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'computer', label: 'Computer shops', category: 'Shopping', description: 'Computer shops.', tag: { key: 'shop', value: 'computer' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'books', label: 'Bookshops', category: 'Shopping', description: 'Bookshops.', tag: { key: 'shop', value: 'books' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['cafes', 'bus_stops'] },
{ id: 'stationery', label: 'Stationery shops', category: 'Shopping', description: 'Stationery shops.', tag: { key: 'shop', value: 'stationery' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['schools', 'universities'] },
{ id: 'florist', label: 'Florists', category: 'Shopping', description: 'Florists.', tag: { key: 'shop', value: 'florist' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'gift_shops', label: 'Gift shops', category: 'Shopping', description: 'Gift shops.', tag: { key: 'shop', value: 'gift' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['tourist_attractions'] },
{ id: 'hairdressers', label: 'Hairdressers', category: 'Shopping', description: 'Hairdressers.', tag: { key: 'shop', value: 'hairdresser' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'beauty', label: 'Beauty shops', category: 'Shopping', description: 'Beauty and cosmetics services.', tag: { key: 'shop', value: 'beauty' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'laundry', label: 'Laundries', category: 'Shopping', description: 'Laundry services.', tag: { key: 'shop', value: 'laundry' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'dry_cleaning', label: 'Dry cleaners', category: 'Shopping', description: 'Dry cleaning services.', tag: { key: 'shop', value: 'dry_cleaning' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'hardware', label: 'Hardware shops', category: 'Shopping', description: 'Hardware shops.', tag: { key: 'shop', value: 'hardware' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'doityourself', label: 'DIY stores', category: 'Shopping', description: 'Do-it-yourself stores.', tag: { key: 'shop', value: 'doityourself' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'furniture', label: 'Furniture shops', category: 'Shopping', description: 'Furniture shops.', tag: { key: 'shop', value: 'furniture' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'garden_centre', label: 'Garden centres', category: 'Shopping', description: 'Garden centres.', tag: { key: 'shop', value: 'garden_centre' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'pet_shops', label: 'Pet shops', category: 'Shopping', description: 'Pet shops.', tag: { key: 'shop', value: 'pet' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'car_shops', label: 'Car shops', category: 'Shopping', description: 'Car sales shops.', tag: { key: 'shop', value: 'car' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['fuel'] },
{ id: 'car_repair', label: 'Car repair', category: 'Shopping', description: 'Car repair workshops.', tag: { key: 'shop', value: 'car_repair' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['fuel'] },
{ id: 'bicycle_shops', label: 'Bicycle shops', category: 'Shopping', description: 'Bicycle shops and repair.', tag: { key: 'shop', value: 'bicycle' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bicycle_parking'] },
{
id: 'benches',
label: 'Benches',
category: 'Amenities',
description: 'Public benches.',
tag: { key: 'amenity', value: 'bench' },
commonFilters: [filters.backrest, filters.covered, filters.lit],
usefulNearbyPresetIds: ['shops', 'parks', 'playgrounds', 'bus_stops', 'drinking_water'],
},
{
id: 'atms',
label: 'ATMs',
tag: { key: 'amenity', value: 'atm' },
id: 'toilets',
label: 'Public toilets',
category: 'Amenities',
description: 'Public toilets.',
tag: { key: 'amenity', value: 'toilets' },
commonFilters: [filters.wheelchair, filters.changingTable, filters.feeNo],
usefulNearbyPresetIds: ['parks', 'playgrounds', 'restaurants', 'tourist_attractions'],
},
{ id: 'atms', label: 'ATMs', category: 'Amenities', description: 'Cash machines.', tag: { key: 'amenity', value: 'atm' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['shops', 'banks'] },
{ id: 'banks', label: 'Banks', category: 'Amenities', description: 'Bank branches.', tag: { key: 'amenity', value: 'bank' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking', 'bus_stops'] },
{ id: 'post_boxes', label: 'Post boxes', category: 'Amenities', description: 'Mail post boxes.', tag: { key: 'amenity', value: 'post_box' }, usefulNearbyPresetIds: ['post_offices'] },
{ id: 'post_offices', label: 'Post offices', category: 'Amenities', description: 'Post offices.', tag: { key: 'amenity', value: 'post_office' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'libraries', label: 'Libraries', category: 'Amenities', description: 'Libraries.', tag: { key: 'amenity', value: 'library' }, commonFilters: [filters.openingHours, filters.wheelchair, filters.internet], usefulNearbyPresetIds: ['cafes', 'bus_stops', 'bicycle_parking'] },
{ id: 'community_centres', label: 'Community centres', category: 'Amenities', description: 'Community centres.', tag: { key: 'amenity', value: 'community_centre' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'shelters', label: 'Shelters', category: 'Amenities', description: 'Shelters and covered waiting areas.', tag: { key: 'amenity', value: 'shelter' }, commonFilters: [filters.covered, filters.lit], usefulNearbyPresetIds: ['bus_stops', 'parks'] },
{ id: 'waste_baskets', label: 'Waste baskets', category: 'Amenities', description: 'Public waste baskets.', tag: { key: 'amenity', value: 'waste_basket' }, usefulNearbyPresetIds: ['benches', 'parks'] },
{ id: 'recycling', label: 'Recycling points', category: 'Amenities', description: 'Recycling collection points.', tag: { key: 'amenity', value: 'recycling' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'marketplaces', label: 'Marketplaces', category: 'Amenities', description: 'Marketplaces.', tag: { key: 'amenity', value: 'marketplace' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking', 'bus_stops'] },
{ id: 'vending_machines', label: 'Vending machines', category: 'Amenities', description: 'Vending machines.', tag: { key: 'amenity', value: 'vending_machine' }, usefulNearbyPresetIds: ['stations', 'schools'] },
{ id: 'parcel_lockers', label: 'Parcel lockers', category: 'Amenities', description: 'Parcel pickup lockers.', tag: { key: 'amenity', value: 'parcel_locker' }, usefulNearbyPresetIds: ['parking', 'shops'] },
{ id: 'charging_stations_small_devices', label: 'Device charging points', category: 'Amenities', description: 'Public places for charging small devices.', tag: { key: 'amenity', value: 'charging_station' }, usefulNearbyPresetIds: ['cafes', 'libraries'] },
{ id: 'public_bookcases', label: 'Public bookcases', category: 'Amenities', description: 'Public book exchange shelves.', tag: { key: 'amenity', value: 'public_bookcase' }, usefulNearbyPresetIds: ['parks', 'benches'] },
{ id: 'fountains', label: 'Fountains', category: 'Amenities', description: 'Fountains.', tag: { key: 'amenity', value: 'fountain' }, usefulNearbyPresetIds: ['benches', 'parks'] },
{ id: 'clock', label: 'Public clocks', category: 'Amenities', description: 'Public clocks.', tag: { key: 'amenity', value: 'clock' }, usefulNearbyPresetIds: ['squares', 'stations'] },
{ id: 'telephone', label: 'Public telephones', category: 'Amenities', description: 'Public telephones.', tag: { key: 'amenity', value: 'telephone' }, usefulNearbyPresetIds: ['stations'] },
{
id: 'bicycle_parking',
label: 'Bicycle parking',
category: 'Transport',
description: 'Places to park bicycles.',
tag: { key: 'amenity', value: 'bicycle_parking' },
commonFilters: [filters.covered, filters.accessPublic],
usefulNearbyPresetIds: ['cafes', 'shops', 'stations', 'schools'],
},
{ id: 'bus_stops', label: 'Bus stops', category: 'Transport', description: 'Bus stops.', tag: { key: 'highway', value: 'bus_stop' }, commonFilters: [filters.covered, filters.lit], usefulNearbyPresetIds: ['benches', 'shelters', 'shops'] },
{ id: 'tram_stops', label: 'Tram stops', category: 'Transport', description: 'Tram stops.', tag: { key: 'railway', value: 'tram_stop' }, commonFilters: [filters.lit], usefulNearbyPresetIds: ['benches', 'shelters'] },
{ id: 'subway_entrances', label: 'Subway entrances', category: 'Transport', description: 'Subway station entrances.', tag: { key: 'railway', value: 'subway_entrance' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bicycle_parking', 'atms'] },
{ id: 'stations', label: 'Railway stations', category: 'Transport', description: 'Railway stations.', tag: { key: 'railway', value: 'station' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['toilets', 'atms', 'bicycle_parking'] },
{ id: 'halts', label: 'Railway halts', category: 'Transport', description: 'Small railway stops.', tag: { key: 'railway', value: 'halt' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bicycle_parking'] },
{ id: 'taxi_stands', label: 'Taxi stands', category: 'Transport', description: 'Taxi stands.', tag: { key: 'amenity', value: 'taxi' }, usefulNearbyPresetIds: ['stations', 'hotels'] },
{ id: 'parking', label: 'Car parking', category: 'Transport', description: 'Car parking lots.', tag: { key: 'amenity', value: 'parking' }, commonFilters: [filters.feeNo, filters.accessPublic, filters.lit], usefulNearbyPresetIds: ['shops', 'restaurants', 'parks'] },
{ id: 'parking_entrances', label: 'Parking entrances', category: 'Transport', description: 'Entrances to parking areas.', tag: { key: 'amenity', value: 'parking_entrance' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'motorcycle_parking', label: 'Motorcycle parking', category: 'Transport', description: 'Motorcycle parking.', tag: { key: 'amenity', value: 'motorcycle_parking' }, usefulNearbyPresetIds: ['shops'] },
{ id: 'fuel', label: 'Fuel stations', category: 'Transport', description: 'Fuel stations.', tag: { key: 'amenity', value: 'fuel' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['car_wash', 'atms'] },
{ id: 'ev_charging', label: 'EV charging', category: 'Transport', description: 'Electric vehicle charging stations.', tag: { key: 'amenity', value: 'charging_station' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking', 'restaurants'] },
{ id: 'car_wash', label: 'Car wash', category: 'Transport', description: 'Car wash places.', tag: { key: 'amenity', value: 'car_wash' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['fuel'] },
{ id: 'car_rental', label: 'Car rental', category: 'Transport', description: 'Car rental points.', tag: { key: 'amenity', value: 'car_rental' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['stations', 'airports'] },
{ id: 'car_sharing', label: 'Car sharing', category: 'Transport', description: 'Car sharing stations.', tag: { key: 'amenity', value: 'car_sharing' }, usefulNearbyPresetIds: ['stations'] },
{ id: 'bicycle_rental', label: 'Bicycle rental', category: 'Transport', description: 'Bicycle rental stations.', tag: { key: 'amenity', value: 'bicycle_rental' }, usefulNearbyPresetIds: ['parks', 'stations'] },
{ id: 'bicycle_repair_station', label: 'Bicycle repair stations', category: 'Transport', description: 'Public bicycle repair stations.', tag: { key: 'amenity', value: 'bicycle_repair_station' }, usefulNearbyPresetIds: ['bicycle_parking', 'bicycle_shops'] },
{ id: 'ferry_terminals', label: 'Ferry terminals', category: 'Transport', description: 'Ferry terminals.', tag: { key: 'amenity', value: 'ferry_terminal' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'airports', label: 'Airports', category: 'Transport', description: 'Airports.', tag: { key: 'aeroway', value: 'aerodrome' }, commonFilters: [filters.hasName], usefulNearbyPresetIds: ['car_rental', 'parking'] },
{
id: 'hotels',
label: 'Hotels',
category: 'Tourism & Stay',
description: 'Hotels.',
tag: { key: 'tourism', value: 'hotel' },
commonFilters: [filters.wheelchair, filters.internet],
usefulNearbyPresetIds: ['restaurants', 'parking', 'stations', 'atms'],
},
{ id: 'hostels', label: 'Hostels', category: 'Tourism & Stay', description: 'Hostels.', tag: { key: 'tourism', value: 'hostel' }, commonFilters: [filters.internet], usefulNearbyPresetIds: ['restaurants', 'stations'] },
{ id: 'guest_houses', label: 'Guest houses', category: 'Tourism & Stay', description: 'Guest houses.', tag: { key: 'tourism', value: 'guest_house' }, commonFilters: [filters.internet], usefulNearbyPresetIds: ['restaurants', 'parking'] },
{ id: 'apartments', label: 'Tourist apartments', category: 'Tourism & Stay', description: 'Tourist apartments.', tag: { key: 'tourism', value: 'apartment' }, commonFilters: [filters.internet], usefulNearbyPresetIds: ['shops'] },
{ id: 'camp_sites', label: 'Camp sites', category: 'Tourism & Stay', description: 'Camp sites.', tag: { key: 'tourism', value: 'camp_site' }, commonFilters: [filters.drinkingWater, filters.wheelchair], usefulNearbyPresetIds: ['toilets', 'drinking_water'] },
{ id: 'caravan_sites', label: 'Caravan sites', category: 'Tourism & Stay', description: 'Caravan sites.', tag: { key: 'tourism', value: 'caravan_site' }, commonFilters: [filters.drinkingWater], usefulNearbyPresetIds: ['toilets', 'fuel'] },
{ id: 'museums', label: 'Museums', category: 'Tourism & Stay', description: 'Museums.', tag: { key: 'tourism', value: 'museum' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['cafes', 'bus_stops'] },
{ id: 'artwork', label: 'Public artwork', category: 'Tourism & Stay', description: 'Public artworks.', tag: { key: 'tourism', value: 'artwork' }, usefulNearbyPresetIds: ['benches'] },
{ id: 'tourist_attractions', label: 'Tourist attractions', category: 'Tourism & Stay', description: 'Tourist attractions.', tag: { key: 'tourism', value: 'attraction' }, commonFilters: [filters.hasName], usefulNearbyPresetIds: ['toilets', 'cafes', 'bus_stops'] },
{ id: 'viewpoints', label: 'Viewpoints', category: 'Tourism & Stay', description: 'Viewpoints.', tag: { key: 'tourism', value: 'viewpoint' }, usefulNearbyPresetIds: ['benches', 'parking'] },
{ id: 'information', label: 'Tourist information', category: 'Tourism & Stay', description: 'Tourist information boards and offices.', tag: { key: 'tourism', value: 'information' }, usefulNearbyPresetIds: ['tourist_attractions'] },
{ id: 'picnic_sites_tourism', label: 'Picnic sites', category: 'Tourism & Stay', description: 'Picnic sites.', tag: { key: 'tourism', value: 'picnic_site' }, usefulNearbyPresetIds: ['toilets', 'drinking_water'] },
{ id: 'zoos', label: 'Zoos', category: 'Tourism & Stay', description: 'Zoos.', tag: { key: 'tourism', value: 'zoo' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking', 'toilets'] },
{ id: 'theme_parks', label: 'Theme parks', category: 'Tourism & Stay', description: 'Theme parks.', tag: { key: 'tourism', value: 'theme_park' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking', 'toilets'] },
{ id: 'parks', label: 'Parks', category: 'Outdoors', description: 'Public parks.', tag: { key: 'leisure', value: 'park' }, commonFilters: [filters.accessPublic], usefulNearbyPresetIds: ['benches', 'toilets', 'drinking_water'] },
{ id: 'playgrounds', label: 'Playgrounds', category: 'Outdoors', description: 'Playgrounds.', tag: { key: 'leisure', value: 'playground' }, commonFilters: [filters.supervised, filters.accessPublic], usefulNearbyPresetIds: ['benches', 'toilets', 'drinking_water'] },
{ id: 'gardens', label: 'Gardens', category: 'Outdoors', description: 'Gardens.', tag: { key: 'leisure', value: 'garden' }, commonFilters: [filters.accessPublic], usefulNearbyPresetIds: ['benches'] },
{ id: 'nature_reserves', label: 'Nature reserves', category: 'Outdoors', description: 'Nature reserves.', tag: { key: 'leisure', value: 'nature_reserve' }, usefulNearbyPresetIds: ['parking', 'viewpoints'] },
{ id: 'sports_centres', label: 'Sports centres', category: 'Outdoors', description: 'Sports centres.', tag: { key: 'leisure', value: 'sports_centre' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'sports_pitches', label: 'Sports pitches', category: 'Outdoors', description: 'Sports pitches.', tag: { key: 'leisure', value: 'pitch' }, commonFilters: [filters.lit], usefulNearbyPresetIds: ['toilets'] },
{ id: 'stadiums', label: 'Stadiums', category: 'Outdoors', description: 'Stadiums.', tag: { key: 'leisure', value: 'stadium' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parking', 'bus_stops'] },
{ id: 'swimming_pools', label: 'Swimming pools', category: 'Outdoors', description: 'Swimming pools.', tag: { key: 'leisure', value: 'swimming_pool' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'fitness_centres', label: 'Fitness centres', category: 'Outdoors', description: 'Fitness centres and gyms.', tag: { key: 'leisure', value: 'fitness_centre' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'dog_parks', label: 'Dog parks', category: 'Outdoors', description: 'Dog parks.', tag: { key: 'leisure', value: 'dog_park' }, commonFilters: [filters.accessPublic], usefulNearbyPresetIds: ['benches', 'waste_baskets'] },
{ id: 'marinas', label: 'Marinas', category: 'Outdoors', description: 'Marinas.', tag: { key: 'leisure', value: 'marina' }, usefulNearbyPresetIds: ['restaurants', 'parking'] },
{ id: 'slipways', label: 'Slipways', category: 'Outdoors', description: 'Boat slipways.', tag: { key: 'leisure', value: 'slipway' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'beaches', label: 'Beaches', category: 'Outdoors', description: 'Beaches.', tag: { key: 'natural', value: 'beach' }, usefulNearbyPresetIds: ['toilets', 'drinking_water', 'parking'] },
{ id: 'squares', label: 'Squares', category: 'Outdoors', description: 'Named squares and plazas.', tag: { key: 'place', value: 'square' }, usefulNearbyPresetIds: ['benches', 'cafes'] },
{ id: 'pharmacies', label: 'Pharmacies', category: 'Health', description: 'Pharmacies.', tag: { key: 'amenity', value: 'pharmacy' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'hospitals', label: 'Hospitals', category: 'Health', description: 'Hospitals.', tag: { key: 'amenity', value: 'hospital' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parking', 'bus_stops'] },
{ id: 'clinics', label: 'Clinics', category: 'Health', description: 'Clinics.', tag: { key: 'amenity', value: 'clinic' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'doctors', label: 'Doctors', category: 'Health', description: 'Doctors and medical practices.', tag: { key: 'amenity', value: 'doctors' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'dentists', label: 'Dentists', category: 'Health', description: 'Dentists.', tag: { key: 'amenity', value: 'dentist' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'veterinary', label: 'Veterinary clinics', category: 'Health', description: 'Veterinary clinics.', tag: { key: 'amenity', value: 'veterinary' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'defibrillators', label: 'Defibrillators', category: 'Health', description: 'Emergency defibrillators.', tag: { key: 'emergency', value: 'defibrillator' }, usefulNearbyPresetIds: ['schools', 'sports_centres', 'stations'] },
{ id: 'social_facilities', label: 'Social facilities', category: 'Health', description: 'Social support facilities.', tag: { key: 'amenity', value: 'social_facility' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'schools', label: 'Schools', category: 'Education', description: 'Schools.', tag: { key: 'amenity', value: 'school' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bus_stops', 'bicycle_parking'] },
{ id: 'kindergartens', label: 'Kindergartens', category: 'Education', description: 'Kindergartens.', tag: { key: 'amenity', value: 'kindergarten' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['playgrounds'] },
{ id: 'universities', label: 'Universities', category: 'Education', description: 'Universities.', tag: { key: 'amenity', value: 'university' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['libraries', 'cafes', 'bicycle_parking'] },
{ id: 'colleges', label: 'Colleges', category: 'Education', description: 'Colleges.', tag: { key: 'amenity', value: 'college' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'music_schools', label: 'Music schools', category: 'Education', description: 'Music schools.', tag: { key: 'amenity', value: 'music_school' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'language_schools', label: 'Language schools', category: 'Education', description: 'Language schools.', tag: { key: 'amenity', value: 'language_school' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'driving_schools', label: 'Driving schools', category: 'Education', description: 'Driving schools.', tag: { key: 'amenity', value: 'driving_school' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['parking'] },
{ id: 'cinemas', label: 'Cinemas', category: 'Culture & Leisure', description: 'Cinemas.', tag: { key: 'amenity', value: 'cinema' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['restaurants', 'parking'] },
{ id: 'theatres', label: 'Theatres', category: 'Culture & Leisure', description: 'Theatres.', tag: { key: 'amenity', value: 'theatre' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['restaurants', 'parking'] },
{ id: 'arts_centres', label: 'Arts centres', category: 'Culture & Leisure', description: 'Arts centres.', tag: { key: 'amenity', value: 'arts_centre' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['cafes'] },
{ id: 'nightclubs', label: 'Nightclubs', category: 'Culture & Leisure', description: 'Nightclubs.', tag: { key: 'amenity', value: 'nightclub' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['taxi_stands'] },
{ id: 'events_venues', label: 'Events venues', category: 'Culture & Leisure', description: 'Events venues.', tag: { key: 'amenity', value: 'events_venue' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'casinos', label: 'Casinos', category: 'Culture & Leisure', description: 'Casinos.', tag: { key: 'amenity', value: 'casino' }, commonFilters: [filters.openingHours], usefulNearbyPresetIds: ['hotels'] },
{ id: 'police', label: 'Police', category: 'Public Services', description: 'Police stations.', tag: { key: 'amenity', value: 'police' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'fire_stations', label: 'Fire stations', category: 'Public Services', description: 'Fire stations.', tag: { key: 'amenity', value: 'fire_station' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'townhalls', label: 'Town halls', category: 'Public Services', description: 'Town halls.', tag: { key: 'amenity', value: 'townhall' }, commonFilters: [filters.openingHours, filters.wheelchair], usefulNearbyPresetIds: ['bus_stops'] },
{ id: 'courthouses', label: 'Courthouses', category: 'Public Services', description: 'Courthouses.', tag: { key: 'amenity', value: 'courthouse' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'embassies', label: 'Embassies', category: 'Public Services', description: 'Embassies.', tag: { key: 'amenity', value: 'embassy' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'places_of_worship', label: 'Places of worship', category: 'Public Services', description: 'Places of worship.', tag: { key: 'amenity', value: 'place_of_worship' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parking'] },
{ id: 'cemeteries', label: 'Cemeteries', category: 'Public Services', description: 'Cemeteries.', tag: { key: 'amenity', value: 'grave_yard' }, usefulNearbyPresetIds: ['parking'] },
{ id: 'wheelchair_places', label: 'Wheelchair accessible places', category: 'Accessibility', description: 'Objects explicitly tagged wheelchair=yes.', tag: { key: 'wheelchair', value: 'yes' }, usefulNearbyPresetIds: ['bus_stops', 'parking', 'toilets'] },
{ id: 'accessible_toilets', label: 'Accessible toilets', category: 'Accessibility', description: 'Toilets tagged wheelchair=yes.', tag: { key: 'amenity', value: 'toilets' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['parks', 'stations'] },
{ id: 'tactile_paving', label: 'Tactile paving', category: 'Accessibility', description: 'Objects tagged with tactile paving.', tag: { key: 'tactile_paving', value: 'yes' }, usefulNearbyPresetIds: ['bus_stops', 'subway_entrances'] },
{ id: 'elevators', label: 'Elevators', category: 'Accessibility', description: 'Elevators.', tag: { key: 'highway', value: 'elevator' }, commonFilters: [filters.wheelchair], usefulNearbyPresetIds: ['subway_entrances', 'stations'] },
{ id: 'ramps', label: 'Ramps', category: 'Accessibility', description: 'Ramps and ramp-equipped ways.', tag: { key: 'ramp', value: 'yes' }, usefulNearbyPresetIds: ['wheelchair_places'] },
{ id: 'audible_signals', label: 'Audible crossing signals', category: 'Accessibility', description: 'Crossings with audible signals.', tag: { key: 'traffic_signals:sound', value: 'yes' }, usefulNearbyPresetIds: ['bus_stops'] },
];
export const searchPresetCategories = Array.from(
new Set(searchPresets.map((preset) => preset.category)),
);
export function getPresetById(id: SearchPresetId): SearchPreset {
const preset = searchPresets.find((item) => item.id === id);
@@ -63,3 +267,7 @@ export function getPresetById(id: SearchPresetId): SearchPreset {
return preset;
}
export function getPresetsByCategory(category: string): SearchPreset[] {
return searchPresets.filter((preset) => preset.category === category);
}

View File

@@ -1,15 +1,6 @@
export type OsmElementType = 'node' | 'way' | 'relation';
export type SearchPresetId =
| 'drinking_water'
| 'toilets'
| 'bicycle_parking'
| 'hotels'
| 'restaurants'
| 'cafes'
| 'shops'
| 'benches'
| 'atms';
export type SearchPresetId = string;
export type SearchChoice = SearchPresetId | 'custom';
@@ -34,6 +25,8 @@ export type SearchState = {
choice: SearchChoice;
customKey?: string;
customValue?: string;
filters?: OsmTagFilter[];
nearby?: NearbySearch | null;
area: SearchArea;
};
@@ -42,6 +35,13 @@ export type OsmTagFilter = {
value?: string;
};
export type NearbySearch = {
presetId?: SearchPresetId;
customKey?: string;
customValue?: string;
radiusMeters: number;
};
export type OverpassElement = {
type: OsmElementType;
id: number;

View File

@@ -69,7 +69,7 @@ a {
.app-shell {
display: grid;
grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
grid-template-columns: minmax(340px, 440px) minmax(0, 1fr);
min-height: 100vh;
}
@@ -107,12 +107,117 @@ a {
padding: 0;
}
.search-section {
display: grid;
gap: 0.75rem;
border-bottom: 1px solid #e1e6df;
padding-bottom: 1rem;
}
.section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.section-heading h2 {
margin: 0;
font-size: 0.98rem;
line-height: 1.2;
}
.section-heading span,
.field-note {
color: #66726a;
font-size: 0.82rem;
line-height: 1.35;
}
.field-note {
margin: 0;
}
.custom-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
}
.checkbox-grid label {
display: flex;
align-items: center;
gap: 0.45rem;
border: 1px solid #d7ded5;
border-radius: 6px;
background: #ffffff;
min-height: 36px;
padding: 0.42rem 0.55rem;
font-weight: 560;
}
.checkbox-grid input {
width: auto;
min-height: auto;
}
.active-filter-list {
display: grid;
gap: 0.45rem;
margin: 0;
padding: 0;
list-style: none;
}
.active-filter-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border: 1px solid #d7ded5;
border-radius: 6px;
background: #ffffff;
padding: 0.4rem;
}
.active-filter-list span {
min-width: 0;
overflow-wrap: anywhere;
color: #394840;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.8rem;
}
.active-filter-list button {
min-height: 30px;
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
}
.suggestion-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.suggestion-row button {
min-height: 32px;
padding: 0.35rem 0.55rem;
font-size: 0.82rem;
}
.suggestion-active {
border-color: #226a5d;
background: #e8f3ef;
color: #174d44;
}
.segmented-control {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -353,7 +458,8 @@ a {
}
.custom-fields,
.segmented-control {
.segmented-control,
.checkbox-grid {
grid-template-columns: 1fr;
}