feat: build MapSift Overpass data explorer
This commit is contained in:
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;
|
||||
};
|
||||
Reference in New Issue
Block a user