This commit is contained in:
2025-12-24 17:00:21 -06:00
parent 7f6d9812cb
commit d2ef9add72
30 changed files with 6175 additions and 0 deletions

23
.dockerignore Normal file
View 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
View 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-*

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
dist
.DS_Store

8
.prettierrc Normal file
View 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
View 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
View 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
View 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
View 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
View File

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

73
src/app.css Normal file
View 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
View 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
View 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
View 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;
}

View 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
View 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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

412
src/lib/map.ts Normal file
View 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);
}

View File

@@ -0,0 +1,6 @@
<script lang="ts">
import '../app.css';
import 'ol/ol.css';
</script>
<slot />

879
src/routes/+page.svelte Normal file
View 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
View 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

View File

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

12
svelte.config.docker.js Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
});