Praveen-K-0503
fix: resolve parameter bugs in frontend settings panel
3a66575
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 (
<div className="custom-tooltip">
<div className="custom-tooltip-label">{label}</div>
<div className="custom-tooltip-value">{payload[0].value}</div>
</div>
);
}
// ─── Alert Item ───────────────────────────────────────────────
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>
);
}
// ─── 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 (
<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>
{/* ════════ CENTER PANEL ════════ */}
<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" />
{/* Topbar */}
<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 state */}
{loading && !resultImg && (
<div className="loader-overlay">
<div className="spinner" />
<div className="loader-text">
{fileType === 'video' ? 'Initializing Telemetry...' : 'Neural Engine Processing...'}
</div>
</div>
)}
{/* Image / video feed with zoom+pan+opacity (GAP 2, 4) */}
<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>
{/* Empty state */}
{!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>
)}
{/* Fencing SVG */}
{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>
)}
{/* Count overlay */}
{(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>
)}
{/* Anomaly banner */}
{anomalyActive && (
<div className="anomaly-banner">
<AlertTriangle size={18} />
ANOMALY DETECTED — CHAOS / COUNTERFLOW
</div>
)}
{/* Predictive warning overlay */}
{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>
{/* ════════ RIGHT INTELLIGENCE PANEL ════════ */}
<aside className="right-panel panel">
{/* Alert Feed */}
<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>
{/* Session History */}
<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>
{/* ════════ BOTTOM ANALYTICS PANEL ════════ */}
<section className="bottom-panel panel">
{/* Metric: Zone Population */}
<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>
{/* Metric: Unique Subjects */}
<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>
{/* Metric: Avg Density (GAP 3) */}
<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>
{/* Metric: Latency / Frames */}
<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>
{/* Chart (GAP 1: time-formatted X-axis) */}
<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>
);
}