"use strict"; /* ------------------------------------------------------------------ * * Radar System web client. * Streams the live Qt plot as an image (latest-wins) + REST controls + * the processor live-settings panel, synced both ways with the desktop. * ------------------------------------------------------------------ */ /* ---- DOM handles ------------------------------------------------- */ const plotImg = document.getElementById("plot"); const sar3dStatusEl = document.getElementById("sar3d-status"); const displayComboControls = document.getElementById("display-combo-controls"); const displayComboSelect = document.getElementById("display-combo"); const btnNextCombo = document.getElementById("btn-next-combo"); const btnStart = document.getElementById("btn-start"); const btnSingle = document.getElementById("btn-single"); const btnStop = document.getElementById("btn-stop"); const btnTmpRef = document.getElementById("btn-tmp-ref"); const btnRemoveLast = document.getElementById("btn-remove-last"); const btnApply = document.getElementById("btn-apply"); const btnResetHistory = document.getElementById("btn-reset-history"); const btnLoadConfig = document.getElementById("btn-load-config"); const configSelect = document.getElementById("config-select"); const configActiveEl = document.getElementById("config-active"); const savePathInput = document.getElementById("save-path"); const saveNameInput = document.getElementById("save-name"); const btnSaveDataset = document.getElementById("btn-save-dataset"); const recordCountInput = document.getElementById("record-count"); const btnStartRecording = document.getElementById("btn-start-recording"); const recordingStatusEl = document.getElementById("recording-status"); const settingsToggle = document.getElementById("settings-toggle"); const sidePanel = document.querySelector(".side-panel"); const settingsFields = document.getElementById("settings-fields"); const settingsNote = document.getElementById("settings-note"); const runningEl = document.getElementById("stat-running"); const processorEl = document.getElementById("stat-processor"); const ringEl = document.getElementById("stat-ring"); const scansEl = document.getElementById("stat-scans"); const staleEl = document.getElementById("stat-stale"); const toastEl = document.getElementById("toast"); /* ---- state ------------------------------------------------------- */ let pendingPng = null; // newest PNG (base64), shown on the next animation frame let lastFrameTs = 0; // performance.now() of the last received frame let pipelineRunning = false; // gates the config loader (loading requires a stopped pipeline) let saveFieldsPrefilled = false; // seed the save path/name fields once, then leave the operator's edits let recordCountPrefilled = false; // seed the record-count field once from the desktop default /* ---- helpers ----------------------------------------------------- */ function toast(message, isError) { toastEl.textContent = message; toastEl.classList.toggle("error", !!isError); toastEl.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove("show"), 2600); } async function api(path, body) { const opts = { method: body === undefined ? "GET" : "POST" }; if (body !== undefined) { opts.headers = { "Content-Type": "application/json" }; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); let data = null; try { data = await res.json(); } catch (_) { /* empty body */ } if (!res.ok) { const detail = (data && data.detail) || `HTTP ${res.status}`; throw new Error(detail); } return data; } /* ---- controls ---------------------------------------------------- */ function bindControl(button, path, body) { button.addEventListener("click", async () => { button.disabled = true; try { await api(path, body); } catch (err) { toast(err.message, true); } finally { button.disabled = false; } }); } bindControl(btnStart, "/api/start", {}); bindControl(btnSingle, "/api/single_capture", {}); bindControl(btnStop, "/api/stop", {}); bindControl(btnTmpRef, "/api/tmp_reference", {}); bindControl(btnRemoveLast, "/api/remove_last", {}); let comboOptionsSignature = ""; async function changeDisplayCombo(path, body) { displayComboSelect.disabled = btnNextCombo.disabled = true; try { applyStatus(await api(path, body)); } catch (err) { toast(err.message, true); } finally { displayComboSelect.disabled = displayComboSelect.options.length === 0; btnNextCombo.disabled = displayComboSelect.options.length < 2; } } displayComboSelect.addEventListener("change", () => { const [input_pos, output_pos] = displayComboSelect.value.split(":").map(Number); changeDisplayCombo("/api/display_combo", { input_pos, output_pos }); }); btnNextCombo.addEventListener("click", () => changeDisplayCombo("/api/next_combo", {})); /* ---- settings panel --------------------------------------------- */ settingsToggle.addEventListener("click", () => sidePanel.classList.toggle("collapsed")); // The form is built ENTIRELY from the schema the server derives from the Qt widgets // (field, group, kind, options, ranges, value, enabled). The web hardcodes nothing // and shows only the active mode's fields, so it always mirrors the desktop. const fieldInputs = {}; // field name -> { el, kind, dirty } let formSignature = ""; // field names currently in the form (detect mode/structure change) function settingsFormSignature(schema) { return JSON.stringify(schema.map((entry) => [entry.name, entry.kind, entry.position_count])); } function makeGeometryEditor(entry) { const el = document.createElement("div"); el.className = "geometry-editor"; const table = document.createElement("table"); const head = table.createTHead().insertRow(); const keys = [entry.position_field, "x_m", "y_m", "z_m"]; for (const title of [entry.position_field === "output_pos" ? "Output" : "Input", "X (m)", "Y (m)", "Z (m)", ""]) { const cell = document.createElement("th"); cell.textContent = title; head.appendChild(cell); } const body = table.createTBody(); const add = document.createElement("button"); const appendRow = (record) => { const row = body.insertRow(); for (const [index, key] of keys.entries()) { const input = document.createElement("input"); input.type = "number"; input.step = index === 0 ? "1" : "any"; input.required = true; input.setAttribute("aria-label", `${entry.name} ${key}`); if (index === 0) { input.min = "0"; input.max = entry.position_count - 1; } input.value = record[key] ?? 0; input.disabled = entry.enabled === false; row.insertCell().appendChild(input); } const remove = document.createElement("button"); remove.type = "button"; remove.textContent = "−"; remove.title = "Remove antenna"; remove.disabled = entry.enabled === false; remove.addEventListener("click", () => { row.remove(); add.disabled = entry.enabled === false; el.dispatchEvent(new Event("input")); }); row.insertCell().appendChild(remove); }; el.getRecords = () => Array.from(body.rows, (row) => Object.fromEntries( Array.from(row.querySelectorAll("input"), (input, index) => { if (!input.reportValidity()) throw new Error("Enter valid antenna coordinates"); return [keys[index], Number(input.value)]; }) )); el.setRecords = (records) => { body.replaceChildren(); for (const record of records) appendRow(record); add.disabled = entry.enabled === false || records.length >= entry.position_count; }; el.setRecords(entry.value); add.type = "button"; add.textContent = "Add antenna"; add.addEventListener("click", () => { if (body.rows.length >= entry.position_count) return; const positions = new Set(Array.from(body.rows, (row) => Number(row.querySelector("input").value))); let position = 0; while (positions.has(position)) position++; appendRow({ [entry.position_field]: position }); add.disabled = body.rows.length >= entry.position_count; el.dispatchEvent(new Event("input")); }); el.append(table, add); return el; } function makeFieldRow(entry) { const row = document.createElement("div"); row.className = "field"; if (entry.kind === "geometry") row.classList.add("geometry-field"); const label = document.createElement("label"); label.textContent = entry.kind === "geometry" ? (entry.name === "tx_geometry" ? "Tx antenna positions" : "Rx antenna positions") : entry.name; label.htmlFor = "f_" + entry.name; row.appendChild(label); let el; if (entry.kind === "geometry") { el = makeGeometryEditor(entry); } else if (entry.kind === "bool") { el = document.createElement("input"); el.type = "checkbox"; el.checked = !!entry.value; } else if (entry.kind === "select") { el = document.createElement("select"); for (const opt of entry.options || []) { const o = document.createElement("option"); o.value = opt; o.textContent = opt; el.appendChild(o); } el.value = String(entry.value); } else if (entry.kind === "int" || entry.kind === "float") { el = document.createElement("input"); el.type = "number"; if (entry.min != null) el.min = entry.min; if (entry.max != null) el.max = entry.max; el.step = entry.kind === "float" ? (entry.step || "any") : (entry.step || 1); el.value = entry.value; } else if (entry.kind === "textarea") { el = document.createElement("textarea"); el.rows = 3; el.value = entry.value == null ? "" : String(entry.value); } else { el = document.createElement("input"); el.type = "text"; el.value = entry.value == null ? "" : String(entry.value); } el.id = "f_" + entry.name; if (entry.enabled === false) el.disabled = true; // Mark dirty while edited so a live push never overwrites a half-entered value. const markDirty = () => { fieldInputs[entry.name].dirty = true; }; el.addEventListener("input", markDirty); el.addEventListener("change", markDirty); // Switching mode changes which settings are shown — apply it immediately. if (entry.name === "processor_mode") { el.addEventListener("change", () => applyOne("processor_mode", el.value)); } row.appendChild(el); fieldInputs[entry.name] = { el, kind: entry.kind, dirty: false }; return row; } function buildSettingsForm(schema) { settingsFields.innerHTML = ""; for (const key in fieldInputs) delete fieldInputs[key]; let lastGroup = null; for (const entry of schema) { if (entry.group !== lastGroup) { lastGroup = entry.group; const heading = document.createElement("div"); heading.className = "group-title"; heading.textContent = entry.group; settingsFields.appendChild(heading); } settingsFields.appendChild(makeFieldRow(entry)); } formSignature = settingsFormSignature(schema); } function collectFields() { const out = {}; for (const key in fieldInputs) { const { el, kind, dirty } = fieldInputs[key]; if (!dirty) continue; // unchanged browser values must not overwrite newer desktop edits if (kind === "geometry") out[key] = el.getRecords(); else if (kind === "bool") out[key] = el.checked; else if (kind === "int" || kind === "float") { const value = Number(el.value); if (!el.value.trim() || !Number.isFinite(value) || (kind === "int" && !Number.isSafeInteger(value))) { throw new Error("Enter valid numeric settings"); } out[key] = value; } else out[key] = el.value; // select + text (positions are sent as CSV text) } return out; } function setFieldValue(input, value) { const { el, kind } = input; if (kind === "geometry") el.setRecords(value); else if (kind === "bool") el.checked = !!value; else if (kind === "select") el.value = String(value); else el.value = value == null ? "" : value; } function clearDirty() { for (const key in fieldInputs) fieldInputs[key].dirty = false; } async function applyOne(field, value) { try { const schema = await api("/api/live_settings", { [field]: value }); if (fieldInputs[field]) fieldInputs[field].dirty = false; applySettings(schema); } catch (err) { toast(err.message, true); } } // Update the form from a schema pushed by the desktop. Rebuild if the field set // changed (e.g. the mode switched); otherwise update values in place without // clobbering a field the operator is editing here. function applySettings(schema) { if (settingsFormSignature(schema) !== formSignature) { buildSettingsForm(schema); return; } for (const entry of schema) { const input = fieldInputs[entry.name]; if (!input || input.el.contains(document.activeElement) || input.dirty) continue; setFieldValue(input, entry.value); if (entry.enabled !== undefined) input.el.disabled = entry.enabled === false; } } btnApply.addEventListener("click", async () => { btnApply.disabled = true; try { const schema = await api("/api/live_settings", collectFields()); clearDirty(); // applied; let live pushes update the form again applySettings(schema); settingsNote.textContent = "Applied."; } catch (err) { settingsNote.textContent = err.message; toast(err.message, true); } finally { btnApply.disabled = false; } }); btnResetHistory.addEventListener("click", async () => { btnResetHistory.disabled = true; try { await api("/api/clear_history"); settingsNote.textContent = "History reset requested."; } catch (err) { settingsNote.textContent = err.message; toast(err.message, true); } finally { btnResetHistory.disabled = false; } }); async function loadSettings() { try { const cfg = await api("/api/live_settings"); buildSettingsForm(cfg); } catch (err) { settingsNote.textContent = "Could not load settings: " + err.message; } } /* ---- config profile --------------------------------------------- */ // Loading a config does exactly what the desktop "Load Config" button does. The file // list comes from the server's run_configs/ directory (the browser has no file access), // and loading is gated to a stopped pipeline — mirroring the desktop precondition. function updateLoadButtonState() { btnLoadConfig.disabled = pipelineRunning || configSelect.options.length === 0; } async function loadConfigList() { try { const { names = [] } = await api("/api/configs"); const previous = configSelect.value; configSelect.innerHTML = ""; for (const name of names) { const opt = document.createElement("option"); opt.value = name; opt.textContent = name; configSelect.appendChild(opt); } if (names.includes(previous)) configSelect.value = previous; // keep the operator's choice updateLoadButtonState(); } catch (err) { toast("Could not load config list: " + err.message, true); } } btnLoadConfig.addEventListener("click", async () => { const name = configSelect.value; if (!name) return; btnLoadConfig.disabled = true; try { await api("/api/load_config", { name }); toast(`Config loaded: ${name}`); // the new settings schema arrives via the live status push } catch (err) { toast(err.message, true); } finally { updateLoadButtonState(); } }); /* ---- save dataset ----------------------------------------------- */ // Saves to the path in the field, mirroring the desktop "Save Dataset" button exactly. btnSaveDataset.addEventListener("click", async () => { btnSaveDataset.disabled = true; try { await api("/api/save_dataset", { path: savePathInput.value.trim(), name: saveNameInput.value.trim(), }); toast("Dataset saved"); // a radar-config prefix is prepended to the name server-side } catch (err) { toast(err.message, true); // e.g. the destination directory already exists } finally { btnSaveDataset.disabled = false; } }); // Start (if stopped) and record the next N measurements to disk, then stop writing. // While a recording is in progress the button stays disabled (driven by status), so a // second recording can't be armed over the first. let recordingActive = false; function updateRecordButtonState() { btnStartRecording.disabled = recordingActive; } btnStartRecording.addEventListener("click", async () => { const count = Number(recordCountInput.value); if (!Number.isSafeInteger(count) || count < 1) { toast("Enter how many sweeps to record (>= 1)", true); return; } btnStartRecording.disabled = true; // optimistic; status keeps it disabled while recording try { await api("/api/start_recording", { path: savePathInput.value.trim(), name: saveNameInput.value.trim(), count, }); toast(`Recording armed: next ${count} sweep(s)`); } catch (err) { toast(err.message, true); // e.g. the destination directory already exists } finally { updateRecordButtonState(); // re-enable only if not actually recording } }); /* ---- status ------------------------------------------------------ */ function setStat(el, label, value, cls) { el.className = "stat" + (cls ? " " + cls : ""); el.innerHTML = label + ": "; el.querySelector("b").textContent = value; } function applyStatus(s) { if (s.display_combo) { const combo = s.display_combo; displayComboControls.hidden = !combo.enabled; const options = combo.options || []; const signature = JSON.stringify(options); if (signature !== comboOptionsSignature) { displayComboSelect.replaceChildren(...options.map(([inp, out]) => { const option = document.createElement("option"); option.value = `${inp}:${out}`; option.textContent = `Input ${inp} / Output ${out}`; return option; })); comboOptionsSignature = signature; } displayComboSelect.value = `${combo.input}:${combo.output}`; displayComboSelect.disabled = options.length === 0; btnNextCombo.disabled = options.length < 2; } if ("running" in s) { setStat(runningEl, "running", s.running ? "yes" : "no", s.running ? "ok" : "off"); pipelineRunning = !!s.running; updateLoadButtonState(); } if ("active_config" in s) configActiveEl.textContent = s.active_config ? `current: ${s.active_config}` : ""; if ("save_path" in s && !saveFieldsPrefilled) { savePathInput.value = s.save_path || ""; // seed once; don't clobber later edits saveNameInput.value = s.save_name || ""; saveFieldsPrefilled = true; } if ("record_count" in s && !recordCountPrefilled && document.activeElement !== recordCountInput) { recordCountInput.value = s.record_count; // seed once; don't clobber later edits recordCountPrefilled = true; } if (s.recording) { const r = s.recording; recordingActive = !!r.active; updateRecordButtonState(); recordingStatusEl.textContent = r.active ? `recording ${r.collected} / ${r.target} sweep(s)…` : ""; } if ("processor_running" in s) setStat(processorEl, "processor", s.processor_running ? "yes" : "no", s.processor_running ? "ok" : "off"); if ("ring_name" in s) setStat(ringEl, "ring", s.ring_name || "—", s.ring_name ? "" : "off"); if ("raw_count" in s) setStat(scansEl, "scans", `raw ${s.raw_count} / preprocessed ${s.preprocessed_count} / results ${s.result_count}`); sar3dStatusEl.textContent = s.sar3d_status || ""; sar3dStatusEl.hidden = !s.sar3d_status; } /* ---- frame rendering (latest-wins; the frame IS the Qt plot image) - */ function renderLoop() { if (pendingPng !== null) { // Show exactly what the desktop draws; the browser scales it to fit (CSS). plotImg.src = "data:image/png;base64," + pendingPng; pendingPng = null; } // Stale indicator (>2s without a frame). const stale = performance.now() - lastFrameTs > 2000; staleEl.classList.toggle("stale", stale && lastFrameTs > 0); staleEl.textContent = lastFrameTs === 0 ? "no data" : stale ? "stale" : "live"; requestAnimationFrame(renderLoop); } /* ---- WebSocket --------------------------------------------------- */ function connectWs() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}/ws`); ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch (_) { return; } if (msg.type === "frame") { pendingPng = msg.png_b64; // latest-wins; rAF swaps the image lastFrameTs = performance.now(); } else if (msg.type === "status") { applyStatus(msg); } else if (msg.type === "settings") { applySettings(msg.schema); // live desktop schema mirrors into the form } }; ws.onclose = () => setTimeout(connectWs, 1500); ws.onerror = () => ws.close(); } /* ---- boot -------------------------------------------------------- */ async function init() { requestAnimationFrame(renderLoop); try { applyStatus(await api("/api/status")); } catch (err) { settingsNote.textContent = "Status unavailable: " + err.message; } await loadSettings(); await loadConfigList(); connectWs(); } init();