Compare commits
15 Commits
9e4ccab080
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e06ffcc497 | |||
| e323aa066d | |||
| 44c4050878 | |||
| f4e081f6ef | |||
| 7fada22a70 | |||
| 659a532821 | |||
| ebf9f02d95 | |||
| 46d4d92dfe | |||
| c53d109c41 | |||
| 0a008a4d39 | |||
| 11621b2394 | |||
| 8299cfa4c3 | |||
| 940e294b3a | |||
| 970df9daeb | |||
| 8d18146d17 |
24
README.md
24
README.md
@@ -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.
|
||||
|
||||
@@ -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
141
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
8
public/favicon.svg
Normal file
8
public/favicon.svg
Normal 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 |
481
src/app/App.tsx
481
src/app/App.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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='© <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();
|
||||
|
||||
|
||||
57
src/features/map/mapStyles.ts
Normal file
57
src/features/map/mapStyles.ts
Normal 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:
|
||||
'© <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:
|
||||
'© <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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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];
|
||||
}
|
||||
144
src/shared/ui/SelectField.tsx
Normal file
144
src/shared/ui/SelectField.tsx
Normal 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)
|
||||
);
|
||||
}
|
||||
1045
src/styles.css
1045
src/styles.css
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user