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:
@@ -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/{z}/{x}/{y}.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/{z}/{x}/{y}.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>
|
||||
|
||||
Reference in New Issue
Block a user