0.1.0
This commit is contained in:
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
dist
|
||||
.DS_Store
|
||||
|
||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
FROM cgr.dev/chainguard/node:latest-dev AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
RUN npm install --save-dev @sveltejs/adapter-node@latest
|
||||
|
||||
COPY --chown=node:node . .
|
||||
COPY --chown=node:node svelte.config.docker.js svelte.config.js
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM cgr.dev/chainguard/node:latest AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
|
||||
RUN npm install --omit=dev && \
|
||||
npm cache clean --force
|
||||
|
||||
COPY --from=builder --chown=node:node /app/build ./build
|
||||
COPY --from=builder --chown=node:node /app/package.json ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
CMD ["build/index.js"]
|
||||
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Quad4.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
32
Makefile
Normal file
32
Makefile
Normal file
@@ -0,0 +1,32 @@
|
||||
.PHONY: help install dev build preview check lint format clean
|
||||
|
||||
help:
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Available targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
install:
|
||||
npm install
|
||||
|
||||
dev:
|
||||
npm run dev
|
||||
|
||||
build:
|
||||
npm run build
|
||||
|
||||
preview:
|
||||
npm run preview
|
||||
|
||||
check:
|
||||
npm run check
|
||||
|
||||
lint:
|
||||
npm run lint
|
||||
|
||||
format:
|
||||
npm run format
|
||||
|
||||
clean:
|
||||
rm -rf .svelte-kit build node_modules/.vite
|
||||
|
||||
61
eslint.config.js
Normal file
61
eslint.config.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import js from '@eslint/js';
|
||||
import tsPlugin from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sveltePlugin from 'eslint-plugin-svelte';
|
||||
import svelteParser from 'svelte-eslint-parser';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,mjs,cjs,ts,svelte}'],
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
globals: {
|
||||
fetch: 'readonly',
|
||||
caches: 'readonly',
|
||||
URL: 'readonly',
|
||||
console: 'readonly',
|
||||
HTMLElement: 'readonly',
|
||||
HTMLImageElement: 'readonly',
|
||||
navigator: 'readonly',
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
Blob: 'readonly',
|
||||
Event: 'readonly',
|
||||
MouseEvent: 'readonly',
|
||||
HTMLSelectElement: 'readonly',
|
||||
HTMLDivElement: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': tsPlugin,
|
||||
svelte: sveltePlugin,
|
||||
},
|
||||
rules: {
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parser: svelteParser,
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
svelte: sveltePlugin,
|
||||
},
|
||||
rules: {
|
||||
...sveltePlugin.configs.recommended.rules,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**'],
|
||||
},
|
||||
];
|
||||
4257
package-lock.json
generated
Normal file
4257
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "surveilled",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.0",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
"@typescript-eslint/parser": "^8.50.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-eslint-parser": "^1.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"autoprefixer": "^10.4.23",
|
||||
"lucide-svelte": "^0.562.0",
|
||||
"ol": "^10.7.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
73
src/app.css
Normal file
73
src/app.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src:
|
||||
local(''),
|
||||
url('/fonts/nunito-v16-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply bg-bg-primary text-text-primary;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 p-0 min-h-screen bg-bg-primary text-text-primary font-sans antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply bg-accent-red text-text-primary border-none px-4 py-2 rounded-md cursor-pointer text-sm font-medium flex items-center gap-2 transition-colors;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
@apply bg-accent-red-dark;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
@apply bg-accent-red-dark;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
@apply bg-accent-red-dark;
|
||||
}
|
||||
|
||||
.btn-icon-only {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
@apply p-1.5 rounded text-text-primary hover:bg-bg-primary/50 transition-colors cursor-pointer border-none bg-transparent;
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
@apply bg-accent-red/20 text-accent-red-light;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.loading-bar {
|
||||
@apply h-1 bg-accent-red transition-all duration-300;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.loading-bar.active {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
13
src/app.d.ts
vendored
Normal file
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
117
src/lib/api.ts
Normal file
117
src/lib/api.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
OVERPASS_ENDPOINTS,
|
||||
OVERPASS_TIMEOUT,
|
||||
OVERPASS_MAX_BOUNDS_SPAN,
|
||||
ERROR_MESSAGES,
|
||||
} from './constants';
|
||||
|
||||
export interface Camera {
|
||||
lon: number;
|
||||
lat: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
operator?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OverpassElement {
|
||||
type: 'node' | 'way';
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OverpassResponse {
|
||||
elements: OverpassElement[];
|
||||
}
|
||||
|
||||
export async function fetchOverpassWithFallback(query: string): Promise<OverpassResponse> {
|
||||
let lastErr: Error | null = null;
|
||||
for (const endpoint of OVERPASS_ENDPOINTS) {
|
||||
try {
|
||||
const url = `${endpoint}?data=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
lastErr = new Error(`Overpass error ${response.status}: ${text.slice(0, 200)}`);
|
||||
if (response.status === 429) continue;
|
||||
else continue;
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (err) {
|
||||
lastErr = err as Error;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw lastErr || new Error('Overpass request failed');
|
||||
}
|
||||
|
||||
export function isBoundsTooLarge(bounds: [number, number, number, number] | null): boolean {
|
||||
if (!bounds) return true;
|
||||
const [minLon, minLat, maxLon, maxLat] = bounds;
|
||||
const latSpan = Math.abs(maxLat - minLat);
|
||||
const lonSpan = Math.abs(maxLon - minLon);
|
||||
return latSpan > OVERPASS_MAX_BOUNDS_SPAN || lonSpan > OVERPASS_MAX_BOUNDS_SPAN;
|
||||
}
|
||||
|
||||
export async function fetchSurveillanceCameras(
|
||||
bounds: [number, number, number, number] | null
|
||||
): Promise<Camera[]> {
|
||||
if (!bounds) {
|
||||
throw new Error(ERROR_MESSAGES.NO_BOUNDS);
|
||||
}
|
||||
|
||||
if (isBoundsTooLarge(bounds)) {
|
||||
throw new Error(ERROR_MESSAGES.BOUNDS_TOO_LARGE);
|
||||
}
|
||||
|
||||
const [minLon, minLat, maxLon, maxLat] = bounds;
|
||||
|
||||
const overpassQuery = `
|
||||
[out:json][timeout:${OVERPASS_TIMEOUT}];
|
||||
(
|
||||
node["surveillance"="camera"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
node["man_made"="surveillance"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["surveillance"="camera"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["man_made"="surveillance"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
);
|
||||
out center meta;
|
||||
`;
|
||||
|
||||
const data = await fetchOverpassWithFallback(overpassQuery);
|
||||
const cameras: Camera[] = [];
|
||||
|
||||
data.elements.forEach((element) => {
|
||||
let lon: number | undefined, lat: number | undefined;
|
||||
|
||||
if (element.type === 'node') {
|
||||
lon = element.lon;
|
||||
lat = element.lat;
|
||||
} else if (element.type === 'way' && element.center) {
|
||||
lon = element.center.lon;
|
||||
lat = element.center.lat;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!lon || !lat) return;
|
||||
|
||||
const direction = element.tags['camera:direction'] || element.tags.direction || null;
|
||||
const directionValue = direction ? direction.toString().split(';')[0].trim() : null;
|
||||
|
||||
const camera: Camera = {
|
||||
lon,
|
||||
lat,
|
||||
type: element.tags.surveillance || element.tags.man_made || 'surveillance',
|
||||
direction: directionValue,
|
||||
operator: element.tags.operator,
|
||||
description: element.tags.description || element.tags.note || '',
|
||||
};
|
||||
|
||||
cameras.push(camera);
|
||||
});
|
||||
|
||||
return cameras;
|
||||
}
|
||||
4
src/lib/assets/favicon.svg
Normal file
4
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Z"/>
|
||||
<circle cx="12" cy="12" r="3" fill="#ef4444" stroke="#ef4444"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 285 B |
61
src/lib/constants.ts
Normal file
61
src/lib/constants.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Overpass API Configuration
|
||||
export const OVERPASS_ENDPOINTS = [
|
||||
'https://overpass-api.de/api/interpreter',
|
||||
'https://overpass.kumi.systems/api/interpreter',
|
||||
] as const;
|
||||
|
||||
export const OVERPASS_TIMEOUT = 25; // seconds
|
||||
export const OVERPASS_MAX_BOUNDS_SPAN = 10; // degrees
|
||||
|
||||
// Tile Cache Configuration
|
||||
export const TILE_CACHE_NAME = 'tile-cache-v1';
|
||||
|
||||
// Map Configuration
|
||||
export const MAP_DEFAULT_CENTER: [number, number] = [0, 0];
|
||||
export const MAP_DEFAULT_ZOOM = 2;
|
||||
export const MAP_DEFAULT_ZOOM_USER = 15; // Zoom level when using geolocation
|
||||
|
||||
// Basemap URLs
|
||||
export const BASEMAP_URLS = {
|
||||
dark: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
light: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||
satellite:
|
||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
} as const;
|
||||
|
||||
// Camera Icon Configuration
|
||||
export const CAMERA_ICON_SIZE = 24;
|
||||
export const CAMERA_ICON_SVG = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="${CAMERA_ICON_SIZE}" height="${CAMERA_ICON_SIZE}" viewBox="0 0 24 24" fill="#dc2626" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16.75 12h3.632a1 1 0 0 1 .894 1.447l-2.034 4.069a1 1 0 0 1-1.708.134l-2.124-2.97"/>
|
||||
<path d="M17.106 9.053a1 1 0 0 1 .447 1.341l-3.106 6.211a1 1 0 0 1-1.342.447L3.61 12.3a2.92 2.92 0 0 1-1.3-3.91L3.69 5.6a2.92 2.92 0 0 1 3.92-1.3z"/>
|
||||
<path d="M2 19h3.76a2 2 0 0 0 1.8-1.1L9 15"/>
|
||||
<path d="M2 21v-4"/>
|
||||
<path d="M7 9h.01"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Field of View (FOV) Configuration
|
||||
export const FOV_DEFAULT_ANGLE = 60; // degrees
|
||||
export const FOV_DEFAULT_RANGE = 120; // meters
|
||||
export const FOV_MIN_ZOOM = 10; // Hide FOV cones below this zoom level
|
||||
|
||||
// Map Layer Configuration
|
||||
export const LAYER_RENDER_BUFFER = 64;
|
||||
|
||||
// Error Messages
|
||||
export const ERROR_MESSAGES = {
|
||||
NO_BOUNDS: 'Draw a box to search for cameras.',
|
||||
BOUNDS_TOO_LARGE: 'Selection is too large. Please draw a smaller box (under ~10° span).',
|
||||
OVERPASS_RATE_LIMIT: 'Overpass rate limit. Try again soon or reduce selection size.',
|
||||
OVERPASS_FAILED: 'Overpass request failed',
|
||||
} as const;
|
||||
|
||||
// Status Messages
|
||||
export const STATUS_MESSAGES = {
|
||||
DRAW_BOX: 'Draw a box to load cameras.',
|
||||
DRAW_BOX_AREA: 'Draw a box to load cameras in your area.',
|
||||
SELECT_AREA: 'Click to set the first corner of the selection box. Press Esc to cancel.',
|
||||
SELECT_SECOND_CORNER: 'Click again to set the second corner and complete the selection.',
|
||||
RELEASE_TO_SEARCH: 'Release to run the search in the selected box.',
|
||||
} as const;
|
||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
412
src/lib/map.ts
Normal file
412
src/lib/map.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import Map from 'ol/Map.js';
|
||||
import View from 'ol/View.js';
|
||||
import { fromLonLat, toLonLat, transformExtent } from 'ol/proj.js';
|
||||
import TileLayer from 'ol/layer/Tile.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
import XYZ from 'ol/source/XYZ.js';
|
||||
import VectorSource from 'ol/source/Vector.js';
|
||||
import Cluster from 'ol/source/Cluster.js';
|
||||
import Tile from 'ol/Tile.js';
|
||||
import ImageTile from 'ol/ImageTile.js';
|
||||
import { Style, Icon, Circle, Fill, Stroke, Text } from 'ol/style.js';
|
||||
import Feature from 'ol/Feature.js';
|
||||
import Point from 'ol/geom/Point.js';
|
||||
import Polygon from 'ol/geom/Polygon.js';
|
||||
import Draw from 'ol/interaction/Draw.js';
|
||||
import { createBox } from 'ol/interaction/Draw.js';
|
||||
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||
import type { StyleFunction } from 'ol/style/Style.js';
|
||||
import type { GeoJSONFeatureCollection } from 'ol/format/GeoJSON.js';
|
||||
import {
|
||||
TILE_CACHE_NAME,
|
||||
MAP_DEFAULT_CENTER,
|
||||
MAP_DEFAULT_ZOOM,
|
||||
BASEMAP_URLS,
|
||||
CAMERA_ICON_SVG,
|
||||
FOV_DEFAULT_ANGLE,
|
||||
FOV_DEFAULT_RANGE,
|
||||
FOV_MIN_ZOOM,
|
||||
LAYER_RENDER_BUFFER,
|
||||
} from './constants';
|
||||
|
||||
export interface Camera {
|
||||
lon: number;
|
||||
lat: number;
|
||||
type: string;
|
||||
direction: string | null;
|
||||
operator?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const canUseCacheApi = typeof caches !== 'undefined';
|
||||
|
||||
async function cachedTileLoad(imageTile: Tile, src: string) {
|
||||
if (!(imageTile instanceof ImageTile)) {
|
||||
return;
|
||||
}
|
||||
const image = imageTile.getImage();
|
||||
if (!(image instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
if (!canUseCacheApi) {
|
||||
image.src = src;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cache = await caches.open(TILE_CACHE_NAME);
|
||||
const cached = await cache.match(src);
|
||||
if (cached) {
|
||||
image.src = URL.createObjectURL(await cached.blob());
|
||||
return;
|
||||
}
|
||||
const response = await fetch(src, { mode: 'cors' });
|
||||
if (response.ok) {
|
||||
cache.put(src, response.clone());
|
||||
image.src = URL.createObjectURL(await response.blob());
|
||||
} else {
|
||||
image.src = src;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Tile cache fetch failed, falling back', err);
|
||||
image.src = src;
|
||||
}
|
||||
}
|
||||
|
||||
const cameraIconSVGEncoded = encodeURIComponent(CAMERA_ICON_SVG);
|
||||
|
||||
const cameraBaseIcon = new Icon({
|
||||
src: `data:image/svg+xml;utf8,${cameraIconSVGEncoded}`,
|
||||
anchor: [0.5, 0.5],
|
||||
anchorXUnits: 'fraction',
|
||||
anchorYUnits: 'fraction',
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
export function createMap(target: HTMLElement): Map {
|
||||
const tempSource = new XYZ({
|
||||
url: '',
|
||||
tileLoadFunction: () => {},
|
||||
});
|
||||
const map = new Map({
|
||||
target,
|
||||
layers: [new TileLayer({ source: tempSource })],
|
||||
view: new View({
|
||||
center: fromLonLat(MAP_DEFAULT_CENTER),
|
||||
zoom: MAP_DEFAULT_ZOOM,
|
||||
}),
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
export function createBasemapSources() {
|
||||
return {
|
||||
dark: new XYZ({
|
||||
url: BASEMAP_URLS.dark,
|
||||
attributions: ['© OpenStreetMap contributors © CARTO'],
|
||||
tileLoadFunction: cachedTileLoad,
|
||||
cacheSize: 4096,
|
||||
}),
|
||||
light: new XYZ({
|
||||
url: BASEMAP_URLS.light,
|
||||
attributions: ['© OpenStreetMap contributors © CARTO'],
|
||||
tileLoadFunction: cachedTileLoad,
|
||||
cacheSize: 4096,
|
||||
}),
|
||||
satellite: new XYZ({
|
||||
url: BASEMAP_URLS.satellite,
|
||||
attributions: ['© Esri, Maxar, Earthstar Geographics'],
|
||||
tileLoadFunction: cachedTileLoad,
|
||||
cacheSize: 4096,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCameraSource() {
|
||||
return new VectorSource();
|
||||
}
|
||||
|
||||
export function createClusterSource(cameraSource: VectorSource) {
|
||||
return new Cluster({
|
||||
distance: 50,
|
||||
source: cameraSource,
|
||||
});
|
||||
}
|
||||
|
||||
export function clusterStyle(feature: Feature) {
|
||||
const size = feature.get('features')?.length || 0;
|
||||
if (size > 1) {
|
||||
return new Style({
|
||||
image: new Circle({
|
||||
radius: 12,
|
||||
fill: new Fill({ color: '#ef4444' }),
|
||||
stroke: new Stroke({ color: '#ffffff', width: 2 }),
|
||||
}),
|
||||
text: new Text({
|
||||
text: String(size),
|
||||
fill: new Fill({ color: '#ffffff' }),
|
||||
font: 'bold 11px "Nunito", sans-serif',
|
||||
}),
|
||||
});
|
||||
}
|
||||
return new Style({
|
||||
image: cameraBaseIcon,
|
||||
});
|
||||
}
|
||||
|
||||
export function createCameraLayer(clusterSource: Cluster) {
|
||||
return new VectorLayer({
|
||||
source: clusterSource,
|
||||
style: clusterStyle as StyleFunction,
|
||||
updateWhileAnimating: false,
|
||||
updateWhileInteracting: false,
|
||||
declutter: true,
|
||||
renderBuffer: LAYER_RENDER_BUFFER,
|
||||
});
|
||||
}
|
||||
|
||||
export function createFovLayer() {
|
||||
return new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 1.5,
|
||||
lineDash: [8, 4],
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(239, 68, 68, 0.12)',
|
||||
}),
|
||||
}),
|
||||
updateWhileAnimating: false,
|
||||
updateWhileInteracting: false,
|
||||
declutter: false,
|
||||
renderBuffer: LAYER_RENDER_BUFFER,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSelectionLayer() {
|
||||
return new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2.5,
|
||||
lineDash: [5, 3],
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(239, 68, 68, 0.18)',
|
||||
}),
|
||||
}),
|
||||
updateWhileAnimating: false,
|
||||
updateWhileInteracting: false,
|
||||
declutter: true,
|
||||
renderBuffer: LAYER_RENDER_BUFFER,
|
||||
});
|
||||
}
|
||||
|
||||
export function createCameraMarker(camera: Camera): Feature {
|
||||
return new Feature({
|
||||
geometry: new Point(fromLonLat([camera.lon, camera.lat])),
|
||||
camera: camera,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDirection(direction: string | null): number | null {
|
||||
if (!direction) return null;
|
||||
|
||||
const dir = direction.toString().toLowerCase().trim();
|
||||
|
||||
const cardinalMap: Record<string, number> = {
|
||||
n: 0,
|
||||
north: 0,
|
||||
ne: 45,
|
||||
northeast: 45,
|
||||
'north-east': 45,
|
||||
e: 90,
|
||||
east: 90,
|
||||
se: 135,
|
||||
southeast: 135,
|
||||
'south-east': 135,
|
||||
s: 180,
|
||||
south: 180,
|
||||
sw: 225,
|
||||
southwest: 225,
|
||||
'south-west': 225,
|
||||
w: 270,
|
||||
west: 270,
|
||||
nw: 315,
|
||||
northwest: 315,
|
||||
'north-west': 315,
|
||||
};
|
||||
|
||||
if (cardinalMap[dir] !== undefined) {
|
||||
return cardinalMap[dir];
|
||||
}
|
||||
|
||||
const num = parseFloat(dir);
|
||||
if (!isNaN(num)) {
|
||||
return num;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDirectionLabel(angle: number | null): string {
|
||||
if (angle === null || angle === undefined) return 'Unknown';
|
||||
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const idx = Math.round(angle / 45) % 8;
|
||||
return directions[idx];
|
||||
}
|
||||
|
||||
export function createCameraFOV(
|
||||
lon: number,
|
||||
lat: number,
|
||||
direction: string | null,
|
||||
map: Map,
|
||||
showFov: boolean,
|
||||
fovAngle: number = FOV_DEFAULT_ANGLE,
|
||||
fovRange: number = FOV_DEFAULT_RANGE
|
||||
): Feature | null {
|
||||
if (!showFov) return null;
|
||||
const currentZoom = map.getView().getZoom();
|
||||
if (currentZoom === undefined || currentZoom < FOV_MIN_ZOOM) return null;
|
||||
const angle = parseDirection(direction);
|
||||
if (angle === null) return null;
|
||||
|
||||
const center = fromLonLat([lon, lat]);
|
||||
const metersPerDegreeLat = 111320;
|
||||
const metersPerDegreeLon = 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
const rangeDegreesLat = fovRange / metersPerDegreeLat;
|
||||
const rangeDegreesLon = fovRange / metersPerDegreeLon;
|
||||
|
||||
const startAngle = ((angle - fovAngle / 2) * Math.PI) / 180;
|
||||
const endAngle = ((angle + fovAngle / 2) * Math.PI) / 180;
|
||||
|
||||
const points = [center];
|
||||
|
||||
for (let i = 0; i <= 30; i++) {
|
||||
const currentAngle = startAngle + (endAngle - startAngle) * (i / 30);
|
||||
const dx = Math.sin(currentAngle) * rangeDegreesLon;
|
||||
const dy = Math.cos(currentAngle) * rangeDegreesLat;
|
||||
const point = fromLonLat([lon + dx, lat + dy]);
|
||||
points.push(point);
|
||||
}
|
||||
|
||||
points.push(center);
|
||||
|
||||
try {
|
||||
const fovPolygon = new Polygon([points]);
|
||||
return new Feature({
|
||||
geometry: fovPolygon,
|
||||
type: 'camera-fov',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error creating camera FOV:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getMapBounds(map: Map): [number, number, number, number] {
|
||||
const extent = map.getView().calculateExtent(map.getSize());
|
||||
const bottomLeft = toLonLat([extent[0], extent[1]]);
|
||||
const topRight = toLonLat([extent[2], extent[3]]);
|
||||
|
||||
return [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]];
|
||||
}
|
||||
|
||||
export function getBoundsFromExtent(extent: number[]): [number, number, number, number] {
|
||||
const bottomLeft = toLonLat([extent[0], extent[1]]);
|
||||
const topRight = toLonLat([extent[2], extent[3]]);
|
||||
|
||||
return [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]];
|
||||
}
|
||||
|
||||
export function createDrawInteraction(
|
||||
source: VectorSource,
|
||||
onDrawEnd: (bounds: [number, number, number, number], feature: Feature) => void,
|
||||
onDrawStart?: () => void
|
||||
): Draw {
|
||||
const draw = new Draw({
|
||||
source,
|
||||
type: 'Circle',
|
||||
geometryFunction: createBox(),
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2.5,
|
||||
lineDash: [5, 3],
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(239, 68, 68, 0.18)',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
draw.on('drawstart', () => {
|
||||
source.clear();
|
||||
onDrawStart?.();
|
||||
});
|
||||
|
||||
draw.on('drawend', (event) => {
|
||||
const geometry = event.feature.getGeometry();
|
||||
if (geometry) {
|
||||
const extent = geometry.getExtent();
|
||||
const bounds = getBoundsFromExtent(extent);
|
||||
onDrawEnd(bounds, event.feature);
|
||||
}
|
||||
});
|
||||
|
||||
return draw;
|
||||
}
|
||||
|
||||
export function buildGeoJson(
|
||||
cameraSource: VectorSource,
|
||||
cameras: Camera[],
|
||||
bounds: [number, number, number, number] | null
|
||||
): GeoJSONFeatureCollection & {
|
||||
bbox?: [number, number, number, number];
|
||||
metadata?: { count: number; bbox?: [number, number, number, number] };
|
||||
} {
|
||||
const format = new GeoJSON();
|
||||
const features = cameraSource.getFeatures();
|
||||
const geojson = format.writeFeaturesObject(features, {
|
||||
featureProjection: 'EPSG:3857',
|
||||
dataProjection: 'EPSG:4326',
|
||||
}) as GeoJSONFeatureCollection;
|
||||
return {
|
||||
...geojson,
|
||||
bbox: bounds,
|
||||
metadata: {
|
||||
count: cameras.length,
|
||||
bbox: bounds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function renderSelection(
|
||||
bounds: [number, number, number, number] | null,
|
||||
selectionLayer: VectorLayer<VectorSource>
|
||||
) {
|
||||
if (!bounds) return;
|
||||
const extent = transformExtent(
|
||||
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
||||
'EPSG:4326',
|
||||
'EPSG:3857'
|
||||
);
|
||||
const geometry = new Polygon([
|
||||
[
|
||||
[extent[0], extent[1]],
|
||||
[extent[2], extent[1]],
|
||||
[extent[2], extent[3]],
|
||||
[extent[0], extent[3]],
|
||||
[extent[0], extent[1]],
|
||||
],
|
||||
]);
|
||||
const feature = new Feature({
|
||||
geometry,
|
||||
});
|
||||
selectionLayer.getSource()?.clear();
|
||||
selectionLayer.getSource()?.addFeature(feature);
|
||||
}
|
||||
6
src/routes/+layout.svelte
Normal file
6
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import 'ol/ol.css';
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
879
src/routes/+page.svelte
Normal file
879
src/routes/+page.svelte
Normal file
@@ -0,0 +1,879 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import Map from 'ol/Map.js';
|
||||
import type MapBrowserEvent from 'ol/MapBrowserEvent.js';
|
||||
import { Style, Stroke, Fill } 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';
|
||||
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 VectorSource from 'ol/source/Vector.js';
|
||||
import Cluster from 'ol/source/Cluster.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
import TileLayer from 'ol/layer/Tile.js';
|
||||
import XYZ from 'ol/source/XYZ.js';
|
||||
import {
|
||||
createMap,
|
||||
createBasemapSources,
|
||||
createCameraSource,
|
||||
createClusterSource,
|
||||
createCameraLayer,
|
||||
createFovLayer,
|
||||
createSelectionLayer,
|
||||
createCameraMarker,
|
||||
createCameraFOV,
|
||||
buildGeoJson,
|
||||
renderSelection,
|
||||
formatDirectionLabel,
|
||||
type Camera,
|
||||
} from '$lib/map';
|
||||
import { fetchSurveillanceCameras } from '$lib/api';
|
||||
import {
|
||||
FOV_DEFAULT_ANGLE,
|
||||
FOV_DEFAULT_RANGE,
|
||||
MAP_DEFAULT_ZOOM_USER,
|
||||
STATUS_MESSAGES,
|
||||
ERROR_MESSAGES,
|
||||
} from '$lib/constants';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: Map | null = null;
|
||||
let cameras: Camera[] = [];
|
||||
let isBoxSelectMode = false;
|
||||
let lastSelectionBounds: [number, number, number, number] | null = null;
|
||||
let showFov = true;
|
||||
const fovAngle = FOV_DEFAULT_ANGLE;
|
||||
const fovRange = FOV_DEFAULT_RANGE;
|
||||
let filterQuery = '';
|
||||
let filteredCameras: Camera[] = [];
|
||||
let cursorLonLat: [number, number] | null = null;
|
||||
let isMeasureMode = false;
|
||||
let measureStart: [number, number] | null = null;
|
||||
let measureEnd: [number, number] | null = null;
|
||||
let measureDistanceKm = 0;
|
||||
let measureDistanceMiles = 0;
|
||||
let fetchInFlight = 0;
|
||||
let statusMessage = '';
|
||||
let statusError = false;
|
||||
let lastUpdated = 'never';
|
||||
let basemap: 'dark' | 'light' | 'satellite' = 'dark';
|
||||
let selectedCamera: Camera | null = null;
|
||||
let infoOpen = true;
|
||||
let isMobile = false;
|
||||
let boxStartPoint: [number, number] | null = null;
|
||||
let boxFeature: Feature | null = null;
|
||||
let boxMoveHandler: EventsKey | null = null;
|
||||
let cursorMoveHandler: EventsKey | null = null;
|
||||
|
||||
let cameraSource: VectorSource;
|
||||
let clusterSource: Cluster;
|
||||
let cameraLayer: VectorLayer<Cluster>;
|
||||
let fovLayer: VectorLayer<VectorSource>;
|
||||
let selectionLayer: VectorLayer<VectorSource>;
|
||||
let measureSource: VectorSource;
|
||||
let measureLayer: VectorLayer<VectorSource>;
|
||||
let baseLayer: TileLayer<XYZ> | null = null;
|
||||
let basemapSources: ReturnType<typeof createBasemapSources>;
|
||||
|
||||
onMount(() => {
|
||||
const updateIsMobile = () => {
|
||||
isMobile = window.matchMedia('(max-width: 640px)').matches;
|
||||
if (isMobile) infoOpen = false;
|
||||
};
|
||||
updateIsMobile();
|
||||
window.addEventListener('resize', updateIsMobile);
|
||||
|
||||
if (!mapContainer) return;
|
||||
|
||||
map = createMap(mapContainer);
|
||||
basemapSources = createBasemapSources();
|
||||
const firstLayer = map.getLayers().item(0);
|
||||
if (firstLayer instanceof TileLayer) {
|
||||
baseLayer = firstLayer as TileLayer<XYZ>;
|
||||
baseLayer.setSource(basemapSources.dark);
|
||||
}
|
||||
|
||||
cameraSource = createCameraSource();
|
||||
clusterSource = createClusterSource(cameraSource);
|
||||
cameraLayer = createCameraLayer(clusterSource);
|
||||
fovLayer = createFovLayer();
|
||||
selectionLayer = createSelectionLayer();
|
||||
measureSource = new VectorSource();
|
||||
measureLayer = new VectorLayer({
|
||||
source: measureSource,
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2,
|
||||
lineDash: [6, 4],
|
||||
}),
|
||||
}),
|
||||
updateWhileInteracting: true,
|
||||
});
|
||||
|
||||
map.addLayer(fovLayer);
|
||||
map.addLayer(cameraLayer);
|
||||
map.addLayer(selectionLayer);
|
||||
map.addLayer(measureLayer);
|
||||
|
||||
cursorMoveHandler = map.on('pointermove', (event) => {
|
||||
const lonLat = toLonLat(event.coordinate);
|
||||
cursorLonLat = [lonLat[0], lonLat[1]];
|
||||
if (isMeasureMode && measureStart && !measureEnd) {
|
||||
updateMeasurePreview(event.coordinate);
|
||||
}
|
||||
});
|
||||
|
||||
map.on('click', (event) => {
|
||||
if (isMeasureMode) {
|
||||
handleMeasureClick(event.coordinate);
|
||||
return;
|
||||
}
|
||||
if (isBoxSelectMode) {
|
||||
handleBoxClick(event);
|
||||
return;
|
||||
}
|
||||
|
||||
const feature = map!.forEachFeatureAtPixel(
|
||||
event.pixel,
|
||||
(feature, layer) => {
|
||||
if (layer === cameraLayer) {
|
||||
return feature;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
{ hitTolerance: 6 }
|
||||
);
|
||||
|
||||
if (feature) {
|
||||
const clustered = feature.get('features');
|
||||
if (clustered && clustered.length === 1) {
|
||||
const inner = clustered[0];
|
||||
if (inner.get('camera')) {
|
||||
selectedCamera = inner.get('camera');
|
||||
setStatusMessage('');
|
||||
}
|
||||
} else if (clustered && clustered.length > 1) {
|
||||
const target = (feature.getGeometry() as any)?.getCoordinates?.();
|
||||
const view = map!.getView();
|
||||
if (target) {
|
||||
view.animate({
|
||||
center: target,
|
||||
zoom: Math.min((view.getZoom() ?? MAP_DEFAULT_ZOOM_USER) + 1.5, 20),
|
||||
duration: 300,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
map.getView().on('change:resolution', () => {
|
||||
rebuildFovFromCameras();
|
||||
});
|
||||
|
||||
restoreStateFromUrl();
|
||||
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const lon = position.coords.longitude;
|
||||
const lat = position.coords.latitude;
|
||||
map!.getView().setCenter(fromLonLat([lon, lat]));
|
||||
map!.getView().setZoom(MAP_DEFAULT_ZOOM_USER);
|
||||
setStatusMessage(STATUS_MESSAGES.DRAW_BOX_AREA);
|
||||
},
|
||||
() => {
|
||||
setStatusMessage(STATUS_MESSAGES.DRAW_BOX);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
setStatusMessage(STATUS_MESSAGES.DRAW_BOX);
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && isBoxSelectMode) {
|
||||
disableBoxSelectMode();
|
||||
}
|
||||
if (event.key === 'b' || event.key === 'B') {
|
||||
toggleBoxSelectMode();
|
||||
}
|
||||
if (event.key === 'r' || event.key === 'R') {
|
||||
handleRefresh();
|
||||
}
|
||||
if (event.key === 'c' || event.key === 'C') {
|
||||
handleCopyGeoJson();
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('resize', updateIsMobile);
|
||||
if (cursorMoveHandler) {
|
||||
unByKey(cursorMoveHandler);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function setStatusMessage(message: string, isError = false) {
|
||||
statusMessage = message;
|
||||
statusError = isError;
|
||||
}
|
||||
|
||||
function setLoading(active: boolean) {
|
||||
if (active) {
|
||||
fetchInFlight += 1;
|
||||
} else {
|
||||
fetchInFlight = Math.max(0, fetchInFlight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLastUpdated() {
|
||||
lastUpdated = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function rebuildFovFromCameras(source: Camera[] = filteredCameras) {
|
||||
if (!map || !fovLayer) return;
|
||||
const fovSource = fovLayer.getSource();
|
||||
if (!fovSource) return;
|
||||
fovSource.clear();
|
||||
if (!showFov) return;
|
||||
const fovFeatures: Feature[] = [];
|
||||
source.forEach((cam) => {
|
||||
if (cam.direction && map) {
|
||||
const fovFeature = createCameraFOV(
|
||||
cam.lon,
|
||||
cam.lat,
|
||||
cam.direction,
|
||||
map,
|
||||
showFov,
|
||||
fovAngle,
|
||||
fovRange
|
||||
);
|
||||
if (fovFeature) fovFeatures.push(fovFeature);
|
||||
}
|
||||
});
|
||||
if (fovFeatures.length) {
|
||||
fovSource.addFeatures(fovFeatures);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilterAndRender() {
|
||||
if (!cameraSource) return;
|
||||
const query = filterQuery.trim().toLowerCase();
|
||||
filteredCameras = query
|
||||
? cameras.filter((cam) => {
|
||||
const fields = [
|
||||
cam.description || '',
|
||||
cam.type || '',
|
||||
cam.operator || '',
|
||||
cam.direction || '',
|
||||
directionLabel(cam.direction),
|
||||
];
|
||||
return fields.some((f) => f.toLowerCase().includes(query));
|
||||
})
|
||||
: cameras;
|
||||
|
||||
const features = filteredCameras.map((cam) => createCameraMarker(cam));
|
||||
cameraSource.clear();
|
||||
cameraSource.addFeatures(features);
|
||||
rebuildFovFromCameras(filteredCameras);
|
||||
updateLastUpdated();
|
||||
setStatusMessage('');
|
||||
}
|
||||
|
||||
function handleFilterInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
filterQuery = target.value;
|
||||
applyFilterAndRender();
|
||||
}
|
||||
|
||||
function directionLabel(direction: string | null) {
|
||||
if (!direction) return 'Unknown';
|
||||
const num = parseFloat(direction);
|
||||
if (!Number.isNaN(num)) {
|
||||
return `${formatDirectionLabel(num)} (${num}°)`;
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
|
||||
function haversineKm(a: [number, number], b: [number, number]) {
|
||||
const toRad = (v: number) => (v * Math.PI) / 180;
|
||||
const R = 6371; // km
|
||||
const dLat = toRad(b[1] - a[1]);
|
||||
const dLon = toRad(b[0] - a[0]);
|
||||
const lat1 = toRad(a[1]);
|
||||
const lat2 = toRad(b[1]);
|
||||
const sinLat = Math.sin(dLat / 2);
|
||||
const sinLon = Math.sin(dLon / 2);
|
||||
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||
return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
function handleMeasureClick(coordinate: number[]) {
|
||||
const lonLat = toLonLat(coordinate) as [number, number];
|
||||
if (!measureStart) {
|
||||
measureStart = lonLat;
|
||||
measureEnd = null;
|
||||
measureDistanceKm = 0;
|
||||
measureDistanceMiles = 0;
|
||||
setStatusMessage('Click second point to measure distance.');
|
||||
measureSource.clear();
|
||||
measureSource.addFeature(new Feature(new Point(fromLonLat(lonLat))));
|
||||
return;
|
||||
}
|
||||
measureEnd = lonLat;
|
||||
const km = haversineKm(measureStart, measureEnd);
|
||||
measureDistanceKm = km;
|
||||
measureDistanceMiles = km * 0.621371;
|
||||
setStatusMessage(`Distance: ${measureDistanceKm.toFixed(2)} km / ${measureDistanceMiles.toFixed(2)} mi`);
|
||||
|
||||
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)]))
|
||||
);
|
||||
}
|
||||
|
||||
function updateMeasurePreview(coordinate: number[]) {
|
||||
if (!isMeasureMode || !measureStart || measureEnd) return;
|
||||
const current = toLonLat(coordinate) as [number, number];
|
||||
const km = haversineKm(measureStart, current);
|
||||
measureDistanceKm = km;
|
||||
measureDistanceMiles = km * 0.621371;
|
||||
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)]))
|
||||
);
|
||||
}
|
||||
|
||||
function toggleMeasureMode() {
|
||||
isMeasureMode = !isMeasureMode;
|
||||
if (!isMeasureMode) {
|
||||
measureStart = null;
|
||||
measureEnd = null;
|
||||
measureDistanceKm = 0;
|
||||
measureDistanceMiles = 0;
|
||||
setStatusMessage('');
|
||||
measureSource?.clear();
|
||||
} else {
|
||||
setStatusMessage('Click two points to measure distance.');
|
||||
measureSource?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchCameras(bounds: [number, number, number, number] | null) {
|
||||
if (!bounds) {
|
||||
setStatusMessage(ERROR_MESSAGES.NO_BOUNDS, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
cameras = await fetchSurveillanceCameras(bounds);
|
||||
applyFilterAndRender();
|
||||
} catch (error: unknown) {
|
||||
console.error('Error fetching surveillance cameras:', error);
|
||||
const message =
|
||||
error instanceof Error && error.message?.includes('429')
|
||||
? ERROR_MESSAGES.OVERPASS_RATE_LIMIT
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'Unable to load cameras right now. Please retry or use a smaller box.';
|
||||
setStatusMessage(message, true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBoxSelectMode() {
|
||||
if (isBoxSelectMode) {
|
||||
disableBoxSelectMode();
|
||||
} else {
|
||||
enableBoxSelectMode();
|
||||
}
|
||||
}
|
||||
|
||||
function enableBoxSelectMode() {
|
||||
if (isBoxSelectMode || !map || !selectionLayer) return;
|
||||
const source = selectionLayer.getSource();
|
||||
if (!source) return;
|
||||
|
||||
isBoxSelectMode = true;
|
||||
source.clear();
|
||||
boxStartPoint = null;
|
||||
boxFeature = null;
|
||||
lastSelectionBounds = null;
|
||||
setStatusMessage(STATUS_MESSAGES.SELECT_AREA);
|
||||
if (map.getTargetElement()) {
|
||||
map.getTargetElement().style.cursor = 'crosshair';
|
||||
}
|
||||
updateUrlState();
|
||||
|
||||
boxMoveHandler = map.on('pointermove', (event) => {
|
||||
if (isBoxSelectMode && boxStartPoint) {
|
||||
updateBoxPreview(event.coordinate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleBoxClick(event: MapBrowserEvent<PointerEvent | KeyboardEvent | WheelEvent>) {
|
||||
if (!map || !selectionLayer) return;
|
||||
const source = selectionLayer.getSource();
|
||||
if (!source) return;
|
||||
|
||||
const coordinate = event.coordinate;
|
||||
const lonLat = toLonLat(coordinate);
|
||||
const lonLatTuple: [number, number] = [lonLat[0], lonLat[1]];
|
||||
|
||||
if (!boxStartPoint) {
|
||||
boxStartPoint = lonLatTuple;
|
||||
setStatusMessage(STATUS_MESSAGES.SELECT_SECOND_CORNER);
|
||||
} else {
|
||||
const bounds: [number, number, number, number] = [
|
||||
Math.min(boxStartPoint[0], lonLatTuple[0]),
|
||||
Math.min(boxStartPoint[1], lonLatTuple[1]),
|
||||
Math.max(boxStartPoint[0], lonLatTuple[0]),
|
||||
Math.max(boxStartPoint[1], lonLatTuple[1]),
|
||||
];
|
||||
|
||||
finishBoxSelection(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBoxPreview(endCoordinate: number[]) {
|
||||
if (!map || !selectionLayer || !boxStartPoint) return;
|
||||
const source = selectionLayer.getSource();
|
||||
if (!source) return;
|
||||
|
||||
const startCoord = fromLonLat(boxStartPoint);
|
||||
const endCoord = endCoordinate;
|
||||
|
||||
const minX = Math.min(startCoord[0], endCoord[0]);
|
||||
const maxX = Math.max(startCoord[0], endCoord[0]);
|
||||
const minY = Math.min(startCoord[1], endCoord[1]);
|
||||
const maxY = Math.max(startCoord[1], endCoord[1]);
|
||||
|
||||
const boxGeometry = new Polygon([
|
||||
[
|
||||
[minX, minY],
|
||||
[maxX, minY],
|
||||
[maxX, maxY],
|
||||
[minX, maxY],
|
||||
[minX, minY],
|
||||
],
|
||||
]);
|
||||
|
||||
if (boxFeature) {
|
||||
boxFeature.setGeometry(boxGeometry);
|
||||
} else {
|
||||
boxFeature = new Feature({
|
||||
geometry: boxGeometry,
|
||||
});
|
||||
boxFeature.setStyle(
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#ef4444',
|
||||
width: 2.5,
|
||||
lineDash: [5, 3],
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(239, 68, 68, 0.18)',
|
||||
}),
|
||||
})
|
||||
);
|
||||
source.clear();
|
||||
source.addFeature(boxFeature);
|
||||
}
|
||||
}
|
||||
|
||||
function finishBoxSelection(bounds: [number, number, number, number]) {
|
||||
if (!map || !selectionLayer) return;
|
||||
const source = selectionLayer.getSource();
|
||||
if (!source) return;
|
||||
|
||||
lastSelectionBounds = bounds;
|
||||
handleFetchCameras(bounds);
|
||||
disableBoxSelectMode(true);
|
||||
updateUrlState();
|
||||
setStatusMessage('');
|
||||
}
|
||||
|
||||
function disableBoxSelectMode(keepSelection = false) {
|
||||
if (!isBoxSelectMode || !map) return;
|
||||
isBoxSelectMode = false;
|
||||
|
||||
if (boxMoveHandler) {
|
||||
unByKey(boxMoveHandler);
|
||||
boxMoveHandler = null;
|
||||
}
|
||||
|
||||
if (!keepSelection && selectionLayer) {
|
||||
const source = selectionLayer.getSource();
|
||||
if (source) {
|
||||
source.clear();
|
||||
}
|
||||
boxStartPoint = null;
|
||||
boxFeature = null;
|
||||
lastSelectionBounds = null;
|
||||
}
|
||||
|
||||
if (map.getTargetElement()) {
|
||||
map.getTargetElement().style.cursor = '';
|
||||
}
|
||||
setStatusMessage('');
|
||||
updateUrlState();
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
if (lastSelectionBounds) {
|
||||
handleFetchCameras(lastSelectionBounds);
|
||||
updateUrlState();
|
||||
} else {
|
||||
setStatusMessage('Draw a box to refresh camera data.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyLink() {
|
||||
updateUrlState();
|
||||
navigator.clipboard
|
||||
.writeText(window.location.href)
|
||||
.then(() => {
|
||||
setStatusMessage('Link copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
setStatusMessage('Copy failed. You can manually copy the address bar.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCopyGeoJson() {
|
||||
try {
|
||||
const geojson = buildGeoJson(cameraSource, cameras, lastSelectionBounds);
|
||||
navigator.clipboard
|
||||
.writeText(JSON.stringify(geojson, null, 2))
|
||||
.then(() => {
|
||||
setStatusMessage('GeoJSON copied to clipboard');
|
||||
})
|
||||
.catch(() => {
|
||||
setStatusMessage('Copy failed. Try download instead.', true);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Copy GeoJSON failed', err);
|
||||
setStatusMessage('Copy failed. Try download instead.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadGeoJson() {
|
||||
try {
|
||||
const geojson = buildGeoJson(cameraSource, cameras, lastSelectionBounds);
|
||||
const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'surveillance.geojson';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('Download GeoJSON failed', err);
|
||||
setStatusMessage('Download failed.', true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBasemapChange(event: Event) {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
basemap = target.value as 'dark' | 'light' | 'satellite';
|
||||
if (baseLayer && basemapSources) {
|
||||
baseLayer.setSource(basemapSources[basemap]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlState() {
|
||||
if (!map) return;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const center = map.getView().getCenter();
|
||||
const zoom = map.getView().getZoom();
|
||||
if (center) {
|
||||
const lonLat = toLonLat(center);
|
||||
params.set('center', `${lonLat[0].toFixed(6)},${lonLat[1].toFixed(6)}`);
|
||||
}
|
||||
if (zoom !== undefined) {
|
||||
params.set('zoom', zoom.toFixed(2));
|
||||
}
|
||||
if (lastSelectionBounds) {
|
||||
params.set(
|
||||
'bbox',
|
||||
`${lastSelectionBounds[0].toFixed(6)},${lastSelectionBounds[1].toFixed(6)},${lastSelectionBounds[2].toFixed(6)},${lastSelectionBounds[3].toFixed(6)}`
|
||||
);
|
||||
params.set('count', String(cameras.length));
|
||||
} else {
|
||||
params.delete('bbox');
|
||||
params.delete('count');
|
||||
}
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}
|
||||
|
||||
function restoreStateFromUrl() {
|
||||
if (!map) return false;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const centerParam = params.get('center');
|
||||
const zoomParam = params.get('zoom');
|
||||
const bboxParam = params.get('bbox');
|
||||
let restored = false;
|
||||
let bounds: [number, number, number, number] | null = null;
|
||||
|
||||
if (centerParam) {
|
||||
const centerParts = centerParam.split(',').map(Number);
|
||||
if (centerParts.length === 2 && centerParts.every((v) => !Number.isNaN(v))) {
|
||||
map.getView().setCenter(fromLonLat(centerParts));
|
||||
restored = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (zoomParam) {
|
||||
const zoomValue = parseFloat(zoomParam);
|
||||
if (!Number.isNaN(zoomValue)) {
|
||||
map.getView().setZoom(zoomValue);
|
||||
restored = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (bboxParam) {
|
||||
const parts = bboxParam.split(',').map(Number);
|
||||
if (parts.length === 4 && parts.every((v) => !Number.isNaN(v))) {
|
||||
bounds = [parts[0], parts[1], parts[2], parts[3]];
|
||||
lastSelectionBounds = bounds;
|
||||
renderSelection(bounds, selectionLayer);
|
||||
if (!centerParam) {
|
||||
const extent = transformExtent(
|
||||
[bounds[0], bounds[1], bounds[2], bounds[3]],
|
||||
'EPSG:4326',
|
||||
'EPSG:3857'
|
||||
);
|
||||
map.getView().fit(extent, { duration: 0 });
|
||||
}
|
||||
handleFetchCameras(bounds);
|
||||
restored = true;
|
||||
}
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Surveilled - Surveillance Camera Map</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Surveilled maps publicly listed surveillance cameras so you can explore coverage, export GeoJSON, and share selections."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="Surveilled - Surveillance Camera Map" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Draw a box to discover surveillance cameras nearby, copy GeoJSON, and share links."
|
||||
/>
|
||||
<meta property="og:image" content="/favicon.svg" />
|
||||
<meta property="og:url" content="https://surveilled.quad4.io" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Surveilled - Surveillance Camera Map" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Map and export surveillance camera locations using OpenStreetMap Overpass data."
|
||||
/>
|
||||
<meta name="twitter:image" content="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</svelte:head>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
{#if lastUpdated !== 'never'}
|
||||
<span class="text-xs">• {lastUpdated}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
class="h-1 bg-accent-red transition-all duration-300 {fetchInFlight > 0 ? 'w-full' : 'w-0'}"
|
||||
></div>
|
||||
<main class="flex-1 relative overflow-hidden bg-bg-primary">
|
||||
<div bind:this={mapContainer} class="w-full h-full"></div>
|
||||
{#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}
|
||||
<div>Lat: {cursorLonLat[1].toFixed(5)} | Lon: {cursorLonLat[0].toFixed(5)}</div>
|
||||
{:else}
|
||||
<div>Move cursor over map</div>
|
||||
{/if}
|
||||
{#if measureDistanceKm > 0}
|
||||
<div>Distance: {measureDistanceKm.toFixed(2)} km / {measureDistanceMiles.toFixed(2)} mi</div>
|
||||
{:else if isMeasureMode}
|
||||
<div>Measure: click two points</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2 z-[1000]">
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
class="toolbar-btn {isBoxSelectMode ? 'active' : ''}"
|
||||
title="Select Area (B)"
|
||||
on:click={toggleBoxSelectMode}
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
<button class="toolbar-btn" title="Refresh (R)" on:click={handleRefresh}>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<button class="toolbar-btn" title="Copy Link" on:click={handleCopyLink}>
|
||||
<Link size={16} />
|
||||
</button>
|
||||
<button class="toolbar-btn" title="Copy GeoJSON (C)" on:click={handleCopyGeoJson}>
|
||||
<Copy size={16} />
|
||||
</button>
|
||||
<button class="toolbar-btn" title="Download GeoJSON" on:click={handleDownloadGeoJson}>
|
||||
<Download size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="toolbar-btn {isMeasureMode ? 'active' : ''}"
|
||||
title="Measure distance"
|
||||
on:click={toggleMeasureMode}
|
||||
>
|
||||
<Ruler size={16} />
|
||||
</button>
|
||||
<select
|
||||
class="toolbar-select text-text-primary bg-transparent border-none text-xs px-2 py-1 cursor-pointer"
|
||||
title="Basemap"
|
||||
value={basemap}
|
||||
on:change={handleBasemapChange}
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="satellite">Satellite</option>
|
||||
</select>
|
||||
</div>
|
||||
</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 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}
|
||||
</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="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Type:</strong>
|
||||
{selectedCamera.type || 'Unknown'}
|
||||
</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>
|
||||
{/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>
|
||||
<div class="bg-bg-primary border border-border-color rounded p-2">
|
||||
<strong class="text-text-primary">Lon:</strong>
|
||||
{selectedCamera.lon.toFixed(5)}
|
||||
</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>
|
||||
{/if}
|
||||
{#if selectedCamera.description}
|
||||
<div class="mt-2 bg-bg-primary border border-border-color rounded p-2 text-xs">
|
||||
<strong class="text-text-primary">Notes:</strong>
|
||||
{selectedCamera.description}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer
|
||||
class="bg-bg-secondary border-t border-border-color px-4 py-3 flex-shrink-0 text-xs text-text-secondary"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto space-y-2">
|
||||
<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">
|
||||
Developed by <a href="https://quad4.io" target="_blank" rel="noopener noreferrer" class="text-accent-red-light hover:underline">Quad4</a> - Open-Source MIT
|
||||
<a href="https://git.quad4.io/Quad4-Software/Surveilled" target="_blank" rel="noopener noreferrer" class="inline-flex items-center text-text-secondary hover:text-accent-red-light transition-colors" title="View on Gitea">
|
||||
<GitBranch size={14} />
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-xs opacity-75">
|
||||
Data is fetched from OSM Overpass and may not be accurate or up to date as this data is community-sourced.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-xs opacity-60 max-w-md">
|
||||
<p>
|
||||
This software is provided "as is" without warranty. Quad4 and its contributors are not responsible for the accuracy, completeness, or reliability of the surveillance camera data displayed on this map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.status-message {
|
||||
@apply text-sm p-2 rounded border border-border-color bg-bg-primary text-text-secondary;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
@apply border-accent-red text-accent-red-light bg-red-500/10;
|
||||
}
|
||||
|
||||
.camera-item {
|
||||
@apply space-y-2;
|
||||
}
|
||||
</style>
|
||||
5
static/favicon.svg
Normal file
5
static/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12Z"/>
|
||||
<circle cx="12" cy="12" r="3" fill="#ef4444" stroke="#ef4444"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 287 B |
0
static/fonts/nunito-v16-latin-regular.woff2
Normal file
0
static/fonts/nunito-v16-latin-regular.woff2
Normal file
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
svelte.config.docker.js
Normal file
12
svelte.config.docker.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
18
svelte.config.js
Normal file
18
svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
22
tailwind.config.js
Normal file
22
tailwind.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'bg-primary': '#0a0a0a',
|
||||
'bg-secondary': '#171717',
|
||||
'text-primary': '#fafafa',
|
||||
'text-secondary': '#a3a3a3',
|
||||
'accent-red': '#dc2626',
|
||||
'accent-red-dark': '#991b1b',
|
||||
'accent-red-light': '#ef4444',
|
||||
'border-color': '#262626',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Nunito', 'Inter', 'Segoe UI', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
6
vite.config.ts
Normal file
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
});
|
||||
Reference in New Issue
Block a user