diff --git a/src/app.css b/src/app.css index 5205a53..0de8ded 100644 --- a/src/app.css +++ b/src/app.css @@ -62,6 +62,11 @@ @apply text-xs; } + .toolbar-select option { + background-color: #171717; + color: #fafafa; + } + .loading-bar { @apply h-1 bg-accent-red transition-all duration-300; width: 0%; @@ -71,3 +76,44 @@ @apply w-full; } } + +.ol-zoom-in, +.ol-zoom-out { + background-color: #171717 !important; + color: #fafafa !important; + border-color: #262626 !important; +} + +.ol-zoom-in:hover, +.ol-zoom-out:hover { + background-color: #262626 !important; +} + +.ol-attribution.ol-unselectable.ol-control { + background-color: rgba(23, 23, 23, 0.9) !important; + color: #fafafa !important; +} + +.ol-attribution.ol-unselectable.ol-control.ol-collapsed { + background-color: rgba(23, 23, 23, 0.9) !important; + color: #fafafa !important; +} + +.ol-attribution.ol-unselectable.ol-control button { + background-color: rgba(23, 23, 23, 0.9) !important; + color: #fafafa !important; + border-color: #262626 !important; + font-size: 0.75rem !important; + padding: 0.25rem 0.5rem !important; + min-width: 1.5rem !important; + height: 1.5rem !important; + line-height: 1 !important; +} + +.ol-attribution.ol-unselectable.ol-control button:hover { + background-color: rgba(38, 38, 38, 0.9) !important; +} + +.ol-attribution.ol-unselectable.ol-control ul { + color: #fafafa !important; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 0e9fd87..1889bf3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -115,3 +115,51 @@ export async function fetchSurveillanceCameras( return cameras; } + +export interface NominatimResult { + place_id: number; + licence: string; + osm_type: string; + osm_id: number; + boundingbox: [string, string, string, string]; + lat: string; + lon: string; + display_name: string; + class: string; + type: string; + importance: number; +} + +export interface NominatimResponse extends Array {} + +export async function searchNominatim(query: string): Promise { + if (!query.trim()) { + return []; + } + + const url = new URL('https://nominatim.openstreetmap.org/search'); + url.searchParams.set('q', query); + url.searchParams.set('format', 'json'); + url.searchParams.set('limit', '10'); + url.searchParams.set('addressdetails', '0'); + url.searchParams.set('extratags', '0'); + url.searchParams.set('namedetails', '0'); + + try { + const response = await fetch(url.toString(), { + headers: { + 'User-Agent': 'Surveilled/1.0', + }, + }); + + if (!response.ok) { + throw new Error(`Nominatim error ${response.status}`); + } + + const data: NominatimResponse = await response.json(); + return data; + } catch (err) { + console.error('Nominatim search failed:', err); + throw err; + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3998ad5..953a6d6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,7 +8,7 @@ import Feature from 'ol/Feature.js'; import { unByKey } from 'ol/Observable.js'; import type { EventsKey } from 'ol/events.js'; - import { Square, RefreshCw, Link, Copy, Download, GitBranch, Ruler } from 'lucide-svelte'; + import { Square, RefreshCw, Link, Copy, Download, GitBranch, Ruler, Search, Loader2, ChevronUp, ChevronDown } from 'lucide-svelte'; import VectorSource from 'ol/source/Vector.js'; import Cluster from 'ol/source/Cluster.js'; import VectorLayer from 'ol/layer/Vector.js'; @@ -29,7 +29,7 @@ formatDirectionLabel, type Camera, } from '$lib/map'; - import { fetchSurveillanceCameras } from '$lib/api'; + import { fetchSurveillanceCameras, searchNominatim, type NominatimResult } from '$lib/api'; import { FOV_DEFAULT_ANGLE, FOV_DEFAULT_RANGE, @@ -68,6 +68,14 @@ let boxFeature: Feature | null = null; let boxMoveHandler: EventsKey | null = null; let cursorMoveHandler: EventsKey | null = null; + let locationSearchQuery = ''; + let locationSearchResults: NominatimResult[] = []; + let locationSearchSelectedIndex = -1; + let locationSearchOpen = false; + let locationSearchLoading = false; + let locationSearchDebounceTimer: ReturnType | null = null; + let selectionBoxCenterPixel: [number, number] | null = null; + let footerCollapsed = false; let cameraSource: VectorSource; let clusterSource: Cluster; @@ -173,6 +181,11 @@ map.getView().on('change:resolution', () => { rebuildFovFromCameras(); + updateSelectionBoxCenterPixel(); + }); + + map.getView().on('change:center', () => { + updateSelectionBoxCenterPixel(); }); restoreStateFromUrl(); @@ -213,6 +226,9 @@ if (cursorMoveHandler) { unByKey(cursorMoveHandler); } + if (locationSearchDebounceTimer) { + clearTimeout(locationSearchDebounceTimer); + } }); }); @@ -387,6 +403,9 @@ setStatusMessage(message, true); } finally { setLoading(false); + if (!fetchInFlight) { + selectionBoxCenterPixel = null; + } } } @@ -491,12 +510,25 @@ } } + function updateSelectionBoxCenterPixel() { + if (!map || !lastSelectionBounds) { + selectionBoxCenterPixel = null; + return; + } + const centerLon = (lastSelectionBounds[0] + lastSelectionBounds[2]) / 2; + const centerLat = (lastSelectionBounds[1] + lastSelectionBounds[3]) / 2; + const centerCoord = fromLonLat([centerLon, centerLat]); + const pixel = map.getPixelFromCoordinate(centerCoord); + selectionBoxCenterPixel = [pixel[0], pixel[1]]; + } + function finishBoxSelection(bounds: [number, number, number, number]) { if (!map || !selectionLayer) return; const source = selectionLayer.getSource(); if (!source) return; lastSelectionBounds = bounds; + updateSelectionBoxCenterPixel(); handleFetchCameras(bounds); disableBoxSelectMode(true); updateUrlState(); @@ -593,6 +625,106 @@ } } + async function handleLocationSearchInput(event: Event) { + const target = event.target as HTMLInputElement; + locationSearchQuery = target.value; + locationSearchSelectedIndex = -1; + + if (locationSearchDebounceTimer) { + clearTimeout(locationSearchDebounceTimer); + } + + if (!locationSearchQuery.trim()) { + locationSearchResults = []; + locationSearchOpen = false; + locationSearchLoading = false; + return; + } + + locationSearchDebounceTimer = setTimeout(async () => { + locationSearchLoading = true; + try { + const results = await searchNominatim(locationSearchQuery); + locationSearchResults = results; + locationSearchOpen = results.length > 0; + } catch (err) { + console.error('Location search failed:', err); + locationSearchResults = []; + locationSearchOpen = false; + } finally { + locationSearchLoading = false; + } + }, 300); + } + + function handleLocationSearchKeydown(event: KeyboardEvent) { + if (!locationSearchOpen || locationSearchResults.length === 0) { + if (event.key === 'Escape') { + locationSearchQuery = ''; + locationSearchResults = []; + locationSearchOpen = false; + locationSearchLoading = false; + } + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + locationSearchSelectedIndex = Math.min( + locationSearchSelectedIndex + 1, + locationSearchResults.length - 1 + ); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + locationSearchSelectedIndex = Math.max(locationSearchSelectedIndex - 1, -1); + } else if (event.key === 'Enter') { + event.preventDefault(); + if (locationSearchSelectedIndex >= 0 && locationSearchSelectedIndex < locationSearchResults.length) { + selectLocation(locationSearchResults[locationSearchSelectedIndex]); + } else if (locationSearchResults.length > 0) { + selectLocation(locationSearchResults[0]); + } + } else if (event.key === 'Escape') { + locationSearchQuery = ''; + locationSearchResults = []; + locationSearchOpen = false; + locationSearchSelectedIndex = -1; + } + } + + function selectLocation(result: NominatimResult) { + if (!map) return; + + const lat = parseFloat(result.lat); + const lon = parseFloat(result.lon); + + if (isNaN(lat) || isNaN(lon)) return; + + map.getView().animate({ + center: fromLonLat([lon, lat]), + zoom: Math.max(map.getView().getZoom() ?? MAP_DEFAULT_ZOOM_USER, 14), + duration: 500, + }); + + locationSearchQuery = result.display_name; + locationSearchResults = []; + locationSearchOpen = false; + locationSearchSelectedIndex = -1; + locationSearchLoading = false; + } + + function handleLocationSearchBlur() { + setTimeout(() => { + locationSearchOpen = false; + }, 200); + } + + function handleLocationSearchFocus() { + if (locationSearchResults.length > 0) { + locationSearchOpen = true; + } + } + function updateUrlState() { if (!map) return; const params = new URLSearchParams(window.location.search); @@ -712,6 +844,16 @@ >
+ {#if fetchInFlight > 0 && selectionBoxCenterPixel && lastSelectionBounds && !locationSearchLoading} +
+
+ +
+
+ {/if} {#if !isMobile}
{#if cursorLonLat} @@ -726,9 +868,50 @@ {/if}
{/if} -
+
+
+
+
+ {#if locationSearchLoading} + + {:else} + + {/if} +
+ + {#if locationSearchOpen && locationSearchResults.length > 0} +
+ {#each locationSearchResults as result, index} + + {/each} +
+ {/if} +
+