feat: build MapSift Overpass data explorer
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
18
AGENTS.md
Normal file
18
AGENTS.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# MapSift Agent Guidelines
|
||||||
|
|
||||||
|
- The application is named MapSift.
|
||||||
|
- The application UI must be in English.
|
||||||
|
- Responses to the repository owner may be in Polish.
|
||||||
|
- Preferred stack: Vite + React + TypeScript + Leaflet + Vitest.
|
||||||
|
- Do not add a backend in the MVP.
|
||||||
|
- Do not add a database in the MVP.
|
||||||
|
- Do not add authentication in the MVP.
|
||||||
|
- Keep Overpass QL logic outside React components.
|
||||||
|
- Query generation must live in a pure `buildOverpassQuery(state)` function.
|
||||||
|
- Overpass API communication must live in a separate module.
|
||||||
|
- Overpass API response parsing must live in a separate module.
|
||||||
|
- UI components must not contain chaotic query string assembly.
|
||||||
|
- Add or update tests for query logic and parser changes.
|
||||||
|
- Do not use emoji in code, README, or UI.
|
||||||
|
- Use Conventional Commits when committing changes.
|
||||||
|
- Before committing, run tests and build when available.
|
||||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="MapSift lets you explore OpenStreetMap data without writing queries."
|
||||||
|
/>
|
||||||
|
<title>MapSift</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1448
package-lock.json
generated
Normal file
1448
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "mapsift",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "latest",
|
||||||
|
"react-dom": "latest",
|
||||||
|
"leaflet": "latest",
|
||||||
|
"react-leaflet": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "latest",
|
||||||
|
"@types/leaflet": "latest",
|
||||||
|
"@types/react": "latest",
|
||||||
|
"@types/react-dom": "latest",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vite": "latest",
|
||||||
|
"vitest": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
305
src/app/App.tsx
Normal file
305
src/app/App.tsx
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { MapView } from '../features/map/MapView';
|
||||||
|
import { ResultsList } from '../features/results/ResultsList';
|
||||||
|
import {
|
||||||
|
buildOverpassQuery,
|
||||||
|
isBBoxTooLarge,
|
||||||
|
} from '../features/search/buildOverpassQuery';
|
||||||
|
import { fetchOverpass } from '../features/search/overpassClient';
|
||||||
|
import { parseOverpassResponse } from '../features/search/parseOverpassResponse';
|
||||||
|
import { searchPresets } from '../features/search/presets';
|
||||||
|
import type {
|
||||||
|
BBox,
|
||||||
|
ParsedOsmResult,
|
||||||
|
SearchArea,
|
||||||
|
SearchChoice,
|
||||||
|
SearchState,
|
||||||
|
} from '../features/search/types';
|
||||||
|
|
||||||
|
const DEFAULT_BBOX: BBox = {
|
||||||
|
south: 52.191,
|
||||||
|
west: 20.941,
|
||||||
|
north: 52.268,
|
||||||
|
east: 21.079,
|
||||||
|
};
|
||||||
|
|
||||||
|
const namedAreas = ['Warsaw', 'Berlin', 'Paris'];
|
||||||
|
|
||||||
|
type RequestStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [choice, setChoice] = useState<SearchChoice>('drinking_water');
|
||||||
|
const [customKey, setCustomKey] = useState('');
|
||||||
|
const [customValue, setCustomValue] = useState('');
|
||||||
|
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 [status, setStatus] = useState<RequestStatus>('idle');
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const activeQueryRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const handleViewportChange = useCallback((nextBBox: BBox) => {
|
||||||
|
setBBox(nextBBox);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const searchState = useMemo<SearchState>(() => {
|
||||||
|
const area: SearchArea =
|
||||||
|
areaMode === 'namedArea'
|
||||||
|
? { mode: 'namedArea', name: namedArea }
|
||||||
|
: { mode: 'bbox', bbox };
|
||||||
|
|
||||||
|
return {
|
||||||
|
choice,
|
||||||
|
customKey,
|
||||||
|
customValue,
|
||||||
|
area,
|
||||||
|
};
|
||||||
|
}, [areaMode, bbox, choice, customKey, customValue, namedArea]);
|
||||||
|
|
||||||
|
const queryPreview = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
query: buildOverpassQuery(searchState),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
query: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Unable to build query.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [searchState]);
|
||||||
|
|
||||||
|
const canSearch =
|
||||||
|
status !== 'loading' &&
|
||||||
|
Boolean(queryPreview.query) &&
|
||||||
|
!(areaMode === 'bbox' && isBBoxTooLarge(bbox));
|
||||||
|
|
||||||
|
async function handleSearch() {
|
||||||
|
if (!queryPreview.query) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(queryPreview.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areaMode === 'bbox' && isBBoxTooLarge(bbox)) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('Zoom in before searching. The current viewport is too large.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading' && activeQueryRef.current === queryPreview.query) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus('loading');
|
||||||
|
setMessage(null);
|
||||||
|
activeQueryRef.current = queryPreview.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchOverpass(queryPreview.query);
|
||||||
|
const parsedResults = parseOverpassResponse(response);
|
||||||
|
setResults(parsedResults);
|
||||||
|
setSelectedResultId(parsedResults[0]?.id ?? null);
|
||||||
|
setStatus('success');
|
||||||
|
setMessage(
|
||||||
|
parsedResults.length === 0
|
||||||
|
? 'No matching OSM objects found.'
|
||||||
|
: `Found ${parsedResults.length} OSM object${parsedResults.length === 1 ? '' : 's'}.`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The Overpass request failed. Try again in a moment.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
activeQueryRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClearResults() {
|
||||||
|
setResults([]);
|
||||||
|
setSelectedResultId(null);
|
||||||
|
setStatus('idle');
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopyQuery() {
|
||||||
|
if (queryPreview.query) {
|
||||||
|
await navigator.clipboard.writeText(queryPreview.query);
|
||||||
|
setMessage('Query copied to clipboard.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenOverpassTurbo() {
|
||||||
|
if (queryPreview.query) {
|
||||||
|
window.open(
|
||||||
|
`https://overpass-turbo.eu/?Q=${encodeURIComponent(queryPreview.query)}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="control-group">
|
||||||
|
<label htmlFor="preset">Data preset</label>
|
||||||
|
<select
|
||||||
|
id="preset"
|
||||||
|
value={choice}
|
||||||
|
onChange={(event) => setChoice(event.target.value as SearchChoice)}
|
||||||
|
>
|
||||||
|
{searchPresets.map((preset) => (
|
||||||
|
<option key={preset.id} value={preset.id}>
|
||||||
|
{preset.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Custom tag</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{choice === 'custom' ? (
|
||||||
|
<div className="custom-fields">
|
||||||
|
<div className="control-group">
|
||||||
|
<label htmlFor="custom-key">Key</label>
|
||||||
|
<input
|
||||||
|
id="custom-key"
|
||||||
|
value={customKey}
|
||||||
|
onChange={(event) => setCustomKey(event.target.value)}
|
||||||
|
placeholder="amenity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="control-group">
|
||||||
|
<label htmlFor="custom-value">Value</label>
|
||||||
|
<input
|
||||||
|
id="custom-value"
|
||||||
|
value={customValue}
|
||||||
|
onChange={(event) => setCustomValue(event.target.value)}
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<fieldset className="control-group">
|
||||||
|
<legend>Search area</legend>
|
||||||
|
<div className="segmented-control">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={areaMode === 'bbox'}
|
||||||
|
onChange={() => setAreaMode('bbox')}
|
||||||
|
/>
|
||||||
|
Current map
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={areaMode === 'namedArea'}
|
||||||
|
onChange={() => setAreaMode('namedArea')}
|
||||||
|
/>
|
||||||
|
Named area
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
) : (
|
||||||
|
<p className="viewport-note">
|
||||||
|
Using the visible map viewport. Zoom in if the area is too large.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="button-row">
|
||||||
|
<button type="button" className="primary-button" disabled={!canSearch} onClick={handleSearch}>
|
||||||
|
{status === 'loading' ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleClearResults}>
|
||||||
|
Clear results
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="query-panel" aria-labelledby="query-heading">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<h2 id="query-heading">Query Preview</h2>
|
||||||
|
<div className="query-actions">
|
||||||
|
<button type="button" disabled={!queryPreview.query} onClick={handleCopyQuery}>
|
||||||
|
Copy query
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!queryPreview.query}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{areaMode === 'bbox' && isBBoxTooLarge(bbox) ? (
|
||||||
|
<p className="message error-message">
|
||||||
|
The current viewport is too large for a responsible Overpass request.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{message ? (
|
||||||
|
<p className={status === 'error' ? 'message error-message' : 'message'}>{message}</p>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="workspace">
|
||||||
|
<section className="map-region" aria-label="Interactive map">
|
||||||
|
<MapView
|
||||||
|
results={results}
|
||||||
|
selectedResultId={selectedResultId}
|
||||||
|
onResultSelect={setSelectedResultId}
|
||||||
|
onViewportChange={handleViewportChange}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="results-panel" aria-labelledby="results-heading">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<h2 id="results-heading">Results</h2>
|
||||||
|
<span>{results.length}</span>
|
||||||
|
</div>
|
||||||
|
<ResultsList
|
||||||
|
results={results}
|
||||||
|
selectedResultId={selectedResultId}
|
||||||
|
isLoading={status === 'loading'}
|
||||||
|
onResultSelect={setSelectedResultId}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/features/map/MapView.tsx
Normal file
126
src/features/map/MapView.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import {
|
||||||
|
MapContainer,
|
||||||
|
Marker,
|
||||||
|
Popup,
|
||||||
|
TileLayer,
|
||||||
|
useMap,
|
||||||
|
useMapEvents,
|
||||||
|
} from 'react-leaflet';
|
||||||
|
import type { BBox, ParsedOsmResult } from '../search/types';
|
||||||
|
|
||||||
|
type MapViewProps = {
|
||||||
|
results: ParsedOsmResult[];
|
||||||
|
selectedResultId: string | null;
|
||||||
|
onResultSelect: (id: string) => void;
|
||||||
|
onViewportChange: (bbox: BBox) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CENTER: [number, number] = [52.2297, 21.0122];
|
||||||
|
const DEFAULT_ZOOM = 13;
|
||||||
|
|
||||||
|
export function MapView({
|
||||||
|
results,
|
||||||
|
selectedResultId,
|
||||||
|
onResultSelect,
|
||||||
|
onViewportChange,
|
||||||
|
}: MapViewProps) {
|
||||||
|
const markerIcon = useMemo(
|
||||||
|
() =>
|
||||||
|
L.divIcon({
|
||||||
|
className: 'result-marker',
|
||||||
|
html: '<span></span>',
|
||||||
|
iconSize: [18, 18],
|
||||||
|
iconAnchor: [9, 9],
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedMarkerIcon = useMemo(
|
||||||
|
() =>
|
||||||
|
L.divIcon({
|
||||||
|
className: 'result-marker result-marker-selected',
|
||||||
|
html: '<span></span>',
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
minZoom={3}
|
||||||
|
className="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"
|
||||||
|
/>
|
||||||
|
<ViewportReporter onViewportChange={onViewportChange} />
|
||||||
|
<SelectedResultFocus result={results.find((item) => item.id === selectedResultId) ?? null} />
|
||||||
|
{results.map((result) => (
|
||||||
|
<Marker
|
||||||
|
key={result.id}
|
||||||
|
position={[result.lat, result.lon]}
|
||||||
|
icon={result.id === selectedResultId ? selectedMarkerIcon : markerIcon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onResultSelect(result.id),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<strong>{result.name ?? 'Unnamed place'}</strong>
|
||||||
|
<br />
|
||||||
|
{result.type} {result.osmId}
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewportReporter({
|
||||||
|
onViewportChange,
|
||||||
|
}: {
|
||||||
|
onViewportChange: (bbox: BBox) => void;
|
||||||
|
}) {
|
||||||
|
const map = useMapEvents({
|
||||||
|
moveend: () => onViewportChange(boundsToBBox(map.getBounds())),
|
||||||
|
zoomend: () => onViewportChange(boundsToBBox(map.getBounds())),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onViewportChange(boundsToBBox(map.getBounds()));
|
||||||
|
}, [map, onViewportChange]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectedResultFocus({ result }: { result: ParsedOsmResult | null }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (result) {
|
||||||
|
map.setView([result.lat, result.lon], Math.max(map.getZoom(), 15), {
|
||||||
|
animate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [map, result]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsToBBox(bounds: L.LatLngBounds): BBox {
|
||||||
|
const southWest = bounds.getSouthWest();
|
||||||
|
const northEast = bounds.getNorthEast();
|
||||||
|
|
||||||
|
return {
|
||||||
|
south: southWest.lat,
|
||||||
|
west: southWest.lng,
|
||||||
|
north: northEast.lat,
|
||||||
|
east: northEast.lng,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
src/features/results/ResultsList.tsx
Normal file
63
src/features/results/ResultsList.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { ParsedOsmResult } from '../search/types';
|
||||||
|
|
||||||
|
type ResultsListProps = {
|
||||||
|
results: ParsedOsmResult[];
|
||||||
|
selectedResultId: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
onResultSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResultsList({
|
||||||
|
results,
|
||||||
|
selectedResultId,
|
||||||
|
isLoading,
|
||||||
|
onResultSelect,
|
||||||
|
}: ResultsListProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="empty-state">Searching OpenStreetMap data...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
Search the visible map area or a named area to see matching OSM objects here.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="results-list">
|
||||||
|
{results.map((result) => (
|
||||||
|
<li key={result.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={
|
||||||
|
result.id === selectedResultId
|
||||||
|
? 'result-row result-row-selected'
|
||||||
|
: 'result-row'
|
||||||
|
}
|
||||||
|
onClick={() => onResultSelect(result.id)}
|
||||||
|
>
|
||||||
|
<span className="result-title">{result.name ?? 'Unnamed place'}</span>
|
||||||
|
<span className="result-meta">
|
||||||
|
{result.type} {result.osmId}
|
||||||
|
</span>
|
||||||
|
<span className="tag-list">{formatTags(result.tags)}</span>
|
||||||
|
</button>
|
||||||
|
<a className="osm-link" href={result.osmUrl} target="_blank" rel="noreferrer">
|
||||||
|
Open OSM object
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTags(tags: Record<string, string>): string {
|
||||||
|
const visibleTags = Object.entries(tags)
|
||||||
|
.filter(([key]) => key !== 'name')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([key, value]) => `${key}=${value}`);
|
||||||
|
|
||||||
|
return visibleTags.length > 0 ? visibleTags.join(' · ') : 'No descriptive tags';
|
||||||
|
}
|
||||||
77
src/features/search/buildOverpassQuery.test.ts
Normal file
77
src/features/search/buildOverpassQuery.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildOverpassQuery } from './buildOverpassQuery';
|
||||||
|
import type { BBox, SearchState } from './types';
|
||||||
|
|
||||||
|
const bbox: BBox = {
|
||||||
|
south: 52.1,
|
||||||
|
west: 20.9,
|
||||||
|
north: 52.2,
|
||||||
|
east: 21.1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildOverpassQuery', () => {
|
||||||
|
it('builds a viewport query for amenity=drinking_water', () => {
|
||||||
|
const query = buildOverpassQuery({
|
||||||
|
choice: 'drinking_water',
|
||||||
|
area: { mode: 'bbox', bbox },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query).toContain('node["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);');
|
||||||
|
expect(query).toContain('way["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);');
|
||||||
|
expect(query).toContain('relation["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);');
|
||||||
|
expect(query).toContain('[out:json][timeout:25];');
|
||||||
|
expect(query).toContain('out center 100;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a wildcard shop preset query', () => {
|
||||||
|
const query = buildOverpassQuery({
|
||||||
|
choice: 'shops',
|
||||||
|
area: { mode: 'bbox', bbox },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query).toContain('node["shop"](52.100000,20.900000,52.200000,21.100000);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a custom tag query with key and value', () => {
|
||||||
|
const query = buildOverpassQuery({
|
||||||
|
choice: 'custom',
|
||||||
|
customKey: 'amenity',
|
||||||
|
customValue: 'library',
|
||||||
|
area: { mode: 'bbox', bbox },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query).toContain('node["amenity"="library"](52.100000,20.900000,52.200000,21.100000);');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a named area query', () => {
|
||||||
|
const query = buildOverpassQuery({
|
||||||
|
choice: 'hotels',
|
||||||
|
area: { mode: 'namedArea', name: 'Warsaw' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(query).toBe(
|
||||||
|
[
|
||||||
|
'[out:json][timeout:25];',
|
||||||
|
'area["name"="Warsaw"]->.searchArea;',
|
||||||
|
'(',
|
||||||
|
' nwr["tourism"="hotel"](area.searchArea);',
|
||||||
|
');',
|
||||||
|
'out center 100;',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotation marks in Overpass string values', () => {
|
||||||
|
const state: SearchState = {
|
||||||
|
choice: 'custom',
|
||||||
|
customKey: 'name',
|
||||||
|
customValue: 'Joe "Coffee"',
|
||||||
|
area: { mode: 'namedArea', name: 'Paris "Centre"' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = buildOverpassQuery(state);
|
||||||
|
|
||||||
|
expect(query).toContain('area["name"="Paris \\"Centre\\""]->.searchArea;');
|
||||||
|
expect(query).toContain('nwr["name"="Joe \\"Coffee\\""](area.searchArea);');
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/features/search/buildOverpassQuery.ts
Normal file
83
src/features/search/buildOverpassQuery.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { getPresetById } from './presets';
|
||||||
|
import type { BBox, OsmTagFilter, SearchState } from './types';
|
||||||
|
|
||||||
|
const MAX_BBOX_AREA = 1.5;
|
||||||
|
|
||||||
|
export function isBBoxTooLarge(bbox: BBox): boolean {
|
||||||
|
const height = Math.abs(bbox.north - bbox.south);
|
||||||
|
const width = Math.abs(bbox.east - bbox.west);
|
||||||
|
|
||||||
|
return height * width > MAX_BBOX_AREA;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOverpassQuery(state: SearchState): string {
|
||||||
|
const tag = resolveTagFilter(state);
|
||||||
|
const filter = buildTagFilter(tag);
|
||||||
|
|
||||||
|
if (state.area.mode === 'namedArea') {
|
||||||
|
const areaName = state.area.name.trim();
|
||||||
|
|
||||||
|
if (!areaName) {
|
||||||
|
throw new Error('Named area is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'[out:json][timeout:25];',
|
||||||
|
`area["name"="${escapeOverpassString(areaName)}"]->.searchArea;`,
|
||||||
|
'(',
|
||||||
|
` nwr${filter}(area.searchArea);`,
|
||||||
|
');',
|
||||||
|
'out center 100;',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bbox = formatBBox(state.area.bbox);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'[out:json][timeout:25];',
|
||||||
|
'(',
|
||||||
|
` node${filter}(${bbox});`,
|
||||||
|
` way${filter}(${bbox});`,
|
||||||
|
` relation${filter}(${bbox});`,
|
||||||
|
');',
|
||||||
|
'out center 100;',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTagFilter(state: SearchState): OsmTagFilter {
|
||||||
|
if (state.choice !== 'custom') {
|
||||||
|
return getPresetById(state.choice).tag;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = state.customKey?.trim();
|
||||||
|
const value = state.customValue?.trim();
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Custom tag key is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? { key, value } : { key };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagFilter(tag: OsmTagFilter): string {
|
||||||
|
const key = escapeOverpassString(tag.key);
|
||||||
|
|
||||||
|
if (!tag.value) {
|
||||||
|
return `["${key}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `["${key}"="${escapeOverpassString(tag.value)}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeOverpassString(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBBox(bbox: BBox): string {
|
||||||
|
return [
|
||||||
|
bbox.south.toFixed(6),
|
||||||
|
bbox.west.toFixed(6),
|
||||||
|
bbox.north.toFixed(6),
|
||||||
|
bbox.east.toFixed(6),
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
21
src/features/search/overpassClient.ts
Normal file
21
src/features/search/overpassClient.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { OverpassResponse } from './types';
|
||||||
|
|
||||||
|
const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter';
|
||||||
|
|
||||||
|
export async function fetchOverpass(
|
||||||
|
query: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<OverpassResponse> {
|
||||||
|
const body = new URLSearchParams({ data: query });
|
||||||
|
const response = await fetch(OVERPASS_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Overpass API returned ${response.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<OverpassResponse>;
|
||||||
|
}
|
||||||
98
src/features/search/parseOverpassResponse.test.ts
Normal file
98
src/features/search/parseOverpassResponse.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseOverpassResponse } from './parseOverpassResponse';
|
||||||
|
|
||||||
|
describe('parseOverpassResponse', () => {
|
||||||
|
it('parses nodes with lat and lon', () => {
|
||||||
|
const results = parseOverpassResponse({
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'node',
|
||||||
|
id: 123,
|
||||||
|
lat: 52.2,
|
||||||
|
lon: 21.0,
|
||||||
|
tags: {
|
||||||
|
name: 'Water point',
|
||||||
|
amenity: 'drinking_water',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
{
|
||||||
|
id: 'node/123',
|
||||||
|
osmId: 123,
|
||||||
|
type: 'node',
|
||||||
|
lat: 52.2,
|
||||||
|
lon: 21.0,
|
||||||
|
name: 'Water point',
|
||||||
|
tags: {
|
||||||
|
name: 'Water point',
|
||||||
|
amenity: 'drinking_water',
|
||||||
|
},
|
||||||
|
osmUrl: 'https://www.openstreetmap.org/node/123',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses ways and relations with center points', () => {
|
||||||
|
const results = parseOverpassResponse({
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'way',
|
||||||
|
id: 456,
|
||||||
|
center: {
|
||||||
|
lat: 52.21,
|
||||||
|
lon: 21.01,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
amenity: 'toilets',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'relation',
|
||||||
|
id: 789,
|
||||||
|
center: {
|
||||||
|
lat: 52.22,
|
||||||
|
lon: 21.02,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
tourism: 'hotel',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toHaveLength(2);
|
||||||
|
expect(results[0]).toMatchObject({
|
||||||
|
id: 'way/456',
|
||||||
|
type: 'way',
|
||||||
|
lat: 52.21,
|
||||||
|
lon: 21.01,
|
||||||
|
osmUrl: 'https://www.openstreetmap.org/way/456',
|
||||||
|
});
|
||||||
|
expect(results[1]).toMatchObject({
|
||||||
|
id: 'relation/789',
|
||||||
|
type: 'relation',
|
||||||
|
lat: 52.22,
|
||||||
|
lon: 21.02,
|
||||||
|
osmUrl: 'https://www.openstreetmap.org/relation/789',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips elements without a point or center', () => {
|
||||||
|
const results = parseOverpassResponse({
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
type: 'way',
|
||||||
|
id: 456,
|
||||||
|
tags: {
|
||||||
|
amenity: 'toilets',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
src/features/search/parseOverpassResponse.ts
Normal file
47
src/features/search/parseOverpassResponse.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { OverpassElement, OverpassResponse, ParsedOsmResult } from './types';
|
||||||
|
|
||||||
|
export function parseOverpassResponse(response: OverpassResponse): ParsedOsmResult[] {
|
||||||
|
return (response.elements ?? []).flatMap((element) => {
|
||||||
|
const point = getElementPoint(element);
|
||||||
|
|
||||||
|
if (!point) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = element.tags ?? {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: `${element.type}/${element.id}`,
|
||||||
|
osmId: element.id,
|
||||||
|
type: element.type,
|
||||||
|
lat: point.lat,
|
||||||
|
lon: point.lon,
|
||||||
|
name: tags.name ?? null,
|
||||||
|
tags,
|
||||||
|
osmUrl: `https://www.openstreetmap.org/${element.type}/${element.id}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElementPoint(element: OverpassElement) {
|
||||||
|
if (typeof element.lat === 'number' && typeof element.lon === 'number') {
|
||||||
|
return {
|
||||||
|
lat: element.lat,
|
||||||
|
lon: element.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof element.center?.lat === 'number' &&
|
||||||
|
typeof element.center?.lon === 'number'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
lat: element.center.lat,
|
||||||
|
lon: element.center.lon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
65
src/features/search/presets.ts
Normal file
65
src/features/search/presets.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { OsmTagFilter, SearchPresetId } from './types';
|
||||||
|
|
||||||
|
export type SearchPreset = {
|
||||||
|
id: SearchPresetId;
|
||||||
|
label: string;
|
||||||
|
tag: OsmTagFilter;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchPresets: SearchPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'drinking_water',
|
||||||
|
label: 'Drinking water',
|
||||||
|
tag: { key: 'amenity', value: 'drinking_water' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toilets',
|
||||||
|
label: 'Public toilets',
|
||||||
|
tag: { key: 'amenity', value: 'toilets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bicycle_parking',
|
||||||
|
label: 'Bicycle parking',
|
||||||
|
tag: { key: 'amenity', value: 'bicycle_parking' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hotels',
|
||||||
|
label: 'Hotels',
|
||||||
|
tag: { key: 'tourism', value: 'hotel' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restaurants',
|
||||||
|
label: 'Restaurants',
|
||||||
|
tag: { key: 'amenity', value: 'restaurant' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cafes',
|
||||||
|
label: 'Cafes',
|
||||||
|
tag: { key: 'amenity', value: 'cafe' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shops',
|
||||||
|
label: 'Shops',
|
||||||
|
tag: { key: 'shop' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'benches',
|
||||||
|
label: 'Benches',
|
||||||
|
tag: { key: 'amenity', value: 'bench' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'atms',
|
||||||
|
label: 'ATMs',
|
||||||
|
tag: { key: 'amenity', value: 'atm' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPresetById(id: SearchPresetId): SearchPreset {
|
||||||
|
const preset = searchPresets.find((item) => item.id === id);
|
||||||
|
|
||||||
|
if (!preset) {
|
||||||
|
throw new Error(`Unknown preset: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return preset;
|
||||||
|
}
|
||||||
70
src/features/search/types.ts
Normal file
70
src/features/search/types.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export type OsmElementType = 'node' | 'way' | 'relation';
|
||||||
|
|
||||||
|
export type SearchPresetId =
|
||||||
|
| 'drinking_water'
|
||||||
|
| 'toilets'
|
||||||
|
| 'bicycle_parking'
|
||||||
|
| 'hotels'
|
||||||
|
| 'restaurants'
|
||||||
|
| 'cafes'
|
||||||
|
| 'shops'
|
||||||
|
| 'benches'
|
||||||
|
| 'atms';
|
||||||
|
|
||||||
|
export type SearchChoice = SearchPresetId | 'custom';
|
||||||
|
|
||||||
|
export type BBox = {
|
||||||
|
south: number;
|
||||||
|
west: number;
|
||||||
|
north: number;
|
||||||
|
east: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchArea =
|
||||||
|
| {
|
||||||
|
mode: 'bbox';
|
||||||
|
bbox: BBox;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
mode: 'namedArea';
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchState = {
|
||||||
|
choice: SearchChoice;
|
||||||
|
customKey?: string;
|
||||||
|
customValue?: string;
|
||||||
|
area: SearchArea;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OsmTagFilter = {
|
||||||
|
key: string;
|
||||||
|
value?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverpassElement = {
|
||||||
|
type: OsmElementType;
|
||||||
|
id: number;
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
center?: {
|
||||||
|
lat?: number;
|
||||||
|
lon?: number;
|
||||||
|
};
|
||||||
|
tags?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OverpassResponse = {
|
||||||
|
elements?: OverpassElement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParsedOsmResult = {
|
||||||
|
id: string;
|
||||||
|
osmId: number;
|
||||||
|
type: OsmElementType;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
name: string | null;
|
||||||
|
tags: Record<string, string>;
|
||||||
|
osmUrl: string;
|
||||||
|
};
|
||||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import './styles.css';
|
||||||
|
import { App } from './app/App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
4
src/shared/types.ts
Normal file
4
src/shared/types.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type Coordinates = {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
};
|
||||||
369
src/styles.css
Normal file
369
src/styles.css
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
:root {
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||||
|
sans-serif;
|
||||||
|
color: #17211c;
|
||||||
|
background: #eef1ed;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 1px solid #b8c2b5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211c;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: #5d7f86;
|
||||||
|
background: #f4f8f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #b8c2b5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #17211c;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label,
|
||||||
|
legend {
|
||||||
|
color: #394840;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #0b6977;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
border-right: 1px solid #cdd5c9;
|
||||||
|
background: #fafbf8;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #17211c;
|
||||||
|
font-size: 1.65rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: #66726a;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.42rem;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 1px solid #b8c2b5;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segmented-control input {
|
||||||
|
width: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewport-note {
|
||||||
|
margin: -0.25rem 0 0;
|
||||||
|
color: #66726a;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row,
|
||||||
|
.query-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
border-color: #226a5d;
|
||||||
|
background: #226a5d;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover:not(:disabled) {
|
||||||
|
border-color: #174d44;
|
||||||
|
background: #174d44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-panel,
|
||||||
|
.results-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading span {
|
||||||
|
color: #66726a;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-preview {
|
||||||
|
overflow: auto;
|
||||||
|
max-height: 260px;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid #cbd6cf;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #16201d;
|
||||||
|
color: #eaf4ef;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 0;
|
||||||
|
border-left: 3px solid #2e7a70;
|
||||||
|
background: #e8f3ef;
|
||||||
|
color: #29433b;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
border-left-color: #a13f30;
|
||||||
|
background: #f9e9e5;
|
||||||
|
color: #6d2d23;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(420px, 1fr) minmax(180px, 34vh);
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-region {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-marker {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #0b6977;
|
||||||
|
box-shadow: 0 2px 10px rgb(0 0 0 / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-marker span {
|
||||||
|
display: block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-marker-selected {
|
||||||
|
background: #c84830;
|
||||||
|
box-shadow: 0 0 0 4px rgb(200 72 48 / 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-panel {
|
||||||
|
border-top: 1px solid #cdd5c9;
|
||||||
|
background: #f7f8f5;
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
overflow: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list li {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid #d7ded5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-row:hover:not(:disabled),
|
||||||
|
.result-row-selected {
|
||||||
|
background: #eef6f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
color: #17211c;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-meta,
|
||||||
|
.tag-list {
|
||||||
|
color: #66726a;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osm-link {
|
||||||
|
justify-self: start;
|
||||||
|
padding: 0 0.25rem 0.2rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 120px;
|
||||||
|
border: 1px dashed #b8c2b5;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #66726a;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #cdd5c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
grid-template-rows: 62vh minmax(260px, auto);
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.control-panel,
|
||||||
|
.results-panel {
|
||||||
|
padding: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-fields,
|
||||||
|
.segmented-control {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions,
|
||||||
|
.button-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-row button,
|
||||||
|
.query-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
tsconfig.app.json
Normal file
22
tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
16
tsconfig.node.json
Normal file
16
tsconfig.node.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
9
vite.config.ts
Normal file
9
vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user