// 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 (
{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 (
);
}
/* ---------------- 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 (
);
}
/* ---------------- 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,
});