diff --git a/frontend/index.html b/frontend/index.html index 982729b..8643e8a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,627 +1,16 @@ - + - - - POWDER-RANGER — Artifact Catalog - - - - - - - - - - - -
- - -
-
-
- - - ⌘K -
-
- - - - - -
-
- -
- -
-
-
-
-
OBELISK_CORE · Multi‑agent AI with encrypted vaults
-
AI Agent · 2025
-
- Active -
-
- Coordinator for autonomous tools with governance, encrypted state, and safety rails. - This is a sample card; the full catalog wiring comes from the artifact dataset. -
-
- multi‑agent - vaults - governance -
-
-
-
-
- - - + + + + CIVWATCH + + + + + + +
+ + - diff --git a/frontend/package.json b/frontend/package.json index 172c9b4..30d89c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,29 +1,66 @@ { "name": "civwatch-frontend", - "version": "0.1.0", + "private": true, + "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite --port ${VITE_PORT:-5173}", - "build": "tsc && vite build", - "preview": "vite preview", - "lint": "eslint src --ext .ts,.tsx", - "test": "vitest run" + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "preview": "vite preview" }, "dependencies": { - "axios": "^1.17.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router-dom": "^6.30.3", - "react-toastify": "^10.0.6", - "recharts": "^2.15.4" + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@types/leaflet": "^1.9.21", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "leaflet": "^1.9.4", + "leaflet.heat": "^0.2.0", + "leaflet.markercluster": "^1.5.3", + "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-leaflet": "^5.0.0", + "react-router": "^7.6.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0" }, "devDependencies": { - "@types/react": "^18.3.31", - "@types/react-dom": "^18.3.7", - "@types/express": "^4.17.25", - "@vitejs/plugin-react": "^6.0.2", - "typescript": "^5.4.5", - "vite": "^8.0.16", - "vitest": "^4.1.9" + "@eslint/js": "^9.32.0", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.39.0", + "@typescript-eslint/parser": "^8.39.0", + "@vitejs/plugin-react": "^5.1.1", + "autoprefixer": "^10.4.23", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.5.0", + "jsdom": "^26.1.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.19", + "tailwindcss-animate": "^1.0.7", + "typescript": "~5.9.3", + "vite": "^7.2.4", + "vitest": "^2.1.9" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae4559b..d2d381d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,109 +1,10 @@ -import React from 'react'; -import { Routes, Route, Navigate } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; -import { ProtectedRoute, AdminRoute, AnalystRoute } from './components/ProtectedRoute'; -import { LoginPage } from './pages/LoginPage'; -import { RegisterPage } from './pages/RegisterPage'; -import { DashboardPage } from './pages/DashboardPage'; -import { AnomalyDashboard } from './pages/AnomalyDashboard'; -import { SourcesPage } from './pages/SourcesPage'; -import { AlertsPage } from './pages/AlertsPage'; -import { AnalyticsPage } from './pages/AnalyticsPage'; -import { AdminPage } from './pages/AdminPage'; -import { CampaignFinancePage } from './pages/CampaignFinancePage'; -import { ContractsPage } from './pages/ContractsPage'; -import { LegislationPage } from './pages/LegislationPage'; +import { Routes, Route } from 'react-router' +import Home from './pages/Home' export default function App() { return ( - - - {/* Public routes */} - } /> - } /> - - {/* Protected routes - all authenticated users */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Analyst routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - {/* Admin routes */} - - - - } - /> - - {/* Default redirects */} - } /> - } /> - - - ); + + } /> + + ) } diff --git a/frontend/src/components/AlertSidebar.tsx b/frontend/src/components/AlertSidebar.tsx new file mode 100644 index 0000000..e3f8fde --- /dev/null +++ b/frontend/src/components/AlertSidebar.tsx @@ -0,0 +1,131 @@ +import type { AlertItem, MarkerType } from '@/types'; +import { alerts, MARKER_COLORS } from '@/data/surveillanceData'; + +const typeLabels: Record = { + alpr: 'ALPR', + facial_recognition: 'FACE REC', + microphone: 'MIC ARRAY', + ice_raid: 'ICE', + protest: 'PROTEST', +}; + +export default function AlertSidebar() { + const getBorderColor = (type: MarkerType): string => { + return MARKER_COLORS[type] || '#5A6570'; + }; + + return ( + + ); +} diff --git a/frontend/src/components/CameraFeedPanel.tsx b/frontend/src/components/CameraFeedPanel.tsx new file mode 100644 index 0000000..407cde5 --- /dev/null +++ b/frontend/src/components/CameraFeedPanel.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { ChevronLeft, ChevronRight, Wifi, WifiOff, AlertTriangle } from 'lucide-react'; +import { cameraFeeds } from '@/data/surveillanceData'; +import type { CameraFeed } from '@/types'; + +function FeedCard({ feed }: { feed: CameraFeed }) { + const getStatusIcon = () => { + switch (feed.status) { + case 'ACTIVE': + return ; + case 'OFFLINE': + return ; + case 'INTERMITTENT': + return ; + } + }; + + const getStatusColor = () => { + switch (feed.status) { + case 'ACTIVE': + return '#00E5C7'; + case 'OFFLINE': + return '#5A6570'; + case 'INTERMITTENT': + return '#FFB800'; + } + }; + + return ( +
+
+ {feed.status !== 'OFFLINE' && ( + <> +
+
+ + )} + {feed.status === 'OFFLINE' && ( +
+ +
+ )} +
+ {new Date().toISOString().slice(11, 19)} +
+
+ {getStatusIcon()} + + {feed.status} + +
+
+
+ {feed.label} +
+
+ ); +} + +export default function CameraFeedPanel() { + const [collapsed, setCollapsed] = useState(false); + + return ( + <> + + + + + ); +} diff --git a/frontend/src/components/DetailModal.tsx b/frontend/src/components/DetailModal.tsx new file mode 100644 index 0000000..cc15d95 --- /dev/null +++ b/frontend/src/components/DetailModal.tsx @@ -0,0 +1,228 @@ +import { X, Video, Flag } from 'lucide-react'; +import type { SurveillancePoint } from '@/types'; +import { TYPE_LABELS, MARKER_COLORS } from '@/data/surveillanceData'; + +interface DetailModalProps { + point: SurveillancePoint | null; + onClose: () => void; +} + +export default function DetailModal({ point, onClose }: DetailModalProps) { + if (!point) return null; + + const accentColor = MARKER_COLORS[point.type] || '#00E5C7'; + const typeLabel = TYPE_LABELS[point.type] || 'SURVEILLANCE POINT'; + + const details: { key: string; value: string }[] = [ + ...(point.installDate ? [{ key: 'INSTALL DATE', value: point.installDate }] : []), + ...(point.operator ? [{ key: 'OPERATOR', value: point.operator }] : []), + { key: 'STATUS', value: point.status }, + { key: 'LAST VERIFIED', value: point.lastVerified }, + { key: 'REPORTED', value: new Date(point.timestamp).toLocaleString() }, + ]; + + return ( +
+
e.stopPropagation()} + > + + +
+
+ {typeLabel} +
+

+ {point.title} +

+
+ {point.location} +
+
+ {point.lat.toFixed(4)}° N, {Math.abs(point.lng).toFixed(4)}° W +
+
+ {point.description} +
+
+ +
+ +
+
+ {details.map((detail) => ( +
+ + {detail.key} + + + {detail.value} + +
+ ))} +
+
+ +
+ +
+ {point.feedUrl && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/FooterStatus.tsx b/frontend/src/components/FooterStatus.tsx new file mode 100644 index 0000000..d29d6b0 --- /dev/null +++ b/frontend/src/components/FooterStatus.tsx @@ -0,0 +1,51 @@ +interface FooterStatusProps { + coords: { lat: number; lng: number }; + zoom: number; +} + +export default function FooterStatus({ coords, zoom }: FooterStatusProps) { + return ( +
+ + {coords.lat.toFixed(4)}° N, {Math.abs(coords.lng).toFixed(4)}° W + + + + ZOOM: {zoom} + + + + DATA: OPENSTREETMAP + CIVIC SOURCES + +
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..57adbf2 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,129 @@ +import { useState, useEffect } from 'react'; +import { Eye, Radio } from 'lucide-react'; +import { tickerText } from '@/data/surveillanceData'; + +export default function Header() { + const [activeFeeds, setActiveFeeds] = useState(1247); + const [utcTime, setUtcTime] = useState(''); + + useEffect(() => { + const update = () => { + const now = new Date(); + setUtcTime(now.toISOString().slice(11, 19) + ' UTC'); + }; + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + const interval = setInterval(() => { + setActiveFeeds(prev => prev + Math.floor(Math.random() * 3) - 1); + }, 5000); + return () => clearInterval(interval); + }, []); + + return ( +
+
+
+ + + CIVWATCH + +
+
+
+ + LIVE + +
+
+ +
+
+ + {tickerText} + + + {tickerText} + +
+
+ +
+
+ + + FEEDS: {activeFeeds.toLocaleString()} ACTIVE + +
+ + {utcTime} + +
+
+ ); +} diff --git a/frontend/src/components/SurveillanceMap.tsx b/frontend/src/components/SurveillanceMap.tsx new file mode 100644 index 0000000..d42c0ee --- /dev/null +++ b/frontend/src/components/SurveillanceMap.tsx @@ -0,0 +1,303 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import L from 'leaflet'; +import 'leaflet.heat'; +import type { SurveillancePoint, LayerVisibility, MapCoords } from '@/types'; +import { surveillancePoints, MARKER_COLORS, MARKER_SYMBOLS } from '@/data/surveillanceData'; +import DetailModal from './DetailModal'; +import { Layers } from 'lucide-react'; + +interface SurveillanceMapProps { + onCoordsChange: (coords: MapCoords) => void; + onZoomChange: (zoom: number) => void; +} + +const layerConfig = [ + { key: 'alpr' as const, label: 'ALPR', color: '#00E5C7' }, + { key: 'facial_recognition' as const, label: 'FACE REC', color: '#FFB800' }, + { key: 'microphone' as const, label: 'MICS', color: '#5A6570' }, + { key: 'ice_raid' as const, label: 'ICE', color: '#FF453A' }, + { key: 'protest' as const, label: 'PROTEST', color: '#8B5CF6' }, +]; + +function createMarkerElement(type: string, color: string, symbol: string): HTMLElement { + const el = document.createElement('div'); + el.className = 'cw-marker'; + el.style.setProperty('--marker-color', color); + el.style.color = color; + el.style.width = '16px'; + el.style.height = '16px'; + el.style.display = 'flex'; + el.style.alignItems = 'center'; + el.style.justifyContent = 'center'; + el.style.position = 'relative'; + + const symbolSpan = document.createElement('span'); + symbolSpan.className = 'cw-marker-symbol'; + symbolSpan.style.fontSize = '14px'; + symbolSpan.style.lineHeight = '1'; + symbolSpan.style.fontFamily = "'JetBrains Mono', monospace"; + symbolSpan.style.zIndex = '2'; + symbolSpan.style.position = 'relative'; + symbolSpan.style.textShadow = `0 0 6px ${color}`; + symbolSpan.textContent = symbol; + el.appendChild(symbolSpan); + + if (type === 'alpr') { + const pulse = document.createElement('div'); + pulse.className = 'cw-marker-pulse'; + pulse.style.position = 'absolute'; + pulse.style.width = '20px'; + pulse.style.height = '20px'; + pulse.style.borderRadius = '50%'; + pulse.style.border = `2px solid ${color}`; + pulse.style.top = '50%'; + pulse.style.left = '50%'; + pulse.style.marginTop = '-10px'; + pulse.style.marginLeft = '-10px'; + pulse.style.pointerEvents = 'none'; + pulse.style.animation = 'marker-pulse 2s ease-out infinite'; + el.appendChild(pulse); + } + + if (type === 'ice_raid') { + const warning = document.createElement('div'); + warning.className = 'cw-marker-warning'; + warning.style.position = 'absolute'; + warning.style.width = '24px'; + warning.style.height = '24px'; + warning.style.borderRadius = '4px'; + warning.style.border = '2px solid #FF453A'; + warning.style.top = '50%'; + warning.style.left = '50%'; + warning.style.marginTop = '-12px'; + warning.style.marginLeft = '-12px'; + warning.style.pointerEvents = 'none'; + warning.style.animation = 'warning-pulse 1.5s ease-out infinite'; + el.appendChild(warning); + } + + return el; +} + +export default function SurveillanceMap({ onCoordsChange, onZoomChange }: SurveillanceMapProps) { + const mapRef = useRef(null); + const markersRef = useRef([]); + const heatLayerRef = useRef(null); + const [selectedPoint, setSelectedPoint] = useState(null); + const [layers, setLayers] = useState({ + alpr: true, + facial_recognition: true, + microphone: true, + ice_raid: true, + protest: true, + }); + const [showLayerControl, setShowLayerControl] = useState(false); + + useEffect(() => { + if (mapRef.current) return; + + const map = L.map('cw-map-container', { + zoomControl: false, + attributionControl: false, + minZoom: 3, + maxZoom: 18, + }).setView([32.7767, -96.7970], 12); + + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + subdomains: 'abcd', + maxZoom: 19, + opacity: 0.7, + }).addTo(map); + + mapRef.current = map; + + map.on('mousemove', (e: L.LeafletMouseEvent) => { + if (e.latlng) { + onCoordsChange({ lat: e.latlng.lat, lng: e.latlng.lng }); + } + }); + + map.on('zoomend', () => { + onZoomChange(map.getZoom()); + }); + + return () => { + map.remove(); + mapRef.current = null; + }; + }, [onCoordsChange, onZoomChange]); + + const renderMarkers = useCallback(() => { + if (!mapRef.current) return; + + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + if (heatLayerRef.current) { + heatLayerRef.current.remove(); + heatLayerRef.current = null; + } + + const visiblePoints = surveillancePoints.filter((p) => layers[p.type]); + + visiblePoints.forEach((point) => { + const color = MARKER_COLORS[point.type] || '#00E5C7'; + const symbol = MARKER_SYMBOLS[point.type] || '●'; + + const el = createMarkerElement(point.type, color, symbol); + el.setAttribute('aria-label', `${point.title} at ${point.location}`); + + const icon = L.divIcon({ + className: '', + iconSize: [16, 16], + iconAnchor: [8, 8], + html: el.outerHTML, + }); + + const marker = L.marker([point.lat, point.lng], { icon }); + marker.on('click', () => { + setSelectedPoint(point); + }); + marker.addTo(mapRef.current!); + markersRef.current.push(marker); + }); + + const alprPoints = surveillancePoints + .filter((p) => p.type === 'alpr' && layers.alpr) + .map((p) => [p.lat, p.lng, p.intensity || 0.5] as [number, number, number]); + + if (alprPoints.length > 0) { + // @ts-expect-error leaflet.heat types + const heat = L.heatLayer(alprPoints, { + radius: 25, + blur: 20, + maxZoom: 15, + max: 1.0, + gradient: { + 0.0: '#1A1F2E', + 0.3: '#00E5C744', + 0.6: '#00E5C7AA', + 1.0: '#00E5C7', + }, + }); + heat.addTo(mapRef.current); + heatLayerRef.current = heat; + } + }, [layers]); + + useEffect(() => { + renderMarkers(); + }, [renderMarkers]); + + const toggleLayer = (key: keyof LayerVisibility) => { + setLayers((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + return ( + <> +
+ +
+ + + {showLayerControl && ( +
+ {layerConfig.map((layer) => ( +
toggleLayer(layer.key)} + className={`cw-layer-toggle ${layers[layer.key] ? 'active' : ''}`} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '4px 8px', + borderRadius: 2, + border: '1px solid transparent', + cursor: 'pointer', + }} + > +
+ + {layer.label} + +
+ ))} +
+ )} +
+ + {selectedPoint && ( + setSelectedPoint(null)} + /> + )} + + ); +} diff --git a/frontend/src/data/surveillanceData.ts b/frontend/src/data/surveillanceData.ts new file mode 100644 index 0000000..a03fe79 --- /dev/null +++ b/frontend/src/data/surveillanceData.ts @@ -0,0 +1,86 @@ +import type { SurveillancePoint, AlertItem, CameraFeed } from '@/types'; + +export const MARKER_COLORS: Record = { + alpr: '#00E5C7', + facial_recognition: '#FFB800', + microphone: '#5A6570', + ice_raid: '#FF453A', + protest: '#8B5CF6', +}; + +export const MARKER_SYMBOLS: Record = { + alpr: '◆', + facial_recognition: '▲', + microphone: '●', + ice_raid: '■', + protest: '◉', +}; + +export const TYPE_LABELS: Record = { + alpr: 'AUTOMATIC LICENSE PLATE READER', + facial_recognition: 'FACIAL RECOGNITION CAMERA', + microphone: 'PUBLIC MICROPHONE ARRAY', + ice_raid: 'ICE ENFORCEMENT ACTIVITY', + protest: 'CIVIL DEMONSTRATION ZONE', +}; + +export const surveillancePoints: SurveillancePoint[] = [ + { id: 'alpr-001', lat: 32.8145, lng: -96.6184, type: 'alpr', title: 'FLOCK-2847', description: 'ALPR cluster — 12 cameras detected', location: 'Garland Rd & I-635', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T14:32:00Z', installDate: '2024-03-15', operator: 'DALLAS PD / FLOCK SAFETY', status: 'ACTIVE', lastVerified: '2 HOURS AGO', intensity: 0.9 }, + { id: 'alpr-002', lat: 32.7767, lng: -96.7970, type: 'alpr', title: 'FLOCK-3102', description: 'High-traffic ALPR installation', location: 'I-35E & Royal Ln', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T13:45:00Z', installDate: '2023-11-20', operator: 'DALLAS PD', status: 'ACTIVE', lastVerified: '1 HOUR AGO', intensity: 0.85 }, + { id: 'alpr-003', lat: 32.7901, lng: -96.8103, type: 'alpr', title: 'NTTA-CAM-47', description: 'Toll authority ALPR array', location: 'DNT & I-635', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T12:20:00Z', installDate: '2024-01-10', operator: 'NTTA / VIGilant Solutions', status: 'ACTIVE', lastVerified: '3 HOURS AGO', intensity: 0.7 }, + { id: 'alpr-004', lat: 32.8482, lng: -96.7694, type: 'alpr', title: 'FLOCK-5129', description: 'New installation spotted', location: 'Skillman St & Walnut Hill Ln', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T11:10:00Z', installDate: '2024-06-20', operator: 'FLOCK SAFETY', status: 'ACTIVE', lastVerified: '5 HOURS AGO', intensity: 0.6 }, + { id: 'alpr-005', lat: 32.7502, lng: -96.8207, type: 'alpr', title: 'FLOCK-0891', description: 'Oak Cliff corridor monitor', location: 'Zang Blvd & Colorado Blvd', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T10:45:00Z', installDate: '2023-08-05', operator: 'DALLAS PD', status: 'ACTIVE', lastVerified: '6 HOURS AGO', intensity: 0.75 }, + { id: 'alpr-006', lat: 32.9259, lng: -96.7694, type: 'alpr', title: 'FLOCK-7623', description: 'Richardson border monitoring', location: 'Belt Line Rd & US-75', city: 'Richardson', state: 'TX', timestamp: '2024-06-24T09:30:00Z', installDate: '2024-04-01', operator: 'RICHARDSON PD', status: 'ACTIVE', lastVerified: '8 HOURS AGO', intensity: 0.5 }, + { id: 'alpr-007', lat: 32.7299, lng: -96.6989, type: 'alpr', title: 'FLOCK-4451', description: 'Pleasant Grove coverage', location: 'Lake June Rd & Masters Dr', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T08:15:00Z', installDate: '2024-02-28', operator: 'DALLAS PD', status: 'ACTIVE', lastVerified: '10 HOURS AGO', intensity: 0.55 }, + { id: 'alpr-101', lat: 29.7604, lng: -95.3698, type: 'alpr', title: 'HPD-ALPR-12', description: 'Houston Police ALPR grid', location: 'I-10 & Loop 610', city: 'Houston', state: 'TX', timestamp: '2024-06-24T14:00:00Z', installDate: '2024-01-15', operator: 'HOUSTON PD', status: 'ACTIVE', lastVerified: '1 HOUR AGO', intensity: 0.8 }, + { id: 'alpr-102', lat: 30.2672, lng: -97.7431, type: 'alpr', title: 'APD-FLOCK-09', description: 'Austin downtown corridor', location: 'I-35 & 6th St', city: 'Austin', state: 'TX', timestamp: '2024-06-24T13:30:00Z', installDate: '2024-02-01', operator: 'AUSTIN PD', status: 'ACTIVE', lastVerified: '2 HOURS AGO', intensity: 0.7 }, + { id: 'alpr-103', lat: 33.7490, lng: -84.3880, type: 'alpr', title: 'ATL-FLOCK-55', description: 'Atlanta perimeter coverage', location: 'I-285 & GA-400', city: 'Atlanta', state: 'GA', timestamp: '2024-06-24T12:00:00Z', installDate: '2023-12-01', operator: 'ATLANTA PD', status: 'ACTIVE', lastVerified: '4 HOURS AGO', intensity: 0.65 }, + { id: 'alpr-104', lat: 34.0522, lng: -118.2437, type: 'alpr', title: 'LAPD-ALPR-88', description: 'LA freeway network node', location: 'I-110 & I-10', city: 'Los Angeles', state: 'CA', timestamp: '2024-06-24T11:30:00Z', installDate: '2024-03-01', operator: 'LAPD', status: 'ACTIVE', lastVerified: '3 HOURS AGO', intensity: 0.9 }, + { id: 'alpr-105', lat: 40.7128, lng: -74.0060, type: 'alpr', title: 'NYPD-ALPR-33', description: 'NYC bridge/tunnel monitoring', location: 'Holland Tunnel approach', city: 'New York', state: 'NY', timestamp: '2024-06-24T10:00:00Z', installDate: '2023-09-15', operator: 'NYPD', status: 'ACTIVE', lastVerified: '5 HOURS AGO', intensity: 0.85 }, + { id: 'alpr-106', lat: 41.8781, lng: -87.6298, type: 'alpr', title: 'CPD-FLOCK-21', description: 'Chicago expressway coverage', location: 'I-90 & I-290', city: 'Chicago', state: 'IL', timestamp: '2024-06-24T09:45:00Z', installDate: '2024-04-15', operator: 'CHICAGO PD', status: 'ACTIVE', lastVerified: '6 HOURS AGO', intensity: 0.75 }, + { id: 'alpr-107', lat: 39.9526, lng: -75.1652, type: 'alpr', title: 'PPD-ALPR-17', description: 'Philadelphia transit corridor', location: 'I-95 & I-676', city: 'Philadelphia', state: 'PA', timestamp: '2024-06-24T08:30:00Z', installDate: '2024-05-01', operator: 'PHILADELPHIA PD', status: 'ACTIVE', lastVerified: '7 HOURS AGO', intensity: 0.6 }, + { id: 'fr-001', lat: 32.7826, lng: -96.7989, type: 'facial_recognition', title: 'FACE-REC-DT01', description: 'Metro center station deployment', location: 'Metro Center Station', city: 'Nashville', state: 'TN', timestamp: '2024-06-24T12:15:00Z', installDate: '2024-05-20', operator: 'METRO NASHVILLE PD / CLEARVIEW AI', status: 'ACTIVE', lastVerified: '4 HOURS AGO', intensity: 0.5 }, + { id: 'fr-002', lat: 32.7767, lng: -96.7970, type: 'facial_recognition', title: 'DFW-FR-02', description: 'Downtown surveillance network', location: 'Main St & Akard St', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T11:00:00Z', installDate: '2024-04-10', operator: 'DALLAS PD / NEC', status: 'ACTIVE', lastVerified: '3 HOURS AGO', intensity: 0.6 }, + { id: 'fr-003', lat: 29.7490, lng: -95.3582, type: 'facial_recognition', title: 'HOU-FR-05', description: 'Convention district monitoring', location: 'Discovery Green', city: 'Houston', state: 'TX', timestamp: '2024-06-24T10:30:00Z', installDate: '2024-03-01', operator: 'HOUSTON PD', status: 'ACTIVE', lastVerified: '5 HOURS AGO', intensity: 0.55 }, + { id: 'fr-004', lat: 33.4484, lng: -112.0740, type: 'facial_recognition', title: 'PHX-FR-12', description: 'Phoenix transit hub', location: 'Central Station', city: 'Phoenix', state: 'AZ', timestamp: '2024-06-24T09:00:00Z', installDate: '2024-01-20', operator: 'PHOENIX PD / AMAZON REKOGNITION', status: 'ACTIVE', lastVerified: '6 HOURS AGO', intensity: 0.45 }, + { id: 'fr-005', lat: 38.9072, lng: -77.0369, type: 'facial_recognition', title: 'DC-FR-01', description: 'Federal corridor surveillance', location: '14th St & Pennsylvania Ave', city: 'Washington', state: 'DC', timestamp: '2024-06-24T08:45:00Z', installDate: '2023-10-01', operator: 'DHS / CBP', status: 'ACTIVE', lastVerified: '8 HOURS AGO', intensity: 0.7 }, + { id: 'fr-006', lat: 37.7749, lng: -122.4194, type: 'facial_recognition', title: 'SF-FR-08', description: 'Union Square business district', location: 'Union Square', city: 'San Francisco', state: 'CA', timestamp: '2024-06-24T07:30:00Z', installDate: '2024-02-15', operator: 'SFPD', status: 'ACTIVE', lastVerified: '9 HOURS AGO', intensity: 0.5 }, + { id: 'fr-007', lat: 47.6062, lng: -122.3321, type: 'facial_recognition', title: 'SEA-FR-03', description: 'Pike Place Market coverage', location: 'Pike Place & 1st Ave', city: 'Seattle', state: 'WA', timestamp: '2024-06-24T06:15:00Z', installDate: '2024-05-01', operator: 'SEATTLE PD', status: 'ACTIVE', lastVerified: '10 HOURS AGO', intensity: 0.4 }, + { id: 'mic-001', lat: 32.7920, lng: -96.7699, type: 'microphone', title: 'SHOTSPOTTER-D14', description: 'Gunshot detection array', location: 'MLK Blvd & Second Ave', city: 'Dallas', state: 'TX', timestamp: '2024-06-24T11:30:00Z', installDate: '2023-06-01', operator: 'DALLAS PD / SOUNDTHINKING', status: 'ACTIVE', lastVerified: '2 HOURS AGO', intensity: 0.4 }, + { id: 'mic-002', lat: 29.7589, lng: -95.3674, type: 'microphone', title: 'SHOTSPOTTER-H09', description: 'Acoustic surveillance node', location: 'Third Ward', city: 'Houston', state: 'TX', timestamp: '2024-06-24T10:00:00Z', installDate: '2023-09-01', operator: 'HOUSTON PD', status: 'ACTIVE', lastVerified: '4 HOURS AGO', intensity: 0.35 }, + { id: 'mic-003', lat: 41.8781, lng: -87.6298, type: 'microphone', title: 'SS-CHI-22', description: 'South Side coverage zone', location: 'Englewood District', city: 'Chicago', state: 'IL', timestamp: '2024-06-24T09:00:00Z', installDate: '2023-03-15', operator: 'CHICAGO PD', status: 'ACTIVE', lastVerified: '6 HOURS AGO', intensity: 0.3 }, + { id: 'mic-004', lat: 40.7128, lng: -74.0060, type: 'microphone', title: 'NYPD-ASA-04', description: 'Broadway corridor audio', location: 'Times Square', city: 'New York', state: 'NY', timestamp: '2024-06-24T08:00:00Z', installDate: '2024-01-01', operator: 'NYPD', status: 'ACTIVE', lastVerified: '8 HOURS AGO', intensity: 0.45 }, + { id: 'ice-001', lat: 29.7604, lng: -95.3390, type: 'ice_raid', title: 'ICE-RAID-HOU-06', description: 'Enforcement spotted — apartment complex', location: 'Pecan Grove Apartments', city: 'Houston', state: 'TX', timestamp: '2024-06-24T13:47:00Z', operator: 'ICE / ERO', status: 'ACTIVE', lastVerified: '1 HOUR AGO', intensity: 0.95 }, + { id: 'ice-002', lat: 34.0522, lng: -118.2437, type: 'ice_raid', title: 'ICE-LA-12', description: 'Workplace enforcement action', location: 'Vernon & Soto St', city: 'Los Angeles', state: 'CA', timestamp: '2024-06-24T12:30:00Z', operator: 'ICE', status: 'ACTIVE', lastVerified: '3 HOURS AGO', intensity: 0.9 }, + { id: 'ice-003', lat: 40.7589, lng: -73.9851, type: 'ice_raid', title: 'ICE-NYC-08', description: 'Courthouse arrest reported', location: '26 Federal Plaza', city: 'New York', state: 'NY', timestamp: '2024-06-24T11:15:00Z', operator: 'ICE', status: 'ACTIVE', lastVerified: '4 HOURS AGO', intensity: 0.85 }, + { id: 'ice-004', lat: 25.7617, lng: -80.1918, type: 'ice_raid', title: 'ICE-MIA-04', description: 'Home raid — early morning', location: 'Little Havana', city: 'Miami', state: 'FL', timestamp: '2024-06-24T09:00:00Z', operator: 'ICE', status: 'ACTIVE', lastVerified: '6 HOURS AGO', intensity: 0.8 }, + { id: 'ice-005', lat: 33.4484, lng: -112.0740, type: 'ice_raid', title: 'ICE-PHX-03', description: 'Traffic stop escalation', location: '27th Ave & Van Buren', city: 'Phoenix', state: 'AZ', timestamp: '2024-06-24T08:30:00Z', operator: 'ICE / BP', status: 'ACTIVE', lastVerified: '7 HOURS AGO', intensity: 0.75 }, + { id: 'ice-006', lat: 44.9778, lng: -93.2650, type: 'ice_raid', title: 'ICE-MIN-02', description: 'Community center surveillance', location: 'Lake St & Chicago Ave', city: 'Minneapolis', state: 'MN', timestamp: '2024-06-24T07:00:00Z', operator: 'ICE', status: 'ACTIVE', lastVerified: '9 HOURS AGO', intensity: 0.7 }, + { id: 'pro-001', lat: 45.5152, lng: -122.6784, type: 'protest', title: 'DEMO-PDX-01', description: 'Demonstration active — city hall', location: 'City Hall Plaza', city: 'Portland', state: 'OR', timestamp: '2024-06-24T11:58:00Z', status: 'ACTIVE', lastVerified: '30 MIN AGO', intensity: 0.5 }, + { id: 'pro-002', lat: 38.9072, lng: -77.0369, type: 'protest', title: 'DEMO-DC-03', description: 'Civil rights march — National Mall', location: 'National Mall', city: 'Washington', state: 'DC', timestamp: '2024-06-24T10:30:00Z', status: 'ACTIVE', lastVerified: '2 HOURS AGO', intensity: 0.6 }, + { id: 'pro-003', lat: 37.8044, lng: -122.2712, type: 'protest', title: 'DEMO-OAK-02', description: 'Port action — waterfront', location: 'Port of Oakland', city: 'Oakland', state: 'CA', timestamp: '2024-06-24T09:00:00Z', status: 'ACTIVE', lastVerified: '4 HOURS AGO', intensity: 0.45 }, + { id: 'pro-004', lat: 41.8756, lng: -87.6244, type: 'protest', title: 'DEMO-CHI-01', description: 'Labor strike — downtown core', location: 'Daley Plaza', city: 'Chicago', state: 'IL', timestamp: '2024-06-24T08:00:00Z', status: 'ACTIVE', lastVerified: '5 HOURS AGO', intensity: 0.4 }, +]; + +export const alerts: AlertItem[] = [ + { id: 'a-001', type: 'alpr', label: 'ALPR CLUSTER', description: '12 cameras detected', location: 'I-35E & Royal Ln', city: 'Dallas', state: 'TX', timestamp: '14:32 UTC' }, + { id: 'a-002', type: 'ice_raid', label: 'ICE ACTIVITY', description: 'Enforcement spotted', location: 'Pecan Grove Apartments', city: 'Houston', state: 'TX', timestamp: '13:47 UTC' }, + { id: 'a-003', type: 'facial_recognition', label: 'FACE REC', description: 'New installation', location: 'Metro Center Station', city: 'Nashville', state: 'TN', timestamp: '12:15 UTC' }, + { id: 'a-004', type: 'protest', label: 'PROTEST', description: 'Demonstration active', location: 'City Hall Plaza', city: 'Portland', state: 'OR', timestamp: '11:58 UTC' }, + { id: 'a-005', type: 'alpr', label: 'ALPR DEPLOYED', description: 'Flock camera installation confirmed', location: 'Garland Rd & I-635', city: 'Dallas', state: 'TX', timestamp: '11:22 UTC' }, + { id: 'a-006', type: 'ice_raid', label: 'ICE RAID', description: 'Early morning enforcement', location: 'Little Havana', city: 'Miami', state: 'FL', timestamp: '10:45 UTC' }, + { id: 'a-007', type: 'microphone', label: 'MIC ARRAY', description: 'ShotSpotter expansion', location: 'MLK Blvd & Second Ave', city: 'Dallas', state: 'TX', timestamp: '10:15 UTC' }, + { id: 'a-008', type: 'facial_recognition', label: 'FACE REC', description: 'Federal corridor deployment', location: '14th St corridor', city: 'Washington', state: 'DC', timestamp: '09:30 UTC' }, + { id: 'a-009', type: 'protest', label: 'PROTEST', description: 'Civil rights march forming', location: 'National Mall', city: 'Washington', state: 'DC', timestamp: '09:00 UTC' }, + { id: 'a-010', type: 'ice_raid', label: 'ICE ACTIVITY', description: 'Courthouse arrest reported', location: '26 Federal Plaza', city: 'New York', state: 'NY', timestamp: '08:45 UTC' }, +]; + +export const cameraFeeds: CameraFeed[] = [ + { id: 'feed-001', label: 'FLOCK-2847 // GARLAND RD', location: 'Garland Rd & I-635, Dallas, TX', status: 'ACTIVE' }, + { id: 'feed-002', label: 'DOT-CAM-91 // I-35E N', location: 'I-35E & Royal Ln, Dallas, TX', status: 'ACTIVE' }, + { id: 'feed-003', label: 'PRIV-SAFE-03 // KNOLLWOOD', location: 'Knollwood & Lovers Ln, Dallas, TX', status: 'INTERMITTENT' }, + { id: 'feed-004', label: 'NTTA-47 // DNT S', location: 'DNT & I-635, Dallas, TX', status: 'OFFLINE' }, + { id: 'feed-005', label: 'HPD-FEED-12 // I-10', location: 'I-10 & Loop 610, Houston, TX', status: 'ACTIVE' }, +]; + +export const tickerText = '[LIVE] ICE enforcement activity reported — Pecan Grove Apartments, Houston :: [ALERT] Flock camera installation confirmed — Garland Rd & I-635, Dallas :: [UPDATE] Facial recognition deployment — 14th St corridor, DC :: [PROTEST] Demonstration forming — City Hall Plaza, Portland :: [RAID] Early morning ICE action — Little Havana, Miami :: [NEW] ShotSpotter array expanded — MLK Blvd, Dallas'; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..0c9e9aa --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,366 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 220 22% 7%; + --foreground: 210 15% 93%; + --card: 220 16% 11%; + --card-foreground: 210 15% 93%; + --popover: 220 16% 11%; + --popover-foreground: 210 15% 93%; + --primary: 170 100% 45%; + --primary-foreground: 220 22% 7%; + --secondary: 220 16% 11%; + --secondary-foreground: 210 15% 93%; + --muted: 210 10% 40%; + --muted-foreground: 210 10% 40%; + --accent: 170 100% 45%; + --accent-foreground: 220 22% 7%; + --destructive: 3 100% 62%; + --destructive-foreground: 0 0% 98%; + --warning: 42 100% 50%; + --warning-foreground: 220 22% 7%; + --border: 170 100% 45%; + --input: 220 16% 15%; + --ring: 170 100% 45%; + --radius: 2px; + } +} + +@layer base { + * { + @apply border-border; + } + body { + font-family: 'Inter', sans-serif; + background-color: #0D0F14; + color: #E8ECEF; + overflow: hidden; + height: 100vh; + width: 100vw; + } +} + +/* Leaflet Dark Overrides */ +.leaflet-container { + background: #1A1F2E !important; + font-family: 'JetBrains Mono', monospace !important; +} + +.leaflet-control-zoom { + display: none !important; +} + +/* CIVWATCH Custom Marker Styles */ +.cw-marker { + position: relative; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.15s ease; +} + +.cw-marker:hover { + transform: scale(1.4); +} + +.cw-marker-symbol { + font-size: 14px; + line-height: 1; + font-family: 'JetBrains Mono', monospace; + z-index: 2; + position: relative; + text-shadow: 0 0 6px currentColor; +} + +/* Pulse Ring Animation */ +@keyframes marker-pulse { + 0% { + transform: scale(1); + opacity: 0.6; + } + 100% { + transform: scale(2.5); + opacity: 0; + } +} + +.cw-marker-pulse { + position: absolute; + width: 20px; + height: 20px; + border-radius: 50%; + border: 2px solid currentColor; + color: var(--marker-color); + animation: marker-pulse 2s ease-out infinite; + top: 50%; + left: 50%; + margin-top: -10px; + margin-left: -10px; + pointer-events: none; +} + +/* Warning Ring Animation */ +@keyframes warning-pulse { + 0% { + transform: scale(1); + opacity: 0.8; + } + 100% { + transform: scale(2); + opacity: 0; + } +} + +.cw-marker-warning { + position: absolute; + width: 24px; + height: 24px; + border-radius: 4px; + border: 2px solid #FF453A; + animation: warning-pulse 1.5s ease-out infinite; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -12px; + pointer-events: none; +} + +/* Ticker Animation */ +@keyframes ticker { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +.cw-ticker-content { + display: flex; + white-space: nowrap; + animation: ticker 40s linear infinite; +} + +.cw-ticker-content:hover { + animation-play-state: paused; +} + +/* Live Blink Animation */ +@keyframes live-blink { + 0%, 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} + +.cw-live-dot { + animation: live-blink 1.5s ease-in-out infinite; +} + +/* Static Noise Animation */ +@keyframes noise { + 0% { + background-position: 0 0; + } + 100% { + background-position: 100% 100%; + } +} + +.cw-feed-noise { + background-image: url('/noise-texture.png'); + background-size: 64px 64px; + animation: noise 0.2s steps(4) infinite; + opacity: 0.4; + position: absolute; + inset: 0; +} + +/* Feed scanline */ +@keyframes scanline { + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(100%); + } +} + +.cw-feed-scanline { + position: absolute; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent, rgba(0, 229, 199, 0.3), transparent); + animation: scanline 3s linear infinite; + pointer-events: none; +} + +/* Panel slide-in */ +@keyframes slideInRight { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +.cw-panel-slide-in { + animation: slideInRight 0.4s ease-out 0.6s both; +} + +/* Modal animation */ +@keyframes modalFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.cw-modal-enter { + animation: modalFadeIn 0.2s ease-out; +} + +/* Backdrop */ +.cw-modal-backdrop { + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); +} + +/* Scrollbar Styling */ +.cw-scroll::-webkit-scrollbar { + width: 3px; +} + +.cw-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.cw-scroll::-webkit-scrollbar-thumb { + background: rgba(0, 229, 199, 0.2); + border-radius: 2px; +} + +.cw-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(0, 229, 199, 0.4); +} + +/* Alert card hover */ +.cw-alert-card { + transition: border-color 0.15s ease, background-color 0.15s ease; +} + +.cw-alert-card:hover { + background-color: rgba(0, 229, 199, 0.04); +} + +/* Focus states */ +.cw-focus:focus { + outline: 2px solid #00E5C7; + outline-offset: 2px; +} + +/* Leaflet popup custom */ +.leaflet-popup-content-wrapper { + background: #161920 !important; + border: 1px solid rgba(0, 229, 199, 0.15) !important; + border-radius: 4px !important; + color: #E8ECEF !important; + font-family: 'Inter', sans-serif !important; +} + +.leaflet-popup-tip { + background: #161920 !important; + border: 1px solid rgba(0, 229, 199, 0.15) !important; +} + +.leaflet-popup-close-button { + color: #5A6570 !important; +} + +.leaflet-popup-close-button:hover { + color: #E8ECEF !important; +} + +/* Protest zone polygon */ +.cw-protest-zone { + fill: #8B5CF6; + fill-opacity: 0.15; + stroke: #8B5CF6; + stroke-width: 1; + stroke-dasharray: 4 4; + stroke-opacity: 0.6; +} + +/* Crosshair cursor on map */ +.leaflet-container { + cursor: crosshair !important; +} + +/* Loading shimmer */ +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + +.cw-shimmer { + background: linear-gradient(90deg, #161920 25%, #1e232d 50%, #161920 75%); + background-size: 200% 100%; + animation: shimmer 2s infinite; +} + +/* Button hover effects */ +.cw-btn-primary { + background: #00E5C7; + color: #0D0F14; + transition: all 0.15s ease; +} + +.cw-btn-primary:hover { + background: #00C4A9; + box-shadow: 0 0 12px rgba(0, 229, 199, 0.3); +} + +.cw-btn-ghost { + background: transparent; + border: 1px solid rgba(0, 229, 199, 0.3); + color: #00E5C7; + transition: all 0.15s ease; +} + +.cw-btn-ghost:hover { + border-color: #00E5C7; + background: rgba(0, 229, 199, 0.08); +} + +/* Layer toggle */ +.cw-layer-toggle { + transition: all 0.15s ease; + cursor: pointer; +} + +.cw-layer-toggle.active { + background: rgba(0, 229, 199, 0.12); + border-color: rgba(0, 229, 199, 0.4); +} + +.cw-layer-toggle:hover { + border-color: rgba(0, 229, 199, 0.3); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ea0d14a..ae3d161 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,13 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { ToastContainer } from 'react-toastify'; -import 'react-toastify/dist/ReactToastify.css'; -import App from './App'; -import './styles.css'; +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import './index.css' +import App from './App.tsx' -ReactDOM.createRoot(document.getElementById('root')!).render( - +createRoot(document.getElementById('root')!).render( + - - -); + , +) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx new file mode 100644 index 0000000..06a2e23 --- /dev/null +++ b/frontend/src/pages/Home.tsx @@ -0,0 +1,37 @@ +import { useState, useCallback } from 'react'; +import Header from '@/components/Header'; +import AlertSidebar from '@/components/AlertSidebar'; +import SurveillanceMap from '@/components/SurveillanceMap'; +import CameraFeedPanel from '@/components/CameraFeedPanel'; +import FooterStatus from '@/components/FooterStatus'; +import type { MapCoords } from '@/types'; + +export default function Home() { + const [coords, setCoords] = useState({ lat: 32.7767, lng: -96.7970 }); + const [zoom, setZoom] = useState(12); + + const handleCoordsChange = useCallback((newCoords: MapCoords) => { + setCoords(newCoords); + }, []); + + const handleZoomChange = useCallback((newZoom: number) => { + setZoom(newZoom); + }, []); + + return ( +
+
+ + + + +
+ ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..2c443a4 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,48 @@ +export type MarkerType = 'alpr' | 'facial_recognition' | 'microphone' | 'ice_raid' | 'protest'; + +export interface SurveillancePoint { + id: string; + lat: number; + lng: number; + type: MarkerType; + title: string; + description: string; + location: string; + city: string; + state: string; + timestamp: string; + installDate?: string; + operator?: string; + status: 'ACTIVE' | 'INACTIVE' | 'UNVERIFIED'; + lastVerified: string; + feedUrl?: string; + intensity?: number; +} + +export interface AlertItem { + id: string; + type: MarkerType; + label: string; + description: string; + location: string; + city: string; + state: string; + timestamp: string; +} + +export interface CameraFeed { + id: string; + label: string; + location: string; + status: 'ACTIVE' | 'OFFLINE' | 'INTERMITTENT'; + lastFrame?: string; +} + +export interface MapCoords { + lat: number; + lng: number; +} + +export type LayerVisibility = { + [key in MarkerType]: boolean; +};