Compare commits

..

15 Commits

Author SHA1 Message Date
zv
e06ffcc497 feat: add site favicon 2026-06-24 15:43:51 +02:00
zv
e323aa066d fix: use static production server 2026-06-24 15:43:41 +02:00
zv
44c4050878 chore: add lint script 2026-06-24 15:19:35 +02:00
zv
f4e081f6ef fix: add start script for preview 2026-06-24 15:12:33 +02:00
zv
7fada22a70 feat: add dark theme setting 2026-06-23 20:49:12 +02:00
zv
659a532821 fix: enable map result fitting by default 2026-06-23 20:45:42 +02:00
zv
ebf9f02d95 fix: use working CARTO tile endpoints 2026-06-23 20:41:44 +02:00
zv
46d4d92dfe fix: clarify advanced settings label 2026-06-23 20:38:21 +02:00
zv
c53d109c41 feat: add settings modal for map display 2026-06-23 20:35:45 +02:00
zv
0a008a4d39 feat: add custom advanced query mode 2026-06-23 20:25:54 +02:00
zv
11621b2394 style: separate query preview actions 2026-06-23 20:21:47 +02:00
zv
8299cfa4c3 style: polish form controls 2026-06-23 20:21:05 +02:00
zv
940e294b3a feat: allow custom nearby radius 2026-06-23 20:02:43 +02:00
zv
970df9daeb fix: clarify object filter label 2026-06-23 20:01:06 +02:00
zv
8d18146d17 fix: improve nearby search controls 2026-06-23 19:28:19 +02:00
11 changed files with 1792 additions and 170 deletions

View File

@@ -79,18 +79,40 @@ Run tests:
npm test
```
Run static checks:
```bash
npm run lint
```
Build for production:
```bash
npm run build
```
Preview the production build:
Run the production static server:
```bash
npm run start
```
This serves the generated `dist/` directory on `0.0.0.0:4173` with SPA fallback enabled.
Preview the production build with Vite:
```bash
npm run preview
```
You can override the production server host or port when needed:
```bash
HOST=127.0.0.1 PORT=8080 npm run start
```
For deployment, serve the generated `dist/` directory with any static web server.
## Notes
MapSift uses the public Overpass API. Large viewport searches are blocked in the UI to avoid irresponsible requests. If a query returns too much data or the API is busy, zoom in, reduce filters, or try again later.

View File

@@ -7,6 +7,8 @@
name="description"
content="MapSift lets you explore OpenStreetMap data without writing queries."
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" />
<title>MapSift</title>
</head>
<body>

141
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"leaflet": "latest",
"react": "latest",
"react-dom": "latest",
"react-leaflet": "latest"
"react-leaflet": "latest",
"sirv-cli": "^3.0.1"
},
"devDependencies": {
"@types/leaflet": "latest",
@@ -93,6 +94,12 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT"
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -625,6 +632,15 @@
"node": ">=18"
}
},
"node_modules/console-clear": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz",
"integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -709,6 +725,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -988,6 +1025,15 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/local-access": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz",
"integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -998,6 +1044,24 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1156,12 +1220,33 @@
"@rolldown/binding-win32-x64-msvc": "1.1.2"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"license": "MIT",
"dependencies": {
"mri": "^1.1.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/semiver": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz",
"integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1169,6 +1254,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/sirv-cli": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz",
"integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==",
"license": "MIT",
"dependencies": {
"console-clear": "^1.1.0",
"get-port": "^5.1.1",
"kleur": "^4.1.4",
"local-access": "^1.0.1",
"sade": "^1.6.0",
"semiver": "^1.0.0",
"sirv": "^3.0.0",
"tinydate": "^1.0.0"
},
"bin": {
"sirv": "bin.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1200,6 +1321,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/tinydate": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz",
"integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
@@ -1237,6 +1367,15 @@
"node": ">=14.0.0"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -5,21 +5,24 @@
"type": "module",
"scripts": {
"dev": "vite",
"lint": "tsc -b --pretty false",
"build": "tsc -b && vite build",
"preview": "vite preview",
"start": "sirv dist --host 0.0.0.0 --port 4173 --single",
"test": "vitest run"
},
"dependencies": {
"leaflet": "latest",
"react": "latest",
"react-dom": "latest",
"leaflet": "latest",
"react-leaflet": "latest"
"react-leaflet": "latest",
"sirv-cli": "^3.0.1"
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
"@types/leaflet": "latest",
"@types/react": "latest",
"@types/react-dom": "latest",
"@vitejs/plugin-react": "latest",
"typescript": "latest",
"vite": "latest",
"vitest": "latest"

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

8
public/favicon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MapSift">
<rect width="64" height="64" rx="14" fill="#17211c"/>
<path d="M17 15h30v34H17z" fill="#eef6f3"/>
<path d="M17 25h30M17 36h30M27 15v34M38 15v34" stroke="#9db0a6" stroke-width="2"/>
<circle cx="32" cy="31" r="10" fill="#226a5d"/>
<path d="M32 19c5 0 10 4 10 9 0 8-10 18-10 18S22 36 22 28c0-5 5-9 10-9z" fill="#2f8e7c"/>
<circle cx="32" cy="28" r="4" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@@ -1,5 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MapView } from '../features/map/MapView';
import {
getMapTileStyle,
mapTileStyles,
type MapStyleId,
} from '../features/map/mapStyles';
import { ResultsList } from '../features/results/ResultsList';
import {
buildOverpassQuery,
@@ -22,6 +27,7 @@ import type {
SearchChoice,
SearchState,
} from '../features/search/types';
import { SelectField, type SelectOptionGroup } from '../shared/ui/SelectField';
const DEFAULT_BBOX: BBox = {
south: 52.191,
@@ -48,9 +54,13 @@ const namedAreas = [
const radiusOptions = [50, 100, 250, 500, 1000];
const customChoice = 'custom';
const noNearbyChoice = 'none';
const customRadiusChoice = 'custom-radius';
type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string;
type RadiusChoice = string;
type QueryMode = 'generated' | 'custom';
type AppTheme = 'light' | 'dark';
export function App() {
const [category, setCategory] = useState(searchPresetCategories[0]);
@@ -64,12 +74,22 @@ export function App() {
const [nearbyChoice, setNearbyChoice] = useState<NearbyChoice>(noNearbyChoice);
const [nearbyCustomKey, setNearbyCustomKey] = useState('');
const [nearbyCustomValue, setNearbyCustomValue] = useState('');
const [radiusChoice, setRadiusChoice] = useState<RadiusChoice>('100');
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]);
const [results, setResults] = useState<ParsedOsmResult[]>([]);
const [selectedResultId, setSelectedResultId] = useState<string | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [appTheme, setAppTheme] = useState<AppTheme>('light');
const [mapStyleId, setMapStyleId] = useState<MapStyleId>('osm-standard');
const [showAdvanced, setShowAdvanced] = useState(true);
const [showResultLabels, setShowResultLabels] = useState(false);
const [fitMapToResults, setFitMapToResults] = useState(true);
const [dimMapWhileLoading, setDimMapWhileLoading] = useState(true);
const [queryMode, setQueryMode] = useState<QueryMode>('generated');
const [customQuery, setCustomQuery] = useState('');
const [status, setStatus] = useState<RequestStatus>('idle');
const [message, setMessage] = useState<string | null>(null);
const activeQueryRef = useRef<string | null>(null);
@@ -86,6 +106,37 @@ export function App() {
);
const commonFilters = selectedPreset?.commonFilters ?? [];
const mapTileStyle = useMemo(() => getMapTileStyle(mapStyleId), [mapStyleId]);
const nearbyOptionGroups = useMemo<SelectOptionGroup[]>(() => {
const suggestedPresetIds = selectedPreset?.usefulNearbyPresetIds ?? [];
const suggestedOptions = suggestedPresetIds.map((presetId) => {
const preset = getPresetById(presetId);
return {
value: preset.id,
label: preset.label,
};
});
return [
...(suggestedOptions.length > 0
? [
{
label: 'Suggested for this search',
options: suggestedOptions,
},
]
: []),
...searchPresetCategories.map((item) => ({
label: item,
options: getPresetsByCategory(item).map((preset) => ({
value: preset.id,
label: preset.label,
})),
})),
];
}, [selectedPreset]);
useEffect(() => {
setSelectedFilterIds([]);
@@ -136,7 +187,7 @@ export function App() {
nearbyRadius,
]);
const queryPreview = useMemo(() => {
const generatedQuery = useMemo(() => {
try {
return {
query: buildOverpassQuery(searchState),
@@ -150,37 +201,47 @@ export function App() {
}
}, [searchState]);
const activeQuery =
queryMode === 'custom' ? customQuery.trim() : generatedQuery.query;
const activeQueryError =
queryMode === 'custom'
? customQuery.trim()
? null
: 'Enter a custom Overpass QL query.'
: generatedQuery.error;
const shouldBlockLargeBBox = queryMode === 'generated' && areaMode === 'bbox';
const canSearch =
status !== 'loading' &&
Boolean(queryPreview.query) &&
!(areaMode === 'bbox' && isBBoxTooLarge(bbox));
Boolean(activeQuery) &&
!(shouldBlockLargeBBox && isBBoxTooLarge(bbox));
async function handleSearch() {
if (!queryPreview.query) {
if (!activeQuery) {
setStatus('error');
setMessage(queryPreview.error);
setMessage(activeQueryError);
return;
}
if (areaMode === 'bbox' && isBBoxTooLarge(bbox)) {
if (shouldBlockLargeBBox && isBBoxTooLarge(bbox)) {
setStatus('error');
setMessage('Zoom in before searching. The current viewport is too large.');
return;
}
if (status === 'loading' && activeQueryRef.current === queryPreview.query) {
if (status === 'loading' && activeQueryRef.current === activeQuery) {
return;
}
setStatus('loading');
setMessage(null);
activeQueryRef.current = queryPreview.query;
activeQueryRef.current = activeQuery;
try {
const response = await fetchOverpass(queryPreview.query);
const response = await fetchOverpass(activeQuery);
const parsedResults = parseOverpassResponse(response);
setResults(parsedResults);
setSelectedResultId(parsedResults[0]?.id ?? null);
setSelectedResultId(fitMapToResults ? null : parsedResults[0]?.id ?? null);
setStatus('success');
setMessage(
parsedResults.length === 0
@@ -232,6 +293,35 @@ export function App() {
setExtraFilters((current) => current.filter((_, itemIndex) => itemIndex !== index));
}
function handleClearNearby() {
setNearbyChoice(noNearbyChoice);
setNearbyCustomKey('');
setNearbyCustomValue('');
}
function handleRadiusChoiceChange(value: string) {
if (value === customRadiusChoice) {
setRadiusChoice(customRadiusChoice);
return;
}
const radius = Number(value);
setRadiusChoice(value);
setNearbyRadius(radius);
}
function handleCustomRadiusChange(value: string) {
const radius = Number(value);
if (Number.isFinite(radius)) {
setNearbyRadius(radius);
}
}
function handleRadiusStep(direction: 1 | -1) {
setNearbyRadius((current) => Math.max(1, current + direction));
}
function handleClearResults() {
setResults([]);
setSelectedResultId(null);
@@ -240,30 +330,45 @@ export function App() {
}
async function handleCopyQuery() {
if (queryPreview.query) {
await navigator.clipboard.writeText(queryPreview.query);
if (activeQuery) {
await navigator.clipboard.writeText(activeQuery);
setMessage('Query copied to clipboard.');
}
}
function handleOpenOverpassTurbo() {
if (queryPreview.query) {
if (activeQuery) {
window.open(
`https://overpass-turbo.eu/?Q=${encodeURIComponent(queryPreview.query)}`,
`https://overpass-turbo.eu/?Q=${encodeURIComponent(activeQuery)}`,
'_blank',
'noopener,noreferrer',
);
}
}
function handleShowAdvancedChange(isVisible: boolean) {
setShowAdvanced(isVisible);
if (!isVisible) {
setQueryMode('generated');
}
}
return (
<div className="app-shell">
<div className={appTheme === 'dark' ? 'app-shell theme-dark' : 'app-shell'}>
<aside className="control-panel" aria-label="Search controls">
<header className="brand-header">
<div>
<h1>MapSift</h1>
<p>Explore OpenStreetMap data without writing queries.</p>
</div>
<button
type="button"
className="compact-button"
onClick={() => setIsSettingsOpen(true)}
>
Settings
</button>
</header>
<section className="search-section" aria-labelledby="target-heading">
@@ -271,35 +376,28 @@ export function App() {
<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>
<SelectField
id="category"
label="Category"
value={category}
options={searchPresetCategories.map((item) => ({ value: item, label: item }))}
onChange={handleCategoryChange}
/>
<div className="control-group">
<label htmlFor="preset">Object type</label>
<select
<SelectField
id="preset"
label="Object type"
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>
options={[
...categoryPresets.map((preset) => ({
value: preset.id,
label: preset.label,
})),
{ value: customChoice, label: 'Custom tag' },
]}
onChange={(value) => setChoice(value as SearchChoice)}
/>
{selectedPreset ? <p className="field-note">{selectedPreset.description}</p> : null}
</div>
@@ -327,9 +425,9 @@ export function App() {
) : null}
</section>
<section className="search-section" aria-labelledby="refine-heading">
<section className="search-section" aria-labelledby="object-filters-heading">
<div className="section-heading">
<h2 id="refine-heading">Refine</h2>
<h2 id="object-filters-heading">Object filters</h2>
</div>
{commonFilters.length > 0 ? (
@@ -390,47 +488,24 @@ export function App() {
<section className="search-section" aria-labelledby="near-heading">
<div className="section-heading">
<h2 id="near-heading">Near</h2>
{nearbyChoice !== noNearbyChoice ? (
<button type="button" className="compact-button" onClick={handleClearNearby}>
Clear nearby
</button>
) : null}
</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>
<SelectField
id="nearby-preset"
label="Nearby object"
value={nearbyChoice}
options={[
{ value: noNearbyChoice, label: 'No nearby condition' },
{ value: customChoice, label: 'Custom nearby tag' },
]}
groups={nearbyOptionGroups}
onChange={setNearbyChoice}
/>
{nearbyChoice === customChoice ? (
<div className="custom-fields">
@@ -456,19 +531,51 @@ export function App() {
) : 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 className="radius-controls">
<SelectField
id="nearby-radius"
label="Radius"
value={radiusChoice}
disabled={nearbyChoice === noNearbyChoice}
options={[
...radiusOptions.map((radius) => ({
value: String(radius),
label: `${radius} meters`,
})),
{ value: customRadiusChoice, label: 'Custom' },
]}
onChange={handleRadiusChoiceChange}
/>
{radiusChoice === customRadiusChoice ? (
<div className="number-stepper">
<input
aria-label="Custom radius in meters"
type="number"
min="1"
step="1"
value={nearbyRadius}
disabled={nearbyChoice === noNearbyChoice}
onChange={(event) => handleCustomRadiusChange(event.target.value)}
/>
<div className="stepper-buttons">
<button
type="button"
className="stepper-button stepper-up"
aria-label="Increase radius"
disabled={nearbyChoice === noNearbyChoice}
onClick={() => handleRadiusStep(1)}
/>
<button
type="button"
className="stepper-button stepper-down"
aria-label="Decrease radius"
disabled={nearbyChoice === noNearbyChoice}
onClick={() => handleRadiusStep(-1)}
/>
</div>
</div>
) : null}
</div>
</div>
</section>
@@ -495,20 +602,13 @@ export function App() {
</fieldset>
{areaMode === 'namedArea' ? (
<div className="control-group">
<label htmlFor="named-area">Named area</label>
<select
id="named-area"
value={namedArea}
onChange={(event) => setNamedArea(event.target.value)}
>
{namedAreas.map((area) => (
<option key={area} value={area}>
{area}
</option>
))}
</select>
</div>
<SelectField
id="named-area"
label="Named area"
value={namedArea}
options={namedAreas.map((area) => ({ value: area, label: area }))}
onChange={setNamedArea}
/>
) : (
<p className="viewport-note">
Using the visible map viewport. Zoom in if the area is too large.
@@ -529,28 +629,65 @@ export function App() {
</button>
</div>
<section className="query-panel" aria-labelledby="query-heading">
<div className="panel-heading">
<h2 id="query-heading">Query Preview</h2>
{showAdvanced ? (
<section className="query-panel query-panel-separated" aria-labelledby="query-heading">
<div className="panel-heading">
<div>
<h2 id="query-heading">Query Preview</h2>
<p className="field-note">Advanced output generated from the current search.</p>
</div>
</div>
<div className="query-actions">
<button type="button" disabled={!queryPreview.query} onClick={handleCopyQuery}>
<button type="button" disabled={!activeQuery} onClick={handleCopyQuery}>
Copy query
</button>
<button
type="button"
disabled={!queryPreview.query}
disabled={!activeQuery}
onClick={handleOpenOverpassTurbo}
>
Open in Overpass Turbo
</button>
</div>
</div>
<pre className="query-preview">
{queryPreview.query || queryPreview.error || 'Choose a preset to generate a query.'}
</pre>
</section>
<div className="query-mode-control" aria-label="Query mode">
<label>
<input
type="radio"
checked={queryMode === 'generated'}
onChange={() => setQueryMode('generated')}
/>
Generated
</label>
<label>
<input
type="radio"
checked={queryMode === 'custom'}
onChange={() => setQueryMode('custom')}
/>
Custom query
</label>
</div>
{queryMode === 'custom' ? (
<textarea
className="custom-query-input"
aria-label="Custom Overpass QL query"
value={customQuery}
onChange={(event) => setCustomQuery(event.target.value)}
placeholder={
'[out:json][timeout:25];\nnode["amenity"="bench"](52.191000,20.941000,52.268000,21.079000);\nout center 100;'
}
/>
) : (
<pre className="query-preview">
{generatedQuery.query ||
generatedQuery.error ||
'Choose a preset to generate a query.'}
</pre>
)}
</section>
) : null}
{areaMode === 'bbox' && isBBoxTooLarge(bbox) ? (
{shouldBlockLargeBBox && isBBoxTooLarge(bbox) ? (
<p className="message error-message">
The current viewport is too large for a responsible Overpass request.
</p>
@@ -565,6 +702,10 @@ export function App() {
<MapView
results={results}
selectedResultId={selectedResultId}
tileStyle={mapTileStyle}
showResultLabels={showResultLabels}
fitMapToResults={fitMapToResults}
dimMap={dimMapWhileLoading && status === 'loading'}
onResultSelect={setSelectedResultId}
onViewportChange={handleViewportChange}
/>
@@ -583,6 +724,108 @@ export function App() {
/>
</section>
</main>
{isSettingsOpen ? (
<div className="modal-backdrop" role="presentation">
<section
className="settings-modal"
role="dialog"
aria-modal="true"
aria-labelledby="settings-heading"
>
<header className="settings-header">
<div>
<h2 id="settings-heading">Settings</h2>
<p>Adjust map display and advanced tools.</p>
</div>
<button
type="button"
className="compact-button"
onClick={() => setIsSettingsOpen(false)}
>
Close
</button>
</header>
<section className="settings-section" aria-labelledby="appearance-settings-heading">
<div className="section-heading">
<h3 id="appearance-settings-heading">Appearance</h3>
</div>
<SelectField
id="app-theme"
label="Theme"
value={appTheme}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
onChange={(value) => setAppTheme(value as AppTheme)}
/>
</section>
<section className="settings-section" aria-labelledby="map-settings-heading">
<div className="section-heading">
<h3 id="map-settings-heading">Map settings</h3>
</div>
<SelectField
id="map-style"
label="Tile provider"
value={mapStyleId}
options={mapTileStyles.map((style) => ({
value: style.id,
label: style.label,
}))}
onChange={(value) => setMapStyleId(value as MapStyleId)}
/>
<p className="field-note">{mapTileStyle.description}</p>
<div className="settings-toggle-grid">
<label>
<input
type="checkbox"
checked={showResultLabels}
onChange={(event) => setShowResultLabels(event.target.checked)}
/>
Show result labels
</label>
<label>
<input
type="checkbox"
checked={fitMapToResults}
onChange={(event) => setFitMapToResults(event.target.checked)}
/>
Fit map to results
</label>
<label>
<input
type="checkbox"
checked={dimMapWhileLoading}
onChange={(event) => setDimMapWhileLoading(event.target.checked)}
/>
Dim map while loading
</label>
</div>
</section>
<section className="settings-section" aria-labelledby="advanced-settings-heading">
<div className="section-heading">
<h3 id="advanced-settings-heading">Advanced</h3>
</div>
<div className="settings-toggle-grid">
<label>
<input
type="checkbox"
checked={showAdvanced}
onChange={(event) => handleShowAdvancedChange(event.target.checked)}
/>
Show Advanced section
</label>
</div>
<p className="field-note">
Hiding Advanced also switches searches back to generated queries.
</p>
</section>
</section>
</div>
) : null}
</div>
);
}

View File

@@ -5,14 +5,20 @@ import {
Marker,
Popup,
TileLayer,
Tooltip,
useMap,
useMapEvents,
} from 'react-leaflet';
import type { MapTileStyle } from './mapStyles';
import type { BBox, ParsedOsmResult } from '../search/types';
type MapViewProps = {
results: ParsedOsmResult[];
selectedResultId: string | null;
tileStyle: MapTileStyle;
showResultLabels: boolean;
fitMapToResults: boolean;
dimMap: boolean;
onResultSelect: (id: string) => void;
onViewportChange: (bbox: BBox) => void;
};
@@ -23,6 +29,10 @@ const DEFAULT_ZOOM = 13;
export function MapView({
results,
selectedResultId,
tileStyle,
showResultLabels,
fitMapToResults,
dimMap,
onResultSelect,
onViewportChange,
}: MapViewProps) {
@@ -53,14 +63,17 @@ export function MapView({
center={DEFAULT_CENTER}
zoom={DEFAULT_ZOOM}
minZoom={3}
className="map-view"
className={dimMap ? 'map-view map-view-dimmed' : 'map-view'}
zoomControl
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
key={tileStyle.id}
attribution={tileStyle.attribution}
url={tileStyle.url}
maxZoom={tileStyle.maxZoom}
/>
<ViewportReporter onViewportChange={onViewportChange} />
<FitResults results={results} enabled={fitMapToResults} />
<SelectedResultFocus result={results.find((item) => item.id === selectedResultId) ?? null} />
{results.map((result) => (
<Marker
@@ -76,6 +89,11 @@ export function MapView({
<br />
{result.type} {result.osmId}
</Popup>
{showResultLabels ? (
<Tooltip permanent direction="top" offset={[0, -10]} opacity={0.94}>
{result.name ?? `${result.type} ${result.osmId}`}
</Tooltip>
) : null}
</Marker>
))}
</MapContainer>
@@ -99,6 +117,33 @@ function ViewportReporter({
return null;
}
function FitResults({
results,
enabled,
}: {
results: ParsedOsmResult[];
enabled: boolean;
}) {
const map = useMap();
useEffect(() => {
if (!enabled || results.length === 0) {
return;
}
const bounds = L.latLngBounds(results.map((result) => [result.lat, result.lon]));
if (bounds.isValid()) {
map.fitBounds(bounds.pad(0.2), {
animate: true,
maxZoom: 16,
});
}
}, [enabled, map, results]);
return null;
}
function SelectedResultFocus({ result }: { result: ParsedOsmResult | null }) {
const map = useMap();

View File

@@ -0,0 +1,57 @@
export type MapStyleId =
| 'osm-standard'
| 'osm-humanitarian'
| 'carto-light'
| 'carto-dark';
export type MapTileStyle = {
id: MapStyleId;
label: string;
description: string;
url: string;
attribution: string;
maxZoom?: number;
};
export const mapTileStyles: MapTileStyle[] = [
{
id: 'osm-standard',
label: 'OpenStreetMap Standard',
description: 'Default OSM raster tiles with broad detail.',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
},
{
id: 'osm-humanitarian',
label: 'OpenStreetMap Humanitarian',
description: 'Higher-contrast OSM style focused on humanitarian mapping.',
url: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by HOT',
maxZoom: 20,
},
{
id: 'carto-light',
label: 'CARTO Positron',
description: 'Light basemap that keeps point results prominent.',
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20,
},
{
id: 'carto-dark',
label: 'CARTO Dark Matter',
description: 'Dark basemap for high-contrast result exploration.',
url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
maxZoom: 20,
},
];
export function getMapTileStyle(id: MapStyleId): MapTileStyle {
return mapTileStyles.find((style) => style.id === id) ?? mapTileStyles[0];
}

View File

@@ -0,0 +1,144 @@
import { useEffect, useId, useRef, useState } from 'react';
export type SelectOption = {
value: string;
label: string;
};
export type SelectOptionGroup = {
label: string;
options: SelectOption[];
};
type SelectFieldProps = {
id?: string;
label: string;
value: string;
options: SelectOption[];
groups?: SelectOptionGroup[];
disabled?: boolean;
onChange: (value: string) => void;
};
export function SelectField({
id,
label,
value,
options,
groups = [],
disabled = false,
onChange,
}: SelectFieldProps) {
const generatedId = useId();
const fieldId = id ?? generatedId;
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const selectedOption = findOption(value, options, groups);
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
function handleSelect(nextValue: string) {
onChange(nextValue);
setIsOpen(false);
}
return (
<div className="select-field" ref={rootRef}>
<label id={`${fieldId}-label`} htmlFor={fieldId}>
{label}
</label>
<button
id={fieldId}
type="button"
className="select-trigger"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby={`${fieldId}-label ${fieldId}`}
disabled={disabled}
onClick={() => setIsOpen((current) => !current)}
>
<span>{selectedOption?.label ?? 'Choose an option'}</span>
<span className="select-chevron" aria-hidden="true" />
</button>
{isOpen ? (
<div className="select-menu" role="listbox" aria-labelledby={`${fieldId}-label`}>
{options.map((option) => (
<SelectItem
key={option.value}
option={option}
isSelected={option.value === value}
onSelect={handleSelect}
/>
))}
{groups.map((group) => (
<div className="select-group" key={group.label}>
<div className="select-group-label">{group.label}</div>
{group.options.map((option) => (
<SelectItem
key={`${group.label}-${option.value}`}
option={option}
isSelected={option.value === value}
onSelect={handleSelect}
/>
))}
</div>
))}
</div>
) : null}
</div>
);
}
function SelectItem({
option,
isSelected,
onSelect,
}: {
option: SelectOption;
isSelected: boolean;
onSelect: (value: string) => void;
}) {
return (
<button
type="button"
className={isSelected ? 'select-option select-option-selected' : 'select-option'}
role="option"
aria-selected={isSelected}
onClick={() => onSelect(option.value)}
>
<span>{option.label}</span>
{isSelected ? <span className="select-check" aria-hidden="true" /> : null}
</button>
);
}
function findOption(
value: string,
options: SelectOption[],
groups: SelectOptionGroup[],
): SelectOption | undefined {
return (
options.find((option) => option.value === value) ??
groups.flatMap((group) => group.options).find((option) => option.value === value)
);
}

File diff suppressed because it is too large Load Diff