feat: build MapSift Overpass data explorer

This commit is contained in:
zv
2026-06-23 18:56:59 +02:00
commit b1e1df7e1d
23 changed files with 2909 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
*.local
*.log
.DS_Store
Thumbs.db

18
AGENTS.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View 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>
);
}

View 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='&copy; <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,
};
}

View 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';
}

View 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);');
});
});

View 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(',');
}

View 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>;
}

View 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([]);
});
});

View 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;
}

View 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;
}

View 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
View 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
View File

@@ -0,0 +1,4 @@
export type Coordinates = {
lat: number;
lon: number;
};

369
src/styles.css Normal file
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

22
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

16
tsconfig.node.json Normal file
View 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
View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
},
});