Compare commits

...

9 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
10 changed files with 906 additions and 63 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,
@@ -55,6 +60,7 @@ type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string; type NearbyChoice = typeof noNearbyChoice | typeof customChoice | string;
type RadiusChoice = string; type RadiusChoice = string;
type QueryMode = 'generated' | 'custom'; 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]);
@@ -75,6 +81,13 @@ export function App() {
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 [queryMode, setQueryMode] = useState<QueryMode>('generated');
const [customQuery, setCustomQuery] = useState(''); const [customQuery, setCustomQuery] = useState('');
const [status, setStatus] = useState<RequestStatus>('idle'); const [status, setStatus] = useState<RequestStatus>('idle');
@@ -93,6 +106,7 @@ export function App() {
); );
const commonFilters = selectedPreset?.commonFilters ?? []; const commonFilters = selectedPreset?.commonFilters ?? [];
const mapTileStyle = useMemo(() => getMapTileStyle(mapStyleId), [mapStyleId]);
const nearbyOptionGroups = useMemo<SelectOptionGroup[]>(() => { const nearbyOptionGroups = useMemo<SelectOptionGroup[]>(() => {
const suggestedPresetIds = selectedPreset?.usefulNearbyPresetIds ?? []; const suggestedPresetIds = selectedPreset?.usefulNearbyPresetIds ?? [];
@@ -227,7 +241,7 @@ export function App() {
const response = await fetchOverpass(activeQuery); 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
@@ -332,14 +346,29 @@ export function App() {
} }
} }
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">
@@ -600,6 +629,7 @@ export function App() {
</button> </button>
</div> </div>
{showAdvanced ? (
<section className="query-panel query-panel-separated" aria-labelledby="query-heading"> <section className="query-panel query-panel-separated" aria-labelledby="query-heading">
<div className="panel-heading"> <div className="panel-heading">
<div> <div>
@@ -655,6 +685,7 @@ export function App() {
</pre> </pre>
)} )}
</section> </section>
) : null}
{shouldBlockLargeBBox && isBBoxTooLarge(bbox) ? ( {shouldBlockLargeBBox && isBBoxTooLarge(bbox) ? (
<p className="message error-message"> <p className="message error-message">
@@ -671,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}
/> />
@@ -689,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

@@ -150,6 +150,13 @@ a {
line-height: 1.1; line-height: 1.1;
} }
.brand-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
}
.brand-header p { .brand-header p {
margin: 0.25rem 0 0; margin: 0.25rem 0 0;
color: #66726a; color: #66726a;
@@ -313,6 +320,12 @@ a {
line-height: 1.2; line-height: 1.2;
} }
.section-heading h3 {
margin: 0;
font-size: 0.98rem;
line-height: 1.2;
}
.section-heading span, .section-heading span,
.field-note { .field-note {
color: #66726a; color: #66726a;
@@ -821,6 +834,12 @@ a {
height: 100%; height: 100%;
} }
.map-view-dimmed .leaflet-pane,
.map-view-dimmed .leaflet-control-container {
opacity: 0.55;
transition: opacity 140ms ease;
}
.map-view .leaflet-control-zoom { .map-view .leaflet-control-zoom {
overflow: hidden; overflow: hidden;
border: 1px solid #a7b6ad; border: 1px solid #a7b6ad;
@@ -867,6 +886,20 @@ a {
cursor: not-allowed; cursor: not-allowed;
} }
.map-view .leaflet-tooltip {
border: 1px solid #a7b6ad;
border-radius: 6px;
background: #ffffff;
color: #17211c;
box-shadow: 0 4px 14px rgb(23 33 28 / 14%);
font-size: 0.76rem;
font-weight: 720;
}
.map-view .leaflet-tooltip::before {
border-top-color: #ffffff;
}
.result-marker { .result-marker {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -960,6 +993,398 @@ a {
line-height: 1.45; line-height: 1.45;
} }
.modal-backdrop {
position: fixed;
z-index: 3000;
inset: 0;
display: grid;
place-items: center;
background: rgb(23 33 28 / 38%);
padding: 1rem;
}
.settings-modal {
display: grid;
gap: 1rem;
width: min(560px, 100%);
max-height: min(760px, calc(100vh - 2rem));
overflow: auto;
border: 1px solid #a7b6ad;
border-radius: 10px;
background: #fafbf8;
padding: 1rem;
box-shadow:
0 24px 70px rgb(23 33 28 / 26%),
0 8px 24px rgb(23 33 28 / 16%);
}
.settings-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid #dde5de;
padding-bottom: 0.8rem;
}
.settings-header h2 {
margin: 0;
font-size: 1.25rem;
}
.settings-header p {
margin: 0.25rem 0 0;
color: #66726a;
font-size: 0.88rem;
line-height: 1.4;
}
.settings-section {
display: grid;
gap: 0.75rem;
border-bottom: 1px solid #e1e6df;
padding-bottom: 1rem;
}
.settings-section:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.settings-toggle-grid {
display: grid;
gap: 0.5rem;
}
.settings-toggle-grid label {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 38px;
border: 1px solid #c8d2ca;
border-radius: 7px;
background: linear-gradient(#ffffff, #f7f9f6);
padding: 0.48rem 0.6rem;
font-weight: 620;
cursor: pointer;
}
.settings-toggle-grid label:hover {
border-color: #7f9588;
background: #f3f9f6;
}
.settings-toggle-grid input {
position: relative;
flex: 0 0 1rem;
width: 1rem !important;
min-width: 1rem;
max-width: 1rem;
height: 1rem;
min-height: 1rem;
margin: 0;
border: 1px solid #8ea098;
border-radius: 4px;
appearance: none;
background: #ffffff;
padding: 0;
}
.settings-toggle-grid input::before {
content: "";
position: absolute;
left: 50%;
top: 46%;
width: 0.28rem;
height: 0.52rem;
border-right: 2px solid #ffffff;
border-bottom: 2px solid #ffffff;
transform: translate(-50%, -50%) rotate(45deg) scale(0);
transition: transform 120ms ease;
}
.settings-toggle-grid input:checked {
border-color: #226a5d;
background: #226a5d;
}
.settings-toggle-grid input:checked::before {
transform: translate(-50%, -50%) rotate(45deg) scale(1);
}
.settings-toggle-grid label:has(input:checked) {
border-color: #5f9488;
background: #e5f2ee;
box-shadow: inset 0 0 0 1px rgb(34 106 93 / 16%);
}
.theme-dark {
color: #e5ede8;
background: #101816;
}
.theme-dark .control-panel,
.theme-dark .settings-modal {
border-color: #2c3d38;
background: #121c19;
}
.theme-dark .brand-header h1,
.theme-dark .section-heading h2,
.theme-dark .section-heading h3,
.theme-dark .panel-heading h2,
.theme-dark .settings-header h2,
.theme-dark .result-title,
.theme-dark input,
.theme-dark textarea,
.theme-dark select {
color: #e5ede8;
}
.theme-dark .brand-header p,
.theme-dark .section-heading span,
.theme-dark .field-note,
.theme-dark .viewport-note,
.theme-dark .panel-heading span,
.theme-dark .result-meta,
.theme-dark .tag-list,
.theme-dark .settings-header p {
color: #9aaca5;
}
.theme-dark label,
.theme-dark legend {
color: #c4d2cc;
}
.theme-dark button,
.theme-dark .select-trigger,
.theme-dark .checkbox-grid label,
.theme-dark .segmented-control label,
.theme-dark .query-mode-control label,
.theme-dark .settings-toggle-grid label,
.theme-dark .compact-button {
border-color: #354943;
background: linear-gradient(#1d2a26, #17231f);
color: #e5ede8;
box-shadow:
0 1px 2px rgb(0 0 0 / 22%),
inset 0 1px 0 rgb(255 255 255 / 4%);
}
.theme-dark button:hover:not(:disabled),
.theme-dark .select-trigger:hover:not(:disabled),
.theme-dark .checkbox-grid label:hover,
.theme-dark .segmented-control label:hover,
.theme-dark .settings-toggle-grid label:hover {
border-color: #55756b;
background: linear-gradient(#24342f, #1b2925);
box-shadow: 0 2px 10px rgb(0 0 0 / 24%);
}
.theme-dark .primary-button {
border-color: #58a898;
background: linear-gradient(180deg, #2f8e7c 0%, #226f62 100%);
color: #ffffff;
}
.theme-dark input,
.theme-dark textarea,
.theme-dark select {
border-color: #354943;
background: #101816;
box-shadow:
inset 0 1px 2px rgb(0 0 0 / 28%),
0 1px 0 rgb(255 255 255 / 3%);
}
.theme-dark input:hover:not(:disabled),
.theme-dark textarea:hover:not(:disabled),
.theme-dark select:hover:not(:disabled) {
border-color: #55756b;
background: #14201c;
}
.theme-dark input:disabled,
.theme-dark textarea:disabled,
.theme-dark select:disabled {
background: #1a2522;
color: #7f918b;
}
.theme-dark .select-trigger[aria-expanded="true"] {
border-color: #58a898;
background: #101816;
box-shadow:
0 0 0 3px rgb(88 168 152 / 18%),
0 10px 28px rgb(0 0 0 / 28%);
}
.theme-dark .select-chevron {
border-color: #b7c7c1;
}
.theme-dark .select-menu {
border-color: #3b524b;
background: #121c19;
box-shadow:
0 18px 42px rgb(0 0 0 / 42%),
0 2px 8px rgb(0 0 0 / 28%);
}
.theme-dark .select-option {
color: #e5ede8;
}
.theme-dark .select-option:hover,
.theme-dark .select-option:focus-visible {
background: #1c302a;
}
.theme-dark .select-option-selected {
background: #24473f;
color: #dff5ef;
}
.theme-dark .select-group + .select-group,
.theme-dark .select-option + .select-group,
.theme-dark .search-section,
.theme-dark .settings-header,
.theme-dark .settings-section,
.theme-dark .results-panel {
border-color: #2c3d38;
}
.theme-dark .select-group-label {
color: #8ea29a;
}
.theme-dark .checkbox-grid input,
.theme-dark .segmented-control input,
.theme-dark .query-mode-control input,
.theme-dark .settings-toggle-grid input {
border-color: #55756b;
background: #0e1513;
}
.theme-dark .checkbox-grid input:checked,
.theme-dark .segmented-control input:checked,
.theme-dark .query-mode-control input:checked,
.theme-dark .settings-toggle-grid input:checked {
border-color: #58a898;
background: #2f8e7c;
}
.theme-dark .checkbox-grid label:has(input:checked),
.theme-dark .segmented-control label:has(input:checked),
.theme-dark .query-mode-control label:has(input:checked),
.theme-dark .settings-toggle-grid label:has(input:checked) {
border-color: #58a898;
background: #1f3b35;
box-shadow: inset 0 0 0 1px rgb(88 168 152 / 20%);
}
.theme-dark .stepper-buttons {
border-color: #354943;
background: #1a2522;
}
.theme-dark .stepper-button:hover:not(:disabled) {
background: #233630;
}
.theme-dark .stepper-up::before {
border-bottom-color: #b7c7c1;
}
.theme-dark .stepper-down::before {
border-top-color: #b7c7c1;
}
.theme-dark .query-panel-separated {
border-color: #2c3d38;
background:
linear-gradient(180deg, rgb(28 48 42 / 70%) 0%, rgb(18 28 25 / 0%) 100%);
}
.theme-dark .query-panel-separated::before {
background: #121c19;
color: #9aaca5;
}
.theme-dark .query-actions,
.theme-dark .active-filter-list li,
.theme-dark .results-list li {
border-color: #2c3d38;
background: #17231f;
}
.theme-dark .query-preview,
.theme-dark .custom-query-input {
border-color: #2c3d38;
background: #09110f;
color: #dff5ef;
}
.theme-dark .result-row:hover:not(:disabled),
.theme-dark .result-row-selected {
background: #1f3b35;
}
.theme-dark .results-panel {
background: #111a18;
}
.theme-dark .empty-state {
border-color: #3b524b;
color: #9aaca5;
}
.theme-dark .message {
border-left-color: #58a898;
background: #17342e;
color: #d8eee8;
}
.theme-dark .error-message {
border-left-color: #d37a68;
background: #3a211e;
color: #f2c7bd;
}
.theme-dark .modal-backdrop {
background: rgb(0 0 0 / 58%);
}
.theme-dark .map-view .leaflet-control-zoom {
border-color: #2c3d38;
box-shadow:
0 10px 24px rgb(0 0 0 / 34%),
0 2px 6px rgb(0 0 0 / 24%);
}
.theme-dark .map-view .leaflet-control-zoom a {
border-color: #2c3d38;
background: linear-gradient(#1d2a26, #17231f);
color: #e5ede8;
}
.theme-dark .map-view .leaflet-control-zoom a:hover,
.theme-dark .map-view .leaflet-control-zoom a:focus-visible {
background: #1f3b35;
color: #dff5ef;
}
.theme-dark .map-view .leaflet-tooltip {
border-color: #2c3d38;
background: #121c19;
color: #e5ede8;
}
.theme-dark .map-view .leaflet-tooltip::before {
border-top-color: #121c19;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.app-shell { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1001,4 +1426,9 @@ a {
.query-actions button { .query-actions button {
width: 100%; width: 100%;
} }
.settings-header {
align-items: stretch;
flex-direction: column;
}
} }