refactor: enforce typed frontend runtime boundaries
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Frontend architecture
|
||||
|
||||
The HTTP frontend is a strict TypeScript application built with esbuild. Rust
|
||||
embeds deterministic JavaScript output from `assets/web/generated`; Cargo does
|
||||
not run Node.js or contact the network.
|
||||
|
||||
## Runtime graph
|
||||
|
||||
`frontend/src/bootstrap.ts` is the only first-party script referenced by the
|
||||
HTML document. Its imports establish startup order for WebGL support, shared UI
|
||||
services, decoder dispatch, the local Leaflet AIS adapter, and the application
|
||||
coordinator. Leaflet and the Opus decoder remain isolated vendored scripts.
|
||||
|
||||
Feature entries under `frontend/src/plugins` are ESM bundles. The typed plugin
|
||||
loader imports them by feature group and keeps expensive map, scheduling, and
|
||||
decoder behavior lazy. Shared code is emitted as content-hashed chunks. The
|
||||
decode-history worker is an independent entry compiled against Web Worker
|
||||
globals.
|
||||
|
||||
Dependencies point from application and feature code toward `core` and `api`.
|
||||
`api/generated.ts` contains Rust wire formats; `api/client.ts` and focused
|
||||
parsers validate untrusted HTTP, SSE, WebSocket, and worker data before it is
|
||||
used as typed application state.
|
||||
|
||||
## Browser host contract
|
||||
|
||||
Separate lazy bundles cannot share module instances with the stable application
|
||||
entry, so they use three intentional host namespaces:
|
||||
|
||||
| Global | Purpose | Mutation policy |
|
||||
| --- | --- | --- |
|
||||
| `window.trx` | Application state, core services, and feature registrations | The root is frozen; lazy features may register only their documented `modules.*` service. |
|
||||
| `window.trxPluginRuntime` | Typed decoder registration and message dispatch | Runtime object is installed once; plugins register lifecycle handlers through its API. |
|
||||
| `window.trxUi` | Notifications, confirmations, tab accessibility, and control presentation | Installed once by `ui-core.ts`; consumers call methods but do not replace them. |
|
||||
|
||||
The WebGL adapter exposes `createTrxWebGlRenderer`, `trxParseCssColor`,
|
||||
`trxHslToRgba`, and `trxClearCssColorCache` for the application bundle. Leaflet
|
||||
adds `L.TrxAisTrackSymbol` and `L.trxAisTrackSymbol` to the vendored Leaflet
|
||||
namespace.
|
||||
|
||||
The following transitional properties are explicitly part of the lazy-feature
|
||||
host contract and are declared in `app.ts`: `lastSpectrumData`, `lastFreqHz`,
|
||||
`currentBandwidthHz`, `ft8BaseHz`, `getDecodeHistoryRetentionMs`,
|
||||
`applyDecodeHistoryRetention`, `getDecodeRigMeta`, `renderRdsOverlays`,
|
||||
`buildAisVesselUrl`, `trxScheduleUiFrameJob`,
|
||||
`takeSchedulerControlForDecoderDisable`, `navigateToTab`, `_syncRecorderState`,
|
||||
and `refreshRdsUi`. Optional callbacks owned by lazy features are
|
||||
`refreshCwTonePicker`, `updateFt8RfDisplay`, `clearSatPredictionDom`,
|
||||
`syncWefaxToggle`, `updateAisBar`, `updateVdesBar`, `updateAprsBar`,
|
||||
`updateFt8Bar`, `updateSatLiveState`, `applyCwAutoUi`, and
|
||||
`applyCwAutoUiFromServer`.
|
||||
|
||||
This list is closed: new standalone mutable `window` properties are not an
|
||||
accepted integration mechanism. Extend an existing typed service or introduce
|
||||
an imported interface instead. Removing transitional properties as feature
|
||||
boundaries become directly importable remains preferable.
|
||||
|
||||
## Build and verification
|
||||
|
||||
The generated directory is removed before every build, preventing orphaned
|
||||
compatibility bundles. Stable feature entry names are allowlisted by the Rust
|
||||
asset manifest; shared chunks use content hashes. The generic Rust handler
|
||||
rejects unknown names and unsupported MIME types and serves embedded assets
|
||||
with compression, ETags, and immutable caching.
|
||||
|
||||
CI installs from `package-lock.json`, caches only npm downloads, type-checks the
|
||||
window and worker environments separately, lints, runs unit and DOM tests,
|
||||
starts the application in Chromium, regenerates Rust contracts and bundles,
|
||||
checks for drift, and runs REUSE validation after generation.
|
||||
|
||||
See `frontend/src/README.md` for local commands and
|
||||
`docs/ts-migration-plan.md` for the migration decisions and completion gates.
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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(() => {});
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user