diff --git a/src/app/App.tsx b/src/app/App.tsx index 021dd17..1a9ca29 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -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('drinking_water'); const [customKey, setCustomKey] = useState(''); const [customValue, setCustomValue] = useState(''); + const [selectedFilterIds, setSelectedFilterIds] = useState([]); + const [extraFilters, setExtraFilters] = useState([]); + const [extraFilterKey, setExtraFilterKey] = useState(''); + const [extraFilterValue, setExtraFilterValue] = useState(''); + const [nearbyChoice, setNearbyChoice] = useState(noNearbyChoice); + const [nearbyCustomKey, setNearbyCustomKey] = useState(''); + const [nearbyCustomValue, setNearbyCustomValue] = useState(''); + const [nearbyRadius, setNearbyRadius] = useState(100); const [areaMode, setAreaMode] = useState('bbox'); const [bbox, setBBox] = useState(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(() => { 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() { -
- - -
+
+
+

Find

+ {searchPresets.length} presets +
+
+ + +
+ +
+ + + {selectedPreset ?

{selectedPreset.description}

: null} +
+ + {choice === customChoice ? ( +
+
+ + setCustomKey(event.target.value)} + placeholder="amenity" + /> +
+
+ + setCustomValue(event.target.value)} + placeholder="Optional" + /> +
+
+ ) : null} +
+ +
+
+

Refine

+
+ + {commonFilters.length > 0 ? ( +
+ {commonFilters.map((filter) => ( + + ))} +
+ ) : ( +

Add a custom tag filter for this search.

+ )} - {choice === 'custom' ? (
- + setCustomKey(event.target.value)} - placeholder="amenity" + id="extra-filter-key" + value={extraFilterKey} + onChange={(event) => setExtraFilterKey(event.target.value)} + placeholder="operator" />
- + setCustomValue(event.target.value)} + id="extra-filter-value" + value={extraFilterValue} + onChange={(event) => setExtraFilterValue(event.target.value)} placeholder="Optional" />
- ) : null} + + + {extraFilters.length > 0 ? ( +
    + {extraFilters.map((filter, index) => ( +
  • + {formatTag(filter)} + +
  • + ))} +
+ ) : null} +
+ +
+
+

Near

+
+ + {selectedPreset?.usefulNearbyPresetIds?.length ? ( +
+ {selectedPreset.usefulNearbyPresetIds.slice(0, 5).map((presetId) => { + const preset = getPresetById(presetId); + + return ( + + ); + })} +
+ ) : null} + +
+ + +
+ + {nearbyChoice === customChoice ? ( +
+
+ + setNearbyCustomKey(event.target.value)} + placeholder="shop" + /> +
+
+ + setNearbyCustomValue(event.target.value)} + placeholder="Optional" + /> +
+
+ ) : null} + +
+ + +
+
Search area @@ -238,7 +516,12 @@ export function App() { )}
-
); } + +function formatTag(filter: OsmTagFilter | PresetFilter): string { + return filter.value ? `${filter.key}=${filter.value}` : `${filter.key}=*`; +} diff --git a/src/features/search/buildOverpassQuery.test.ts b/src/features/search/buildOverpassQuery.test.ts index c6028c3..07128eb 100644 --- a/src/features/search/buildOverpassQuery.test.ts +++ b/src/features/search/buildOverpassQuery.test.ts @@ -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'), + ); + }); }); diff --git a/src/features/search/buildOverpassQuery.ts b/src/features/search/buildOverpassQuery.ts index 40203b1..1e6539c 100644 --- a/src/features/search/buildOverpassQuery.ts +++ b/src/features/search/buildOverpassQuery.ts @@ -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): 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 { diff --git a/src/features/search/presets.test.ts b/src/features/search/presets.test.ts new file mode 100644 index 0000000..8c9140d --- /dev/null +++ b/src/features/search/presets.test.ts @@ -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', + }); + }); +}); diff --git a/src/features/search/presets.ts b/src/features/search/presets.ts index 56467b0..d5e536f 100644 --- a/src/features/search/presets.ts +++ b/src/features/search/presets.ts @@ -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; + 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); +} diff --git a/src/features/search/types.ts b/src/features/search/types.ts index 8e35944..e007bda 100644 --- a/src/features/search/types.ts +++ b/src/features/search/types.ts @@ -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; diff --git a/src/styles.css b/src/styles.css index 53def0c..3d33a3a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; }