Enhance camera map interface with new settings and improved functionality

- Added settings management for Overpass API, custom tile URLs, and Nominatim search endpoints, allowing users to customize their experience.
- Implemented responsive design adjustments for mobile views, including a modal for location search.
- Improved camera selection and measurement features with enhanced visual feedback and distance labeling.
- Updated footer behavior to persist user preferences and improve usability.
This commit is contained in:
2025-12-25 13:36:09 -06:00
parent 07d5c540b6
commit 3c574b58f6

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { replaceState } from '$app/navigation';
import Map from 'ol/Map.js';
import type MapBrowserEvent from 'ol/MapBrowserEvent.js';
import { Style, Stroke, Fill } from 'ol/style.js';
import { Style, Stroke, Fill, Text } from 'ol/style.js';
import { fromLonLat, toLonLat, transformExtent } from 'ol/proj.js';
import { Polygon, Point, LineString } from 'ol/geom.js';
import Feature from 'ol/Feature.js';
@@ -20,6 +21,7 @@
Loader2,
ChevronUp,
ChevronDown,
Settings,
} from 'lucide-svelte';
import VectorSource from 'ol/source/Vector.js';
import Cluster from 'ol/source/Cluster.js';
@@ -48,7 +50,15 @@
MAP_DEFAULT_ZOOM_USER,
STATUS_MESSAGES,
ERROR_MESSAGES,
OVERPASS_ENDPOINTS,
} from '$lib/constants';
import {
overpassEndpoint,
customTileUrl,
nominatimEndpoint,
basemapPreference,
footerCollapsedPref,
} from '$lib/settings';
import { APP_VERSION } from '$lib/version';
import favicon from '$lib/assets/favicon.svg';
@@ -69,12 +79,16 @@
let measureDistanceKm = 0;
let measureDistanceMiles = 0;
let fetchInFlight = 0;
let statusMessage = '';
let statusError = false;
let lastUpdated = 'never';
let basemap: 'dark' | 'light' | 'satellite' = 'dark';
let basemap: 'dark' | 'light' | 'satellite' | 'custom' =
$basemapPreference === 'dark' ||
$basemapPreference === 'light' ||
$basemapPreference === 'satellite' ||
$basemapPreference === 'custom'
? $basemapPreference
: 'dark';
let selectedCamera: Camera | null = null;
let infoOpen = true;
let selectedCameraPixel: [number, number] | null = null;
let isMobile = false;
let boxStartPoint: [number, number] | null = null;
let boxFeature: Feature | null = null;
@@ -86,9 +100,17 @@
let locationSearchOpen = false;
let locationSearchLoading = false;
let locationSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let locationSearchModalOpen = false;
let locationSearchInput: HTMLInputElement;
let selectionBoxCenterPixel: [number, number] | null = null;
let footerCollapsed = false;
let selectionBoxTopCenterPixel: [number, number] | null = null;
let footerCollapsed = $footerCollapsedPref;
let showBoxTooltip = false;
let showEndpointSettings = false;
let settingsTab: 'overpass' | 'tiles' | 'nominatim' = 'overpass';
let customEndpoint = '';
let customTileUrlInput = '';
let nominatimEndpointInput = '';
let boxSelectButton: HTMLButtonElement;
let boxTooltipPosition: { left: number; top: number } | null = null;
@@ -102,10 +124,13 @@
let baseLayer: TileLayer<XYZ> | null = null;
let basemapSources: ReturnType<typeof createBasemapSources>;
$: if (locationSearchModalOpen && isMobile && locationSearchInput) {
setTimeout(() => locationSearchInput.focus(), 100);
}
onMount(() => {
const updateIsMobile = () => {
isMobile = window.matchMedia('(max-width: 640px)').matches;
if (isMobile) infoOpen = false;
};
updateIsMobile();
window.addEventListener('resize', updateIsMobile);
@@ -117,7 +142,16 @@
const firstLayer = map.getLayers().item(0);
if (firstLayer instanceof TileLayer) {
baseLayer = firstLayer as TileLayer<XYZ>;
baseLayer.setSource(basemapSources.dark);
if (basemap === 'custom' && $customTileUrl) {
baseLayer.setSource(
new XYZ({
url: $customTileUrl,
crossOrigin: 'anonymous',
})
);
} else {
baseLayer.setSource(basemapSources[basemap === 'custom' ? 'dark' : basemap]);
}
}
cameraSource = createCameraSource();
@@ -128,13 +162,54 @@
measureSource = new VectorSource();
measureLayer = new VectorLayer({
source: measureSource,
style: new Style({
stroke: new Stroke({
color: '#ef4444',
width: 2,
lineDash: [6, 4],
}),
}),
style: (feature) => {
const styles = [
new Style({
stroke: new Stroke({
color: '#ef4444',
width: 2,
lineDash: [6, 4],
}),
}),
];
const geometry = feature.getGeometry();
if (geometry instanceof LineString) {
const distanceKm = feature.get('distanceKm');
const distanceMi = feature.get('distanceMi');
if (distanceKm && distanceKm > 0) {
const coords = geometry.getCoordinates();
const midCoord = [(coords[0][0] + coords[1][0]) / 2, (coords[0][1] + coords[1][1]) / 2];
const dx = coords[1][0] - coords[0][0];
const dy = coords[1][1] - coords[0][1];
let angle = Math.atan2(dy, dx);
let perpAngle = angle + Math.PI / 2;
if (Math.abs(angle) > Math.PI / 2) {
angle = angle > 0 ? angle - Math.PI : angle + Math.PI;
perpAngle = angle + Math.PI / 2;
}
const offset = 20;
const labelCoord = [
midCoord[0] + Math.cos(perpAngle) * offset,
midCoord[1] + Math.sin(perpAngle) * offset,
];
styles.push(
new Style({
geometry: new Point(labelCoord),
text: new Text({
text: `${distanceMi.toFixed(2)} mi\n\n${distanceKm.toFixed(2)} km`,
font: 'bold 11px sans-serif',
fill: new Fill({ color: '#ffffff' }),
textAlign: 'center',
textBaseline: 'middle',
rotation: angle,
offsetY: -2,
}),
})
);
}
}
return styles;
},
updateWhileInteracting: true,
});
@@ -177,8 +252,19 @@
if (clustered && clustered.length === 1) {
const inner = clustered[0];
if (inner.get('camera')) {
selectedCamera = inner.get('camera');
setStatusMessage('');
const camera = inner.get('camera') as Camera;
const geometry = inner.getGeometry();
if (geometry instanceof Point) {
const pixel = map!.getPixelFromCoordinate(geometry.getCoordinates());
if (selectedCamera === camera && selectedCameraPixel) {
selectedCamera = null;
selectedCameraPixel = null;
} else {
selectedCamera = camera;
selectedCameraPixel = [pixel[0], pixel[1]];
}
setStatusMessage('');
}
}
} else if (clustered && clustered.length > 1) {
const geometry = feature.getGeometry();
@@ -192,20 +278,31 @@
});
}
}
} else {
selectedCamera = null;
selectedCameraPixel = null;
}
});
map.getView().on('change:resolution', () => {
rebuildFovFromCameras();
updateSelectionBoxCenterPixel();
updateSelectedCameraPixel();
});
map.getView().on('change:center', () => {
updateSelectionBoxCenterPixel();
updateSelectedCameraPixel();
});
restoreStateFromUrl();
if (!OVERPASS_ENDPOINTS.includes($overpassEndpoint as (typeof OVERPASS_ENDPOINTS)[number])) {
customEndpoint = $overpassEndpoint;
}
customTileUrlInput = $customTileUrl;
nominatimEndpointInput = $nominatimEndpoint;
const hasSeenTooltip = localStorage.getItem('surveilled-box-tooltip-seen');
if (!hasSeenTooltip) {
setTimeout(async () => {
@@ -257,9 +354,8 @@
});
});
function setStatusMessage(message: string, isError = false) {
statusMessage = message;
statusError = isError;
function setStatusMessage(_message: string, ..._args: unknown[]) {
// Status messages are currently not displayed in the UI
}
function setLoading(active: boolean) {
@@ -375,9 +471,13 @@
measureSource.clear();
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
measureSource.addFeature(new Feature(new Point(fromLonLat(measureEnd))));
measureSource.addFeature(
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)]))
const lineFeature = new Feature(
new LineString([fromLonLat(measureStart), fromLonLat(measureEnd)])
);
lineFeature.set('distanceKm', measureDistanceKm);
lineFeature.set('distanceMi', measureDistanceMiles);
measureSource.addFeature(lineFeature);
measureLayer.changed();
}
function updateMeasurePreview(coordinate: number[]) {
@@ -389,9 +489,13 @@
measureSource.clear();
measureSource.addFeature(new Feature(new Point(fromLonLat(measureStart))));
measureSource.addFeature(new Feature(new Point(fromLonLat(current))));
measureSource.addFeature(
new Feature(new LineString([fromLonLat(measureStart), fromLonLat(current)]))
const lineFeature = new Feature(
new LineString([fromLonLat(measureStart), fromLonLat(current)])
);
lineFeature.set('distanceKm', measureDistanceKm);
lineFeature.set('distanceMi', measureDistanceMiles);
measureSource.addFeature(lineFeature);
measureLayer.changed();
}
function toggleMeasureMode() {
@@ -452,7 +556,9 @@
function dismissBoxTooltip() {
showBoxTooltip = false;
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
if (typeof window !== 'undefined') {
localStorage.setItem('surveilled-box-tooltip-seen', 'true');
}
}
function toggleBoxSelectMode() {
@@ -571,6 +677,7 @@
function updateSelectionBoxCenterPixel() {
if (!map || !lastSelectionBounds) {
selectionBoxCenterPixel = null;
selectionBoxTopCenterPixel = null;
return;
}
const centerLon = (lastSelectionBounds[0] + lastSelectionBounds[2]) / 2;
@@ -578,6 +685,21 @@
const centerCoord = fromLonLat([centerLon, centerLat]);
const pixel = map.getPixelFromCoordinate(centerCoord);
selectionBoxCenterPixel = [pixel[0], pixel[1]];
const topLat = lastSelectionBounds[3];
const topCenterCoord = fromLonLat([centerLon, topLat]);
const topPixel = map.getPixelFromCoordinate(topCenterCoord);
selectionBoxTopCenterPixel = [topPixel[0], topPixel[1]];
}
function updateSelectedCameraPixel() {
if (!map || !selectedCamera) {
selectedCameraPixel = null;
return;
}
const coord = fromLonLat([selectedCamera.lon, selectedCamera.lat]);
const pixel = map.getPixelFromCoordinate(coord);
selectedCameraPixel = [pixel[0], pixel[1]];
}
function finishBoxSelection(bounds: [number, number, number, number]) {
@@ -680,9 +802,19 @@
function handleBasemapChange(event: Event) {
const target = event.target as HTMLSelectElement;
basemap = target.value as 'dark' | 'light' | 'satellite';
if (baseLayer && basemapSources) {
baseLayer.setSource(basemapSources[basemap]);
basemap = target.value as 'dark' | 'light' | 'satellite' | 'custom';
basemapPreference.set(basemap);
if (baseLayer) {
if (basemap === 'custom' && $customTileUrl) {
baseLayer.setSource(
new XYZ({
url: $customTileUrl,
crossOrigin: 'anonymous',
})
);
} else if (basemapSources && basemap !== 'custom') {
baseLayer.setSource(basemapSources[basemap as keyof typeof basemapSources]);
}
}
}
@@ -719,6 +851,15 @@
}
function handleLocationSearchKeydown(event: KeyboardEvent) {
if (isMobile && event.key === 'Escape' && locationSearchModalOpen) {
locationSearchModalOpen = false;
locationSearchQuery = '';
locationSearchResults = [];
locationSearchOpen = false;
locationSearchSelectedIndex = -1;
locationSearchLoading = false;
return;
}
if (!locationSearchOpen || locationSearchResults.length === 0) {
if (event.key === 'Escape') {
locationSearchQuery = '';
@@ -775,6 +916,9 @@
locationSearchOpen = false;
locationSearchSelectedIndex = -1;
locationSearchLoading = false;
if (isMobile) {
locationSearchModalOpen = false;
}
}
function handleLocationSearchBlur() {
@@ -811,8 +955,8 @@
params.delete('bbox');
params.delete('count');
}
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
const newUrl = `?${params.toString()}`;
replaceState(newUrl, {});
}
function isValidLatitude(lat: number): boolean {
@@ -916,19 +1060,37 @@
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<header
class="bg-bg-secondary border-b border-border-color px-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0"
class="bg-bg-secondary border-b border-border-color px-3 sm:px-6 py-2 sm:py-3 flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center flex-shrink-0"
>
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
<img src={favicon} alt="Surveilled logo" class="h-5 w-5" />
Surveilled
</h1>
<div class="text-text-secondary text-xs sm:text-sm flex items-center gap-2">
<span class="text-accent-red-light font-semibold">{cameras.length}</span>
<span>cameras</span>
<div
class="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full sm:w-auto gap-1.5 sm:gap-2"
>
<h1
class="text-base sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2"
>
<a
href="https://git.quad4.io/Quad4-Software/Surveilled"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 sm:gap-2 hover:opacity-80 transition-opacity"
>
<img src={favicon} alt="Surveilled logo" class="h-4 w-4 sm:h-5 sm:w-5" />
Surveilled
</a>
</h1>
{#if lastUpdated !== 'never'}
<span class="text-xs">{lastUpdated}</span>
<div class="text-text-secondary text-[10px] sm:text-xs">
{lastUpdated}
</div>
{/if}
</div>
<input
class="w-full sm:w-64 bg-bg-primary border border-border-color rounded px-3 py-1.5 text-xs sm:text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
type="text"
placeholder="Filter cameras..."
bind:value={filterQuery}
on:input={handleFilterInput}
/>
</header>
<div
class="h-1 bg-accent-red transition-all duration-300 {fetchInFlight > 0 ? 'w-full' : 'w-0'}"
@@ -947,6 +1109,16 @@
</div>
</div>
{/if}
{#if selectionBoxTopCenterPixel && lastSelectionBounds && cameras.length > 0}
<div
class="absolute z-[1001] pointer-events-none"
style="left: {selectionBoxTopCenterPixel[0]}px; top: {selectionBoxTopCenterPixel[1]}px; transform: translate(-50%, -100%);"
>
<div class="text-accent-red text-xs font-semibold whitespace-nowrap px-2 py-0.5">
{cameras.length} cameras
</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"
@@ -966,10 +1138,10 @@
</div>
{/if}
<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"
class="absolute top-3 sm:top-1.5 left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1100] 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"
class="relative w-full sm:w-auto sm:min-w-[300px] max-w-[calc(100vw-8rem)] sm:max-w-none hidden sm:block"
>
<div class="relative">
<div
@@ -983,7 +1155,7 @@
</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"
class="w-full bg-bg-secondary/95 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}
@@ -1017,8 +1189,16 @@
</div>
</div>
<div
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"
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"
>
<button
class="sm:hidden toolbar-btn"
title="Search location"
on:click={() => (locationSearchModalOpen = true)}
>
<Search size={16} />
</button>
<div class="sm:hidden w-px h-4 bg-border-color mx-0.5"></div>
<div class="relative">
{#if showBoxTooltip}
<div
@@ -1053,6 +1233,176 @@
>
<Ruler size={16} />
</button>
<div class="w-px h-4 bg-border-color mx-0.5"></div>
<div class="relative flex items-center">
<button
class="toolbar-btn {showEndpointSettings ? 'active' : ''}"
title="Settings"
on:click={() => (showEndpointSettings = !showEndpointSettings)}
>
<Settings size={16} />
</button>
{#if showEndpointSettings}
<div
class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-[1100] bg-bg-secondary border border-border-color rounded-lg p-0 shadow-xl w-80 text-text-primary overflow-hidden"
>
<div class="flex border-b border-border-color">
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'overpass'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'overpass')}
>
Overpass
</button>
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'nominatim'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'nominatim')}
>
Nominatim
</button>
<button
class="flex-1 px-2 py-2 text-[9px] font-semibold transition-colors {settingsTab ===
'tiles'
? 'bg-white/5 text-accent-red-light border-b border-accent-red'
: 'text-text-secondary hover:text-text-primary'}"
on:click={() => (settingsTab = 'tiles')}
>
Tiles
</button>
</div>
<div class="p-3">
{#if settingsTab === 'overpass'}
<div class="flex flex-col gap-1.5">
{#each OVERPASS_ENDPOINTS as endpoint}
<label
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
>
<input
type="radio"
name="overpass-endpoint-toolbar"
value={endpoint}
checked={$overpassEndpoint === endpoint}
on:change={() => {
overpassEndpoint.set(endpoint);
customEndpoint = '';
}}
class="accent-accent-red"
/>
<span class="truncate text-[10px] opacity-90">{endpoint.split('/')[2]}</span
>
</label>
{/each}
<div class="mt-1 border-t border-border-color/50 pt-2 flex flex-col gap-1.5">
<label
class="flex items-center gap-2 cursor-pointer hover:bg-white/5 p-1.5 rounded transition-colors"
>
<input
type="radio"
name="overpass-endpoint-toolbar"
value="custom"
checked={customEndpoint !== '' && $overpassEndpoint === customEndpoint}
on:change={() => {
if (customEndpoint) overpassEndpoint.set(customEndpoint);
}}
class="accent-accent-red"
/>
<span class="text-[10px] opacity-90 font-medium">Custom Endpoint</span>
</label>
<input
type="text"
placeholder="https://your-overpass/api/interpreter"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={customEndpoint}
on:input={() => {
if (customEndpoint.startsWith('http')) {
overpassEndpoint.set(customEndpoint);
}
}}
/>
</div>
</div>
<p
class="mt-3 text-[9px] opacity-50 leading-relaxed border-t border-border-color/30 pt-2"
>
Preferred endpoint for camera data. Automatic fallback will occur if selected
service is unavailable.
</p>
{:else if settingsTab === 'nominatim'}
<div class="flex flex-col gap-2">
<div class="text-[10px] font-medium text-text-secondary mb-1">
Nominatim Search Endpoint
</div>
<input
type="text"
placeholder="https://nominatim.openstreetmap.org/search"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={nominatimEndpointInput}
on:input={() => {
if (nominatimEndpointInput.startsWith('http')) {
nominatimEndpoint.set(nominatimEndpointInput);
}
}}
/>
<div class="bg-bg-primary/30 rounded p-2 border border-border-color/30">
<div class="text-[9px] font-semibold text-accent-red-light mb-1 uppercase">
Default: OSM Public
</div>
<code class="text-[9px] break-all opacity-70">
https://nominatim.openstreetmap.org/search
</code>
</div>
<p class="text-[9px] opacity-50 leading-relaxed mt-1">
Used for geocoding location searches. Ensure the endpoint supports the
standard Nominatim query parameters.
</p>
</div>
{:else}
<div class="flex flex-col gap-2">
<div class="text-[10px] font-medium text-text-secondary mb-1">
Tile URL (XYZ format)
</div>
<input
type="text"
placeholder="https://tile.openstreetmap.org/&#123;z&#125;/&#123;x&#125;/&#123;y&#125;.png"
class="w-full bg-bg-primary/50 border border-border-color rounded px-2 py-1.5 text-[10px] text-text-primary placeholder:opacity-30 focus:outline-none focus:ring-1 focus:ring-accent-red transition-all"
bind:value={customTileUrlInput}
on:input={() => {
customTileUrl.set(customTileUrlInput);
if (basemap === 'custom' && baseLayer) {
baseLayer.setSource(
new XYZ({
url: customTileUrlInput,
crossOrigin: 'anonymous',
})
);
}
}}
/>
<div class="bg-bg-primary/30 rounded p-2 border border-border-color/30">
<div class="text-[9px] font-semibold text-accent-red-light mb-1 uppercase">
Example: OpenStreetMap
</div>
<code class="text-[9px] break-all opacity-70">
https://tile.openstreetmap.org/&#123;z&#125;/&#123;x&#125;/&#123;y&#125;.png
</code>
</div>
<p class="text-[9px] opacity-50 leading-relaxed mt-1">
To use these tiles, select "Custom Tiles" from the basemap dropdown in the
toolbar.
</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
<select
class="toolbar-select text-text-primary bg-bg-secondary border-none text-xs px-2 py-1 cursor-pointer"
title="Basemap"
@@ -1062,8 +1412,88 @@
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="satellite">Satellite</option>
<option value="custom">Custom Tiles</option>
</select>
</div>
{#if locationSearchModalOpen && isMobile}
<div
class="fixed inset-0 z-[1200] bg-black/50 backdrop-blur-sm flex items-start justify-center pt-4 px-4"
role="dialog"
aria-modal="true"
aria-label="Search location"
tabindex="-1"
on:click={(e) => {
if (e.target === e.currentTarget) {
locationSearchModalOpen = false;
}
}}
on:keydown={(e) => {
if (e.key === 'Escape') locationSearchModalOpen = false;
}}
>
<div
class="w-full max-w-md bg-bg-secondary border border-border-color rounded-lg shadow-xl overflow-hidden"
>
<div class="p-4 border-b border-border-color flex items-center justify-between">
<h3 class="text-base font-semibold text-text-primary">Search Location</h3>
<button
class="text-text-secondary hover:text-text-primary transition-colors"
on:click={() => (locationSearchModalOpen = false)}
>
×
</button>
</div>
<div class="p-4">
<div class="relative mb-4">
<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={18} stroke-width={2} />
{:else}
<Search class="text-text-secondary" size={18} stroke-width={2} />
{/if}
</div>
<input
type="text"
class="w-full bg-bg-primary border border-border-color rounded-lg pl-10 pr-3 py-2.5 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-2 focus:ring-accent-red"
placeholder="Search location..."
bind:value={locationSearchQuery}
on:input={handleLocationSearchInput}
on:keydown={handleLocationSearchKeydown}
bind:this={locationSearchInput}
/>
</div>
{#if locationSearchResults.length > 0}
<div class="max-h-[60vh] overflow-y-auto space-y-1">
{#each locationSearchResults as result, index}
<button
class="w-full text-left px-4 py-3 rounded-lg hover:bg-bg-primary transition-colors border border-transparent hover:border-border-color {index ===
locationSearchSelectedIndex
? 'bg-bg-primary border-border-color'
: ''}"
on:click={() => selectLocation(result)}
>
<div class="text-sm text-text-primary font-medium">
{result.display_name}
</div>
<div class="text-xs text-text-secondary mt-1">
{result.class} · {result.type}
</div>
</button>
{/each}
</div>
{:else if locationSearchQuery && !locationSearchLoading}
<div class="text-center py-8 text-text-secondary text-sm">No results found</div>
{:else if !locationSearchQuery}
<div class="text-center py-8 text-text-secondary text-sm">
Start typing to search for a location
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if showBoxTooltip && boxTooltipPosition}
<div
class="absolute z-[1300] pointer-events-none"
@@ -1089,94 +1519,76 @@
</div>
{/if}
</div>
<button
class="sm:hidden fixed bottom-4 left-4 z-[1100] bg-bg-secondary border border-border-color rounded-full px-3 py-2 text-xs font-semibold text-text-primary shadow-lg"
on:click={() => (infoOpen = !infoOpen)}
>
{infoOpen ? 'Hide info' : 'Show info'}
</button>
<div
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">
<h2 class="text-base font-semibold text-text-primary">Surveillance Camera Map</h2>
<p class="text-sm text-text-secondary">
Click on camera markers to view details. Use "Select Area" to choose a specific region to
search.
</p>
<div class="flex items-center gap-2">
<input
class="w-full bg-bg-primary border border-border-color rounded px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary/70 focus:outline-none focus:ring-1 focus:ring-accent-red"
type="text"
placeholder="Filter by notes, type, operator, or direction"
bind:value={filterQuery}
on:input={handleFilterInput}
/>
</div>
{#if statusMessage}
<div class="status-message {statusError ? 'error' : ''}">
{statusMessage}
{#if selectedCamera && selectedCameraPixel}
<div
class="absolute z-[1200] pointer-events-none"
style="left: {selectedCameraPixel[0]}px; top: {selectedCameraPixel[1] +
20}px; transform: translateX(-50%);"
>
<div
class="bg-bg-secondary border border-border-color rounded-lg shadow-xl text-text-primary max-w-[280px] sm:max-w-sm pointer-events-auto"
>
<div class="p-3 border-b border-border-color">
<h3 class="text-sm font-semibold text-accent-red-light">Surveillance Camera</h3>
</div>
{/if}
{#if selectedCamera}
<div class="camera-item">
<h3 class="text-sm font-semibold text-accent-red-light mb-2">Surveillance Camera</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-xs">
<div class="p-3 space-y-2">
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Type:</strong>
{selectedCamera.type || 'Unknown'}
<div class="text-text-secondary">{selectedCamera.type || 'Unknown'}</div>
</div>
{#if selectedCamera.operator}
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Operator:</strong>
{selectedCamera.operator}
<div class="text-text-secondary">{selectedCamera.operator}</div>
</div>
{/if}
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Lat:</strong>
{selectedCamera.lat.toFixed(5)}
<div class="text-text-secondary">{selectedCamera.lat.toFixed(5)}</div>
</div>
<div class="bg-bg-primary border border-border-color rounded p-2">
<strong class="text-text-primary">Lon:</strong>
{selectedCamera.lon.toFixed(5)}
<div class="text-text-secondary">{selectedCamera.lon.toFixed(5)}</div>
</div>
</div>
{#if selectedCamera.direction}
<div class="mt-2 p-2 bg-bg-secondary border border-border-color rounded">
<div class="text-sm text-text-primary font-medium">
Direction: {directionLabel(selectedCamera.direction)}
</div>
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
<strong class="text-text-primary">Direction:</strong>
<div class="text-text-secondary">{directionLabel(selectedCamera.direction)}</div>
</div>
{/if}
{#if selectedCamera.description}
<div class="mt-2 bg-bg-primary border border-border-color rounded p-2 text-xs">
<div class="bg-bg-primary border border-border-color rounded p-2 text-xs">
<strong class="text-text-primary">Notes:</strong>
{selectedCamera.description}
<div class="text-text-secondary mt-1">{selectedCamera.description}</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
</main>
<footer
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary relative"
class="bg-bg-secondary border-border-color px-4 flex-shrink-0 text-xs text-text-secondary relative transition-all duration-300 {footerCollapsed
? 'py-0 h-0 min-h-0 border-t-0'
: 'py-3 border-t'}"
>
{#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}>
<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 z-[10]"
on:click={() => {
footerCollapsed = !footerCollapsed;
footerCollapsedPref.set(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>
<div class="max-w-7xl mx-auto space-y-2" class:hidden={footerCollapsed}>
<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">
@@ -1214,26 +1626,3 @@
</div>
</footer>
</div>
<style>
.status-message {
font-size: 0.875rem;
line-height: 1.25rem;
padding: 0.5rem;
border-radius: 0.25rem;
border-width: 1px;
border-color: #262626;
background-color: #0a0a0a;
color: #a3a3a3;
}
.status-message.error {
border-color: #dc2626;
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
.camera-item > * + * {
margin-top: 0.5rem;
}
</style>