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 npm test
``` ```
Run static checks:
```bash
npm run lint
```
Build for production: Build for production:
```bash ```bash
npm run build 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 ```bash
npm run preview 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 ## 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. 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" name="description"
content="MapSift lets you explore OpenStreetMap data without writing queries." 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> <title>MapSift</title>
</head> </head>
<body> <body>

141
package-lock.json generated
View File

@@ -11,7 +11,8 @@
"leaflet": "latest", "leaflet": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"react-leaflet": "latest" "react-leaflet": "latest",
"sirv-cli": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "latest", "@types/leaflet": "latest",
@@ -93,6 +94,12 @@
"url": "https://github.com/sponsors/Boshen" "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": { "node_modules/@react-leaflet/core": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
@@ -625,6 +632,15 @@
"node": ">=18" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "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": "^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": { "node_modules/leaflet": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
@@ -988,6 +1025,15 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -998,6 +1044,24 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1156,12 +1220,33 @@
"@rolldown/binding-win32-x64-msvc": "1.1.2" "@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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "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": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1169,6 +1254,42 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1200,6 +1321,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/tinyexec": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
@@ -1237,6 +1367,15 @@
"node": ">=14.0.0" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",

View File

@@ -5,21 +5,24 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"lint": "tsc -b --pretty false",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"start": "sirv dist --host 0.0.0.0 --port 4173 --single",
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"leaflet": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"leaflet": "latest", "react-leaflet": "latest",
"react-leaflet": "latest" "sirv-cli": "^3.0.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest",
"@types/leaflet": "latest", "@types/leaflet": "latest",
"@types/react": "latest", "@types/react": "latest",
"@types/react-dom": "latest", "@types/react-dom": "latest",
"@vitejs/plugin-react": "latest",
"typescript": "latest", "typescript": "latest",
"vite": "latest", "vite": "latest",
"vitest": "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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { MapView } from '../features/map/MapView'; import { MapView } from '../features/map/MapView';
import {
getMapTileStyle,
mapTileStyles,
type MapStyleId,
} from '../features/map/mapStyles';
import { ResultsList } from '../features/results/ResultsList'; import { ResultsList } from '../features/results/ResultsList';
import { import {
buildOverpassQuery, buildOverpassQuery,
@@ -22,6 +27,7 @@ import type {
SearchChoice, SearchChoice,
SearchState, SearchState,
} from '../features/search/types'; } from '../features/search/types';
import { SelectField, type SelectOptionGroup } from '../shared/ui/SelectField';
const DEFAULT_BBOX: BBox = { const DEFAULT_BBOX: BBox = {
south: 52.191, south: 52.191,
@@ -48,9 +54,13 @@ const namedAreas = [
const radiusOptions = [50, 100, 250, 500, 1000]; const radiusOptions = [50, 100, 250, 500, 1000];
const customChoice = 'custom'; const customChoice = 'custom';
const noNearbyChoice = 'none'; const noNearbyChoice = 'none';
const customRadiusChoice = 'custom-radius';
type RequestStatus = 'idle' | 'loading' | 'success' | 'error'; type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string; type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string;
type RadiusChoice = string;
type QueryMode = 'generated' | 'custom';
type AppTheme = 'light' | 'dark';
export function App() { export function App() {
const [category, setCategory] = useState(searchPresetCategories[0]); const [category, setCategory] = useState(searchPresetCategories[0]);
@@ -64,12 +74,22 @@ export function App() {
const [nearbyChoice, setNearbyChoice] = useState<NearbyChoice>(noNearbyChoice); const [nearbyChoice, setNearbyChoice] = useState<NearbyChoice>(noNearbyChoice);
const [nearbyCustomKey, setNearbyCustomKey] = useState(''); const [nearbyCustomKey, setNearbyCustomKey] = useState('');
const [nearbyCustomValue, setNearbyCustomValue] = useState(''); const [nearbyCustomValue, setNearbyCustomValue] = useState('');
const [radiusChoice, setRadiusChoice] = useState<RadiusChoice>('100');
const [nearbyRadius, setNearbyRadius] = useState(100); const [nearbyRadius, setNearbyRadius] = useState(100);
const [areaMode, setAreaMode] = useState<SearchArea['mode']>('bbox'); const [areaMode, setAreaMode] = useState<SearchArea['mode']>('bbox');
const [bbox, setBBox] = useState<BBox>(DEFAULT_BBOX); const [bbox, setBBox] = useState<BBox>(DEFAULT_BBOX);
const [namedArea, setNamedArea] = useState(namedAreas[0]); const [namedArea, setNamedArea] = useState(namedAreas[0]);
const [results, setResults] = useState<ParsedOsmResult[]>([]); const [results, setResults] = useState<ParsedOsmResult[]>([]);
const [selectedResultId, setSelectedResultId] = useState<string | null>(null); 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 [status, setStatus] = useState<RequestStatus>('idle');
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const activeQueryRef = useRef<string | null>(null); const activeQueryRef = useRef<string | null>(null);
@@ -86,6 +106,37 @@ export function App() {
); );
const commonFilters = selectedPreset?.commonFilters ?? []; 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(() => { useEffect(() => {
setSelectedFilterIds([]); setSelectedFilterIds([]);
@@ -136,7 +187,7 @@ export function App() {
nearbyRadius, nearbyRadius,
]); ]);
const queryPreview = useMemo(() => { const generatedQuery = useMemo(() => {
try { try {
return { return {
query: buildOverpassQuery(searchState), query: buildOverpassQuery(searchState),
@@ -150,37 +201,47 @@ export function App() {
} }
}, [searchState]); }, [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 = const canSearch =
status !== 'loading' && status !== 'loading' &&
Boolean(queryPreview.query) && Boolean(activeQuery) &&
!(areaMode === 'bbox' && isBBoxTooLarge(bbox)); !(shouldBlockLargeBBox && isBBoxTooLarge(bbox));
async function handleSearch() { async function handleSearch() {
if (!queryPreview.query) { if (!activeQuery) {
setStatus('error'); setStatus('error');
setMessage(queryPreview.error); setMessage(activeQueryError);
return; return;
} }
if (areaMode === 'bbox' && isBBoxTooLarge(bbox)) { if (shouldBlockLargeBBox && isBBoxTooLarge(bbox)) {
setStatus('error'); setStatus('error');
setMessage('Zoom in before searching. The current viewport is too large.'); setMessage('Zoom in before searching. The current viewport is too large.');
return; return;
} }
if (status === 'loading' && activeQueryRef.current === queryPreview.query) { if (status === 'loading' && activeQueryRef.current === activeQuery) {
return; return;
} }
setStatus('loading'); setStatus('loading');
setMessage(null); setMessage(null);
activeQueryRef.current = queryPreview.query; activeQueryRef.current = activeQuery;
try { try {
const response = await fetchOverpass(queryPreview.query); const response = await fetchOverpass(activeQuery);
const parsedResults = parseOverpassResponse(response); const parsedResults = parseOverpassResponse(response);
setResults(parsedResults); setResults(parsedResults);
setSelectedResultId(parsedResults[0]?.id ?? null); setSelectedResultId(fitMapToResults ? null : parsedResults[0]?.id ?? null);
setStatus('success'); setStatus('success');
setMessage( setMessage(
parsedResults.length === 0 parsedResults.length === 0
@@ -232,6 +293,35 @@ export function App() {
setExtraFilters((current) => current.filter((_, itemIndex) => itemIndex !== index)); 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() { function handleClearResults() {
setResults([]); setResults([]);
setSelectedResultId(null); setSelectedResultId(null);
@@ -240,30 +330,45 @@ export function App() {
} }
async function handleCopyQuery() { async function handleCopyQuery() {
if (queryPreview.query) { if (activeQuery) {
await navigator.clipboard.writeText(queryPreview.query); await navigator.clipboard.writeText(activeQuery);
setMessage('Query copied to clipboard.'); setMessage('Query copied to clipboard.');
} }
} }
function handleOpenOverpassTurbo() { function handleOpenOverpassTurbo() {
if (queryPreview.query) { if (activeQuery) {
window.open( window.open(
`https://overpass-turbo.eu/?Q=${encodeURIComponent(queryPreview.query)}`, `https://overpass-turbo.eu/?Q=${encodeURIComponent(activeQuery)}`,
'_blank', '_blank',
'noopener,noreferrer', 'noopener,noreferrer',
); );
} }
} }
function handleShowAdvancedChange(isVisible: boolean) {
setShowAdvanced(isVisible);
if (!isVisible) {
setQueryMode('generated');
}
}
return ( return (
<div className="app-shell"> <div className={appTheme === 'dark' ? 'app-shell theme-dark' : 'app-shell'}>
<aside className="control-panel" aria-label="Search controls"> <aside className="control-panel" aria-label="Search controls">
<header className="brand-header"> <header className="brand-header">
<div> <div>
<h1>MapSift</h1> <h1>MapSift</h1>
<p>Explore OpenStreetMap data without writing queries.</p> <p>Explore OpenStreetMap data without writing queries.</p>
</div> </div>
<button
type="button"
className="compact-button"
onClick={() => setIsSettingsOpen(true)}
>
Settings
</button>
</header> </header>
<section className="search-section" aria-labelledby="target-heading"> <section className="search-section" aria-labelledby="target-heading">
@@ -271,35 +376,28 @@ export function App() {
<h2 id="target-heading">Find</h2> <h2 id="target-heading">Find</h2>
<span>{searchPresets.length} presets</span> <span>{searchPresets.length} presets</span>
</div> </div>
<div className="control-group"> <SelectField
<label htmlFor="category">Category</label>
<select
id="category" id="category"
label="Category"
value={category} value={category}
onChange={(event) => handleCategoryChange(event.target.value)} options={searchPresetCategories.map((item) => ({ value: item, label: item }))}
> onChange={handleCategoryChange}
{searchPresetCategories.map((item) => ( />
<option key={item} value={item}>
{item}
</option>
))}
</select>
</div>
<div className="control-group"> <div className="control-group">
<label htmlFor="preset">Object type</label> <SelectField
<select
id="preset" id="preset"
label="Object type"
value={choice} value={choice}
onChange={(event) => setChoice(event.target.value as SearchChoice)} options={[
> ...categoryPresets.map((preset) => ({
{categoryPresets.map((preset) => ( value: preset.id,
<option key={preset.id} value={preset.id}> label: preset.label,
{preset.label} })),
</option> { value: customChoice, label: 'Custom tag' },
))} ]}
<option value={customChoice}>Custom tag</option> onChange={(value) => setChoice(value as SearchChoice)}
</select> />
{selectedPreset ? <p className="field-note">{selectedPreset.description}</p> : null} {selectedPreset ? <p className="field-note">{selectedPreset.description}</p> : null}
</div> </div>
@@ -327,9 +425,9 @@ export function App() {
) : null} ) : null}
</section> </section>
<section className="search-section" aria-labelledby="refine-heading"> <section className="search-section" aria-labelledby="object-filters-heading">
<div className="section-heading"> <div className="section-heading">
<h2 id="refine-heading">Refine</h2> <h2 id="object-filters-heading">Object filters</h2>
</div> </div>
{commonFilters.length > 0 ? ( {commonFilters.length > 0 ? (
@@ -390,48 +488,25 @@ export function App() {
<section className="search-section" aria-labelledby="near-heading"> <section className="search-section" aria-labelledby="near-heading">
<div className="section-heading"> <div className="section-heading">
<h2 id="near-heading">Near</h2> <h2 id="near-heading">Near</h2>
</div> {nearbyChoice !== noNearbyChoice ? (
<button type="button" className="compact-button" onClick={handleClearNearby}>
{selectedPreset?.usefulNearbyPresetIds?.length ? ( Clear nearby
<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> </button>
);
})}
</div>
) : null} ) : 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> </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 ? ( {nearbyChoice === customChoice ? (
<div className="custom-fields"> <div className="custom-fields">
<div className="control-group"> <div className="control-group">
@@ -456,19 +531,51 @@ export function App() {
) : null} ) : null}
<div className="control-group"> <div className="control-group">
<label htmlFor="nearby-radius">Radius</label> <div className="radius-controls">
<select <SelectField
id="nearby-radius" 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} value={nearbyRadius}
disabled={nearbyChoice === noNearbyChoice} disabled={nearbyChoice === noNearbyChoice}
onChange={(event) => setNearbyRadius(Number(event.target.value))} onChange={(event) => handleCustomRadiusChange(event.target.value)}
> />
{radiusOptions.map((radius) => ( <div className="stepper-buttons">
<option key={radius} value={radius}> <button
{radius} meters type="button"
</option> className="stepper-button stepper-up"
))} aria-label="Increase radius"
</select> 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> </div>
</section> </section>
@@ -495,20 +602,13 @@ export function App() {
</fieldset> </fieldset>
{areaMode === 'namedArea' ? ( {areaMode === 'namedArea' ? (
<div className="control-group"> <SelectField
<label htmlFor="named-area">Named area</label>
<select
id="named-area" id="named-area"
label="Named area"
value={namedArea} value={namedArea}
onChange={(event) => setNamedArea(event.target.value)} options={namedAreas.map((area) => ({ value: area, label: area }))}
> onChange={setNamedArea}
{namedAreas.map((area) => ( />
<option key={area} value={area}>
{area}
</option>
))}
</select>
</div>
) : ( ) : (
<p className="viewport-note"> <p className="viewport-note">
Using the visible map viewport. Zoom in if the area is too large. Using the visible map viewport. Zoom in if the area is too large.
@@ -529,28 +629,65 @@ export function App() {
</button> </button>
</div> </div>
<section className="query-panel" aria-labelledby="query-heading"> {showAdvanced ? (
<section className="query-panel query-panel-separated" aria-labelledby="query-heading">
<div className="panel-heading"> <div className="panel-heading">
<div>
<h2 id="query-heading">Query Preview</h2> <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"> <div className="query-actions">
<button type="button" disabled={!queryPreview.query} onClick={handleCopyQuery}> <button type="button" disabled={!activeQuery} onClick={handleCopyQuery}>
Copy query Copy query
</button> </button>
<button <button
type="button" type="button"
disabled={!queryPreview.query} disabled={!activeQuery}
onClick={handleOpenOverpassTurbo} onClick={handleOpenOverpassTurbo}
> >
Open in Overpass Turbo Open in Overpass Turbo
</button> </button>
</div> </div>
<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> </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"> <pre className="query-preview">
{queryPreview.query || queryPreview.error || 'Choose a preset to generate a query.'} {generatedQuery.query ||
generatedQuery.error ||
'Choose a preset to generate a query.'}
</pre> </pre>
)}
</section> </section>
) : null}
{areaMode === 'bbox' && isBBoxTooLarge(bbox) ? ( {shouldBlockLargeBBox && isBBoxTooLarge(bbox) ? (
<p className="message error-message"> <p className="message error-message">
The current viewport is too large for a responsible Overpass request. The current viewport is too large for a responsible Overpass request.
</p> </p>
@@ -565,6 +702,10 @@ export function App() {
<MapView <MapView
results={results} results={results}
selectedResultId={selectedResultId} selectedResultId={selectedResultId}
tileStyle={mapTileStyle}
showResultLabels={showResultLabels}
fitMapToResults={fitMapToResults}
dimMap={dimMapWhileLoading && status === 'loading'}
onResultSelect={setSelectedResultId} onResultSelect={setSelectedResultId}
onViewportChange={handleViewportChange} onViewportChange={handleViewportChange}
/> />
@@ -583,6 +724,108 @@ export function App() {
/> />
</section> </section>
</main> </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> </div>
); );
} }

View File

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