// Saamid panels: threat, probability gauge, event log, mic editor, cue, sim controls // React hooks accessed via React.* to avoid global-scope name clashes across Babel files /* ---------------- Threat indicator ---------------- */ function ThreatPanel({ threatType, T, dir, prob, droneClass, classConfidence }) { const map = { clear: { tone: "ok", dot: "var(--green-500)", label: T.th_clear, sub: T.th_clear_sub, code: "T-0" }, detected: { tone: "alert", dot: "var(--alert)", label: T.th_detected, sub: T.th_detected_sub, code: "T-3" }, decoy: { tone: "warn", dot: "var(--warn)", label: T.th_decoy, sub: T.th_decoy_sub, code: "T-2" }, unknown: { tone: "warn", dot: "var(--gold-400)", label: T.th_unknown, sub: T.th_unknown_sub, code: "T-1" }, }; const cfg = map[threatType] || map.clear; const isShahed = droneClass === "shahed-136"; const showClassChip = threatType !== "clear" && isShahed; return (
{cfg.code} p={prob.toFixed(2)}
{cfg.label}
{showClassChip && (
{T.cls_label} {T.cls_shahed} {(classConfidence * 100).toFixed(1)}%
)}
{cfg.sub}
{["clear", "unknown", "decoy", "detected"].map((k, i) => ( ))}
); } /* ---------------- Probability gauge ---------------- */ function ProbabilityGauge({ prob, T, dir, threshold = 0.6 }) { // semi-circular gauge const pct = Math.max(0, Math.min(1, prob)); const ang = -90 + pct * 180; // -90 = left, +90 = right const r = 78; const cx = 110, cy = 100; const arc = (start, end, color, width) => { const s = (start - 90) * Math.PI / 180; const e = (end - 90) * Math.PI / 180; const x1 = cx + Math.cos(s) * r, y1 = cy + Math.sin(s) * r; const x2 = cx + Math.cos(e) * r, y2 = cy + Math.sin(e) * r; const large = end - start > 180 ? 1 : 0; return ; }; return (
{/* track */} {arc(0, 180, "var(--surface-line)", 10)} {/* fill */} {pct > 0.001 && arc(0, pct * 180, pct > threshold ? "var(--alert)" : "var(--accent)", 10)} {/* end-cap dot at the leading edge of the fill — sits ON the arc, not over the centred numeric readout the way a centre-pivot needle does */} {pct > 0.001 && (() => { const a = ang * Math.PI / 180; const tx = cx + Math.cos(a) * r; const ty = cy + Math.sin(a) * r; return ( threshold ? "var(--alert)" : "var(--accent)"} stroke="var(--bg-elev)" strokeWidth="1.5" /> ); })()} {/* tick labels */} 0 0.5 1.0
{pct.toFixed(2)}
{T.panel_prob}
{dir === "rtl" ? "العتبة" : "threshold"} {threshold.toFixed(2)}
); } /* ---------------- Event log ---------------- */ function EventLog({ events, T, dir }) { return (
{events.length === 0 && (
{T.log_empty}
)} {events.map((e, i) => (
{e.time} {(T[e.key] || e.text || "") + (e.suffix || "")} {e.conf != null && {e.conf.toFixed(2)}}
))}
); } /* ---------------- Mic editor ---------------- */ function MicEditor({ mics, setMics, T, dir, onHover, levels, silentChannels, sourceLive }) { const update = (id, key, val) => { setMics(mics.map(m => m.id === id ? { ...m, [key]: parseFloat(val) || 0 } : m)); }; const add = () => { const id = (mics.length ? Math.max(...mics.map(m => m.id)) : 0) + 1; setMics([...mics, { id, x: 1, y: 1, z: 0 }]); }; const remove = (id) => setMics(mics.filter(m => m.id !== id)); const showLevels = sourceLive && Array.isArray(levels) && levels.length > 0; const silentSet = new Set((silentChannels || []).map(Number)); return (
{T.mic} {showLevels && {T.src_levels || "level"}} {T.coord_x} {T.coord_y} {T.coord_z}
{mics.map((m, idx) => { // levels are per audio-channel index (0..n-1), mic ids start at 1 const lvl = showLevels ? levels[idx] || 0 : 0; const silent = showLevels && silentSet.has(idx); return (
onHover && onHover(m.id)} onMouseLeave={() => onHover && onHover(null)}> M{m.id} {showLevels && ( )} update(m.id, "x", e.target.value)} /> update(m.id, "y", e.target.value)} /> update(m.id, "z", e.target.value)} />
); })}
); } /* ---------------- Cueing handoff JSON ---------------- Mirrors the `cueing` object emitted by scripts/triangulate.py exactly: drone_class, position_m, velocity_m_s, predicted_path_m, confidence, threat_level, is_decoy, longest_consecutive_windows_above_threshold, timestamp_s Wrapped in a transport envelope (protocol/timestamp/site_id) for the bus. */ function CueingPanel({ drone, threatType, mics, T, dir, lastCueTime, longestRun, sampledPath, velocity, simTimeS, liveCue }) { const json = React.useMemo(() => { // Live mode: trust the backend's cue payload verbatim. if (liveCue) { return { protocol: "saamid.cue/1", timestamp: new Date().toISOString(), site_id: "RUH-14", cueing: liveCue, }; } // map UI threat type -> triangulate.py threat_level vocabulary const threatLevel = threatType === "decoy" ? "decoy" : threatType === "unknown" ? "unreliable" : threatType === "detected" ? (drone && drone.prob >= 0.7 ? "high" : "moderate") : "clear"; const cueing = { drone_class: "unknown", // model is binary today; always "unknown" position_m: drone ? [+drone.x.toFixed(3), +drone.y.toFixed(3), 0.0] : null, velocity_m_s: velocity || null, predicted_path_m: sampledPath || [], confidence: drone ? +drone.prob.toFixed(3) : 0.0, threat_level: threatLevel, is_decoy: threatType === "decoy", longest_consecutive_windows_above_threshold: longestRun || 0, timestamp_s: +(simTimeS || 0).toFixed(2), }; return { protocol: "saamid.cue/1", timestamp: new Date().toISOString(), site_id: "RUH-14", cueing, }; }, [drone, threatType, longestRun, sampledPath, velocity, simTimeS, liveCue]); return (
{T.cue_status} {threatType !== "clear" ? `${T.cue_sent} ${lastCueTime || "—"}` : T.cue_idle}
{JSON.stringify(json, null, 2)}
); } /* ---------------- Track simulator scrubber ---------------- */ function SimControls({ playing, t, setT, setPlaying, speed, setSpeed, T, dir }) { return (
{ setT(parseFloat(e.target.value)); setPlaying(false); }} /> {(t * 100).toFixed(0)}%
{T.speed_x} {[0.5, 1, 2].map(s => ( ))}
); } /* ---------------- Bearing / range readout (large) ---------------- The 4-mic 2m planar array has poor z-resolution — we deliberately omit altitude and GPS, and only show the things triangulate.py actually computes from TDoAs: planar position, bearing, range, and trajectory- derived speed. */ function BearingReadout({ drone, T, dir, speed, oodFlag }) { const bearing = drone ? (((Math.atan2(drone.x - 1, drone.y - 1) * 180 / Math.PI) + 360) % 360) : null; const range = drone ? Math.sqrt((drone.x - 1) ** 2 + (drone.y - 1) ** 2) : null; const stat = (label, value, unit, foot) => (
{label} {value} {unit} {foot && {foot}}
); return (
{stat(T.bearing, bearing != null ? Math.round(bearing).toString().padStart(3, "0") : "—", "°")} {stat(T.distance, range != null ? range.toFixed(1) : "—", "m")} {stat(T.speed, drone ? speed.toFixed(1) : "—", "m/s")} {stat(T.openset, oodFlag ? T.openset_yes : T.openset_no, "", T.openset_foot)}
); } /* ---------------- Alert banner ---------------- */ function AlertBanner({ visible, T, dir, onAck, intensity = "moderate", muted, setMuted }) { if (!visible) return null; return (
{T.alert_title} {T.alert_sub}
); } Object.assign(window, { ThreatPanel, ProbabilityGauge, EventLog, MicEditor, CueingPanel, SimControls, BearingReadout, AlertBanner, });