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