551 lines
20 KiB
JavaScript
551 lines
20 KiB
JavaScript
"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 = btnNextCombo.disabled = false;
|
||
}
|
||
}
|
||
|
||
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 } = fieldInputs[key];
|
||
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 {
|
||
await api("/api/live_settings", { [field]: value });
|
||
} 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 {
|
||
await api("/api/live_settings", collectFields());
|
||
clearDirty(); // applied; let live pushes update the form again
|
||
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/live_settings", { history_command: "clear_all" });
|
||
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 + ": <b></b>";
|
||
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}`;
|
||
}
|
||
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();
|