import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, Area, AreaChart } from 'recharts'; import { Shield, Activity, AlertTriangle, Zap, Target, Eye, Layers, Radio, Cpu, Users, TrendingUp, Download, Crosshair, Map, BarChart2, Wifi, WifiOff, Play, Square, RotateCcw, Database, Maximize2, Upload, GitBranch, Thermometer, Move, ZoomIn, ZoomOut, Wind, Brain, Gauge, Sun, Moon } from 'lucide-react'; import './index.css'; // ─── helpers ────────────────────────────────────────────────── const nowStr = () => new Date().toLocaleTimeString('en-US', { hour12: false }); const uid = () => Math.random().toString(36).slice(2, 8); const secStr = (ms) => { const s = Math.floor(ms / 1000); return `${String(Math.floor(s / 60)).padStart(2,'0')}:${String(s % 60).padStart(2,'0')}`; }; // ─── API Base URLs (reads from env var, falls back to localhost for dev) ────── const rawApiBase = (import.meta.env.VITE_API_URL || '').trim(); const hasPlaceholderApiBase = !rawApiBase || /YOUR_(HF_USERNAME|USERNAME)|your_hf_username|your_username/i.test(rawApiBase); const isLocalHost = typeof window !== 'undefined' && ['localhost', '127.0.0.1'].includes(window.location.hostname); const fallbackApiBase = typeof window === 'undefined' ? 'http://127.0.0.1:8000' : (isLocalHost ? 'http://127.0.0.1:8000' : ''); const API_BASE = (hasPlaceholderApiBase ? fallbackApiBase : rawApiBase).replace(/\/$/, ''); const WS_BASE = API_BASE.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://'); const API_CONFIG_ERROR = 'Backend API is not configured. Set VITE_API_URL to your FastAPI server URL.'; const THREAT = { SAFE: { label: 'SAFE', cls: 'safe' }, MODERATE: { label: 'MODERATE', cls: 'moderate' }, DANGER: { label: 'DANGER', cls: 'danger' }, }; function getThreat(count, limit) { const r = limit > 0 ? count / limit : 0; if (r >= 1) return THREAT.DANGER; if (r >= 0.75) return THREAT.MODERATE; return THREAT.SAFE; } // Density classification per 10k pixel reference area function getDensityLabel(count, limit) { const r = limit > 0 ? count / limit : 0; if (r >= 1) return { label: 'CRITICAL', cls: 'danger' }; if (r >= 0.75) return { label: 'HIGH', cls: 'moderate' }; if (r >= 0.40) return { label: 'MEDIUM', cls: 'blue' }; return { label: 'LOW', cls: '' }; } // Simple linear regression on last N data points for predictive alerts function predictNextFrameCount(history, n = 12) { const data = history.slice(-n); if (data.length < 3) return null; const xs = data.map((_, i) => i); const ys = data.map(d => d.count); const xMean = xs.reduce((a, b) => a + b, 0) / xs.length; const yMean = ys.reduce((a, b) => a + b, 0) / ys.length; const num = xs.reduce((s, x, i) => s + (x - xMean) * (ys[i] - yMean), 0); const den = xs.reduce((s, x) => s + (x - xMean) ** 2, 0); if (den === 0) return null; const slope = num / den; const intercept = yMean - slope * xMean; // predict 10 frames ahead return Math.max(0, Math.round(slope * (xs.length + 10) + intercept)); } // ─── Custom Recharts Tooltip ────────────────────────────────── function CTooltip({ active, payload, label }) { if (!active || !payload?.length) return null; return (
{label}
{payload[0].value}
); } // ─── Alert Item ─────────────────────────────────────────────── function AlertItem({ type, title, msg, time }) { const Icon = type === 'danger' ? AlertTriangle : type === 'warning' ? Zap : Radio; return (
{title}
{msg}
{time}
); } // ─── Main App ───────────────────────────────────────────────── export default function App() { // ── Theme ── const [theme, setTheme] = useState(() => localStorage.getItem('cp_theme') || 'dark'); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('cp_theme', theme); }, [theme]); const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); // ── File / mode ── const [file, setFile] = useState(null); const [fileType, setFileType] = useState('image'); const [preview, setPreview] = useState(null); // image preview URL const [videoPreview, setVideoPreview] = useState(null); // video preview URL const [resultImg, setResultImg] = useState(null); const [loading, setLoading] = useState(false); const [dragActive, setDragActive] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); // 0-100 // ── Analytics ── const [stats, setStats] = useState({ count: 0, unique: 0, latency: 0, frames: 0 }); const [history, setHistory] = useState([]); const [peakCount, setPeak] = useState(0); const [alerts, setAlerts] = useState([]); const [sessions, setSessions] = useState(() => { try { return JSON.parse(localStorage.getItem('cp_sessions') || '[]'); } catch { return []; } }); const sessionStartTs = useRef(null); // ── WebSocket ── const wsRef = useRef(null); const [wsConnected, setWsConnected] = useState(false); // ── Settings ── const [settings, setSettings] = useState({ heatmap: false, clustering: false, showPoints: true, motionVecs: false, zoning: false, mode: 'Balanced', capacity: 150, magnification: 1.5, nmsRadius: 9.0, frameSkip: 3, overlayOpacity: 100, // GAP 2: opacity slider }); // ── Zoom / Pan (GAP 4) ── const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const isPanning = useRef(false); const panStart = useRef({ x: 0, y: 0 }); const panOrigin = useRef({ x: 0, y: 0 }); // ── Zone fencing ── const [fencePoints, setFencePoints] = useState([]); const [drawingFence, setDrawingFence] = useState(false); const viewerRef = useRef(null); const fileInputRef = useRef(null); // ── Derived ── const threat = getThreat(stats.count, settings.capacity); const density = getDensityLabel(stats.count, settings.capacity); const anomalyActive = alerts.some(a => a.type === 'danger' && Date.now() - a._ts < 5000); const predicted = useMemo(() => predictNextFrameCount(history), [history]); const predictAlert = predicted !== null && predicted > settings.capacity; // ── Clock ── const [clock, setClock] = useState(nowStr()); useEffect(() => { const t = setInterval(() => setClock(nowStr()), 1000); return () => clearInterval(t); }, []); // ── FPS ── const lastHistLen = useRef(0); const [fps, setFps] = useState(0); useEffect(() => { const t = setInterval(() => { const delta = history.length - lastHistLen.current; setFps(Math.max(0, Math.round(delta / Math.max(1, settings.frameSkip) * settings.frameSkip))); lastHistLen.current = history.length; }, 1000); return () => clearInterval(t); }, [history, settings.frameSkip]); // ── Average density from history ── const avgCount = history.length > 0 ? Math.round(history.reduce((s, h) => s + h.count, 0) / history.length) : 0; // ── Alert helper ── const addAlert = useCallback((type, title, msg) => { const entry = { id: uid(), type, title, msg, time: nowStr(), _ts: Date.now() }; setAlerts(prev => [entry, ...prev].slice(0, 60)); }, []); useEffect(() => { if (hasPlaceholderApiBase) { addAlert( 'warning', 'API Fallback Active', API_BASE ? `Using fallback backend: ${API_BASE}. Set VITE_API_URL for deployed builds.` : API_CONFIG_ERROR ); } }, [addAlert]); // ── Predictive alert (GAP optional-7) ── useEffect(() => { if (predictAlert && history.length % 15 === 0 && history.length > 0) { addAlert('warning', '🔮 Predictive Alert', `Model predicts ~${predicted} subjects in ~10 frames — limit ${settings.capacity}`); } }, [predictAlert, predicted, history.length, settings.capacity, addAlert]); // ── File handling ── const handleFile = useCallback((f) => { setFile(f); setResultImg(null); setHistory([]); setPeak(0); setFencePoints([]); setZoom(1); setPan({ x: 0, y: 0 }); setUploadProgress(0); setStats({ count: 0, unique: 0, latency: 0, frames: 0 }); sessionStartTs.current = Date.now(); // Revoke any old preview URLs to avoid memory leaks if (preview) URL.revokeObjectURL(preview); if (videoPreview) URL.revokeObjectURL(videoPreview); if (f.type.startsWith('video')) { setFileType('video'); setPreview(null); setVideoPreview(URL.createObjectURL(f)); } else { setFileType('image'); setPreview(URL.createObjectURL(f)); setVideoPreview(null); } addAlert('info', 'Feed Loaded', `${f.name.slice(0, 28)} (${(f.size/1024/1024).toFixed(1)} MB)`); // eslint-disable-next-line react-hooks/exhaustive-deps }, [addAlert]); const onDrop = (e) => { e.preventDefault(); setDragActive(false); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); }; const onDrag = (e) => { e.preventDefault(); setDragActive(e.type === 'dragenter' || e.type === 'dragover'); }; // ── Zoom via scroll wheel (GAP 4) ── const onWheel = (e) => { e.preventDefault(); setZoom(z => Math.min(5, Math.max(1, z - e.deltaY * 0.002))); }; // ── Pan via mouse drag (GAP 4) ── const onMouseDown = (e) => { if (zoom <= 1) return; isPanning.current = true; panStart.current = { x: e.clientX, y: e.clientY }; panOrigin.current = { ...pan }; e.currentTarget.style.cursor = 'grabbing'; }; const onMouseMove = (e) => { if (!isPanning.current) return; setPan({ x: panOrigin.current.x + (e.clientX - panStart.current.x), y: panOrigin.current.y + (e.clientY - panStart.current.y), }); }; const onMouseUp = (e) => { isPanning.current = false; if (e.currentTarget) e.currentTarget.style.cursor = zoom > 1 ? 'grab' : 'crosshair'; }; // ── Zone fencing click ── const onViewerClick = (e) => { if (!drawingFence || !viewerRef.current || isPanning.current) return; const r = viewerRef.current.getBoundingClientRect(); const xr = (e.clientX - r.left) / r.width; const yr = (e.clientY - r.top) / r.height; setFencePoints(fp => [...fp, { x: xr, y: yr }]); }; // ── Wheel listener (must be non-passive) ── useEffect(() => { const el = viewerRef.current; if (!el) return; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }); // ── Image scan ── const executeImageScan = async () => { if (!file) return; setLoading(true); addAlert('info', 'Scan Initiated', 'Neural engine processing frame...'); const form = new FormData(); form.append('file', file); form.append('confidence_threshold', settings.mode === 'Performance' ? '0.45' : settings.mode === 'Accuracy' ? '0.25' : '0.35'); form.append('magnification', parseFloat(settings.magnification).toFixed(2)); form.append('nms_radius', parseFloat(settings.nmsRadius).toFixed(2)); form.append('use_heatmap', String(settings.heatmap)); form.append('use_clustering', String(settings.clustering)); form.append('use_motion_vectors', String(settings.motionVecs)); form.append('fencing_polygon', JSON.stringify(fencePoints)); form.append('inference_batch_size', '8'); form.append('patch_overlap', settings.mode === 'Performance' ? '0.0' : settings.mode === 'Accuracy' ? '0.5' : '0.25'); form.append('inference_strategy', 'Auto'); form.append('max_resolution', '3840'); try { if (!API_BASE) throw new Error(API_CONFIG_ERROR); const res = await fetch(`${API_BASE}/api/process-image`, { method: 'POST', body: form }); const responseText = await res.text(); let data; try { data = responseText ? JSON.parse(responseText) : {}; } catch { data = { detail: responseText || `HTTP ${res.status}` }; } if (!res.ok) throw new Error(data.detail || `Image scan failed (HTTP ${res.status})`); if (data.detail) throw new Error(data.detail); setResultImg(`data:image/jpeg;base64,${data.imageB64}`); const c = data.count; const ts = nowStr(); setStats(s => ({ ...s, count: c, unique: c, latency: data.elapsed })); setPeak(p => Math.max(p, c)); setHistory([{ label: ts, count: c }]); const t = getThreat(c, settings.capacity); if (t === THREAT.DANGER) addAlert('danger', '⚠ Capacity Breach', `${c} subjects — limit ${settings.capacity}`); else if (t === THREAT.MODERATE) addAlert('warning', 'Elevated Density', `Zone at ${Math.round(c / settings.capacity * 100)}%`); else addAlert('info', 'Scan Complete', `${c} subjects in ${data.elapsed.toFixed(2)}s`); } catch (err) { addAlert('danger', 'Scan Failed', err?.message || 'Unknown image scan error'); } finally { setLoading(false); } }; // ── Video stream ── const streamVideo = async () => { if (!file) return; setLoading(true); setHistory([]); setPeak(0); setUploadProgress(0); addAlert('info', 'Uploading Video', `${file.name.slice(0,28)} — ${(file.size/1024/1024).toFixed(1)} MB`); try { if (!API_BASE) throw new Error(API_CONFIG_ERROR); // Use XMLHttpRequest so we can track upload progress const file_id = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const form = new FormData(); form.append('file', file); xhr.open('POST', `${API_BASE}/api/upload-video`, true); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { setUploadProgress(Math.round((e.loaded / e.total) * 100)); } }; xhr.onload = () => { if (xhr.status === 200) { try { const data = JSON.parse(xhr.responseText); resolve(data.file_id); } catch { reject(new Error('Invalid server response')); } } else { try { const err = JSON.parse(xhr.responseText); reject(new Error(err.detail || `Upload failed (HTTP ${xhr.status})`)); } catch { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); } } }; xhr.onerror = () => reject(new Error('Network error during upload')); xhr.send(form); }); setUploadProgress(100); addAlert('info', 'Upload Complete', 'Connecting to inference engine...'); const ws = new WebSocket(`${WS_BASE}/api/stream-video/${file_id}`); wsRef.current = ws; let lastCapacityAlertFrame = -999; ws.onopen = () => { setWsConnected(true); addAlert('info', 'WebSocket Live', 'Real-time telemetry stream established'); ws.send(JSON.stringify({ settings: { confidenceThresh: settings.mode === 'Performance' ? 0.45 : settings.mode === 'Accuracy' ? 0.25 : 0.35, magnification: parseFloat(parseFloat(settings.magnification).toFixed(2)), nmsRadius: parseFloat(parseFloat(settings.nmsRadius).toFixed(2)), useHeatmap: Boolean(settings.heatmap), useClustering: Boolean(settings.clustering), useMotionVecs: Boolean(settings.motionVecs), frameSkip: Math.round(settings.frameSkip), fencingPolygon: fencePoints, capacityLimit: Math.round(settings.capacity), } })); }; ws.onmessage = (e) => { const payload = JSON.parse(e.data); if (payload.status === 'playing') { setResultImg(`data:image/jpeg;base64,${payload.imageB64}`); const c = payload.count; const ts = nowStr(); setStats(s => ({ ...s, count: c, unique: payload.total_unique, frames: payload.frame })); setPeak(p => Math.max(p, c)); setHistory(h => [...h, { label: ts, count: c }]); if (payload.anomalyEvent) addAlert('danger', '⚠ CHAOS DETECTED', `Rapid movement at frame ${payload.frame}`); const t = getThreat(c, settings.capacity); if (t === THREAT.DANGER && payload.frame - lastCapacityAlertFrame > 30) { lastCapacityAlertFrame = payload.frame; addAlert('danger', 'Zone Overcrowding', `${c} subjects — ${Math.round(c / settings.capacity * 100)}%`); } } else if (payload.status === 'done') { ws.close(); addAlert('info', 'Stream Complete', `${payload.total_unique ?? '?'} unique subjects archived`); saveSession(file.name); } else if (payload.status === 'error') { ws.close(); addAlert('danger', 'Engine Error', payload.message || 'Unknown stream error'); } }; ws.onerror = () => addAlert('danger', 'Stream Error', 'WebSocket connection failed'); ws.onclose = () => { setWsConnected(false); setLoading(false); wsRef.current = null; }; } catch (err) { addAlert('danger', 'Upload Failed', err.message); setLoading(false); } }; const terminateStream = () => { wsRef.current?.close(); setLoading(false); addAlert('warning', 'Stream Terminated', 'Operator manually terminated'); }; // ── Session save ── const saveSession = (name) => { const elapsed = sessionStartTs.current ? secStr(Date.now() - sessionStartTs.current) : '—'; const session = { id: uid(), name, peak: peakCount, avg: avgCount, alerts: alerts.length, elapsed, time: new Date().toLocaleString(), history: history.slice(-300), }; setSessions(prev => { const next = [session, ...prev].slice(0, 20); localStorage.setItem('cp_sessions', JSON.stringify(next)); return next; }); }; // ── Export ── const exportReport = () => { const report = { generated: new Date().toISOString(), file: file?.name, peakCount, avgCount, alertCount: alerts.length, settings, history, alerts: alerts.map(a => ({ time: a.time, type: a.type, title: a.title, msg: a.msg })), }; const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `civic_pulse_${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); addAlert('info', 'Report Exported', 'Analytics report downloaded'); }; // ── Fence SVG ── const fenceSvg = viewerRef.current && fencePoints.length > 0 ? (() => { const { offsetWidth: w, offsetHeight: h } = viewerRef.current; return { pts: fencePoints.map(p => `${p.x * w},${p.y * h}`).join(' '), w, h }; })() : null; const handleExecute = () => fileType === 'video' ? streamVideo() : executeImageScan(); const toggleSetting = (key) => setSettings(s => ({ ...s, [key]: !s[key] })); const setSetting = (key, val) => setSettings(s => ({ ...s, [key]: val })); const countClass = threat === THREAT.DANGER ? 'danger' : threat === THREAT.MODERATE ? 'moderate' : ''; // ────────────────────────────────────────────── return (
{/* ════════ TOP NAVBAR ════════ */} {/* ════════ LEFT PANEL ════════ */} {/* ════════ CENTER PANEL ════════ */}
1 ? (isPanning.current ? 'grabbing' : 'grab') : drawingFence ? 'crosshair' : 'default' }} >
{/* Topbar */}
AERIAL FEED {(loading || wsConnected) && LIVE} {zoom > 1 && {zoom.toFixed(1)}×}
{[ { key: 'heatmap', Icon: Thermometer, title: 'Heatmap' }, { key: 'clustering', Icon: GitBranch, title: 'Clustering' }, { key: 'motionVecs', Icon: Wind, title: 'Motion Vecs'}, { key: 'drawFence', Icon: Target, title: 'Draw Zone' }, ].map(({ key, Icon, title }) => (
{ e.stopPropagation(); if (key === 'drawFence') setDrawingFence(d => !d); else toggleSetting(key); }}>
))}
{ e.stopPropagation(); setZoom(z => Math.min(5, +(z + 0.5).toFixed(1))); }}>
{ e.stopPropagation(); viewerRef.current?.requestFullscreen?.(); }}>
{/* Loading state */} {loading && !resultImg && (
{fileType === 'video' ? 'Initializing Telemetry...' : 'Neural Engine Processing...'}
)} {/* Image / video feed with zoom+pan+opacity (GAP 2, 4) */}
{resultImg && ( Analysis feed )} {!resultImg && preview && !loading && ( Preview )}
{/* Empty state */} {!resultImg && !preview && !loading && (
No Feed Detected
Upload aerial imagery or video to begin analysis
)} {/* Fencing SVG */} {drawingFence && viewerRef.current && ( {fenceSvg && ( <> {fencePoints.map((p, i) => ( ))} )} )} {/* Count overlay */} {(resultImg || stats.count > 0) && (
Zone Population
{stats.count}
)} {/* Anomaly banner */} {anomalyActive && (
ANOMALY DETECTED — CHAOS / COUNTERFLOW
)} {/* Predictive warning overlay */} {predictAlert && !anomalyActive && (
PREDICTIVE ALERT — CAPACITY BREACH IMMINENT (~{predicted} subjects)
)}
{/* ════════ RIGHT INTELLIGENCE PANEL ════════ */} {/* ════════ BOTTOM ANALYTICS PANEL ════════ */}
{/* Metric: Zone Population */}
Zone Pop.
{stats.count}
Current frame
{/* Metric: Unique Subjects */}
Unique
{stats.unique}
Tracked subjects
{/* Metric: Avg Density (GAP 3) */}
Density
{density.label}
Avg {avgCount} · Peak {peakCount}
{/* Metric: Latency / Frames */}
Latency
{fileType === 'video' ? (stats.frames || 0) : (stats.latency > 0 ? stats.latency.toFixed(2) : '—')}
{fileType === 'video' ? 'frames processed' : 'seconds'}
{/* Chart (GAP 1: time-formatted X-axis) */}
Population Dynamics Timeline
{history.length > 0 ? ( {/* GAP 1: X-axis shows HH:MM:SS time label */} } /> {settings.capacity > 0 && ( )} ) : (
Run an image scan or video stream to populate analytics.
)}
); }