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

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