// Saamid Dashboard — root app // React hooks accessed via React.* to avoid global-scope name clashes across Babel files const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "lang": "auto", "theme": "dark", "mapStyle": "hybrid", "headerStyle": "command", "alertIntensity": "moderate", "showCompass": true, "showMicLabels": true, "panelDensity": "comfortable" }/*EDITMODE-END*/; function detectLang() { const browser = (navigator.language || "en").toLowerCase(); if (browser.startsWith("ar")) return "ar"; // user requested: detect, default Arabic if unclear (treat any non-en as ar fallback) return "ar"; } function nowTimeStr() { const d = new Date(); return d.toTimeString().slice(0, 8); } // VU bar mapping lives in live.js so panels.jsx can share it. const rmsToPercent = (window.SaamidLive && window.SaamidLive.rmsToPercent) ? window.SaamidLive.rmsToPercent : (v) => Math.max(2, Math.min(100, Math.sqrt(+v) * 200)); function App() { const [T_, setTweak] = window.useTweaks ? window.useTweaks(TWEAK_DEFAULTS) : [TWEAK_DEFAULTS, () => {}]; // Live backend state — empty/false in demo mode. const live = window.SaamidLive ? window.SaamidLive.useSaamidLive() : { live: false, connected: false, hello: null, frame: null, events: [], lastCue: null }; // Resolve language const [lang, setLang] = React.useState(() => { if (T_.lang === "ar" || T_.lang === "en") return T_.lang; return detectLang(); }); React.useEffect(() => { if (T_.lang === "ar" || T_.lang === "en") setLang(T_.lang); }, [T_.lang]); const [theme, setTheme] = React.useState(T_.theme || "dark"); React.useEffect(() => { setTheme(T_.theme); }, [T_.theme]); const T = window.SaamidI18N[lang]; const dir = T.dir; React.useEffect(() => { document.documentElement.lang = lang; document.documentElement.dir = dir; document.documentElement.classList.remove("theme-light", "theme-dark"); document.documentElement.classList.add(theme === "light" ? "theme-light" : "theme-dark"); }, [lang, dir, theme]); // Mic positions — seeded from defaults, overwritten by backend `hello` if live const [mics, setMics] = React.useState(window.SaamidDefaultMics); const [hoveredMicId, setHoveredMicId] = React.useState(null); React.useEffect(() => { if (live.live && live.hello && Array.isArray(live.hello.mics)) { const fromHello = live.hello.mics.map((m, i) => ({ id: i + 1, x: +m[0], y: +m[1], z: +(m[2] || 0), })); // only overwrite once per hello — operator may still edit afterwards setMics((prev) => (prev === window.SaamidDefaultMics ? fromHello : prev)); } }, [live.live, live.hello]); // Live detection threshold — owned at App level so the gauge and the // source-bar slider stay in sync, and so we can broadcast it back to the // backend via /control/threshold. const [liveThreshold, setLiveThreshold] = React.useState(0.6); React.useEffect(() => { if (live.live && live.hello && typeof live.hello.threshold === "number") { setLiveThreshold(live.hello.threshold); } }, [live.live, live.hello]); // Push mic edits to the backend so triangulation actually uses them. // Debounced — judges may scrub a number while typing. const micsPushRef = React.useRef(null); const micsLastSentRef = React.useRef(""); React.useEffect(() => { if (!live.live || !window.SaamidLive) return; const helloN = (live.hello && live.hello.n_channels) || mics.length; if (mics.length !== helloN) return; // count mismatch — backend will reject const payload = mics.map((m) => [+m.x || 0, +m.y || 0, +m.z || 0]); const key = JSON.stringify(payload); if (key === micsLastSentRef.current) return; if (micsPushRef.current) clearTimeout(micsPushRef.current); micsPushRef.current = setTimeout(() => { micsLastSentRef.current = key; window.SaamidLive.setMics(payload).catch((e) => console.warn("[mics] push failed", e)); }, 350); return () => micsPushRef.current && clearTimeout(micsPushRef.current); }, [mics, live.live, live.hello]); // Sim state — frozen when live mode is connected const [simT, setSimT] = React.useState(0.0); const [simPlaying, setSimPlaying] = React.useState(true); const [simSpeed, setSimSpeed] = React.useState(1); const lastTickRef = React.useRef(performance.now()); React.useEffect(() => { if (live.live) return; // backend is the source of truth let raf; const tick = (now) => { const dt = (now - lastTickRef.current) / 1000; lastTickRef.current = now; if (simPlaying) { setSimT((t) => { let next = t + (dt * simSpeed) / 24; // ~24s loop at 1× if (next > 1) next = 0; return next; }); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [simPlaying, simSpeed, live.live]); // Sample current drone state — from backend frame in live mode, simulator otherwise. const demoSample = React.useMemo(() => window.SaamidSampleTrack(simT), [simT]); const sample = React.useMemo(() => { if (live.live && live.frame && window.SaamidLive) { return window.SaamidLive.frameToSample(live.frame, demoSample); } return demoSample; }, [live.live, live.frame, demoSample]); // In live mode the backend already classified the threat — trust it. const liveThreat = live.live && live.frame ? live.frame.threat : null; // Drone-type classification with sticky hysteresis. Backend confidence // sits right around the prototype threshold for real Shahed audio // (0.965-0.973), which makes the per-frame `drone_class` field flicker // between "shahed-136" and "unknown" every 0.5 s. We latch the // classification for the duration of a detection burst: once we've seen // shahed-136 in this burst, keep showing it until threat goes clear. const stickyClassRef = React.useRef("unknown"); const stickyClassConfRef = React.useRef(0); const liveDroneClass = live.live && live.frame ? live.frame.drone_class : "unknown"; const liveClassConf = live.live && live.frame ? (live.frame.class_confidence || 0) : 0; React.useEffect(() => { if (!liveThreat || liveThreat === "clear") { stickyClassRef.current = "unknown"; stickyClassConfRef.current = 0; } else if (liveDroneClass !== "unknown") { stickyClassRef.current = liveDroneClass; // Track the highest confidence we've seen this burst — stable readout. if (liveClassConf > stickyClassConfRef.current) { stickyClassConfRef.current = liveClassConf; } } }, [liveThreat, liveDroneClass, liveClassConf]); const aboveThreshold = liveThreat ? liveThreat !== "clear" : sample.prob > 0.6; const drone = aboveThreshold && (!live.live || sample.hasPosition) ? sample : null; // Trajectory-derived speed — read directly off the keyframe segment slope. // Robust against scrubbing / wrap-around (no EMA needed). const speedNow = sample.speed != null ? sample.speed : 0; // Longest consecutive windows above threshold (rolling, mirrors triangulate.py) const runRef = React.useRef({ cur: 0, longest: 0 }); React.useEffect(() => { if (live.live && live.frame) { // Backend reports authoritative longest_run. runRef.current.longest = live.frame.longest_run || 0; runRef.current.cur = aboveThreshold ? runRef.current.cur + 1 : 0; return; } if (aboveThreshold) { runRef.current.cur += 1; runRef.current.longest = Math.max(runRef.current.longest, runRef.current.cur); } else { runRef.current.cur = 0; } }, [aboveThreshold, simT, live.live, live.frame]); // Threat type — backend-supplied in live mode, derived from p(drone) in demo. const threatType = React.useMemo(() => { if (liveThreat) return liveThreat; if (!aboveThreshold) return "clear"; if (sample.prob < 0.78) return "unknown"; return "detected"; }, [sample.prob, aboveThreshold, liveThreat]); // No trail. GCC-PHAT positions wander frame-to-frame even on a clean // recording, and a multi-point trail visually amplifies that wander — // it makes the system look unreliable even when detection itself is // confident. We just show the current locked-in position marker // instead, which the backend only emits once it has 3 consecutive // nearby triangulations. const trailRef = React.useRef([]); const trailVersion = 0; // Sampled predicted path (down-sampled trail, in metres) — after trail refs const predictedPath = React.useMemo(() => { if (!drone) return []; return trailRef.current.slice(0, 6).map(p => [+p.x.toFixed(2), +p.y.toFixed(2), 0.0]); }, [drone, trailVersion]); // Event log const [demoEvents, setDemoEvents] = React.useState([]); const lastTypeRef = React.useRef("clear"); const lastCueTimeRef = React.useRef(null); React.useEffect(() => { if (live.live) return; // events stream from backend if (threatType !== lastTypeRef.current) { const time = nowTimeStr(); const push = (key, tone, conf, suffix) => setDemoEvents(es => [{ time, key, tone, conf, suffix }, ...es].slice(0, 60)); if (threatType === "detected") { push("ev_first_detect", "alert", sample.prob); push("ev_decoy_check", "ok", null); push("ev_handoff", "info", null); lastCueTimeRef.current = time; } else if (threatType === "unknown") { push("ev_path_lock", "warn", sample.prob); } else if (threatType === "decoy") { push("ev_decoy_check", "warn", sample.prob, " ✗"); } else if (threatType === "clear" && lastTypeRef.current !== "clear") { push("ev_lost", "info", null); } lastTypeRef.current = threatType; } }, [threatType, live.live]); // Choose event source. Live mode uses backend log events; demo mode uses local. const events = live.live ? live.events : demoEvents; const lastCueTime = live.live ? (live.lastCue ? live.lastCue.time : null) : lastCueTimeRef.current; // Alert banner — shown on fresh transition into 'detected' until acknowledged const [alertVisible, setAlertVisible] = React.useState(false); const [muted, setMuted] = React.useState(false); const prevTypeForAlert = React.useRef("clear"); React.useEffect(() => { if (threatType === "detected" && prevTypeForAlert.current !== "detected") { setAlertVisible(true); // auto-dismiss after 8s const id = setTimeout(() => setAlertVisible(false), 9000); return () => clearTimeout(id); } if (threatType === "clear") setAlertVisible(false); prevTypeForAlert.current = threatType; }, [threatType]); // Top-bar clock const [clock, setClock] = React.useState(nowTimeStr()); React.useEffect(() => { const id = setInterval(() => setClock(nowTimeStr()), 1000); return () => clearInterval(id); }, []); // Splash hide React.useEffect(() => { const splash = document.getElementById("splash"); if (splash) { setTimeout(() => splash.classList.add("hide"), 700); setTimeout(() => splash.remove(), 1400); } }, []); return (
{T.src_array_help}