| 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'; |
|
|
| |
| 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')}`; |
| }; |
|
|
| |
| 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; |
| } |
|
|
| |
| 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: '' }; |
| } |
|
|
| |
| 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; |
| |
| return Math.max(0, Math.round(slope * (xs.length + 10) + intercept)); |
| } |
|
|
| |
| function CTooltip({ active, payload, label }) { |
| if (!active || !payload?.length) return null; |
| return ( |
| <div className="custom-tooltip"> |
| <div className="custom-tooltip-label">{label}</div> |
| <div className="custom-tooltip-value">{payload[0].value}</div> |
| </div> |
| ); |
| } |
|
|
| |
| function AlertItem({ type, title, msg, time }) { |
| const Icon = type === 'danger' ? AlertTriangle : type === 'warning' ? Zap : Radio; |
| return ( |
| <div className={`alert-item ${type}`}> |
| <div className="alert-icon"><Icon /></div> |
| <div className="alert-body"> |
| <div className="alert-title">{title}</div> |
| <div className="alert-msg">{msg}</div> |
| <div className="alert-time">{time}</div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| export default function App() { |
|
|
| |
| 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'); |
|
|
| |
| const [file, setFile] = useState(null); |
| const [fileType, setFileType] = useState('image'); |
| const [preview, setPreview] = useState(null); |
| const [videoPreview, setVideoPreview] = useState(null); |
| const [resultImg, setResultImg] = useState(null); |
| const [loading, setLoading] = useState(false); |
| const [dragActive, setDragActive] = useState(false); |
| const [uploadProgress, setUploadProgress] = useState(0); |
|
|
| |
| 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); |
|
|
| |
| const wsRef = useRef(null); |
| const [wsConnected, setWsConnected] = useState(false); |
|
|
| |
| 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, |
| }); |
|
|
| |
| 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 }); |
|
|
| |
| const [fencePoints, setFencePoints] = useState([]); |
| const [drawingFence, setDrawingFence] = useState(false); |
| const viewerRef = useRef(null); |
| const fileInputRef = useRef(null); |
|
|
| |
| 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; |
|
|
| |
| const [clock, setClock] = useState(nowStr()); |
| useEffect(() => { |
| const t = setInterval(() => setClock(nowStr()), 1000); |
| return () => clearInterval(t); |
| }, []); |
|
|
| |
| 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]); |
|
|
| |
| const avgCount = history.length > 0 |
| ? Math.round(history.reduce((s, h) => s + h.count, 0) / history.length) |
| : 0; |
|
|
| |
| 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]); |
|
|
| |
| 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]); |
|
|
| |
| 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(); |
| |
| 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)`); |
| |
| }, [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'); |
| }; |
|
|
| |
| const onWheel = (e) => { |
| e.preventDefault(); |
| setZoom(z => Math.min(5, Math.max(1, z - e.deltaY * 0.002))); |
| }; |
|
|
| |
| 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'; |
| }; |
|
|
| |
| 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 }]); |
| }; |
|
|
| |
| useEffect(() => { |
| const el = viewerRef.current; |
| if (!el) return; |
| el.addEventListener('wheel', onWheel, { passive: false }); |
| return () => el.removeEventListener('wheel', onWheel); |
| }); |
|
|
| |
| 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); |
| } |
| }; |
|
|
| |
| 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); |
| |
| 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'); |
| }; |
|
|
| |
| 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; |
| }); |
| }; |
|
|
| |
| 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'); |
| }; |
|
|
| |
| 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 ( |
| <div className="dashboard"> |
| |
| {/* ════════ TOP NAVBAR ════════ */} |
| <nav className="navbar"> |
| <div className="navbar-brand"> |
| <div className="navbar-logo"><Shield /></div> |
| <div> |
| <div className="navbar-title">CIVIC PULSE</div> |
| <div className="navbar-subtitle">Tactical Crowd Intelligence</div> |
| </div> |
| </div> |
| |
| <div className="navbar-center"> |
| <div className={`threat-level ${threat.cls}`}> |
| <span className="threat-dot" /> |
| THREAT: {threat.label} |
| </div> |
| {predictAlert && ( |
| <div className="threat-level moderate" style={{ fontSize: '0.6rem' }}> |
| <Brain size={10} /> |
| PREDICT: ~{predicted} SOON |
| </div> |
| )} |
| <div className={`ws-status ${wsConnected ? 'connected' : 'disconnected'}`}> |
| {wsConnected ? <Wifi size={12} /> : <WifiOff size={12} />} |
| {wsConnected ? 'STREAM LIVE' : 'STANDBY'} |
| </div> |
| </div> |
| |
| <div className="navbar-stats"> |
| <div className="nav-stat"><span className="nav-stat-label">FPS</span><span className="nav-stat-value">{fps}</span></div> |
| <div className="nav-stat"><span className="nav-stat-label">PEAK</span><span className="nav-stat-value">{peakCount}</span></div> |
| <div className="nav-stat"><span className="nav-stat-label">AVG</span><span className="nav-stat-value">{avgCount}</span></div> |
| <div className="nav-stat"> |
| <span className="nav-stat-label">ALERTS</span> |
| <span className="nav-stat-value" style={{ color: alerts[0]?.type === 'danger' ? 'var(--danger)' : undefined }}>{alerts.length}</span> |
| </div> |
| <div className="nav-stat"><span className="nav-stat-label">TIME</span><span className="nav-stat-value">{clock}</span></div> |
| |
| {/* ── Theme Toggle ── */} |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, marginLeft: 8 }}> |
| <span className="theme-label">{theme === 'dark' ? 'Dark' : 'Light'}</span> |
| <div |
| className="theme-toggle" |
| onClick={toggleTheme} |
| title={`Switch to ${theme === 'dark' ? 'Light' : 'Dark'} mode`} |
| > |
| <div className="theme-toggle-thumb"> |
| {theme === 'dark' ? <Moon size={10} style={{ color: '#94a3b8' }} /> : <Sun size={10} style={{ color: '#fff' }} />} |
| </div> |
| </div> |
| </div> |
| </div> |
| </nav> |
| |
| {/* ════════ LEFT PANEL ════════ */} |
| <aside className="left-panel panel"> |
| |
| {/* Upload */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <Upload size={16} className="section-icon" /> |
| <span className="section-title">Ingest Data</span> |
| </div> |
| <div |
| className={`upload-zone ${dragActive ? 'drag-active' : ''}`} |
| onDragEnter={onDrag} onDragLeave={onDrag} onDragOver={onDrag} onDrop={onDrop} |
| onClick={() => fileInputRef.current?.click()} |
| > |
| {/* Explicit MIME types — critical for Windows file dialog */} |
| <input ref={fileInputRef} type="file" |
| accept="image/jpeg,image/png,image/gif,image/bmp,image/webp,image/tiff,video/mp4,video/avi,video/quicktime,video/x-matroska,video/webm,video/x-msvideo,video/*,image/*" |
| style={{ display: 'none' }} |
| onChange={e => e.target.files[0] && handleFile(e.target.files[0])} /> |
| {/* Video preview thumbnail */} |
| {videoPreview ? ( |
| <div style={{ width: '100%', marginBottom: 8 }}> |
| <video |
| src={videoPreview} |
| style={{ width: '100%', maxHeight: 80, borderRadius: 6, objectFit: 'cover', display: 'block' }} |
| muted preload="metadata" |
| /> |
| {/* Upload progress bar */} |
| {loading && uploadProgress < 100 && ( |
| <div style={{ marginTop: 6, background: 'var(--bg-input)', borderRadius: 4, overflow: 'hidden', height: 4 }}> |
| <div style={{ |
| height: '100%', borderRadius: 4, |
| background: 'linear-gradient(90deg, var(--primary), var(--blue))', |
| width: `${uploadProgress}%`, |
| transition: 'width 0.3s ease', |
| boxShadow: '0 0 6px var(--primary-glow)' |
| }} /> |
| </div> |
| )} |
| </div> |
| ) : ( |
| <span className="upload-icon">🛰️</span> |
| )} |
| <div className="upload-title"> |
| {file |
| ? file.name.slice(0, 22) |
| : 'Load Aerial Feed'} |
| </div> |
| <div className="upload-sub"> |
| {file |
| ? `${(file.size/1024/1024).toFixed(1)} MB · ${fileType}${ |
| loading && uploadProgress > 0 && uploadProgress < 100 |
| ? ` · uploading ${uploadProgress}%` : ''}` |
| : 'Image or Video (drag & drop)'} |
| </div> |
| </div> |
| </div> |
| |
| {/* Overlay Toggles */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <Layers size={16} className="section-icon" /> |
| <span className="section-title">Overlay Layers</span> |
| </div> |
| {[ |
| { key: 'heatmap', label: 'Heatmap Density', Icon: Thermometer }, |
| { key: 'clustering', label: 'AI Pod Clustering', Icon: GitBranch }, |
| { key: 'showPoints', label: 'Head Points', Icon: Crosshair }, |
| { key: 'motionVecs', label: 'Motion Vectors', Icon: Wind }, // GAP 5 |
| ].map(({ key, label, Icon }) => ( |
| <div key={key} className="toggle-row" onClick={() => toggleSetting(key)}> |
| <span className="toggle-label"><Icon size={13} />{label}</span> |
| <div className={`toggle-switch ${settings[key] ? 'on' : ''}`} /> |
| </div> |
| ))} |
| |
| {/* GAP 2: Overlay Opacity Slider */} |
| <div className="slider-group" style={{ marginTop: 10 }}> |
| <div className="slider-header"> |
| <span>Overlay Opacity</span> |
| <span className="slider-value">{settings.overlayOpacity}%</span> |
| </div> |
| <input type="range" min={20} max={100} step={5} |
| value={settings.overlayOpacity} |
| onChange={e => setSetting('overlayOpacity', +e.target.value)} /> |
| </div> |
| </div> |
| |
| {/* Engine Mode */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <Cpu size={16} className="section-icon" /> |
| <span className="section-title">Engine Mode</span> |
| </div> |
| <select className="mode-select" value={settings.mode} |
| onChange={e => setSetting('mode', e.target.value)}> |
| <option value="Performance">Performance (Fast)</option> |
| <option value="Balanced">Balanced</option> |
| <option value="Accuracy">Accuracy (Slow)</option> |
| </select> |
| </div> |
| |
| {/* Parameters */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <BarChart2 size={16} className="section-icon" /> |
| <span className="section-title">Parameters</span> |
| </div> |
| {[ |
| { key: 'capacity', label: 'Capacity Limit', suffix: '', min: 10, max: 1000, step: 5, isInt: true }, |
| { key: 'magnification', label: 'Magnification', suffix: '×', min: 1.0, max: 3.0, step: 0.1, isInt: false }, |
| { key: 'nmsRadius', label: 'NMS Radius', suffix: 'px', min: 3.0, max: 20.0, step: 0.5, isInt: false }, |
| { key: 'frameSkip', label: 'Frame Skip', suffix: '', min: 1, max: 15, step: 1, isInt: true }, |
| ].map(({ key, label, suffix, min, max, step, isInt }) => ( |
| <div className="slider-group" key={key} style={{ marginTop: 10 }}> |
| <div className="slider-header"> |
| <span>{label}</span> |
| <span className="slider-value"> |
| {isInt |
| ? Math.round(settings[key]) |
| : parseFloat(settings[key]).toFixed(1)}{suffix} |
| </span> |
| </div> |
| <input type="range" min={min} max={max} step={step} |
| value={settings[key]} |
| onChange={e => setSetting(key, isInt ? Math.round(+e.target.value) : parseFloat((+e.target.value).toFixed(2)))} /> |
| </div> |
| ))} |
| </div> |
| |
| {/* Zone Fencing */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <Map size={16} className="section-icon" /> |
| <span className="section-title">Zone Fencing</span> |
| </div> |
| <div className="toggle-row" onClick={() => setDrawingFence(d => !d)}> |
| <span className="toggle-label"><Target size={13} />Draw Zone Polygon</span> |
| <div className={`toggle-switch ${drawingFence ? 'on' : ''}`} /> |
| </div> |
| <div className="fencing-controls"> |
| {fencePoints.length > 0 |
| ? <div className="fencing-info">{fencePoints.length} anchor point{fencePoints.length !== 1 ? 's' : ''} — AI counts only inside zone.</div> |
| : drawingFence && <div className="fencing-info">Click on the feed to drop anchor points.</div> |
| } |
| {fencePoints.length > 0 && ( |
| <button className="btn-clear-fence" onClick={() => setFencePoints([])}>Clear Zone Fence</button> |
| )} |
| </div> |
| </div> |
| |
| {/* Zoom / Pan Controls (GAP 4) */} |
| <div className="panel-section"> |
| <div className="section-header"> |
| <Move size={16} className="section-icon" /> |
| <span className="section-title">Viewport ({zoom.toFixed(1)}×)</span> |
| </div> |
| <div style={{ display: 'flex', gap: 6 }}> |
| <button className="btn-secondary" style={{ flex: 1 }} |
| onClick={() => setZoom(z => Math.min(5, +(z + 0.5).toFixed(1)))}> |
| <ZoomIn size={12} style={{ marginRight: 4, verticalAlign: 'middle' }} />Zoom In |
| </button> |
| <button className="btn-secondary" style={{ flex: 1 }} |
| onClick={() => { setZoom(z => Math.max(1, +(z - 0.5).toFixed(1))); setPan({ x: 0, y: 0 }); }}> |
| <ZoomOut size={12} style={{ marginRight: 4, verticalAlign: 'middle' }} />Zoom Out |
| </button> |
| </div> |
| {zoom > 1 && ( |
| <button className="btn-secondary" style={{ marginTop: 6 }} |
| onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }}> |
| <RotateCcw size={12} style={{ marginRight: 4, verticalAlign: 'middle' }} />Reset View |
| </button> |
| )} |
| </div> |
| |
| {/* Execute & Export */} |
| <div className="panel-section" style={{ marginTop: 'auto' }}> |
| {!loading ? ( |
| <> |
| <button className="btn-execute" onClick={handleExecute} disabled={!file}> |
| <Play size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} /> |
| {fileType === 'video' ? 'Initialize Stream' : 'Execute Scan'} |
| </button> |
| <button className="btn-secondary" onClick={exportReport} disabled={history.length === 0}> |
| <Download size={12} style={{ marginRight: 4, verticalAlign: 'middle' }} /> |
| Export Report |
| </button> |
| </> |
| ) : ( |
| <button className="btn-execute terminate" onClick={terminateStream}> |
| <Square size={14} style={{ marginRight: 6, verticalAlign: 'middle' }} /> |
| Terminate Stream |
| </button> |
| )} |
| </div> |
| </aside> |
|
|
| {} |
| <main |
| className="center-panel" |
| ref={viewerRef} |
| onClick={onViewerClick} |
| onMouseDown={onMouseDown} |
| onMouseMove={onMouseMove} |
| onMouseUp={onMouseUp} |
| onMouseLeave={onMouseUp} |
| style={{ cursor: zoom > 1 ? (isPanning.current ? 'grabbing' : 'grab') : drawingFence ? 'crosshair' : 'default' }} |
| > |
| <div className="scan-lines" /> |
|
|
| {} |
| <div className="panel-topbar"> |
| <div className="panel-topbar-title"> |
| <Eye size={14} /> |
| AERIAL FEED |
| {(loading || wsConnected) && <span className="live-badge">LIVE</span>} |
| {zoom > 1 && <span style={{ fontSize: '0.6rem', color: 'var(--blue)', marginLeft: 6 }}>{zoom.toFixed(1)}×</span>} |
| </div> |
| <div className="video-overlay-controls"> |
| {[ |
| { 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 }) => ( |
| <div key={key} |
| className={`overlay-btn ${(key === 'drawFence' ? drawingFence : settings[key]) ? 'active' : ''}`} |
| title={title} |
| onClick={e => { |
| e.stopPropagation(); |
| if (key === 'drawFence') setDrawingFence(d => !d); |
| else toggleSetting(key); |
| }}> |
| <Icon size={13} /> |
| </div> |
| ))} |
| <div className="overlay-btn" title="Zoom In" |
| onClick={e => { e.stopPropagation(); setZoom(z => Math.min(5, +(z + 0.5).toFixed(1))); }}> |
| <ZoomIn size={13} /> |
| </div> |
| <div className="overlay-btn" title="Fullscreen" |
| onClick={e => { e.stopPropagation(); viewerRef.current?.requestFullscreen?.(); }}> |
| <Maximize2 size={13} /> |
| </div> |
| </div> |
| </div> |
|
|
| {} |
| {loading && !resultImg && ( |
| <div className="loader-overlay"> |
| <div className="spinner" /> |
| <div className="loader-text"> |
| {fileType === 'video' ? 'Initializing Telemetry...' : 'Neural Engine Processing...'} |
| </div> |
| </div> |
| )} |
|
|
| {} |
| <div style={{ |
| position: 'absolute', inset: 0, |
| transform: `scale(${zoom}) translate(${pan.x / zoom}px, ${pan.y / zoom}px)`, |
| transformOrigin: 'center center', |
| transition: isPanning.current ? 'none' : 'transform 0.15s ease', |
| display: 'flex', alignItems: 'center', justifyContent: 'center', |
| }}> |
| {resultImg && ( |
| <img src={resultImg} className="main-feed fade-up" alt="Analysis feed" |
| style={{ opacity: settings.overlayOpacity / 100 }} |
| /> |
| )} |
| {!resultImg && preview && !loading && ( |
| <img src={preview} className="main-feed" alt="Preview" |
| style={{ opacity: (settings.overlayOpacity / 100) * 0.6, filter: 'grayscale(20%)' }} /> |
| )} |
| </div> |
|
|
| {} |
| {!resultImg && !preview && !loading && ( |
| <div className="empty-feed"> |
| <div className="empty-feed-icon"><Radio size={36} /></div> |
| <div className="empty-feed-text">No Feed Detected</div> |
| <div className="empty-feed-sub">Upload aerial imagery or video to begin analysis</div> |
| </div> |
| )} |
|
|
| {} |
| {drawingFence && viewerRef.current && ( |
| <svg className="fencing-svg-overlay" |
| width={viewerRef.current.offsetWidth} |
| height={viewerRef.current.offsetHeight}> |
| {fenceSvg && ( |
| <> |
| <polygon |
| points={fenceSvg.pts} |
| fill="rgba(56,189,248,0.12)" |
| stroke="#38bdf8" strokeWidth={1.5} strokeDasharray="6,4" |
| /> |
| {fencePoints.map((p, i) => ( |
| <circle key={i} |
| cx={p.x * fenceSvg.w} cy={p.y * fenceSvg.h} |
| r={5} fill="var(--teal)" |
| stroke="rgba(0,230,184,0.4)" strokeWidth={2} |
| /> |
| ))} |
| </> |
| )} |
| </svg> |
| )} |
|
|
| {} |
| {(resultImg || stats.count > 0) && ( |
| <div className="count-overlay"> |
| <div className="count-overlay-label">Zone Population</div> |
| <div className={`count-overlay-value ${countClass}`}>{stats.count}</div> |
| </div> |
| )} |
|
|
| {} |
| {anomalyActive && ( |
| <div className="anomaly-banner"> |
| <AlertTriangle size={18} /> |
| ANOMALY DETECTED — CHAOS / COUNTERFLOW |
| </div> |
| )} |
|
|
| {} |
| {predictAlert && !anomalyActive && ( |
| <div className="anomaly-banner" style={{ |
| background: 'rgba(245,158,11,0.12)', |
| border: '1.5px solid var(--moderate)', |
| color: 'var(--moderate)', |
| boxShadow: '0 0 30px var(--moderate-glow)', |
| }}> |
| <Brain size={18} /> |
| PREDICTIVE ALERT — CAPACITY BREACH IMMINENT (~{predicted} subjects) |
| </div> |
| )} |
| </main> |
|
|
| {} |
| <aside className="right-panel panel"> |
| {} |
| <div className="right-panel-section" style={{ flex: '3 1 0' }}> |
| <div className="panel-header"> |
| <div className="panel-header-title"> |
| <AlertTriangle size={13} />Alert Feed |
| </div> |
| {alerts.length > 0 && <div className="panel-header-count">{alerts.length}</div>} |
| </div> |
| <div className="alert-feed"> |
| {alerts.length === 0 |
| ? <div className="alert-empty"><Shield size={28} /><p>All systems nominal</p></div> |
| : alerts.map(a => <AlertItem key={a.id} type={a.type} title={a.title} msg={a.msg} time={a.time} />) |
| } |
| </div> |
| </div> |
|
|
| {} |
| <div className="right-panel-section" style={{ flex: '2 1 0' }}> |
| <div className="panel-header"> |
| <div className="panel-header-title"><Database size={13} />Session History</div> |
| {sessions.length > 0 && ( |
| <button className="btn-export" style={{ fontSize: '0.6rem', padding: '2px 8px' }} |
| onClick={() => { setSessions([]); localStorage.removeItem('cp_sessions'); }}> |
| Clear |
| </button> |
| )} |
| </div> |
| <div className="session-list"> |
| {sessions.length === 0 |
| ? <div className="session-empty"><Database size={24} /><span>No sessions recorded</span></div> |
| : sessions.map(s => ( |
| <div key={s.id} className="session-item" |
| onClick={() => s.history?.length && setHistory(s.history)}> |
| <div className="session-name">{s.name.slice(0, 28)}</div> |
| <div className="session-meta"> |
| <span><TrendingUp size={10} />{s.peak}</span> |
| <span><Gauge size={10} />{s.avg}</span> |
| <span><AlertTriangle size={10} />{s.alerts}</span> |
| <span style={{ marginLeft: 'auto' }}>{s.elapsed}</span> |
| </div> |
| </div> |
| )) |
| } |
| </div> |
| </div> |
| </aside> |
|
|
| {} |
| <section className="bottom-panel panel"> |
|
|
| {} |
| <div className="metric-card"> |
| <div className="metric-label"><Users size={11} />Zone Pop.</div> |
| <div> |
| <div className={`metric-value ${countClass}`}>{stats.count}</div> |
| <div className="metric-sub">Current frame</div> |
| </div> |
| </div> |
|
|
| {} |
| <div className="metric-card"> |
| <div className="metric-label"><Crosshair size={11} />Unique</div> |
| <div> |
| <div className="metric-value blue">{stats.unique}</div> |
| <div className="metric-sub">Tracked subjects</div> |
| </div> |
| </div> |
|
|
| {} |
| <div className="metric-card"> |
| <div className="metric-label"><Gauge size={11} />Density</div> |
| <div> |
| <div className={`metric-value ${density.cls}`} style={{ fontSize: '1.5rem', letterSpacing: '-0.5px' }}> |
| {density.label} |
| </div> |
| <div className="metric-sub">Avg {avgCount} · Peak {peakCount}</div> |
| </div> |
| </div> |
|
|
| {} |
| <div className="metric-card"> |
| <div className="metric-label"><Activity size={11} />Latency</div> |
| <div> |
| <div className="metric-value" style={{ fontSize: '1.6rem' }}> |
| {fileType === 'video' |
| ? (stats.frames || 0) |
| : (stats.latency > 0 ? stats.latency.toFixed(2) : '—')} |
| </div> |
| <div className="metric-sub">{fileType === 'video' ? 'frames processed' : 'seconds'}</div> |
| </div> |
| </div> |
|
|
| {} |
| <div className="chart-area"> |
| <div className="chart-header"> |
| <div className="chart-title">Population Dynamics Timeline</div> |
| <div className="export-actions"> |
| <button className="btn-export" onClick={exportReport} disabled={history.length === 0}> |
| <Download size={11} />Report |
| </button> |
| <button className="btn-export" |
| style={{ borderColor: 'rgba(167,139,250,0.3)', color: 'var(--purple)', background: 'rgba(167,139,250,0.06)' }} |
| onClick={() => setHistory([])} disabled={history.length === 0}> |
| <RotateCcw size={11} />Reset |
| </button> |
| </div> |
| </div> |
| <div className="chart-wrapper"> |
| {history.length > 0 ? ( |
| <ResponsiveContainer width="100%" height="100%"> |
| <AreaChart data={history} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}> |
| <defs> |
| <linearGradient id="tealGrad" x1="0" y1="0" x2="0" y2="1"> |
| <stop offset="5%" stopColor="var(--teal)" stopOpacity={0.25} /> |
| <stop offset="95%" stopColor="var(--teal)" stopOpacity={0} /> |
| </linearGradient> |
| </defs> |
| <CartesianGrid strokeDasharray="2 4" stroke="rgba(255,255,255,0.04)" /> |
| {/* GAP 1: X-axis shows HH:MM:SS time label */} |
| <XAxis dataKey="label" |
| stroke="rgba(255,255,255,0.12)" |
| tick={{ fontSize: 8, fill: 'var(--text-muted)', fontFamily: 'Orbitron' }} |
| interval="preserveStartEnd" |
| /> |
| <YAxis stroke="rgba(255,255,255,0.12)" |
| tick={{ fontSize: 9, fill: 'var(--text-muted)' }} /> |
| <Tooltip content={<CTooltip />} /> |
| {settings.capacity > 0 && ( |
| <ReferenceLine y={settings.capacity} |
| stroke="var(--danger)" strokeDasharray="4 4" strokeOpacity={0.5} |
| label={{ value: 'LIMIT', fill: 'var(--danger)', fontSize: 9, fontFamily: 'Orbitron' }} |
| /> |
| )} |
| <Area type="monotone" dataKey="count" |
| stroke="var(--teal)" strokeWidth={2.5} |
| fill="url(#tealGrad)" |
| dot={false} |
| activeDot={{ r: 5, fill: 'var(--bg-base)', stroke: 'var(--teal)', strokeWidth: 2 }} |
| isAnimationActive={false} |
| /> |
| </AreaChart> |
| </ResponsiveContainer> |
| ) : ( |
| <div className="chart-empty-state">Run an image scan or video stream to populate analytics.</div> |
| )} |
| </div> |
| </div> |
| </section> |
| </div> |
| ); |
| } |
|
|