Files
2026-10-16 18:35:02 +03:00

558 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, label, 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.label} ${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.label;
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 = entry.slider ? "range" : "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 + ": <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}`;
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();