refactor: enforce typed frontend runtime boundaries

This commit is contained in:
sjg
2026-08-01 22:19:50 +02:00
parent d929ad3bde
commit 812359c744
4 changed files with 523 additions and 341 deletions
@@ -939,52 +939,6 @@ var runtime = {
};
window.trxPluginRuntime = runtime;
// src/plugin-loader.ts
var pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
var loaded = /* @__PURE__ */ new Set();
var loading = /* @__PURE__ */ new Map();
async function loadPlugin(path) {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group) {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group]) await loadPlugin(path);
}
function requestPlugins(group) {
void loadPlugins(group).catch((error) => {
console.error(error);
});
}
var loaderWindow = window;
loaderWindow.loadEagerPlugins = async () => {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
};
loaderWindow.loadPluginsForTab = loadPlugins;
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const tab = event.target.closest("[data-tab]")?.dataset.tab;
if (tab) requestPlugins(tab);
});
// src/leaflet-ais-tracksymbol.ts
(function() {
const leaflet = globalThis.L;
@@ -1605,6 +1559,60 @@ function estimateNoiseFloorDb(bins) {
return nthElement(bins, Math.floor(bins.length * 0.15));
}
// src/plugin-loader.ts
var pluginGroups = {
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
statistics: ["/map-core.js"],
bookmarks: ["/bookmarks.js"],
recorder: [],
settings: ["/vchan.js", "/scheduler.js"]
};
var loaded = /* @__PURE__ */ new Set();
var loading = /* @__PURE__ */ new Map();
async function loadPlugin(path) {
if (loaded.has(path)) return;
const pending = loading.get(path);
if (pending) return pending;
const request = import(path).then(() => {
loaded.add(path);
loading.delete(path);
}).catch((error) => {
loading.delete(path);
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
});
loading.set(path, request);
return request;
}
async function loadPlugins(group) {
if (!(group in pluginGroups)) return;
for (const path of pluginGroups[group]) await loadPlugin(path);
}
async function loadEagerPlugins() {
await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins));
}
async function loadPluginsForTab(tab) {
await loadPlugins(tab);
}
// src/api/client.ts
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isRigSnapshot(value) {
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
return false;
}
const { info, status } = value;
return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean";
}
function isRigListResponse(value) {
return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every(
(rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean"
);
}
// src/app.ts
function requiredElement(id) {
const element = document.getElementById(id);
@@ -1614,6 +1622,41 @@ function requiredElement(id) {
function isFiniteNumber(value) {
return typeof value === "number" && Number.isFinite(value);
}
function primitiveString(value) {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
}
function isRecord2(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJsonUnknown(source) {
return JSON.parse(source);
}
function messageEventData(event) {
const data = event.data;
if (typeof data !== "string") throw new TypeError("Expected a text event payload");
return data;
}
async function responseJsonUnknown(response) {
return await response.json();
}
function isAppUpdate(value) {
return isRecord2(value) && (value.status === void 0 || isRecord2(value.status));
}
function isAudioStreamInfo(value) {
return isRecord2(value) && typeof value.sample_rate === "number" && typeof value.channels === "number";
}
function isRecorderActiveList(value) {
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.rig_id === "string" && typeof entry.path === "string" && typeof entry.started_at === "number");
}
function isRecorderFileList(value) {
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.name === "string" && typeof entry.size === "number");
}
function isRdsData(value) {
return isRecord2(value);
}
function isVchanRdsEntry(value) {
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
}
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
var authRole = null;
var authEnabled = true;
@@ -1731,7 +1774,6 @@ function applyAuthRestrictions() {
const txLimitBtn2 = document.getElementById("tx-limit-btn");
const txAudioBtn2 = document.getElementById("tx-audio-btn");
const txLimitRow2 = document.getElementById("tx-limit-row");
const vfoPicker2 = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up");
const jogDown = document.getElementById("jog-down");
const jogButtons = document.querySelectorAll(".jog-step button");
@@ -1809,7 +1851,7 @@ function applyCapabilities(caps) {
if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio();
void stopTxAudio();
}
const txLimitRow2 = document.getElementById("tx-limit-row");
if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none";
@@ -2063,7 +2105,6 @@ function decodeHistoryMapRenderingDeferred() {
}
var lastSpectrumData = null;
window.lastSpectrumData = null;
var lastControl;
var lastTxEn = null;
var lastHasTx = true;
var lastRendered = null;
@@ -2659,7 +2700,7 @@ function applyRigList(activeRigId, rigIds, displayNames = {}) {
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
}
window.trx.modules.map?.updateMapRigFilter();
}
@@ -2667,8 +2708,9 @@ async function refreshRigList() {
try {
const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const rigs = Array.isArray(data.rigs) ? data.rigs : [];
const data = await responseJsonUnknown(resp);
if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r) => r.remote).filter(Boolean);
const displayNames = {};
rigs.forEach((r) => {
@@ -2684,7 +2726,7 @@ async function refreshRigList() {
});
serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null;
serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) {
@@ -2740,8 +2782,7 @@ var overviewWfTexPushCount = 0;
var overviewWfTexPalKey = "";
var overviewWfTexReady = false;
function cssColorToRgba(color, alphaMul = 1) {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
const parsed = parser ? parser(color) : [0, 0, 0, 1];
const parsed = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor(color) : [0, 0, 0, 1];
return [
parsed[0] ?? 0,
parsed[1] ?? 0,
@@ -2769,7 +2810,7 @@ function overviewWfResetTextureCache() {
overviewWfTexReady = false;
}
function overviewWfPaletteKey(pal, viewKey = "") {
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
}
function resizeHeaderSignalCanvas() {
if (!ensureOverviewCanvasBackingStore()) return;
@@ -2837,7 +2878,7 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) {
if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap();
const grouped = /* @__PURE__ */ new Map();
for (const bm of bmRef) {
@@ -2847,8 +2888,9 @@ function drawSignalOverlay() {
const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []);
grouped.get(color).push(x, 0, x, H);
const segments = grouped.get(color) ?? [];
segments.push(x, 0, x, H);
grouped.set(color, segments);
}
for (const [color, segments] of grouped.entries()) {
if (!Array.isArray(segments) || segments.length === 0) continue;
@@ -3138,7 +3180,7 @@ function waterfallColorRgba(db, pal, minDb, maxDb) {
var _wfLutKey = "";
var _wfLut = new Uint8Array(256 * 4);
function ensureWaterfallLut(pal, minDb, maxDb) {
const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`;
const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`;
if (key === _wfLutKey) return;
_wfLutKey = key;
for (let i = 0; i < 256; i++) {
@@ -3256,7 +3298,7 @@ function renderRdsOverlays() {
el.innerHTML = html;
el.addEventListener("click", (evt) => {
evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz);
void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
});
el.addEventListener("mouseenter", () => {
el.style.zIndex = String(entries.length + 10);
@@ -3915,7 +3957,7 @@ function updateJogStepSupport(cap) {
}
function normalizeMode(modeVal) {
if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") {
if (isRecord2(modeVal)) {
const entries = Object.entries(modeVal);
if (entries.length > 0) {
const firstEntry = entries[0];
@@ -4436,7 +4478,7 @@ function render(update) {
}
lastModeName = modeUpper2;
if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false);
void applyBwDefaultForMode(mode, false);
}
}
updateWfmControls();
@@ -4590,7 +4632,6 @@ function render(update) {
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
txLimitRow.style.display = "";
@@ -4725,9 +4766,10 @@ async function pollFreshSnapshot() {
const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status";
const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data);
refreshRigList();
void refreshRigList();
lastEventAt = Date.now();
} catch (e) {
}
@@ -4742,7 +4784,7 @@ function connect() {
}
stopMeterStreaming();
startMeterStreaming();
pollFreshSnapshot();
void pollFreshSnapshot();
const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events";
es = new EventSource(eventsUrl);
const source = es;
@@ -4751,13 +4793,14 @@ function connect() {
setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot();
refreshRigList();
void pollFreshSnapshot();
void refreshRigList();
};
source.onmessage = (evt) => {
try {
if (evt.data === lastRendered) return;
const data = JSON.parse(evt.data);
const data = parseJsonUnknown(evt.data);
if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape");
lastRendered = evt.data;
render(data);
lastEventAt = Date.now();
@@ -4777,21 +4820,22 @@ function connect() {
});
source.addEventListener("session", (evt) => {
try {
const d = JSON.parse(evt.data);
sseSessionId = d.session_id || null;
const eventData = messageEventData(evt);
const d = parseJsonUnknown(eventData);
sseSessionId = isRecord2(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) {
}
window.trx.modules.vchan?.handleSession(evt.data);
window.trx.modules.vchan?.handleSession(messageEventData(evt));
});
source.addEventListener("channels", (evt) => {
window.trx.modules.vchan?.handleChannels(evt.data);
window.trx.modules.vchan?.handleChannels(messageEventData(evt));
});
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) {
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(1e3);
}
};
@@ -4801,7 +4845,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying…";
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(250);
}
}, 5e3);
@@ -4915,7 +4959,7 @@ async function switchRigFromSelect(selectEl) {
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker();
connect();
stopSpectrumStreaming();
@@ -4940,7 +4984,7 @@ async function switchRigFromSelect(selectEl) {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => {
switchRigFromSelect(headerRigSwitchSelect);
void switchRigFromSelect(headerRigSwitchSelect);
});
}
function setControlPending(control, pending) {
@@ -5037,7 +5081,7 @@ if (centerFreqEl) {
centerFreqDirty = true;
if (e.key === "Enter") {
e.preventDefault();
applyCenterFreqFromInput();
void applyCenterFreqFromInput();
} else if (e.key === "Escape") {
centerFreqDirty = false;
refreshCenterFreqDisplay();
@@ -5382,18 +5426,18 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandwidthFromInput();
void applyBandwidthFromInput();
}
});
}
if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => {
applyBandwidthFromInput();
void applyBandwidthFromInput();
});
}
if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => {
applyAutoBandwidth();
void applyAutoBandwidth();
});
}
if (spectrumBwSweetBtn) {
@@ -5466,7 +5510,9 @@ function navigateToTab(name, options = {}) {
updateTabHistory(name, replaceHistory);
}
scheduleSpectrumLayout();
if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
void loadPluginsForTab(name).catch((error) => {
console.error(error);
});
if (name === "map") {
_initMapWhenReady();
}
@@ -5474,7 +5520,7 @@ function navigateToTab(name, options = {}) {
window.trx.modules.map?.scheduleStatsRender();
}
if (name === "recorder") {
refreshRecorderStatus();
void refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
@@ -5604,7 +5650,7 @@ requiredElement("auth-form").addEventListener("submit", async (e) => {
});
var guestBtn = document.getElementById("auth-guest-btn");
if (guestBtn) {
guestBtn.addEventListener("click", async () => {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement("auth-passphrase").value = "";
hideAuthGate();
@@ -5630,7 +5676,7 @@ if (headerAuthBtn) {
});
}
var trxState = /* @__PURE__ */ Object.create(null);
var trxModules = /* @__PURE__ */ Object.create(null);
var trxModules = {};
Object.defineProperties(trxState, {
serverLat: { get() {
return serverLat;
@@ -5804,8 +5850,10 @@ Object.defineProperties(trxState, {
} }
});
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins();
initializeApp();
void loadEagerPlugins().catch((error) => {
console.error(error);
});
void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas);
function bookmarkDistanceText(bm) {
if (!bm || serverLat == null || serverLon == null) return null;
@@ -5830,7 +5878,7 @@ function buildBookmarkTooltipText(bm) {
}
function nearestBookmarkForHz(hz, widthPx, range) {
const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
return null;
}
const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10);
@@ -5951,7 +5999,6 @@ var wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
var wfmAudioModeEl = document.getElementById("wfm-audio-mode");
var wfmDenoiseEl = document.getElementById("wfm-denoise");
var sdrSettingsRowEl = document.getElementById("sdr-settings-row");
var sdrGainControlsEl = document.getElementById("sdr-gain-controls");
var sdrGainEl = document.getElementById("sdr-gain-db");
var sdrGainSetBtn = document.getElementById("sdr-gain-set");
var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -6056,7 +6103,7 @@ function levelFromChannels(channels, frameCount) {
return Math.min(100, rms * 220);
}
function normalizeWfmDenoiseLevel(value) {
const next = String(value ?? "").toLowerCase();
const next = primitiveString(value).toLowerCase();
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
@@ -6282,7 +6329,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX");
stopTxAudio();
void stopTxAudio();
}, TX_TIMEOUT_SECS * 1e3);
}
function startTxTimeoutCountdown() {
@@ -6441,7 +6488,9 @@ function startRxAudio() {
audioWs.onmessage = (evt) => {
if (typeof evt.data === "string") {
try {
configureRxStream(JSON.parse(evt.data));
const info = parseJsonUnknown(evt.data);
if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata");
configureRxStream(info);
} catch (e) {
console.error("Audio stream info parse error", e);
}
@@ -6518,7 +6567,7 @@ function startRxAudio() {
};
audioWs.onclose = () => {
if (txActive) {
stopTxAudio();
void stopTxAudio();
}
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
@@ -6559,7 +6608,7 @@ function stopRxAudio() {
audioWs = null;
}
if (audioCtx) {
audioCtx.close();
void audioCtx.close();
audioCtx = null;
}
updateWfmControls();
@@ -6587,7 +6636,7 @@ function stopRxAudio() {
}
function startTxAudio() {
if (txActive) {
stopTxAudio();
void stopTxAudio();
return;
}
if (!hasWebCodecs) {
@@ -6780,12 +6829,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files")
]);
if (statusResp.ok) {
const active = await statusResp.json();
renderRecorderActive(active);
const active = await responseJsonUnknown(statusResp);
if (isRecorderActiveList(active)) renderRecorderActive(active);
}
if (filesResp.ok) {
_recorderFiles = await filesResp.json();
renderRecorderFiles();
const files = await responseJsonUnknown(filesResp);
if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
}
} catch (e) {
console.error("Recorder status fetch failed", e);
@@ -6923,10 +6975,8 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true");
btn.textContent = "Hide";
try {
audio.play();
} catch (_) {
}
void audio.play().catch(() => {
});
});
});
el.querySelectorAll(".rec-delete-btn").forEach(function(btn) {
@@ -7011,7 +7061,6 @@ if (sdrSquelchEl) {
}
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
var decodeSource = null;
var decodeConnected = false;
var decodeHistoryWorker = null;
function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
if (!el) return;
@@ -7215,9 +7264,10 @@ function connectDecode() {
decodeHistoryWorker = worker;
worker.onmessage = (evt) => {
if (historySettled || worker !== decodeHistoryWorker) return;
const data = evt?.data || {};
const data = evt.data;
if (!isRecord2(data) || typeof data.type !== "string") return;
if (data.type === "status") {
const phase = String(data.phase || "");
const phase = primitiveString(data.phase);
if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") {
@@ -7235,7 +7285,8 @@ function connectDecode() {
return;
}
if (data.type === "group") {
enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages);
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
}
if (data.type === "done") {
@@ -7246,7 +7297,7 @@ function connectDecode() {
return;
}
if (data.type === "error") {
console.error("Decode history worker failed", data.message || "unknown worker failure");
console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure");
terminateDecodeHistoryWorker();
startDecodeHistoryFallback();
}
@@ -7268,21 +7319,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode");
const source = decodeSource;
source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets");
};
source.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
if (historySettled) dispatchDecodeMessage(msg);
else liveBuffer.push(msg);
const msg = parseJsonUnknown(evt.data);
if (!isRecord2(msg) || typeof msg.type !== "string") return;
const decoded = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) {
}
};
source.onerror = () => {
const wasClosed = source.readyState === 2;
source.close();
decodeConnected = false;
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
@@ -7612,11 +7663,12 @@ function startSpectrumStreaming() {
};
source.addEventListener("b", (evt) => {
try {
const commaA = evt.data.indexOf(",");
const commaB = evt.data.indexOf(",", commaA + 1);
const centerHz = Number(evt.data.slice(0, commaA));
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1);
const eventData = messageEventData(evt);
const commaA = eventData.indexOf(",");
const commaB = eventData.indexOf(",", commaA + 1);
const centerHz = Number(eventData.slice(0, commaA));
const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64Int8(b64);
const rds = lastSpectrumData?.rds;
@@ -7652,7 +7704,9 @@ function startSpectrumStreaming() {
});
source.addEventListener("rds", (evt) => {
try {
const rds = evt.data === "null" ? void 0 : JSON.parse(evt.data);
const eventData = messageEventData(evt);
const value = eventData === "null" ? void 0 : parseJsonUnknown(eventData);
const rds = value !== void 0 && isRdsData(value) ? value : null;
if (lastSpectrumData) lastSpectrumData.rds = rds;
updateRdsPsOverlay(rds ?? null);
} catch (_) {
@@ -7660,22 +7714,20 @@ function startSpectrumStreaming() {
});
source.addEventListener("rds_vchan", (evt) => {
try {
const payload = evt.data === "null" ? [] : JSON.parse(evt.data);
const eventData = messageEventData(evt);
const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
const next = /* @__PURE__ */ new Map();
const nextSig = /* @__PURE__ */ new Map();
if (Array.isArray(payload)) {
payload.forEach((entry) => {
if (entry && entry.id) {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
}
});
}
payload.forEach((entry) => {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
});
vchanRdsById = next;
vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId);
sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay();
}
updateRdsPsOverlay(primaryRds);
@@ -7758,8 +7810,8 @@ function startMeterStreaming() {
meterSource = new EventSource(url);
meterSource.onmessage = (evt) => {
try {
const { sig } = JSON.parse(evt.data);
applyMeterSample(sig);
const value = parseJsonUnknown(evt.data);
if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) {
}
};
@@ -7794,10 +7846,10 @@ function clearSpectrumCanvas() {
}
}
function formatOverlayPs(ps) {
return String(ps ?? "").slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
}
function formatPsHtml(ps) {
const clipped = String(ps ?? "").slice(0, 8);
const clipped = primitiveString(ps).slice(0, 8);
let html = "";
for (let i = 0; i < 8; i += 1) {
const ch = clipped[i];
@@ -7813,8 +7865,10 @@ function formatOverlayPi(pi) {
return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --";
}
function formatOverlayPty(pty, ptyName) {
if (ptyName) return ptyName;
return pty != null ? String(pty) : "--";
const name = primitiveString(ptyName);
if (name) return name;
const code = primitiveString(pty);
return code || "--";
}
function overlayTrafficFlagHtml(label, active) {
const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
@@ -7913,13 +7967,13 @@ async function copyRdsRawToClipboard() {
var rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => {
copyRdsPsToClipboard();
void copyRdsPsToClipboard();
});
}
var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => {
copyRdsRawToClipboard();
void copyRdsRawToClipboard();
});
}
var rdsAfListEl = document.getElementById("rds-af-list");
@@ -8202,7 +8256,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4;
const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -8329,7 +8383,6 @@ function bmCategoryColorMap() {
function createBookmarkChip(bm, colorMap, options = {}) {
const span = document.createElement("span");
const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "Hz";
const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
span.className = "spectrum-bookmark-chip";
if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side");
@@ -8374,8 +8427,7 @@ function updateBookmarkAxis(range) {
const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3);
const rightBookmarks = allBookmarks.filter((bm) => bm.freq_hz > range.visHiHz).sort((a, b) => a.freq_hz - b.freq_hz).slice(0, 3);
@@ -9106,11 +9158,12 @@ var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
var bandplanRegionSelect = document.getElementById("bandplan-region-select");
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
(function loadBandplanJson() {
fetch("/bandplan.json").then((r) => {
if (!r.ok) throw new Error(String(r.status));
return r.json();
}).then((d) => {
bandplanData = d;
fetch("/bandplan.json").then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
}).then((data) => {
if (!isRecord2(data)) return;
bandplanData = data;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
}).catch(() => {
@@ -36,35 +36,18 @@ export default tseslint.config(
rules: {
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
caughtErrors: "none",
varsIgnorePattern: "^_",
}],
// DOM event targets intentionally ignore listener return values. Async
// listeners handle their own failures; keep the rule for all other
// promise misuse sites.
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
},
},
{
// app.ts is the final migrated monolith. The compiler checks it with the
// full strict baseline; rules that require runtime schemas or broad async
// rewrites stay disabled here until those responsibilities are extracted.
files: ["src/app.ts"],
rules: {
"prefer-const": "off",
"@typescript-eslint/no-base-to-string": "off",
"@typescript-eslint/no-duplicate-type-constituents": "off",
"@typescript-eslint/no-floating-promises": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-unnecessary-condition": "off",
"@typescript-eslint/no-unnecessary-type-assertion": "off",
"@typescript-eslint/no-unnecessary-type-conversion": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/unbound-method": "off",
},
},
{
files: ["src/decode-history-worker.ts"],
languageOptions: {
@@ -44,9 +44,10 @@ import {
} from "./features/spectrum/math.js";
import type { NumericBins } from "./features/spectrum/math.js";
import type { AuthRole } from "./api/auth.js";
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js";
import type { RdsData, RigCapabilities, RigListItem, RigSnapshot, VchanRdsEntry } from "./api/generated.js";
import type { TrxPluginRuntime } from "./plugins/runtime-contract.js";
import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js";
import { isRigListResponse, isRigSnapshot } from "./api/client.js";
interface SpectrumFrame {
bins: NumericBins;
@@ -224,6 +225,47 @@ interface TrxModules {
};
screenshot?: { captureSpectrumScreenshot(): Promise<boolean> };
}
interface TrxState {
serverLat: number | null;
serverLon: number | null;
readonly lastFreqHz: number | null;
readonly lastActiveRigId: string | null;
readonly lastRigIds: string[];
readonly lastRigDisplayNames: Record<string, string>;
readonly initialMapZoom: number;
readonly decodeHistoryRetentionMin: number;
readonly authRole: AuthRole | null;
readonly decoderRegistry: typeof decoderRegistry;
readonly sseSessionId: string | null;
readonly primaryRds: RdsData | null;
readonly vchanRdsById: Map<string, RdsData | null>;
readonly vchanSignalDbById: Map<string, number>;
lastCityLabel: string;
readonly serverVersion: string | null;
readonly serverBuildDate: string | null;
readonly serverCallsign: string | null;
readonly ownerCallsign: string | null;
readonly ownerWebsiteUrl: string | null;
readonly ownerWebsiteName: string | null;
readonly aisVesselUrlBase: string | null;
readonly serverRigs: RigListItem[];
readonly serverActiveRigId: string | null;
readonly lastModeName: string;
readonly lastSpectrumData: SpectrumFrame | null;
readonly lastSpectrumRenderData: SpectrumFrame | null;
currentBandwidthHz: number;
readonly spectrumFloor: number;
readonly spectrumRange: number;
readonly spectrumCanvas: HTMLCanvasElement;
readonly overviewCanvas: HTMLCanvasElement;
readonly overviewGl: TrxWebGlRenderer;
readonly spectrumGl: TrxWebGlRenderer;
readonly signalOverlayGl: TrxWebGlRenderer;
readonly decodeHistoryReplayActive: boolean;
readonly decodeMapSyncPending: boolean;
readonly _activeTab: TabName;
readonly locationSubtitle: HTMLElement | null;
}
interface TrxUi {
confirm(options?: { title?: string; message?: string; confirmLabel?: string; danger?: boolean }): Promise<boolean>;
notify(message: string, options?: { kind?: "info" | "error" | "success" | "warning"; duration?: number; action?: { label?: string; run(): void } | null }): HTMLDivElement;
@@ -250,9 +292,67 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function primitiveString(value: unknown): string {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
? String(value)
: "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseJsonUnknown(source: string): unknown {
return JSON.parse(source) as unknown;
}
function messageEventData(event: Event): string {
const data: unknown = (event as MessageEvent<unknown>).data;
if (typeof data !== "string") throw new TypeError("Expected a text event payload");
return data;
}
async function responseJsonUnknown(response: Response): Promise<unknown> {
return await response.json() as unknown;
}
function isAppUpdate(value: unknown): value is AppUpdate {
return isRecord(value) && (value.status === undefined || isRecord(value.status));
}
function isAudioStreamInfo(value: unknown): value is AudioStreamInfo {
return isRecord(value)
&& typeof value.sample_rate === "number"
&& typeof value.channels === "number";
}
function isRecorderActiveList(value: unknown): value is RecorderActive[] {
return Array.isArray(value) && value.every((entry) => isRecord(entry)
&& typeof entry.rig_id === "string"
&& typeof entry.path === "string"
&& typeof entry.started_at === "number");
}
function isRecorderFileList(value: unknown): value is RecorderFile[] {
return Array.isArray(value) && value.every((entry) => isRecord(entry)
&& typeof entry.name === "string"
&& typeof entry.size === "number");
}
function isRdsData(value: unknown): value is RdsData {
return isRecord(value);
}
function isVchanRdsEntry(value: unknown): value is VchanRdsEntry {
return isRecord(value)
&& typeof value.id === "string"
&& (value.rds === undefined || value.rds === null || isRdsData(value.rds))
&& (value.signal_db === undefined || value.signal_db === null || typeof value.signal_db === "number");
}
declare global {
interface Window {
trx: { state: Record<string, unknown>; core: Record<string, unknown>; modules: TrxModules };
trx: { state: TrxState; core: Readonly<Record<string, unknown>>; modules: TrxModules };
trxUi: TrxUi;
trxPluginRuntime: TrxPluginRuntime;
lastSpectrumData: SpectrumFrame | null;
@@ -269,8 +369,6 @@ declare global {
navigateToTab(name: TabName, options?: { updateHistory?: boolean; replaceHistory?: boolean }): void;
_syncRecorderState(enabled: boolean): void;
refreshRdsUi(): void;
loadEagerPlugins?(): Promise<void>;
loadPluginsForTab?(tab: string): Promise<void>;
refreshCwTonePicker?(): void;
updateFt8RfDisplay?(): void;
clearSatPredictionDom?(): void;
@@ -430,7 +528,6 @@ function applyAuthRestrictions() {
const txLimitBtn = document.getElementById("tx-limit-btn") as HTMLButtonElement | null;
const txAudioBtn = document.getElementById("tx-audio-btn") as HTMLButtonElement | null;
const txLimitRow = document.getElementById("tx-limit-row");
const vfoPicker = document.getElementById("vfo-picker");
const jogUp = document.getElementById("jog-up") as HTMLButtonElement | null;
const jogDown = document.getElementById("jog-down") as HTMLButtonElement | null;
const jogButtons = document.querySelectorAll<HTMLButtonElement>(".jog-step button");
@@ -526,7 +623,7 @@ function applyCapabilities(caps: RigCapabilities | null) {
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
stopTxAudio();
void stopTxAudio();
}
// TX limit row
@@ -814,11 +911,10 @@ function decodeHistoryMapRenderingDeferred() {
let lastSpectrumData: SpectrumFrame | null = null;
window.lastSpectrumData = null;
let lastControl;
let lastTxEn: boolean | null = null;
let lastHasTx = true;
let lastRendered: string | null = null;
let prevRenderData: Record<string, unknown> = {};
const prevRenderData: Record<string, unknown> = {};
let hintTimer: ReturnType<typeof setTimeout> | null = null;
let sigMeasuring = false;
let sigLastSUnits: number | null = null;
@@ -941,7 +1037,7 @@ function updateDocumentTitle(rds: RdsData | null = null) {
document.title = originalTitle;
return;
}
const parts = [formatFreq(freqHz as number)];
const parts = [formatFreq(freqHz)];
const ps = rds?.program_service;
if (ps && ps.length > 0) {
parts.push(ps);
@@ -1327,7 +1423,7 @@ function applyRigList(activeRigId: string | null, rigIds: string[], displayNames
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.populateScopePicker();
window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
}
window.trx.modules.map?.updateMapRigFilter();
}
@@ -1337,8 +1433,9 @@ async function refreshRigList() {
try {
const resp = await fetch("/rigs", { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const rigs = Array.isArray(data.rigs) ? data.rigs : [];
const data = await responseJsonUnknown(resp);
if (!isRigListResponse(data)) return;
const rigs = data.rigs;
const rigIds = rigs.map((r: RigListItem) => r.remote).filter(Boolean);
const displayNames: Record<string, string> = {};
rigs.forEach((r: RigListItem) => {
@@ -1354,7 +1451,7 @@ async function refreshRigList() {
});
serverRigs = rigs;
refreshOperatorLayoutCapabilities();
serverActiveRigId = data.active_remote || null;
serverActiveRigId = data.active_remote;
applyRigList(data.active_remote, rigIds, displayNames);
window.trx.modules.map?.syncAprsReceiverMarker();
} catch (e) {
@@ -1399,7 +1496,7 @@ setInterval(() => {
if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart);
}, 1000);
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let overviewSignalSamples: SignalSample[] = [];
const overviewSignalSamples: SignalSample[] = [];
let overviewSignalTimer: ReturnType<typeof setInterval> | null = null;
let overviewWaterfallRows: NumericBins[] = [];
let overviewWaterfallPushCount = 0; // monotonically increments on every push
@@ -1413,8 +1510,9 @@ let overviewWfTexPalKey = "";
let overviewWfTexReady = false;
function cssColorToRgba(color: string, alphaMul = 1): Rgba {
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
const parsed = parser ? parser(color) : [0, 0, 0, 1];
const parsed = typeof window.trxParseCssColor === "function"
? window.trxParseCssColor(color)
: [0, 0, 0, 1];
return [
parsed[0] ?? 0,
parsed[1] ?? 0,
@@ -1447,7 +1545,7 @@ function overviewWfResetTextureCache() {
}
function overviewWfPaletteKey(pal: CanvasPalette, viewKey = "") {
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
}
function resizeHeaderSignalCanvas() {
@@ -1526,9 +1624,9 @@ function drawSignalOverlay() {
const bwStroke = BW_OVERLAY_COLORS.stroke;
const bwHard = BW_OVERLAY_COLORS.hard;
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
if (Array.isArray(bmRef) && bmRef.length > 0) {
if (bmRef && bmRef.length > 0) {
const colorMap = bmCategoryColorMap();
const grouped = new Map();
const grouped = new Map<string, number[]>();
for (const bm of bmRef) {
const f = Number(bm?.freq_hz);
if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue;
@@ -1536,8 +1634,9 @@ function drawSignalOverlay() {
const x = hzToX(f);
if (!isFiniteNumber(x) || x < 0 || x > W) continue;
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
if (!grouped.has(color)) grouped.set(color, []);
grouped.get(color).push(x, 0, x, H);
const segments = grouped.get(color) ?? [];
segments.push(x, 0, x, H);
grouped.set(color, segments);
}
for (const [color, segments] of grouped.entries()) {
if (!Array.isArray(segments) || segments.length === 0) continue;
@@ -1856,7 +1955,7 @@ let _wfLutKey = "";
const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range
function ensureWaterfallLut(pal: CanvasPalette, minDb: number, maxDb: number) {
const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`;
const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`;
if (key === _wfLutKey) return;
_wfLutKey = key;
for (let i = 0; i < 256; i++) {
@@ -2000,7 +2099,7 @@ function renderRdsOverlays() {
el.innerHTML = html;
el.addEventListener("click", (evt) => {
evt.stopPropagation();
copyRdsPsToClipboard(entry.rds, entry.freq_hz);
void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
});
el.addEventListener("mouseenter", () => {
el.style.zIndex = String(entries.length + 10);
@@ -2211,14 +2310,6 @@ function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz);
}
function isAisMode(mode = modeEl ? modeEl.value : "") {
return String(mode || "").toUpperCase() === "AIS";
}
function isVdesMode(mode = modeEl ? modeEl.value : "") {
return String(mode || "").toUpperCase() === "VDES";
}
function visibleBandwidthSpecs(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : ""): BandwidthSpec[] {
if (!isFiniteNumber(freqHz)) return [];
const modeUpper = String(mode || "").toUpperCase();
@@ -2273,10 +2364,6 @@ function coverageSpanForMode(freqHz: number | null, bandwidthHz = coverageGuardB
return { loHz, hiHz };
}
function visibleBandwidthCenters(freqHz: number | null = lastFreqHz, mode = modeEl ? modeEl.value : "") {
return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz);
}
function effectiveSpectrumCoverageSpanHz(sampleRateHz: number) {
const sampleRate = Number(sampleRateHz);
if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0;
@@ -2785,7 +2872,7 @@ function updateJogStepSupport(cap: RigCapabilities | null) {
function normalizeMode(modeVal: unknown): string {
if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") {
if (isRecord(modeVal)) {
const entries = Object.entries(modeVal);
if (entries.length > 0) {
const firstEntry = entries[0];
@@ -3402,7 +3489,7 @@ function render(update: AppUpdate) {
// When SDR backend is active (spectrum visible), apply BW default for new
// mode — but only if the server hasn't already pushed a filter_state.
if (lastSpectrumData && !update.filter) {
applyBwDefaultForMode(mode, false);
void applyBwDefaultForMode(mode, false);
}
}
updateWfmControls();
@@ -3559,7 +3646,6 @@ function render(update: AppUpdate) {
powerBtn.setAttribute("aria-pressed", "false");
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = String(update.status.tx.limit);
@@ -3713,9 +3799,10 @@ async function pollFreshSnapshot() {
: "/status";
const resp = await fetch(statusUrl, { cache: "no-store" });
if (!resp.ok) return;
const data = await resp.json();
const data = await responseJsonUnknown(resp);
if (!isRigSnapshot(data)) return;
render(data);
refreshRigList();
void refreshRigList();
lastEventAt = Date.now();
} catch (e) {
// Ignore network errors; connect() retry loop handles reconnection.
@@ -3732,7 +3819,7 @@ function connect() {
}
stopMeterStreaming();
startMeterStreaming();
pollFreshSnapshot();
void pollFreshSnapshot();
const eventsUrl = lastActiveRigId
? `/events?remote=${encodeURIComponent(lastActiveRigId)}`
: "/events";
@@ -3743,13 +3830,14 @@ function connect() {
setConnLostOverlay(false);
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
if (!aboutUptimeStart) aboutUptimeStart = Date.now();
pollFreshSnapshot();
refreshRigList();
void pollFreshSnapshot();
void refreshRigList();
};
source.onmessage = (evt) => {
source.onmessage = (evt: MessageEvent<string>) => {
try {
if (evt.data === lastRendered) return;
const data = JSON.parse(evt.data);
const data = parseJsonUnknown(evt.data);
if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape");
lastRendered = evt.data;
render(data);
lastEventAt = Date.now();
@@ -3769,13 +3857,14 @@ function connect() {
});
source.addEventListener("session", evt => {
try {
const d = JSON.parse(evt.data);
sseSessionId = d.session_id || null;
const eventData = messageEventData(evt);
const d = parseJsonUnknown(eventData);
sseSessionId = isRecord(d) && typeof d.session_id === "string" ? d.session_id : null;
} catch (_) {}
window.trx.modules.vchan?.handleSession(evt.data);
window.trx.modules.vchan?.handleSession(messageEventData(evt));
});
source.addEventListener("channels", evt => {
window.trx.modules.vchan?.handleChannels(evt.data);
window.trx.modules.vchan?.handleChannels(messageEventData(evt));
});
source.onerror = () => {
// Check if this is an auth error by looking at readyState
@@ -3783,7 +3872,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(1000);
}
};
@@ -3794,7 +3883,7 @@ function connect() {
powerHint.textContent = "trx-client connection lost, retrying\u2026";
setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true);
source.close();
pollFreshSnapshot();
void pollFreshSnapshot();
scheduleReconnect(250);
}
}, 5000);
@@ -3827,12 +3916,8 @@ function disconnect() {
// Yield the main thread so the browser can paint before heavy async work.
// Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback.
function yieldToMain() {
return new Promise((resolve) => setTimeout(resolve, 0));
}
const uiFrameJobs = new Map();
let uiFrameJobsHandle: ReturnType<typeof setTimeout> | number | null = null;
const uiFrameJobs = new Map<string, () => void>();
let uiFrameJobsHandle: ReturnType<typeof setTimeout> | null = null;
function flushUiFrameJobs() {
uiFrameJobsHandle = null;
@@ -3929,7 +4014,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
window.trxUi?.setActiveRig(lastActiveRigId);
window.trx.modules.scheduler?.setRig(lastActiveRigId);
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
void window.trx.modules.bookmarks?.fetch((document.getElementById("bm-category-filter") as HTMLSelectElement | null)?.value || "");
window.trx.modules.map?.syncAprsReceiverMarker();
connect();
stopSpectrumStreaming();
@@ -3954,7 +4039,7 @@ async function switchRigFromSelect(selectEl: HTMLSelectElement) {
}
if (headerRigSwitchSelect) {
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
headerRigSwitchSelect.addEventListener("change", () => { void switchRigFromSelect(headerRigSwitchSelect); });
}
function setControlPending(control: HTMLButtonElement | HTMLInputElement | HTMLSelectElement | null, pending: boolean) {
@@ -4057,7 +4142,7 @@ if (centerFreqEl) {
centerFreqDirty = true;
if (e.key === "Enter") {
e.preventDefault();
applyCenterFreqFromInput();
void applyCenterFreqFromInput();
} else if (e.key === "Escape") {
centerFreqDirty = false;
refreshCenterFreqDisplay();
@@ -4443,15 +4528,15 @@ if (spectrumBwInput) {
spectrumBwInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
applyBandwidthFromInput();
void applyBandwidthFromInput();
}
});
}
if (spectrumBwSetBtn) {
spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); });
spectrumBwSetBtn.addEventListener("click", () => { void applyBandwidthFromInput(); });
}
if (spectrumBwAutoBtn) {
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); });
spectrumBwAutoBtn.addEventListener("click", () => { void applyAutoBandwidth(); });
}
if (spectrumBwSweetBtn) {
spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); });
@@ -4539,7 +4624,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
window.trx.modules.map?.scheduleStatsRender();
}
if (name === "recorder") {
refreshRecorderStatus();
void refreshRecorderStatus();
}
}
window.navigateToTab = navigateToTab;
@@ -4604,10 +4689,6 @@ window.addEventListener("popstate", () => {
window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
// --- Auth startup sequence ---
function getAvailableRigIds() {
return lastRigIds || [];
}
async function initializeApp() {
showAuthGate(false);
const authStatus = await checkAuthStatus();
@@ -4687,7 +4768,7 @@ requiredElement<HTMLFormElement>("auth-form").addEventListener("submit", async (
// Setup guest button
const guestBtn = document.getElementById("auth-guest-btn") as HTMLButtonElement | null;
if (guestBtn) {
guestBtn.addEventListener("click", async () => {
guestBtn.addEventListener("click", () => {
authRole = "rx";
requiredElement<HTMLInputElement>("auth-passphrase").value = "";
hideAuthGate();
@@ -4721,12 +4802,12 @@ if (headerAuthBtn) {
// Modules (map-core.js, screenshot.js) access core state and utilities via
// window.trx. Modules register their own APIs as sub-namespaces
// (e.g. window.trx.modules.map, window.trx.modules.screenshot).
const trxState = Object.create(null);
const trxModules = Object.create(null);
const trxState = Object.create(null) as TrxState;
const trxModules: TrxModules = {};
// -- State getters (backed by core-scoped variables) --
Object.defineProperties(trxState, {
serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
serverLat: { get() { return serverLat; }, set(v: number | null) { serverLat = v; } },
serverLon: { get() { return serverLon; }, set(v: number | null) { serverLon = v; } },
lastFreqHz: { get() { return lastFreqHz; } },
lastActiveRigId: { get() { return lastActiveRigId; } },
lastRigIds: { get() { return lastRigIds; } },
@@ -4739,7 +4820,7 @@ Object.defineProperties(trxState, {
primaryRds: { get() { return primaryRds; } },
vchanRdsById: { get() { return vchanRdsById; } },
vchanSignalDbById: { get() { return vchanSignalDbById; } },
lastCityLabel: { get() { return lastCityLabel; }, set(v) { lastCityLabel = v; } },
lastCityLabel: { get() { return lastCityLabel; }, set(v: string) { lastCityLabel = v; } },
serverVersion: { get() { return serverVersion; } },
serverBuildDate: { get() { return serverBuildDate; } },
serverCallsign: { get() { return serverCallsign; } },
@@ -4752,7 +4833,7 @@ Object.defineProperties(trxState, {
lastModeName: { get() { return lastModeName; } },
lastSpectrumData: { get() { return lastSpectrumData; } },
lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } },
currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v: number) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
spectrumFloor: { get() { return spectrumFloor; } },
spectrumRange: { get() { return spectrumRange; } },
spectrumCanvas: { get() { return spectrumCanvas; } },
@@ -4791,7 +4872,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules
void loadEagerPlugins().catch((error: unknown) => { console.error(error); });
// Start the app
initializeApp();
void initializeApp();
window.addEventListener("resize", resizeHeaderSignalCanvas);
@@ -4826,11 +4907,11 @@ function buildBookmarkTooltipText(bm: Bookmark) {
function nearestBookmarkForHz(hz: number, widthPx: number, range: SpectrumRange) {
const ref = window.trx.modules.bookmarks?.overlayList ?? null;
if (!Array.isArray(ref) || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
return null;
}
const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10);
let best = null;
let best: Bookmark | null = null;
let bestDelta = Number.POSITIVE_INFINITY;
for (const bm of ref) {
const delta = Math.abs(Number(bm.freq_hz) - hz);
@@ -4961,7 +5042,6 @@ const wfmDeemphasisEl = document.getElementById("wfm-deemphasis") as HTMLSelectE
const wfmAudioModeEl = document.getElementById("wfm-audio-mode") as HTMLSelectElement | null;
const wfmDenoiseEl = document.getElementById("wfm-denoise") as HTMLSelectElement | null;
const sdrSettingsRowEl = document.getElementById("sdr-settings-row");
const sdrGainControlsEl = document.getElementById("sdr-gain-controls");
const sdrGainEl = document.getElementById("sdr-gain-db") as HTMLInputElement | null;
const sdrGainSetBtn = document.getElementById("sdr-gain-set") as HTMLButtonElement | null;
const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
@@ -5076,7 +5156,7 @@ function levelFromChannels(channels: Float32Array[], frameCount: number) {
}
function normalizeWfmDenoiseLevel(value: unknown) {
const next = String(value ?? "").toLowerCase();
const next = primitiveString(value).toLowerCase();
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
return "auto";
}
@@ -5306,7 +5386,7 @@ function resetTxTimeout() {
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
txTimeoutTimer = setTimeout(() => {
console.warn("PTT safety timeout — stopping TX");
stopTxAudio();
void stopTxAudio();
}, TX_TIMEOUT_SECS * 1000);
}
@@ -5388,7 +5468,7 @@ function extractAudioFrameChannels(frame: AudioData) {
}
// Optional channel_id injected by vchan.js when connecting to a virtual channel.
let _audioChannelOverride: string | null = null;
const _audioChannelOverride: string | null = null;
/** Schedule decoded PCM channels for playback via Web Audio API. */
function scheduleDecodedAudio(channelData: Float32Array[], frameCount: number, sampleRate: number) {
@@ -5470,7 +5550,9 @@ function startRxAudio() {
if (typeof evt.data === "string") {
// Stream info JSON
try {
configureRxStream(JSON.parse(evt.data));
const info = parseJsonUnknown(evt.data);
if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata");
configureRxStream(info);
} catch (e) {
console.error("Audio stream info parse error", e);
}
@@ -5543,7 +5625,7 @@ function startRxAudio() {
} catch (e) { /* ignore per-frame errors */ }
} else if (wasmOpusDecoder) {
try {
const result = wasmOpusDecoder.decodeFrame(data);
const result = wasmOpusDecoder.decodeFrame(data as Uint8Array<ArrayBufferLike>);
if (result && result.samplesDecoded > 0) {
scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48000);
}
@@ -5553,7 +5635,7 @@ function startRxAudio() {
audioWs.onclose = () => {
// If TX was active when WS closed, release PTT
if (txActive) { stopTxAudio(); }
if (txActive) { void stopTxAudio(); }
rxActive = false;
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
@@ -5585,7 +5667,7 @@ function stopRxAudio() {
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
streamInfo = null;
if (audioWs) { audioWs.close(); audioWs = null; }
if (audioCtx) { audioCtx.close(); audioCtx = null; }
if (audioCtx) { void audioCtx.close(); audioCtx = null; }
updateWfmControls();
rxGainNode = null;
if (opusDecoder) {
@@ -5605,7 +5687,7 @@ function stopRxAudio() {
}
function startTxAudio() {
if (txActive) { stopTxAudio(); return; }
if (txActive) { void stopTxAudio(); return; }
if (!hasWebCodecs) {
audioStatus.textContent = "Audio requires Chrome/Edge";
return;
@@ -5796,12 +5878,15 @@ async function refreshRecorderStatus() {
fetch("/api/recorder/files"),
]);
if (statusResp.ok) {
const active = await statusResp.json();
renderRecorderActive(active);
const active = await responseJsonUnknown(statusResp);
if (isRecorderActiveList(active)) renderRecorderActive(active);
}
if (filesResp.ok) {
_recorderFiles = await filesResp.json();
renderRecorderFiles();
const files = await responseJsonUnknown(filesResp);
if (isRecorderFileList(files)) {
_recorderFiles = files;
renderRecorderFiles();
}
}
} catch (e) {
console.error("Recorder status fetch failed", e);
@@ -5933,7 +6018,7 @@ function renderRecorderFiles() {
row.parentNode?.insertBefore(playerRow, row.nextSibling);
btn.setAttribute("aria-expanded", "true");
btn.textContent = "Hide";
try { audio.play(); } catch (_) {}
void audio.play().catch(() => {});
});
});
@@ -6009,7 +6094,6 @@ requiredElement("copyright-year").textContent = String(new Date().getFullYear())
// --- Server-side decode SSE ---
let decodeSource: EventSource | null = null;
let decodeConnected = false;
let decodeHistoryWorker: Worker | null = null;
function setModeBoundDecodeStatus(el: HTMLElement | null, activeModes: string[], inactiveText: string, connectedText: string) {
if (!el) return;
@@ -6047,27 +6131,6 @@ function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
}
}
function dispatchDecodeBatch(batch: DecodeMessage[]) {
if (!Array.isArray(batch) || batch.length === 0) return;
// Record statistics for every message in the batch regardless of dispatch path.
for (const msg of batch) {
if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
}
}
window.trx.modules.map?.scheduleStatsRender();
const type = String(batch[0]?.type || "");
const uniformType = batch.every((msg) => String(msg?.type || "") === type);
if (uniformType && type) {
window.trxPluginRuntime.dispatchBatch(type, batch);
return;
}
for (const msg of batch) {
dispatchDecodeMessage(msg, true);
}
}
const DECODE_HISTORY_TYPE_BATCH_LIMIT = 192;
const DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
const DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8;
@@ -6249,11 +6312,12 @@ function connectDecode() {
return false;
}
decodeHistoryWorker = worker;
worker.onmessage = (evt) => {
worker.onmessage = (evt: MessageEvent<unknown>) => {
if (historySettled || worker !== decodeHistoryWorker) return;
const data = evt?.data || {};
const data = evt.data;
if (!isRecord(data) || typeof data.type !== "string") return;
if (data.type === "status") {
const phase = String(data.phase || "");
const phase = primitiveString(data.phase);
if (phase === "fetching") {
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
} else if (phase === "decoding") {
@@ -6271,7 +6335,10 @@ function connectDecode() {
return;
}
if (data.type === "group") {
enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages);
const messages = Array.isArray(data.messages)
? data.messages.filter((message): message is DecodeMessage => isRecord(message) && typeof message.type === "string")
: [];
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
return;
}
if (data.type === "done") {
@@ -6282,7 +6349,7 @@ function connectDecode() {
return;
}
if (data.type === "error") {
console.error("Decode history worker failed", data.message || "unknown worker failure");
console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure");
terminateDecodeHistoryWorker();
startDecodeHistoryFallback();
}
@@ -6307,21 +6374,21 @@ function connectDecode() {
decodeSource = new EventSource("/decode");
const source = decodeSource;
source.onopen = () => {
decodeConnected = true;
updateDecodeStatus("Connected, listening for packets");
};
source.onmessage = (evt) => {
source.onmessage = (evt: MessageEvent<string>) => {
try {
const msg = JSON.parse(evt.data);
if (historySettled) dispatchDecodeMessage(msg);
else liveBuffer.push(msg);
const msg = parseJsonUnknown(evt.data);
if (!isRecord(msg) || typeof msg.type !== "string") return;
const decoded: DecodeMessage = { ...msg, type: msg.type };
if (historySettled) dispatchDecodeMessage(decoded);
else liveBuffer.push(decoded);
} catch (e) { /* ignore parse errors */ }
};
source.onerror = () => {
// readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop
const wasClosed = source.readyState === 2;
source.close();
decodeConnected = false;
terminateDecodeHistoryWorker();
if (!historySettled) flushLiveBuffer();
if (wasClosed) {
@@ -6385,7 +6452,7 @@ let spectrumRange = 90;
let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20;
const SPECTRUM_SMOOTH_ALPHA = 0.42;
let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
const _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
// Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX: number | null = null;
let spectrumCrosshairY: number | null = null;
@@ -6534,7 +6601,7 @@ function buildSpectrumRenderData(frame: SpectrumFrame): SpectrumFrame {
prev.bins.length === n &&
prev.sample_rate === frame.sample_rate &&
prev.center_hz === frame.center_hz;
if (_smoothBins.length !== n) _smoothBins = new Array(n);
if (_smoothBins.length !== n) _smoothBins = new Array<number>(n);
const src = frame.bins;
if (canBlend) {
const prevBins = prev.bins;
@@ -6730,11 +6797,12 @@ function startSpectrumStreaming() {
// Bins are i8 (1 dB/step), base64-encoded for ~5× size reduction vs JSON f32 array.
source.addEventListener("b", (evt) => {
try {
const commaA = evt.data.indexOf(",");
const commaB = evt.data.indexOf(",", commaA + 1);
const centerHz = Number(evt.data.slice(0, commaA));
const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
const b64 = evt.data.slice(commaB + 1);
const eventData = messageEventData(evt);
const commaA = eventData.indexOf(",");
const commaB = eventData.indexOf(",", commaA + 1);
const centerHz = Number(eventData.slice(0, commaA));
const sampleRate = Number(eventData.slice(commaA + 1, commaB));
const b64 = eventData.slice(commaB + 1);
const hadSpectrum = !!lastSpectrumData;
const bins = decodeBase64ToInt8(b64);
// Preserve any RDS data from the last rds event.
@@ -6772,29 +6840,29 @@ function startSpectrumStreaming() {
// Named "rds" event = RDS metadata changed (emitted only when it changes).
source.addEventListener("rds", (evt) => {
try {
const rds = evt.data === "null" ? undefined : JSON.parse(evt.data);
const eventData = messageEventData(evt);
const value = eventData === "null" ? undefined : parseJsonUnknown(eventData);
const rds = value !== undefined && isRdsData(value) ? value : null;
if (lastSpectrumData) lastSpectrumData.rds = rds;
updateRdsPsOverlay(rds ?? null);
} catch (_) {}
});
source.addEventListener("rds_vchan", (evt) => {
try {
const payload = evt.data === "null" ? [] : JSON.parse(evt.data);
const next = new Map();
const nextSig = new Map();
if (Array.isArray(payload)) {
payload.forEach((entry) => {
if (entry && entry.id) {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
}
});
}
const eventData = messageEventData(evt);
const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
const next = new Map<string, RdsData | null>();
const nextSig = new Map<string, number>();
payload.forEach((entry) => {
next.set(entry.id, entry.rds ?? null);
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
});
vchanRdsById = next;
vchanSignalDbById = nextSig;
const virtualChannelId = window.trx.modules.vchan?.activeId;
if (virtualChannelId && nextSig.has(virtualChannelId)) {
sigLastDbm = nextSig.get(virtualChannelId);
sigLastDbm = nextSig.get(virtualChannelId) ?? null;
refreshSigStrengthDisplay();
}
updateRdsPsOverlay(primaryRds);
@@ -6893,10 +6961,10 @@ function startMeterStreaming() {
? `/meter?remote=${encodeURIComponent(lastActiveRigId)}`
: "/meter";
meterSource = new EventSource(url);
meterSource.onmessage = (evt) => {
meterSource.onmessage = (evt: MessageEvent<string>) => {
try {
const { sig } = JSON.parse(evt.data);
applyMeterSample(sig);
const value = parseJsonUnknown(evt.data);
if (isRecord(value) && typeof value.sig === "number") applyMeterSample(value.sig);
} catch (_) {}
};
meterSource.onerror = () => {
@@ -6934,14 +7002,14 @@ function clearSpectrumCanvas() {
}
function formatOverlayPs(ps: unknown) {
return String(ps ?? "")
return primitiveString(ps)
.slice(0, 8)
.padEnd(8, "_")
.replaceAll(" ", "_");
}
function formatPsHtml(ps: unknown) {
const clipped = String(ps ?? "").slice(0, 8);
const clipped = primitiveString(ps).slice(0, 8);
let html = "";
for (let i = 0; i < 8; i += 1) {
const ch = clipped[i];
@@ -6961,8 +7029,10 @@ function formatOverlayPi(pi: unknown) {
}
function formatOverlayPty(pty: unknown, ptyName: unknown) {
if (ptyName) return ptyName;
return pty != null ? String(pty) : "--";
const name = primitiveString(ptyName);
if (name) return name;
const code = primitiveString(pty);
return code || "--";
}
function overlayTrafficFlagHtml(label: string, active: boolean | null | undefined) {
@@ -7077,11 +7147,11 @@ async function copyRdsRawToClipboard() {
const rdsPsValueEl = document.getElementById("rds-ps");
if (rdsPsValueEl) {
rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); });
rdsPsValueEl.addEventListener("click", () => { void copyRdsPsToClipboard(); });
}
const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn") as HTMLButtonElement | null;
if (rdsRawCopyBtn) {
rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); });
rdsRawCopyBtn.addEventListener("click", () => { void copyRdsRawToClipboard(); });
}
const rdsAfListEl = document.getElementById("rds-af-list");
if (rdsAfListEl) {
@@ -7408,7 +7478,7 @@ function drawSpectrumWaterfall() {
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4;
const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
@@ -7475,13 +7545,6 @@ function drawSpectrumWaterfall() {
spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
}
function bmHexToRgba(hex: string, alpha: number) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// WCAG relative luminance; threshold 0.4 splits well across the palette.
function bmLuminance(hex: string) {
const lin = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
@@ -7559,8 +7622,6 @@ function createBookmarkChip(bm: Bookmark, colorMap: Record<string, string>, opti
const span = document.createElement("span");
const freqStr = window.trx.modules.bookmarks
? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + "\u202fHz";
const esc = (s: unknown) => String(s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
span.className = "spectrum-bookmark-chip";
if (options.sideStack) {
span.classList.add("spectrum-bookmark-chip-side");
@@ -7626,8 +7687,7 @@ function updateBookmarkAxis(range: SpectrumRange) {
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
if (!axisEl) return;
const _bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
const leftBookmarks = allBookmarks
.filter((bm) => bm.freq_hz < range.visLoHz)
@@ -8439,8 +8499,16 @@ const bandplanLabelsCheck = document.getElementById("bandplan-labels-check") as
(function loadBandplanJson() {
fetch("/bandplan.json")
.then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
.then((d) => { bandplanData = d; bandplanSegmentsCache = null; bandplanCacheKey = ""; })
.then(async (response) => {
if (!response.ok) throw new Error(String(response.status));
return await responseJsonUnknown(response);
})
.then((data) => {
if (!isRecord(data)) return;
bandplanData = data as BandplanData;
bandplanSegmentsCache = null;
bandplanCacheKey = "";
})
.catch(() => {});
})();