Add location search functionality using Nominatim API and enhance UI elements
- 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:
46
src/app.css
46
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user