Add location search functionality using Nominatim API and enhance UI elements
Some checks failed
CI / check (push) Failing after 13s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 11s
CI / build (push) Has been skipped

- Implemented a new location search feature in the Svelte component, allowing users to search for locations using the Nominatim API.
- Added relevant state management for search input, results, and loading indicators.
- Enhanced the UI with new styles for the search input and results dropdown.
- Updated toolbar and footer styles for better visual consistency.
This commit is contained in:
2025-12-24 18:11:30 -06:00
parent de6542eafb
commit 4c2f67a406
3 changed files with 298 additions and 8 deletions

View File

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

View File

@@ -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<NominatimResult> {}
export async function searchNominatim(query: string): Promise<NominatimResult[]> {
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;
}
}

View File

@@ -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<typeof setTimeout> | 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 @@
></div>
<main class="flex-1 relative overflow-hidden bg-bg-primary">
<div bind:this={mapContainer} class="w-full h-full"></div>
{#if fetchInFlight > 0 && selectionBoxCenterPixel && lastSelectionBounds && !locationSearchLoading}
<div
class="absolute z-[1200] pointer-events-none"
style="left: {selectionBoxCenterPixel[0]}px; top: {selectionBoxCenterPixel[1]}px; transform: translate(-50%, -50%);"
>
<div class="bg-bg-secondary/90 border border-border-color rounded-lg p-3 shadow-lg flex items-center justify-center">
<Loader2 class="text-accent-red animate-spin" size={24} stroke-width={2} />
</div>
</div>
{/if}
{#if !isMobile}
<div class="absolute bottom-4 left-4 z-[1000] flex flex-col gap-1 text-[11px] text-text-secondary bg-bg-secondary/90 border border-border-color rounded px-2 py-1 shadow">
{#if cursorLonLat}
@@ -726,9 +868,50 @@
{/if}
</div>
{/if}
<div class="absolute top-4 left-1/2 transform -translate-x-1/2 z-[1000]">
<div class="absolute top-4 sm:top-2 left-4 right-20 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1000] flex flex-col sm:flex-row gap-2 items-center">
<div class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none">
<div class="relative">
<div class="absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none flex items-center">
{#if locationSearchLoading}
<Loader2 class="text-text-secondary animate-spin" size={16} stroke-width={2} />
{:else}
<Search class="text-text-secondary" size={16} stroke-width={2} />
{/if}
</div>
<input
type="text"
class="w-full bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg pl-10 pr-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
placeholder="Search location..."
bind:value={locationSearchQuery}
on:input={handleLocationSearchInput}
on:keydown={handleLocationSearchKeydown}
on:blur={handleLocationSearchBlur}
on:focus={handleLocationSearchFocus}
/>
{#if locationSearchOpen && locationSearchResults.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-bg-secondary border border-border-color rounded-lg shadow-lg max-h-64 overflow-y-auto z-[1100]">
{#each locationSearchResults as result, index}
<button
class="w-full text-left px-4 py-2 hover:bg-bg-primary transition-colors {index === locationSearchSelectedIndex
? 'bg-bg-primary'
: ''}"
on:click={() => selectLocation(result)}
on:mousedown|preventDefault
>
<div class="text-sm text-text-primary font-medium truncate">
{result.display_name}
</div>
<div class="text-xs text-text-secondary mt-0.5">
{result.class} · {result.type}
</div>
</button>
{/each}
</div>
{/if}
</div>
</div>
<div
class="toolbar bg-bg-secondary/95 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
class="toolbar bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg px-2 py-1.5 flex items-center gap-1 shadow-lg"
>
<button
class="toolbar-btn {isBoxSelectMode ? 'active' : ''}"
@@ -757,7 +940,7 @@
<Ruler size={16} />
</button>
<select
class="toolbar-select text-text-primary bg-transparent border-none text-xs px-2 py-1 cursor-pointer"
class="toolbar-select text-text-primary bg-bg-secondary border-none text-xs px-2 py-1 cursor-pointer"
title="Basemap"
value={basemap}
on:change={handleBasemapChange}
@@ -775,7 +958,7 @@
{infoOpen ? 'Hide info' : 'Show info'}
</button>
<div
class="absolute z-[1000] bg-bg-secondary border border-border-color rounded-lg p-4 shadow-lg max-w-md w-[calc(100%-2rem)] left-1/2 -translate-x-1/2 bottom-4 sm:bottom-auto sm:top-4 sm:right-4 sm:left-auto sm:translate-x-0"
class="absolute z-[1000] bg-bg-secondary/80 backdrop-blur-sm border border-border-color rounded-lg p-4 shadow-lg max-w-md w-[calc(100%-2rem)] left-1/2 -translate-x-1/2 bottom-4 sm:bottom-auto sm:top-4 sm:right-4 sm:left-auto sm:translate-x-0"
class:hidden={!infoOpen && isMobile}
>
<div class="space-y-3 max-h-[70vh] sm:max-h-none overflow-y-auto">
@@ -840,9 +1023,22 @@
</div>
</main>
<footer
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary"
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary relative"
>
<div class="max-w-7xl mx-auto space-y-2">
{#if isMobile}
<button
class="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-bg-secondary border border-border-color rounded-full p-1.5 shadow-lg hover:bg-bg-primary transition-colors"
on:click={() => (footerCollapsed = !footerCollapsed)}
title={footerCollapsed ? 'Expand footer' : 'Collapse footer'}
>
{#if footerCollapsed}
<ChevronUp class="text-text-secondary" size={16} />
{:else}
<ChevronDown class="text-text-secondary" size={16} />
{/if}
</button>
{/if}
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed && isMobile}>
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<div class="space-y-1">
<p class="flex items-center gap-1">