diff --git a/REUSE.toml b/REUSE.toml index a4fb4239..223a64e1 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -18,7 +18,11 @@ path = [ "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-favicon.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/trx-logo.png", "src/trx-client/trx-frontend/trx-frontend-http/assets/web/bandplan.json", - "src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js", + "src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/**", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json", + "src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.worker.json", ] SPDX-FileCopyrightText = "2026 Stan Grams " SPDX-License-Identifier = "GPL-2.0-or-later" diff --git a/docs/frontend_improvements.md b/docs/frontend_improvements.md index 1350c2f6..3ae56e86 100644 --- a/docs/frontend_improvements.md +++ b/docs/frontend_improvements.md @@ -7,7 +7,11 @@ trx-rs web frontend (`trx-frontend-http`). The frontend is a single-page application served as embedded static assets (gzip-compressed with ETag caching) from the Actix-Web server. -## Current asset inventory +## Historical asset inventory + +This inventory records the frontend before the TypeScript migration. The +first-party JavaScript copies listed here have since been replaced by strict +TypeScript source and deterministic output under `assets/web/generated`. | File | Lines | Size | |------|------:|-----:| diff --git a/docs/ts-migration-plan.md b/docs/ts-migration-plan.md index 330a4e69..19f77a9c 100644 --- a/docs/ts-migration-plan.md +++ b/docs/ts-migration-plan.md @@ -8,7 +8,44 @@ SPDX-License-Identifier: GPL-2.0-or-later > **Scope**: `src/trx-client/trx-frontend/trx-frontend-http/` > -> **Status**: Proposed +> **Status**: Complete (2026-08-01) + +## Implementation result + +The migration was completed on `feat/typescript-frontend-migration`. The +baseline and phased sections below are retained as the decision record; their +descriptions of JavaScript files and classic loading refer to the pre-migration +state. + +Completion evidence: + +- all first-party browser sources are strict `.ts` files, checked by separate + DOM and Web Worker TypeScript projects with no JavaScript compatibility mode + or suppression directives; +- `bootstrap.ts` is the single first-party HTML entry and esbuild represents + startup order, lazy feature imports, shared hashed chunks, and the worker in + its module graph; +- obsolete source and generated compatibility JavaScript was removed; +- Rust generates rig, status, capability, decoder, and flattened frontend + metadata contracts into `api/generated.ts`; runtime guards validate HTTP, + SSE, WebSocket, and worker boundaries; +- the generic embedded-asset handler serves a build-generated allowlist with + constrained MIME types, compression, ETags, immutable caching, and no file + system lookup; +- intentional browser host namespaces and transitional lazy-feature properties + are documented in `docs/frontend-architecture.md`; +- CI uses locked npm dependencies, caches npm downloads rather than + `node_modules`, runs strict type checking and linting, unit/DOM/worker tests, + Chromium startup coverage, generated-output drift checks, and REUSE after + generation; +- Cargo continues to consume committed generated assets without invoking Node + or requiring network access. + +The final local gate ran `npm ci`, type checking, linting, 30 frontend tests, +the Chromium smoke flow (startup, auth gate, audio controls, rig switching, map +initialization, and navigation), generated-contract and bundle verification, +workspace formatting, Clippy with warnings denied, all-target builds, workspace +tests, and REUSE 3.3 validation. ## 1. Decision @@ -20,7 +57,7 @@ the main safety benefits. The migration must be incremental. Every intermediate commit and pull request must leave the frontend buildable and usable. -## 2. Current State +## 2. Baseline State The frontend currently contains roughly 21,700 lines of first-party JavaScript. Its largest components include: diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js deleted file mode 100644 index b41fdb46..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ /dev/null @@ -1,8934 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stan Grams -// -// SPDX-License-Identifier: GPL-2.0-or-later - -// --- Decoder registry (fetched from /decoders on load) --- -/** @type {Array<{id:string,label:string,activation:string,active_modes:string[],background_decode:boolean,bookmark_selectable:boolean}>} */ -let decoderRegistry = []; -window.decoderRegistry = decoderRegistry; - -/** Callbacks invoked once the decoder registry is fetched. */ -const _decoderRegistryReadyCallbacks = []; -window.onDecoderRegistryReady = function (fn) { - if (decoderRegistry.length > 0) fn(); - else _decoderRegistryReadyCallbacks.push(fn); -}; - -(async function fetchDecoderRegistry() { - try { - const resp = await fetch("/decoders"); - if (resp.ok) { - decoderRegistry = await resp.json(); - window.decoderRegistry = decoderRegistry; - for (const fn of _decoderRegistryReadyCallbacks) fn(); - _decoderRegistryReadyCallbacks.length = 0; - hideUnsupportedDecoderTabs(); - refreshOperatorLayoutCapabilities(); - } - } catch (e) { - console.error("Failed to fetch decoder registry:", e); - } -})(); - -/** Hide decoder sub-tabs, panels, about rows, and settings buttons - * for decoders not present in the server's registry. */ -function hideUnsupportedDecoderTabs() { - const knownIds = new Set(decoderRegistry.map(function (d) { return d.id; })); - // Sub-tabs that are not decoders and should always be visible. - const alwaysShow = new Set(["overview", "rds", "sat"]); - document.querySelectorAll("#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]").forEach(function (btn) { - const id = btn.dataset.subtab; - if (alwaysShow.has(id) || knownIds.has(id)) return; - btn.style.display = "none"; - var panel = document.getElementById("subtab-" + id); - if (panel) panel.style.display = "none"; - }); - // About-tab decoder status rows: LABEL - document.querySelectorAll('[id^="about-dec-"]').forEach(function (el) { - var id = el.id.replace("about-dec-", ""); - if (!alwaysShow.has(id) && !knownIds.has(id)) { - var row = el.closest("tr"); - if (row) row.style.display = "none"; - } - }); - // Settings clear-history buttons - document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach(function (el) { - var m = el.id.match(/^settings-clear-(.+)-history$/); - if (m && !alwaysShow.has(m[1]) && !knownIds.has(m[1])) { - el.style.display = "none"; - } - }); - // Overview decoder descriptions - document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach(function (el) { - var id = el.dataset.decoder; - if (!alwaysShow.has(id) && !knownIds.has(id)) { - el.style.display = "none"; - } - }); -} - -// --- Persistent settings (localStorage) --- -const STORAGE_PREFIX = "trx_"; -function saveSetting(key, value) { - try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {} -} -function loadSetting(key, fallback) { - try { - const v = localStorage.getItem(STORAGE_PREFIX + key); - return v !== null ? JSON.parse(v) : fallback; - } catch(e) { return fallback; } -} -function escapeMapHtml(input) { - return String(input) - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("\"", """); -} - -// --- Authentication --- -let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access) -let authEnabled = true; - -async function checkAuthStatus() { - try { - const resp = await fetch("/auth/session"); - if (resp.status === 404) { - // Auth API not exposed -> treat as auth-disabled mode. - return { authenticated: true, role: "control", auth_disabled: true }; - } - if (!resp.ok) return { authenticated: false }; - const data = await resp.json(); - return data; - } catch (e) { - console.error("Auth check failed:", e); - return { authenticated: false }; - } -} - -async function authLogin(passphrase) { - try { - const resp = await fetch("/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ passphrase }), - }); - if (resp.status === 404) { - return { authenticated: true, role: "control", auth_disabled: true }; - } - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || "Login failed"); - } - const data = await resp.json(); - return data; - } catch (e) { - throw e; - } -} - -async function authLogout() { - try { - const resp = await fetch("/auth/logout", { method: "POST" }); - if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed"); - authRole = null; - // Disconnect and show auth gate without page reload - disconnect(); - setDecodeHistoryOverlayVisible(false); - document.getElementById("content").style.display = "none"; - document.getElementById("loading").style.display = "none"; - document.getElementById("auth-passphrase").value = ""; - updateAuthUI(); - - // Check if guest mode is available after logout - const authStatus = await checkAuthStatus(); - const allowGuest = authStatus.role === "rx"; - showAuthGate(allowGuest); - } catch (e) { - console.error("Logout failed:", e); - showAuthError("Logout failed"); - } -} - -function showAuthGate(allowGuest = false) { - if (!authEnabled) return; - setDecodeHistoryOverlayVisible(false); - document.getElementById("loading").style.display = "none"; - document.getElementById("content").style.display = "none"; - const authGate = document.getElementById("auth-gate"); - authGate.style.display = "flex"; - authGate.style.flexDirection = "column"; - authGate.style.justifyContent = "center"; - authGate.style.alignItems = "stretch"; - const signalVisualBlock = document.querySelector(".signal-visual-block"); - if (signalVisualBlock) { - signalVisualBlock.style.display = "none"; - } - - // Hide all tab panels - document.querySelectorAll(".tab-panel").forEach(panel => { - panel.style.display = "none"; - }); - - // Show guest button if guest mode is available - const guestBtn = document.getElementById("auth-guest-btn"); - if (guestBtn) { - guestBtn.style.display = allowGuest ? "block" : "none"; - } - - document.querySelectorAll(".tab-bar .tab").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.tab === "main"); - }); - syncTopBarAccess(); -} - -function hideAuthGate() { - const authGate = document.getElementById("auth-gate"); - authGate.style.display = "none"; - document.getElementById("loading").style.display = "block"; - const signalVisualBlock = document.querySelector(".signal-visual-block"); - if (signalVisualBlock) { - signalVisualBlock.style.display = ""; - } - - // Show the tab that matches the current route. - document.querySelectorAll(".tab-panel").forEach(panel => { - panel.style.display = "none"; - }); - document.querySelectorAll(".tab-bar .tab").forEach(btn => { - btn.classList.remove("active"); - }); - navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true }); - syncTopBarAccess(); -} - -function showAuthError(msg) { - const el = document.getElementById("auth-error"); - el.textContent = msg; - el.style.display = "block"; - setTimeout(() => { - el.style.display = "none"; - }, 5000); -} - -function updateAuthUI() { - const badge = document.getElementById("auth-badge"); - const badgeRole = document.getElementById("auth-role-badge"); - const headerAuthBtn = document.getElementById("header-auth-btn"); - - if (!authEnabled) { - if (badge) badge.style.display = "none"; - if (headerAuthBtn) headerAuthBtn.style.display = "none"; - syncTopBarAccess(); - return; - } - - if (authRole) { - if (badge) badge.style.display = "block"; - if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; - if (headerAuthBtn) { - headerAuthBtn.textContent = "Logout"; - headerAuthBtn.style.display = "block"; - } - } else { - if (badge) badge.style.display = "none"; - if (headerAuthBtn) { - headerAuthBtn.textContent = "Login"; - headerAuthBtn.style.display = "block"; - } - } - syncTopBarAccess(); -} - -function applyAuthRestrictions() { - if (!authRole) return; - - // Disable TX/PTT/frequency/mode/VFO controls for rx role - if (authRole === "rx") { - const pttBtn = document.getElementById("ptt-btn"); - const powerBtn = document.getElementById("power-btn"); - const lockBtn = document.getElementById("lock-btn"); - const freqInput = document.getElementById("freq"); - const centerFreqInput = document.getElementById("center-freq"); - const modeSelect = document.getElementById("mode"); - const txLimitInput = document.getElementById("tx-limit"); - const txLimitBtn = document.getElementById("tx-limit-btn"); - const txAudioBtn = document.getElementById("tx-audio-btn"); - const txLimitRow = document.getElementById("tx-limit-row"); - const vfoPicker = document.getElementById("vfo-picker"); - const jogUp = document.getElementById("jog-up"); - const jogDown = document.getElementById("jog-down"); - const jogButtons = document.querySelectorAll(".jog-step button"); - const vfoButtons = document.querySelectorAll("#vfo-picker button"); - - // Disable TX buttons - if (pttBtn) pttBtn.disabled = true; - if (powerBtn) powerBtn.disabled = true; - if (lockBtn) lockBtn.disabled = true; - if (txAudioBtn) txAudioBtn.disabled = true; - if (txLimitBtn) txLimitBtn.disabled = true; - - // Disable frequency/mode inputs - if (freqInput) freqInput.disabled = true; - if (centerFreqInput) centerFreqInput.disabled = true; - if (modeSelect) modeSelect.disabled = true; - if (txLimitInput) txLimitInput.disabled = true; - - // Disable VFO selector - vfoButtons.forEach(btn => btn.disabled = true); - - // Disable jog controls - const jogWheel = document.getElementById("jog-wheel"); - if (jogUp) jogUp.disabled = true; - if (jogDown) jogDown.disabled = true; - if (jogWheel) jogWheel.style.opacity = "0.5"; - jogButtons.forEach(btn => btn.disabled = true); - - // Disable plugin enable/disable buttons and decode history clear buttons - // Note: sig-clear-btn is allowed for RX (clears local measurements only) - const pluginToggleBtns = [ - "ft8-decode-toggle-btn", - "ft4-decode-toggle-btn", - "ft2-decode-toggle-btn", - "wspr-decode-toggle-btn", - "lrpt-decode-toggle-btn", - "hf-aprs-decode-toggle-btn", - "cw-auto", - "settings-clear-ais-history", - "settings-clear-vdes-history", - "settings-clear-aprs-history", - "settings-clear-hf-aprs-history", - "settings-clear-cw-history", - "settings-clear-ft8-history", - "settings-clear-ft4-history", - "settings-clear-ft2-history", - "settings-clear-wspr-history", - "settings-clear-sat-history", - "header-rec-btn", - "recorder-start-btn", - "recorder-stop-btn" - ]; - pluginToggleBtns.forEach(id => { - const btn = document.getElementById(id); - if (btn && btn.tagName === "BUTTON") { - btn.disabled = true; - } else if (btn && btn.type === "checkbox") { - btn.disabled = true; - } - }); - - // Hide TX-specific UI but keep controls visible (disabled) - if (txLimitRow) txLimitRow.style.opacity = "0.5"; - } -} - -function applyCapabilities(caps) { - if (!caps) return; - lastHasTx = !!caps.tx; - if (signalVisualBlockEl) signalVisualBlockEl.style.display = ""; - - // PTT / TX controls - const pttBtn = document.getElementById("ptt-btn"); - const txPowerCol = document.getElementById("tx-power-col"); - const txMetersRow = document.getElementById("tx-meters"); - const txAudioBtn = document.getElementById("tx-audio-btn"); - const txVolSlider = document.getElementById("tx-vol"); - const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null; - const hasPowerControl = !caps.filter_controls; - if (txPowerCol) { - txPowerCol.style.display = (caps.tx || hasPowerControl || caps.lockable) ? "" : "none"; - const label = txPowerCol.querySelector(".label span"); - if (label) { - label.textContent = caps.tx && hasPowerControl ? "Transmit / Power" - : caps.tx ? "Transmit / Tuning" - : hasPowerControl ? "Power / Tuning" : "Tuning"; - } - } - if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none"; - if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none"; - if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none"; - if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none"; - if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; - if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; - if (!caps.tx && typeof stopTxAudio === "function" && txActive) { - stopTxAudio(); - } - - // TX limit row - const txLimitRow = document.getElementById("tx-limit-row"); - if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none"; - - // VFO row - const vfoRow = document.getElementById("vfo-row"); - if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none"; - - // Signal meter row - document.querySelectorAll(".full-row.label-below-row").forEach(row => { - const label = row.querySelector(".label span"); - if (label && label.textContent === "Signal") { - row.style.display = (caps.signal_meter && !caps.filter_controls) ? "" : "none"; - } - }); - - // Spectrum panel (SDR-only) - const spectrumPanel = document.getElementById("spectrum-panel"); - const centerFreqField = document.getElementById("center-freq-field"); - if (spectrumPanel) { - if (caps.filter_controls) { - spectrumPanel.style.display = ""; - setSignalSplitControlVisible(true); - if (centerFreqField) centerFreqField.style.display = ""; - startSpectrumStreaming(); - } else { - spectrumPanel.style.display = "none"; - setSignalSplitControlVisible(false); - if (centerFreqField) centerFreqField.style.display = "none"; - stopSpectrumStreaming(); - resizeHeaderSignalCanvas(); - scheduleOverviewDraw(); - } - scheduleSpectrumLayout(); - } - if (!caps.filter_controls) { - sdrSquelchSupported = false; - } - updateSdrSquelchControlVisibility(); - if (typeof vchanApplyCapabilities === "function") vchanApplyCapabilities(caps); -} - -const freqEl = document.getElementById("freq"); -const centerFreqEl = document.getElementById("center-freq"); -const wavelengthEl = document.getElementById("wavelength"); -const sigStrengthEl = document.getElementById("sig-strength"); -const modeEl = document.getElementById("mode"); -const bandLabel = document.getElementById("band-label"); -const powerBtn = document.getElementById("power-btn"); -const powerHint = document.getElementById("power-hint"); -const vfoPicker = document.getElementById("vfo-picker"); -const signalBar = document.getElementById("signal-bar"); -const signalValue = document.getElementById("signal-value"); -const pttBtn = document.getElementById("ptt-btn"); -const txLimitInput = document.getElementById("tx-limit"); -const txLimitBtn = document.getElementById("tx-limit-btn"); -const txLimitRow = document.getElementById("tx-limit-row"); -const lockBtn = document.getElementById("lock-btn"); -const txMeters = document.getElementById("tx-meters"); -const pwrBar = document.getElementById("pwr-bar"); -const pwrValue = document.getElementById("pwr-value"); -const swrBar = document.getElementById("swr-bar"); -const swrValue = document.getElementById("swr-value"); -const loadingEl = document.getElementById("loading"); -const contentEl = document.getElementById("content"); -const serverSubtitle = document.getElementById("server-subtitle"); -const rigSubtitle = document.getElementById("rig-subtitle"); -const ownerSubtitle = document.getElementById("owner-subtitle"); -const locationSubtitle = document.getElementById("location-subtitle"); -const loadingTitle = document.getElementById("loading-title"); -const loadingSub = document.getElementById("loading-sub"); -const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); -const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); -const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); -const connLostOverlayEl = document.getElementById("conn-lost-overlay"); -const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); -const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); -const overviewCanvas = document.getElementById("overview-canvas"); -const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); -// Screenshots composite these live WebGL canvases into a PNG. -const spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; -const overviewGl = typeof createTrxWebGlRenderer === "function" - ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) - : null; -const signalOverlayGl = typeof createTrxWebGlRenderer === "function" - ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) - : null; -const signalVisualBlockEl = document.querySelector(".signal-visual-block"); -const signalSplitControlEl = document.getElementById("signal-split-control"); -const signalSplitSliderEl = document.getElementById("signal-split-slider"); -const signalSplitValueEl = document.getElementById("signal-split-value"); -const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); -const themeToggleBtn = document.getElementById("theme-toggle"); -const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); -const headerRigSummary = document.getElementById("header-rig-summary"); -const headerStylePickSelect = document.getElementById("header-style-pick-select"); -const rdsPsOverlay = document.getElementById("rds-ps-overlay"); -const tabMainEl = document.getElementById("tab-main"); -// About-tab elements — resolved lazily after the about template is cloned. -let aboutServerVerEl = null; -let aboutServerBuildDateEl = null; -let aboutServerAddrEl = null; -let aboutServerCallEl = null; -let aboutServerLocationEl = null; -let aboutRigInfoEl = null; -let aboutRigAccessEl = null; -let aboutModesEl = null; -let aboutVfosEl = null; -let aboutActiveRigEl = null; -let aboutAudioCodecEl = null; -let aboutAudioSamplerateEl = null; -let aboutAudioChannelsEl = null; -let aboutAudioBitrateEl = null; -let aboutAudioFrameEl = null; -let aboutAudioRxEl = null; -let aboutAudioStreamsEl = null; -let aboutPskreporterEl = null; -let aboutAprsIsEl = null; -let aboutRigctlClientsEl = null; -let aboutRigctlEndpointEl = null; -let aboutClientsEl = null; -let _aboutElsResolved = false; -function _resolveAboutEls() { - if (_aboutElsResolved) return; - aboutServerVerEl = document.getElementById("about-server-ver"); - if (!aboutServerVerEl) return; // template not cloned yet - _aboutElsResolved = true; - aboutServerBuildDateEl = document.getElementById("about-server-build-date"); - aboutServerAddrEl = document.getElementById("about-server-addr"); - aboutServerCallEl = document.getElementById("about-server-call"); - aboutServerLocationEl = document.getElementById("about-server-location"); - aboutRigInfoEl = document.getElementById("about-rig-info"); - aboutRigAccessEl = document.getElementById("about-rig-access"); - aboutModesEl = document.getElementById("about-modes"); - aboutVfosEl = document.getElementById("about-vfos"); - aboutActiveRigEl = document.getElementById("about-active-rig"); - aboutAudioCodecEl = document.getElementById("about-audio-codec"); - aboutAudioSamplerateEl = document.getElementById("about-audio-samplerate"); - aboutAudioChannelsEl = document.getElementById("about-audio-channels"); - aboutAudioBitrateEl = document.getElementById("about-audio-bitrate"); - aboutAudioFrameEl = document.getElementById("about-audio-frame"); - aboutAudioRxEl = document.getElementById("about-audio-rx"); - aboutAudioStreamsEl = document.getElementById("about-audio-streams"); - aboutPskreporterEl = document.getElementById("about-pskreporter"); - aboutAprsIsEl = document.getElementById("about-aprs-is"); - aboutRigctlClientsEl = document.getElementById("about-rigctl-clients"); - aboutRigctlEndpointEl = document.getElementById("about-rigctl-endpoint"); - aboutClientsEl = document.getElementById("about-clients"); -} -// Cached CW elements (avoid getElementById on every SSE render) -const cwAutoEl = document.getElementById("cw-auto"); -const cwWpmEl = document.getElementById("cw-wpm"); -const cwToneEl = document.getElementById("cw-tone"); -let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); -let decodeHistoryRetentionMin = 24 * 60; - -// Cached decoder toggle buttons — built from the registry, keyed by status -// field name (e.g. "ft8_decode_enabled"). Lazily populated on first SSE. -const _decoderToggles = {}; -function _ensureDecoderToggles() { - if (decoderRegistry.length === 0) return; - for (const d of decoderRegistry) { - if (d.activation !== "toggle") continue; - const key = d.id.replace(/-/g, "_") + "_decode_enabled"; - if (_decoderToggles[key]) continue; - const el = document.getElementById(d.id + "-decode-toggle-btn"); - if (el) _decoderToggles[key] = { el, last: null, label: d.label }; - } -} - -function syncDecoderToggle(entry, enabled, label) { - if (!entry.el || entry.last === enabled) return; - entry.last = enabled; - entry.el.dataset.enabled = enabled ? "true" : "false"; - entry.el.textContent = enabled ? `Disable ${label}` : `Enable ${label}`; - entry.el.style.borderColor = enabled ? "#00d17f" : ""; - entry.el.style.color = enabled ? "#00d17f" : ""; -} - -// About-tab decoder status elements — resolved lazily after template clone. -const _aboutDecIds = [ - "about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr", - "about-dec-cw", "about-dec-aprs", "about-dec-lrpt", -]; -let _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); -function _resolveAboutDecEls() { - if (_aboutDecEls[0].el) return; - for (let i = 0; i < _aboutDecIds.length; i++) { - _aboutDecEls[i].el = document.getElementById(_aboutDecIds[i]); - } -} - -function syncAboutDecoder(idx, enabled) { - const entry = _aboutDecEls[idx]; - if (!entry || !entry.el || entry.last === enabled) return; - entry.last = enabled; - entry.el.textContent = enabled ? "Active" : "Off"; - entry.el.className = enabled ? "about-status-on" : "about-status-off"; -} - -let primaryRds = null; -let vchanRdsById = new Map(); -let vchanSignalDbById = new Map(); -let rdsOverlayEntries = []; - -function currentDecodeHistoryRetentionMs() { - const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || (24 * 60))); - return minutes * 60 * 1000; -} - -window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; - -window.applyDecodeHistoryRetention = function() { - if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); - if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); - if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); - if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); - if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); - if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); -}; - -function syncTopBarAccess() { - const loggedOut = authEnabled && !authRole; - const tabBar = document.getElementById("tab-bar"); - const rigSwitch = document.querySelector(".header-rig-switch"); - if (tabBar) tabBar.style.display = ""; - - document.querySelectorAll(".tab-bar .tab").forEach((btn) => { - const isMain = btn.dataset.tab === "main"; - btn.style.display = !loggedOut || isMain ? "" : "none"; - btn.disabled = false; - }); - - if (rigSwitch) { - rigSwitch.style.display = loggedOut ? "none" : ""; - } - - if (headerRigSwitchSelect) { - headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; - } -} - -let overviewDrawPending = false; -function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") { - if (!decodeHistoryOverlayEl) return; - if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title; - if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || ""; - decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible); -} - -function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying\u2026", fullscreen = false) { - if (!connLostOverlayEl) return; - if (connLostOverlayTitleEl) connLostOverlayTitleEl.textContent = title; - if (connLostOverlaySubEl) connLostOverlaySubEl.textContent = sub; - connLostOverlayEl.classList.toggle("conn-lost-fullscreen", fullscreen); - connLostOverlayEl.classList.toggle("is-hidden", !visible); -} -const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; -let decodeHistoryReplayActive = false; -let decodeMapSyncPending = false; - -// --- Pending decode data buffers --- -// Map-data plugins (ais.js, aprs.js, vdes.js, hf-aprs.js) are loaded eagerly -// but dynamically-inserted scripts have no guaranteed execution order. If -// decode history or live SSE messages arrive before the plugin handlers are -// registered, buffer them here and let each plugin drain on init. -const _pendingDecodeHistory = {}; -const _pendingDecodeLive = {}; - -window._trxDrainPendingDecode = function(kind) { - const historyKey = { - ais: "restoreAisHistory", - vdes: "restoreVdesHistory", - aprs: "restoreAprsHistory", - hf_aprs: "restoreHfAprsHistory", - }[kind]; - if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { - const msgs = _pendingDecodeHistory[kind]; - delete _pendingDecodeHistory[kind]; - window[historyKey](msgs); - } - const liveKey = { - ais: "onServerAis", - vdes: "onServerVdes", - aprs: "onServerAprs", - hf_aprs: "onServerHfAprs", - }[kind]; - if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { - const msgs = _pendingDecodeLive[kind]; - delete _pendingDecodeLive[kind]; - for (const msg of msgs) { - try { window[liveKey](msg); } catch (_) {} - } - } -}; - -function markDecodeMapSyncPending() { - decodeMapSyncPending = true; -} - -function flushDeferredDecodeMapSync() { - if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.map?.aprsMap) return; - decodeMapSyncPending = false; - scheduleUiFrameJob("decode-map-maintenance", () => { - window.trx.modules.map?.pruneMapHistory(); - }); -} - -function setDecodeHistoryReplayActive(active) { - decodeHistoryReplayActive = !!active; - if (!decodeHistoryReplayActive) { - flushDeferredDecodeMapSync(); - } -} - -function decodeHistoryMapRenderingDeferred() { - return decodeHistoryReplayActive || !window.trx?.map?.aprsMap; -} - -function decodeCborUint(view, bytes, state, additional) { - const offset = state.offset; - if (additional < 24) return additional; - if (additional === 24) { - if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 1; - return bytes[offset]; - } - if (additional === 25) { - if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 2; - return view.getUint16(offset); - } - if (additional === 26) { - if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - state.offset += 4; - return view.getUint32(offset); - } - if (additional === 27) { - if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getBigUint64(offset); - state.offset += 8; - const numeric = Number(value); - if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); - return numeric; - } - throw new Error("Unsupported CBOR additional info"); -} - -function decodeCborFloat16(bits) { - const sign = (bits & 0x8000) ? -1 : 1; - const exponent = (bits >> 10) & 0x1f; - const fraction = bits & 0x03ff; - if (exponent === 0) { - return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); - } - if (exponent === 0x1f) { - return fraction === 0 ? sign * Infinity : Number.NaN; - } - return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024)); -} - -function decodeCborItem(view, bytes, state) { - if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); - const initial = bytes[state.offset++]; - const major = initial >> 5; - const additional = initial & 0x1f; - if (major === 0) return decodeCborUint(view, bytes, state, additional); - if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); - if (major === 2) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.slice(state.offset, state.offset + length); - state.offset += length; - return Array.from(chunk); - } - if (major === 3) { - const length = decodeCborUint(view, bytes, state, additional); - if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); - const chunk = bytes.subarray(state.offset, state.offset + length); - state.offset += length; - return decodeHistoryTextDecoder ? decodeHistoryTextDecoder.decode(chunk) : String.fromCharCode(...chunk); - } - if (major === 4) { - const length = decodeCborUint(view, bytes, state, additional); - const items = new Array(length); - for (let i = 0; i < length; i += 1) { - items[i] = decodeCborItem(view, bytes, state); - } - return items; - } - if (major === 5) { - const length = decodeCborUint(view, bytes, state, additional); - const value = {}; - for (let i = 0; i < length; i += 1) { - const key = decodeCborItem(view, bytes, state); - value[String(key)] = decodeCborItem(view, bytes, state); - } - return value; - } - if (major === 6) { - decodeCborUint(view, bytes, state, additional); - return decodeCborItem(view, bytes, state); - } - if (major === 7) { - if (additional === 20) return false; - if (additional === 21) return true; - if (additional === 22) return null; - if (additional === 23) return undefined; - if (additional === 25) { - if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); - const bits = view.getUint16(state.offset); - state.offset += 2; - return decodeCborFloat16(bits); - } - if (additional === 26) { - if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat32(state.offset); - state.offset += 4; - return value; - } - if (additional === 27) { - if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); - const value = view.getFloat64(state.offset); - state.offset += 8; - return value; - } - } - throw new Error("Unsupported CBOR major type"); -} - -function decodeCborPayload(buffer) { - const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const state = { offset: 0 }; - const value = decodeCborItem(view, bytes, state); - if (state.offset !== bytes.length) { - throw new Error("Unexpected trailing bytes in CBOR payload"); - } - return value; -} - -let lastSpectrumData = null; -window.lastSpectrumData = null; -let lastControl; -let lastTxEn = null; -let lastHasTx = true; -let lastRendered = null; -let prevRenderData = {}; -let hintTimer = null; -let sigMeasuring = false; -let sigLastSUnits = null; -let sigLastDbm = null; -const SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; -let sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); - -function sigUnit(u) { return `${u}`; } - -function formatSigStrength(dbm) { - if (!Number.isFinite(dbm)) return "--"; - const unit = SIG_STRENGTH_UNITS[sigStrengthUnitIdx] || "dBFS"; - if (unit === "S") return formatSignal(dbmToSUnits(dbm)); - if (unit === "dBm") return `${dbm.toFixed(1)} ${sigUnit("dBm")}`; - if (unit === "dBf") { - // dBf = dBm + 107 (referenced to 1 femtowatt across 50 Ω) - const dbf = dbm + 107; - return `${dbf.toFixed(1)} ${sigUnit("dBf")}`; - } - // dBFS: map receiver range to a full-scale reference - // Typical receiver: -140 dBm (noise floor) to 0 dBm (full scale) - const dbfs = Math.max(-140, Math.min(0, dbm)); - return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`; -} - -function refreshSigStrengthDisplay() { - if (!sigStrengthEl) return; - sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm); -} - -if (sigStrengthEl) { - sigStrengthEl.addEventListener("click", () => { - sigStrengthUnitIdx = (sigStrengthUnitIdx + 1) % SIG_STRENGTH_UNITS.length; - saveSetting("sigStrengthUnit", sigStrengthUnitIdx); - refreshSigStrengthDisplay(); - }); -} - -let sigMeasureTimer = null; -let sigMeasureLastTickMs = 0; -let sigMeasureAccumMs = 0; -let sigMeasureWeighted = 0; -let sigMeasurePeak = null; -let lastFreqHz = null; -window.lastFreqHz = null; -let centerFreqDirty = false; -let jogUnit = loadSetting("jogUnit", 1000); // base unit: 1, 1000, 1000000 -let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100 -let jogStep = Math.max(Math.round(jogUnit / jogMult), 1); -let minFreqStepHz = 1; -let lastModeName = ""; -let lastWfmCci = 0; -let lastWfmAci = 0; -const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; -function vfoColor(idx) { - if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; - // Deterministic pseudo-random hue for extra VFOs - const hue = ((idx * 137) % 360); - return `hsl(${hue}, 70%, 55%)`; -} -let jogAngle = 0; -let lastClientCount = null; -let lastLocked = false; -let sdrSquelchSupported = false; -// ── Previous-state tracking for "B" hotkey ──────────────────────────────────── -let previousTuneState = null; // { freqHz, bandwidthHz, mode, centerHz } - -function savePreviousTuneState() { - previousTuneState = { - freqHz: lastFreqHz, - bandwidthHz: currentBandwidthHz, - mode: modeEl ? modeEl.value : "", - centerHz: lastSpectrumData ? Number(lastSpectrumData.center_hz) : null, - }; -} - -async function restorePreviousTuneState() { - if (!previousTuneState) { - showHint("No previous state", 1500); - return; - } - const saved = previousTuneState; - savePreviousTuneState(); // save current as previous so B toggles back - if (saved.mode && modeEl && modeEl.value !== saved.mode) { - modeEl.value = saved.mode; - await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`); - updateWfmControls(); - } - if (Number.isFinite(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { - currentBandwidthHz = saved.bandwidthHz; - window.currentBandwidthHz = currentBandwidthHz; - syncBandwidthInput(currentBandwidthHz); - await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`); - } - if (Number.isFinite(saved.freqHz)) { - setRigFrequency(saved.freqHz); - } - if (Number.isFinite(saved.centerHz)) { - await postPath(`/set_center_freq?hz=${saved.centerHz}`); - } - showHint("Restored previous", 1500); -} - -let lastRigIds = []; -let lastRigDisplayNames = {}; -let lastActiveRigId = null; -let rigSwitchInProgress = false; -let lastCityLabel = ""; -let sseSessionId = null; -const originalTitle = document.title; -const savedTheme = loadSetting("theme", null); - -function currentTheme() { - return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; -} - -function updateDocumentTitle(rds = null) { - const freqHz = activeChannelFreqHz(); - if (!Number.isFinite(freqHz)) { - document.title = originalTitle; - return; - } - const parts = [formatFreq(freqHz)]; - const ps = rds?.program_service; - if (ps && ps.length > 0) { - parts.push(ps); - } - const rigName = (lastActiveRigId && lastRigDisplayNames[lastActiveRigId]) || lastActiveRigId || ""; - if (rigName) parts.push(rigName); - if (lastCityLabel) parts.push(lastCityLabel); - parts.push(originalTitle); - document.title = parts.join(" - "); -} - -function setTheme(theme) { - const next = theme === "light" ? "light" : "dark"; - document.documentElement.setAttribute("data-theme", next); - saveSetting("theme", next); - if (themeToggleBtn) { - themeToggleBtn.textContent = next === "dark" ? "☀️ Light" : "🌙 Dark"; - themeToggleBtn.title = next === "dark" ? "Switch to light mode" : "Switch to dark mode"; - } - if (typeof trxClearCssColorCache === 'function') trxClearCssColorCache(); - invalidateBookmarkColors(); -} - -// Recolour bookmark chips after any palette/theme change (setTheme or setStyle). -function invalidateBookmarkColors() { - if (typeof bmOverlayRevision === "undefined") return; - bmOverlayRevision++; - // Force the browser to recalculate styles so getComputedStyle reads new values. - void getComputedStyle(document.documentElement).getPropertyValue("--bg"); - const colorMap = bmCategoryColorMap(); - const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; - document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => { - const bm = ref.find((b) => b.id === chip.dataset.bmId); - if (!bm) return; - const col = colorMap[bm.category || ""] || "#66d9ef"; - chip.style.setProperty("--bm-cat-bg", col); - chip.style.setProperty("--bm-cat-fg", bmContrastFg(col)); - }); - // Clear cached DOM keys so the next spectrum draw rebuilds chips fresh. - for (const id of ["spectrum-bookmark-axis", "spectrum-bookmark-side-left", "spectrum-bookmark-side-right"]) { - const el = document.getElementById(id); - if (el) el.dataset.bmKey = ""; - } - try { if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); } catch (_) {} -} - -// ── Style / palette system ──────────────────────────────────────────────────── -const CANVAS_PALETTE = { - original: { - dark: { - bg: "#0a0f18", - spectrumLine: "#00e676", spectrumFill: "rgba(0,230,118,0.10)", - spectrumGrid: "rgba(255,255,255,0.06)", spectrumLabel: "rgba(180,200,220,0.45)", - waveformLine: "rgba(94,234,212,0.92)", waveformPeak: "rgba(251,191,36,0.88)", - waveformGrid: "rgba(148,163,184,0.12)", waveformLabel: "rgba(203,213,225,0.72)", - waterfallHue: [225, 30], waterfallSat: 88, waterfallLight: [16, 68], waterfallAlpha: [0.28, 0.86], - }, - light: { - bg: "#eef3fb", - spectrumLine: "#007a47", spectrumFill: "rgba(0,110,70,0.12)", - spectrumGrid: "rgba(0,30,80,0.10)", spectrumLabel: "rgba(30,50,90,0.55)", - waveformLine: "rgba(17,94,89,0.95)", waveformPeak: "rgba(217,119,6,0.9)", - waveformGrid: "rgba(71,85,105,0.14)", waveformLabel: "rgba(51,65,85,0.72)", - waterfallHue: [210, 35], waterfallSat: 82, waterfallLight: [92, 40], waterfallAlpha: [0.42, 0.80], - }, - }, - arctic: { - dark: { - bg: "#1e2530", - spectrumLine: "#88c0d0", spectrumFill: "rgba(136,192,208,0.12)", - spectrumGrid: "rgba(216,222,233,0.08)", spectrumLabel: "rgba(216,222,233,0.55)", - waveformLine: "rgba(136,192,208,0.92)", waveformPeak: "rgba(235,203,139,0.88)", - waveformGrid: "rgba(216,222,233,0.10)", waveformLabel: "rgba(216,222,233,0.65)", - waterfallHue: [212, 188], waterfallSat: 70, waterfallLight: [14, 58], waterfallAlpha: [0.28, 0.82], - }, - light: { - bg: "#dde1e9", - spectrumLine: "#5e81ac", spectrumFill: "rgba(94,129,172,0.14)", - spectrumGrid: "rgba(46,52,64,0.08)", spectrumLabel: "rgba(46,52,64,0.55)", - waveformLine: "rgba(94,129,172,0.95)", waveformPeak: "rgba(208,135,112,0.9)", - waveformGrid: "rgba(46,52,64,0.12)", waveformLabel: "rgba(46,52,64,0.65)", - waterfallHue: [215, 195], waterfallSat: 65, waterfallLight: [88, 45], waterfallAlpha: [0.35, 0.78], - }, - }, - lime: { - dark: { - bg: "#181815", - spectrumLine: "#a6e22e", spectrumFill: "rgba(166,226,46,0.10)", - spectrumGrid: "rgba(248,248,242,0.05)", spectrumLabel: "rgba(248,248,242,0.45)", - waveformLine: "rgba(166,226,46,0.92)", waveformPeak: "rgba(230,219,116,0.88)", - waveformGrid: "rgba(248,248,242,0.08)", waveformLabel: "rgba(248,248,242,0.65)", - waterfallHue: [70, 38], waterfallSat: 80, waterfallLight: [12, 62], waterfallAlpha: [0.25, 0.88], - }, - light: { - bg: "#ede8d8", - spectrumLine: "#5f8700", spectrumFill: "rgba(95,135,0,0.12)", - spectrumGrid: "rgba(39,40,34,0.08)", spectrumLabel: "rgba(39,40,34,0.50)", - waveformLine: "rgba(95,135,0,0.95)", waveformPeak: "rgba(176,120,0,0.9)", - waveformGrid: "rgba(39,40,34,0.10)", waveformLabel: "rgba(39,40,34,0.60)", - waterfallHue: [75, 42], waterfallSat: 75, waterfallLight: [90, 42], waterfallAlpha: [0.35, 0.78], - }, - }, - contrast: { - dark: { - bg: "#000000", - spectrumLine: "#00ff88", spectrumFill: "rgba(0,255,136,0.12)", - spectrumGrid: "rgba(255,255,255,0.12)", spectrumLabel: "rgba(255,255,255,0.70)", - waveformLine: "rgba(0,255,136,0.95)", waveformPeak: "rgba(255,204,0,0.92)", - waveformGrid: "rgba(255,255,255,0.15)", waveformLabel: "rgba(255,255,255,0.80)", - waterfallHue: [150, 60], waterfallSat: 100, waterfallLight: [8, 55], waterfallAlpha: [0.30, 0.95], - }, - light: { - bg: "#f4f4f4", - spectrumLine: "#005cc5", spectrumFill: "rgba(0,92,197,0.12)", - spectrumGrid: "rgba(0,0,0,0.12)", spectrumLabel: "rgba(0,0,0,0.65)", - waveformLine: "rgba(0,92,197,0.95)", waveformPeak: "rgba(180,60,0,0.9)", - waveformGrid: "rgba(0,0,0,0.14)", waveformLabel: "rgba(0,0,0,0.70)", - waterfallHue: [220, 180], waterfallSat: 100, waterfallLight: [90, 42], waterfallAlpha: [0.35, 0.82], - }, - }, - "neon-disco": { - dark: { - bg: "#090010", - spectrumLine: "#ff10e0", spectrumFill: "rgba(255,16,224,0.12)", - spectrumGrid: "rgba(255,16,224,0.10)", spectrumLabel: "rgba(240,200,255,0.55)", - waveformLine: "rgba(57,255,20,0.92)", waveformPeak: "rgba(255,16,224,0.88)", - waveformGrid: "rgba(255,16,224,0.10)", waveformLabel: "rgba(240,200,255,0.65)", - waterfallHue: [300, 120], waterfallSat: 100, waterfallLight: [8, 55], waterfallAlpha: [0.30, 0.92], - }, - light: { - bg: "#f0d8ff", - spectrumLine: "#cc00a8", spectrumFill: "rgba(204,0,168,0.12)", - spectrumGrid: "rgba(100,0,150,0.10)", spectrumLabel: "rgba(50,0,80,0.55)", - waveformLine: "rgba(31,136,0,0.95)", waveformPeak: "rgba(180,0,120,0.9)", - waveformGrid: "rgba(50,0,80,0.10)", waveformLabel: "rgba(50,0,80,0.65)", - waterfallHue: [300, 120], waterfallSat: 90, waterfallLight: [90, 45], waterfallAlpha: [0.35, 0.80], - }, - }, - "golden-rain": { - dark: { - bg: "#120d07", - spectrumLine: "#e4b24d", spectrumFill: "rgba(228,178,77,0.11)", - spectrumGrid: "rgba(255,229,172,0.07)", spectrumLabel: "rgba(230,205,152,0.54)", - waveformLine: "rgba(236,199,108,0.92)", waveformPeak: "rgba(214,134,44,0.90)", - waveformGrid: "rgba(255,210,120,0.09)", waveformLabel: "rgba(232,214,174,0.66)", - waterfallHue: [40, 18], waterfallSat: 88, waterfallLight: [8, 58], waterfallAlpha: [0.26, 0.84], - }, - light: { - bg: "#f5ecd9", - spectrumLine: "#9e6700", spectrumFill: "rgba(158,103,0,0.12)", - spectrumGrid: "rgba(82,55,14,0.09)", spectrumLabel: "rgba(82,55,14,0.55)", - waveformLine: "rgba(140,92,0,0.94)", waveformPeak: "rgba(191,86,0,0.90)", - waveformGrid: "rgba(82,55,14,0.11)", waveformLabel: "rgba(82,55,14,0.66)", - waterfallHue: [45, 18], waterfallSat: 86, waterfallLight: [92, 42], waterfallAlpha: [0.34, 0.82], - }, - }, - amber: { - dark: { - bg: "#130706", - spectrumLine: "#ff7a1f", spectrumFill: "rgba(255,122,31,0.14)", - spectrumGrid: "rgba(255,110,40,0.09)", spectrumLabel: "rgba(255,202,164,0.54)", - waveformLine: "rgba(255,134,54,0.94)", waveformPeak: "rgba(255,220,96,0.92)", - waveformGrid: "rgba(255,120,36,0.11)", waveformLabel: "rgba(255,214,176,0.66)", - waterfallHue: [8, 42], waterfallSat: 96, waterfallLight: [8, 58], waterfallAlpha: [0.26, 0.88], - }, - light: { - bg: "#fff2e7", - spectrumLine: "#c24500", spectrumFill: "rgba(194,69,0,0.14)", - spectrumGrid: "rgba(125,52,0,0.09)", spectrumLabel: "rgba(90,38,0,0.56)", - waveformLine: "rgba(176,62,0,0.95)", waveformPeak: "rgba(224,132,0,0.90)", - waveformGrid: "rgba(125,52,0,0.10)", waveformLabel: "rgba(90,38,0,0.68)", - waterfallHue: [18, 48], waterfallSat: 90, waterfallLight: [92, 42], waterfallAlpha: [0.34, 0.84], - }, - }, - fire: { - dark: { - bg: "#140406", - spectrumLine: "#cf1b22", spectrumFill: "rgba(207,27,34,0.14)", - spectrumGrid: "rgba(255,84,60,0.08)", spectrumLabel: "rgba(255,214,202,0.54)", - waveformLine: "rgba(222,46,34,0.94)", waveformPeak: "rgba(255,112,48,0.90)", - waveformGrid: "rgba(255,84,60,0.10)", waveformLabel: "rgba(255,226,214,0.66)", - waterfallHue: [2, 18], waterfallSat: 96, waterfallLight: [8, 52], waterfallAlpha: [0.26, 0.88], - }, - light: { - bg: "#ffede5", - spectrumLine: "#a91511", spectrumFill: "rgba(169,21,17,0.14)", - spectrumGrid: "rgba(125,36,12,0.09)", spectrumLabel: "rgba(92,24,10,0.56)", - waveformLine: "rgba(164,28,16,0.95)", waveformPeak: "rgba(214,88,20,0.90)", - waveformGrid: "rgba(125,36,12,0.10)", waveformLabel: "rgba(92,24,10,0.68)", - waterfallHue: [4, 24], waterfallSat: 82, waterfallLight: [92, 40], waterfallAlpha: [0.34, 0.84], - }, - }, - phosphor: { - dark: { - bg: "#010501", - spectrumLine: "#39ff14", spectrumFill: "rgba(57,255,20,0.13)", - spectrumGrid: "rgba(57,255,20,0.07)", spectrumLabel: "rgba(168,230,168,0.55)", - waveformLine: "rgba(57,255,20,0.92)", waveformPeak: "rgba(184,240,96,0.88)", - waveformGrid: "rgba(57,255,20,0.08)", waveformLabel: "rgba(168,230,168,0.65)", - waterfallHue: [115, 90], waterfallSat: 100, waterfallLight: [5, 52], waterfallAlpha: [0.28, 0.92], - }, - light: { - bg: "#e0f0e0", - spectrumLine: "#1a7a1a", spectrumFill: "rgba(26,122,26,0.13)", - spectrumGrid: "rgba(10,42,10,0.08)", spectrumLabel: "rgba(10,42,10,0.52)", - waveformLine: "rgba(20,110,20,0.95)", waveformPeak: "rgba(74,138,0,0.90)", - waveformGrid: "rgba(10,42,10,0.10)", waveformLabel: "rgba(10,42,10,0.65)", - waterfallHue: [115, 90], waterfallSat: 90, waterfallLight: [92, 40], waterfallAlpha: [0.34, 0.82], - }, - }, -}; - -function currentStyle() { - return document.documentElement.getAttribute("data-style") || "original"; -} - -function canvasPalette() { - const s = currentStyle(); - const t = currentTheme(); - return (CANVAS_PALETTE[s] ?? CANVAS_PALETTE.original)[t]; -} - -function setStyle(style) { - const remapped = - style === "nord" ? "arctic" - : style === "monokai" ? "lime" - : style === "blood" ? "fire" - : style; - const valid = ["original", "arctic", "lime", "contrast", "neon-disco", "golden-rain", "amber", "fire", "phosphor"]; - const next = valid.includes(remapped) ? remapped : "original"; - if (next === "original") { - document.documentElement.removeAttribute("data-style"); - } else { - document.documentElement.setAttribute("data-style", next); - } - saveSetting("style", next); - if (headerStylePickSelect) headerStylePickSelect.value = next; - if (typeof trxClearCssColorCache === 'function') trxClearCssColorCache(); - invalidateBookmarkColors(); - scheduleOverviewDraw(); -} - -if (overviewPeakHoldEl) { - if (!Number.isFinite(overviewPeakHoldMs) || overviewPeakHoldMs < 0) { - overviewPeakHoldMs = 2000; - } - overviewPeakHoldEl.value = String(overviewPeakHoldMs); - overviewPeakHoldEl.addEventListener("change", () => { - overviewPeakHoldMs = Math.max(0, Number(overviewPeakHoldEl.value) || 0); - saveSetting("overviewPeakHoldMs", overviewPeakHoldMs); - pruneSpectrumPeakHoldFrames(); - if (lastSpectrumData) scheduleSpectrumDraw(); - scheduleOverviewDraw(); - }); -} - -if (savedTheme === "light" || savedTheme === "dark") { - setTheme(savedTheme); -} else { - const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches; - setTheme(prefersLight ? "light" : "dark"); -} - -const savedStyle = loadSetting("style", "original"); -setStyle(savedStyle); - -if (themeToggleBtn) { - themeToggleBtn.addEventListener("click", () => { - setTheme(currentTheme() === "dark" ? "light" : "dark"); - updateMapBaseLayerForTheme(currentTheme()); - syncLocatorMarkerStyles(); - refreshAisMarkerColors(); - scheduleOverviewDraw(); - if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw(); - }); -} - -if (headerStylePickSelect) { - headerStylePickSelect.addEventListener("change", () => { - setStyle(headerStylePickSelect.value); - updateMapBaseLayerForTheme(currentTheme()); - syncLocatorMarkerStyles(); - refreshAisMarkerColors(); - }); -} - -function readyText() { - return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready"; -} - -function rigBadgeColor(rigId) { - const text = (rigId || "rx").toString(); - let hash = 0; - for (let i = 0; i < text.length; i++) { - hash = ((hash * 33) + text.charCodeAt(i)) >>> 0; - } - const hue = hash % 360; - return `hsl(${hue}, 62%, 52%)`; -} - -window.getDecodeRigMeta = function() { - const rigId = lastActiveRigId || "local"; - return { - rigId, - label: lastRigDisplayNames[rigId] || rigId, - color: rigBadgeColor(rigId), - }; -}; - -function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { - if (!selectEl) return; - const selectedBefore = selectEl.value; - selectEl.replaceChildren(); - rigIds.forEach((id) => { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = lastRigDisplayNames[id] || id; - selectEl.appendChild(opt); - }); - const preferred = (typeof activeRigId === "string" && rigIds.includes(activeRigId)) - ? activeRigId - : selectedBefore; - if (preferred && rigIds.includes(preferred)) { - selectEl.value = preferred; - } - selectEl.disabled = disabled; -} - -function updateRigIdentitySummary(rigId, pending = false) { - if (!headerRigSummary) return; - const rig = serverRigs.find((entry) => entry?.remote === rigId); - if (!rig) { - headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available"; - return; - } - const hardware = [rig.manufacturer, rig.model].map(value => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote; - const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []; - const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)]; - if (modes.length > 5) features.push(`+${modes.length - 5} modes`); - headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; -} - -function updateRigSubtitle(activeRigId) { - if (!rigSubtitle) return; - const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--"; - rigSubtitle.textContent = `Rig: ${name}`; - updateDocumentTitle(activeChannelRds()); -} - -function applyRigList(activeRigId, rigIds, displayNames) { - if (!Array.isArray(rigIds)) return; - const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); - // Detect whether the rig list or active rig actually changed so we can - // skip expensive bookmark re-fetches on every SSE state update. - const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); - lastRigIds = nextIds; - if (displayNames && typeof displayNames === "object") { - lastRigDisplayNames = { ...displayNames }; - } - const aboutList = document.getElementById("about-rig-list"); - if (aboutList) { - aboutList.textContent = lastRigIds.length ? lastRigIds.join(", ") : "--"; - } - if (typeof activeRigId === "string" && activeRigId.length > 0) { - // Only adopt the server's active rig when this tab has no selection yet - // (first load). Otherwise keep the per-tab choice so other tabs' switches - // do not override ours. - if (!lastActiveRigId) { - lastActiveRigId = activeRigId; - } - const aboutActive = document.getElementById("about-active-rig"); - if (aboutActive) aboutActive.textContent = lastActiveRigId; - } - const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); - const rigListChanged = prevKey !== nextKey; - const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; - populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); - updateRigSubtitle(lastActiveRigId); - updateRigIdentitySummary(lastActiveRigId); - window.trxUi?.setActiveRig(lastActiveRigId); - if (rigListChanged) { - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); - if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); - if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); - if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); - } - window.trx.modules.map?.updateMapRigFilter(); -} - - -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 rigIds = rigs.map((r) => r && r.remote).filter(Boolean); - const displayNames = {}; - rigs.forEach((r) => { - if (!r || !r.remote) return; - if (typeof r.display_name === "string" && r.display_name.length > 0) { - displayNames[r.remote] = r.display_name; - } else { - const mfg = (r.manufacturer || "").trim(); - const mdl = (r.model || "").trim(); - const hw = [mfg, mdl].filter(Boolean).join(" "); - displayNames[r.remote] = hw || r.remote; - } - }); - serverRigs = rigs; - refreshOperatorLayoutCapabilities(); - serverActiveRigId = data.active_remote || null; - applyRigList(data.active_remote, rigIds, displayNames); - window.trx.modules.map?.syncAprsReceiverMarker(); - } catch (e) { - // Non-fatal: SSE/status path still drives main UI. - } -} - -function refreshOperatorLayoutCapabilities() { - const rigModes = serverRigs.map((rig) => - Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [] - ); - const decoderModes = new Set(decoderRegistry.flatMap((decoder) => - Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : [] - )); - window.trxUi?.setLayoutCapabilities({ - broadcast: rigModes.some((modes) => modes.includes("WFM")), - digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))), - }); -} - -function showHint(msg, duration) { - powerHint.textContent = msg; - if (hintTimer) clearTimeout(hintTimer); - if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); - if (/failed|missing|unavailable|unknown|lost|required/i.test(msg)) { - window.trxUi?.notify(msg, { kind: "error" }); - } -} -let supportedModes = []; -let supportedBands = []; -let lastUnsupportedFreqPopupAt = 0; -let freqDirty = false; -let initialized = false; -let lastEventAt = Date.now(); -let aboutUptimeStart = null; -let es; -let esHeartbeat; - -function formatUptime(ms) { - const s = Math.floor(ms / 1000); - const d = Math.floor(s / 86400); - const h = Math.floor((s % 86400) / 3600); - const m = Math.floor((s % 3600) / 60); - const sec = s % 60; - const parts = []; - if (d > 0) parts.push(`${d}d`); - if (h > 0 || d > 0) parts.push(`${h}h`); - parts.push(`${m}m`); - parts.push(`${sec}s`); - return parts.join(" "); -} -setInterval(() => { - if (!aboutUptimeStart) return; - const el = document.getElementById("about-uptime"); - if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart); -}, 1000); -let reconnectTimer = null; -let overviewSignalSamples = []; -let overviewSignalTimer = null; -let overviewWaterfallRows = []; -let overviewWaterfallPushCount = 0; // monotonically increments on every push -const HEADER_SIG_WINDOW_MS = 10_000; -const OVERVIEW_WF_TEX_MAX_W = 512; -let overviewWfTexData = null; -let overviewWfTexWidth = 0; -let overviewWfTexHeight = 0; -let overviewWfTexPushCount = 0; -let overviewWfTexPalKey = ""; -let 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]; - return [ - parsed[0], - parsed[1], - parsed[2], - Math.max(0, Math.min(1, parsed[3] * alphaMul)), - ]; -} - -function rgbaWithAlpha(color, alphaMul = 1) { - return cssColorToRgba(color, alphaMul); -} - -const BW_OVERLAY_COLORS = { - soft: [240 / 255, 173 / 255, 78 / 255, 0.05], - mid: [240 / 255, 173 / 255, 78 / 255, 0.19], - edge: [240 / 255, 173 / 255, 78 / 255, 0.30], - stroke: [240 / 255, 173 / 255, 78 / 255, 0.70], - hard: [240 / 255, 173 / 255, 78 / 255, 0.38], -}; - -const BOOKMARK_MARKER_FALLBACK = "#66d9ef"; - -function overviewWfResetTextureCache() { - overviewWfTexData = null; - overviewWfTexWidth = 0; - overviewWfTexHeight = 0; - overviewWfTexPushCount = 0; - overviewWfTexPalKey = ""; - overviewWfTexReady = false; -} - -function overviewWfPaletteKey(pal, viewKey = "") { - return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; -} - -function resizeHeaderSignalCanvas() { - if (!ensureOverviewCanvasBackingStore()) return; - positionRdsPsOverlay(); - drawHeaderSignalGraph(); -} - -function ensureOverviewCanvasBackingStore() { - if (!overviewCanvas || !overviewGl || !overviewGl.ready) return false; - const cssW = Math.floor(overviewCanvas.clientWidth); - const cssH = Math.floor(overviewCanvas.clientHeight); - if (cssW <= 0 || cssH <= 0) return false; - const dpr = window.devicePixelRatio || 1; - const resized = overviewGl.ensureSize(cssW, cssH, dpr); - if (resized) { - overviewWfResetTextureCache(); - trimOverviewWaterfallRows(); - } - return true; -} - -function signalOverlayHeight() { - if (!overviewCanvas) return 0; - let height = overviewCanvas.clientHeight || 0; - // Include the bandplan strip height when it sits above the overview. - if (bandplanStripEl && bandplanStripEl.classList.contains("bp-visible")) { - height += bandplanStripEl.clientHeight || 0; - } - const spectrumCanvasEl = document.getElementById("spectrum-canvas"); - const spectrumPanelEl = document.getElementById("spectrum-panel"); - const spectrumVisible = - spectrumCanvasEl && - spectrumCanvasEl.clientHeight > 0 && - spectrumPanelEl && - getComputedStyle(spectrumPanelEl).display !== "none"; - if (spectrumVisible) { - height += spectrumCanvasEl.clientHeight || 0; - const wfCanvas = document.getElementById("spectrum-waterfall-canvas"); - if (wfCanvas && wfCanvas.clientHeight > 0) { - height += wfCanvas.clientHeight; - } - } - return Math.floor(height); -} - -function drawSignalOverlay() { - if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return; - if (!lastSpectrumData) { - signalOverlayCanvas.style.height = "0"; - signalOverlayCanvas.width = 0; - signalOverlayCanvas.height = 0; - return; - } - const cssW = Math.floor(signalVisualBlockEl.clientWidth); - const cssH = signalOverlayHeight(); - signalOverlayCanvas.style.height = cssH > 0 ? `${cssH}px` : "0"; - if (cssW <= 0 || cssH <= 0) { - signalOverlayCanvas.width = 0; - signalOverlayCanvas.height = 0; - return; - } - - const dpr = window.devicePixelRatio || 1; - signalOverlayGl.ensureSize(cssW, cssH, dpr); - const W = signalOverlayCanvas.width; - const H = signalOverlayCanvas.height; - if (W <= 0 || H <= 0) return; - signalOverlayGl.clear([0, 0, 0, 0]); - - const range = spectrumVisibleRange(lastSpectrumData); - const hzToX = (hz) => ((hz - range.visLoHz) / range.visSpanHz) * W; - const bwSoft = BW_OVERLAY_COLORS.soft; - const bwMid = BW_OVERLAY_COLORS.mid; - const bwEdge = BW_OVERLAY_COLORS.edge; - const bwStroke = BW_OVERLAY_COLORS.stroke; - const bwHard = BW_OVERLAY_COLORS.hard; - const bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; - if (Array.isArray(bmRef) && bmRef.length > 0) { - const colorMap = bmCategoryColorMap(); - const grouped = new Map(); - for (const bm of bmRef) { - const f = Number(bm?.freq_hz); - if (!Number.isFinite(f) || f < range.visLoHz || f > range.visHiHz) continue; - if (Number.isFinite(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; - const x = hzToX(f); - if (!Number.isFinite(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); - } - for (const [color, segments] of grouped.entries()) { - if (!Array.isArray(segments) || segments.length === 0) continue; - signalOverlayGl.drawSegments(segments, rgbaWithAlpha(color, 0.72), Math.max(1, dpr * 0.9)); - } - } - - const _bwCenterHz = activeBandwidthCenterHz(); - if (_bwCenterHz != null && currentBandwidthHz > 0) { - for (const spec of visibleBandwidthSpecs(_bwCenterHz)) { - const span = displaySpanForBandwidthSpec(spec); - const xL = hzToX(span.loHz); - const xR = hzToX(span.hiHz); - const stripW = xR - xL; - if (stripW <= 1) continue; - if (span.side < 0) { - signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwSoft, bwMid, bwMid, bwSoft); - } else if (span.side > 0) { - signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwMid, bwSoft, bwSoft, bwMid); - } else { - const half = stripW / 2; - signalOverlayGl.fillGradientRect(xL, 0, half, H, bwSoft, bwMid, bwMid, bwSoft); - signalOverlayGl.fillGradientRect(xL + half, 0, half, H, bwMid, bwSoft, bwSoft, bwMid); - } - - const edgeW = Math.max(1, Math.round(5 * dpr)); - if (span.side <= 0) { - signalOverlayGl.fillRect(xL, 0, edgeW, H, bwEdge); - } - if (span.side >= 0) { - signalOverlayGl.fillRect(xR - edgeW, 0, edgeW, H, bwEdge); - } - - if (span.side <= 0) { - signalOverlayGl.drawSegments([xL, 0, xL, H], bwStroke, Math.max(1, dpr * 1.5)); - } - if (span.side >= 0) { - signalOverlayGl.drawSegments([xR, 0, xR, H], bwStroke, Math.max(1, dpr * 1.5)); - } - if (span.side !== 0) { - const hardX = span.side < 0 ? xR : xL; - signalOverlayGl.drawSegments([hardX, 0, hardX, H], bwHard, Math.max(1, dpr)); - } - } - } - - // Virtual channel markers (sky-blue dashed lines, active one is solid). - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels)) { - vchanChannels.forEach(ch => { - if (!Number.isFinite(ch.freq_hz) || ch.freq_hz <= 0) return; - const xc = hzToX(ch.freq_hz); - if (xc < 0 || xc > W) return; - const isActive = ch.id === vchanActiveId; - const color = cssColorToRgba("#38bdf8"); - if (isActive) { - signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5)); - } else { - signalOverlayGl.drawDashedVerticalLine( - xc, 0, H, - Math.max(2, Math.round(4 * dpr)), - Math.max(3, Math.round(6 * dpr)), - color, - Math.max(1, dpr), - ); - } - }); - } - - if (lastFreqHz != null) { - const xf = hzToX(lastFreqHz); - if (xf >= 0 && xf <= W) { - signalOverlayGl.drawDashedVerticalLine( - xf, - 0, - H, - Math.max(2, Math.round(4 * dpr)), - Math.max(2, Math.round(4 * dpr)), - cssColorToRgba("#ff1744"), - Math.max(1, dpr), - ); - } - } -} - -function scheduleOverviewDraw() { - if (!overviewCanvas || overviewDrawPending) return; - overviewDrawPending = true; - requestAnimationFrame(() => { - overviewDrawPending = false; - drawHeaderSignalGraph(); - }); -} - -function pushHeaderSignalSample(sUnits) { - if (!overviewCanvas) return; - const now = Date.now(); - const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; - overviewSignalSamples.push({ t: now, v: sample }); - while (overviewSignalSamples.length && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { - overviewSignalSamples.shift(); - } - scheduleOverviewDraw(); -} - -function trimOverviewWaterfallRows() { - if (!overviewCanvas) return; - const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr)); - if (overviewWaterfallRows.length > maxRows) { - overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows); - } -} - -function overviewVisibleBinWindow(data, binCount) { - if (!data || !Number.isFinite(data.sample_rate) || binCount <= 1) { - return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; - } - const range = spectrumVisibleRange(data); - const fullLoHz = data.center_hz - data.sample_rate / 2; - const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate; - const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate; - const maxIdx = binCount - 1; - const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx))); - const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx))); - return { startIdx, endIdx }; -} - -function pushOverviewWaterfallFrame(data) { - if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; - overviewWaterfallRows.push(data.bins.slice()); - overviewWaterfallPushCount++; - trimOverviewWaterfallRows(); - scheduleOverviewDraw(); -} - -function startHeaderSignalSampling() { - if (!overviewCanvas || overviewSignalTimer) return; - overviewSignalTimer = setInterval(() => { - pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0); - }, 120); -} - -function drawHeaderSignalGraph() { - if (!ensureOverviewCanvasBackingStore()) return; - if (!overviewGl || !overviewGl.ready) return; - const pal = canvasPalette(); - const W = overviewCanvas.width; - const H = overviewCanvas.height; - if (W <= 0 || H <= 0) return; - - overviewGl.clear(cssColorToRgba(pal.bg)); - if (lastSpectrumData && overviewWaterfallRows.length > 0) { - drawOverviewWaterfall(W, H, pal); - } else { - drawOverviewSignalHistory(W, H, pal); - } - positionRdsPsOverlay(); - drawSignalOverlay(); - updateBandplanStrip(bandplanComputeRange()); -} - -function drawOverviewWaterfall(W, H, pal) { - if (!overviewGl || !overviewGl.ready) return; - const maxVisible = Math.max(1, Math.floor(H)); - const rows = overviewWaterfallRows.slice(-maxVisible); - if (rows.length === 0) return; - - const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2))); - const iH = Math.max(1, rows.length); - const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; - const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90); - const view = lastSpectrumData ? spectrumVisibleRange(lastSpectrumData) : null; - const viewKey = view ? `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}` : "na"; - const palKey = overviewWfPaletteKey(pal, viewKey); - const rowStride = iW * 4; - const expectedSize = iW * iH * 4; - const newPushes = overviewWaterfallPushCount - overviewWfTexPushCount; - const sizeChanged = overviewWfTexWidth !== iW || overviewWfTexHeight !== iH; - const palChanged = overviewWfTexPalKey !== palKey; - const needsFull = !overviewWfTexData || sizeChanged || palChanged || overviewWfTexPushCount === 0; - let texUpdated = false; - - if (!overviewWfTexData || overviewWfTexData.length !== expectedSize) { - overviewWfTexData = new Uint8Array(expectedSize); - } - overviewWfTexWidth = iW; - overviewWfTexHeight = iH; - - ensureWaterfallLut(pal, minDb, maxDb); - - function renderRow(dstY, srcBins) { - if (!isBinsArray(srcBins) || srcBins.length === 0) return; - const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); - const spanBins = Math.max(1, endIdx - startIdx); - const rowBase = dstY * rowStride; - const iwM1 = Math.max(1, iW - 1); - for (let x = 0; x < iW; x++) { - const binIdx = Math.min(endIdx, startIdx + ((x * spanBins / iwM1) | 0)); - waterfallLutWrite(overviewWfTexData, rowBase + x * 4, srcBins[binIdx]); - } - } - - if (needsFull) { - for (let y = 0; y < iH; y++) { - renderRow(y, rows[y]); - } - overviewWfTexPushCount = overviewWaterfallPushCount; - overviewWfTexPalKey = palKey; - texUpdated = true; - } else if (newPushes > 0) { - const newCount = Math.min(newPushes, iH); - if (newCount >= iH) { - for (let y = 0; y < iH; y++) renderRow(y, rows[y]); - } else { - const shiftBytes = newCount * rowStride; - overviewWfTexData.copyWithin(0, shiftBytes); - const startRow = iH - newCount; - for (let y = startRow; y < iH; y++) { - renderRow(y, rows[y]); - } - } - overviewWfTexPushCount = overviewWaterfallPushCount; - overviewWfTexPalKey = palKey; - texUpdated = true; - } - - if (texUpdated || !overviewWfTexReady) { - overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, overviewWfTexData, "linear"); - overviewWfTexReady = true; - } - overviewGl.drawTexture("overview-waterfall", 0, 0, W, H, 1, true); -} - -function drawOverviewSignalHistory(W, H, pal) { - if (!overviewGl || !overviewGl.ready) return; - const now = Date.now(); - const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS); - if (samples.length === 0) return; - - const maxVal = 20; - const windowStart = now - HEADER_SIG_WINDOW_MS; - const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * W; - const toY = (v) => H - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (H - 3) - 1.5; - - const gridMarkers = [ - { val: 0 }, - { val: 9 }, - { val: 18 }, - ]; - const gridSegments = []; - for (const marker of gridMarkers) { - const y = toY(marker.val); - gridSegments.push(0, y, W, y); - } - overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1); - - const linePoints = []; - samples.forEach((sample, idx) => { - const x = toX(sample.t); - const y = toY(sample.v); - if (idx === 0 || x >= linePoints[linePoints.length - 2]) { - linePoints.push(x, y); - } - }); - overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); - - const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); - if (holdMs > 0) { - const holdPoints = []; - for (let i = 0; i < samples.length; i++) { - let peak = samples[i].v; - for (let j = i; j >= 0; j--) { - if (samples[i].t - samples[j].t > holdMs) break; - if (samples[j].v > peak) peak = samples[j].v; - } - const x = toX(samples[i].t); - const y = toY(peak); - if (i === 0 || x >= holdPoints[holdPoints.length - 2]) { - holdPoints.push(x, y); - } - } - overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1); - } -} - -function waterfallColorRgba(db, pal, minDb, maxDb) { - const lo = Number.isFinite(minDb) ? minDb : (Number.isFinite(spectrumFloor) ? spectrumFloor : -115); - const hi = Number.isFinite(maxDb) ? maxDb : (lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90)); - const safeDb = Number.isFinite(db) ? db : lo; - const clamped = Math.max(lo, Math.min(hi, safeDb)); - const span = Math.max(1, hi - lo); - const tLinear = (clamped - lo) / span; - const t = waterfallGamma === 1.0 ? tLinear : Math.pow(tLinear, waterfallGamma); - const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]); - const light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]); - const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[0]); - if (typeof window.trxHslToRgba === "function") { - return window.trxHslToRgba(hue, pal.waterfallSat, light, alpha); - } - return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`); -} - -// 256-entry waterfall color lookup table (bins are i8 = 256 possible values). -// Eliminates per-pixel HSL→RGBA computation in the waterfall rendering hot path. -let _wfLutKey = ""; -const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] × 256 entries, 0-255 range - -function ensureWaterfallLut(pal, minDb, maxDb) { - const key = `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${minDb}|${maxDb}|${waterfallGamma}`; - if (key === _wfLutKey) return; - _wfLutKey = key; - for (let i = 0; i < 256; i++) { - // i8 range: -128 to 127 (dB values in the spectrum) - const db = i < 128 ? i : i - 256; - const c = waterfallColorRgba(db, pal, minDb, maxDb); - const p = i * 4; - _wfLut[p + 0] = (c[0] * 255 + 0.5) | 0; - _wfLut[p + 1] = (c[1] * 255 + 0.5) | 0; - _wfLut[p + 2] = (c[2] * 255 + 0.5) | 0; - _wfLut[p + 3] = (c[3] * 255 + 0.5) | 0; - } -} - -// Fast waterfall pixel write using LUT. `db` is the raw i8 bin value. -function waterfallLutWrite(texData, offset, db) { - // Convert signed i8 to 0-255 LUT index - const idx = ((db | 0) + 256) & 0xFF; - const p = idx * 4; - texData[offset] = _wfLut[p]; - texData[offset + 1] = _wfLut[p + 1]; - texData[offset + 2] = _wfLut[p + 2]; - texData[offset + 3] = _wfLut[p + 3]; -} - -function formatFreq(hz) { - if (!Number.isFinite(hz)) return "--"; - if (hz >= 1_000_000_000) { - return `${(hz / 1_000_000_000).toFixed(3)} GHz`; - } - if (hz >= 10_000_000) { - return `${(hz / 1_000_000).toFixed(3)} MHz`; - } - return `${(hz / 1_000).toFixed(1)} kHz`; -} - -function formatFreqForStep(hz, step) { - if (!Number.isFinite(hz)) return "--"; - if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6); - if (step >= 1_000) return (hz / 1_000).toFixed(3); - if (step >= 1) return String(Math.round(hz)); - return formatFreq(hz); -} - -function formatWavelength(hz) { - if (!Number.isFinite(hz) || hz <= 0) return "--"; - const meters = 299_792_458 / hz; - if (meters >= 1) return `${Math.round(meters)} m`; - return `${Math.round(meters * 100)} cm`; -} - -function refreshWavelengthDisplay(hz) { - if (!wavelengthEl) return; - wavelengthEl.textContent = formatWavelength(hz); -} - -function refreshFreqDisplay() { - if (lastFreqHz == null || freqDirty) return; - freqEl.value = formatFreqForStep(lastFreqHz, jogUnit); - refreshWavelengthDisplay(lastFreqHz); -} - -function activeRdsChannelId() { - if (typeof vchanActiveId !== "undefined" && vchanActiveId) return vchanActiveId; - return null; -} - -function activeChannelRds() { - if (!activeChannelIsWfm()) return null; - const activeId = activeRdsChannelId(); - if (activeId) { - const rds = vchanRdsById.get(activeId); - if (rds) return rds; - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { - if (vchanChannels[0].id === activeId) return primaryRds; - } - } - return primaryRds; -} - -function activeChannelIsWfm() { - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { - const activeId = activeRdsChannelId(); - const active = vchanChannels.find((ch) => ch.id === activeId) || vchanChannels[0]; - return String(active?.mode || "").toUpperCase() === "WFM"; - } - return lastModeName === "WFM"; -} - -function activeChannelFreqHz() { - if (typeof vchanActiveChannel === "function") { - const ch = vchanActiveChannel(); - if (Number.isFinite(ch?.freq_hz)) return ch.freq_hz; - } - return lastFreqHz; -} - -function activeBandwidthCenterHz() { - const freqHz = activeChannelFreqHz(); - return Number.isFinite(freqHz) ? freqHz : lastFreqHz; -} - -function buildRdsOverlayHtml(rds) { - const ps = rds?.program_service; - const hasPs = !!(ps && ps.length > 0); - const hasPi = rds?.pi != null; - if (!hasPs && !hasPi) return ""; - const mainText = hasPs ? formatOverlayPs(ps) : formatOverlayPi(rds?.pi); - const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback"; - const metaText = hasPs - ? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}` - : (rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : "")); - const trafficFlags = - `` + - `${overlayTrafficFlagHtml("TP", rds?.traffic_program)}` + - `${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}` + - ``; - return ( - `${hasPs ? formatPsHtml(ps) : escapeMapHtml(mainText)}` + - `` + - `${escapeMapHtml(metaText)}` + - `${trafficFlags}` + - `` - ); -} - -function collectRdsOverlayEntries() { - const entries = []; - if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { - for (const ch of vchanChannels) { - if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; - if (!Number.isFinite(ch?.freq_hz)) continue; - const rds = vchanRdsById.get(ch.id) || (vchanChannels[0].id === ch.id ? primaryRds : null); - if (!rds) continue; - entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds }); - } - } else if (lastModeName === "WFM" && primaryRds && Number.isFinite(lastFreqHz)) { - entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); - } - return entries; -} - -function renderRdsOverlays() { - if (!rdsPsOverlay) return; - if (!lastSpectrumData || !overviewCanvas) { - rdsOverlayEntries = []; - rdsPsOverlay.style.display = "none"; - return; - } - const entries = collectRdsOverlayEntries(); - rdsOverlayEntries = []; - rdsPsOverlay.replaceChildren(); - if (entries.length === 0) { - rdsPsOverlay.style.display = "none"; - return; - } - entries.forEach((entry) => { - const html = buildRdsOverlayHtml(entry.rds); - if (!html) return; - const el = document.createElement("div"); - el.className = "rds-ps-overlay-item"; - el.dataset.freqHz = String(entry.freq_hz); - el.innerHTML = html; - el.addEventListener("click", (evt) => { - evt.stopPropagation(); - copyRdsPsToClipboard(entry.rds, entry.freq_hz); - }); - el.addEventListener("mouseenter", () => { - el.style.zIndex = String(entries.length + 10); - }); - el.addEventListener("mouseleave", () => { - if (el.dataset.defaultZ) el.style.zIndex = el.dataset.defaultZ; - }); - rdsPsOverlay.appendChild(el); - rdsOverlayEntries.push({ ...entry, el }); - }); - if (rdsOverlayEntries.length === 0) { - rdsPsOverlay.style.display = "none"; - return; - } - rdsPsOverlay.style.display = "block"; - positionRdsOverlays(); -} - -window.renderRdsOverlays = renderRdsOverlays; - -function positionRdsOverlays() { - if (!rdsPsOverlay || !lastSpectrumData || !overviewCanvas || rdsOverlayEntries.length === 0) return; - const width = overviewCanvas.clientWidth || overviewCanvas.width || 0; - if (width <= 0) return; - const range = spectrumVisibleRange(lastSpectrumData); - if (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) return; - // Assign z-indices: sort by frequency ascending so higher-frequency layers - // sit on top of lower-frequency ones in the default (non-hover) state. - const sortedByFreq = [...rdsOverlayEntries].sort((a, b) => a.freq_hz - b.freq_hz); - const freqZMap = new Map(sortedByFreq.map((e, i) => [e.id, i + 1])); - rdsOverlayEntries.forEach((entry, idx) => { - const el = entry.el; - if (!el) return; - if (!Number.isFinite(entry.freq_hz)) { - el.style.display = "none"; - return; - } - el.style.display = ""; - const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; - const clamped = Math.max(0.06, Math.min(0.94, rel)); - el.style.left = `${clamped * width}px`; - el.style.top = "50%"; - const z = String(freqZMap.get(entry.id) ?? (idx + 1)); - el.style.zIndex = z; - el.dataset.defaultZ = z; - }); -} - -function positionRdsPsOverlay() { - positionRdsOverlays(); -} - -function resetRdsDisplay() { - updateRdsPsOverlay(primaryRds); -} - -function resetDecoderStateOnRigSwitch() { - // RDS - primaryRds = null; - vchanRdsById = new Map(); - resetRdsDisplay(); - resetWfmStereoIndicator(); - resetIntfBars(); - - // Spectrum — clear stale data from previous rig's SDR - lastSpectrumData = null; - window.lastSpectrumData = null; - lastSpectrumRenderData = null; - - // Decoder status indicators - const decoderIds = ["ais-status", "vdes-status", "aprs-status", "cw-status", "ft8-status", "wspr-status"]; - decoderIds.forEach((id) => { - const el = document.getElementById(id); - if (el) el.textContent = "--"; - }); -} - -function resetWfmStereoIndicator() { - if (!wfmStFlagEl) return; - wfmStFlagEl.textContent = "MO"; - wfmStFlagEl.classList.remove("wfm-st-flag-stereo"); - wfmStFlagEl.classList.add("wfm-st-flag-mono"); -} - -function updateIntfBar(fillEl, valEl, level) { - if (!fillEl || !valEl) return; - const v = Math.round(Math.min(Math.max(level, 0), 100)); - valEl.textContent = String(v); - fillEl.style.width = v + "%"; - fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65); - fillEl.classList.toggle("wfm-intf-high", v >= 65); - if (v < 35) { - fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high"); - } -} - -function resetIntfBars() { - updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); - updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); -} - -// ── Fast CSS-based frequency/BW marker positioning ────────────────────────── -// These lightweight DOM elements reposition via `transform: translateX()` -// which is GPU-composited — zero layout/paint cost. The full WebGL overlay -// (drawSignalOverlay) catches up on the next rAF. -const _fastFreqMarker = document.getElementById("fast-freq-marker"); -const _fastBwLeft = document.getElementById("fast-bw-left"); -const _fastBwRight = document.getElementById("fast-bw-right"); - -function positionFastOverlay(freqHz, bwHz) { - if (!lastSpectrumData || !signalVisualBlockEl) { - if (_fastFreqMarker) _fastFreqMarker.style.display = "none"; - if (_fastBwLeft) _fastBwLeft.style.display = "none"; - if (_fastBwRight) _fastBwRight.style.display = "none"; - return; - } - const cssW = signalVisualBlockEl.clientWidth; - if (cssW <= 0) return; - const range = spectrumVisibleRange(lastSpectrumData); - const hzToFrac = (hz) => (hz - range.visLoHz) / range.visSpanHz; - - if (_fastFreqMarker && Number.isFinite(freqHz)) { - const frac = hzToFrac(freqHz); - if (frac >= 0 && frac <= 1) { - _fastFreqMarker.style.display = ""; - _fastFreqMarker.style.transform = `translateX(${frac * cssW}px)`; - } else { - _fastFreqMarker.style.display = "none"; - } - } - if (_fastBwLeft && _fastBwRight && Number.isFinite(freqHz) && Number.isFinite(bwHz) && bwHz > 0) { - const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); - let loHz, hiHz; - if (side < 0) { - loHz = freqHz - bwHz; hiHz = freqHz; - } else if (side > 0) { - loHz = freqHz; hiHz = freqHz + bwHz; - } else { - loHz = freqHz - bwHz / 2; hiHz = freqHz + bwHz / 2; - } - const lFrac = hzToFrac(loHz); - const rFrac = hzToFrac(hiHz); - const cFrac = hzToFrac(freqHz); - // Left side of BW - if (lFrac < cFrac && cFrac >= 0 && lFrac <= 1) { - const x = Math.max(0, lFrac) * cssW; - const w = (Math.min(1, cFrac) - Math.max(0, lFrac)) * cssW; - _fastBwLeft.style.display = ""; - _fastBwLeft.style.transform = `translateX(${x}px)`; - _fastBwLeft.style.width = `${w}px`; - } else { - _fastBwLeft.style.display = "none"; - } - // Right side of BW - if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) { - const x = Math.max(0, cFrac) * cssW; - const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW; - _fastBwRight.style.display = ""; - _fastBwRight.style.transform = `translateX(${x}px)`; - _fastBwRight.style.width = `${w}px`; - } else { - _fastBwRight.style.display = "none"; - } - } -} - -function applyLocalTunedFrequency(hz, forceDisplay = false) { - if (!Number.isFinite(hz)) return; - const freqChanged = lastFreqHz !== hz; - if (!freqChanged && !forceDisplay) return; - if (freqChanged) { - if (lastFreqHz != null) savePreviousTuneState(); - primaryRds = null; - resetRdsDisplay(); - resetWfmStereoIndicator(); - resetIntfBars(); - } - lastFreqHz = hz; - window.lastFreqHz = lastFreqHz; - updateDocumentTitle(activeChannelRds()); - refreshWavelengthDisplay(lastFreqHz); - if (forceDisplay) { - freqDirty = false; - } - if (forceDisplay || !freqDirty) { - refreshFreqDisplay(); - } - window.ft8BaseHz = lastFreqHz; - if (window.updateFt8RfDisplay) { - window.updateFt8RfDisplay(); - } - if (window.refreshCwTonePicker) { - window.refreshCwTonePicker(); - } - // Instant CSS marker repositioning (GPU-composited, no WebGL). - positionFastOverlay(lastFreqHz, currentBandwidthHz); - if (freqChanged && lastSpectrumData) { - scheduleSpectrumDraw(); - } - if (freqChanged && !lastSpectrumData) { - updateBandplanStrip(bandplanComputeRange()); - } - positionRdsPsOverlay(); -} - -function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { - const [, , maxBw] = mwDefaultsForMode(mode); - return Math.max(0, Number.isFinite(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 = lastFreqHz, mode = modeEl ? modeEl.value : "") { - if (!Number.isFinite(freqHz)) return []; - const modeUpper = String(mode || "").toUpperCase(); - if (modeUpper === "AIS") { - return [ - { centerHz: freqHz, widthHz: currentBandwidthHz }, - { centerHz: freqHz + 50_000, widthHz: currentBandwidthHz }, - ]; - } - return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; -} - -function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { - const modeUpper = String(mode || "").toUpperCase(); - if (modeUpper === "LSB" || modeUpper === "CWR") return -1; - if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; - return 0; -} - -function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { - const centerHz = Number(spec?.centerHz); - const widthHz = Math.max(0, Number.isFinite(spec?.widthHz) ? Number(spec.widthHz) : 0); - const side = sidebandDirectionForMode(mode); - if (side < 0) { - return { loHz: centerHz - widthHz, hiHz: centerHz, side }; - } - if (side > 0) { - return { loHz: centerHz, hiHz: centerHz + widthHz, side }; - } - const halfBw = widthHz / 2; - return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; -} - -function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { - if (!Number.isFinite(freqHz)) return null; - const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { - const widthHz = Math.max( - 0, - Number.isFinite(spec.widthHz) ? spec.widthHz : Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0), - ); - return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); - }); - if (specs.length === 0) return null; - let loHz = specs[0].loHz; - let hiHz = specs[0].hiHz; - for (const spec of specs.slice(1)) { - loHz = Math.min(loHz, spec.loHz); - hiHz = Math.max(hiHz, spec.hiHz); - } - return { loHz, hiHz }; -} - -function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { - return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz); -} - -function effectiveSpectrumCoverageSpanHz(sampleRateHz) { - const sampleRate = Number(sampleRateHz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0; - // Keep a guard band at the spectrum edges; practical usable span is slightly smaller. - const ratio = Number.isFinite(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; - return sampleRate * Math.max(0.01, Math.min(1.0, ratio)); -} - -function sweetSpotMinimumOffsetHz(bandwidthHz) { - if (!Number.isFinite(bandwidthHz) || bandwidthHz <= 0) return 0; - return bandwidthHz / 2; -} - -function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { - if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz)) return false; - const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); - if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) return true; - return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; -} - -function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { - if (!Number.isFinite(freqHz) || !Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { - return null; - } - - const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); - if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) { - const fallbackCenterHz = Number.isFinite(preferredCenterHz) ? preferredCenterHz : freqHz; - return alignFreqToRigStep(Math.round(Math.max(minCenterHz, Math.min(maxCenterHz, fallbackCenterHz)))); - } - - const targetCentersHz = []; - const lowerTargetHz = alignFreqToRigStep(Math.round(freqHz - minOffsetHz)); - const upperTargetHz = alignFreqToRigStep(Math.round(freqHz + minOffsetHz)); - if (lowerTargetHz >= minCenterHz && lowerTargetHz <= maxCenterHz) targetCentersHz.push(lowerTargetHz); - if (upperTargetHz >= minCenterHz && upperTargetHz <= maxCenterHz && !targetCentersHz.some((value) => Math.abs(value - upperTargetHz) < 1)) { - targetCentersHz.push(upperTargetHz); - } - if (!targetCentersHz.length) return null; - - if (Number.isFinite(preferredCenterHz)) { - let bestCenterHz = targetCentersHz[0]; - let bestDistance = Math.abs(bestCenterHz - preferredCenterHz); - for (const targetCenterHz of targetCentersHz.slice(1)) { - const distance = Math.abs(targetCenterHz - preferredCenterHz); - if (distance < bestDistance) { - bestDistance = distance; - bestCenterHz = targetCenterHz; - } - } - return bestCenterHz; - } - - return targetCentersHz[0]; -} - -function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) { - if (!data || !Number.isFinite(freqHz)) return null; - const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); - const currentCenterHz = Number(data.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) { - return null; - } - - const halfSpanHz = sampleRate / 2; - const span = coverageSpanForMode(freqHz, bandwidthHz); - if (!span) return null; - const requiredLoHz = span.loHz - spectrumCoverageMarginHz; - const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; - if (requiredHiHz - requiredLoHz >= sampleRate) { - return alignFreqToRigStep(Math.round(freqHz)); - } - - const currentLoHz = currentCenterHz - halfSpanHz; - const currentHiHz = currentCenterHz + halfSpanHz; - if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) { - return null; - } - - let nextCenterHz = currentCenterHz; - if (requiredLoHz < currentLoHz) { - nextCenterHz = requiredLoHz + halfSpanHz; - } - if (requiredHiHz > currentHiHz) { - nextCenterHz = requiredHiHz - halfSpanHz; - } - return alignFreqToRigStep(Math.round(nextCenterHz)); -} - -function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { - return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz); -} - -async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { - const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz); - if (!Number.isFinite(nextCenterHz)) return; - if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return; - await postPath(`/set_center_freq?hz=${nextCenterHz}`); - if (centerFreqEl && !centerFreqDirty) { - centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit); - } -} - -// Guard: while a set_freq is in flight, SSE state updates must not overwrite -// the optimistic local frequency with the stale server value. -let _freqOptimisticHz = null; -let _freqOptimisticSeq = 0; - -function setRigFrequency(freqHz) { - const targetHz = Math.round(freqHz); - if (!freqAllowed(targetHz)) { - showUnsupportedFreqPopup(targetHz); - throw new Error(`Unsupported frequency: ${targetHz}`); - } - // Optimistic local update — visual is instant via CSS overlay + guard. - const prevFreqHz = lastFreqHz; - const seq = ++_freqOptimisticSeq; - _freqOptimisticHz = targetHz; - applyLocalTunedFrequency(targetHz); - // Fire-and-forget: network calls run in background. The SSE stream will - // push the confirmed frequency; the optimistic guard prevents snap-back. - Promise.all([ - postPath(`/set_freq?hz=${targetHz}`), - ensureTunedBandwidthCoverage(targetHz), - ]).catch((err) => { - // Roll back only if no newer optimistic call has superseded this one. - if (_freqOptimisticSeq === seq && prevFreqHz != null) { - _freqOptimisticHz = null; - applyLocalTunedFrequency(prevFreqHz, true); - } - console.warn("setRigFrequency failed:", err); - }).finally(() => { - if (_freqOptimisticSeq === seq) _freqOptimisticHz = null; - }); -} - -function spectrumBinIndexForHz(data, hz) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(hz)) { - return null; - } - const maxIdx = data.bins.length - 1; - const fullLoHz = Number(data.center_hz) - Number(data.sample_rate) / 2; - const idx = Math.round(((hz - fullLoHz) / Number(data.sample_rate)) * maxIdx); - return Math.max(0, Math.min(maxIdx, idx)); -} - -function spectrumPowerScore(db) { - const value = Number.isFinite(db) ? db : -160; - const clamped = Math.max(-160, Math.min(40, value)); - return 10 ** (clamped / 10); -} - -function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { - return null; - } - if (!Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { - return null; - } - - const bins = data.bins; - const sampleRate = Number(data.sample_rate); - const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); - const currentCenterHz = Number(data.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(usableSpanHz) || usableSpanHz <= 0 || !Number.isFinite(currentCenterHz)) { - return null; - } - - const halfUsableSpanHz = usableSpanHz / 2; - const fullHalfSpanHz = sampleRate / 2; - const span = coverageSpanForMode(freqHz, bandwidthHz); - if (!span) return null; - const requiredLoHz = span.loHz - spectrumCoverageMarginHz; - const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; - if (requiredHiHz - requiredLoHz >= usableSpanHz) { - const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( - freqHz, - bandwidthHz, - currentCenterHz - halfUsableSpanHz, - currentCenterHz + halfUsableSpanHz, - requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), - ); - if (!Number.isFinite(fallbackCenterHz)) return null; - return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; - } - - const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2); - const evalMinCenterHz = currentCenterHz - evalHalfSpanHz; - const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz; - const fitMinCenterHz = requiredHiHz - halfUsableSpanHz; - const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz; - const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz); - const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz); - if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { - const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( - freqHz, - bandwidthHz, - evalMinCenterHz, - evalMaxCenterHz, - requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), - ); - if (!Number.isFinite(fallbackCenterHz)) return null; - return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; - } - - const maxIdx = bins.length - 1; - const usableBins = Math.max(4, Math.min(maxIdx, Math.round((usableSpanHz / sampleRate) * maxIdx))); - const fullLoHz = currentCenterHz - fullHalfSpanHz; - const startMinIdx = Math.max( - 0, - Math.min(maxIdx - usableBins, Math.round((((minCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)), - ); - const startMaxIdx = Math.max( - startMinIdx, - Math.min(maxIdx - usableBins, Math.round((((maxCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)), - ); - - let bestStartIdx = null; - let bestScore = Number.POSITIVE_INFINITY; - const signalLoHz = span.loHz; - const signalHiHz = span.hiHz; - - for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) { - const endIdx = Math.min(maxIdx, startIdx + usableBins); - const windowLoHz = fullLoHz + (startIdx / maxIdx) * sampleRate; - const candidateCenterHz = windowLoHz + halfUsableSpanHz; - if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { - continue; - } - const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz))); - const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz))); - - let score = 0; - for (let i = startIdx; i <= endIdx; i++) { - if (i >= signalLoIdx && i <= signalHiIdx) continue; - score += spectrumPowerScore(bins[i]); - } - - // Keep a very small bias toward a reasonably centered passband when scores are close. - const spanMidHz = (span.loHz + span.hiHz) / 2; - const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz); - score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08; - if (score < bestScore) { - bestScore = score; - bestStartIdx = startIdx; - } - } - - if (!Number.isFinite(bestScore) || bestStartIdx == null) { - const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( - freqHz, - bandwidthHz, - minCenterHz, - maxCenterHz, - requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), - ); - if (!Number.isFinite(fallbackCenterHz)) return null; - return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; - } - - const bestLoHz = fullLoHz + (bestStartIdx / maxIdx) * sampleRate; - const bestCenterHz = bestLoHz + halfUsableSpanHz; - return { - centerHz: alignFreqToRigStep(Math.round(bestCenterHz)), - score: bestScore, - }; -} - -function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) { - const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); - return candidate && Number.isFinite(candidate.centerHz) ? candidate.centerHz : null; -} - -function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { - if (!data || !Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { - return []; - } - - const sampleRate = Number(data.sample_rate); - const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); - if (!Number.isFinite(usableSpanHz) || usableSpanHz <= 0) return []; - - const halfUsableSpanHz = usableSpanHz / 2; - const span = coverageSpanForMode(freqHz, bandwidthHz); - if (!span) return []; - const requiredLoHz = span.loHz - spectrumCoverageMarginHz; - const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; - if (requiredHiHz - requiredLoHz >= usableSpanHz) { - const probeCenters = []; - const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); - for (const centerHz of [freqHz - minOffsetHz, freqHz + minOffsetHz]) { - const alignedHz = alignFreqToRigStep(Math.round(centerHz)); - if (sweetSpotCenterHasRequiredOffset(alignedHz, freqHz, bandwidthHz) - && !probeCenters.some((value) => Math.abs(value - alignedHz) < 1)) { - probeCenters.push(alignedHz); - } - } - return probeCenters; - } - - const minCenterHz = requiredHiHz - halfUsableSpanHz; - const maxCenterHz = requiredLoHz + halfUsableSpanHz; - if (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { - return []; - } - - const points = 5; - const centers = []; - for (let i = 0; i < points; i++) { - const frac = points === 1 ? 0.5 : i / (points - 1); - const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac)); - if (sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) - && !centers.some((value) => Math.abs(value - centerHz) < 1)) { - centers.push(centerHz); - } - } - - const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz))); - if (Number.isFinite(currentCenterHz) - && sweetSpotCenterHasRequiredOffset(currentCenterHz, freqHz, bandwidthHz) - && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) { - centers.push(currentCenterHz); - centers.sort((a, b) => a - b); - } - return centers; -} - -async function applySweetSpotCenter() { - if (sweetSpotScanInFlight) { - showHint("Sweet-spot already scanning", 900); - return; - } - if (!Number.isFinite(lastFreqHz) || !lastSpectrumData) return; - - const originalCenterHz = Number(lastSpectrumData.center_hz); - const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz); - let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz); - if (!probeCentersHz.length && (!bestCandidate || !Number.isFinite(bestCandidate.centerHz))) { - showHint("Sweet-spot unavailable", 1100); - return; - } - - sweetSpotScanInFlight = true; - try { - showHint("Scanning sweet spot...", 1400); - - for (const probeCenterHz of probeCentersHz) { - if (!Number.isFinite(probeCenterHz)) continue; - let probeFrame = lastSpectrumData; - if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) { - await postPath(`/set_center_freq?hz=${probeCenterHz}`); - try { - probeFrame = await waitForSpectrumFrame(probeCenterHz, 1400); - } catch (_) { - continue; - } - } - - const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz); - if (!candidate || !Number.isFinite(candidate.centerHz)) continue; - if (!bestCandidate || candidate.score < bestCandidate.score) { - bestCandidate = candidate; - } - } - - const targetCenterHz = bestCandidate && Number.isFinite(bestCandidate.centerHz) - ? bestCandidate.centerHz - : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); - if (!Number.isFinite(targetCenterHz)) { - if (Number.isFinite(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) { - await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`); - } - showHint("Sweet-spot unavailable", 1100); - return; - } - if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) { - await postPath(`/set_center_freq?hz=${targetCenterHz}`); - } - if (centerFreqEl && !centerFreqDirty) { - centerFreqEl.value = formatFreqForStep(targetCenterHz, jogUnit); - } - if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { - showHint("Already at sweet spot", 900); - } else { - showHint("Sweet-spot set", 1200); - } - } finally { - sweetSpotScanInFlight = false; - } -} - -function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { - if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null; - const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); - if (!Number.isFinite(sampleRate) || sampleRate <= 0) return null; - - const span = coverageSpanForMode(freqHz, bandwidthHz); - if (!span) return null; - const halfSpanHz = sampleRate / 2; - const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz); - const requiredHiOffset = (span.hiHz + spectrumCoverageMarginHz) - freqHz; - if (requiredLoOffset + requiredHiOffset >= sampleRate) { - return alignFreqToRigStep(Math.round(centerHz)); - } - - const minFreqHz = centerHz - halfSpanHz + requiredLoOffset; - const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset; - if (freqHz >= minFreqHz && freqHz <= maxFreqHz) { - return null; - } - const clampedHz = Math.max(minFreqHz, Math.min(maxFreqHz, freqHz)); - return alignFreqToRigStep(Math.round(clampedHz)); -} - -// Optimistic center freq: updated immediately on each arrow click so that -// rapid clicks accumulate rather than all starting from the same stale frame. -let spectrumCenterPendingHz = null; - -async function shiftSpectrumCenter(direction) { - if (!lastSpectrumData || !Number.isFinite(direction) || direction === 0) return; - const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); - const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); - if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) return; - - const stepHz = Math.max(50_000, Math.round(sampleRate * 0.35)); - const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz)); - spectrumCenterPendingHz = nextCenterHz; - showHint("Shifting spectrum…", 900); - await postPath(`/set_center_freq?hz=${nextCenterHz}`); - if (centerFreqEl && !centerFreqDirty) { - centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit); - } - - const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); - if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { - await postPath(`/set_freq?hz=${nextFreqHz}`); - applyLocalTunedFrequency(nextFreqHz); - } -} - -function refreshCenterFreqDisplay() { - if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; - centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit); -} - -function parseFreqInput(val, defaultStep) { - if (!val) return null; - const trimmed = val.trim().toLowerCase(); - const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); - if (!match) return null; - const rawNumber = match[1]; - let num = parseFloat(rawNumber.replace(",", ".")); - const unit = match[2] || ""; - if (Number.isNaN(num)) return null; - if (unit.startsWith("gh") || unit === "g") { - num *= 1_000_000_000; - } else if (unit.startsWith("mh") || unit === "m") { - num *= 1_000_000; - } else if (unit.startsWith("kh") || unit === "k") { - num *= 1_000; - } else if (!unit) { - const mode = (modeEl?.value || "").toUpperCase(); - const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); - if (mode === "WFM") { - if (hasDecimalSeparator && num >= 50 && num < 200) { - num *= 1_000_000; - return Math.round(num); - } - if (!hasDecimalSeparator && num >= 875 && num <= 1080) { - num = (num / 10) * 1_000_000; - return Math.round(num); - } - } - // Use currently selected input unit when user omits suffix. - if (defaultStep >= 1_000_000) { - num *= 1_000_000; - } else if (defaultStep >= 1_000) { - num *= 1_000; - } else if (defaultStep >= 1) { - // already Hz - } else { - // Fallback heuristic. - if (num >= 1_000_000) { - // Assume already Hz. - } else if (num >= 1_000) { - num *= 1_000; - } else { - num *= 1_000_000; - } - } - } - return Math.round(num); -} - -function normalizeMinFreqStep(cap) { - const val = Number(cap && cap.min_freq_step_hz); - if (!Number.isFinite(val) || val < 1) return 1; - return Math.round(val); -} - -function alignFreqToRigStep(hz) { - if (!Number.isFinite(hz)) return hz; - const step = Math.max(1, minFreqStepHz); - return Math.round(hz / step) * step; -} - -function updateJogStepSupport(cap) { - const nextMinStep = normalizeMinFreqStep(cap); - minFreqStepHz = nextMinStep; - - const stepRoot = document.getElementById("jog-step"); - if (!stepRoot) return; - const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); - if (buttons.length === 0) return; - - buttons.forEach((btn) => { - const base = Number(btn.dataset.baseStep || btn.dataset.step); - if (Number.isFinite(base) && base > 0) { - btn.dataset.baseStep = String(Math.round(base)); - btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz)); - } - }); - - const steps = buttons - .map((btn) => Number(btn.dataset.step)) - .filter((s) => Number.isFinite(s) && s > 0); - if (steps.length === 0) return; - - const current = Number(jogUnit); - const desired = - Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz); - - jogUnit = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), steps[0]); - jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); - saveSetting("jogUnit", jogUnit); - saveSetting("jogStep", jogStep); - - buttons.forEach((btn) => { - btn.classList.toggle("active", Number(btn.dataset.step) === jogUnit); - }); - - refreshFreqDisplay(); - refreshCenterFreqDisplay(); -} - -function normalizeMode(modeVal) { - if (typeof modeVal === "string") return modeVal; - if (modeVal && typeof modeVal === "object") { - const entries = Object.entries(modeVal); - if (entries.length > 0) { - const [variant, value] = entries[0]; - if (variant === "Other" && typeof value === "string") return value; - return variant; - } - } - return ""; -} - -function updateSupportedBands(cap) { - if (cap && Array.isArray(cap.supported_bands)) { - supportedBands = cap.supported_bands - .filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number") - .map((b) => ({ low: b.low_hz, high: b.high_hz })); - } else { - supportedBands = []; - } -} - -function freqAllowed(hz) { - if (!Number.isFinite(hz)) return false; - if (supportedBands.length === 0) return true; // if unknown, don't block - return supportedBands.some((b) => hz >= b.low && hz <= b.high); -} - -function unsupportedBandSummary() { - if (supportedBands.length === 0) return "No supported frequency ranges were reported by the rig."; - const ranges = supportedBands - .slice() - .sort((a, b) => a.low - b.low) - .map((b) => `${formatFreqForHumans(b.low)} to ${formatFreqForHumans(b.high)}`); - return `Supported ranges: ${ranges.join(", ")}`; -} - -function formatFreqForHumans(hz) { - if (!Number.isFinite(hz)) return "--"; - if (hz >= 1_000_000_000) return `${(hz / 1_000_000_000).toFixed(3)} GHz`; - if (hz >= 1_000_000) return `${(hz / 1_000_000).toFixed(3)} MHz`; - if (hz >= 1_000) return `${(hz / 1_000).toFixed(3)} kHz`; - return `${Math.round(hz)} Hz`; -} - -function showUnsupportedFreqPopup(hz) { - const message = `Unsupported frequency: ${formatFreqForHumans(hz)}.\n\n${unsupportedBandSummary()}`; - showHint("Out of supported range", 1800); - const now = Date.now(); - if (now - lastUnsupportedFreqPopupAt < 1200) return; - lastUnsupportedFreqPopupAt = now; - window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7000 }); -} - -// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). -// Above S9, returns 9 + (overshoot in S-unit-equivalent, i.e. dB/10). -function dbmToSUnits(dbm) { - if (!Number.isFinite(dbm)) return 0; - // Guard against bogus backend values to keep display in a realistic range. - const clampedDbm = Math.max(-140, Math.min(20, dbm)); - if (clampedDbm <= -121) return 0; - if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10; - return (clampedDbm + 121) / 6; -} - -function formatSignal(sUnits) { - if (!Number.isFinite(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; - if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`; - // S9+xdB: round to nearest 10 dB step, cap at +60. - const overDb = Math.min(60, Math.round((sUnits - 9) * 10 / 10) * 10); - return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; -} - -function setDisabled(disabled) { - [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { - if (el) el.disabled = disabled; - }); -} - -let serverVersion = null; -let serverBuildDate = null; -let serverCallsign = null; -let ownerCallsign = null; -let ownerWebsiteUrl = null; -let ownerWebsiteName = null; -let aisVesselUrlBase = null; -let serverRigs = []; -let serverActiveRigId = null; -let serverLat = null; -let serverLon = null; -let initialMapZoom = 10; -let spectrumCoverageMarginHz = 50_000; -let spectrumUsableSpanRatio = 0.92; -const DEFAULT_OVERVIEW_PLOT_HEIGHT_PX = 160; -const DEFAULT_SPECTRUM_PLOT_HEIGHT_PX = 160; -const MIN_OVERVIEW_PLOT_HEIGHT_PX = 90; -const MIN_SPECTRUM_PLOT_HEIGHT_PX = 130; -const DEFAULT_SIGNAL_SPLIT_PERCENT = 50; -const MIN_SIGNAL_SPLIT_PERCENT = 20; -const MAX_SIGNAL_SPLIT_PERCENT = 80; -let spectrumLayoutPending = false; -let spectrumManualTotalPlotHeightPx = null; -let spectrumResizeState = null; -let signalSplitPercent = clampSignalSplitPercent( - Number(loadSetting("signalSplitPercent", DEFAULT_SIGNAL_SPLIT_PERCENT)), -); - - -function scheduleSpectrumLayout() { - if (spectrumLayoutPending) return; - spectrumLayoutPending = true; - requestAnimationFrame(() => { - spectrumLayoutPending = false; - updateSpectrumAutoHeight(); - }); -} - -function clampSignalSplitPercent(value) { - const numeric = Number.isFinite(value) ? value : DEFAULT_SIGNAL_SPLIT_PERCENT; - return Math.max( - MIN_SIGNAL_SPLIT_PERCENT, - Math.min(MAX_SIGNAL_SPLIT_PERCENT, Math.round(numeric)), - ); -} - -function updateSignalSplitControlText() { - if (!signalSplitValueEl) return; - signalSplitValueEl.textContent = `${signalSplitPercent}/${100 - signalSplitPercent}`; -} - -function setSignalSplitControlVisible(visible) { - if (!signalSplitControlEl) return; - signalSplitControlEl.style.display = visible ? "flex" : "none"; -} - -function currentOverviewHeightPx(overviewCanvasEl) { - return Math.max( - MIN_OVERVIEW_PLOT_HEIGHT_PX, - Math.round(overviewCanvasEl?.clientHeight || DEFAULT_OVERVIEW_PLOT_HEIGHT_PX), - ); -} - -function currentSpectrumHeightPx(spectrumCanvasEl) { - return Math.max( - MIN_SPECTRUM_PLOT_HEIGHT_PX, - Math.round(spectrumCanvasEl?.clientHeight || DEFAULT_SPECTRUM_PLOT_HEIGHT_PX), - ); -} - -function spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl) { - const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); - const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); - const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight; - const tabBottom = tabMainEl.getBoundingClientRect().bottom; - const contentBottom = contentEl.getBoundingClientRect().bottom; - const slackPx = Math.floor(tabBottom - contentBottom); - const minTotalHeight = MIN_OVERVIEW_PLOT_HEIGHT_PX + MIN_SPECTRUM_PLOT_HEIGHT_PX; - const maxAutoTotalHeight = Math.max( - minTotalHeight, - currentTotalHeight + slackPx - 2, - ); - return { - minTotal: minTotalHeight, - autoMaxTotal: maxAutoTotalHeight, - }; -} - -function updateSpectrumAutoHeight() { - const root = document.documentElement; - const overviewCanvasEl = document.getElementById("overview-canvas"); - const spectrumPanelEl = document.getElementById("spectrum-panel"); - const spectrumCanvasEl = document.getElementById("spectrum-canvas"); - if (!root || !tabMainEl || !contentEl || !overviewCanvasEl || !spectrumPanelEl || !spectrumCanvasEl) return; - - const mainVisible = getComputedStyle(tabMainEl).display !== "none"; - const contentVisible = getComputedStyle(contentEl).display !== "none"; - const spectrumVisible = getComputedStyle(spectrumPanelEl).display !== "none"; - const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); - const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); - - if (!mainVisible || !contentVisible || !spectrumVisible) { - setSignalSplitControlVisible(false); - const dimensionsChanged = - currentOverviewHeight !== DEFAULT_OVERVIEW_PLOT_HEIGHT_PX - || currentSpectrumHeight !== DEFAULT_SPECTRUM_PLOT_HEIGHT_PX; - root.style.setProperty("--overview-plot-height", `${DEFAULT_OVERVIEW_PLOT_HEIGHT_PX}px`); - root.style.setProperty("--spectrum-plot-height", `${DEFAULT_SPECTRUM_PLOT_HEIGHT_PX}px`); - if (dimensionsChanged) { - resizeHeaderSignalCanvas(); - scheduleOverviewDraw(); - if (lastSpectrumData) scheduleSpectrumDraw(); - } - return; - } - - setSignalSplitControlVisible(true); - const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); - const nextTotalHeight = spectrumManualTotalPlotHeightPx == null - ? bounds.autoMaxTotal - : Math.max(bounds.minTotal, Math.round(spectrumManualTotalPlotHeightPx)); - if (spectrumManualTotalPlotHeightPx != null) { - spectrumManualTotalPlotHeightPx = nextTotalHeight; - } - const requestedOverviewHeight = Math.round((nextTotalHeight * signalSplitPercent) / 100); - const nextOverviewHeight = Math.max( - MIN_OVERVIEW_PLOT_HEIGHT_PX, - Math.min(nextTotalHeight - MIN_SPECTRUM_PLOT_HEIGHT_PX, requestedOverviewHeight), - ); - const nextSpectrumHeight = Math.max( - MIN_SPECTRUM_PLOT_HEIGHT_PX, - nextTotalHeight - nextOverviewHeight, - ); - if ( - Math.abs(nextOverviewHeight - currentOverviewHeight) < 2 - && Math.abs(nextSpectrumHeight - currentSpectrumHeight) < 2 - ) return; - - root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`); - root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`); - // Refresh cached canvas sizes after layout change. - if (typeof _updateCachedCanvasSizes === "function") _updateCachedCanvasSizes(); - if (lastSpectrumData) { - scheduleSpectrumDraw(); - scheduleOverviewDraw(); - scheduleSpectrumWaterfallDraw(); - } -} - -function beginSpectrumResize(clientY) { - const overviewCanvasEl = document.getElementById("overview-canvas"); - const spectrumCanvasEl = document.getElementById("spectrum-canvas"); - const spectrumPanelEl = document.getElementById("spectrum-panel"); - if (!tabMainEl || !contentEl || !overviewCanvasEl || !spectrumCanvasEl || !spectrumPanelEl) return false; - if (getComputedStyle(spectrumPanelEl).display === "none") return false; - const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); - const startTotalHeight = Math.max( - bounds.minTotal, - currentOverviewHeightPx(overviewCanvasEl) + currentSpectrumHeightPx(spectrumCanvasEl), - ); - spectrumResizeState = { - startY: clientY, - startTotalHeight, - minTotalHeight: bounds.minTotal, - }; - document.body.classList.add("spectrum-resizing"); - return true; -} - -function updateSpectrumResize(clientY) { - if (!spectrumResizeState) return; - const deltaY = clientY - spectrumResizeState.startY; - spectrumManualTotalPlotHeightPx = Math.max( - spectrumResizeState.minTotalHeight, - Math.round(spectrumResizeState.startTotalHeight + deltaY), - ); - updateSpectrumAutoHeight(); -} - -function endSpectrumResize() { - spectrumResizeState = null; - document.body.classList.remove("spectrum-resizing"); -} - -const spectrumSizeGrip = document.getElementById("spectrum-size-grip"); -if (spectrumSizeGrip) { - spectrumSizeGrip.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - if (!beginSpectrumResize(event.clientY)) return; - event.preventDefault(); - if (typeof spectrumSizeGrip.setPointerCapture === "function") { - spectrumSizeGrip.setPointerCapture(event.pointerId); - } - }); - spectrumSizeGrip.addEventListener("pointermove", (event) => { - if (!spectrumResizeState) return; - updateSpectrumResize(event.clientY); - }); - const finishResize = (event) => { - if (!spectrumResizeState) return; - if (typeof spectrumSizeGrip.releasePointerCapture === "function" && spectrumSizeGrip.hasPointerCapture(event.pointerId)) { - spectrumSizeGrip.releasePointerCapture(event.pointerId); - } - endSpectrumResize(); - }; - spectrumSizeGrip.addEventListener("pointerup", finishResize); - spectrumSizeGrip.addEventListener("pointercancel", finishResize); - spectrumSizeGrip.addEventListener("dblclick", () => { - spectrumManualTotalPlotHeightPx = null; - scheduleSpectrumLayout(); - }); -} - -if (signalSplitSliderEl) { - signalSplitSliderEl.value = String(signalSplitPercent); - signalSplitSliderEl.addEventListener("input", () => { - signalSplitPercent = clampSignalSplitPercent(Number(signalSplitSliderEl.value)); - signalSplitSliderEl.value = String(signalSplitPercent); - updateSignalSplitControlText(); - saveSetting("signalSplitPercent", signalSplitPercent); - scheduleSpectrumLayout(); - }); - signalSplitSliderEl.addEventListener("dblclick", (event) => { - event.preventDefault(); - signalSplitPercent = DEFAULT_SIGNAL_SPLIT_PERCENT; - signalSplitSliderEl.value = String(signalSplitPercent); - updateSignalSplitControlText(); - saveSetting("signalSplitPercent", signalSplitPercent); - scheduleSpectrumLayout(); - }); -} -updateSignalSplitControlText(); - -function updateTitle() { - const titleEl = document.getElementById("rig-title"); - if (titleEl) { - if (ownerWebsiteUrl) { - const label = ownerWebsiteName || displayLabelFromUrl(ownerWebsiteUrl); - titleEl.innerHTML = - `${escapeMapHtml(label)}`; - } else { - titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; - } - } - updateDocumentTitle(activeChannelRds()); -} - -function displayLabelFromUrl(url) { - try { - const host = new URL(url).hostname.replace(/^www\./i, ""); - return host || url; - } catch (_e) { - return url; - } -} - -window.buildAisVesselUrl = function(mmsi) { - if (!aisVesselUrlBase || !Number.isFinite(Number(mmsi))) return null; - return `${aisVesselUrlBase}${String(mmsi)}`; -}; - -function render(update) { - if (!update) return; - if (update.server_version) serverVersion = update.server_version; - if (update.server_build_date) serverBuildDate = update.server_build_date; - if (update.server_callsign) serverCallsign = update.server_callsign; - if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) { - ownerCallsign = update.owner_callsign; - } - if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) { - ownerWebsiteUrl = update.owner_website_url; - } - if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) { - ownerWebsiteName = update.owner_website_name; - } - if (typeof update.ais_vessel_url_base === "string" && update.ais_vessel_url_base.length > 0) { - aisVesselUrlBase = update.ais_vessel_url_base; - } - const prevLat = serverLat, prevLon = serverLon; - if (update.server_latitude != null) serverLat = update.server_latitude; - if (update.server_longitude != null) serverLon = update.server_longitude; - if (locationSubtitle && Number.isFinite(serverLat) && Number.isFinite(serverLon) - && (serverLat !== prevLat || serverLon !== prevLon || !locationSubtitle.textContent)) { - const grid = latLonToMaidenhead(serverLat, serverLon); - locationSubtitle.textContent = `Location: ${grid}`; - locationSubtitle.style.display = ""; - window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid); - } - window.trx.modules.map?.syncAprsReceiverMarker(); - if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) { - initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); - } - if ( - typeof update.spectrum_coverage_margin_hz === "number" && - Number.isFinite(update.spectrum_coverage_margin_hz) - ) { - spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); - } - if ( - typeof update.spectrum_usable_span_ratio === "number" && - Number.isFinite(update.spectrum_usable_span_ratio) - ) { - spectrumUsableSpanRatio = Math.max(0.01, Math.min(1.0, Number(update.spectrum_usable_span_ratio))); - } - if ( - typeof update.decode_history_retention_min === "number" && - Number.isFinite(update.decode_history_retention_min) && - update.decode_history_retention_min > 0 - ) { - const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); - if (nextRetentionMin !== decodeHistoryRetentionMin) { - decodeHistoryRetentionMin = nextRetentionMin; - if (typeof window.applyDecodeHistoryRetention === "function") { - window.applyDecodeHistoryRetention(); - } - } - } - scheduleSpectrumLayout(); - updateTitle(); - - initialized = !!update.initialized; - const hasUsableSnapshot = - !!update.info && - !!update.status && - !!update.status.freq && - typeof update.status.freq.hz === "number"; - if (!initialized) { - const fallbackRigName = originalTitle || "Rig"; - const manu = (update.info && update.info.manufacturer) || fallbackRigName; - const model = (update.info && update.info.model) || fallbackRigName; - const rev = (update.info && update.info.revision) || ""; - const parts = [manu, model, rev].filter(Boolean).join(" "); - if (!hasUsableSnapshot) { - loadingTitle.textContent = `Initializing ${parts}…`; - loadingSub.textContent = ""; - console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); - loadingEl.style.display = ""; - if (contentEl) contentEl.style.display = "none"; - powerHint.textContent = "Initializing rig…"; - setDisabled(true); - return; - } - loadingEl.style.display = "none"; - if (contentEl) contentEl.style.display = ""; - powerHint.textContent = "Rig not fully initialized yet"; - } else { - loadingEl.style.display = "none"; - if (contentEl) contentEl.style.display = ""; - } - // Server subtitle: keep the static "trx-client vX.Y.Z" and append callsign if available. - if (serverSubtitle && update.server_callsign) { - const base = serverSubtitle.textContent.split(" hosted by")[0]; - const safeCallsign = escapeMapHtml(update.server_callsign); - const encodedCallsign = encodeURIComponent(update.server_callsign); - serverSubtitle.innerHTML = - `${escapeMapHtml(base)} hosted by ${safeCallsign}`; - } - // Note: rig switch decoder reset is now handled in switchRigFromSelect() - // so that other tabs' switches don't reset our state. - updateRigSubtitle(lastActiveRigId); - if (ownerSubtitle) { - if (ownerCallsign) { - const safeOwner = escapeMapHtml(ownerCallsign); - const encodedOwner = encodeURIComponent(ownerCallsign); - ownerSubtitle.innerHTML = - `Owner: ${safeOwner}`; - } else { - ownerSubtitle.textContent = "Owner: --"; - } - } - setDisabled(false); - if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) { - const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean); - if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) { - supportedModes = modes; - modeEl.replaceChildren(); - supportedModes.forEach((m) => { - const opt = document.createElement("option"); - opt.value = m; - opt.textContent = m; - modeEl.appendChild(opt); - }); - } - } - if (update.info && update.info.capabilities) { - updateJogStepSupport(update.info.capabilities); - updateSupportedBands(update.info.capabilities); - applyCapabilities(update.info.capabilities); - } - // Sync filter state (SDR backends only) - if (update.filter && typeof update.filter.bandwidth_hz === "number") { - currentBandwidthHz = update.filter.bandwidth_hz; - window.currentBandwidthHz = currentBandwidthHz; - syncBandwidthInput(currentBandwidthHz); - // Reposition BW overlay immediately so freq+bw render together. - positionFastOverlay(lastFreqHz, currentBandwidthHz); - if (window.refreshCwTonePicker) { - window.refreshCwTonePicker(); - } - if ( - sdrGainEl - && typeof update.filter.sdr_gain_db === "number" - && document.activeElement !== sdrGainEl - ) { - sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db)); - } - if (sdrLnaGainEl && typeof update.filter.sdr_lna_gain_db === "number" - && document.activeElement !== sdrLnaGainEl) { - sdrLnaGainEl.value = String(Math.round(update.filter.sdr_lna_gain_db)); - if (sdrLnaGainControlsEl) sdrLnaGainControlsEl.style.display = ""; - } - if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") { - wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us); - } - if (wfmAudioModeEl && typeof update.filter.wfm_stereo === "boolean") { - const nextMode = update.filter.wfm_stereo ? "stereo" : "mono"; - if (wfmAudioModeEl.value !== nextMode) { - wfmAudioModeEl.value = nextMode; - saveSetting("wfmAudioMode", nextMode); - } - } - if (wfmDenoiseEl && (typeof update.filter.wfm_denoise === "string" || typeof update.filter.wfm_denoise === "boolean")) { - const nextDenoise = typeof update.filter.wfm_denoise === "string" - ? normalizeWfmDenoiseLevel(update.filter.wfm_denoise) - : (update.filter.wfm_denoise ? "auto" : "off"); - if (wfmDenoiseEl.value !== nextDenoise) { - wfmDenoiseEl.value = nextDenoise; - saveSetting("wfmDenoise", nextDenoise); - } - } - if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") { - const detected = update.filter.wfm_stereo_detected; - wfmStFlagEl.textContent = detected ? "ST" : "MO"; - wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected); - wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected); - } - if (typeof update.filter.wfm_cci === "number") { - lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci)); - updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci); - } - if (typeof update.filter.wfm_aci === "number") { - lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci)); - updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci); - } - if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") { - samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100)); - } - if (samCarrierSyncEl && typeof update.filter.sam_carrier_sync === "boolean") { - const nextVal = update.filter.sam_carrier_sync ? "on" : "off"; - if (samCarrierSyncEl.value !== nextVal) samCarrierSyncEl.value = nextVal; - } - const hasSdrSquelchEnabled = typeof update.filter.sdr_squelch_enabled === "boolean"; - const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number"; - if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) { - sdrSquelchSupported = true; - syncSdrSquelchFromServer( - hasSdrSquelchEnabled ? update.filter.sdr_squelch_enabled : true, - hasSdrSquelchThreshold ? update.filter.sdr_squelch_threshold_db : -120, - ); - } - updateSdrSquelchControlVisibility(); - const hasSdrNbEnabled = typeof update.filter.sdr_nb_enabled === "boolean"; - const hasSdrNbThreshold = typeof update.filter.sdr_nb_threshold === "number"; - if (hasSdrNbEnabled || hasSdrNbThreshold) { - sdrNbSupported = true; - if (sdrNbWrapEl) sdrNbWrapEl.style.display = ""; - if (sdrNbThresholdControlsEl) sdrNbThresholdControlsEl.style.display = ""; - if (hasSdrNbEnabled && sdrNbEnabledEl) { - sdrNbEnabledEl.checked = update.filter.sdr_nb_enabled; - } - if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) { - sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold)); - } - } - } - if (typeof update.show_sdr_gain_control === "boolean") { - if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none"; - } - // Apply server-configured bandplan defaults once, only when the user has not - // previously overridden the setting via the UI (localStorage). - if (!_bandplanServerDefaultApplied && typeof update.bandplan_enabled === "boolean" - && typeof update.bandplan_region === "string") { - _bandplanServerDefaultApplied = true; - const hasUserOverride = localStorage.getItem(STORAGE_PREFIX + "bandplanRegion") !== null; - if (!hasUserOverride) { - const region = update.bandplan_enabled ? update.bandplan_region : "off"; - bandplanRegion = region; - saveSetting("bandplanRegion", region); - if (bandplanRegionSelect) bandplanRegionSelect.value = region; - bandplanSegmentsCache = null; - bandplanCacheKey = ""; - if (lastSpectrumData) scheduleSpectrumDraw(); - } - } - if (update.filter && sdrAgcEl && typeof update.filter.sdr_agc_enabled === "boolean") { - sdrAgcEl.checked = update.filter.sdr_agc_enabled; - updateSdrGainInputState(); - } - if (update.status && update.status.freq && typeof update.status.freq.hz === "number") { - if (update.status.freq.hz !== prevRenderData.freqHz) { - prevRenderData.freqHz = update.status.freq.hz; - const sseHz = update.status.freq.hz; - // While an optimistic set_freq is in flight, suppress SSE updates that - // would snap the marker back to the stale server frequency. - if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) > 1) { - // stale — skip - } else { - if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) <= 1) { - _freqOptimisticHz = null; // server confirmed — clear guard early - } - applyLocalTunedFrequency(sseHz); - } - } - } - if (update.status && update.status.mode && update.status.mode !== prevRenderData.mode) { - prevRenderData.mode = update.status.mode; - const mode = normalizeMode(update.status.mode); - const modeUpper = mode ? mode.toUpperCase() : ""; - const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); - // When subscribed to a virtual channel the mode picker must reflect - // that channel's mode, not the primary rig mode. Skip the update here; - // vchan.js will apply the correct mode via vchanSyncModeDisplay(). - if (!onVirtual) { - modeEl.value = modeUpper; - if (modeUpper === "WFM" && lastModeName !== "WFM") { - setJogDivisor(10); - resetRdsDisplay(); - } else if (modeUpper !== "WFM" && lastModeName === "WFM") { - resetRdsDisplay(); - } - lastModeName = modeUpper; - // When filter panel is active (SDR backend), update the BW slider range - // to match the new mode — but only if the server hasn't already sent a - // filter state that overrides it. - // 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); - } - } - updateWfmControls(); - updateSdrSquelchControlVisibility(); - } - const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; - // Mode-bound decoder status (driven by registry). - for (const d of decoderRegistry) { - if (d.activation !== "mode_bound") continue; - const el = document.getElementById(d.id + "-status"); - if (!el) continue; - const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; - setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); - } - if (window.updateAisBar) window.updateAisBar(); - if (window.updateVdesBar) window.updateVdesBar(); - if (window.updateAprsBar) window.updateAprsBar(); - if (window.updateFt8Bar) window.updateFt8Bar(); - // Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong. - for (const d of decoderRegistry) { - if (d.activation !== "toggle") continue; - const key = d.id.replace(/-/g, "_") + "_decode_enabled"; - const enabled = !!update[key]; - const modeMatch = d.active_modes.includes(modeUpper); - const el = document.getElementById(d.id + "-status"); - if (el && (!enabled || !modeMatch) && el.textContent === "Receiving") { - el.textContent = "Connected, listening for packets"; - } - } - if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) { - prevRenderData.txEn = update.status.tx_en; - lastTxEn = update.status.tx_en; - window.trxUi?.setButtonState(pttBtn, { - active: update.status.tx_en, - activeLabel: "Stop TX", - inactiveLabel: "Start TX", - }); - if (update.status.tx_en) { - pttBtn.style.background = "var(--accent-red)"; - pttBtn.style.borderColor = "var(--accent-red)"; - pttBtn.style.color = "white"; - } else { - pttBtn.style.background = ""; - pttBtn.style.borderColor = ""; - pttBtn.style.color = ""; - } - } - // Decoder toggle buttons: only write DOM when the enabled flag actually changes. - _ensureDecoderToggles(); - for (const [key, entry] of Object.entries(_decoderToggles)) { - syncDecoderToggle(entry, !!update[key], entry.label); - } - // WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles). - if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) { - window.syncWefaxToggle(update.wefax_decode_enabled); - } - // Recorder state sync. - if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) { - window._syncRecorderState(update.recorder_enabled); - } - if (window.updateSatLiveState) window.updateSatLiveState(update); - // cwAutoEl, cwWpmEl, cwToneEl are cached at module level - if (cwWpmEl && typeof update.cw_wpm === "number") { - cwWpmEl.value = update.cw_wpm; - } - if (cwToneEl && typeof update.cw_tone_hz === "number") { - cwToneEl.value = update.cw_tone_hz; - } - if (typeof update.cw_auto === "boolean") { - if (typeof window.applyCwAutoUiFromServer === "function") { - // cw.js is loaded: use the guarded path that respects in-flight user - // changes, preventing a concurrent SSE poll from re-enabling auto just - // after the user disabled it. - window.applyCwAutoUiFromServer(update.cw_auto); - } else if (typeof window.applyCwAutoUi === "function") { - window.applyCwAutoUi(update.cw_auto); - } else { - if (cwAutoEl) cwAutoEl.checked = update.cw_auto; - if (cwWpmEl) { cwWpmEl.disabled = update.cw_auto; cwWpmEl.readOnly = update.cw_auto; } - if (cwToneEl) { cwToneEl.disabled = update.cw_auto; cwToneEl.readOnly = update.cw_auto; } - } - } - let activeFreqColor = "var(--accent-green)"; - if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { - const entries = update.status.vfo.entries; - const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null; - vfoPicker.replaceChildren(); - entries.forEach((entry, idx) => { - const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null; - if (hz === null) return; - const mode = entry.mode ? normalizeMode(entry.mode) : ""; - const modeText = mode ? ` [${mode}]` : ""; - const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`; - const btn = document.createElement("button"); - btn.type = "button"; - btn.textContent = label; - const color = vfoColor(idx); - if (activeIdx === idx) { - btn.classList.add("active"); - btn.style.color = color; - activeFreqColor = color; - } else btn.addEventListener("click", async () => { - btn.disabled = true; - showHint("Toggling VFO…"); - try { - await postPath("/toggle_vfo"); - showHint("VFO toggled", 1200); - } catch (err) { - showHint("VFO toggle failed", 2000); - console.error(err); - } finally { - btn.disabled = false; - } - }); - vfoPicker.appendChild(btn); - }); - } else { - vfoPicker.innerHTML = ""; - } - if (freqEl) { - freqEl.style.color = activeFreqColor; - } - if (update.status && update.status.rx && typeof update.status.rx.sig === "number") { - if (update.status.rx.sig !== prevRenderData.sigDbm) { - prevRenderData.sigDbm = update.status.rx.sig; - const sUnits = dbmToSUnits(update.status.rx.sig); - sigLastSUnits = sUnits; - sigLastDbm = update.status.rx.sig; - const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; - signalBar.style.width = `${pct}%`; - signalValue.innerHTML = formatSignal(sUnits); - refreshSigStrengthDisplay(); - } - } else if (prevRenderData.sigDbm !== null) { - prevRenderData.sigDbm = null; - sigLastSUnits = null; - sigLastDbm = null; - signalBar.style.width = "0%"; - signalValue.textContent = "--"; - refreshSigStrengthDisplay(); - } - if (bandLabel) { - bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; - } - if (typeof update.enabled === "boolean") { - window.trxUi?.setButtonState(powerBtn, { - active: update.enabled, - activeLabel: "Power Off", - inactiveLabel: "Power On", - }); - } else { - powerBtn.disabled = true; - powerBtn.textContent = "Power unavailable"; - 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 = update.status.tx.limit; - txLimitRow.style.display = ""; - } else { - txLimitInput.value = ""; - txLimitRow.style.display = "none"; - } - - if (typeof update.clients === "number") lastClientCount = update.clients; - // Populate About tab — only update DOM when the about tab is visible - if (_activeTab === "about") { - _resolveAboutEls(); - _resolveAboutDecEls(); - // About — Server card - if (update.server_version && aboutServerVerEl) { - aboutServerVerEl.textContent = `trx-server v${update.server_version}`; - } - if (update.server_build_date && aboutServerBuildDateEl) { - aboutServerBuildDateEl.textContent = update.server_build_date; - } - if (aboutServerAddrEl) aboutServerAddrEl.textContent = location.host; - if (update.server_callsign && aboutServerCallEl) { - aboutServerCallEl.textContent = update.server_callsign; - } - if (Number.isFinite(serverLat) && Number.isFinite(serverLon) && aboutServerLocationEl) { - const grid = latLonToMaidenhead(serverLat, serverLon); - aboutServerLocationEl.textContent = `${grid} (${serverLat.toFixed(4)}, ${serverLon.toFixed(4)})`; - } - - // About — Radio card - if (update.info) { - const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" "); - if (parts && aboutRigInfoEl) aboutRigInfoEl.textContent = parts; - const access = update.info.access; - if (access) { - if (access.Serial) { - const serialPath = access.Serial.path || access.Serial.port || "?"; - if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; - } else if (access.Tcp) { - if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; - } else { - const key = Object.keys(access)[0]; - if (key && aboutRigAccessEl) aboutRigAccessEl.textContent = key; - } - } - if (update.info.capabilities) { - const cap = update.info.capabilities; - if (Array.isArray(cap.supported_modes) && cap.supported_modes.length && aboutModesEl) { - aboutModesEl.textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", "); - } - if (typeof cap.num_vfos === "number" && aboutVfosEl) { - aboutVfosEl.textContent = cap.num_vfos; - } - } - } - if (lastActiveRigId && aboutActiveRigEl) { - aboutActiveRigEl.textContent = lastActiveRigId; - } - - // About — Audio card - if (streamInfo) { - if (aboutAudioCodecEl) aboutAudioCodecEl.textContent = "Opus"; - if (aboutAudioSamplerateEl) aboutAudioSamplerateEl.textContent = `${(streamInfo.sample_rate || 48000).toLocaleString()} Hz`; - if (aboutAudioChannelsEl) aboutAudioChannelsEl.textContent = (streamInfo.channels || 1) === 1 ? "Mono" : "Stereo"; - if (streamInfo.bitrate_bps && aboutAudioBitrateEl) { - const kbps = (streamInfo.bitrate_bps / 1000).toFixed(0); - aboutAudioBitrateEl.textContent = `${kbps} kbps`; - } - if (streamInfo.frame_duration_ms && aboutAudioFrameEl) { - aboutAudioFrameEl.textContent = `${streamInfo.frame_duration_ms} ms`; - } - } - if (aboutAudioRxEl) aboutAudioRxEl.textContent = rxActive ? "Active" : "Off"; - if (typeof update.audio_clients === "number" && aboutAudioStreamsEl) { - aboutAudioStreamsEl.textContent = update.audio_clients; - } - - // About — Decoders card (only update when values change) - syncAboutDecoder(0, !!update.ft8_decode_enabled); - syncAboutDecoder(1, !!update.ft4_decode_enabled); - syncAboutDecoder(2, !!update.ft2_decode_enabled); - syncAboutDecoder(3, !!update.wspr_decode_enabled); - syncAboutDecoder(4, !!update.cw_decode_enabled); - syncAboutDecoder(5, !!(update.aprs_decode_enabled || update.hf_aprs_decode_enabled)); - syncAboutDecoder(6, !!update.lrpt_decode_enabled); - - // About — Integrations card - if (update.pskreporter_status && aboutPskreporterEl) { - aboutPskreporterEl.textContent = update.pskreporter_status; - } - if (update.aprs_is_status && aboutAprsIsEl) { - aboutAprsIsEl.textContent = update.aprs_is_status; - } - if (typeof update.rigctl_clients === "number" && aboutRigctlClientsEl) { - aboutRigctlClientsEl.textContent = update.rigctl_clients; - } - if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0 && aboutRigctlEndpointEl) { - aboutRigctlEndpointEl.textContent = update.rigctl_addr; - } - - // About — Clients card - if (typeof update.clients === "number" && aboutClientsEl) { - aboutClientsEl.textContent = update.clients; - } - } // end _activeTab === "about" - if (Array.isArray(update.remotes)) { - applyRigList(update.active_remote, update.remotes); - } - powerHint.textContent = readyText(); - lastLocked = update.status && update.status.lock === true; - window.trxUi?.setButtonState(lockBtn, { - active: lastLocked, - activeLabel: "Unlock Tuning", - inactiveLabel: "Lock Tuning", - }); - - const tx = update.status && update.status.tx ? update.status.tx : null; - txMeters.style.display = lastHasTx ? "" : "none"; - if (tx && typeof tx.power === "number") { - const pct = Math.max(0, Math.min(100, tx.power)); - pwrBar.style.width = `${pct}%`; - pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`; - } else { - pwrBar.style.width = "0%"; - pwrValue.textContent = "PWR --"; - } - if (tx && typeof tx.swr === "number") { - const swr = Math.max(1, tx.swr); - const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100)); - swrBar.style.width = `${pct}%`; - swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`; - } else { - swrBar.style.width = "0%"; - swrValue.textContent = "SWR --"; - } -} - -function scheduleReconnect(delayMs = 1000) { - if (reconnectTimer) return; - reconnectTimer = setTimeout(() => { - reconnectTimer = null; - connect(); - }, delayMs); -} - -async function pollFreshSnapshot() { - try { - 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(); - render(data); - refreshRigList(); - lastEventAt = Date.now(); - } catch (e) { - // Ignore network errors; connect() retry loop handles reconnection. - } -} - -function connect() { - if (es) { - es.close(); - sseSessionId = null; - } - if (esHeartbeat) { - clearInterval(esHeartbeat); - } - stopMeterStreaming(); - startMeterStreaming(); - pollFreshSnapshot(); - const eventsUrl = lastActiveRigId - ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` - : "/events"; - es = new EventSource(eventsUrl); - lastEventAt = Date.now(); - es.onopen = () => { - setConnLostOverlay(false); - if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); - if (!aboutUptimeStart) aboutUptimeStart = Date.now(); - pollFreshSnapshot(); - refreshRigList(); - }; - es.onmessage = (evt) => { - try { - if (evt.data === lastRendered) return; - const data = JSON.parse(evt.data); - lastRendered = evt.data; - render(data); - lastEventAt = Date.now(); - if (data.server_connected === false) { - powerHint.textContent = "trx-server connection lost"; - if (tabMainEl) tabMainEl.classList.add("server-disconnected"); - } else { - if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); - if (data.initialized) powerHint.textContent = readyText(); - } - } catch (e) { - console.error("Bad event data", e); - } - }; - es.addEventListener("ping", () => { - lastEventAt = Date.now(); - }); - es.addEventListener("session", evt => { - try { - const d = JSON.parse(evt.data); - sseSessionId = d.session_id || null; - } catch (_) {} - if (typeof vchanHandleSession === "function") vchanHandleSession(evt.data); - }); - es.addEventListener("channels", evt => { - if (typeof vchanHandleChannels === "function") vchanHandleChannels(evt.data); - }); - es.onerror = () => { - // Check if this is an auth error by looking at readyState - if (es.readyState === EventSource.CLOSED) { - powerHint.textContent = "trx-client connection lost, retrying\u2026"; - setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); - es.close(); - pollFreshSnapshot(); - scheduleReconnect(1000); - } - }; - - esHeartbeat = setInterval(() => { - const now = Date.now(); - if (now - lastEventAt > 15000) { - powerHint.textContent = "trx-client connection lost, retrying\u2026"; - setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); - es.close(); - pollFreshSnapshot(); - scheduleReconnect(250); - } - }, 5000); -} - -function disconnect() { - // Close event sources - if (es) { - es.close(); - es = null; - } - if (decodeSource) { - decodeSource.close(); - decodeSource = null; - } - stopSpectrumStreaming(); - stopMeterStreaming(); - // Clear timers - if (esHeartbeat) { - clearInterval(esHeartbeat); - esHeartbeat = null; - } - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - setDecodeHistoryOverlayVisible(false); - setConnLostOverlay(false); -} - -// Yield the main thread so the browser can paint before heavy async work. -// Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback. -function yieldToMain() { - if (typeof scheduler !== "undefined" && typeof scheduler.yield === "function") { - return scheduler.yield(); - } - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -const uiFrameJobs = new Map(); -let uiFrameJobsHandle = null; - -function flushUiFrameJobs() { - uiFrameJobsHandle = null; - const jobs = Array.from(uiFrameJobs.values()); - uiFrameJobs.clear(); - for (const job of jobs) { - try { - job(); - } catch (err) { - console.error("Deferred UI job failed:", err); - } - } -} - -function scheduleUiFrameJob(key, job) { - if (typeof job !== "function") return; - uiFrameJobs.set(key, job); - if (uiFrameJobsHandle !== null) return; - if (typeof requestAnimationFrame === "function") { - uiFrameJobsHandle = requestAnimationFrame(flushUiFrameJobs); - } else { - uiFrameJobsHandle = setTimeout(flushUiFrameJobs, 16); - } -} - -window.trxScheduleUiFrameJob = scheduleUiFrameJob; - -async function postPath(path, options = {}) { - if (rigSwitchInProgress && !options.allowDuringRigSwitch) { - throw new Error("Wait for the rig switch to finish"); - } - const targetRigId = options.remote === undefined ? lastActiveRigId : options.remote; - // Auto-append remote so each tab targets its own rig. - // Skip when the caller already included remote (e.g. /select_rig). - if (targetRigId && !path.includes("remote=")) { - const sep = path.includes("?") ? "&" : "?"; - path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; - } - const resp = await fetch(path, { method: "POST" }); - if (authEnabled && resp.status === 401) { - // Not authenticated - return to login - authRole = null; - if (es) es.close(); - showAuthGate(); - throw new Error("Authentication required"); - } - if (resp.status === 403) { - // Authenticated but insufficient permissions - don't redirect - throw new Error("Insufficient permissions"); - } - if (!resp.ok) { - const text = await resp.text(); - throw new Error(text || resp.statusText); - } - return resp; -} - -async function takeSchedulerControlForDecoderDisable(buttonEl) { - const enabled = buttonEl?.dataset?.enabled === "true" - || /^\s*Disable\b/i.test(buttonEl?.textContent || ""); - if (!enabled) return; - if (typeof window.vchanTakeSchedulerControl === "function") { - await window.vchanTakeSchedulerControl(); - } -} -window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable; - -async function switchRigFromSelect(selectEl) { - if (!selectEl || !selectEl.value) { - showHint("No rig selected", 1500); - return; - } - if (authRole === "rx") { - showHint("Control role required", 1500); - return; - } - if (!lastRigIds.includes(selectEl.value)) { - showHint("Unknown rig", 1500); - return; - } - const prevRig = lastActiveRigId; - const nextRig = selectEl.value; - if (nextRig === prevRig || rigSwitchInProgress) return; - rigSwitchInProgress = true; - setControlPending(selectEl, true); - selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); - updateRigIdentitySummary(nextRig, true); - showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`); - try { - const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; - await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); - lastActiveRigId = nextRig; - resetDecoderStateOnRigSwitch(); - updateRigSubtitle(lastActiveRigId); - updateRigIdentitySummary(lastActiveRigId); - window.trxUi?.setActiveRig(lastActiveRigId); - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); - if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); - if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); - window.trx.modules.map?.syncAprsReceiverMarker(); - connect(); - stopSpectrumStreaming(); - startSpectrumStreaming(); - stopMeterStreaming(); - startMeterStreaming(); - if (rxActive) { - stopRxAudio(); - startRxAudio(); - } - showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500); - } catch (err) { - console.error("select_rig failed:", err); - selectEl.value = prevRig || ""; - updateRigIdentitySummary(prevRig); - window.trxUi?.notify("Rig could not be switched", { kind: "error" }); - } finally { - rigSwitchInProgress = false; - setControlPending(selectEl, false); - selectEl.closest(".header-rig-switch")?.classList.remove("is-switching"); - } -} - -if (headerRigSwitchSelect) { - headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); }); -} - -function setControlPending(control, pending) { - if (!control) return; - control.disabled = pending; - control.classList.toggle("is-busy", pending); - control.setAttribute("aria-busy", String(pending)); -} - -powerBtn.addEventListener("click", async () => { - setControlPending(powerBtn, true); - showHint("Sending..."); - try { - await postPath("/toggle_power"); - showHint("Toggled, waiting for update…"); - } catch (err) { - showHint("Toggle failed", 2000); - console.error(err); - } finally { - setControlPending(powerBtn, false); - } -}); - -pttBtn.addEventListener("click", async () => { - setControlPending(pttBtn, true); - showHint("Toggling PTT…"); - try { - const desired = lastTxEn ? "false" : "true"; - await postPath(`/set_ptt?ptt=${desired}`); - showHint("PTT command sent", 1500); - } catch (err) { - showHint("PTT toggle failed", 2000); - console.error(err); - } finally { - setControlPending(pttBtn, false); - } -}); - -function applyFreqFromInput() { - const parsedRaw = parseFreqInput(freqEl.value, jogUnit); - const parsed = alignFreqToRigStep(parsedRaw); - if (parsed === null) { - showHint("Freq missing", 1500); - return; - } - if (!freqAllowed(parsed)) { - showUnsupportedFreqPopup(parsed); - return; - } - freqDirty = false; - // setRigFrequency is fire-and-forget; visual update is instant. - setRigFrequency(parsed); -} - -async function applyCenterFreqFromInput() { - if (!centerFreqEl) return; - const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit); - const parsed = alignFreqToRigStep(parsedRaw); - if (parsed === null) { - showHint("Central freq missing", 1500); - return; - } - if (!freqAllowed(parsed)) { - showUnsupportedFreqPopup(parsed); - return; - } - centerFreqDirty = false; - setControlPending(centerFreqEl, true); - showHint("Setting central frequency…"); - try { - await postPath(`/set_center_freq?hz=${parsed}`); - showHint("Central freq set", 1500); - } catch (err) { - showHint("Set central freq failed", 2000); - console.error(err); - } finally { - setControlPending(centerFreqEl, false); - } -} - -freqEl.addEventListener("keydown", (e) => { - freqDirty = true; - if (e.key === "Enter") { - e.preventDefault(); - applyFreqFromInput(); - } else if (e.key === "Escape") { - freqDirty = false; - refreshFreqDisplay(); - freqEl.blur(); - } -}); -freqEl.addEventListener("blur", () => { - if (freqDirty) { - freqDirty = false; - refreshFreqDisplay(); - } -}); -if (centerFreqEl) { - centerFreqEl.addEventListener("keydown", (e) => { - centerFreqDirty = true; - if (e.key === "Enter") { - e.preventDefault(); - applyCenterFreqFromInput(); - } else if (e.key === "Escape") { - centerFreqDirty = false; - refreshCenterFreqDisplay(); - centerFreqEl.blur(); - } - }); - centerFreqEl.addEventListener("blur", () => { - if (centerFreqDirty) { - centerFreqDirty = false; - refreshCenterFreqDisplay(); - } - }); - centerFreqEl.addEventListener("wheel", (e) => { - e.preventDefault(); - const direction = e.deltaY < 0 ? 1 : -1; - jogFreq(direction); - }, { passive: false }); -} -freqEl.addEventListener("wheel", (e) => { - e.preventDefault(); - const direction = e.deltaY < 0 ? 1 : -1; - jogFreq(direction); -}, { passive: false }); - -// --- Jog wheel --- -const jogWheel = document.getElementById("jog-wheel"); -const jogIndicator = document.getElementById("jog-indicator"); -const jogDownBtn = document.getElementById("jog-down"); -const jogUpBtn = document.getElementById("jog-up"); -const jogStepEl = document.getElementById("jog-step"); -const jogMultEl = document.getElementById("jog-mult"); -const VALID_JOG_DIVISORS = new Set([1, 10]); - -function applyJogStep() { - jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); - saveSetting("jogUnit", jogUnit); - saveSetting("jogMult", jogMult); - saveSetting("jogStep", jogStep); - refreshFreqDisplay(); - refreshCenterFreqDisplay(); -} - -function setJogDivisor(divisor) { - const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1; - jogMult = next; - if (jogMultEl) { - jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => { - b.classList.toggle("active", parseInt(b.dataset.mult, 10) === jogMult); - }); - } - applyJogStep(); -} - -function jogFreq(direction) { - if (lastLocked) { showHint("Locked", 1500); return; } - if (lastFreqHz === null) return; - const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep); - if (!freqAllowed(newHz)) { - showUnsupportedFreqPopup(newHz); - return; - } - jogAngle = (jogAngle + direction * 15) % 360; - jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`; - // setRigFrequency is fire-and-forget; visual update is instant. - setRigFrequency(newHz); -} - -jogDownBtn.addEventListener("click", () => jogFreq(-1)); -jogUpBtn.addEventListener("click", () => jogFreq(1)); - -jogWheel.addEventListener("wheel", (e) => { - e.preventDefault(); - const direction = e.deltaY < 0 ? 1 : -1; - jogFreq(direction); -}, { passive: false }); - -// Touch drag on jog wheel -let jogTouchY = null; -jogWheel.addEventListener("touchstart", (e) => { - e.preventDefault(); - jogTouchY = e.touches[0].clientY; -}, { passive: false }); -jogWheel.addEventListener("touchmove", (e) => { - e.preventDefault(); - if (jogTouchY === null) return; - const dy = jogTouchY - e.touches[0].clientY; - if (Math.abs(dy) > 12) { - jogFreq(dy > 0 ? 1 : -1); - jogTouchY = e.touches[0].clientY; - } -}, { passive: false }); -jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); - -// Mouse drag on jog wheel -let jogMouseY = null; -jogWheel.addEventListener("mousedown", (e) => { - e.preventDefault(); - jogMouseY = e.clientY; - jogWheel.style.cursor = "grabbing"; -}); -window.addEventListener("mousemove", (e) => { - if (jogMouseY === null) return; - const dy = jogMouseY - e.clientY; - if (Math.abs(dy) > 10) { - jogFreq(dy > 0 ? 1 : -1); - jogMouseY = e.clientY; - } -}); -window.addEventListener("mouseup", () => { - jogMouseY = null; - if (jogWheel) jogWheel.style.cursor = "grab"; -}); - -// Step unit selector -jogStepEl.addEventListener("click", (e) => { - const btn = e.target.closest("button[data-step]"); - if (!btn) return; - jogUnit = parseInt(btn.dataset.step, 10); - jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); - btn.classList.add("active"); - applyJogStep(); -}); - -// Step multiplier selector -if (jogMultEl) { - jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => { - const divisor = parseInt(btn.dataset.mult, 10); - if (!VALID_JOG_DIVISORS.has(divisor)) { - btn.remove(); - } - }); - jogMultEl.addEventListener("click", (e) => { - const btn = e.target.closest("button[data-mult]"); - if (!btn) return; - setJogDivisor(parseInt(btn.dataset.mult, 10)); - }); -} - -// Restore active jog step buttons from saved settings -{ - const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); - const activeUnit = - unitBtns.find((b) => parseInt(b.dataset.step, 10) === jogUnit) || - unitBtns.find((b) => parseInt(b.dataset.step, 10) === 1000) || - unitBtns[0]; - if (activeUnit) { - jogUnit = parseInt(activeUnit.dataset.step, 10); - unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); - } - if (jogMultEl) { - const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); - const activeMult = - multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || - multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) || - multBtns[0]; - if (activeMult) { - jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult, 10)) - ? parseInt(activeMult.dataset.mult, 10) - : 1; - multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); - } else { - jogMult = 1; - } - } - jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); -} - -async function applyModeFromPicker() { - const mode = modeEl.value || ""; - if (!mode) { - showHint("Mode missing", 1500); - return; - } - updateWfmControls(); - setControlPending(modeEl, true); - showHint("Setting mode…"); - try { - if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { - showHint("Channel mode set", 1500); - return; - } - await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); - showHint("Mode set", 1500); - if (mode.toUpperCase() === "WFM") { - setJogDivisor(10); - } - // Apply sensible default bandwidth for the new mode and push to server. - await applyBwDefaultForMode(mode, true); - } catch (err) { - showHint("Set mode failed", 2000); - console.error(err); - } finally { - setControlPending(modeEl, false); - } -} - -modeEl.addEventListener("change", applyModeFromPicker); - -txLimitInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - txLimitBtn.click(); - } -}); - -txLimitBtn.addEventListener("click", async () => { - const limit = txLimitInput.value; - if (limit === "" || limit === "--") { - showHint("Limit missing", 1500); - return; - } - setControlPending(txLimitBtn, true); - showHint("Setting TX limit…"); - try { - await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); - showHint("TX limit set", 1500); - } catch (err) { - showHint("TX limit failed", 2000); - console.error(err); - } finally { - setControlPending(txLimitBtn, false); - } -}); - -lockBtn.addEventListener("click", async () => { - setControlPending(lockBtn, true); - showHint("Toggling lock…"); - try { - const nextLock = !lastLocked; - await postPath(nextLock ? "/lock" : "/unlock"); - showHint("Lock toggled", 1500); - } catch (err) { - showHint("Lock toggle failed", 2000); - console.error(err); - } finally { - setControlPending(lockBtn, false); - } -}); - -// --- Filter controls --- - -// Per-mode defaults: [default bandwidth Hz, min Hz, max Hz, step Hz] -const MODE_BW_DEFAULTS = { - CW: [500, 100, 9_000, 50], - CWR: [500, 100, 9_000, 50], - LSB: [2_700, 300, 6_000, 100], - USB: [2_700, 300, 6_000, 100], - AM: [9_000, 500, 20_000, 500], - SAM: [9_000, 500, 20_000, 500], - FM: [12_500, 2_500, 25_000, 500], - AIS: [25_000, 12_500, 50_000, 500], - VDES: [100_000, 25_000, 200_000, 1_000], - WFM: [180_000, 60_000,300_000,5_000], - DIG: [3_000, 300, 6_000, 100], - PKT: [25_000, 300, 50_000, 500], -}; -const MODE_BW_FALLBACK = [3_000, 300, 500_000, 100]; - -function mwDefaultsForMode(mode) { - return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK; -} - -function formatBwLabel(hz) { - if (hz >= 1000) return (hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1) + " kHz"; - return hz + " Hz"; -} - -// Current receive bandwidth (Hz) — updated by server sync and BW drag. -let currentBandwidthHz = 3_000; -window.currentBandwidthHz = currentBandwidthHz; -const spectrumBwInput = document.getElementById("spectrum-bw-input"); -const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn"); -const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn"); -const spectrumBwSweetBtn = document.getElementById("spectrum-bw-sweet-btn"); - -function formatBandwidthInputKhz(hz) { - const khz = hz / 1000; - if (Math.abs(Math.round(khz) - khz) < 0.0001) return String(Math.round(khz)); - if (Math.abs(Math.round(khz * 10) - khz * 10) < 0.0001) return khz.toFixed(1); - return khz.toFixed(2); -} - -function syncBandwidthInput(hz) { - if (!spectrumBwInput || !Number.isFinite(hz) || hz <= 0) return; - const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); - spectrumBwInput.min = String(minBw / 1000); - spectrumBwInput.max = String(maxBw / 1000); - spectrumBwInput.step = String(stepBw / 1000); - spectrumBwInput.value = formatBandwidthInputKhz(hz); -} - -// Apply mode-specific BW default and optionally push to server. -async function applyBwDefaultForMode(mode, sendToServer) { - const [def] = mwDefaultsForMode(mode); - currentBandwidthHz = def; - window.currentBandwidthHz = currentBandwidthHz; - syncBandwidthInput(def); - positionFastOverlay(lastFreqHz, def); - if (lastSpectrumData) { - scheduleSpectrumDraw(); - } - if (sendToServer) { - try { await postPath(`/set_bandwidth?hz=${def}`); } - catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); } - } -} - -async function applyBandwidthFromInput() { - if (!spectrumBwInput) return; - const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); - const nextKhz = Number(spectrumBwInput.value); - const next = Math.round(nextKhz * 1000); - if (!Number.isFinite(next) || next <= 0) { - syncBandwidthInput(currentBandwidthHz); - return; - } - const clamped = Math.max(minBw, Math.min(maxBw, next)); - currentBandwidthHz = clamped; - window.currentBandwidthHz = currentBandwidthHz; - syncBandwidthInput(clamped); - positionFastOverlay(lastFreqHz, clamped); - if (lastSpectrumData) { - scheduleSpectrumDraw(); - } - try { - if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(clamped)) return; - await postPath(`/set_bandwidth?hz=${clamped}`); - if (Number.isFinite(lastFreqHz)) { - await ensureTunedBandwidthCoverage(lastFreqHz); - } - } catch (error) { - window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); - } -} - -function estimateOccupiedBandwidth(data, centerHz, interference = {}) { - if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { - return null; - } - - const bins = data.bins; - const maxIdx = bins.length - 1; - const hzPerBin = data.sample_rate / maxIdx; - const fullLoHz = data.center_hz - data.sample_rate / 2; - const centerIdx = Math.max( - 1, - Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)), - ); - const mode = (modeEl ? modeEl.value : "USB").toUpperCase(); - const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); - const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" - ? 1 - : mode === "LSB" || mode === "CWR" ? -1 : 0; - const isWfm = mode === "WFM"; - - // Reduce single-bin peaks and holes before finding occupied-channel edges. - // WFM needs a wider smoothing window because its energy is noise-like and - // spread across the entire channel rather than concentrated at a carrier. - const smoothRadius = isWfm ? 3 : 1; - const smoothed = bins.map((_, i) => { - let sum = 0; - let count = 0; - for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) { - sum += bins[j]; - count += 1; - } - return sum / count; - }); - const sorted = [...bins].sort((a, b) => a - b); - const noise = sorted[Math.floor(sorted.length * 0.2)]; - const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); - const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; - const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins)); - const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins)); - let peak = -Infinity; - for (let i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]); - const snr = peak - noise; - if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw; - - // A threshold relative to the noise floor finds occupied bandwidth much - // more reliably than one relative to the peak. The latter fails for WFM, - // whose multiplex spectrum has peaks, notches, and no narrow centre carrier. - const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); - const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin)); - - function occupiedExtent(direction, limitBins) { - let lastOccupied = centerIdx; - let gap = 0; - for (let n = 0; n <= limitBins; n++) { - const i = centerIdx + direction * n; - if (i <= 0 || i >= maxIdx) break; - if (smoothed[i] >= threshold) { - lastOccupied = i; - gap = 0; - } else if (++gap > allowedGap) { - break; - } - } - return Math.abs(lastOccupied - centerIdx) * hzPerBin; - } - - let rawBw; - if (oneSided !== 0) { - rawBw = occupiedExtent(oneSided, maxSpanBins); - } else { - const leftHz = occupiedExtent(-1, searchHalfBins); - const rightHz = occupiedExtent(1, searchHalfBins); - // A symmetric RF filter must contain the larger of the two sidebands. - rawBw = 2 * Math.max(leftHz, rightHz); - } - - // Add a transition-band margin. Weak WFM deliberately falls back to the - // 60 kHz mode floor above: a narrower filter trades stereo/RDS content for - // a useful improvement in intelligibility when the signal is very poor. - rawBw *= isWfm ? 1.08 : 1.12; - if (isWfm) { - const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; - const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; - // Adjacent-channel energy is outside the wanted modulation, so ACI can - // safely drive the cap all the way from the 300 kHz ceiling to 60 kHz. - const aciCap = maxBw - (maxBw - minBw) * aci; - // CCI overlaps the wanted station and cannot be removed by an RF filter. - // Only distrust the widest edge estimates, retaining at least 65% of the - // useful range between the weak-signal floor and nominal WFM bandwidth. - const cciFloor = minBw + (defaultBw - minBw) * 0.65; - const cciCap = maxBw - (maxBw - cciFloor) * cci; - rawBw = Math.min(rawBw, aciCap, cciCap); - } - const clamped = Math.max(minBw, Math.min(maxBw, rawBw)); - return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); -} - -async function applyAutoBandwidth() { - if (!lastSpectrumData || lastFreqHz == null) return; - // WFM interference telemetry belongs to the primary DSP channel. Do not - // apply it to a virtual channel, where it would describe the wrong signal. - const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); - const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; - const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); - if (!Number.isFinite(estimated) || estimated <= 0) { - syncBandwidthInput(currentBandwidthHz); - return; - } - currentBandwidthHz = estimated; - window.currentBandwidthHz = currentBandwidthHz; - syncBandwidthInput(estimated); - positionFastOverlay(lastFreqHz, estimated); - if (lastSpectrumData) { - scheduleSpectrumDraw(); - } - const mode = (modeEl?.value || "").toUpperCase(); - let reason = "measured occupied spectrum"; - if (mode === "WFM") { - if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; - else if (estimated === 60_000) reason = "weak-signal noise rejection"; - else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`; - else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`; - } - window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5000 }); - try { - if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; - await postPath(`/set_bandwidth?hz=${estimated}`); - if (Number.isFinite(lastFreqHz)) { - await ensureTunedBandwidthCoverage(lastFreqHz); - } - } catch (error) { - window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } }); - } -} - -if (spectrumBwInput) { - spectrumBwInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - applyBandwidthFromInput(); - } - }); -} -if (spectrumBwSetBtn) { - spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); }); -} -if (spectrumBwAutoBtn) { - spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); }); -} -if (spectrumBwSweetBtn) { - spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => {}); }); -} - -// --- Tab navigation --- -let _activeTab = "main"; // tracked for render-path tab awareness -const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"]; -const TAB_PATHS = { - main: "/", - bookmarks: "/bookmarks", - "digital-modes": "/digital-modes", - map: "/map", - recorder: "/recorder", - settings: "/settings", - about: "/about", -}; - -function normalizeTabPath(pathname) { - const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/"; - if (raw === "/") return "/"; - return raw.replace(/\/+$/, "") || "/"; -} - -function tabFromPath(pathname = window.location.pathname) { - const normalized = normalizeTabPath(pathname); - for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) { - if (normalized === tabPath) return tabName; - } - return "main"; -} - -function updateTabHistory(name, replaceHistory = false) { - const targetPath = TAB_PATHS[name] || "/"; - if (normalizeTabPath(window.location.pathname) === targetPath) return; - const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; - const method = replaceHistory ? "replaceState" : "pushState"; - window.history[method]({}, "", nextUrl); -} - -// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js -// (window.trx.modules.map) if they haven't loaded yet. -let _mapInitTimer = null; -function _initMapWhenReady() { - const loadingEl = document.getElementById("map-loading"); - if (window.trx.modules.map && typeof L !== "undefined") { - if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; } - if (loadingEl) loadingEl.classList.add("is-hidden"); - window.trx.modules.map.initAprsMap(); - window.trx.modules.map.sizeAprsMapToViewport(); - // The map panel was just made visible (display:none → ""); the browser - // may not have laid it out yet, so getBoundingClientRect() can return - // stale/zero dimensions. Double-rAF ensures a full layout pass has - // completed before we re-measure and tell Leaflet about its real size. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - window.trx.modules.map.sizeAprsMapToViewport(); - if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize(); - }); - }); - return; - } - // Not ready yet — show overlay and poll until both are available. - if (loadingEl) loadingEl.classList.remove("is-hidden"); - if (!_mapInitTimer) { - _mapInitTimer = setInterval(() => { - if (_activeTab !== "map") { clearInterval(_mapInitTimer); _mapInitTimer = null; return; } - _initMapWhenReady(); - }, 100); - } -} - -function navigateToTab(name, options = {}) { - window.trxUi?.closeMobileOverlays?.(); - const { updateHistory = true, replaceHistory = false } = options; - if (authEnabled && !authRole && name !== "main") { - showAuthGate(false); - return; - } - const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); - if (!btn) return; - _activeTab = name; - document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); - btn.classList.add("active"); - window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const panel = document.getElementById(`tab-${name}`); - panel.style.display = ""; - // Clone deferred