+//
+// 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 content into the panel before any tab-specific init.
+ const tmpl = panel.querySelector("template");
+ if (tmpl) {
+ panel.appendChild(tmpl.content.cloneNode(true));
+ tmpl.remove();
+ // Wire sub-tab bars inside the freshly cloned content.
+ panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
+ // Re-run decoder visibility for about-tab elements now in the DOM.
+ if (decoderRegistry.length) hideUnsupportedDecoderTabs();
+ }
+ if (updateHistory) {
+ updateTabHistory(name, replaceHistory);
+ }
+ scheduleSpectrumLayout();
+ if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name);
+ if (name === "map") {
+ _initMapWhenReady();
+ }
+ if (name === "statistics") {
+ window.trx.modules.map?.scheduleStatsRender();
+ }
+ if (name === "recorder") {
+ refreshRecorderStatus();
+ }
+}
+window.navigateToTab = navigateToTab;
+
+document.querySelector(".tab-bar").addEventListener("click", (e) => {
+ const btn = e.target.closest(".tab[data-tab]");
+ if (!btn) return;
+ navigateToTab(btn.dataset.tab);
+});
+
+window.addEventListener("popstate", () => {
+ navigateToTab(tabFromPath(), { updateHistory: false });
+});
+
+// Swipe left/right on the main content area to switch tabs (mobile).
+(function () {
+ let tx = 0, ty = 0;
+ const THRESHOLD = 60; // px horizontal movement required
+ const ANGLE_LIMIT = 1.6; // |dx/dy| ratio β suppress on near-vertical drags
+
+ // Elements where horizontal drag has its own meaning; exclude from swipe.
+ const NO_SWIPE_SELECTORS = [
+ "#jog-wheel", "#spectrum-canvas", "#overview-canvas",
+ "#aprs-map", ".controls-tray-scroll", ".sub-tab-bar",
+ "input[type=range]", "select", "input[type=text]",
+ "input[type=number]", "input[type=search]",
+ ];
+
+ function isExcluded(el) {
+ return NO_SWIPE_SELECTORS.some((sel) => el.closest(sel));
+ }
+
+ document.addEventListener("touchstart", (e) => {
+ if (e.touches.length !== 1) return;
+ if (isExcluded(e.target)) return;
+ tx = e.touches[0].clientX;
+ ty = e.touches[0].clientY;
+ }, { passive: true });
+
+ document.addEventListener("touchend", (e) => {
+ if (e.changedTouches.length !== 1 || tx === 0) return;
+ const dx = e.changedTouches[0].clientX - tx;
+ const dy = e.changedTouches[0].clientY - ty;
+ tx = 0;
+ if (Math.abs(dx) < THRESHOLD) return;
+ if (Math.abs(dy) > 0 && Math.abs(dx) / Math.abs(dy) < ANGLE_LIMIT) return;
+ const activeBtn = document.querySelector(".tab-bar .tab.active");
+ if (!activeBtn) return;
+ const cur = TAB_ORDER.indexOf(activeBtn.dataset.tab);
+ if (cur === -1) return;
+ const next = dx < 0 ? cur + 1 : cur - 1;
+ if (next >= 0 && next < TAB_ORDER.length) navigateToTab(TAB_ORDER[next]);
+ }, { passive: true });
+})();
+
+window.addEventListener("resize", () => { scheduleSpectrumLayout(); });
+
+// --- Auth startup sequence ---
+function getAvailableRigIds() {
+ return lastRigIds || [];
+}
+
+async function initializeApp() {
+ showAuthGate(false);
+ const authStatus = await checkAuthStatus();
+ authEnabled = !authStatus.auth_disabled;
+
+ if (!authEnabled) {
+ authRole = "control";
+ hideAuthGate();
+ updateAuthUI();
+ connect();
+ connectDecode();
+ initSettingsUI();
+ resizeHeaderSignalCanvas();
+ startHeaderSignalSampling();
+ return;
+ }
+
+ if (authStatus.authenticated) {
+ // User has valid session
+ authRole = authStatus.role;
+ hideAuthGate();
+ updateAuthUI();
+ applyAuthRestrictions();
+ connect();
+ connectDecode();
+ initSettingsUI();
+ resizeHeaderSignalCanvas();
+ startHeaderSignalSampling();
+ } else {
+ // No valid session - show auth gate
+ // Guest button is shown if guest mode is available (role granted without auth)
+ const allowGuest = authStatus.role === "rx";
+ showAuthGate(allowGuest);
+ }
+}
+
+function initSettingsUI() {
+ if (typeof initScheduler === "function") {
+ initScheduler(lastActiveRigId, authRole);
+ wireSchedulerEvents();
+ }
+ if (typeof initBackgroundDecode === "function") {
+ initBackgroundDecode(lastActiveRigId, authRole);
+ wireBackgroundDecodeEvents();
+ }
+}
+
+// Setup auth form
+document.getElementById("auth-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const passphrase = document.getElementById("auth-passphrase").value;
+ const btn = document.querySelector("#auth-form button[type=submit]");
+ btn.disabled = true;
+ btn.textContent = "Logging in...";
+
+ try {
+ const result = await authLogin(passphrase);
+ authRole = result.role;
+ document.getElementById("auth-passphrase").value = "";
+ hideAuthGate();
+ updateAuthUI();
+ applyAuthRestrictions();
+ connect();
+ connectDecode();
+ initSettingsUI();
+ resizeHeaderSignalCanvas();
+ startHeaderSignalSampling();
+ } catch (err) {
+ showAuthError("Invalid passphrase");
+ console.error("Login error:", err);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = "Login";
+ }
+});
+
+// Setup guest button
+const guestBtn = document.getElementById("auth-guest-btn");
+if (guestBtn) {
+ guestBtn.addEventListener("click", async () => {
+ authRole = "rx";
+ document.getElementById("auth-passphrase").value = "";
+ hideAuthGate();
+ updateAuthUI();
+ applyAuthRestrictions();
+ connect();
+ connectDecode();
+ initSettingsUI();
+ resizeHeaderSignalCanvas();
+ startHeaderSignalSampling();
+ });
+}
+
+// Setup header auth button (Login/Logout)
+const headerAuthBtn = document.getElementById("header-auth-btn");
+if (headerAuthBtn) {
+ headerAuthBtn.addEventListener("click", async () => {
+ if (authRole) {
+ // Logged in - show logout confirmation
+ if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
+ await authLogout();
+ }
+ } else {
+ // Not logged in - show auth gate
+ showAuthGate(false);
+ }
+ });
+}
+
+// ββ Shared namespace for lazy-loaded modules ββββββββββββββββββββββββββββββββ
+// Modules (map-core.js, screenshot.js) access core state and utilities via
+// window.trx. Modules register their own APIs as sub-namespaces
+// (e.g. window.trx.modules.map, window.trx.modules.screenshot).
+const trxState = Object.create(null);
+const trxModules = Object.create(null);
+// -- State getters (backed by core-scoped variables) --
+Object.defineProperties(trxState, {
+ serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } },
+ serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } },
+ lastFreqHz: { get() { return lastFreqHz; } },
+ lastActiveRigId: { get() { return lastActiveRigId; } },
+ lastRigIds: { get() { return lastRigIds; } },
+ lastRigDisplayNames: { get() { return lastRigDisplayNames; } },
+ initialMapZoom: { get() { return initialMapZoom; } },
+ decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } },
+ authRole: { get() { return authRole; } },
+ decoderRegistry: { get() { return decoderRegistry; } },
+ sseSessionId: { get() { return sseSessionId; } },
+ primaryRds: { get() { return primaryRds; } },
+ vchanRdsById: { get() { return vchanRdsById; } },
+ vchanSignalDbById: { get() { return vchanSignalDbById; } },
+ lastCityLabel: { get() { return lastCityLabel; }, set(v) { lastCityLabel = v; } },
+ serverVersion: { get() { return serverVersion; } },
+ serverBuildDate: { get() { return serverBuildDate; } },
+ serverCallsign: { get() { return serverCallsign; } },
+ ownerCallsign: { get() { return ownerCallsign; } },
+ ownerWebsiteUrl: { get() { return ownerWebsiteUrl; } },
+ ownerWebsiteName: { get() { return ownerWebsiteName; } },
+ aisVesselUrlBase: { get() { return aisVesselUrlBase; } },
+ serverRigs: { get() { return serverRigs; } },
+ serverActiveRigId: { get() { return serverActiveRigId; } },
+ lastModeName: { get() { return lastModeName; } },
+ lastSpectrumData: { get() { return lastSpectrumData; } },
+ lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } },
+ currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v) { currentBandwidthHz = v; window.currentBandwidthHz = v; } },
+ spectrumFloor: { get() { return spectrumFloor; } },
+ spectrumRange: { get() { return spectrumRange; } },
+ spectrumCanvas: { get() { return spectrumCanvas; } },
+ overviewCanvas: { get() { return overviewCanvas; } },
+ overviewGl: { get() { return overviewGl; } },
+ spectrumGl: { get() { return spectrumGl; } },
+ signalOverlayGl: { get() { return signalOverlayGl; } },
+});
+// -- Shared utility functions --
+const trxCore = Object.freeze({
+ saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
+ formatWavelength, formatBwLabel, formatUptime, formatSigStrength, formatSignal,
+ postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
+ latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm,
+ formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText,
+ nearestBookmarkForHz, currentDecodeHistoryRetentionMs,
+ currentTheme, canvasPalette, currentStyle,
+ cssColorToRgba, rgbaWithAlpha, isBinsArray, estimateNoiseFloorDb,
+ spectrumVisibleRange, drawSpectrum,
+ bandForHz: function(hz) { return trxModules.map?.bandForHz?.(hz); },
+ markDecodeMapSyncPending,
+ decodeHistoryMapRenderingDeferred,
+ updateDocumentTitle,
+ activeChannelRds,
+});
+Object.defineProperties(trxState, {
+ decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } },
+ decodeMapSyncPending: { get() { return decodeMapSyncPending; } },
+ _activeTab: { get() { return _activeTab; } },
+ locationSubtitle: { get() { return locationSubtitle; } },
+});
+window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
+
+// Load plugin scripts now that window.trx is populated. Dynamic scripts are
+// async so they must not be created before the namespace they depend on exists.
+if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins();
+
+// Start the app
+initializeApp();
+window.addEventListener("resize", resizeHeaderSignalCanvas);
+
+
+// ββ Map module (extracted to map-core.js, lazy-loaded) ββββββββββββββββββββββ
+// The map, statistics, and geolocation code (~3,450 lines) has been moved to
+// map-core.js and is loaded on demand when the Map tab is first activated.
+// Core communicates with the map module via window.trx.modules.map.* namespace.
+
+// ββ Geo utilities (shared with map-core.js via window.trx) βββββββββββββββββ
+function haversineKm(lat1, lon1, lat2, lon2) {
+ const R = 6371;
+ const dLat = (lat2 - lat1) * Math.PI / 180;
+ const dLon = (lon2 - lon1) * Math.PI / 180;
+ const a = Math.sin(dLat / 2) ** 2
+ + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+function locatorToLatLon(locator) {
+ const raw = String(locator || "").trim().toUpperCase();
+ if (!/^[A-R]{2}\d{2}([A-X]{2})?$/.test(raw)) return null;
+ let lon = -180;
+ let lat = -90;
+ lon += (raw.charCodeAt(0) - 65) * 20;
+ lat += (raw.charCodeAt(1) - 65) * 10;
+ lon += Number(raw.slice(2, 3)) * 2;
+ lat += Number(raw.slice(3, 4));
+ if (raw.length >= 6) {
+ lon += (raw.charCodeAt(4) - 65) * (5 / 60);
+ lat += (raw.charCodeAt(5) - 65) * (2.5 / 60);
+ lon += 2.5 / 60;
+ lat += 1.25 / 60;
+ } else {
+ lon += 1;
+ lat += 0.5;
+ }
+ return { lat, lon };
+}
+
+function formatDistanceKm(distKm) {
+ if (!Number.isFinite(distKm)) return null;
+ return distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`;
+}
+
+function bookmarkDistanceText(bm) {
+ if (!bm || serverLat == null || serverLon == null) return null;
+ const latLon = locatorToLatLon(bm.locator);
+ if (!latLon) return null;
+ return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon));
+}
+
+function buildBookmarkTooltipText(bm) {
+ if (!bm) return null;
+ const parts = [];
+ if (bm.name) parts.push(String(bm.name));
+ if (typeof bmFmtFreq === "function") parts.push(bmFmtFreq(bm.freq_hz));
+ if (bm.mode) parts.push(String(bm.mode));
+ if (bm.locator) parts.push(String(bm.locator));
+ const distance = bookmarkDistanceText(bm);
+ if (distance) parts.push(distance);
+ let text = parts.join(" Β· ");
+ if (bm.comment) {
+ text += (text ? "\n" : "") + String(bm.comment);
+ }
+ return text;
+}
+
+function nearestBookmarkForHz(hz, widthPx, range) {
+ const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : null;
+ if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) {
+ return null;
+ }
+ const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10);
+ let best = null;
+ let bestDelta = Number.POSITIVE_INFINITY;
+ for (const bm of ref) {
+ const delta = Math.abs(Number(bm.freq_hz) - hz);
+ if (delta <= maxDeltaHz && delta < bestDelta) {
+ best = bm;
+ bestDelta = delta;
+ }
+ }
+ return best;
+}
+
+function formatTimeAgo(tsMs) {
+ if (!tsMs) return null;
+ const secs = Math.round((Date.now() - tsMs) / 1000);
+ if (secs < 60) return `${secs}s ago`;
+ const mins = Math.round(secs / 60);
+ if (mins < 60) return `${mins} min ago`;
+ const hrs = Math.floor(mins / 60);
+ const remMins = mins % 60;
+ return remMins > 0 ? `${hrs}h ${remMins}min ago` : `${hrs}h ago`;
+}
+
+
+function latLonToMaidenhead(lat, lon) {
+ const adjustedLon = lon + 180;
+ const adjustedLat = lat + 90;
+ const A = "A".charCodeAt(0);
+ const a = "a".charCodeAt(0);
+ const field1 = String.fromCharCode(A + Math.floor(adjustedLon / 20));
+ const field2 = String.fromCharCode(A + Math.floor(adjustedLat / 10));
+ const square1 = Math.floor((adjustedLon % 20) / 2);
+ const square2 = Math.floor(adjustedLat % 10);
+ const sub1 = String.fromCharCode(a + Math.floor((adjustedLon % 2) * 12));
+ const sub2 = String.fromCharCode(a + Math.floor((adjustedLat % 1) * 24));
+ return `${field1}${field2}${square1}${square2}${sub1}${sub2}`;
+}
+
+
+// --- Sub-tab navigation ---
+function _wireSubTabBar(bar) {
+ if (bar._subtabWired) return;
+ bar._subtabWired = true;
+ window.trxUi?.prepareTabList(bar, "secondary");
+ bar.addEventListener("click", (e) => {
+ const btn = e.target.closest(".sub-tab[data-subtab]");
+ if (!btn) return;
+ bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
+ btn.classList.add("active");
+ window.trxUi?.syncSelectedTab(bar, btn);
+ const decoderPicker = document.getElementById("decoder-tab-select");
+ if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab;
+ const parent = bar.parentElement;
+ parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
+ const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
+ if (nextPanel) nextPanel.style.display = "";
+ if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) {
+ requestAnimationFrame(() => {
+ if (window.refreshCwTonePicker) window.refreshCwTonePicker();
+ });
+ }
+ // Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
+ if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
+ window.clearSatPredictionDom();
+ }
+ });
+}
+document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
+
+window.addEventListener("resize", () => {
+ const mapTab = document.getElementById("tab-map");
+ if (!mapTab || mapTab.style.display === "none") return;
+ window.trx.modules.map?.sizeAprsMapToViewport();
+});
+
+// --- Signal measurement ---
+const sigMeasureBtn = document.getElementById("sig-measure-btn");
+const sigClearBtn = document.getElementById("sig-clear-btn");
+const sigResult = document.getElementById("sig-result");
+
+function resetSignalMeasurementState() {
+ sigMeasureLastTickMs = 0;
+ sigMeasureAccumMs = 0;
+ sigMeasureWeighted = 0;
+ sigMeasurePeak = null;
+}
+
+function updateSignalMeasurement(nowMs) {
+ if (!sigMeasuring) return;
+ if (sigMeasureLastTickMs === 0) {
+ sigMeasureLastTickMs = nowMs;
+ return;
+ }
+ const dt = Math.max(0, nowMs - sigMeasureLastTickMs);
+ sigMeasureLastTickMs = nowMs;
+ if (!Number.isFinite(sigLastSUnits)) return;
+
+ sigMeasureAccumMs += dt;
+ sigMeasureWeighted += sigLastSUnits * dt;
+ if (sigMeasurePeak === null || sigLastSUnits > sigMeasurePeak) {
+ sigMeasurePeak = sigLastSUnits;
+ }
+}
+
+function stopSignalMeasurement() {
+ if (sigMeasureTimer) {
+ clearInterval(sigMeasureTimer);
+ sigMeasureTimer = null;
+ }
+ sigMeasuring = false;
+ sigMeasureBtn.textContent = "Measure";
+ sigMeasureBtn.style.borderColor = "";
+ sigMeasureBtn.style.color = "";
+}
+
+sigMeasureBtn.addEventListener("click", () => {
+ if (!sigMeasuring) {
+ resetSignalMeasurementState();
+ sigMeasuring = true;
+ sigMeasureBtn.textContent = "Stop (0.0s)";
+ sigMeasureBtn.style.borderColor = "#00d17f";
+ sigMeasureBtn.style.color = "#00d17f";
+ sigMeasureTimer = setInterval(() => {
+ const now = Date.now();
+ updateSignalMeasurement(now);
+ sigMeasureBtn.textContent = `Stop (${(sigMeasureAccumMs / 1000).toFixed(1)}s)`;
+ }, 200);
+ } else {
+ updateSignalMeasurement(Date.now());
+ stopSignalMeasurement();
+ if (sigMeasureAccumMs > 0) {
+ const avg = sigMeasureWeighted / sigMeasureAccumMs;
+ const peak = sigMeasurePeak;
+ sigResult.innerHTML = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1000).toFixed(1)}s)`;
+ }
+ }
+});
+
+sigClearBtn.addEventListener("click", () => {
+ stopSignalMeasurement();
+ resetSignalMeasurementState();
+ sigResult.textContent = "";
+});
+
+// --- Audio streaming ---
+const rxAudioBtn = document.getElementById("rx-audio-btn");
+const txAudioBtn = document.getElementById("tx-audio-btn");
+const RX_AUDIO_LABEL = "Play Audio";
+const TX_AUDIO_LABEL = "Transmit Audio";
+const audioStatus = document.getElementById("audio-status");
+const audioLevelFill = document.getElementById("audio-level-fill");
+const audioRow = document.getElementById("audio-row");
+const wfmControlsCol = document.getElementById("wfm-controls-col");
+const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
+const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
+const wfmDenoiseEl = document.getElementById("wfm-denoise");
+const sdrSettingsRowEl = document.getElementById("sdr-settings-row");
+const sdrGainControlsEl = document.getElementById("sdr-gain-controls");
+const sdrGainEl = document.getElementById("sdr-gain-db");
+const sdrGainSetBtn = document.getElementById("sdr-gain-set");
+const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
+const sdrLnaGainEl = document.getElementById("sdr-lna-gain-db");
+const sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set");
+const sdrAgcEl = document.getElementById("sdr-agc-enabled");
+const wfmStFlagEl = document.getElementById("wfm-st-flag");
+const wfmCciFillEl = document.getElementById("wfm-cci-fill");
+const wfmCciValEl = document.getElementById("wfm-cci-val");
+const wfmAciFillEl = document.getElementById("wfm-aci-fill");
+const wfmAciValEl = document.getElementById("wfm-aci-val");
+const samControlsCol = document.getElementById("sam-controls-col");
+const samStereoWidthEl = document.getElementById("sam-stereo-width");
+const samCarrierSyncEl = document.getElementById("sam-carrier-sync");
+const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
+const sdrSquelchEl = document.getElementById("sdr-squelch");
+const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
+const SDR_SQUELCH_MIN_DB = -120;
+const SDR_SQUELCH_MAX_DB = -30;
+let syncFromServerSdrSquelch = false;
+const sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
+const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
+const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
+const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
+const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
+let sdrNbSupported = false;
+
+// Hide audio row if audio is not configured on the server
+fetch("/audio", { method: "GET" }).then((r) => {
+ if (r.status === 404) audioRow.style.display = "none";
+}).catch(() => {});
+
+let audioWs = null;
+let audioCtx = null;
+let rxActive = false;
+let txActive = false;
+let txStream = null;
+let txProcessor = null;
+let streamInfo = null;
+let opusDecoder = null;
+let wasmOpusDecoder = null;
+let txEncoder = null;
+let nextPlayTime = 0;
+let lastLevelUpdate = 0;
+let rxGainNode = null;
+let txGainNode = null;
+const rxVolSlider = document.getElementById("rx-vol");
+const txVolSlider = document.getElementById("tx-vol");
+const TX_TIMEOUT_SECS = 120;
+let txTimeoutTimer = null;
+let txTimeoutRemaining = 0;
+let txTimeoutInterval = null;
+const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
+const hasWasmOpus = typeof window["opus-decoder"] !== "undefined" && typeof window["opus-decoder"].OpusDecoder !== "undefined";
+const MAX_RX_BUFFER_SECS = 0.25;
+const TARGET_RX_BUFFER_SECS = 0.04;
+const MIN_RX_JITTER_SAMPLES = 512;
+
+if (rxAudioBtn) {
+ rxAudioBtn.textContent = RX_AUDIO_LABEL;
+ rxAudioBtn.setAttribute("aria-label", RX_AUDIO_LABEL);
+}
+if (txAudioBtn) {
+ txAudioBtn.textContent = TX_AUDIO_LABEL;
+ txAudioBtn.setAttribute("aria-label", TX_AUDIO_LABEL);
+}
+
+function setAudioLevel(levelPct) {
+ if (!audioLevelFill) return;
+ const clamped = Math.max(0, Math.min(100, Number.isFinite(levelPct) ? levelPct : 0));
+ audioLevelFill.style.width = `${clamped}%`;
+}
+
+// Create/resume the output context from a direct user gesture so Chromium
+// does not leave playback suspended until a later click.
+function ensureRxAudioContext(preferredSampleRate) {
+ if (!audioCtx) {
+ try {
+ audioCtx = Number.isFinite(preferredSampleRate) && preferredSampleRate > 0
+ ? new AudioContext({ sampleRate: preferredSampleRate })
+ : new AudioContext();
+ } catch (e) {
+ audioCtx = new AudioContext();
+ }
+ }
+ audioCtx.resume().catch(() => {});
+ if (!rxGainNode) {
+ rxGainNode = audioCtx.createGain();
+ rxGainNode.connect(audioCtx.destination);
+ }
+}
+
+function levelFromChannels(channels, frameCount) {
+ if (!Array.isArray(channels) || channels.length === 0 || !Number.isFinite(frameCount) || frameCount <= 0) {
+ return 0;
+ }
+ let sumSquares = 0;
+ let samples = 0;
+ for (const channel of channels) {
+ if (!channel) continue;
+ const limit = Math.min(frameCount, channel.length);
+ for (let i = 0; i < limit; i++) {
+ const sample = channel[i];
+ sumSquares += sample * sample;
+ }
+ samples += limit;
+ }
+ if (samples <= 0) return 0;
+ const rms = Math.sqrt(sumSquares / samples);
+ return Math.min(100, rms * 220);
+}
+
+function normalizeWfmDenoiseLevel(value) {
+ const next = String(value ?? "").toLowerCase();
+ if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
+ return "auto";
+}
+
+function clampSdrSquelchPercent(value) {
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(0, Math.min(100, Math.round(value)));
+}
+
+function sdrSquelchPercentToServer(percent) {
+ const pct = clampSdrSquelchPercent(percent);
+ if (pct <= 0) {
+ return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
+ }
+ const ratio = pct / 100;
+ const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
+ return { enabled: true, thresholdDb };
+}
+
+function sdrSquelchServerToPercent(enabled, thresholdDb) {
+ if (!enabled) return 0;
+ if (!Number.isFinite(thresholdDb)) return 0;
+ const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
+ return clampSdrSquelchPercent(ratio * 100);
+}
+
+function updateSdrSquelchPctLabel() {
+ if (!sdrSquelchEl || !sdrSquelchPctEl) return;
+ const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
+ sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
+}
+
+function updateSdrSquelchControlVisibility() {
+ if (!sdrSquelchWrapEl) return;
+ const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
+ sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
+}
+
+function syncSdrSquelchFromServer(enabled, thresholdDb) {
+ if (!sdrSquelchEl) return;
+ if (document.activeElement === sdrSquelchEl) return;
+ const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
+ syncFromServerSdrSquelch = true;
+ sdrSquelchEl.value = String(pct);
+ updateSdrSquelchPctLabel();
+ syncFromServerSdrSquelch = false;
+ saveSetting("sdrSquelchPct", pct);
+}
+
+function submitSdrSquelchPercent(percent) {
+ if (!sdrSquelchSupported) return;
+ const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
+ postPath(
+ `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`,
+ ).catch(() => {});
+}
+
+if (sdrSquelchEl) {
+ const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
+ sdrSquelchEl.value = String(savedPct);
+ updateSdrSquelchPctLabel();
+ sdrSquelchEl.addEventListener("input", () => {
+ const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
+ sdrSquelchEl.value = String(pct);
+ updateSdrSquelchPctLabel();
+ saveSetting("sdrSquelchPct", pct);
+ if (!syncFromServerSdrSquelch) {
+ submitSdrSquelchPercent(pct);
+ }
+ });
+}
+
+const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
+if (sdrSquelchAutoBtn) {
+ sdrSquelchAutoBtn.addEventListener("click", () => {
+ if (!sdrSquelchSupported) return;
+ let pct = 0; // default: Off
+ const data = lastSpectrumData || window.lastSpectrumData;
+ if (data && isBinsArray(data.bins) && data.bins.length > 0) {
+ const noiseDb = estimateNoiseFloorDb(data.bins);
+ if (noiseDb != null && Number.isFinite(noiseDb)) {
+ // Set threshold slightly above noise floor so squelch closes on noise
+ const thresholdDb = noiseDb + 6;
+ const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
+ pct = clampSdrSquelchPercent(
+ ((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
+ );
+ }
+ }
+ if (sdrSquelchEl) {
+ sdrSquelchEl.value = String(pct);
+ updateSdrSquelchPctLabel();
+ saveSetting("sdrSquelchPct", pct);
+ }
+ submitSdrSquelchPercent(pct);
+ });
+}
+
+if (wfmAudioModeEl) {
+ wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
+ wfmAudioModeEl.addEventListener("change", () => {
+ saveSetting("wfmAudioMode", wfmAudioModeEl.value);
+ const enabled = wfmAudioModeEl.value !== "mono";
+ postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => {});
+ });
+}
+if (wfmDenoiseEl) {
+ wfmDenoiseEl.value = normalizeWfmDenoiseLevel(loadSetting("wfmDenoise", "auto"));
+ wfmDenoiseEl.addEventListener("change", () => {
+ const level = normalizeWfmDenoiseLevel(wfmDenoiseEl.value);
+ wfmDenoiseEl.value = level;
+ saveSetting("wfmDenoise", level);
+ postPath(`/set_wfm_denoise?level=${encodeURIComponent(level)}`).catch(() => {});
+ });
+}
+if (wfmDeemphasisEl) {
+ wfmDeemphasisEl.addEventListener("change", () => {
+ postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
+ });
+}
+if (samStereoWidthEl) {
+ samStereoWidthEl.addEventListener("input", () => {
+ const width = Number(samStereoWidthEl.value) / 100;
+ postPath(`/set_sam_stereo_width?width=${width}`).catch(() => {});
+ });
+}
+if (samCarrierSyncEl) {
+ samCarrierSyncEl.addEventListener("change", () => {
+ const enabled = samCarrierSyncEl.value === "on";
+ postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => {});
+ });
+}
+function submitSdrGain() {
+ if (!sdrGainEl) return;
+ const parsed = Number.parseFloat(sdrGainEl.value);
+ if (!Number.isFinite(parsed) || parsed < 0) return;
+ postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {});
+}
+function updateSdrGainInputState() {
+ if (!sdrAgcEl) return;
+ const agcOn = sdrAgcEl.checked;
+ if (sdrGainEl) sdrGainEl.disabled = agcOn;
+ if (sdrGainSetBtn) sdrGainSetBtn.disabled = agcOn;
+ if (sdrLnaGainEl) sdrLnaGainEl.disabled = agcOn;
+ if (sdrLnaGainSetBtn) sdrLnaGainSetBtn.disabled = agcOn;
+}
+if (sdrAgcEl) {
+ sdrAgcEl.addEventListener("change", () => {
+ postPath(`/set_sdr_agc?enabled=${sdrAgcEl.checked ? "true" : "false"}`).catch(() => {});
+ updateSdrGainInputState();
+ });
+}
+if (sdrGainSetBtn) {
+ sdrGainSetBtn.addEventListener("click", submitSdrGain);
+}
+if (sdrGainEl) {
+ sdrGainEl.addEventListener("keydown", (ev) => {
+ if (ev.key === "Enter") {
+ ev.preventDefault();
+ submitSdrGain();
+ }
+ });
+}
+function submitSdrLnaGain() {
+ if (!sdrLnaGainEl) return;
+ const parsed = Number.parseFloat(sdrLnaGainEl.value);
+ if (!Number.isFinite(parsed) || parsed < 0) return;
+ postPath(`/set_sdr_lna_gain?db=${encodeURIComponent(parsed)}`).catch(() => {});
+}
+if (sdrLnaGainSetBtn) {
+ sdrLnaGainSetBtn.addEventListener("click", submitSdrLnaGain);
+}
+if (sdrLnaGainEl) {
+ sdrLnaGainEl.addEventListener("keydown", (ev) => {
+ if (ev.key === "Enter") {
+ ev.preventDefault();
+ submitSdrLnaGain();
+ }
+ });
+}
+function submitSdrNbState() {
+ if (!sdrNbSupported) return;
+ const enabled = sdrNbEnabledEl ? sdrNbEnabledEl.checked : false;
+ const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
+ if (!Number.isFinite(threshold) || threshold < 1 || threshold > 100) return;
+ postPath(
+ `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`,
+ ).catch(() => {});
+}
+if (sdrNbEnabledEl) {
+ sdrNbEnabledEl.addEventListener("change", () => {
+ submitSdrNbState();
+ });
+}
+function submitSdrNbThreshold() {
+ if (!sdrNbThresholdEl) return;
+ const parsed = Number.parseFloat(sdrNbThresholdEl.value);
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 100) return;
+ submitSdrNbState();
+}
+if (sdrNbThresholdSetBtn) {
+ sdrNbThresholdSetBtn.addEventListener("click", submitSdrNbThreshold);
+}
+if (sdrNbThresholdEl) {
+ sdrNbThresholdEl.addEventListener("keydown", (ev) => {
+ if (ev.key === "Enter") {
+ ev.preventDefault();
+ submitSdrNbThreshold();
+ }
+ });
+}
+function updateWfmControls() {
+ const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
+ if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
+ if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
+}
+
+// Show compatibility warning for non-Chromium browsers
+if (!hasWebCodecs) {
+ rxAudioBtn.disabled = true;
+ txAudioBtn.disabled = true;
+ audioStatus.textContent = "Audio requires Chrome/Edge";
+}
+
+function resetTxTimeout() {
+ txTimeoutRemaining = TX_TIMEOUT_SECS;
+ if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
+ txTimeoutTimer = setTimeout(() => {
+ console.warn("PTT safety timeout β stopping TX");
+ stopTxAudio();
+ }, TX_TIMEOUT_SECS * 1000);
+}
+
+function startTxTimeoutCountdown() {
+ txTimeoutRemaining = TX_TIMEOUT_SECS;
+ if (txTimeoutInterval) clearInterval(txTimeoutInterval);
+ txTimeoutInterval = setInterval(() => {
+ txTimeoutRemaining--;
+ if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) {
+ audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`;
+ }
+ }, 1000);
+}
+
+function clearTxTimeout() {
+ if (txTimeoutTimer) { clearTimeout(txTimeoutTimer); txTimeoutTimer = null; }
+ if (txTimeoutInterval) { clearInterval(txTimeoutInterval); txTimeoutInterval = null; }
+ txTimeoutRemaining = 0;
+}
+
+function resetRxDecoder() {
+ if (opusDecoder) {
+ try { opusDecoder.close(); } catch (e) {}
+ opusDecoder = null;
+ }
+ if (wasmOpusDecoder) {
+ try { wasmOpusDecoder.free(); } catch (e) {}
+ wasmOpusDecoder = null;
+ }
+ nextPlayTime = 0;
+}
+
+function configureRxStream(nextInfo) {
+ const nextSampleRate = (nextInfo && nextInfo.sample_rate) || 48000;
+ streamInfo = nextInfo;
+ updateWfmControls();
+ resetRxDecoder();
+ ensureRxAudioContext(nextSampleRate);
+ rxGainNode.gain.value = rxVolSlider.value / 100;
+ rxActive = true;
+ window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
+ setAudioLevel(0);
+ rxAudioBtn.style.borderColor = "#00d17f";
+ rxAudioBtn.style.color = "#00d17f";
+ audioStatus.textContent = "RX";
+ syncHeaderAudioBtn();
+}
+
+function extractAudioFrameChannels(frame) {
+ const channels = Math.max(1, frame.numberOfChannels || 1);
+ const frames = Math.max(0, frame.numberOfFrames || 0);
+ const format = String(frame.format || "").toLowerCase();
+ const isPlanar = format.includes("planar");
+
+ if (!isPlanar) {
+ const interleaved = new Float32Array(frames * channels);
+ frame.copyTo(interleaved, { planeIndex: 0 });
+ const out = Array.from({ length: channels }, () => new Float32Array(frames));
+ for (let i = 0; i < frames; i++) {
+ for (let ch = 0; ch < channels; ch++) {
+ out[ch][i] = interleaved[i * channels + ch];
+ }
+ }
+ return out;
+ }
+
+ const out = [];
+ for (let ch = 0; ch < channels; ch++) {
+ let len = frames;
+ try {
+ len = Math.max(frames, Math.floor(frame.allocationSize({ planeIndex: ch }) / 4));
+ } catch (e) {}
+ const plane = new Float32Array(len);
+ frame.copyTo(plane, { planeIndex: ch });
+ out.push(plane.length === frames ? plane : plane.subarray(0, frames));
+ }
+ return out;
+}
+
+// Optional channel_id injected by vchan.js when connecting to a virtual channel.
+let _audioChannelOverride = null;
+
+/** Schedule decoded PCM channels for playback via Web Audio API. */
+function scheduleDecodedAudio(channelData, frameCount, sampleRate) {
+ if (!audioCtx || !rxGainNode) return;
+ const levelNow = Date.now();
+ if (levelNow - lastLevelUpdate >= 50) {
+ setAudioLevel(levelFromChannels(channelData, frameCount));
+ lastLevelUpdate = levelNow;
+ }
+ const forceMono = channelData.length >= 2
+ && wfmAudioModeEl
+ && wfmAudioModeEl.value === "mono"
+ && modeEl
+ && (modeEl.value || "").toUpperCase() === "WFM";
+ const outChannels = forceMono ? 1 : channelData.length;
+ const ab = audioCtx.createBuffer(outChannels, frameCount, sampleRate);
+ if (forceMono) {
+ const monoData = new Float32Array(frameCount);
+ for (let ch = 0; ch < channelData.length; ch++) {
+ const plane = channelData[ch];
+ for (let i = 0; i < frameCount; i++) monoData[i] += plane[i];
+ }
+ const inv = 1 / Math.max(1, channelData.length);
+ for (let i = 0; i < frameCount; i++) monoData[i] *= inv;
+ ab.copyToChannel(monoData, 0);
+ } else {
+ for (let ch = 0; ch < channelData.length; ch++) {
+ ab.copyToChannel(channelData[ch], ch);
+ }
+ }
+ const src = audioCtx.createBufferSource();
+ src.buffer = ab;
+ src.connect(rxGainNode);
+ const now = audioCtx.currentTime;
+ const sr = (streamInfo && streamInfo.sample_rate) || sampleRate || 48000;
+ const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sr));
+ const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs);
+ if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) {
+ nextPlayTime = now + targetLeadSecs;
+ }
+ if (!nextPlayTime || nextPlayTime < now + minLeadSecs) {
+ nextPlayTime = now + targetLeadSecs;
+ }
+ const schedTime = nextPlayTime || (now + targetLeadSecs);
+ src.start(schedTime);
+ nextPlayTime = schedTime + ab.duration;
+}
+
+function startRxAudio() {
+ if (rxActive) { stopRxAudio(); return; }
+ if (!hasWebCodecs && !hasWasmOpus) {
+ audioStatus.textContent = "Audio not supported in this browser";
+ return;
+ }
+ ensureRxAudioContext((streamInfo && streamInfo.sample_rate) || 48000);
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
+ let audioPath;
+ if (_audioChannelOverride) {
+ const remoteParam = lastActiveRigId
+ ? `&remote=${encodeURIComponent(lastActiveRigId)}`
+ : "";
+ audioPath = `/audio?channel_id=${encodeURIComponent(_audioChannelOverride)}${remoteParam}`;
+ } else if (lastActiveRigId) {
+ audioPath = `/audio?remote=${encodeURIComponent(lastActiveRigId)}`;
+ } else {
+ audioPath = "/audio";
+ }
+ audioWs = new WebSocket(`${proto}//${location.host}${audioPath}`);
+ audioWs.binaryType = "arraybuffer";
+ audioStatus.textContent = "Connectingβ¦";
+
+ audioWs.onopen = () => {
+ audioStatus.textContent = "Connected";
+ };
+
+ audioWs.onmessage = (evt) => {
+ if (typeof evt.data === "string") {
+ // Stream info JSON
+ try {
+ configureRxStream(JSON.parse(evt.data));
+ } catch (e) {
+ console.error("Audio stream info parse error", e);
+ }
+ return;
+ }
+
+ // Binary Opus data
+ if (!audioCtx) return;
+ const data = new Uint8Array(evt.data);
+
+ // Lazily initialise a decoder: prefer WebCodecs, fall back to WASM.
+ if (!opusDecoder && !wasmOpusDecoder) {
+ const channels = (streamInfo && streamInfo.channels) || 1;
+ const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
+ // Try WebCodecs AudioDecoder first (Chrome/Edge).
+ if (hasWebCodecs) {
+ try {
+ opusDecoder = new AudioDecoder({
+ output: (frame) => {
+ const ch = extractAudioFrameChannels(frame);
+ scheduleDecodedAudio(ch, frame.numberOfFrames, frame.sampleRate);
+ frame.close();
+ },
+ error: (e) => { console.error("AudioDecoder error", e); }
+ });
+ opusDecoder.configure({ codec: "opus", sampleRate, numberOfChannels: channels });
+ } catch (e) {
+ console.warn("WebCodecs Opus not supported, trying WASM fallback", e);
+ opusDecoder = null;
+ }
+ }
+ // WASM fallback (Safari/Firefox).
+ if (!opusDecoder && hasWasmOpus) {
+ try {
+ const coupledStreamCount = channels >= 2 ? 1 : 0;
+ const mapping = channels >= 2 ? [0, 1] : [0];
+ wasmOpusDecoder = new window["opus-decoder"].OpusDecoder({
+ sampleRate,
+ channels,
+ streamCount: 1,
+ coupledStreamCount,
+ channelMappingTable: mapping,
+ preSkip: 0,
+ });
+ // .ready is a Promise that resolves when WASM is compiled.
+ wasmOpusDecoder.ready.then(() => {
+ audioStatus.textContent = "RX";
+ }).catch((e) => {
+ console.error("WASM Opus init failed", e);
+ wasmOpusDecoder = null;
+ });
+ } catch (e) {
+ console.warn("WASM Opus decoder init failed", e);
+ wasmOpusDecoder = null;
+ }
+ }
+ }
+
+ // Decode with whichever decoder is available.
+ if (opusDecoder) {
+ try {
+ opusDecoder.decode(new EncodedAudioChunk({
+ type: "key",
+ timestamp: performance.now() * 1000,
+ data: data,
+ }));
+ } catch (e) { /* ignore per-frame errors */ }
+ } else if (wasmOpusDecoder) {
+ try {
+ const result = wasmOpusDecoder.decodeFrame(data);
+ if (result && result.samplesDecoded > 0) {
+ scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate);
+ }
+ } catch (e) { /* ignore per-frame errors */ }
+ }
+ };
+
+ audioWs.onclose = () => {
+ // If TX was active when WS closed, release PTT
+ if (txActive) { stopTxAudio(); }
+ rxActive = false;
+ window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
+ streamInfo = null;
+ updateWfmControls();
+ rxAudioBtn.style.borderColor = "";
+ rxAudioBtn.style.color = "";
+ audioStatus.textContent = "Off";
+ setAudioLevel(0);
+ rxGainNode = null;
+ if (opusDecoder) {
+ try { opusDecoder.close(); } catch(e) {}
+ opusDecoder = null;
+ }
+ if (wasmOpusDecoder) {
+ try { wasmOpusDecoder.free(); } catch(e) {}
+ wasmOpusDecoder = null;
+ }
+ nextPlayTime = 0;
+ syncHeaderAudioBtn();
+ };
+
+ audioWs.onerror = () => {
+ audioStatus.textContent = "Error";
+ };
+}
+
+function stopRxAudio() {
+ rxActive = false;
+ window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
+ streamInfo = null;
+ if (audioWs) { audioWs.close(); audioWs = null; }
+ if (audioCtx) { audioCtx.close(); audioCtx = null; }
+ updateWfmControls();
+ rxGainNode = null;
+ if (opusDecoder) {
+ try { opusDecoder.close(); } catch(e) {}
+ opusDecoder = null;
+ }
+ if (wasmOpusDecoder) {
+ try { wasmOpusDecoder.free(); } catch(e) {}
+ wasmOpusDecoder = null;
+ }
+ nextPlayTime = 0;
+ rxAudioBtn.style.borderColor = "";
+ rxAudioBtn.style.color = "";
+ audioStatus.textContent = "Off";
+ setAudioLevel(0);
+ syncHeaderAudioBtn();
+}
+
+function startTxAudio() {
+ if (txActive) { stopTxAudio(); return; }
+ if (!hasWebCodecs) {
+ audioStatus.textContent = "Audio requires Chrome/Edge";
+ return;
+ }
+ if (!audioWs || audioWs.readyState !== WebSocket.OPEN) {
+ audioStatus.textContent = "RX first";
+ return;
+ }
+ if (!streamInfo) return;
+
+ navigator.mediaDevices.getUserMedia({
+ audio: { sampleRate: streamInfo.sample_rate || 48000, channelCount: streamInfo.channels || 1 }
+ }).then(async (stream) => {
+ txStream = stream;
+ txActive = true;
+ window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
+ txAudioBtn.style.borderColor = "#e55353";
+ txAudioBtn.style.color = "#e55353";
+ audioStatus.textContent = "RX+TX";
+
+ // Start PTT safety timeout
+ resetTxTimeout();
+ startTxTimeoutCountdown();
+
+ // Engage PTT automatically
+ try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); }
+
+ const sampleRate = streamInfo.sample_rate || 48000;
+ const channels = streamInfo.channels || 1;
+ const encoder = new AudioEncoder({
+ output: (chunk) => {
+ const buf = new ArrayBuffer(chunk.byteLength);
+ chunk.copyTo(buf);
+ if (audioWs && audioWs.readyState === WebSocket.OPEN) {
+ audioWs.send(buf);
+ }
+ },
+ error: (e) => { console.error("AudioEncoder error", e); }
+ });
+ encoder.configure({
+ codec: "opus",
+ sampleRate: sampleRate,
+ numberOfChannels: channels,
+ bitrate: (streamInfo.bitrate_bps || 24000),
+ });
+ txEncoder = encoder;
+
+ // Use AudioWorklet or ScriptProcessor to feed encoder
+ if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
+ const source = audioCtx.createMediaStreamSource(stream);
+ const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000;
+ const frameSize = Math.floor(sampleRate * frameDuration);
+ // Use ScriptProcessorNode (deprecated but widely supported)
+ const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
+ let tsCounter = 0;
+ processor.onaudioprocess = (e) => {
+ if (!txActive || !txEncoder) return;
+ const input = e.inputBuffer;
+ // Reset PTT safety timeout on each audio callback
+ resetTxTimeout();
+ // Use mono (channel 0) for f32-planar format
+ const monoData = input.getChannelData(0);
+ try {
+ const frame = new AudioData({
+ format: "f32-planar",
+ sampleRate: input.sampleRate,
+ numberOfFrames: input.length,
+ numberOfChannels: 1,
+ timestamp: tsCounter,
+ data: monoData,
+ });
+ tsCounter += (input.length / input.sampleRate) * 1_000_000;
+ txEncoder.encode(frame);
+ frame.close();
+ } catch (e) {
+ // Ignore
+ }
+ };
+ txGainNode = audioCtx.createGain();
+ txGainNode.gain.value = txVolSlider.value / 100;
+ source.connect(txGainNode);
+ txGainNode.connect(processor);
+ processor.connect(audioCtx.destination);
+ txProcessor = { source, processor };
+ }).catch((err) => {
+ console.error("getUserMedia failed:", err);
+ audioStatus.textContent = "Mic denied";
+ });
+}
+
+async function stopTxAudio() {
+ if (!txActive) return;
+ txActive = false;
+ window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
+ clearTxTimeout();
+
+ // Release PTT automatically
+ try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); }
+
+ if (txStream) {
+ txStream.getTracks().forEach(t => t.stop());
+ txStream = null;
+ }
+ if (txProcessor) {
+ txProcessor.source.disconnect();
+ txProcessor.processor.disconnect();
+ txProcessor = null;
+ }
+ if (txEncoder) {
+ try { txEncoder.close(); } catch(e) {}
+ txEncoder = null;
+ }
+ txGainNode = null;
+ txAudioBtn.style.borderColor = "";
+ txAudioBtn.style.color = "";
+ audioStatus.textContent = rxActive ? "RX" : "Off";
+}
+
+rxAudioBtn.addEventListener("click", startRxAudio);
+txAudioBtn.addEventListener("click", startTxAudio);
+
+// Header play button mirrors the RX audio toggle.
+const headerAudioToggle = document.getElementById("header-audio-toggle");
+const _audioIconPlay = ' ';
+const _audioIconPause = ' ';
+function syncHeaderAudioBtn() {
+ if (!headerAudioToggle) return;
+ headerAudioToggle.classList.toggle("audio-active", rxActive);
+ headerAudioToggle.title = rxActive ? "Stop audio" : "Play audio";
+ headerAudioToggle.innerHTML = rxActive ? _audioIconPause : _audioIconPlay;
+}
+if (headerAudioToggle) {
+ headerAudioToggle.addEventListener("click", startRxAudio);
+}
+
+// ββ Recorder βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+let recorderActive = false;
+const recorderStartBtn = document.getElementById("recorder-start-btn");
+const recorderStopBtn = document.getElementById("recorder-stop-btn");
+const recorderStatusInd = document.getElementById("recorder-status-indicator");
+const headerRecBtn = document.getElementById("header-rec-btn");
+
+function syncRecorderUi() {
+ if (recorderStartBtn) recorderStartBtn.disabled = recorderActive;
+ if (recorderStopBtn) recorderStopBtn.disabled = !recorderActive;
+ if (recorderStatusInd) {
+ recorderStatusInd.textContent = recorderActive ? "Recording" : "";
+ recorderStatusInd.classList.toggle("rec-active", recorderActive);
+ }
+ if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive);
+ const tabBtn = document.querySelector('.tab[data-tab="recorder"]');
+ if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive);
+}
+
+if (recorderStartBtn) {
+ recorderStartBtn.addEventListener("click", async () => {
+ try { await postPath("/api/recorder/start"); } catch (e) { console.error("Recorder start failed", e); }
+ });
+}
+if (recorderStopBtn) {
+ recorderStopBtn.addEventListener("click", async () => {
+ try { await postPath("/api/recorder/stop"); } catch (e) { console.error("Recorder stop failed", e); }
+ });
+}
+if (headerRecBtn) {
+ headerRecBtn.addEventListener("click", async () => {
+ try {
+ if (recorderActive) { await postPath("/api/recorder/stop"); }
+ else { await postPath("/api/recorder/start"); }
+ } catch (e) { console.error("Recorder toggle failed", e); }
+ });
+}
+
+window._syncRecorderState = function (enabled) {
+ recorderActive = enabled;
+ syncRecorderUi();
+};
+
+let _recorderFiles = [];
+let _recFilesPage = 0;
+const REC_PAGE_SIZE = 15;
+
+async function refreshRecorderStatus() {
+ try {
+ const [statusResp, filesResp] = await Promise.all([
+ fetch("/api/recorder/status"),
+ fetch("/api/recorder/files"),
+ ]);
+ if (statusResp.ok) {
+ const active = await statusResp.json();
+ renderRecorderActive(active);
+ }
+ if (filesResp.ok) {
+ _recorderFiles = await filesResp.json();
+ renderRecorderFiles();
+ }
+ } catch (e) {
+ console.error("Recorder status fetch failed", e);
+ }
+}
+
+function renderRecorderActive(list) {
+ const el = document.getElementById("recorder-active-list");
+ if (!el) return;
+ if (!list.length) {
+ el.innerHTML = 'No active recordings.
';
+ return;
+ }
+ let html = 'Rig VChan File Started ';
+ for (const r of list) {
+ const started = new Date(r.started_at * 1000).toLocaleTimeString();
+ const fname = r.path.split("/").pop();
+ html += `${escapeMapHtml(r.rig_id)} ${r.vchan_id ? escapeMapHtml(r.vchan_id) : "-"} ${escapeMapHtml(fname)} ${started} `;
+ }
+ html += "
";
+ el.innerHTML = html;
+}
+
+function recorderFormatSize(bytes) {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
+ return (bytes / 1048576).toFixed(1) + " MB";
+}
+
+function recFilterAndSort() {
+ const filterEl = document.getElementById("recorder-filter");
+ const sortEl = document.getElementById("recorder-sort");
+ const filter = (filterEl ? filterEl.value : "").toLowerCase();
+ const sortMode = sortEl ? sortEl.value : "name-desc";
+
+ let filtered = _recorderFiles;
+ if (filter) {
+ filtered = filtered.filter(function (f) {
+ return f.name.toLowerCase().includes(filter);
+ });
+ }
+
+ const sorted = filtered.slice();
+ switch (sortMode) {
+ case "name-asc": sorted.sort(function (a, b) { return a.name.localeCompare(b.name); }); break;
+ case "name-desc": sorted.sort(function (a, b) { return b.name.localeCompare(a.name); }); break;
+ case "size-asc": sorted.sort(function (a, b) { return a.size - b.size; }); break;
+ case "size-desc": sorted.sort(function (a, b) { return b.size - a.size; }); break;
+ }
+ return sorted;
+}
+
+function renderRecorderFiles() {
+ const el = document.getElementById("recorder-files-list");
+ if (!el) return;
+
+ const sorted = recFilterAndSort();
+ const total = sorted.length;
+ const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE));
+ if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1;
+ if (_recFilesPage < 0) _recFilesPage = 0;
+ const start = _recFilesPage * REC_PAGE_SIZE;
+ const page = sorted.slice(start, start + REC_PAGE_SIZE);
+
+ const summaryEl = document.getElementById("rec-page-summary");
+ const indicatorEl = document.getElementById("rec-page-indicator");
+ const prevBtn = document.getElementById("rec-page-prev");
+ const nextBtn = document.getElementById("rec-page-next");
+
+ if (summaryEl) {
+ summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0";
+ }
+ if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages;
+ if (prevBtn) prevBtn.disabled = _recFilesPage <= 0;
+ if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1;
+
+ const filterEl = document.getElementById("recorder-filter");
+ const filter = filterEl ? filterEl.value : "";
+
+ if (!page.length) {
+ el.innerHTML = '' + (filter ? "No files match filter." : "No recorded files.") + "
";
+ return;
+ }
+
+ let html = 'File Size Actions ';
+ for (const f of page) {
+ const safeName = escapeMapHtml(f.name);
+ const encodedName = encodeURIComponent(f.name);
+ html += ''
+ + "" + safeName + " "
+ + "" + recorderFormatSize(f.size) + " "
+ + ''
+ + '
Play '
+ + '
Download '
+ + '
Remove '
+ + "
";
+ }
+ html += "
";
+ el.innerHTML = html;
+
+ el.querySelectorAll(".rec-play-btn").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ const row = btn.closest("tr");
+ if (!row) return;
+ const next = row.nextElementSibling;
+ if (next && next.classList.contains("rec-player-row")) {
+ const audio = next.querySelector("audio");
+ if (audio) { try { audio.pause(); } catch (_) {} }
+ next.remove();
+ btn.setAttribute("aria-expanded", "false");
+ btn.textContent = "Play";
+ return;
+ }
+ // Close any other open player.
+ el.querySelectorAll(".rec-player-row").forEach(function (r) {
+ const a = r.querySelector("audio");
+ if (a) { try { a.pause(); } catch (_) {} }
+ r.remove();
+ });
+ el.querySelectorAll(".rec-play-btn").forEach(function (b) {
+ b.setAttribute("aria-expanded", "false");
+ b.textContent = "Play";
+ });
+ const playerRow = document.createElement("tr");
+ playerRow.className = "rec-player-row";
+ const cell = document.createElement("td");
+ cell.colSpan = 3;
+ const audio = document.createElement("audio");
+ audio.controls = true;
+ audio.preload = "metadata";
+ audio.src = btn.dataset.url;
+ audio.className = "rec-player-audio";
+ cell.appendChild(audio);
+ playerRow.appendChild(cell);
+ row.parentNode.insertBefore(playerRow, row.nextSibling);
+ btn.setAttribute("aria-expanded", "true");
+ btn.textContent = "Hide";
+ try { audio.play(); } catch (_) {}
+ });
+ });
+
+ el.querySelectorAll(".rec-delete-btn").forEach(function (btn) {
+ btn.addEventListener("click", async function () {
+ const name = btn.dataset.name;
+ if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
+ try {
+ const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ _recorderFiles = _recorderFiles.filter(function (f) { return f.name !== name; });
+ renderRecorderFiles();
+ } catch (e) {
+ console.error("Delete failed", e);
+ window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
+ }
+ });
+ });
+}
+
+(function () {
+ const filterEl = document.getElementById("recorder-filter");
+ const sortEl = document.getElementById("recorder-sort");
+ if (filterEl) filterEl.addEventListener("input", function () { _recFilesPage = 0; renderRecorderFiles(); });
+ if (sortEl) sortEl.addEventListener("change", function () { _recFilesPage = 0; renderRecorderFiles(); });
+ const prevBtn = document.getElementById("rec-page-prev");
+ const nextBtn = document.getElementById("rec-page-next");
+ if (prevBtn) prevBtn.addEventListener("click", function () { _recFilesPage--; renderRecorderFiles(); });
+ if (nextBtn) nextBtn.addEventListener("click", function () { _recFilesPage++; renderRecorderFiles(); });
+})();
+
+const rxVolPct = document.getElementById("rx-vol-pct");
+const txVolPct = document.getElementById("tx-vol-pct");
+
+// Restore saved volumes
+rxVolSlider.value = loadSetting("rxVol", 80);
+txVolSlider.value = loadSetting("txVol", 80);
+rxVolPct.textContent = `${rxVolSlider.value}%`;
+txVolPct.textContent = `${txVolSlider.value}%`;
+
+function updateVolSlider(slider, pctEl, gainNode) {
+ pctEl.textContent = `${slider.value}%`;
+ if (gainNode) gainNode.gain.value = slider.value / 100;
+}
+
+rxVolSlider.addEventListener("input", () => { updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); saveSetting("rxVol", Number(rxVolSlider.value)); });
+txVolSlider.addEventListener("input", () => { updateVolSlider(txVolSlider, txVolPct, txGainNode); saveSetting("txVol", Number(txVolSlider.value)); });
+
+function volWheel(slider, pctEl, getGain, storageKey) {
+ slider.addEventListener("wheel", (e) => {
+ e.preventDefault();
+ const step = e.deltaY < 0 ? 2 : -2;
+ slider.value = Math.max(0, Math.min(100, Number(slider.value) + step));
+ updateVolSlider(slider, pctEl, getGain());
+ saveSetting(storageKey, Number(slider.value));
+ }, { passive: false });
+}
+volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
+volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
+if (sdrSquelchEl) {
+ sdrSquelchEl.addEventListener("wheel", (e) => {
+ e.preventDefault();
+ const step = e.deltaY < 0 ? 2 : -2;
+ const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
+ sdrSquelchEl.value = String(next);
+ updateSdrSquelchPctLabel();
+ saveSetting("sdrSquelchPct", next);
+ submitSdrSquelchPercent(next);
+ }, { passive: false });
+}
+
+document.getElementById("copyright-year").textContent = new Date().getFullYear();
+
+// --- Server-side decode SSE ---
+let decodeSource = null;
+let decodeConnected = false;
+let decodeHistoryWorker = null;
+function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
+ if (!el) return;
+ const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
+ const isActiveMode = activeModes.includes(modeUpper);
+ if (el.textContent === "Receiving" && isActiveMode) return;
+ el.textContent = isActiveMode ? connectedText : inactiveText;
+}
+// Custom connected-state text overrides per decoder.
+const _decodeConnectedText = {
+ vdes: "Connected, listening for bursts",
+ cw: "Connected, listening for CW",
+};
+function updateDecodeStatus(text) {
+ // Mode-bound decoders: show mode-gated status text.
+ 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] || text;
+ setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
+ }
+ // Toggle-gated decoders: update status text if not currently receiving.
+ for (const d of decoderRegistry) {
+ if (d.activation !== "toggle") continue;
+ const el = document.getElementById(d.id + "-status");
+ if (el && el.textContent !== "Receiving") el.textContent = text;
+ }
+}
+function dispatchDecodeMessage(msg, skipStats) {
+ if (msg.type === "ais") { if (window.onServerAis) window.onServerAis(msg); else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); }
+ if (msg.type === "vdes") { if (window.onServerVdes) window.onServerVdes(msg); else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); }
+ if (msg.type === "aprs") { if (window.onServerAprs) window.onServerAprs(msg); else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg); }
+ if (msg.type === "hf_aprs") { if (window.onServerHfAprs) window.onServerHfAprs(msg); else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg); }
+ if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
+ if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
+ if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg);
+ if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg);
+ if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
+ if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg);
+ if (msg.type === "lrpt_progress" && window.onServerLrptProgress) window.onServerLrptProgress(msg);
+ if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg);
+ if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(msg);
+ if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
+ window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
+ window.trx.modules.map?.scheduleStatsRender();
+ }
+}
+
+function dispatchDecodeBatch(batch) {
+ if (!Array.isArray(batch) || batch.length === 0) return;
+ // Record statistics for every message in the batch regardless of dispatch path.
+ for (const msg of batch) {
+ if (msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
+ window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
+ }
+ }
+ window.trx.modules.map?.scheduleStatsRender();
+ const type = String(batch[0]?.type || "");
+ const uniformType = batch.every((msg) => String(msg?.type || "") === type);
+ if (uniformType) {
+ if (type === "ais" && window.onServerAisBatch) {
+ window.onServerAisBatch(batch);
+ return;
+ }
+ if (type === "vdes" && window.onServerVdesBatch) {
+ window.onServerVdesBatch(batch);
+ return;
+ }
+ if (type === "aprs" && window.onServerAprsBatch) {
+ window.onServerAprsBatch(batch);
+ return;
+ }
+ if (type === "hf_aprs" && window.onServerHfAprsBatch) {
+ window.onServerHfAprsBatch(batch);
+ return;
+ }
+ if (type === "ft8" && window.onServerFt8Batch) {
+ window.onServerFt8Batch(batch);
+ return;
+ }
+ if (type === "ft4" && window.onServerFt4Batch) {
+ window.onServerFt4Batch(batch);
+ return;
+ }
+ if (type === "ft2" && window.onServerFt2Batch) {
+ window.onServerFt2Batch(batch);
+ return;
+ }
+ if (type === "wspr" && window.onServerWsprBatch) {
+ window.onServerWsprBatch(batch);
+ return;
+ }
+ }
+ for (const msg of batch) {
+ dispatchDecodeMessage(msg, true);
+ }
+}
+
+const DECODE_HISTORY_TYPE_BATCH_LIMIT = 192;
+const DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
+const DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8;
+
+function terminateDecodeHistoryWorker() {
+ if (!decodeHistoryWorker) return;
+ try { decodeHistoryWorker.terminate(); } catch (_) {}
+ decodeHistoryWorker = null;
+}
+
+function scheduleDecodeHistoryDrainStep(callback) {
+ if (typeof callback !== "function") return;
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(() => callback());
+ } else {
+ setTimeout(callback, 16);
+ }
+}
+
+function decodeHistoryUrl() {
+ return "/decode/history";
+}
+
+function loadDecodeHistoryOnMainThread(onReady, onError) {
+ fetch(decodeHistoryUrl()).then(async (resp) => {
+ if (!resp.ok) return null;
+ setDecodeHistoryOverlayVisible(true, "Loading decode historyβ¦", "Receiving compressed history payload");
+ const payload = await resp.arrayBuffer();
+ if (!payload || payload.byteLength === 0) return {};
+ setDecodeHistoryOverlayVisible(true, "Loading decode historyβ¦", "Decoding compressed history payload");
+ return decodeCborPayload(payload);
+ }).then((groups) => {
+ if (typeof onReady === "function") onReady(groups && typeof groups === "object" ? groups : {});
+ }).catch((err) => {
+ if (typeof onError === "function") onError(err);
+ });
+}
+
+function restoreDecodeHistoryGroup(kind, messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ // Record statistics for restored history messages.
+ if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
+ for (const msg of messages) {
+ window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
+ }
+ window.trx.modules.map?.scheduleStatsRender();
+ }
+ if (kind === "ais") {
+ if (window.restoreAisHistory) { window.restoreAisHistory(messages); }
+ else { _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages); }
+ return;
+ }
+ if (kind === "vdes") {
+ if (window.restoreVdesHistory) { window.restoreVdesHistory(messages); }
+ else { _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages); }
+ return;
+ }
+ if (kind === "aprs") {
+ if (window.restoreAprsHistory) { window.restoreAprsHistory(messages); }
+ else { _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages); }
+ return;
+ }
+ if (kind === "hf_aprs") {
+ if (window.restoreHfAprsHistory) { window.restoreHfAprsHistory(messages); }
+ else { _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages); }
+ return;
+ }
+ if (kind === "cw" && window.restoreCwHistory) {
+ window.restoreCwHistory(messages);
+ return;
+ }
+ if (kind === "ft8" && window.restoreFt8History) {
+ window.restoreFt8History(messages);
+ return;
+ }
+ if (kind === "ft4" && window.restoreFt4History) {
+ window.restoreFt4History(messages);
+ return;
+ }
+ if (kind === "ft2" && window.restoreFt2History) {
+ window.restoreFt2History(messages);
+ return;
+ }
+ if (kind === "wspr" && window.restoreWsprHistory) {
+ window.restoreWsprHistory(messages);
+ return;
+ }
+ if (kind === "wefax" && window.restoreWefaxHistory) {
+ window.restoreWefaxHistory(messages);
+ return;
+ }
+}
+
+function connectDecode() {
+ if (decodeSource) { decodeSource.close(); }
+ terminateDecodeHistoryWorker();
+ decodeHistoryReplayActive = false;
+ decodeMapSyncPending = false;
+ // Clear any pending buffers from a previous connection cycle.
+ for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k];
+ for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k];
+ if (window.resetAisHistoryView) window.resetAisHistoryView();
+ if (window.resetVdesHistoryView) window.resetVdesHistoryView();
+ if (window.resetAprsHistoryView) window.resetAprsHistoryView();
+ if (window.resetCwHistoryView) window.resetCwHistoryView();
+ if (window.resetFt8HistoryView) window.resetFt8HistoryView();
+ if (window.resetFt4HistoryView) window.resetFt4HistoryView();
+ if (window.resetWsprHistoryView) window.resetWsprHistoryView();
+
+ // Buffer live messages until history fetch settles so history always appears
+ // before any live updates, regardless of network ordering.
+ let historySettled = false;
+ let historyWorkerDone = false;
+ let historyFallbackStarted = false;
+ let historyBatchDrainScheduled = false;
+ let historyTotal = 0;
+ let historyProcessed = 0;
+ const historyGroupQueue = [];
+ const liveBuffer = [];
+ function flushLiveBuffer() {
+ historySettled = true;
+ terminateDecodeHistoryWorker();
+ setDecodeHistoryReplayActive(false);
+ setDecodeHistoryOverlayVisible(false);
+ for (const msg of liveBuffer) {
+ try { dispatchDecodeMessage(msg); } catch (_) {}
+ }
+ liveBuffer.length = 0;
+ }
+
+ function updateHistoryReplayOverlay() {
+ setDecodeHistoryOverlayVisible(
+ true,
+ "Loading decode historyβ¦",
+ `Replaying ${historyProcessed} / ${historyTotal} decoded messages`
+ );
+ }
+
+ function maybeFinishHistoryReplay() {
+ if (historySettled) return;
+ if (historyWorkerDone && historyGroupQueue.length === 0) {
+ clearTimeout(historyTimeout);
+ flushLiveBuffer();
+ }
+ }
+
+ function pumpDecodeHistoryGroupQueue() {
+ historyBatchDrainScheduled = false;
+ const startedAt = typeof performance !== "undefined" && typeof performance.now === "function"
+ ? performance.now()
+ : 0;
+ while (historyGroupQueue.length > 0) {
+ const next = historyGroupQueue.shift();
+ restoreDecodeHistoryGroup(next.kind, next.messages);
+ historyProcessed += Array.isArray(next.messages) ? next.messages.length : 0;
+ updateHistoryReplayOverlay();
+ if (startedAt > 0 && (performance.now() - startedAt) >= DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS) {
+ break;
+ }
+ }
+ if (historyGroupQueue.length > 0) {
+ scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue);
+ historyBatchDrainScheduled = true;
+ return;
+ }
+ maybeFinishHistoryReplay();
+ }
+
+ function enqueueDecodeHistoryGroup(kind, messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ historyGroupQueue.push({ kind, messages });
+ if (historyBatchDrainScheduled) return;
+ historyBatchDrainScheduled = true;
+ scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue);
+ }
+
+ function totalDecodeHistoryMessages(groups) {
+ if (!groups || typeof groups !== "object") return 0;
+ return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]
+ .reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0);
+ }
+
+ function enqueueDecodeHistoryGroups(groups) {
+ historyTotal = totalDecodeHistoryMessages(groups);
+ historyProcessed = 0;
+ if (historyTotal > 0) {
+ setDecodeHistoryReplayActive(true);
+ updateHistoryReplayOverlay();
+ }
+ for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]) {
+ const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : [];
+ if (messages.length === 0) continue;
+ for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) {
+ enqueueDecodeHistoryGroup(kind, messages.slice(index, index + DECODE_HISTORY_WORKER_GROUP_LIMIT));
+ }
+ }
+ historyWorkerDone = true;
+ maybeFinishHistoryReplay();
+ }
+
+ function startDecodeHistoryFallback() {
+ if (historyFallbackStarted || historySettled) return;
+ historyFallbackStarted = true;
+ loadDecodeHistoryOnMainThread((groups) => {
+ clearTimeout(historyTimeout);
+ const total = totalDecodeHistoryMessages(groups);
+ if (total > 0) {
+ enqueueDecodeHistoryGroups(groups);
+ } else {
+ flushLiveBuffer();
+ }
+ }, (err) => {
+ console.error("Decode history fallback failed", err);
+ clearTimeout(historyTimeout);
+ flushLiveBuffer();
+ });
+ }
+
+ function startDecodeHistoryWorkerReplay() {
+ if (typeof Worker !== "function") return false;
+ let worker;
+ try {
+ worker = new Worker("/decode-history-worker.js");
+ } catch (err) {
+ console.error("Decode history worker startup failed", err);
+ return false;
+ }
+ decodeHistoryWorker = worker;
+ worker.onmessage = (evt) => {
+ if (historySettled || worker !== decodeHistoryWorker) return;
+ const data = evt?.data || {};
+ if (data.type === "status") {
+ const phase = String(data.phase || "");
+ if (phase === "fetching") {
+ setDecodeHistoryOverlayVisible(true, "Loading decode historyβ¦", "Fetching recent decodes from the client buffer");
+ } else if (phase === "decoding") {
+ setDecodeHistoryOverlayVisible(true, "Loading decode historyβ¦", "Decoding compressed history in background");
+ }
+ return;
+ }
+ if (data.type === "start") {
+ historyTotal = Math.max(0, Number(data.total) || 0);
+ historyProcessed = 0;
+ if (historyTotal > 0) {
+ setDecodeHistoryReplayActive(true);
+ updateHistoryReplayOverlay();
+ }
+ return;
+ }
+ if (data.type === "group") {
+ enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages);
+ return;
+ }
+ if (data.type === "done") {
+ historyWorkerDone = true;
+ clearTimeout(historyTimeout);
+ terminateDecodeHistoryWorker();
+ maybeFinishHistoryReplay();
+ return;
+ }
+ if (data.type === "error") {
+ console.error("Decode history worker failed", data.message || "unknown worker failure");
+ terminateDecodeHistoryWorker();
+ startDecodeHistoryFallback();
+ }
+ };
+ worker.postMessage({
+ type: "fetch-history",
+ url: decodeHistoryUrl(),
+ batchLimit: DECODE_HISTORY_WORKER_GROUP_LIMIT,
+ });
+ return true;
+ }
+
+ // Safety valve: if the history fetch hangs, unblock after 20 s.
+ const historyTimeout = setTimeout(() => {
+ if (!historySettled) {
+ terminateDecodeHistoryWorker();
+ flushLiveBuffer();
+ }
+ }, 20000);
+ setDecodeHistoryOverlayVisible(true, "Loading decode historyβ¦", "Fetching recent decodes from the client buffer");
+
+ decodeSource = new EventSource("/decode");
+ decodeSource.onopen = () => {
+ decodeConnected = true;
+ updateDecodeStatus("Connected, listening for packets");
+ };
+ decodeSource.onmessage = (evt) => {
+ try {
+ const msg = JSON.parse(evt.data);
+ if (historySettled) dispatchDecodeMessage(msg);
+ else liveBuffer.push(msg);
+ } catch (e) { /* ignore parse errors */ }
+ };
+ decodeSource.onerror = () => {
+ // readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop
+ const wasClosed = decodeSource.readyState === 2;
+ decodeSource.close();
+ decodeConnected = false;
+ terminateDecodeHistoryWorker();
+ if (!historySettled) flushLiveBuffer();
+ if (wasClosed) {
+ updateDecodeStatus("Decode not available (check client audio config)");
+ setTimeout(connectDecode, 10000);
+ } else {
+ updateDecodeStatus("Decode disconnected, retryingβ¦");
+ setTimeout(connectDecode, 5000);
+ }
+ };
+
+ if (!startDecodeHistoryWorkerReplay()) {
+ startDecodeHistoryFallback();
+ }
+}
+// connectDecode() is called from initializeApp() after auth succeeds,
+// and from login/guest handlers β no standalone window.load call needed.
+
+// Release PTT on page unload to prevent stuck transmit
+window.addEventListener("beforeunload", () => {
+ if (txActive) {
+ navigator.sendBeacon("/set_ptt?ptt=false", "");
+ }
+});
+
+
+// ββ Spectrum display βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+const spectrumCanvas = document.getElementById("spectrum-canvas");
+const spectrumGl = typeof createTrxWebGlRenderer === "function"
+ ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions)
+ : null;
+const spectrumDbAxis = document.getElementById("spectrum-db-axis");
+const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
+const spectrumTooltip = document.getElementById("spectrum-tooltip");
+const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn");
+const spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn");
+let spectrumSource = null;
+let spectrumReconnectTimer = null;
+let meterSource = null;
+let meterReconnectTimer = null;
+let spectrumDrawPending = false;
+let spectrumAxisKey = "";
+let spectrumDbAxisKey = "";
+let lastSpectrumRenderData = null;
+let spectrumPeakHoldFrames = [];
+let pendingSpectrumFrameWaiters = [];
+let sweetSpotScanInFlight = false;
+const spectrumTmpGridSegments = [];
+const spectrumTmpFillPoints = [];
+const spectrumTmpPeakPoints = [];
+const spectrumTmpMarkerPoints = [];
+
+// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
+// bandwidth at the centre of the visible window.
+let spectrumZoom = 1;
+let spectrumPanFrac = 0.5;
+
+// Y-axis level: floor = bottom dB value shown; range = total dB span.
+let spectrumFloor = -115;
+let spectrumRange = 90;
+let waterfallGamma = 1.0;
+const SPECTRUM_HEADROOM_DB = 20;
+const SPECTRUM_SMOOTH_ALPHA = 0.42;
+let _spectrumBinBuf = []; // Reusable buffer for SSE bin decoding
+// Fast base64 β Int8Array decoder using a lookup table.
+// Avoids atob() (which allocates a UTF-16 string) and the subsequent
+// charCodeAt loop, decoding directly into a reusable typed array.
+const _b64Lut = new Uint8Array(128);
+for (let i = 0; i < 128; i++) _b64Lut[i] = 255;
+"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").forEach((c, i) => {
+ _b64Lut[c.charCodeAt(0)] = i;
+});
+let _spectrumBinI8 = new Int8Array(0); // Reusable typed-array bin buffer
+// Check if a value is an array-like bins buffer (Array or TypedArray).
+function isBinsArray(v) { return Array.isArray(v) || ArrayBuffer.isView(v); }
+function decodeBase64ToInt8(b64) {
+ // Strip trailing '=' padding
+ let end = b64.length;
+ while (end > 0 && b64.charCodeAt(end - 1) === 61) end--;
+ const outLen = (end * 3 >>> 2); // exact byte count without padding
+ if (_spectrumBinI8.length !== outLen) _spectrumBinI8 = new Int8Array(outLen);
+ const out = _spectrumBinI8;
+ let j = 0;
+ for (let i = 0; i < end; ) {
+ const a = _b64Lut[b64.charCodeAt(i++)];
+ const b = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
+ const c = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
+ const d = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0;
+ const n = (a << 18) | (b << 12) | (c << 6) | d;
+ if (j < outLen) out[j++] = (n >> 16) & 0xff;
+ if (j < outLen) out[j++] = (n >> 8) & 0xff;
+ if (j < outLen) out[j++] = n & 0xff;
+ }
+ return out;
+}
+
+// Crosshair state (CSS coords relative to spectrum canvas).
+let spectrumCrosshairX = null;
+let spectrumCrosshairY = null;
+
+// BW-strip drag state.
+let _bwDragEdge = null; // "left" | "right" | null
+let _bwDragStartX = 0;
+let _bwDragStartBwHz = 0;
+let _bwDragCanvas = null;
+
+function spectrumBgColor() {
+ return canvasPalette().bg;
+}
+
+function clearSpectrumPeakHoldFrames() {
+ spectrumPeakHoldFrames = [];
+}
+
+function settlePendingSpectrumFrameWaiters(frame) {
+ if (!pendingSpectrumFrameWaiters.length) return;
+ const remaining = [];
+ for (const waiter of pendingSpectrumFrameWaiters) {
+ if (!waiter) continue;
+ const targetCenterHz = Number(waiter.targetCenterHz);
+ if (
+ Number.isFinite(targetCenterHz) &&
+ (!frame || Math.abs(Number(frame.center_hz) - targetCenterHz) >= 2)
+ ) {
+ remaining.push(waiter);
+ continue;
+ }
+ if (waiter.timer) {
+ clearTimeout(waiter.timer);
+ waiter.timer = null;
+ }
+ if (typeof waiter.resolve === "function") {
+ waiter.resolve(frame);
+ }
+ }
+ pendingSpectrumFrameWaiters = remaining;
+}
+
+function rejectPendingSpectrumFrameWaiters(error) {
+ if (!pendingSpectrumFrameWaiters.length) return;
+ for (const waiter of pendingSpectrumFrameWaiters) {
+ if (!waiter) continue;
+ if (waiter.timer) {
+ clearTimeout(waiter.timer);
+ waiter.timer = null;
+ }
+ if (typeof waiter.reject === "function") {
+ waiter.reject(error || new Error("Spectrum unavailable"));
+ }
+ }
+ pendingSpectrumFrameWaiters = [];
+}
+
+function waitForSpectrumFrame(expectedCenterHz = null, timeoutMs = 1200) {
+ const targetCenterHz = Number(expectedCenterHz);
+ if (
+ lastSpectrumData &&
+ (!Number.isFinite(targetCenterHz) || Math.abs(Number(lastSpectrumData.center_hz) - targetCenterHz) < 2)
+ ) {
+ return Promise.resolve(lastSpectrumData);
+ }
+
+ return new Promise((resolve, reject) => {
+ const waiter = {
+ targetCenterHz,
+ resolve,
+ reject,
+ timer: null,
+ };
+ waiter.timer = setTimeout(() => {
+ pendingSpectrumFrameWaiters = pendingSpectrumFrameWaiters.filter((entry) => entry !== waiter);
+ reject(new Error("Timed out waiting for spectrum frame"));
+ }, Math.max(200, timeoutMs));
+ pendingSpectrumFrameWaiters.push(waiter);
+ });
+}
+
+function pruneSpectrumPeakHoldFrames(now = Date.now()) {
+ const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
+ if (holdMs <= 0) {
+ clearSpectrumPeakHoldFrames();
+ return;
+ }
+ // In-place removal from front (frames are time-ordered).
+ let removeCount = 0;
+ for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
+ const f = spectrumPeakHoldFrames[i];
+ if (f && isBinsArray(f.bins) && now - f.t <= holdMs) break;
+ removeCount++;
+ }
+ if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
+}
+
+function pushSpectrumPeakHoldFrame(frame) {
+ if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) {
+ clearSpectrumPeakHoldFrames();
+ return;
+ }
+ const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
+ if (holdMs <= 0) {
+ clearSpectrumPeakHoldFrames();
+ return;
+ }
+ const now = Date.now();
+ pruneSpectrumPeakHoldFrames(now);
+ const lastFrame = spectrumPeakHoldFrames[spectrumPeakHoldFrames.length - 1];
+ if (lastFrame && lastFrame.bins.length !== frame.bins.length) {
+ clearSpectrumPeakHoldFrames();
+ }
+ spectrumPeakHoldFrames.push({ t: now, bins: frame.bins.slice() });
+}
+
+function buildSpectrumPeakHoldBins(currentBins) {
+ const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
+ if (holdMs <= 0 || !isBinsArray(currentBins) || currentBins.length === 0) {
+ return null;
+ }
+ pruneSpectrumPeakHoldFrames();
+ if (spectrumPeakHoldFrames.length === 0) return null;
+ const peakBins = currentBins.slice();
+ for (const frame of spectrumPeakHoldFrames) {
+ if (!frame || !isBinsArray(frame.bins) || frame.bins.length !== peakBins.length) continue;
+ for (let i = 0; i < peakBins.length; i++) {
+ if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i];
+ }
+ }
+ return peakBins;
+}
+
+// Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto).
+// Uses O(N) nth-element selection instead of O(N log N) sort.
+function estimateNoiseFloorDb(bins) {
+ if (!isBinsArray(bins) || bins.length === 0) return null;
+ const k = Math.floor(bins.length * 0.15);
+ return nthElement(bins, k);
+}
+
+// O(N) average-case selection algorithm (Floyd-Rivest / quickselect).
+function nthElement(arr, k) {
+ const tmp = _nthScratch.length >= arr.length ? _nthScratch : new Float64Array(arr.length);
+ if (tmp.length > _nthScratch.length) _nthScratch = tmp;
+ for (let i = 0; i < arr.length; i++) tmp[i] = arr[i];
+ let lo = 0, hi = arr.length - 1;
+ while (lo < hi) {
+ const pivot = tmp[lo + ((hi - lo) >> 1)];
+ let i = lo, j = hi;
+ while (i <= j) {
+ while (tmp[i] < pivot) i++;
+ while (tmp[j] > pivot) j--;
+ if (i <= j) { const t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; i++; j--; }
+ }
+ if (j < k) lo = i;
+ if (k < i) hi = j;
+ }
+ return tmp[k];
+}
+let _nthScratch = new Float64Array(0);
+
+// Pre-allocated buffer for smoothed spectrum bins (avoids .map() allocation per frame).
+let _smoothBins = [];
+
+function buildSpectrumRenderData(frame) {
+ if (!frame || !isBinsArray(frame.bins)) return frame;
+ const n = frame.bins.length;
+ const prev = lastSpectrumRenderData;
+ const canBlend =
+ prev &&
+ isBinsArray(prev.bins) &&
+ prev.bins.length === n &&
+ prev.sample_rate === frame.sample_rate &&
+ prev.center_hz === frame.center_hz;
+ if (_smoothBins.length !== n) _smoothBins = new Array(n);
+ const src = frame.bins;
+ if (canBlend) {
+ const prevBins = prev.bins;
+ const alpha = SPECTRUM_SMOOTH_ALPHA;
+ for (let i = 0; i < n; i++) {
+ _smoothBins[i] = prevBins[i] + (src[i] - prevBins[i]) * alpha;
+ }
+ } else {
+ for (let i = 0; i < n; i++) _smoothBins[i] = src[i];
+ }
+ // Return object reusing the frame's metadata.
+ return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds };
+}
+
+// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
+// panFrac so the view never scrolls past the edges.
+function spectrumVisibleRange(data) {
+ const fullSpanHz = data.sample_rate;
+ const loHz = data.center_hz - fullSpanHz / 2;
+ const halfVis = 0.5 / spectrumZoom;
+ spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
+ const visCenterHz = loHz + spectrumPanFrac * fullSpanHz;
+ const visSpanHz = fullSpanHz / spectrumZoom;
+ return {
+ loHz,
+ hiHz: loHz + fullSpanHz,
+ visLoHz: visCenterHz - visSpanHz / 2,
+ visHiHz: visCenterHz + visSpanHz / 2,
+ fullSpanHz,
+ visSpanHz,
+ };
+}
+
+function canvasXToHz(cssX, cssW, range) {
+ return range.visLoHz + (cssX / cssW) * range.visSpanHz;
+}
+
+function nearestSpectrumPeak(cssX, cssW, data) {
+ if (!data || !isBinsArray(data.bins) || data.bins.length === 0 || cssW <= 0) {
+ return null;
+ }
+
+ const bins = data.bins;
+ const maxIdx = bins.length - 1;
+ const range = spectrumVisibleRange(data);
+ const fullLoHz = data.center_hz - data.sample_rate / 2;
+ const targetHz = canvasXToHz(cssX, cssW, range);
+ const targetIdx = Math.max(
+ 0,
+ Math.min(maxIdx, Math.round(((targetHz - fullLoHz) / data.sample_rate) * maxIdx)),
+ );
+
+ const visStartIdx = Math.max(
+ 0,
+ Math.min(maxIdx, Math.floor(((range.visLoHz - fullLoHz) / data.sample_rate) * maxIdx)),
+ );
+ const visEndIdx = Math.max(
+ visStartIdx,
+ Math.min(maxIdx, Math.ceil(((range.visHiHz - fullLoHz) / data.sample_rate) * maxIdx)),
+ );
+ const visSpanBins = Math.max(1, visEndIdx - visStartIdx);
+ const searchRadius = Math.max(3, Math.min(80, Math.round((24 / cssW) * visSpanBins)));
+ const searchLo = Math.max(1, targetIdx - searchRadius);
+ const searchHi = Math.min(maxIdx - 1, targetIdx + searchRadius);
+
+ let windowMax = -Infinity;
+ const localPeaks = [];
+ for (let i = searchLo; i <= searchHi; i++) {
+ const val = bins[i];
+ if (val > windowMax) windowMax = val;
+ if (val >= bins[i - 1] && val >= bins[i + 1]) {
+ localPeaks.push(i);
+ }
+ }
+
+ const candidates = localPeaks.filter((i) => bins[i] >= windowMax - 6);
+ const ranked = (candidates.length ? candidates : localPeaks).sort((a, b) => {
+ const dist = Math.abs(a - targetIdx) - Math.abs(b - targetIdx);
+ if (dist !== 0) return dist;
+ return bins[b] - bins[a];
+ });
+
+ let snappedIdx = ranked[0];
+ if (snappedIdx == null) {
+ snappedIdx = targetIdx;
+ for (let i = searchLo; i <= searchHi; i++) {
+ if (bins[i] > bins[snappedIdx]) snappedIdx = i;
+ }
+ }
+
+ return {
+ index: snappedIdx,
+ hz: Math.round(fullLoHz + (snappedIdx / maxIdx) * data.sample_rate),
+ db: bins[snappedIdx],
+ };
+}
+
+function nearestSpectrumPeakHz(cssX, cssW, data) {
+ return nearestSpectrumPeak(cssX, cssW, data)?.hz ?? null;
+}
+
+function spectrumTargetHzAt(cssX, cssW, data) {
+ if (!data) return null;
+ const range = spectrumVisibleRange(data);
+ return nearestSpectrumPeakHz(cssX, cssW, data)
+ ?? Math.round(canvasXToHz(cssX, cssW, range));
+}
+
+function visibleSpectrumPeakIndices(data, limit = 24) {
+ if (!data || !isBinsArray(data.bins) || data.bins.length < 3) {
+ return [];
+ }
+
+ const bins = data.bins;
+ const maxIdx = bins.length - 1;
+ const range = spectrumVisibleRange(data);
+ const fullLoHz = data.center_hz - data.sample_rate / 2;
+ const visStartIdx = Math.max(
+ 1,
+ Math.min(maxIdx - 1, Math.floor(((range.visLoHz - fullLoHz) / data.sample_rate) * maxIdx)),
+ );
+ const visEndIdx = Math.max(
+ visStartIdx,
+ Math.min(maxIdx - 1, Math.ceil(((range.visHiHz - fullLoHz) / data.sample_rate) * maxIdx)),
+ );
+
+ const peaks = [];
+ for (let i = visStartIdx; i <= visEndIdx; i++) {
+ const v = bins[i];
+ if (v >= bins[i - 1] && v >= bins[i + 1]) {
+ peaks.push(i);
+ }
+ }
+ if (peaks.length === 0) {
+ return [];
+ }
+
+ const peakValues = peaks.map((i) => bins[i]).sort((a, b) => a - b);
+ const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))];
+
+ return peaks
+ .filter((i) => bins[i] >= cutoff)
+ .sort((a, b) => bins[b] - bins[a])
+ .slice(0, limit)
+ .sort((a, b) => a - b);
+}
+
+// Format a frequency according to the current jog-step unit.
+function formatSpectrumFreq(hz) {
+ if (jogUnit >= 1_000_000) return (hz / 1e6).toFixed(3) + " MHz";
+ if (jogUnit >= 1_000) return (hz / 1e3).toFixed(3) + " kHz";
+ return hz.toFixed(0) + " Hz";
+}
+
+// ββ Streaming ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function scheduleSpectrumReconnect() {
+ if (spectrumReconnectTimer !== null) return;
+ spectrumReconnectTimer = setTimeout(() => {
+ spectrumReconnectTimer = null;
+ startSpectrumStreaming();
+ }, 1000);
+}
+
+function startSpectrumStreaming() {
+ if (spectrumSource !== null) return;
+ const spectrumUrl = lastActiveRigId
+ ? `/spectrum?remote=${encodeURIComponent(lastActiveRigId)}`
+ : "/spectrum";
+ spectrumSource = new EventSource(spectrumUrl);
+ // Unnamed event = reset signal.
+ spectrumSource.onmessage = (evt) => {
+ if (evt.data === "null") {
+ rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset"));
+ lastSpectrumData = null;
+ lastSpectrumRenderData = null;
+ clearSpectrumPeakHoldFrames();
+ overviewWaterfallRows = [];
+ overviewWaterfallPushCount = 0;
+ overviewWfResetTextureCache();
+ spectrumWfRows = [];
+ spectrumWfPushCount = 0;
+ spectrumWfTexReady = false;
+ scheduleOverviewDraw();
+ clearSpectrumCanvas();
+ updateRdsPsOverlay(null);
+ }
+ };
+ // Named "b" event = compact binary frame: "{center_hz},{sample_rate},{base64_i8_bins}"
+ // Bins are i8 (1 dB/step), base64-encoded for ~5Γ size reduction vs JSON f32 array.
+ // Named "b" event = compact binary frame: "{center_hz},{sample_rate},{base64_i8_bins}"
+ // Bins are i8 (1 dB/step), base64-encoded for ~5Γ size reduction vs JSON f32 array.
+ spectrumSource.addEventListener("b", (evt) => {
+ try {
+ const commaA = evt.data.indexOf(",");
+ const commaB = evt.data.indexOf(",", commaA + 1);
+ const centerHz = Number(evt.data.slice(0, commaA));
+ const sampleRate = Number(evt.data.slice(commaA + 1, commaB));
+ const b64 = evt.data.slice(commaB + 1);
+ const hadSpectrum = !!lastSpectrumData;
+ const bins = decodeBase64ToInt8(b64);
+ // Preserve any RDS data from the last rds event.
+ const rds = lastSpectrumData?.rds;
+ lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds };
+ window.lastSpectrumData = lastSpectrumData;
+ const spectrumSummary = document.getElementById("spectrum-text-summary");
+ if (spectrumSummary && bins.length) {
+ let peakIndex = 0;
+ for (let i = 1; i < bins.length; i += 1) if (bins[i] > bins[peakIndex]) peakIndex = i;
+ const peakHz = centerHz - sampleRate / 2 + (peakIndex / Math.max(1, bins.length - 1)) * sampleRate;
+ spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
+ }
+ // Server confirmed a new center β clear optimistic pending value.
+ if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1000) {
+ spectrumCenterPendingHz = null;
+ }
+ lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData);
+ settlePendingSpectrumFrameWaiters(lastSpectrumData);
+ pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
+ pushOverviewWaterfallFrame(lastSpectrumData);
+ pushSpectrumWaterfallFrame(lastSpectrumData);
+ refreshCenterFreqDisplay();
+ if (window.refreshCwTonePicker) window.refreshCwTonePicker();
+ scheduleSpectrumDraw();
+ if (!hadSpectrum) {
+ updateRdsPsOverlay(lastSpectrumData.rds);
+ } else {
+ positionRdsPsOverlay();
+ }
+ } catch (_) {}
+ });
+ // Named "rds" event = RDS metadata changed (emitted only when it changes).
+ spectrumSource.addEventListener("rds", (evt) => {
+ try {
+ const rds = evt.data === "null" ? undefined : JSON.parse(evt.data);
+ if (lastSpectrumData) lastSpectrumData.rds = rds;
+ updateRdsPsOverlay(rds ?? null);
+ } catch (_) {}
+ });
+ spectrumSource.addEventListener("rds_vchan", (evt) => {
+ try {
+ const payload = evt.data === "null" ? [] : JSON.parse(evt.data);
+ const next = new Map();
+ const nextSig = new Map();
+ if (Array.isArray(payload)) {
+ payload.forEach((entry) => {
+ if (entry && entry.id) {
+ next.set(entry.id, entry.rds ?? null);
+ if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
+ }
+ });
+ }
+ vchanRdsById = next;
+ vchanSignalDbById = nextSig;
+ if (typeof vchanActiveId !== "undefined" && vchanActiveId && nextSig.has(vchanActiveId)) {
+ sigLastDbm = nextSig.get(vchanActiveId);
+ refreshSigStrengthDisplay();
+ }
+ updateRdsPsOverlay(primaryRds);
+ } catch (_) {}
+ });
+ spectrumSource.onerror = () => {
+ rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected"));
+ if (spectrumSource) {
+ spectrumSource.close();
+ spectrumSource = null;
+ }
+ scheduleSpectrumReconnect();
+ };
+}
+
+function stopSpectrumStreaming() {
+ if (spectrumSource !== null) {
+ spectrumSource.close();
+ spectrumSource = null;
+ }
+ if (spectrumReconnectTimer !== null) {
+ clearTimeout(spectrumReconnectTimer);
+ spectrumReconnectTimer = null;
+ }
+ spectrumDrawPending = false;
+ lastSpectrumData = null;
+ lastSpectrumRenderData = null;
+ rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped"));
+ clearSpectrumPeakHoldFrames();
+ overviewWaterfallRows = [];
+ overviewWaterfallPushCount = 0;
+ overviewWfResetTextureCache();
+ spectrumWfRows = [];
+ spectrumWfPushCount = 0;
+ spectrumWfTexReady = false;
+ scheduleOverviewDraw();
+ updateRdsPsOverlay(null);
+ clearSpectrumCanvas();
+}
+
+// ββ /meter (fast signal-strength) streaming βββββββββββββββββββββββββββββββββ
+// Dedicated SSE channel pushed at ~30 Hz by trx-server; bypasses /events so
+// meter frames are never gated by full-RigState diffing.
+//
+// Client-side asymmetric EMA smoothing (GQRX-style ballistics):
+// attack Ο β 400 ms β rises in ~12 frames at 30 Hz
+// decay Ο β 1.0 s β falls in ~30 frames, readable
+// DOM updates are coalesced via requestAnimationFrame so the bar
+// animates at display refresh rate, not SSE rate.
+const METER_ATTACK_ALPHA = 0.08; // per-frame at ~30 Hz β 400 ms Ο
+const METER_DECAY_ALPHA = 0.03; // per-frame at ~30 Hz β 1.0 s Ο
+let meterSmoothedDbm = null;
+let meterRafPending = false;
+
+function scheduleMeterReconnect() {
+ if (meterReconnectTimer !== null) return;
+ meterReconnectTimer = setTimeout(() => {
+ meterReconnectTimer = null;
+ startMeterStreaming();
+ }, 1000);
+}
+
+function applyMeterSample(dbm) {
+ if (typeof dbm !== "number" || !Number.isFinite(dbm)) return;
+ // Asymmetric EMA: fast attack, slow decay.
+ if (meterSmoothedDbm === null) {
+ meterSmoothedDbm = dbm;
+ } else {
+ const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA;
+ meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm);
+ }
+ // Coalesce DOM writes to display refresh rate.
+ if (!meterRafPending) {
+ meterRafPending = true;
+ requestAnimationFrame(flushMeterDom);
+ }
+}
+
+function flushMeterDom() {
+ meterRafPending = false;
+ const dbm = meterSmoothedDbm;
+ if (dbm === null) return;
+ prevRenderData.sigDbm = dbm;
+ const sUnits = dbmToSUnits(dbm);
+ sigLastSUnits = sUnits;
+ sigLastDbm = dbm;
+ const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
+ if (signalBar) signalBar.style.width = `${pct}%`;
+ if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
+ refreshSigStrengthDisplay();
+}
+
+function startMeterStreaming() {
+ if (meterSource !== null) return;
+ const url = lastActiveRigId
+ ? `/meter?remote=${encodeURIComponent(lastActiveRigId)}`
+ : "/meter";
+ meterSource = new EventSource(url);
+ meterSource.onmessage = (evt) => {
+ try {
+ const { sig } = JSON.parse(evt.data);
+ applyMeterSample(sig);
+ } catch (_) {}
+ };
+ meterSource.onerror = () => {
+ if (meterSource) {
+ meterSource.close();
+ meterSource = null;
+ }
+ scheduleMeterReconnect();
+ };
+}
+
+function stopMeterStreaming() {
+ if (meterSource !== null) {
+ meterSource.close();
+ meterSource = null;
+ }
+ if (meterReconnectTimer !== null) {
+ clearTimeout(meterReconnectTimer);
+ meterReconnectTimer = null;
+ }
+ meterSmoothedDbm = null; // reset so next rig starts fresh
+}
+
+// ββ Rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function clearSpectrumCanvas() {
+ if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
+ const cssW = spectrumCanvas.clientWidth || 1;
+ const cssH = spectrumCanvas.clientHeight || 1;
+ spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1);
+ spectrumGl.clear(cssColorToRgba(spectrumBgColor()));
+ if (spectrumDbAxis) {
+ spectrumDbAxis.replaceChildren();
+ spectrumDbAxisKey = "";
+ }
+}
+
+function formatOverlayPs(ps) {
+ return String(ps ?? "")
+ .slice(0, 8)
+ .padEnd(8, "_")
+ .replaceAll(" ", "_");
+}
+
+function formatPsHtml(ps) {
+ const clipped = String(ps ?? "").slice(0, 8);
+ let html = "";
+ for (let i = 0; i < 8; i += 1) {
+ const ch = clipped[i];
+ if (ch == null || ch === " ") {
+ html += `_ `;
+ } else {
+ html += escapeMapHtml(ch);
+ }
+ }
+ return html;
+}
+
+function formatOverlayPi(pi) {
+ return pi != null
+ ? `PI 0x${pi.toString(16).toUpperCase().padStart(4, "0")}`
+ : "PI --";
+}
+
+function formatOverlayPty(pty, ptyName) {
+ if (ptyName) return ptyName;
+ return pty != null ? String(pty) : "--";
+}
+
+function overlayTrafficFlagHtml(label, active) {
+ const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
+ return `${label} `;
+}
+
+function formatRdsFlag(value, yes = "Yes", no = "No") {
+ if (value == null) return "--";
+ return value ? yes : no;
+}
+
+function formatRdsAudio(value) {
+ if (value == null) return "--";
+ return value ? "Music" : "Speech";
+}
+
+function formatMinuteTimestamp(date = new Date()) {
+ const yyyy = date.getFullYear();
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
+ const dd = String(date.getDate()).padStart(2, "0");
+ const hh = String(date.getHours()).padStart(2, "0");
+ const min = String(date.getMinutes()).padStart(2, "0");
+ return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
+}
+
+function buildRdsRawPayload(rds) {
+ const freqHz = activeChannelFreqHz();
+ return {
+ time: formatMinuteTimestamp(),
+ freq_hz: Number.isFinite(freqHz) ? Math.round(freqHz) : null,
+ ...rds,
+ };
+}
+
+function formatRdsAfMHz(hz) {
+ return `${(hz / 1_000_000).toFixed(1)} MHz`;
+}
+
+function tuneRdsAlternativeFrequency(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return;
+ const targetHz = Math.round(hz);
+ setRigFrequency(targetHz);
+ showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200);
+}
+
+function renderRdsAlternativeFrequencies(list) {
+ const afEl = document.getElementById("rds-af-list");
+ if (!afEl) return;
+ const afs = Array.isArray(list)
+ ? list
+ .filter((hz) => Number.isFinite(hz) && hz > 0)
+ .map((hz) => Math.round(hz))
+ : [];
+ const afKey = afs.join(",");
+ if (!afs.length) {
+ if (afEl.dataset.afKey === "") return;
+ afEl.dataset.afKey = "";
+ afEl.textContent = "--";
+ return;
+ }
+ if (afEl.dataset.afKey === afKey) return;
+ afEl.dataset.afKey = afKey;
+ afEl.replaceChildren();
+ for (const hz of afs) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "rds-af-btn";
+ btn.dataset.hz = String(hz);
+ btn.textContent = formatRdsAfMHz(hz);
+ afEl.appendChild(btn);
+ }
+ if (!afEl.childElementCount) afEl.textContent = "--";
+}
+
+async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) {
+ const rds = rdsOverride || activeChannelRds();
+ const ps = rds?.program_service;
+ if (!rds || !ps || ps.length === 0) {
+ showHint("No RDS PS", 1200);
+ return;
+ }
+ const freqHz = Number.isFinite(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz();
+ const freqMhz = Number.isFinite(freqHz) ? (Math.round((freqHz / 100_000)) / 10).toFixed(1) : "--.-";
+ const piHex = rds.pi != null
+ ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}`
+ : "--";
+ const clipPs = formatOverlayPs(ps);
+ const clipText = `${formatMinuteTimestamp()} - ${freqMhz} MHz - ${piHex} - ${clipPs}`;
+ try {
+ await navigator.clipboard.writeText(clipText);
+ showHint("RDS copied", 1200);
+ } catch (_) {
+ showHint("Clipboard failed", 1500);
+ }
+}
+
+async function copyRdsRawToClipboard() {
+ const rawEl = document.getElementById("rds-raw");
+ const rawText = rawEl?.textContent ?? "";
+ if (!rawText || rawText === "--") {
+ showHint("No RDS JSON", 1200);
+ return;
+ }
+ try {
+ await navigator.clipboard.writeText(rawText);
+ showHint("RDS JSON copied", 1200);
+ } catch (_) {
+ showHint("Clipboard failed", 1500);
+ }
+}
+
+const rdsPsValueEl = document.getElementById("rds-ps");
+if (rdsPsValueEl) {
+ rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); });
+}
+const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
+if (rdsRawCopyBtn) {
+ rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); });
+}
+const rdsAfListEl = document.getElementById("rds-af-list");
+if (rdsAfListEl) {
+ rdsAfListEl.addEventListener("click", (event) => {
+ const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null;
+ const hz = Number(btn?.dataset?.hz);
+ if (btn && Number.isFinite(hz)) {
+ tuneRdsAlternativeFrequency(hz);
+ }
+ });
+}
+
+function updateRdsPsOverlay(rds) {
+ primaryRds = rds || null;
+ const activeRds = activeChannelRds();
+ updateDocumentTitle(activeRds);
+ renderRdsOverlays();
+
+ // RDS debug panel
+ const statusEl = document.getElementById("rds-status");
+ const modeEl = document.getElementById("rds-mode");
+ const piEl = document.getElementById("rds-pi");
+ const psEl = document.getElementById("rds-ps");
+ const ptyEl = document.getElementById("rds-pty");
+ const ptyNameEl = document.getElementById("rds-pty-name");
+ const ptynEl = document.getElementById("rds-ptyn");
+ const tpEl = document.getElementById("rds-tp");
+ const taEl = document.getElementById("rds-ta");
+ const musicEl = document.getElementById("rds-music");
+ const stereoEl = document.getElementById("rds-stereo");
+ const compEl = document.getElementById("rds-compressed");
+ const headEl = document.getElementById("rds-artificial-head");
+ const dynPtyEl = document.getElementById("rds-dynamic-pty");
+ const afEl = document.getElementById("rds-af-list");
+ const rtEl = document.getElementById("rds-radio-text");
+ const rawEl = document.getElementById("rds-raw");
+ if (!statusEl) return;
+
+ // Always show the current mode, frame counter, and a sanitised spectrum snapshot
+ if (modeEl) modeEl.textContent = document.getElementById("mode")?.value || "--";
+
+ if (!activeRds) {
+ statusEl.textContent = "No signal";
+ statusEl.className = "rds-value rds-no-signal";
+ piEl.textContent = "--";
+ psEl.textContent = "--";
+ ptyEl.textContent = "--";
+ ptyNameEl.textContent = "--";
+ if (ptynEl) ptynEl.textContent = "--";
+ if (tpEl) tpEl.textContent = "--";
+ if (taEl) taEl.textContent = "--";
+ if (musicEl) musicEl.textContent = "--";
+ if (stereoEl) stereoEl.textContent = "--";
+ if (compEl) compEl.textContent = "--";
+ if (headEl) headEl.textContent = "--";
+ if (dynPtyEl) dynPtyEl.textContent = "--";
+ if (afEl) afEl.textContent = "--";
+ if (rtEl) rtEl.textContent = "--";
+ if (rawEl && lastSpectrumData) {
+ const { bins: _b, ...rest } = lastSpectrumData;
+ const freqHz = activeChannelFreqHz();
+ rawEl.textContent = JSON.stringify({
+ time: formatMinuteTimestamp(),
+ freq_hz: Number.isFinite(freqHz) ? Math.round(freqHz) : null,
+ ...rest,
+ }, null, 2);
+ }
+ return;
+ }
+
+ statusEl.textContent = "Decoding";
+ statusEl.className = "rds-value rds-decoding";
+ piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--";
+ if (psEl) {
+ if (activeRds.program_service) {
+ psEl.innerHTML = formatPsHtml(activeRds.program_service);
+ } else {
+ psEl.textContent = "--";
+ }
+ }
+ ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--");
+ ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--";
+ if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--";
+ if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program);
+ if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement);
+ if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music);
+ if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo);
+ if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed);
+ if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head);
+ if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty);
+ renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz);
+ if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--";
+ rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2);
+}
+
+window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds);
+
+function scheduleSpectrumDraw() {
+ if (spectrumDrawPending) return;
+ spectrumDrawPending = true;
+ requestAnimationFrame(() => {
+ spectrumDrawPending = false;
+ if (lastSpectrumRenderData) {
+ drawSpectrum(lastSpectrumRenderData);
+ if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
+ if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw();
+ }
+ });
+}
+
+function drawSpectrum(data) {
+ if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
+
+ const dpr = _cachedDpr;
+ const cssW = _cachedSpectrumCssW;
+ const cssH = _cachedSpectrumCssH;
+ spectrumGl.ensureSize(cssW, cssH, dpr);
+ const W = spectrumCanvas.width;
+ const H = spectrumCanvas.height;
+
+ const pal = canvasPalette();
+ const range = spectrumVisibleRange(data);
+ const bins = data.bins;
+ const peakHoldBins = buildSpectrumPeakHoldBins(bins);
+ const n = bins.length;
+
+ spectrumGl.clear(cssColorToRgba(pal.bg));
+ if (!n) return;
+
+ const DB_MIN = spectrumFloor;
+ const DB_MAX = spectrumFloor + spectrumRange;
+ const dbRange = DB_MAX - DB_MIN;
+ const fullSpanHz = data.sample_rate;
+ const loHz = data.center_hz - fullSpanHz / 2;
+
+ const gridStep = spectrumRange > 100 ? 20 : 10;
+ spectrumTmpGridSegments.length = 0;
+ for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
+ const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
+ spectrumTmpGridSegments.push(0, y, W, y);
+ }
+ spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
+ updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
+
+ function hzToX(hz) {
+ return ((hz - range.visLoHz) / range.visSpanHz) * W;
+ }
+ function binX(i) {
+ return hzToX(loHz + (i / (n - 1)) * fullSpanHz);
+ }
+ function binYFromBins(srcBins, i) {
+ const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i]));
+ return H * (1 - (db - DB_MIN) / dbRange);
+ }
+
+ spectrumTmpFillPoints.length = 0;
+ for (let i = 0; i < n; i++) {
+ spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i));
+ }
+ spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill));
+
+ if (isBinsArray(peakHoldBins) && peakHoldBins.length === n) {
+ spectrumTmpPeakPoints.length = 0;
+ for (let i = 0; i < n; i++) {
+ spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
+ }
+ spectrumGl.drawPolyline(spectrumTmpPeakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9));
+ }
+
+ spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr));
+
+ // ββ Noise floor reference line ββ
+ const noiseDb = estimateNoiseFloorDb(bins);
+ if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) {
+ const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange));
+ const nfSegments = [];
+ const dashLen = Math.max(4, Math.round(6 * dpr));
+ const gapLen = Math.max(3, Math.round(5 * dpr));
+ for (let x = 0; x < W; x += dashLen + gapLen) {
+ nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY);
+ }
+ spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8));
+ }
+
+ const markerPeaks = visibleSpectrumPeakIndices(data);
+ if (markerPeaks.length > 0) {
+ spectrumTmpMarkerPoints.length = 0;
+ for (const idx of markerPeaks) {
+ spectrumTmpMarkerPoints.push(binX(idx), binYFromBins(bins, idx));
+ }
+ spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
+ }
+
+ // ββ Crosshair lines ββ
+ if (spectrumCrosshairX != null && spectrumCrosshairY != null) {
+ const cx = spectrumCrosshairX * dpr;
+ const cy = spectrumCrosshairY * dpr;
+ const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5);
+ spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6));
+ spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6));
+ }
+
+ // ββ Zoom indicator ββ
+ if (_spectrumZoomEl) {
+ if (spectrumZoom > 1.01) {
+ _spectrumZoomEl.textContent = spectrumZoom.toFixed(1) + "x";
+ _spectrumZoomEl.style.display = "block";
+ } else {
+ _spectrumZoomEl.style.display = "none";
+ }
+ }
+
+ // ββ Zoom minimap ββ
+ if (_spectrumMinimapEl) {
+ if (spectrumZoom > 1.01) {
+ _spectrumMinimapEl.style.display = "block";
+ const viewFrac = 1 / spectrumZoom;
+ const halfVis = viewFrac / 2;
+ const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
+ const viewL = panClamped - halfVis;
+ const viewR = panClamped + halfVis;
+ if (_spectrumMinimapInner) {
+ _spectrumMinimapInner.style.left = (viewL * 100) + "%";
+ _spectrumMinimapInner.style.width = ((viewR - viewL) * 100) + "%";
+ }
+ } else {
+ _spectrumMinimapEl.style.display = "none";
+ }
+ }
+
+ updateSpectrumFreqAxis(range);
+ updateBookmarkAxis(range);
+ updateBandplanStrip(range); // use precise spectrum range when available
+ drawSignalOverlay();
+}
+
+// ββ Full waterfall panel below spectrum βββββββββββββββββββββββββββββββββββββββ
+const spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas");
+const spectrumWaterfallGl = (typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas)
+ ? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions)
+ : null;
+let spectrumWfRows = [];
+let spectrumWfPushCount = 0;
+let spectrumWfTexData = null;
+let spectrumWfTexWidth = 0;
+let spectrumWfTexHeight = 0;
+let spectrumWfTexPushCount = 0;
+let spectrumWfTexPalKey = "";
+let spectrumWfTexReady = false;
+let spectrumWfDrawPending = false;
+const SPECTRUM_WF_TEX_MAX_W = 1024;
+
+// Cached DOM references for drawSpectrum (avoid getElementById per frame).
+const _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator");
+const _spectrumMinimapEl = document.getElementById("spectrum-minimap");
+const _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null;
+
+// Cached canvas dimensions (updated on resize instead of reading clientWidth/clientHeight per frame).
+let _cachedSpectrumCssW = 640, _cachedSpectrumCssH = 160;
+let _cachedSpecWfCssW = 640, _cachedSpecWfCssH = 120;
+let _cachedDpr = window.devicePixelRatio || 1;
+
+function _updateCachedCanvasSizes() {
+ _cachedDpr = window.devicePixelRatio || 1;
+ if (spectrumCanvas) {
+ _cachedSpectrumCssW = spectrumCanvas.clientWidth || 640;
+ _cachedSpectrumCssH = spectrumCanvas.clientHeight || 160;
+ }
+ if (spectrumWaterfallCanvas) {
+ _cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640;
+ _cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120;
+ }
+}
+// Refresh on resize; also called from scheduleSpectrumLayout.
+window.addEventListener("resize", _updateCachedCanvasSizes);
+// Initial read.
+_updateCachedCanvasSizes();
+
+function pushSpectrumWaterfallFrame(data) {
+ if (!spectrumWaterfallCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return;
+ spectrumWfRows.push(data.bins.slice());
+ spectrumWfPushCount++;
+ trimSpectrumWaterfallRows();
+ scheduleSpectrumWaterfallDraw();
+}
+
+function trimSpectrumWaterfallRows() {
+ if (!spectrumWaterfallCanvas) return;
+ const maxRows = Math.max(1, Math.floor(_cachedSpecWfCssH * _cachedDpr));
+ if (spectrumWfRows.length > maxRows) {
+ spectrumWfRows.splice(0, spectrumWfRows.length - maxRows);
+ }
+}
+
+function scheduleSpectrumWaterfallDraw() {
+ if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return;
+ spectrumWfDrawPending = true;
+ requestAnimationFrame(() => {
+ spectrumWfDrawPending = false;
+ drawSpectrumWaterfall();
+ });
+}
+
+function drawSpectrumWaterfall() {
+ if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return;
+ if (!lastSpectrumData || spectrumWfRows.length === 0) return;
+
+ const dpr = _cachedDpr;
+ const cssW = _cachedSpecWfCssW;
+ const cssH = _cachedSpecWfCssH;
+ spectrumWaterfallGl.ensureSize(cssW, cssH, dpr);
+ const W = spectrumWaterfallCanvas.width;
+ const H = spectrumWaterfallCanvas.height;
+ if (W <= 0 || H <= 0) return;
+
+ const pal = canvasPalette();
+ const maxVisible = Math.max(1, Math.floor(H));
+ const rows = spectrumWfRows.slice(-maxVisible);
+ if (rows.length === 0) return;
+
+ const iW = Math.max(96, Math.min(SPECTRUM_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 = spectrumVisibleRange(lastSpectrumData);
+ const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
+ const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
+ const rowStride = iW * 4;
+ const expectedSize = iW * iH * 4;
+ const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
+ const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH;
+ const palChanged = spectrumWfTexPalKey !== palKey;
+ const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0;
+ let texUpdated = false;
+
+ if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) {
+ spectrumWfTexData = new Uint8Array(expectedSize);
+ }
+ spectrumWfTexWidth = iW;
+ spectrumWfTexHeight = 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(spectrumWfTexData, rowBase + x * 4, srcBins[binIdx]);
+ }
+ }
+
+ if (needsFull) {
+ for (let y = 0; y < iH; y++) renderRow(y, rows[y]);
+ spectrumWfTexPushCount = spectrumWfPushCount;
+ spectrumWfTexPalKey = 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;
+ spectrumWfTexData.copyWithin(0, shiftBytes);
+ const startRow = iH - newCount;
+ for (let y = startRow; y < iH; y++) renderRow(y, rows[y]);
+ }
+ spectrumWfTexPushCount = spectrumWfPushCount;
+ spectrumWfTexPalKey = palKey;
+ texUpdated = true;
+ }
+
+ if (texUpdated || !spectrumWfTexReady) {
+ spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, spectrumWfTexData, "linear");
+ spectrumWfTexReady = true;
+ }
+ spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
+}
+
+function bmHexToRgba(hex, alpha) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r},${g},${b},${alpha})`;
+}
+
+// WCAG relative luminance; threshold 0.4 splits well across the palette.
+function bmLuminance(hex) {
+ const lin = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
+ const r = lin(parseInt(hex.slice(1, 3), 16) / 255);
+ const g = lin(parseInt(hex.slice(3, 5), 16) / 255);
+ const b = lin(parseInt(hex.slice(5, 7), 16) / 255);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function bmContrastFg(bgHex) {
+ return bmLuminance(bgHex) >= 0.4 ? "#1a202c" : "#ffffff";
+}
+
+// Read a theme CSS colour variable from the live theme and return it as a hex string.
+function bmResolveThemeColor(name, fallbackHex) {
+ const val = getComputedStyle(document.documentElement)
+ .getPropertyValue(name).trim();
+ if (/^#[0-9a-f]{6}$/i.test(val)) return val;
+ if (/^#[0-9a-f]{3}$/i.test(val))
+ return "#" + [...val.slice(1)].map((c) => c + c).join("");
+ const m = val.match(/\d+/g);
+ if (m && m.length >= 3)
+ return "#" + m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, "0")).join("");
+ return fallbackHex;
+}
+
+function bmBlendHex(aHex, bHex, ratio = 0.5) {
+ const mix = Math.max(0, Math.min(1, Number.isFinite(ratio) ? ratio : 0.5));
+ const aR = parseInt(aHex.slice(1, 3), 16);
+ const aG = parseInt(aHex.slice(3, 5), 16);
+ const aB = parseInt(aHex.slice(5, 7), 16);
+ const bR = parseInt(bHex.slice(1, 3), 16);
+ const bG = parseInt(bHex.slice(3, 5), 16);
+ const bB = parseInt(bHex.slice(5, 7), 16);
+ const toHex = (value) => Math.round(value).toString(16).padStart(2, "0");
+ return "#" + [
+ aR + (bR - aR) * mix,
+ aG + (bG - aG) * mix,
+ aB + (bB - aB) * mix,
+ ].map(toHex).join("");
+}
+
+function bmThemePalette() {
+ const yellow = bmResolveThemeColor("--accent-yellow", "#f0ad4e");
+ const green = bmResolveThemeColor("--accent-green", "#c24b1a");
+ const red = bmResolveThemeColor("--accent-red", "#e55353");
+ const heading = bmResolveThemeColor("--text-heading", "#c6d5ea");
+ const border = bmResolveThemeColor("--border-light", "#304766");
+ return [
+ yellow,
+ bmBlendHex(yellow, heading, 0.28),
+ bmBlendHex(yellow, green, 0.45),
+ bmBlendHex(green, heading, 0.22),
+ bmBlendHex(yellow, red, 0.42),
+ bmBlendHex(red, heading, 0.18),
+ bmBlendHex(border, yellow, 0.58),
+ bmBlendHex(border, heading, 0.5),
+ ];
+}
+
+// Returns a map of category β hex colour, including "" for uncategorised.
+function bmCategoryColorMap() {
+ const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : [];
+ const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort();
+ const palette = bmThemePalette();
+ const map = { "": palette[0] };
+ cats.forEach((cat, i) => { map[cat] = palette[(i + 1) % palette.length]; });
+ return map;
+}
+
+function createBookmarkChip(bm, colorMap, options = {}) {
+ const span = document.createElement("span");
+ const freqStr = typeof bmFmtFreq === "function"
+ ? bmFmtFreq(bm.freq_hz) : bm.freq_hz + "\u202fHz";
+ const esc = (s) => String(s)
+ .replace(/&/g, "&").replace(//g, ">");
+ span.className = "spectrum-bookmark-chip";
+ if (options.sideStack) {
+ span.classList.add("spectrum-bookmark-chip-side");
+ } else {
+ // Keep main in-band bookmark chips pinned at the very top of the spectrum strip.
+ span.style.top = "2px";
+ }
+ span.title = buildBookmarkTooltipText(bm) || (bm.name + " \u2014 " + freqStr + (bm.comment ? "\n" + bm.comment : ""));
+ span.dataset.bmId = bm.id;
+ const labelHtml = options.sideStack
+ ? (
+ `` +
+ `` +
+ " " +
+ ` ` +
+ `${escapeMapHtml(freqStr)} ` +
+ ` ` +
+ `${escapeMapHtml(bm.name)} `
+ )
+ : (
+ "" +
+ " " +
+ " \u00a0" + escapeMapHtml(bm.name) + " "
+ );
+ span.innerHTML =
+ labelHtml;
+ const col = colorMap[bm.category || ""];
+ span.style.setProperty("--bm-cat-bg", col);
+ span.style.setProperty("--bm-cat-fg", bmContrastFg(col));
+ span.addEventListener("click", () => {
+ if (typeof bmApply === "function") bmApply(bm);
+ });
+ return span;
+}
+
+function updateSideBookmarkStack(container, bookmarks, colorMap) {
+ if (!container) return;
+ const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0;
+ const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : "";
+ if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
+ if (container.dataset.bmKey) {
+ container.replaceChildren();
+ container.dataset.bmKey = "";
+ }
+ container.classList.remove("bm-side-visible");
+ return;
+ }
+
+ if (container.dataset.bmKey !== nextKey) {
+ container.dataset.bmKey = nextKey;
+ container.replaceChildren();
+ for (const bm of bookmarks) {
+ container.appendChild(createBookmarkChip(bm, colorMap, { sideStack: true }));
+ }
+ }
+
+ container.classList.add("bm-side-visible");
+}
+
+function updateBookmarkAxis(range) {
+ const axisEl = document.getElementById("spectrum-bookmark-axis");
+ const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
+ const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
+ if (!axisEl) return;
+
+ const _bmRef = typeof bmOverlayList !== "undefined" ? bmOverlayList : null;
+ const allBookmarks = Array.isArray(_bmRef) ? _bmRef : [];
+ const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
+ const leftBookmarks = allBookmarks
+ .filter((bm) => bm.freq_hz < range.visLoHz)
+ .sort((a, b) => b.freq_hz - a.freq_hz)
+ .slice(0, 3);
+ const rightBookmarks = allBookmarks
+ .filter((bm) => bm.freq_hz > range.visHiHz)
+ .sort((a, b) => a.freq_hz - b.freq_hz)
+ .slice(0, 3);
+ const colorMap = bmCategoryColorMap();
+
+ updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
+ updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
+
+ const hasVisible = visBookmarks.length > 0;
+ axisEl.classList.toggle("bm-axis-visible", hasVisible);
+
+ if (!hasVisible) {
+ if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; }
+ return;
+ }
+
+ // Only rebuild DOM when the set of visible bookmarks changes.
+ // Positions are always updated to handle pan/zoom smoothly.
+ const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 0;
+ const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`;
+ if (axisEl.dataset.bmKey !== newKey) {
+ axisEl.dataset.bmKey = newKey;
+ axisEl.replaceChildren();
+ for (const bm of visBookmarks) {
+ axisEl.appendChild(createBookmarkChip(bm, colorMap));
+ }
+ }
+
+ // Always recompute horizontal positions (pan/zoom changes frac every frame).
+ const axisWidth = axisEl.clientWidth || 0;
+ const edgePad = 8;
+ const spans = axisEl.querySelectorAll(":scope > span");
+ // Batch all offsetWidth reads before any writes to avoid layout thrashing.
+ const widths = [];
+ for (let i = 0; i < spans.length; i++) widths.push(spans[i].offsetWidth || 0);
+ visBookmarks.forEach((bm, i) => {
+ const span = spans[i];
+ if (!span) return;
+ const frac = (bm.freq_hz - range.visLoHz) / range.visSpanHz;
+ if (axisWidth > 0) {
+ const lw = widths[i];
+ const clamped = Math.max(edgePad + lw / 2, Math.min(axisWidth - edgePad - lw / 2, frac * axisWidth));
+ span.style.transform = `translateX(${clamped - lw / 2}px)`;
+ } else {
+ span.style.left = (frac * 100).toFixed(2) + "%";
+ }
+ });
+}
+
+function updateSpectrumFreqAxis(range) {
+ if (!spectrumFreqAxis) return;
+ const spanHz = range.visSpanHz;
+ // Pick a step that gives ~5 labels
+ const targets = [100, 200, 500, 1e3, 2e3, 5e3, 10e3, 20e3, 50e3,
+ 100e3, 200e3, 500e3, 1e6, 2e6, 5e6, 10e6];
+ const ideal = spanHz / 5;
+ const stepHz = targets.reduce((best, s) =>
+ Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, targets[0]);
+ const axisKey = [
+ Math.round(range.visLoHz),
+ Math.round(range.visHiHz),
+ Math.round(stepHz),
+ spectrumFreqAxis.clientWidth || 0,
+ ].join(":");
+ if (axisKey === spectrumAxisKey) return;
+ spectrumAxisKey = axisKey;
+
+ const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz;
+ const leftShiftBtn = document.getElementById("spectrum-center-left-btn");
+ const rightShiftBtn = document.getElementById("spectrum-center-right-btn");
+ spectrumFreqAxis.replaceChildren();
+ if (leftShiftBtn) spectrumFreqAxis.appendChild(leftShiftBtn);
+ if (rightShiftBtn) spectrumFreqAxis.appendChild(rightShiftBtn);
+ const axisWidth = spectrumFreqAxis.clientWidth || 0;
+ const buttonReserve = Math.max(
+ leftShiftBtn?.offsetWidth || 0,
+ rightShiftBtn?.offsetWidth || 0,
+ 0,
+ );
+ const edgePad = Math.max(6, buttonReserve + 10);
+ for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) {
+ const frac = (hz - range.visLoHz) / range.visSpanHz;
+ if (frac < 0 || frac > 1) continue;
+ const label = hz >= 1e6
+ ? (hz / 1e6).toFixed(stepHz < 1e6 ? (stepHz < 100e3 ? 3 : 1) : 0) + " M"
+ : hz >= 1e3
+ ? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k"
+ : hz.toFixed(0);
+ const span = document.createElement("span");
+ span.textContent = label;
+ spectrumFreqAxis.appendChild(span);
+ const labelWidth = span.offsetWidth || 0;
+ if (axisWidth > 0 && labelWidth > 0) {
+ const minCenter = edgePad + labelWidth / 2;
+ const maxCenter = axisWidth - edgePad - labelWidth / 2;
+ const desiredCenter = frac * axisWidth;
+ const clampedCenter = Math.max(minCenter, Math.min(maxCenter, desiredCenter));
+ span.style.left = `${clampedCenter}px`;
+ } else {
+ span.style.left = (frac * 100).toFixed(2) + "%";
+ }
+ }
+}
+
+function updateSpectrumDbAxis(dbMin, dbMax, gridStep, heightPx, dpr) {
+ if (!spectrumDbAxis) return;
+ const key = [
+ Math.round(dbMin),
+ Math.round(dbMax),
+ Math.round(gridStep),
+ Math.round(heightPx),
+ Math.round((dpr || 1) * 100),
+ currentTheme(),
+ currentStyle(),
+ ].join(":");
+ if (key === spectrumDbAxisKey) return;
+ spectrumDbAxisKey = key;
+ spectrumDbAxis.replaceChildren();
+
+ const spanDb = Math.max(1, dbMax - dbMin);
+ const cssHeight = heightPx / Math.max(1, dpr || 1);
+ for (let db = Math.ceil(dbMin / gridStep) * gridStep; db <= dbMax; db += gridStep) {
+ const yPx = Math.round(heightPx * (1 - (db - dbMin) / spanDb));
+ const yCss = yPx / Math.max(1, dpr || 1);
+ if (yCss <= 7 || yCss >= cssHeight - 4) continue;
+ const span = document.createElement("span");
+ span.textContent = `${db}`;
+ span.style.top = `${yCss}px`;
+ spectrumDbAxis.appendChild(span);
+ }
+}
+
+
+// ββ Screenshot module (extracted to screenshot.js, loaded on demand) ββββββββ
+// Spectrum screenshot capture (~245 lines) moved to screenshot.js.
+
+function shouldIgnoreGlobalShortcut(target) {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ if (target.isContentEditable) return true;
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
+ return !!target.closest("[contenteditable='true']");
+}
+
+// ββ Shortcut help overlay βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function toggleShortcutOverlay() {
+ const el = document.getElementById("shortcut-overlay");
+ if (!el) return;
+ el.classList.toggle("is-hidden");
+}
+function hideShortcutOverlay() {
+ const el = document.getElementById("shortcut-overlay");
+ if (el) el.classList.add("is-hidden");
+}
+function isShortcutOverlayVisible() {
+ const el = document.getElementById("shortcut-overlay");
+ return el && !el.classList.contains("is-hidden");
+}
+document.addEventListener("DOMContentLoaded", () => {
+ const overlay = document.getElementById("shortcut-overlay");
+ if (overlay) overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) hideShortcutOverlay();
+ });
+});
+
+window.addEventListener("keydown", (event) => {
+ if (event.defaultPrevented || event.repeat || event.isComposing) return;
+
+ const key = (event.key || "").toLowerCase();
+
+ // F1 β toggle shortcut help
+ if (event.key === "F1") {
+ event.preventDefault();
+ toggleShortcutOverlay();
+ return;
+ }
+
+ // Escape β close shortcut overlay if open
+ if (event.key === "Escape" && isShortcutOverlayVisible()) {
+ event.preventDefault();
+ hideShortcutOverlay();
+ return;
+ }
+
+ // F β focus frequency input
+ if (key === "f" && !event.ctrlKey && !event.metaKey && !event.altKey && !shouldIgnoreGlobalShortcut(event.target)) {
+ event.preventDefault();
+ const fi = document.getElementById("freq");
+ if (fi) { fi.focus(); fi.select(); }
+ return;
+ }
+
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ if (shouldIgnoreGlobalShortcut(event.target)) return;
+
+ // S β spectrum screenshot (lazy-loads screenshot.js on first use)
+ if (key === "s") {
+ event.preventDefault();
+ if (window.trx.modules.screenshot) {
+ void window.trx.modules.screenshot.captureSpectrumScreenshot();
+ } else {
+ const s = document.createElement("script");
+ s.src = "/screenshot.js";
+ s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); };
+ document.body.appendChild(s);
+ }
+ return;
+ }
+
+ // R β round frequency to nearest jog step boundary
+ if (key === "r") {
+ event.preventDefault();
+ if (lastLocked) { showHint("Locked", 1500); return; }
+ if (lastFreqHz != null) {
+ const step = Math.max(1, jogStep);
+ const rounded = Math.round(lastFreqHz / step) * step;
+ if (rounded !== lastFreqHz) {
+ if (!freqAllowed(rounded)) { showUnsupportedFreqPopup(rounded); return; }
+ setRigFrequency(rounded);
+ showHint(`Rounded β ${formatFreq(rounded)}`, 1200);
+ } else {
+ showHint("Already on step", 1200);
+ }
+ }
+ return;
+ }
+
+ // B β jump to previous frequency/bw/mode/decode state
+ if (key === "b") {
+ event.preventDefault();
+ void restorePreviousTuneState();
+ return;
+ }
+
+ // [ β narrow bandwidth by 10 kHz
+ if (key === "[") {
+ event.preventDefault();
+ const [, minBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
+ const next = Math.max(minBw, currentBandwidthHz - 10_000);
+ if (next !== currentBandwidthHz) {
+ currentBandwidthHz = next;
+ window.currentBandwidthHz = currentBandwidthHz;
+ syncBandwidthInput(next);
+ positionFastOverlay(lastFreqHz, next);
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ postPath(`/set_bandwidth?hz=${next}`).catch(() => {});
+ showHint(`BW ${formatBwLabel(next)}`, 1200);
+ }
+ return;
+ }
+
+ // ] β widen bandwidth by 10 kHz
+ if (key === "]") {
+ event.preventDefault();
+ const [, , maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
+ const next = Math.min(maxBw, currentBandwidthHz + 10_000);
+ if (next !== currentBandwidthHz) {
+ currentBandwidthHz = next;
+ window.currentBandwidthHz = currentBandwidthHz;
+ syncBandwidthInput(next);
+ positionFastOverlay(lastFreqHz, next);
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ postPath(`/set_bandwidth?hz=${next}`).catch(() => {});
+ showHint(`BW ${formatBwLabel(next)}`, 1200);
+ }
+ return;
+ }
+
+ // Left/Right arrows β retune by current jog step
+ if (key === "arrowleft" || key === "arrowright") {
+ event.preventDefault();
+ jogFreq(key === "arrowright" ? 1 : -1);
+ return;
+ }
+
+ // Up/Down arrows β shift center (spectrum) frequency
+ if (key === "arrowup" || key === "arrowdown") {
+ event.preventDefault();
+ void shiftSpectrumCenter(key === "arrowup" ? 1 : -1);
+ return;
+ }
+
+ // M β open mode picker
+ if (key === "m") {
+ event.preventDefault();
+ if (modeEl && !modeEl.disabled) {
+ modeEl.focus();
+ modeEl.click();
+ // Attempt to programmatically open the via showPicker (modern browsers)
+ if (typeof modeEl.showPicker === "function") {
+ try { modeEl.showPicker(); } catch (_) {}
+ }
+ }
+ return;
+ }
+
+ // Z β toggle mono/stereo (WFM)
+ if (key === "z") {
+ event.preventDefault();
+ if (wfmAudioModeEl) {
+ const next = wfmAudioModeEl.value === "mono" ? "stereo" : "mono";
+ wfmAudioModeEl.value = next;
+ saveSetting("wfmAudioMode", next);
+ const enabled = next !== "mono";
+ postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => {});
+ showHint(next === "stereo" ? "Stereo" : "Mono", 1200);
+ } else {
+ showHint("Stereo N/A", 1200);
+ }
+ return;
+ }
+
+ // N β toggle noise blanker
+ if (key === "n") {
+ event.preventDefault();
+ if (sdrNbSupported && sdrNbEnabledEl) {
+ sdrNbEnabledEl.checked = !sdrNbEnabledEl.checked;
+ submitSdrNbState();
+ showHint(sdrNbEnabledEl.checked ? "NB On" : "NB Off", 1200);
+ } else {
+ showHint("NB N/A", 1200);
+ }
+ return;
+ }
+
+ // Q β toggle squelch (cycle 0 β auto β 0)
+ if (key === "q") {
+ event.preventDefault();
+ if (sdrSquelchSupported && sdrSquelchEl) {
+ const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
+ let nextPct;
+ if (current > 0) {
+ nextPct = 0; // turn off
+ } else {
+ // Auto: estimate from noise floor
+ let auto = 30;
+ const data = lastSpectrumData || window.lastSpectrumData;
+ if (data && isBinsArray(data.bins) && data.bins.length > 0) {
+ const noiseDb = estimateNoiseFloorDb(data.bins);
+ if (noiseDb != null && Number.isFinite(noiseDb)) {
+ const thresholdDb = noiseDb + 6;
+ const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
+ auto = clampSdrSquelchPercent(
+ ((clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB)) * 100,
+ );
+ }
+ }
+ nextPct = auto;
+ }
+ sdrSquelchEl.value = String(nextPct);
+ updateSdrSquelchPctLabel();
+ saveSetting("sdrSquelchPct", nextPct);
+ submitSdrSquelchPercent(nextPct);
+ showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
+ } else {
+ showHint("Squelch N/A", 1200);
+ }
+ return;
+ }
+
+ // Spectrum keyboard navigation
+ if (lastSpectrumData && spectrumCanvas) {
+ // +/= β zoom in
+ if (key === "+" || key === "=") {
+ event.preventDefault();
+ const cssW = spectrumCanvas.clientWidth || 640;
+ spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25);
+ scheduleSpectrumDraw();
+ scheduleOverviewDraw();
+ return;
+ }
+ // - β zoom out
+ if (key === "-") {
+ event.preventDefault();
+ const cssW = spectrumCanvas.clientWidth || 640;
+ spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25);
+ scheduleSpectrumDraw();
+ scheduleOverviewDraw();
+ return;
+ }
+ // 0 β reset zoom
+ if (key === "0") {
+ event.preventDefault();
+ spectrumZoom = 1;
+ spectrumPanFrac = 0.5;
+ scheduleSpectrumDraw();
+ scheduleOverviewDraw();
+ return;
+ }
+ }
+}, { capture: true });
+
+// ββ Zoom helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function spectrumZoomAt(cssX, cssW, data, factor) {
+ const range = spectrumVisibleRange(data);
+ const hzAtCursor = canvasXToHz(cssX, cssW, range);
+ const frac = cssX / cssW;
+ spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor));
+ // Recompute so the pixel under the cursor keeps the same frequency
+ const newVisSpan = data.sample_rate / spectrumZoom;
+ const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan;
+ const loHz = data.center_hz - data.sample_rate / 2;
+ spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate;
+}
+
+// ββ Scroll to zoom ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function handleSpectrumWheel(e, canvasEl) {
+ e.preventDefault();
+ if (!lastSpectrumData || !canvasEl) return;
+ if (e.ctrlKey) {
+ const direction = e.deltaY < 0 ? 1 : -1;
+ jogFreq(direction);
+ return;
+ }
+ const rect = canvasEl.getBoundingClientRect();
+ const cssX = e.clientX - rect.left;
+ const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
+ spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor);
+ scheduleSpectrumDraw();
+ scheduleOverviewDraw();
+}
+
+function handleSpectrumClick(e, canvasEl) {
+ if (_sDragMoved) {
+ _sDragMoved = false;
+ return;
+ }
+ if (!lastSpectrumData || !canvasEl) return;
+ const rect = canvasEl.getBoundingClientRect();
+ const cssX = e.clientX - rect.left;
+ const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData);
+ if (!Number.isFinite(targetHz)) return;
+ setRigFrequency(targetHz);
+}
+
+if (spectrumCanvas) {
+ spectrumCanvas.addEventListener("wheel", (e) => {
+ handleSpectrumWheel(e, spectrumCanvas);
+ }, { passive: false });
+}
+
+// Keep waterfall (overview strip) wheel behavior aligned with waveform/spectrum.
+if (overviewCanvas) {
+ overviewCanvas.addEventListener("wheel", (e) => {
+ handleSpectrumWheel(e, overviewCanvas);
+ }, { passive: false });
+ overviewCanvas.addEventListener("click", (e) => {
+ handleSpectrumClick(e, overviewCanvas);
+ });
+}
+
+// Full waterfall panel interactions.
+if (spectrumWaterfallCanvas) {
+ spectrumWaterfallCanvas.addEventListener("wheel", (e) => {
+ handleSpectrumWheel(e, spectrumWaterfallCanvas);
+ }, { passive: false });
+ spectrumWaterfallCanvas.addEventListener("click", (e) => {
+ handleSpectrumClick(e, spectrumWaterfallCanvas);
+ });
+ spectrumWaterfallCanvas.addEventListener("mousedown", (e) => {
+ onSpectrumMouseDown(e, spectrumWaterfallCanvas);
+ });
+}
+
+
+// ββ BW strip edge hit-test (CSS pixels) ββββββββββββββββββββββββββββββββββββββ
+function getBwEdgeHit(cssX, cssW, range) {
+ const bwCenterHz = activeBandwidthCenterHz();
+ if (!Number.isFinite(bwCenterHz) || !currentBandwidthHz || !lastSpectrumData) return null;
+
+ const HIT = 8;
+ let bestEdge = null;
+ let bestDist = Number.POSITIVE_INFINITY;
+ for (const spec of visibleBandwidthSpecs(bwCenterHz)) {
+ const span = displaySpanForBandwidthSpec(spec);
+ const xL = ((span.loHz - range.visLoHz) / range.visSpanHz) * cssW;
+ const xR = ((span.hiHz - range.visLoHz) / range.visSpanHz) * cssW;
+ if (span.side < 0) {
+ const distL = Math.abs(cssX - xL);
+ if (distL < HIT && distL < bestDist) {
+ bestEdge = "left";
+ bestDist = distL;
+ }
+ continue;
+ }
+ if (span.side > 0) {
+ const distR = Math.abs(cssX - xR);
+ if (distR < HIT && distR < bestDist) {
+ bestEdge = "right";
+ bestDist = distR;
+ }
+ continue;
+ }
+ const distL = Math.abs(cssX - xL);
+ const distR = Math.abs(cssX - xR);
+ if (distL < HIT && distL < bestDist) {
+ bestEdge = "left";
+ bestDist = distL;
+ }
+ if (distR < HIT && distR < bestDist) {
+ bestEdge = "right";
+ bestDist = distR;
+ }
+ }
+ if (bestEdge) return bestEdge;
+ return null;
+}
+
+// ββ Mouse drag to pan / BW resize βββββββββββββββββββββββββββββββββββββββββββββ
+let _sDragStart = null; // { clientX, panFrac }
+let _sDragMoved = false;
+let _sDragCanvas = null;
+
+function onSpectrumMouseDown(e, canvasEl) {
+ if (!canvasEl || e.button !== 0) return;
+ if (lastSpectrumData) {
+ const rect = canvasEl.getBoundingClientRect();
+ const cssX = e.clientX - rect.left;
+ const range = spectrumVisibleRange(lastSpectrumData);
+ const edge = getBwEdgeHit(cssX, rect.width, range);
+ if (edge) {
+ _bwDragEdge = edge;
+ _bwDragStartX = cssX;
+ _bwDragStartBwHz = currentBandwidthHz;
+ _bwDragCanvas = canvasEl;
+ _sDragStart = null;
+ _sDragCanvas = null;
+ _sDragMoved = true; // suppress click-to-tune
+ return;
+ }
+ }
+ _sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
+ _sDragCanvas = canvasEl;
+ _sDragMoved = false;
+}
+
+if (spectrumCanvas) {
+ spectrumCanvas.addEventListener("mousedown", (e) => { onSpectrumMouseDown(e, spectrumCanvas); });
+}
+if (overviewCanvas) {
+ overviewCanvas.addEventListener("mousedown", (e) => { onSpectrumMouseDown(e, overviewCanvas); });
+}
+
+if (spectrumCanvas || overviewCanvas) {
+ window.addEventListener("mousemove", (e) => {
+ if (_bwDragEdge && lastSpectrumData) {
+ const dragCanvas = _bwDragCanvas || spectrumCanvas;
+ if (!dragCanvas) return;
+ const rect = dragCanvas.getBoundingClientRect();
+ const cssX = e.clientX - rect.left;
+ const range = spectrumVisibleRange(lastSpectrumData);
+ const dxHz = ((cssX - _bwDragStartX) / rect.width) * range.visSpanHz;
+ const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB");
+ let newBw;
+ if (side === 0) {
+ newBw = _bwDragEdge === "right"
+ ? _bwDragStartBwHz + dxHz * 2
+ : _bwDragStartBwHz - dxHz * 2;
+ } else {
+ newBw = _bwDragEdge === "right"
+ ? _bwDragStartBwHz + dxHz
+ : _bwDragStartBwHz - dxHz;
+ }
+ const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
+ newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw)));
+ currentBandwidthHz = newBw;
+ window.currentBandwidthHz = currentBandwidthHz;
+ syncBandwidthInput(newBw);
+ positionFastOverlay(lastFreqHz, newBw);
+ scheduleSpectrumDraw();
+ scheduleOverviewDraw();
+ return;
+ }
+ if (!_sDragStart || !lastSpectrumData) return;
+ const dragCanvas = _sDragCanvas || spectrumCanvas || overviewCanvas;
+ if (!dragCanvas) return;
+ const rect = dragCanvas.getBoundingClientRect();
+ const dx = e.clientX - _sDragStart.clientX;
+ if (Math.abs(dx) > 3) _sDragMoved = true;
+ spectrumPanFrac = _sDragStart.panFrac - (dx / rect.width) / spectrumZoom;
+ scheduleSpectrumDraw();
+ });
+
+ window.addEventListener("mouseup", async () => {
+ if (_bwDragEdge) {
+ try {
+ const bwHz = Math.round(currentBandwidthHz);
+ if (!(typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bwHz))) {
+ await postPath(`/set_bandwidth?hz=${bwHz}`);
+ if (Number.isFinite(lastFreqHz)) {
+ await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
+ }
+ }
+ } catch (error) {
+ window.trxUi?.notify("Bandwidth could not be changed", {
+ kind: "error",
+ action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) },
+ });
+ }
+ _bwDragEdge = null;
+ _bwDragCanvas = null;
+ return;
+ }
+ _sDragStart = null;
+ _sDragCanvas = null;
+ });
+}
+
+// ββ Touch: pinch-to-zoom + single-finger pan ββββββββββββββββββββββββββββββββββ
+let _sTouch = null;
+
+if (spectrumCanvas) {
+ spectrumCanvas.addEventListener("touchstart", (e) => {
+ e.preventDefault();
+ if (e.touches.length === 2) {
+ const t0 = e.touches[0], t1 = e.touches[1];
+ _sTouch = {
+ type: "pinch",
+ dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY),
+ midX: (t0.clientX + t1.clientX) / 2,
+ zoom: spectrumZoom,
+ panFrac: spectrumPanFrac,
+ };
+ } else if (e.touches.length === 1) {
+ _sTouch = { type: "pan", clientX: e.touches[0].clientX, panFrac: spectrumPanFrac };
+ }
+ }, { passive: false });
+
+ spectrumCanvas.addEventListener("touchmove", (e) => {
+ e.preventDefault();
+ if (!_sTouch || !lastSpectrumData) return;
+ const rect = spectrumCanvas.getBoundingClientRect();
+ if (_sTouch.type === "pinch" && e.touches.length === 2) {
+ const t0 = e.touches[0], t1 = e.touches[1];
+ const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
+ const newMidX = (t0.clientX + t1.clientX) / 2;
+ const scale = newDist / _sTouch.dist;
+ const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale));
+ const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2;
+ // Compute Hz under original midpoint in original view
+ const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom;
+ const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2;
+ const midFrac = (_sTouch.midX - rect.left) / rect.width;
+ const midHz = oldVisLo + midFrac * oldVisSpan;
+ const newVisSpan = lastSpectrumData.sample_rate / newZoom;
+ const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan;
+ spectrumZoom = newZoom;
+ spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate;
+ // Pan contribution from mid shift
+ const dxMid = newMidX - _sTouch.midX;
+ spectrumPanFrac -= (dxMid / rect.width) / spectrumZoom;
+ scheduleSpectrumDraw();
+ } else if (_sTouch.type === "pan" && e.touches.length === 1) {
+ const dx = e.touches[0].clientX - _sTouch.clientX;
+ spectrumPanFrac = _sTouch.panFrac - (dx / rect.width) / spectrumZoom;
+ scheduleSpectrumDraw();
+ }
+ }, { passive: false });
+
+ spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; });
+}
+
+// ββ Hover tooltip + cursor ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+if (spectrumCanvas) {
+ spectrumCanvas.addEventListener("mousemove", (e) => {
+ if (!lastSpectrumData || !spectrumTooltip) return;
+ const rect = spectrumCanvas.getBoundingClientRect();
+ const cssX = e.clientX - rect.left;
+ const range = spectrumVisibleRange(lastSpectrumData);
+ // Change cursor when hovering near BW strip edges
+ const edge = getBwEdgeHit(cssX, rect.width, range);
+ spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
+ const hz = canvasXToHz(cssX, rect.width, range);
+ const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range);
+ const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData);
+ const peakHz = peak?.hz ?? null;
+ const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : null;
+ if (bookmark) {
+ spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark);
+ } else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) {
+ spectrumTooltip.textContent = peakDb
+ ? `Peak ${formatSpectrumFreq(peakHz)} Β· ${peakDb}`
+ : `Peak ${formatSpectrumFreq(peakHz)}`;
+ } else {
+ const baseText = formatSpectrumFreq(peakHz ?? hz);
+ spectrumTooltip.textContent = peakDb ? `${baseText} Β· ${peakDb}` : baseText;
+ }
+ spectrumTooltip.style.display = "block";
+ const tw = spectrumTooltip.offsetWidth;
+ let tx = cssX + 10;
+ if (tx + tw > rect.width) tx = cssX - tw - 10;
+ spectrumTooltip.style.left = tx + "px";
+ spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px";
+ // Update crosshair position
+ spectrumCrosshairX = cssX;
+ spectrumCrosshairY = e.clientY - rect.top;
+ scheduleSpectrumDraw();
+ });
+ spectrumCanvas.addEventListener("mouseleave", () => {
+ if (spectrumTooltip) spectrumTooltip.style.display = "none";
+ spectrumCanvas.style.cursor = "crosshair";
+ spectrumCrosshairX = null;
+ spectrumCrosshairY = null;
+ scheduleSpectrumDraw();
+ });
+}
+
+// ββ Click to tune (only when not dragging) ββββββββββββββββββββββββββββββββββββ
+if (spectrumCanvas) {
+ spectrumCanvas.addEventListener("click", (e) => {
+ handleSpectrumClick(e, spectrumCanvas);
+ });
+}
+
+if (spectrumCenterLeftBtn) {
+ spectrumCenterLeftBtn.addEventListener("click", () => {
+ shiftSpectrumCenter(-1).catch(() => {});
+ });
+}
+if (spectrumCenterRightBtn) {
+ spectrumCenterRightBtn.addEventListener("click", () => {
+ shiftSpectrumCenter(1).catch(() => {});
+ });
+}
+
+// ββ Spectrum floor input + Auto level ββββββββββββββββββββββββββββββββββββββββ
+(function () {
+ const floorInput = document.getElementById("spectrum-floor-input");
+ const autoBtn = document.getElementById("spectrum-auto-btn");
+
+ if (floorInput) {
+ floorInput.addEventListener("change", () => {
+ const v = Number(floorInput.value);
+ if (!isNaN(v)) {
+ spectrumFloor = v;
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ }
+ });
+ }
+
+ const rangeInput = document.getElementById("spectrum-range-input");
+ if (rangeInput) {
+ rangeInput.value = spectrumRange;
+ rangeInput.addEventListener("change", () => {
+ const v = Number(rangeInput.value);
+ if (!isNaN(v) && v >= 10) {
+ spectrumRange = v;
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ }
+ });
+ }
+
+ if (autoBtn) {
+ autoBtn.addEventListener("click", () => {
+ if (!lastSpectrumData) return;
+ const sorted = [...lastSpectrumData.bins].sort((a, b) => a - b);
+ // Use 15th-percentile as noise floor, peak for top
+ const noise = sorted[Math.floor(sorted.length * 0.15)];
+ const peak = sorted[sorted.length - 1];
+ spectrumFloor = Math.floor(noise / 10) * 10 - 10;
+ spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
+ if (floorInput) floorInput.value = spectrumFloor;
+ if (rangeInput) rangeInput.value = spectrumRange;
+ scheduleSpectrumDraw();
+ });
+ }
+
+ const gammaInput = document.getElementById("spectrum-gamma-input");
+ const gammaValue = document.getElementById("spectrum-gamma-value");
+ if (gammaInput) {
+ gammaInput.addEventListener("input", () => {
+ const v = Number(gammaInput.value);
+ if (Number.isFinite(v) && v > 0) {
+ waterfallGamma = v;
+ if (gammaValue) gammaValue.textContent = v.toFixed(1);
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ }
+ });
+ gammaInput.addEventListener("dblclick", () => {
+ waterfallGamma = 1.0;
+ gammaInput.value = "1.0";
+ if (gammaValue) gammaValue.textContent = "1.0";
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ });
+ }
+})();
+
+// ββ Bandplan strip ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+let bandplanData = null;
+let bandplanRegion = loadSetting("bandplanRegion", "off");
+let bandplanShowLabels = loadSetting("bandplanLabels", true);
+let _bandplanServerDefaultApplied = false;
+let bandplanSegmentsCache = null;
+let bandplanCacheKey = "";
+
+const bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
+const bandplanRegionSelect = document.getElementById("bandplan-region-select");
+const bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
+
+(function loadBandplanJson() {
+ fetch("/bandplan.json")
+ .then((r) => { if (!r.ok) throw new Error(r.status); return r.json(); })
+ .then((d) => { bandplanData = d; bandplanSegmentsCache = null; bandplanCacheKey = ""; })
+ .catch(() => {});
+})();
+
+if (bandplanRegionSelect) {
+ bandplanRegionSelect.value = bandplanRegion;
+ bandplanRegionSelect.addEventListener("change", () => {
+ bandplanRegion = bandplanRegionSelect.value;
+ saveSetting("bandplanRegion", bandplanRegion);
+ bandplanSegmentsCache = null;
+ bandplanCacheKey = "";
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ });
+}
+if (bandplanLabelsCheck) {
+ bandplanLabelsCheck.checked = bandplanShowLabels;
+ bandplanLabelsCheck.addEventListener("change", () => {
+ bandplanShowLabels = bandplanLabelsCheck.checked;
+ saveSetting("bandplanLabels", bandplanShowLabels);
+ bandplanSegmentsCache = null;
+ bandplanCacheKey = "";
+ if (lastSpectrumData) scheduleSpectrumDraw();
+ });
+}
+
+function bandplanComputeRange() {
+ // When spectrum data is available (SDR), use the zoomed visible range
+ if (lastSpectrumData) {
+ return spectrumVisibleRange(lastSpectrumData);
+ }
+ // For non-SDR rigs, derive a range from the current tuned frequency.
+ // Find the band containing the frequency and show that full band.
+ const freq = lastFreqHz;
+ if (!freq || !Number.isFinite(freq)) return null;
+
+ // Check bandplan data for the current region to find the matching band
+ if (bandplanData && bandplanData[bandplanRegion]) {
+ const bands = bandplanData[bandplanRegion].bands;
+ for (const band of bands) {
+ if (freq >= band.low_hz && freq <= band.high_hz) {
+ const margin = (band.high_hz - band.low_hz) * 0.05;
+ return {
+ visLoHz: band.low_hz - margin,
+ visHiHz: band.high_hz + margin,
+ visSpanHz: (band.high_hz - band.low_hz) + 2 * margin,
+ };
+ }
+ }
+ }
+ // Fallback: show a 500 kHz window around the frequency
+ const span = 500000;
+ return { visLoHz: freq - span / 2, visHiHz: freq + span / 2, visSpanHz: span };
+}
+
+function bandplanVisibleSegments(region, loHz, hiHz) {
+ if (!bandplanData || !bandplanData[region]) return [];
+ const bands = bandplanData[region].bands;
+ const result = [];
+ for (const band of bands) {
+ if (band.high_hz < loHz || band.low_hz > hiHz) continue;
+ for (const seg of band.segments) {
+ if (seg.high_hz <= loHz || seg.low_hz >= hiHz) continue;
+ result.push({
+ low_hz: seg.low_hz,
+ high_hz: seg.high_hz,
+ mode: seg.mode,
+ label: seg.label,
+ band: band.name,
+ });
+ }
+ }
+ return result;
+}
+
+function _hideBandplanStrip() {
+ if (!bandplanStripEl) return;
+ bandplanStripEl.classList.remove("bp-visible");
+ bandplanStripEl.replaceChildren();
+ bandplanCacheKey = "";
+}
+
+function updateBandplanStrip(range) {
+ if (!bandplanStripEl) return;
+ if (!range || bandplanRegion === "off" || !bandplanData) {
+ if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
+ return;
+ }
+
+ const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
+ if (segments.length === 0) {
+ if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip();
+ return;
+ }
+
+ bandplanStripEl.classList.add("bp-visible");
+
+ const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" +
+ segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
+
+ const stripW = bandplanStripEl.clientWidth || 1;
+
+ if (bandplanCacheKey !== newKey) {
+ bandplanCacheKey = newKey;
+ bandplanStripEl.replaceChildren();
+
+ const seenBands = new Set();
+ for (const seg of segments) {
+ const el = document.createElement("div");
+ el.className = "bp-segment";
+ el.dataset.mode = seg.mode;
+ el.title = seg.band + " \u2013 " + seg.label + " (" + seg.mode + ")";
+ if (bandplanShowLabels) {
+ const lbl = document.createElement("span");
+ lbl.className = "bp-segment-label";
+ lbl.textContent = seg.label;
+ el.appendChild(lbl);
+ }
+ bandplanStripEl.appendChild(el);
+
+ if (!seenBands.has(seg.band)) {
+ seenBands.add(seg.band);
+ const bandLbl = document.createElement("div");
+ bandLbl.className = "bp-band-label";
+ bandLbl.textContent = seg.band;
+ bandLbl.dataset.bandLow = seg.low_hz;
+ bandplanStripEl.appendChild(bandLbl);
+ }
+ }
+ bandplanSegmentsCache = segments;
+ }
+
+ const children = bandplanStripEl.querySelectorAll(".bp-segment");
+ const bandLabels = bandplanStripEl.querySelectorAll(".bp-band-label");
+ const segs = bandplanSegmentsCache || segments;
+
+ segs.forEach((seg, i) => {
+ const el = children[i];
+ if (!el) return;
+ const l = Math.max(0, (seg.low_hz - range.visLoHz) / range.visSpanHz);
+ const r = Math.min(1, (seg.high_hz - range.visLoHz) / range.visSpanHz);
+ const leftPx = l * stripW;
+ const widthPx = Math.max(1, (r - l) * stripW);
+ el.style.left = leftPx + "px";
+ el.style.width = widthPx + "px";
+
+ const lbl = el.querySelector(".bp-segment-label");
+ if (lbl) {
+ lbl.style.display = widthPx < 20 ? "none" : "";
+ }
+ });
+
+ bandLabels.forEach((lbl) => {
+ const bandLow = Number(lbl.dataset.bandLow);
+ const frac = (bandLow - range.visLoHz) / range.visSpanHz;
+ const px = Math.max(2, frac * stripW);
+ lbl.style.left = px + "px";
+ lbl.style.display = (frac < -0.1 || frac > 1.05) ? "none" : "";
+ });
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js
new file mode 100644
index 00000000..08f42b6e
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js
@@ -0,0 +1,180 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
+const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
+
+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 textDecoder ? textDecoder.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 decode history payload");
+ }
+ return value;
+}
+
+async function fetchAndDecodeHistory(url, batchLimit) {
+ self.postMessage({ type: "status", phase: "fetching" });
+ const resp = await fetch(url, { credentials: "same-origin" });
+ if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
+ const payload = await resp.arrayBuffer();
+ if (!payload || payload.byteLength === 0) {
+ self.postMessage({ type: "start", total: 0 });
+ self.postMessage({ type: "done", total: 0 });
+ return;
+ }
+
+ self.postMessage({ type: "status", phase: "decoding" });
+ const history = decodeCborPayload(payload);
+ const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
+ const items = history && Array.isArray(history[key]) ? history[key] : [];
+ return sum + items.length;
+ }, 0);
+ self.postMessage({ type: "start", total });
+
+ let processed = 0;
+ const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
+
+ for (const kind of HISTORY_GROUP_KEYS) {
+ const items = history && Array.isArray(history[kind]) ? history[kind] : [];
+ if (items.length === 0) continue;
+ for (let index = 0; index < items.length; index += safeLimit) {
+ const messages = items.slice(index, index + safeLimit);
+ processed += messages.length;
+ self.postMessage({
+ type: "group",
+ kind,
+ messages,
+ processed,
+ total,
+ });
+ }
+ }
+ self.postMessage({ type: "done", total });
+}
+
+self.onmessage = (event) => {
+ const data = event?.data || {};
+ if (data?.type !== "fetch-history") return;
+ fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
+ .catch((err) => {
+ self.postMessage({
+ type: "error",
+ message: err && err.message ? err.message : String(err || "unknown worker failure"),
+ });
+ });
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js
new file mode 100644
index 00000000..ad0d13b3
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js
@@ -0,0 +1,120 @@
+(function() {
+ if (typeof L === "undefined") return;
+
+ function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ function finiteAngle(value) {
+ if (!Number.isFinite(value)) return null;
+ const normalized = ((Number(value) % 360) + 360) % 360;
+ return normalized;
+ }
+
+ function svgColor(value, fallback) {
+ const text = String(value || fallback || "");
+ return text.replace(/"/g, """);
+ }
+
+ function buildSymbolHtml(options, zoom) {
+ const heading = finiteAngle(options.heading);
+ const course = finiteAngle(options.course);
+ const angle = heading != null ? heading : course;
+ const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
+ const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
+ const color = svgColor(options.color, "#ff7559");
+ const outline = svgColor(options.outline, "#6b2118");
+
+ const body = angle != null
+ ? `` +
+ ` ` +
+ ` `
+ : ` `;
+
+ const courseLine = course != null
+ ? `` +
+ ` ` +
+ ` `
+ : "";
+
+ return (
+ `` +
+ courseLine +
+ body +
+ ` `
+ );
+ }
+
+ L.TrxAisTrackSymbol = L.Marker.extend({
+ options: {
+ heading: null,
+ course: null,
+ speed: null,
+ color: "#ff7559",
+ outline: "#6b2118",
+ size: 22,
+ interactive: true,
+ keyboard: true,
+ riseOnHover: true,
+ },
+
+ initialize: function(latlng, options) {
+ const merged = L.Util.extend({}, this.options, options || {});
+ merged.icon = L.divIcon({
+ className: "trx-ais-track-symbol-icon",
+ html: "",
+ iconSize: [merged.size, merged.size],
+ iconAnchor: [merged.size / 2, merged.size / 2],
+ });
+ L.Marker.prototype.initialize.call(this, latlng, merged);
+ },
+
+ onAdd: function(map) {
+ L.Marker.prototype.onAdd.call(this, map);
+ this._refreshIcon();
+ this._boundZoomRefresh = this._refreshIcon.bind(this);
+ map.on("zoomend", this._boundZoomRefresh);
+ },
+
+ onRemove: function(map) {
+ if (this._boundZoomRefresh) {
+ map.off("zoomend", this._boundZoomRefresh);
+ this._boundZoomRefresh = null;
+ }
+ L.Marker.prototype.onRemove.call(this, map);
+ },
+
+ setAisState: function(next) {
+ if (next && typeof next === "object") {
+ if ("heading" in next) this.options.heading = next.heading;
+ if ("course" in next) this.options.course = next.course;
+ if ("speed" in next) this.options.speed = next.speed;
+ if ("color" in next) this.options.color = next.color;
+ if ("outline" in next) this.options.outline = next.outline;
+ }
+ this._refreshIcon();
+ return this;
+ },
+
+ _refreshIcon: function() {
+ if (!this._icon) return;
+ const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
+ const html = buildSymbolHtml(this.options, zoom);
+ this._icon.innerHTML = html;
+ const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ this._icon.style.width = `${size}px`;
+ this._icon.style.height = `${size}px`;
+ this._icon.style.marginLeft = `${-size / 2}px`;
+ this._icon.style.marginTop = `${-size / 2}px`;
+ },
+ });
+
+ L.trxAisTrackSymbol = function(latlng, options) {
+ return new L.TrxAisTrackSymbol(latlng, options);
+ };
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js
new file mode 100644
index 00000000..2e69dd8b
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js
@@ -0,0 +1,3515 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// Map, statistics, and geolocation module (lazy-loaded on map tab activation).
+// Communicates with app.js through explicit state/core/module services.
+(function () {
+ "use strict";
+ const { state: T, core: C, modules } = window.trx;
+
+ // Destructure shared utility functions for convenience
+ const { saveSetting, loadSetting, showHint, escapeMapHtml, formatFreq, formatFreqForHumans,
+ postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor,
+ formatUptime, latLonToMaidenhead, locatorToLatLon, haversineKm,
+ formatDistanceKm, formatTimeAgo, currentDecodeHistoryRetentionMs,
+ formatWavelength, bookmarkDistanceText, buildBookmarkTooltipText,
+ nearestBookmarkForHz } = C;
+
+ function updateMapRigFilter() {
+ const el = document.getElementById("map-rig-filter");
+ if (!el) return;
+ const prev = el.value;
+ while (el.options.length > 1) el.remove(1);
+ for (const id of T.lastRigIds) {
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = T.lastRigDisplayNames[id] || id;
+ el.appendChild(opt);
+ }
+ if (prev && T.lastRigIds.includes(prev)) {
+ el.value = prev;
+ } else {
+ el.value = "";
+ mapRigFilter = "";
+ }
+ updateStatsRigFilter();
+ }
+
+ // --- Leaflet Map (lazy-initialized) ---
+ let aprsMap = null;
+ let aprsMapBaseLayer = null;
+ let aprsMapReceiverMarker = null;
+ let aprsMapReceiverMarkers = {}; // keyed by rig remote id
+ let aprsRadioPaths = [];
+ let selectedLocatorMarker = null;
+ let selectedLocatorPulseRaf = null;
+ let mapFullscreenListenerBound = false;
+ let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false;
+ let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false;
+ let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false;
+ const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440];
+ const MAP_QSO_SUMMARY_LIMIT = 5;
+ const stationMarkers = new Map();
+ const locatorMarkers = new Map();
+ const decodeContactPaths = new Map();
+ let selectedMapQsoKey = null;
+ const mapMarkers = new Set();
+ const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
+ const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
+ const mapLocatorFilter = { phase: "band", bands: new Set() };
+ let mapSearchFilter = "";
+ let mapRigFilter = ""; // "" = all rigs
+ let mapHistoryPruneTimer = null;
+ let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(
+ Number(loadSetting("mapHistoryLimitMinutes", 1440))
+ );
+ const APRS_TRACK_MAX_POINTS = 64;
+ const AIS_TRACK_MAX_POINTS = 64;
+ const aisMarkers = new Map();
+ const vdesMarkers = new Map();
+ let selectedAprsTrackCall = null;
+ let selectedAisTrackMmsi = null;
+ const HAM_BANDS = [
+ { label: "2200m", meters: 2200 },
+ { label: "630m", meters: 630 },
+ { label: "160m", meters: 160 },
+ { label: "80m", meters: 80 },
+ { label: "60m", meters: 60 },
+ { label: "40m", meters: 40 },
+ { label: "30m", meters: 30 },
+ { label: "20m", meters: 20 },
+ { label: "17m", meters: 17 },
+ { label: "15m", meters: 15 },
+ { label: "12m", meters: 12 },
+ { label: "10m", meters: 10 },
+ { label: "6m", meters: 6 },
+ { label: "4m", meters: 4 },
+ { label: "3m", meters: 3 },
+ { label: "2m", meters: 2 },
+ { label: "1m", meters: 1 },
+ { label: "70cm", meters: 0.7 },
+ { label: "23cm", meters: 0.23 },
+ { label: "13cm", meters: 0.13 },
+ { label: "9cm", meters: 0.09 },
+ { label: "6cm", meters: 0.06 },
+ { label: "3cm", meters: 0.03 },
+ ].map((band) => ({
+ ...band,
+ nominalHz: 299_792_458 / band.meters,
+ }));
+
+ function normalizeLocatorFreqHz(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return null;
+ if (hz >= 100_000) return hz;
+ const baseHz = Number(window.ft8BaseHz);
+ if (Number.isFinite(baseHz) && baseHz > 0) {
+ return baseHz + hz;
+ }
+ return hz;
+ }
+
+ function normalizeMapHistoryLimitMinutes(value) {
+ const minutes = Math.round(Number(value));
+ return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440;
+ }
+
+ function mapHistoryCutoffMs() {
+ return Date.now() - (mapHistoryLimitMinutes * 60 * 1000);
+ }
+
+ function trimTrackHistory(history, cutoffMs, maxPoints) {
+ const list = Array.isArray(history) ? history : [];
+ const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs);
+ if (trimmed.length > maxPoints) {
+ trimmed.splice(0, trimmed.length - maxPoints);
+ }
+ return trimmed;
+ }
+
+ function refreshAprsTrack(call, entry) {
+ if (!entry) return;
+ if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
+ if (entry.track) {
+ entry.track.remove();
+ entry.track = null;
+ }
+ return;
+ }
+ if (entry.track) {
+ entry.track.setLatLngs(entry.trackPoints);
+ return;
+ }
+ const track = L.polyline(entry.trackPoints, {
+ color: "#f0be4d",
+ weight: 2,
+ opacity: 0.72,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ });
+ track.__trxType = "aprs";
+ track._aprsCall = call;
+ entry.track = track;
+ }
+
+ function refreshAisTrack(mmsi, entry) {
+ if (!entry) return;
+ if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
+ if (entry.track) {
+ entry.track.remove();
+ entry.track = null;
+ }
+ return;
+ }
+ if (entry.track) {
+ entry.track.setLatLngs(entry.trackPoints);
+ return;
+ }
+ const track = L.polyline(entry.trackPoints, {
+ color: getAisAccentColor(),
+ weight: 2,
+ opacity: 0.68,
+ lineCap: "round",
+ lineJoin: "round",
+ interactive: false,
+ dashArray: "5 4",
+ });
+ track.__trxType = "ais";
+ track._aisMmsi = mmsi;
+ entry.track = track;
+ }
+
+ function removeMapMarker(marker) {
+ if (!marker) return;
+ if (marker === selectedLocatorMarker) {
+ setSelectedLocatorMarker(null);
+ clearMapRadioPath();
+ }
+ if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap);
+ mapMarkers.delete(marker);
+ }
+
+ function setRetainedMapMarkerVisible(marker, visible) {
+ if (!marker) return;
+ marker.__trxHistoryVisible = visible !== false;
+ if (!visible) {
+ if (marker === selectedLocatorMarker) {
+ setSelectedLocatorMarker(null);
+ clearMapRadioPath();
+ }
+ if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap);
+ }
+ }
+
+ function ensureAprsMarker(call, entry) {
+ if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return;
+ _aprsAddMarkerToMap(call, entry);
+ }
+
+ function ensureAisMarker(key, entry) {
+ if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return;
+ const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg)
+ .addTo(aprsMap)
+ .bindPopup(buildAisPopupHtml(entry.msg));
+ marker.__trxType = "ais";
+ marker.__trxRigIds = entry.rigIds || new Set();
+ marker._aisMmsi = String(key);
+ entry.marker = marker;
+ mapMarkers.add(marker);
+ }
+
+ function ensureVdesMarker(key, entry) {
+ if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return;
+ const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], {
+ radius: 5,
+ color: "#5c394f",
+ fillColor: "#c46392",
+ fillOpacity: 0.82,
+ }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg));
+ marker.__trxType = "vdes";
+ marker.__trxRigIds = entry.rigIds || new Set();
+ marker._vdesKey = String(key);
+ entry.marker = marker;
+ mapMarkers.add(marker);
+ }
+
+ function ensureDecodeLocatorMarker(entry) {
+ if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return;
+ const bounds = maidenheadToBounds(entry.grid);
+ if (!bounds) return;
+ const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1);
+ const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType);
+ const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count))
+ .addTo(aprsMap)
+ .bindPopup(tooltipHtml);
+ marker.__trxType = entry.sourceType;
+ marker.__trxRigIds = entry.rigIds || new Set();
+ sendLocatorOverlayToBack(marker);
+ assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta);
+ entry.marker = marker;
+ mapMarkers.add(marker);
+ }
+
+ function pruneAprsEntry(call, entry, cutoffMs) {
+ const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive;
+ const pktTsMs = Number(entry?.pkt?._tsMs);
+ const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs;
+ entry.visibleInHistoryWindow = visible;
+ entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS)
+ .map((point) => [point.lat, point.lon]);
+ if (canRenderMap) {
+ refreshAprsTrack(call, entry);
+ } else {
+ C.markDecodeMapSyncPending();
+ }
+ if (!visible) {
+ if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
+ selectedAprsTrackCall = null;
+ }
+ if (canRenderMap && entry?.track) {
+ entry.track.remove();
+ entry.track = null;
+ }
+ if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false);
+ return false;
+ }
+ if (!canRenderMap) return true;
+ ensureAprsMarker(call, entry);
+ setRetainedMapMarkerVisible(entry?.marker, true);
+ if (entry?.marker) {
+ entry.marker.setLatLng([entry.lat, entry.lon]);
+ entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt));
+ }
+ return true;
+ }
+
+ function pruneAisEntry(key, entry, cutoffMs) {
+ const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive;
+ const msgTsMs = Number(entry?.msg?._tsMs);
+ const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs;
+ entry.visibleInHistoryWindow = visible;
+ entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS)
+ .map((point) => [point.lat, point.lon]);
+ if (canRenderMap) {
+ refreshAisTrack(key, entry);
+ } else {
+ C.markDecodeMapSyncPending();
+ }
+ if (!visible) {
+ if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
+ selectedAisTrackMmsi = null;
+ }
+ if (canRenderMap && entry?.track) {
+ entry.track.remove();
+ entry.track = null;
+ }
+ if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false);
+ return false;
+ }
+ if (!canRenderMap) return true;
+ ensureAisMarker(key, entry);
+ setRetainedMapMarkerVisible(entry?.marker, true);
+ if (entry?.marker) {
+ updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg));
+ }
+ return true;
+ }
+
+ function pruneLocatorEntry(key, entry, cutoffMs) {
+ const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive;
+ if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) return true;
+ if (!(entry.allStationDetails instanceof Map)) {
+ entry.allStationDetails = entry.stationDetails instanceof Map
+ ? new Map(entry.stationDetails)
+ : new Map();
+ }
+ const nextDetails = new Map();
+ for (const [detailKey, detail] of entry.allStationDetails.entries()) {
+ const tsMs = Number(detail?.ts_ms);
+ if (Number.isFinite(tsMs) && tsMs >= cutoffMs) {
+ nextDetails.set(detailKey, detail);
+ }
+ }
+ entry.visibleInHistoryWindow = nextDetails.size > 0;
+ if (nextDetails.size === 0) {
+ entry.stationDetails = new Map();
+ entry.stations = new Set();
+ entry.bandMeta = new Map();
+ if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false);
+ else C.markDecodeMapSyncPending();
+ return false;
+ }
+ const nextStations = new Set();
+ for (const detail of nextDetails.values()) {
+ const source = String(detail?.source || detail?.station || "").trim().toUpperCase();
+ if (source) nextStations.add(source);
+ }
+ entry.stationDetails = nextDetails;
+ entry.stations = nextStations;
+ entry.bandMeta = collectBandMeta(
+ Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz))
+ );
+ const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
+ if (!canRenderMap) {
+ C.markDecodeMapSyncPending();
+ return true;
+ }
+ ensureDecodeLocatorMarker(entry);
+ setRetainedMapMarkerVisible(entry.marker, true);
+ if (entry.marker) {
+ entry.marker.setStyle(locatorStyleForEntry(entry, count));
+ entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType));
+ assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
+ }
+ return true;
+ }
+
+ function pruneMapHistory() {
+ const cutoffMs = mapHistoryCutoffMs();
+ for (const [call, entry] of stationMarkers.entries()) {
+ pruneAprsEntry(call, entry, cutoffMs);
+ }
+ for (const [key, entry] of aisMarkers.entries()) {
+ pruneAisEntry(key, entry, cutoffMs);
+ }
+ for (const [key, entry] of vdesMarkers.entries()) {
+ const tsMs = Number(entry?.msg?._tsMs);
+ const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs;
+ entry.visibleInHistoryWindow = visible;
+ if (!visible) {
+ setRetainedMapMarkerVisible(entry?.marker, false);
+ continue;
+ }
+ ensureVdesMarker(key, entry);
+ setRetainedMapMarkerVisible(entry?.marker, true);
+ if (entry?.marker) {
+ entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]);
+ entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg));
+ }
+ }
+ for (const [key, entry] of locatorMarkers.entries()) {
+ pruneLocatorEntry(key, entry, cutoffMs);
+ }
+ if (!aprsMap || T.decodeHistoryReplayActive) {
+ C.markDecodeMapSyncPending();
+ return;
+ }
+ rebuildDecodeContactPaths();
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ }
+
+ function locatorSourceLabel(type) {
+ if (type === "bookmark") return "Bookmarks";
+ if (type === "wspr") return "WSPR";
+ if (type === "ft4") return "FT4";
+ if (type === "ft2") return "FT2";
+ return "FT8";
+ }
+
+ function mapSourceLabel(type) {
+ if (type === "bookmark") return "Bookmarks";
+ return String(type || "").toUpperCase();
+ }
+
+ function locatorFilterColor(type) {
+ const hues = locatorThemeHues();
+ const lightTheme = C.currentTheme() === "light";
+ const sat = lightTheme ? 66 : 76;
+ const light = lightTheme ? 42 : 56;
+ const hue = type === "bookmark"
+ ? hues.bookmark
+ : (type === "wspr" ? hues.wspr : (type === "ft4" ? hues.ft4 : (type === "ft2" ? hues.ft2 : hues.ft8)));
+ return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`;
+ }
+
+ function mapSourceColor(type) {
+ if (type === "ais") return "#38bdf8";
+ if (type === "vdes") return "#a78bfa";
+ if (type === "sat") return "#f59e0b";
+ if (type === "aprs") return "#00d17f";
+ return locatorFilterColor(type);
+ }
+
+ function bandForHz(hz) {
+ const rfHz = normalizeLocatorFreqHz(hz);
+ if (!Number.isFinite(rfHz) || rfHz <= 0) return null;
+ let bestBand = null;
+ let bestDistance = Infinity;
+ for (const band of HAM_BANDS) {
+ const distance = Math.abs(Math.log(rfHz / band.nominalHz));
+ if (distance < bestDistance) {
+ bestDistance = distance;
+ bestBand = band;
+ }
+ }
+ return bestBand;
+ }
+
+ function collectBandMeta(freqs) {
+ const out = new Map();
+ if (!Array.isArray(freqs)) return out;
+ for (const hz of freqs) {
+ const band = bandForHz(hz);
+ if (band && !out.has(band.label)) out.set(band.label, band.nominalHz);
+ }
+ return out;
+ }
+
+ function assignLocatorMarkerMeta(marker, sourceType, bandMeta) {
+ if (!marker) return;
+ const safeMeta = bandMeta instanceof Map ? bandMeta : new Map();
+ marker._locatorFilterMeta = {
+ sourceType,
+ bands: new Set(safeMeta.keys()),
+ bandMeta: new Map(safeMeta),
+ };
+ }
+
+ function parseMapColor(input) {
+ const value = String(input || "").trim();
+ if (!value) return null;
+ const hex = value.match(/^#([0-9a-f]{3,8})$/i);
+ if (hex) {
+ const raw = hex[1];
+ if (raw.length === 3 || raw.length === 4) {
+ const chars = raw.split("");
+ return {
+ r: parseInt(chars[0] + chars[0], 16),
+ g: parseInt(chars[1] + chars[1], 16),
+ b: parseInt(chars[2] + chars[2], 16),
+ };
+ }
+ if (raw.length === 6 || raw.length === 8) {
+ return {
+ r: parseInt(raw.slice(0, 2), 16),
+ g: parseInt(raw.slice(2, 4), 16),
+ b: parseInt(raw.slice(4, 6), 16),
+ };
+ }
+ }
+ const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i);
+ if (rgb) {
+ return {
+ r: Math.max(0, Math.min(255, Number(rgb[1]))),
+ g: Math.max(0, Math.min(255, Number(rgb[2]))),
+ b: Math.max(0, Math.min(255, Number(rgb[3]))),
+ };
+ }
+ return null;
+ }
+
+ function rgbToHsl(rgb) {
+ if (!rgb) return null;
+ const r = rgb.r / 255;
+ const g = rgb.g / 255;
+ const b = rgb.b / 255;
+ const max = Math.max(r, g, b);
+ const min = Math.min(r, g, b);
+ const l = (max + min) / 2;
+ if (max === min) {
+ return { h: 0, s: 0, l: l * 100 };
+ }
+ const d = max - min;
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
+ let h;
+ switch (max) {
+ case r:
+ h = ((g - b) / d) + (g < b ? 6 : 0);
+ break;
+ case g:
+ h = ((b - r) / d) + 2;
+ break;
+ default:
+ h = ((r - g) / d) + 4;
+ break;
+ }
+ return { h: (h * 60) % 360, s: s * 100, l: l * 100 };
+ }
+
+ function wrapHue(hue) {
+ const value = Number(hue) || 0;
+ return ((value % 360) + 360) % 360;
+ }
+
+ function paletteHue(input, fallback) {
+ const hsl = rgbToHsl(parseMapColor(input));
+ return Number.isFinite(hsl?.h) ? hsl.h : fallback;
+ }
+
+ function locatorThemeHues() {
+ const pal = C.canvasPalette();
+ const baseHue = paletteHue(pal?.spectrumLine, 145);
+ const waveHue = paletteHue(pal?.waveformLine, baseHue + 34);
+ const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42);
+ return {
+ bookmark: wrapHue(baseHue),
+ ft8: wrapHue(peakHue),
+ ft4: wrapHue(peakHue + 30),
+ ft2: wrapHue(peakHue + 60),
+ wspr: wrapHue((waveHue + baseHue) / 2),
+ bandBase: wrapHue((baseHue * 0.65) + (peakHue * 0.35)),
+ };
+ }
+
+ function locatorBandIndex(label) {
+ const idx = HAM_BANDS.findIndex((band) => band.label === label);
+ return idx >= 0 ? idx : 0;
+ }
+
+ function locatorBandChipColor(label) {
+ const hues = locatorThemeHues();
+ const lightTheme = C.currentTheme() === "light";
+ const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508);
+ const sat = lightTheme ? 68 : 78;
+ const light = lightTheme ? 44 : 58;
+ return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`;
+ }
+
+ function locatorBandLabelForEntry(entry) {
+ const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map();
+ if (meta.size === 0) return null;
+ if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) {
+ for (const label of mapLocatorFilter.bands) {
+ if (meta.has(label)) return label;
+ }
+ }
+ let bestLabel = null;
+ let bestHz = -Infinity;
+ for (const [label, hz] of meta.entries()) {
+ const value = Number.isFinite(hz) ? Number(hz) : 0;
+ if (value > bestHz) {
+ bestHz = value;
+ bestLabel = label;
+ }
+ }
+ return bestLabel;
+ }
+
+ function locatorHueForEntry(entry) {
+ const hues = locatorThemeHues();
+ if (mapLocatorFilter.phase === "band") {
+ const label = locatorBandLabelForEntry(entry);
+ if (label) {
+ return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508);
+ }
+ }
+ if (entry?.sourceType === "bookmark") return hues.bookmark;
+ if (entry?.sourceType === "wspr") return hues.wspr;
+ if (entry?.sourceType === "ft4") return hues.ft4;
+ if (entry?.sourceType === "ft2") return hues.ft2;
+ return hues.ft8;
+ }
+
+ function locatorStyleForEntry(entry, count) {
+ const safeCount = Math.max(1, Number.isFinite(count) ? count : 1);
+ const intensity = Math.min(1, Math.log2(safeCount + 1) / 5);
+ const hue = locatorHueForEntry(entry);
+ const lightTheme = C.currentTheme() === "light";
+ const strokeSat = lightTheme ? 62 : 74;
+ const fillSat = lightTheme ? 68 : 78;
+ const strokeLight = lightTheme ? 40 : 56;
+ const fillLight = lightTheme ? 60 : 42;
+ return {
+ color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`,
+ opacity: 0.42 + intensity * 0.5,
+ weight: 1 + intensity * 1.2,
+ fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`,
+ fillOpacity: 0.16 + intensity * 0.34,
+ };
+ }
+
+ function locatorEntryCount(entry) {
+ if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1);
+ if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1);
+ if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1);
+ return 1;
+ }
+
+ function locatorEntryForMarker(marker) {
+ if (!marker) return null;
+ for (const entry of locatorMarkers.values()) {
+ if (entry?.marker === marker) return entry;
+ }
+ return null;
+ }
+
+ function syncLocatorMarkerStyles() {
+ for (const entry of locatorMarkers.values()) {
+ if (!entry?.marker) continue;
+ entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry)));
+ }
+ for (const entry of decodeContactPaths.values()) {
+ if (!entry?.line) continue;
+ const color = decodeContactPathColor(entry);
+ entry.line.setStyle({ color, opacity: 0.78 });
+ }
+ }
+
+ function stopSelectedLocatorPulse() {
+ if (selectedLocatorPulseRaf != null) {
+ cancelAnimationFrame(selectedLocatorPulseRaf);
+ selectedLocatorPulseRaf = null;
+ }
+ }
+
+ function startSelectedLocatorPulse(marker) {
+ stopSelectedLocatorPulse();
+ if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return;
+ const tick = (ts) => {
+ if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) {
+ return;
+ }
+ const entry = locatorEntryForMarker(marker);
+ const base = locatorStyleForEntry(entry, locatorEntryCount(entry));
+ const phase = (ts % 1600) / 1600;
+ const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2;
+ marker.setStyle({
+ ...base,
+ opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave),
+ weight: (base.weight || 1.8) + 1.8 * wave,
+ });
+ selectedLocatorPulseRaf = requestAnimationFrame(tick);
+ };
+ selectedLocatorPulseRaf = requestAnimationFrame(tick);
+ }
+
+ function clearMapRadioPath() {
+ for (const p of aprsRadioPaths) p.remove();
+ aprsRadioPaths = [];
+ }
+
+ function clearDecodeContactPathRender(entry) {
+ if (!entry) return;
+ if (entry.line) {
+ entry.line.remove();
+ entry.line = null;
+ }
+ if (entry.labelMarker) {
+ entry.labelMarker.remove();
+ entry.labelMarker = null;
+ }
+ }
+
+ function clearDecodeContactPaths() {
+ for (const entry of decodeContactPaths.values()) {
+ clearDecodeContactPathRender(entry);
+ }
+ decodeContactPaths.clear();
+ updateMapPathsAnimationClass();
+ }
+
+ const MAP_PATHS_STATIC_THRESHOLD = 20;
+ function updateMapPathsAnimationClass() {
+ const mapEl = document.getElementById("aprs-map");
+ if (!mapEl) return;
+ mapEl.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD);
+ }
+
+ function formatDecodeContactDistance(distanceKm) {
+ const text = formatDistanceKm(distanceKm);
+ return text || "--";
+ }
+
+ function decodeLocatorPathVisibility(grid) {
+ const normalizedGrid = String(grid || "").trim().toUpperCase();
+ if (!normalizedGrid || !aprsMap) return false;
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || entry.grid !== normalizedGrid) continue;
+ if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue;
+ if (entry.marker && aprsMap.hasLayer(entry.marker)) return true;
+ }
+ return false;
+ }
+
+ function midpointLatLon(a, b) {
+ if (!a || !b) return null;
+ if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) {
+ return null;
+ }
+ return {
+ lat: (a.lat + b.lat) / 2,
+ lon: (a.lon + b.lon) / 2,
+ };
+ }
+
+ function decodeContactPathColor(entry) {
+ if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel);
+ const srcEntry = locatorMarkers.get(entry?.sourceGrid);
+ if (srcEntry) {
+ const label = locatorBandLabelForEntry(srcEntry);
+ if (label) return locatorBandChipColor(label);
+ return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color;
+ }
+ return locatorFilterColor("ft8");
+ }
+
+ function ensureDecodeContactPathRendered(entry) {
+ if (!entry || !aprsMap) return;
+ const linePoints = [
+ [entry.from.lat, entry.from.lon],
+ [entry.to.lat, entry.to.lon],
+ ];
+ const color = decodeContactPathColor(entry);
+ if (!entry.line) {
+ entry.line = L.polyline(linePoints, {
+ color,
+ opacity: 0.78,
+ className: "decode-contact-path",
+ weight: 2.8,
+ interactive: false,
+ }).addTo(aprsMap);
+ } else {
+ entry.line.setLatLngs(linePoints);
+ entry.line.setStyle({ color, opacity: 0.78 });
+ if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap);
+ }
+ const mid = midpointLatLon(entry.from, entry.to);
+ if (!mid) return;
+ const title = `${entry.source} β ${entry.target} Β· ${entry.distanceText}`;
+ const icon = L.divIcon({
+ className: "decode-contact-distance-label",
+ html: `${escapeMapHtml(entry.distanceText)} `,
+ });
+ if (!entry.labelMarker) {
+ entry.labelMarker = L.marker([mid.lat, mid.lon], {
+ icon,
+ interactive: false,
+ keyboard: false,
+ zIndexOffset: 900,
+ }).addTo(aprsMap);
+ } else {
+ entry.labelMarker.setLatLng([mid.lat, mid.lon]);
+ entry.labelMarker.setIcon(icon);
+ if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap);
+ }
+ if (typeof entry.line.bringToBack === "function") entry.line.bringToBack();
+ }
+
+ function decodeContactPathMatchesCurrentMap(entry) {
+ return decodeLocatorPathVisibility(entry.sourceGrid)
+ && decodeLocatorPathVisibility(entry.targetGrid);
+ }
+
+ function decodeContactPathRenderVisible(entry) {
+ return mapDecodeContactPathsEnabled
+ && decodeContactPathMatchesCurrentMap(entry);
+ }
+
+ function syncDecodeContactPathVisibility() {
+ if (selectedMapQsoKey) {
+ const selectedEntry = decodeContactPaths.get(selectedMapQsoKey);
+ if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) {
+ selectedMapQsoKey = null;
+ }
+ }
+ for (const entry of decodeContactPaths.values()) {
+ const visible = decodeContactPathRenderVisible(entry)
+ && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey);
+ if (!visible) {
+ clearDecodeContactPathRender(entry);
+ continue;
+ }
+ ensureDecodeContactPathRendered(entry);
+ }
+ scheduleStatsRender();
+ updateMapPathsAnimationClass();
+ }
+
+ function _resolveReceiverLocations(rigIds) {
+ // Return all unique receiver locations for the given rig(s)
+ const seen = new Set();
+ const locations = [];
+ if (rigIds && rigIds.size) {
+ for (const rid of rigIds) {
+ const rig = T.serverRigs.find(r => r.remote === rid);
+ if (rig && rig.latitude != null && rig.longitude != null) {
+ const key = _receiverLocationKey(rig.latitude, rig.longitude);
+ if (!seen.has(key)) {
+ seen.add(key);
+ locations.push([rig.latitude, rig.longitude]);
+ }
+ }
+ }
+ }
+ // Fall back to active rig location if no specific locations found
+ if (locations.length === 0 && T.serverLat != null && T.serverLon != null) {
+ locations.push([T.serverLat, T.serverLon]);
+ }
+ return locations;
+ }
+
+ function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) {
+ clearMapRadioPath();
+ if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) {
+ return;
+ }
+ const sources = _resolveReceiverLocations(rigIds);
+ for (const src of sources) {
+ aprsRadioPaths.push(
+ L.polyline(
+ [src, [lat, lon]],
+ { color, opacity: 0.85, weight: 2, interactive: false, className }
+ ).addTo(aprsMap)
+ );
+ }
+ }
+
+ function locatorMarkerCenter(marker) {
+ if (!marker) return null;
+ if (typeof marker.getBounds === "function") {
+ const bounds = marker.getBounds();
+ if (bounds && typeof bounds.getCenter === "function") {
+ const center = bounds.getCenter();
+ if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) {
+ return { lat: center.lat, lon: center.lng };
+ }
+ }
+ }
+ if (typeof marker.getLatLng === "function") {
+ const ll = marker.getLatLng();
+ if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) {
+ return { lat: ll.lat, lon: ll.lng };
+ }
+ }
+ return null;
+ }
+
+ function setLocatorMarkerHighlight(marker, enabled) {
+ const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path;
+ if (!element) return;
+ element.classList.toggle("trx-locator-selected", !!enabled);
+ }
+
+ function setSelectedLocatorMarker(marker) {
+ if (selectedLocatorMarker && selectedLocatorMarker !== marker) {
+ setLocatorMarkerHighlight(selectedLocatorMarker, false);
+ const prevEntry = locatorEntryForMarker(selectedLocatorMarker);
+ if (prevEntry?.marker) {
+ prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry)));
+ }
+ }
+ stopSelectedLocatorPulse();
+ selectedLocatorMarker = marker || null;
+ if (selectedLocatorMarker) {
+ setLocatorMarkerHighlight(selectedLocatorMarker, true);
+ startSelectedLocatorPulse(selectedLocatorMarker);
+ }
+ }
+
+ function isLocatorOverlay(marker) {
+ const type = marker?.__trxType;
+ return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr";
+ }
+
+ function sendLocatorOverlayToBack(marker) {
+ if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return;
+ marker.bringToBack();
+ }
+
+ function renderMapLocatorChipRow(container, items, selectedSet, kind) {
+ if (!container) return;
+ container.replaceChildren();
+ if (!Array.isArray(items) || items.length === 0) {
+ container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available `;
+ return;
+ }
+ let helperText = "";
+ const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : [];
+ const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]);
+ if (kind === "source") {
+ if (noneSelected) {
+ helperText = "All sources visible \u2014 click to filter";
+ }
+ } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) {
+ helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`;
+ }
+ for (const item of items) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "map-locator-chip";
+ const isActive = kind === "source" ? !!mapFilter[item.key] : selectedSet.has(item.key);
+ if (kind === "source" && noneSelected) {
+ btn.classList.add("is-default");
+ } else if (!isActive) {
+ btn.classList.add("is-inactive");
+ }
+ btn.dataset.filterKind = kind;
+ btn.dataset.filterKey = item.key;
+ btn.style.setProperty("--chip-color", item.color);
+ btn.innerHTML = `${escapeMapHtml(item.label)} `;
+ container.appendChild(btn);
+ }
+ if (helperText) {
+ const hint = document.createElement("span");
+ hint.className = "map-locator-empty";
+ hint.textContent = helperText;
+ container.appendChild(hint);
+ }
+ }
+
+ function renderMapLocatorPhaseRow(container, phase) {
+ if (!container) return;
+ container.replaceChildren();
+ const phases = [
+ { key: "type", label: "Source" },
+ { key: "band", label: "Band" },
+ ];
+ for (const item of phases) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "map-locator-phase-btn";
+ if (phase === item.key) btn.classList.add("is-active");
+ btn.dataset.phase = item.key;
+ btn.textContent = item.label;
+ container.appendChild(btn);
+ }
+ }
+
+ function renderMapLocatorLegend(phase, sourceItems, bandItems) {
+ const legendEl = document.getElementById("map-band-legend");
+ if (!legendEl) return;
+ const isSourcePhase = phase === "type";
+ const items = Array.isArray(isSourcePhase ? sourceItems : bandItems)
+ ? (isSourcePhase ? sourceItems : bandItems)
+ : [];
+ if (items.length === 0) {
+ legendEl.classList.add("is-empty");
+ legendEl.replaceChildren();
+ return;
+ }
+ legendEl.classList.remove("is-empty");
+ const rows = items
+ .map((item) => {
+ const label = escapeMapHtml(item.label);
+ const color = escapeMapHtml(item.color);
+ return `${label} `;
+ })
+ .join("");
+ const title = isSourcePhase ? "Source Colors" : "Band Colors";
+ legendEl.innerHTML = `${title}
${rows}
`;
+ }
+
+ window.enableMapSourceFilter = function(key) {
+ if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) {
+ mapFilter[key] = true;
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ }
+ };
+
+ function rebuildMapLocatorFilters() {
+ const phaseEl = document.getElementById("map-locator-phase");
+ const choiceEl = document.getElementById("map-locator-choice-filter");
+ const choiceLabelEl = document.getElementById("map-locator-choice-label");
+
+ const availableSources = new Set();
+ for (const entry of aisMarkers.values()) {
+ if (entry?.visibleInHistoryWindow) {
+ availableSources.add("ais");
+ break;
+ }
+ }
+ for (const entry of vdesMarkers.values()) {
+ if (entry?.visibleInHistoryWindow) {
+ availableSources.add("vdes");
+ break;
+ }
+ }
+ for (const entry of stationMarkers.values()) {
+ if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
+ availableSources.add("aprs");
+ break;
+ }
+ }
+ const bandMap = new Map();
+ for (const entry of locatorMarkers.values()) {
+ const sourceType = entry?.sourceType;
+ if (!sourceType) continue;
+ if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue;
+ availableSources.add(sourceType);
+ const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map();
+ for (const [label, hz] of meta.entries()) {
+ if (!bandMap.has(label)) {
+ bandMap.set(label, {
+ key: label,
+ label,
+ color: locatorBandChipColor(label),
+ kind: "band",
+ sortHz: Number.isFinite(hz) ? hz : 0,
+ });
+ continue;
+ }
+ const existing = bandMap.get(label);
+ if (existing && Number.isFinite(hz) && (!Number.isFinite(existing.sortHz) || hz > existing.sortHz)) {
+ existing.sortHz = hz;
+ }
+ if (existing && !existing.color) {
+ existing.color = locatorBandChipColor(label);
+ }
+ }
+ }
+
+ for (const key of Array.from(mapLocatorFilter.bands)) {
+ if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
+ }
+
+ const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"]
+ .filter((key) => availableSources.has(key))
+ .map((key) => ({
+ key,
+ label: mapSourceLabel(key),
+ color: mapSourceColor(key),
+ kind: "source",
+ }));
+ const bandItems = Array.from(bandMap.values())
+ .sort((a, b) => (b.sortHz - a.sortHz) || a.label.localeCompare(b.label));
+
+ renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems);
+ if (!phaseEl || !choiceEl || !choiceLabelEl) return;
+
+ renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase);
+ if (mapLocatorFilter.phase === "band") {
+ choiceLabelEl.textContent = "Visible Bands";
+ renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band");
+ } else {
+ choiceLabelEl.textContent = "Visible Sources";
+ renderMapLocatorChipRow(choiceEl, sourceItems, null, "source");
+ }
+ syncLocatorMarkerStyles();
+ syncDecodeContactPathVisibility();
+ }
+
+ function markerPassesLocatorFilters(marker) {
+ const meta = marker?._locatorFilterMeta;
+ if (!meta) return true;
+ if (mapLocatorFilter.phase === "band") {
+ if (mapLocatorFilter.bands.size === 0) return true;
+ if (!(meta.bands instanceof Set)) return false;
+ for (const label of mapLocatorFilter.bands) {
+ if (meta.bands.has(label)) return true;
+ }
+ return false;
+ }
+ return true;
+ }
+
+ function markerSearchText(marker) {
+ const type = marker?.__trxType;
+ if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") {
+ const entry = locatorEntryForMarker(marker);
+ const parts = [];
+ if (entry?.grid) parts.push(entry.grid);
+ if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType));
+ if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys()));
+ if (Array.isArray(entry?.bookmarks)) {
+ for (const bm of entry.bookmarks) {
+ if (bm?.name) parts.push(String(bm.name));
+ if (bm?.locator) parts.push(String(bm.locator));
+ if (bm?.mode) parts.push(String(bm.mode));
+ if (bm?.category) parts.push(String(bm.category));
+ if (bm?.comment) parts.push(String(bm.comment));
+ if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz))));
+ }
+ }
+ if (entry?.stations instanceof Set) {
+ parts.push(...Array.from(entry.stations.values()).map((v) => String(v)));
+ }
+ if (entry?.stationDetails instanceof Map) {
+ for (const detail of entry.stationDetails.values()) {
+ if (detail?.station) parts.push(String(detail.station));
+ if (detail?.message) parts.push(String(detail.message));
+ if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz))));
+ }
+ }
+ return parts.join(" ").toLowerCase();
+ }
+ if (type === "aprs") {
+ const call = marker?._aprsCall ? String(marker._aprsCall) : "";
+ const entry = stationMarkers.get(call);
+ const info = entry?.info ? String(entry.info) : "";
+ const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
+ return `${call} ${info} ${pktRaw}`.toLowerCase();
+ }
+ if (type === "ais") {
+ const key = marker?._aisMmsi ? String(marker._aisMmsi) : "";
+ const msg = aisMarkers.get(key)?.msg;
+ return [
+ key,
+ msg?.name,
+ msg?.callsign,
+ msg?.destination,
+ Number.isFinite(msg?.mmsi) ? String(msg.mmsi) : "",
+ Number.isFinite(msg?.lat) ? String(msg.lat) : "",
+ Number.isFinite(msg?.lon) ? String(msg.lon) : "",
+ ].join(" ").toLowerCase();
+ }
+ if (type === "vdes") {
+ const key = marker?._vdesKey ? String(marker._vdesKey) : "";
+ const msg = vdesMarkers.get(key)?.msg;
+ return [
+ key,
+ msg?.name,
+ msg?.mmsi,
+ msg?.message,
+ msg?.raw,
+ Number.isFinite(msg?.lat) ? String(msg.lat) : "",
+ Number.isFinite(msg?.lon) ? String(msg.lon) : "",
+ ].join(" ").toLowerCase();
+ }
+ return "";
+ }
+
+ function markerPassesSearchFilter(marker) {
+ const query = String(mapSearchFilter || "").trim().toLowerCase();
+ if (!query) return true;
+ const terms = query.split(/\s+/).filter(Boolean);
+ if (terms.length === 0) return true;
+ const haystack = markerSearchText(marker);
+ if (!haystack) return false;
+ return terms.every((term) => haystack.includes(term));
+ }
+
+ function _receiverLocationKey(lat, lon) {
+ return lat.toFixed(6) + "," + lon.toFixed(6);
+ }
+
+ function syncAprsReceiverMarker() {
+ if (!aprsMap) return;
+ // Build unique locations from all rigs
+ const locGroups = {}; // key -> { lat, lon, rigs: [...] }
+ const activeId = T.lastActiveRigId || T.serverActiveRigId || null;
+ for (const rig of T.serverRigs) {
+ if (!rig || !rig.remote) continue;
+ const lat = rig.latitude, lon = rig.longitude;
+ if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
+ const key = _receiverLocationKey(lat, lon);
+ if (!locGroups[key]) locGroups[key] = { lat, lon, rigs: [], hasActive: false };
+ locGroups[key].rigs.push(rig.remote);
+ if (rig.remote === activeId) locGroups[key].hasActive = true;
+ }
+ // Fallback: if active rig has SSE location but isn't in T.serverRigs yet
+ if (T.serverLat != null && T.serverLon != null) {
+ const key = _receiverLocationKey(T.serverLat, T.serverLon);
+ if (!locGroups[key]) locGroups[key] = { lat: T.serverLat, lon: T.serverLon, rigs: [], hasActive: true };
+ if (!locGroups[key].hasActive) locGroups[key].hasActive = true;
+ }
+
+ const seen = new Set();
+ let didInitialView = false;
+ for (const [key, group] of Object.entries(locGroups)) {
+ seen.add(key);
+ const latLng = [group.lat, group.lon];
+ const isActive = group.hasActive;
+ let m = aprsMapReceiverMarkers[key];
+ if (!m) {
+ m = L.circleMarker(latLng, {
+ radius: isActive ? 8 : 6,
+ className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"),
+ fillOpacity: isActive ? 0.8 : 0.6,
+ }).addTo(aprsMap).bindPopup("");
+ m._receiverLocKey = key;
+ m._receiverRigs = group.rigs;
+ aprsMapReceiverMarkers[key] = m;
+ if (isActive && !didInitialView) {
+ aprsMap.setView(latLng, Math.max(1, T.initialMapZoom));
+ didInitialView = true;
+ }
+ } else {
+ m.setLatLng(latLng);
+ m._receiverRigs = group.rigs;
+ m.setRadius(isActive ? 8 : 6);
+ if (!aprsMap.hasLayer(m)) m.addTo(aprsMap);
+ }
+ // Keep legacy reference for the active-rig location marker
+ if (isActive) aprsMapReceiverMarker = m;
+ }
+ // Remove markers for locations no longer present
+ for (const key of Object.keys(aprsMapReceiverMarkers)) {
+ if (!seen.has(key)) {
+ const m = aprsMapReceiverMarkers[key];
+ if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap);
+ delete aprsMapReceiverMarkers[key];
+ }
+ }
+ if (!seen.size) aprsMapReceiverMarker = null;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Weather satellite image overlays on the map
+ // ---------------------------------------------------------------------------
+
+ const satOverlays = new Map(); // key -> { overlay, track, msg }
+ let satOverlaySeq = 0;
+
+ window.addSatMapOverlay = function(msg) {
+ if (!msg || !msg.geo_bounds || !msg.path) return;
+ const bounds = msg.geo_bounds;
+ // bounds = [south, west, north, east]
+ if (!Array.isArray(bounds) || bounds.length !== 4) return;
+ const latLngBounds = L.latLngBounds(
+ [bounds[0], bounds[1]], // SW
+ [bounds[2], bounds[3]] // NE
+ );
+ const key = "sat-" + (++satOverlaySeq);
+ const overlay = L.imageOverlay(msg.path, latLngBounds, {
+ opacity: 0.55,
+ interactive: true,
+ zIndex: 300,
+ });
+ overlay.__trxType = "sat";
+ overlay.__trxSatKey = key;
+ overlay.__trxRigIds = msg.rig_id ? new Set([msg.rig_id]) : new Set();
+ overlay.__trxHistoryVisible = true;
+ mapMarkers.add(overlay);
+
+ // Build a popup for the overlay
+ const decoder = "Meteor LRPT";
+ const satellite = msg.satellite || "Unknown";
+ const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : "";
+ overlay.bindPopup(
+ `` +
+ `
${escapeMapHtml(decoder)} ` +
+ `${escapeMapHtml(satellite)}
` +
+ `${escapeMapHtml(ts)}
` +
+ (msg.path ? `
Download PNG ` : "") +
+ `
`
+ );
+
+ // Add ground track polyline if available
+ let track = null;
+ if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) {
+ const latlngs = msg.ground_track.map(function(pt) { return [pt[0], pt[1]]; });
+ track = L.polyline(latlngs, {
+ color: mapSourceColor("sat"),
+ weight: 2,
+ opacity: 0.7,
+ dashArray: "6, 4",
+ });
+ track.__trxType = "sat";
+ track.__trxSatKey = key;
+ track.__trxRigIds = overlay.__trxRigIds;
+ track.__trxHistoryVisible = true;
+ mapMarkers.add(track);
+ if (aprsMap) {
+ track.addTo(aprsMap);
+ }
+ }
+
+ satOverlays.set(key, { overlay: overlay, track: track, msg: msg });
+
+ if (aprsMap) {
+ overlay.addTo(aprsMap);
+ }
+ applyMapFilter();
+ };
+
+ window.removeSatMapOverlay = function(key) {
+ const entry = satOverlays.get(key);
+ if (!entry) return;
+ if (entry.overlay) {
+ mapMarkers.delete(entry.overlay);
+ if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap);
+ }
+ if (entry.track) {
+ mapMarkers.delete(entry.track);
+ if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
+ }
+ satOverlays.delete(key);
+ };
+
+ window.clearSatMapOverlays = function() {
+ for (const [key] of satOverlays) {
+ window.removeSatMapOverlay(key);
+ }
+ };
+
+ window.clearMapMarkersByType = function(type) {
+ if (type === "aprs") {
+ selectedAprsTrackCall = null;
+ stationMarkers.forEach((entry) => {
+ if (entry && entry.marker) {
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ if (entry && entry.track) {
+ if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
+ mapMarkers.delete(entry.track);
+ }
+ });
+ stationMarkers.clear();
+ return;
+ }
+
+ if (type === "ais") {
+ aisMarkers.forEach((entry) => {
+ if (entry && entry.marker) {
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ if (entry && entry.track) {
+ if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
+ mapMarkers.delete(entry.track);
+ }
+ });
+ selectedAisTrackMmsi = null;
+ aisMarkers.clear();
+ return;
+ }
+
+ if (type === "vdes") {
+ vdesMarkers.forEach((entry) => {
+ if (entry && entry.marker) {
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ });
+ vdesMarkers.clear();
+ return;
+ }
+
+ if (type === "sat") {
+ window.clearSatMapOverlays();
+ return;
+ }
+
+ if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") {
+ const prefix = `${type}:`;
+ for (const [key, entry] of locatorMarkers.entries()) {
+ if (!key.startsWith(prefix)) continue;
+ if (entry && entry.marker) {
+ if (entry.marker === selectedLocatorMarker) {
+ setSelectedLocatorMarker(null);
+ clearMapRadioPath();
+ }
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ locatorMarkers.delete(key);
+ }
+ rebuildMapLocatorFilters();
+ rebuildDecodeContactPaths();
+ }
+
+ if (type === "bookmark") {
+ for (const [key, entry] of locatorMarkers.entries()) {
+ if (!key.startsWith("bookmark:")) continue;
+ if (entry && entry.marker) {
+ if (entry.marker === selectedLocatorMarker) {
+ setSelectedLocatorMarker(null);
+ clearMapRadioPath();
+ }
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ locatorMarkers.delete(key);
+ }
+ rebuildMapLocatorFilters();
+ }
+ };
+
+ function mapTileSpecForTheme(theme) {
+ if (theme === "dark") {
+ return {
+ url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
+ options: {
+ maxZoom: 19,
+ subdomains: "abcd",
+ attribution: '© OpenStreetMap © CARTO ',
+ },
+ };
+ }
+ return {
+ url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
+ options: {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap ',
+ },
+ };
+ }
+
+ function updateMapBaseLayerForTheme(theme) {
+ if (!aprsMap) return;
+ if (aprsMapBaseLayer) {
+ aprsMap.removeLayer(aprsMapBaseLayer);
+ aprsMapBaseLayer = null;
+ }
+ const spec = mapTileSpecForTheme(theme);
+ aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap);
+ }
+
+ function mapStageEl() {
+ return document.getElementById("map-stage");
+ }
+
+ function mapIsFullscreen() {
+ const stage = mapStageEl();
+ if (!stage) return false;
+ return document.fullscreenElement === stage
+ || document.webkitFullscreenElement === stage
+ || stage.classList.contains("map-fake-fullscreen");
+ }
+
+ function mapExitFakeFullscreen() {
+ const stage = mapStageEl();
+ if (!stage) return;
+ stage.classList.remove("map-fake-fullscreen");
+ document.body.classList.remove("map-fake-fullscreen-active");
+ }
+
+ function mapEnterFakeFullscreen() {
+ const stage = mapStageEl();
+ if (!stage) return;
+ stage.classList.add("map-fake-fullscreen");
+ document.body.classList.add("map-fake-fullscreen-active");
+ }
+
+ function updateMapFullscreenButton() {
+ const btn = document.getElementById("map-fullscreen-btn");
+ if (!btn) return;
+ btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen";
+ }
+
+ function applyMapOverlayPanelVisibility() {
+ const panel = document.querySelector("#map-stage .map-overlay-panel");
+ if (!panel) return;
+ panel.classList.toggle("is-hidden", !mapOverlayPanelVisible);
+ }
+
+ function updateMapOverlayToggleButton() {
+ const btn = document.getElementById("map-overlay-toggle-btn");
+ if (!btn) return;
+ btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters";
+ }
+
+ async function toggleMapFullscreen() {
+ const stage = mapStageEl();
+ if (!stage) return;
+ try {
+ const isNative = document.fullscreenElement === stage || document.webkitFullscreenElement === stage;
+ const isFake = stage.classList.contains("map-fake-fullscreen");
+ if (isNative) {
+ if (document.exitFullscreen) await document.exitFullscreen();
+ else if (document.webkitExitFullscreen) await document.webkitExitFullscreen();
+ } else if (isFake) {
+ mapExitFakeFullscreen();
+ } else {
+ // Try native fullscreen; fall back to CSS fake fullscreen when the
+ // API is unavailable or blocked (e.g. mobile Safari).
+ const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen;
+ if (nativeFn) {
+ try {
+ await nativeFn.call(stage);
+ } catch (_) {
+ mapEnterFakeFullscreen();
+ }
+ } else {
+ mapEnterFakeFullscreen();
+ }
+ }
+ } catch (err) {
+ console.error("Map fullscreen toggle failed", err);
+ } finally {
+ updateMapFullscreenButton();
+ requestAnimationFrame(() => sizeAprsMapToViewport());
+ }
+ }
+
+ // Allow Escape to exit CSS fake fullscreen (native fullscreen handles its own Escape).
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") {
+ const stage = mapStageEl();
+ if (stage && stage.classList.contains("map-fake-fullscreen")) {
+ mapExitFakeFullscreen();
+ updateMapFullscreenButton();
+ requestAnimationFrame(() => sizeAprsMapToViewport());
+ }
+ }
+ });
+
+ function materializeBufferedMapLayers() {
+ if (!aprsMap) return;
+ for (const [key, entry] of locatorMarkers) {
+ if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue;
+ const bounds = maidenheadToBounds(entry.grid);
+ if (!bounds) continue;
+ entry.sourceType = "bookmark";
+ entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz)));
+ entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1))
+ .addTo(aprsMap)
+ .bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || []));
+ entry.marker.__trxType = "bookmark";
+ sendLocatorOverlayToBack(entry.marker);
+ assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
+ mapMarkers.add(entry.marker);
+ }
+ pruneMapHistory();
+ }
+
+ let stageResizeObserver = null;
+ function initAprsMap() {
+ if (typeof L === "undefined") return;
+ const mapEl = document.getElementById("aprs-map");
+ if (!mapEl) return;
+ sizeAprsMapToViewport();
+ if (aprsMap) return;
+
+ const hasLocation = T.serverLat != null && T.serverLon != null;
+ const center = hasLocation ? [T.serverLat, T.serverLon] : [20, 0];
+ const zoom = hasLocation ? T.initialMapZoom : 2;
+
+ aprsMap = L.map("aprs-map").setView(center, zoom);
+
+ // Observe the parent stage for size changes. Display:none β "" on the
+ // map tab, late-arriving content above the map, or any other layout
+ // shift would otherwise leave Leaflet's internal pane sized against
+ // stale dimensions until the user clicked the map. Observing the
+ // *parent* (not #aprs-map itself, which we resize) avoids feedback
+ // loops from sizeAprsMapToViewport's own height assignments.
+ const stage = mapStageEl();
+ if (stage && typeof ResizeObserver !== "undefined") {
+ if (stageResizeObserver) stageResizeObserver.disconnect();
+ stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport());
+ stageResizeObserver.observe(stage);
+ }
+ updateMapBaseLayerForTheme(C.currentTheme());
+ syncAprsReceiverMarker();
+
+ // Rebuild popup content on open (keeps age/distance/rig list fresh)
+ aprsMap.on("popupopen", function(e) {
+ const marker = e.popup._source;
+ clearMapRadioPath();
+ setSelectedLocatorMarker(null);
+ if (selectedAprsTrackCall) {
+ const prevEntry = stationMarkers.get(String(selectedAprsTrackCall));
+ if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) {
+ prevEntry.track.removeFrom(aprsMap);
+ }
+ selectedAprsTrackCall = null;
+ }
+ if (selectedAisTrackMmsi) {
+ selectedAisTrackMmsi = null;
+ syncSelectedAisTrackVisibility();
+ }
+
+ if (marker._receiverLocKey) {
+ e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || []));
+ return;
+ }
+
+ if (!marker) return;
+ const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null;
+
+ if (marker._aprsCall) {
+ if (!ll) return;
+ const entry = stationMarkers.get(marker._aprsCall);
+ if (!entry) return;
+ e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
+ refreshAprsTrack(String(marker._aprsCall), entry);
+ if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
+ entry.track.addTo(aprsMap);
+ }
+ selectedAprsTrackCall = String(marker._aprsCall);
+ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
+ return;
+ }
+
+ if (marker._aisMmsi) {
+ if (!ll) return;
+ const entry = aisMarkers.get(String(marker._aisMmsi));
+ if (!entry || !entry.msg) return;
+ e.popup.setContent(buildAisPopupHtml(entry.msg));
+ refreshAisTrack(String(marker._aisMmsi), entry);
+ selectedAisTrackMmsi = String(marker._aisMmsi);
+ syncSelectedAisTrackVisibility();
+ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds);
+ return;
+ }
+
+ if (marker._vdesKey) {
+ if (!ll) return;
+ const entry = vdesMarkers.get(String(marker._vdesKey));
+ if (!entry || !entry.msg) return;
+ e.popup.setContent(buildVdesPopupHtml(entry.msg));
+ setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds);
+ return;
+ }
+
+ if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") {
+ const center = locatorMarkerCenter(marker);
+ if (center) {
+ setSelectedLocatorMarker(marker);
+ const lEntry = locatorEntryForMarker(marker);
+ const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color : locatorFilterColor(marker.__trxType);
+ setMapRadioPathTo(center.lat, center.lon, lColor, "locator-radio-path", marker.__trxRigIds);
+ }
+ } else if (marker.__trxType === "bookmark") {
+ setSelectedLocatorMarker(marker);
+ }
+ });
+
+ aprsMap.on("popupclose", function() {
+ clearMapRadioPath();
+ setSelectedLocatorMarker(null);
+ if (selectedAprsTrackCall) {
+ const entry = stationMarkers.get(String(selectedAprsTrackCall));
+ if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) {
+ entry.track.removeFrom(aprsMap);
+ }
+ selectedAprsTrackCall = null;
+ }
+ if (selectedAisTrackMmsi) {
+ selectedAisTrackMmsi = null;
+ syncSelectedAisTrackVisibility();
+ }
+ });
+
+ materializeBufferedMapLayers();
+
+ const locatorPhaseEl = document.getElementById("map-locator-phase");
+ const locatorChoiceEl = document.getElementById("map-locator-choice-filter");
+ const mapSearchEl = document.getElementById("map-search-filter");
+ const mapHistoryLimitEl = document.getElementById("map-history-limit");
+ const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle");
+ const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle");
+ const fullscreenBtn = document.getElementById("map-fullscreen-btn");
+ const overlayToggleBtn = document.getElementById("map-overlay-toggle-btn");
+ if (locatorPhaseEl) {
+ locatorPhaseEl.addEventListener("click", (e) => {
+ const btn = e.target.closest(".map-locator-phase-btn[data-phase]");
+ if (!btn) return;
+ const phase = String(btn.dataset.phase || "");
+ if (phase !== "type" && phase !== "band") return;
+ if (mapLocatorFilter.phase === phase) return;
+ mapLocatorFilter.phase = phase;
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ });
+ }
+ if (locatorChoiceEl) {
+ locatorChoiceEl.addEventListener("click", (e) => {
+ const chip = e.target.closest(".map-locator-chip[data-filter-kind]");
+ if (!chip) return;
+ const kind = String(chip.dataset.filterKind || "");
+ const key = String(chip.dataset.filterKey || "");
+ if (!key) return;
+ if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) {
+ // toggle the clicked source; when none are selected everything is shown
+ mapFilter[key] = !mapFilter[key];
+ const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
+ const anySelected = srcKeys.some((k) => mapFilter[k]);
+ if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) {
+ const entry = stationMarkers.get(String(selectedAprsTrackCall));
+ if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) {
+ entry.track.removeFrom(aprsMap);
+ }
+ selectedAprsTrackCall = null;
+ }
+ if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) {
+ const entry = aisMarkers.get(String(selectedAisTrackMmsi));
+ if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) {
+ entry.track.removeFrom(aprsMap);
+ }
+ selectedAisTrackMmsi = null;
+ }
+ } else if (kind === "band") {
+ if (mapLocatorFilter.bands.has(key)) {
+ mapLocatorFilter.bands.delete(key);
+ } else {
+ mapLocatorFilter.bands.add(key);
+ }
+ }
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ });
+ }
+ const mapRigFilterEl = document.getElementById("map-rig-filter");
+ if (mapRigFilterEl) {
+ mapRigFilterEl.addEventListener("change", () => {
+ mapRigFilter = mapRigFilterEl.value;
+ applyMapFilter();
+ });
+ }
+ if (mapSearchEl) {
+ mapSearchEl.value = mapSearchFilter;
+ mapSearchEl.addEventListener("input", () => {
+ mapSearchFilter = String(mapSearchEl.value || "").trim();
+ applyMapFilter();
+ });
+ }
+ if (mapHistoryLimitEl) {
+ mapHistoryLimitEl.value = String(mapHistoryLimitMinutes);
+ mapHistoryLimitEl.addEventListener("change", () => {
+ mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value));
+ mapHistoryLimitEl.value = String(mapHistoryLimitMinutes);
+ saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes);
+ pruneMapHistory();
+ });
+ }
+ if (mapP2pPathsToggleEl) {
+ updateMapP2pPathsToggle();
+ mapP2pPathsToggleEl.addEventListener("click", () => {
+ mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled;
+ saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled);
+ updateMapP2pPathsToggle();
+ if (!mapP2pRadioPathsEnabled) clearMapRadioPath();
+ });
+ }
+ if (mapContactPathsToggleEl) {
+ updateMapContactPathsToggle();
+ mapContactPathsToggleEl.addEventListener("click", () => {
+ mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled;
+ saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled);
+ updateMapContactPathsToggle();
+ syncDecodeContactPathVisibility();
+ });
+ }
+ if (fullscreenBtn) {
+ fullscreenBtn.addEventListener("click", () => {
+ toggleMapFullscreen();
+ });
+ updateMapFullscreenButton();
+ }
+ applyMapOverlayPanelVisibility();
+ updateMapOverlayToggleButton();
+ if (overlayToggleBtn) {
+ overlayToggleBtn.addEventListener("click", () => {
+ mapOverlayPanelVisible = !mapOverlayPanelVisible;
+ saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible);
+ applyMapOverlayPanelVisibility();
+ updateMapOverlayToggleButton();
+ });
+ }
+ if (!mapFullscreenListenerBound) {
+ const onFullscreenChange = () => {
+ updateMapFullscreenButton();
+ sizeAprsMapToViewport();
+ };
+ document.addEventListener("fullscreenchange", onFullscreenChange);
+ document.addEventListener("webkitfullscreenchange", onFullscreenChange);
+ mapFullscreenListenerBound = true;
+ }
+ if (!mapHistoryPruneTimer) {
+ mapHistoryPruneTimer = setInterval(() => {
+ pruneMapHistory();
+ }, 60 * 1000);
+ }
+ rebuildMapLocatorFilters();
+ }
+
+ function sizeAprsMapToViewport() {
+ const mapEl = document.getElementById("aprs-map");
+ if (!mapEl) return;
+ const stage = mapStageEl();
+ if (mapIsFullscreen() && stage) {
+ // For CSS fake fullscreen use window.innerHeight directly β clientHeight
+ // may not yet reflect the fixed layout when called synchronously after
+ // adding the class.
+ const isFake = stage.classList.contains("map-fake-fullscreen");
+ const stageHeight = isFake
+ ? window.innerHeight
+ : (stage.clientHeight || stage.getBoundingClientRect().height);
+ const target = Math.max(260, Math.floor(stageHeight));
+ mapEl.style.height = `${target}px`;
+ if (aprsMap) aprsMap.invalidateSize();
+ return;
+ }
+ const mapRect = mapEl.getBoundingClientRect();
+ const width = mapEl.clientWidth || mapRect.width;
+ const footer = document.querySelector(".footer");
+ let bottom = mapIsFullscreen() && stage
+ ? stage.getBoundingClientRect().bottom
+ : window.innerHeight;
+ if (!mapIsFullscreen() && footer) {
+ const fr = footer.getBoundingClientRect();
+ if (fr.top > mapRect.top + 50) bottom = fr.top;
+ }
+ const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
+ const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
+ const viewportCap = mapIsFullscreen()
+ ? Math.floor(window.innerHeight * 0.9)
+ : Math.floor(window.innerHeight * 0.75);
+ const minHeight = Math.min(260, available);
+ const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven));
+ mapEl.style.height = `${target}px`;
+ if (aprsMap) aprsMap.invalidateSize();
+ }
+
+ function aprsSymbolIcon(symbolTable, symbolCode) {
+ if (!symbolTable || !symbolCode) return null;
+ const sheet = symbolTable === "/" ? 0 : 1;
+ const code = symbolCode.charCodeAt(0) - 33;
+ const col = code % 16;
+ const row = Math.floor(code / 16);
+ const bgX = -(col * 24);
+ const bgY = -(row * 24);
+ const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`;
+ return L.divIcon({
+ className: "",
+ html: `
`,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ popupAnchor: [0, -12]
+ });
+ }
+
+ window.navigateToAprsMap = function(lat, lon) {
+ // Activate the map tab
+ T._activeTab = "map";
+ document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
+ const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
+ if (mapTabBtn) mapTabBtn.classList.add("active");
+ document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none"));
+ const mapPanel = document.getElementById("tab-map");
+ if (mapPanel) mapPanel.style.display = "";
+ initAprsMap();
+ sizeAprsMapToViewport();
+ if (aprsMap) {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ sizeAprsMapToViewport();
+ aprsMap.invalidateSize();
+ aprsMap.setView([lat, lon], 13);
+ });
+ });
+ }
+ };
+
+ window.navigateToMapLocator = function(grid, preferredType = null) {
+ const normalizedGrid = String(grid || "").trim().toUpperCase();
+ if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false;
+
+ T._activeTab = "map";
+ document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
+ const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
+ if (mapTabBtn) mapTabBtn.classList.add("active");
+ document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none"));
+ const mapPanel = document.getElementById("tab-map");
+ if (mapPanel) mapPanel.style.display = "";
+
+ initAprsMap();
+ sizeAprsMapToViewport();
+ if (!aprsMap) return false;
+
+ const pref = preferredType === "wspr" ? "wspr" : (preferredType === "ft4" ? "ft4" : (preferredType === "ft2" ? "ft2" : (preferredType === "ft8" ? "ft8" : null)));
+ const keys = pref
+ ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]
+ : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`];
+ let entry = null;
+ for (const key of keys) {
+ entry = locatorMarkers.get(key);
+ if (entry?.marker) break;
+ }
+ if (!entry?.marker) return false;
+
+ if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) {
+ mapFilter[pref] = true;
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ }
+
+ const marker = entry.marker;
+ if (!aprsMap.hasLayer(marker)) {
+ marker.addTo(aprsMap);
+ sendLocatorOverlayToBack(marker);
+ }
+ const center = locatorMarkerCenter(marker);
+ const focusMarker = () => {
+ if (!aprsMap || !marker) return;
+ sizeAprsMapToViewport();
+ aprsMap.invalidateSize();
+ if (center) {
+ const targetZoom = Math.max(aprsMap.getZoom() || 0, 7);
+ aprsMap.setView([center.lat, center.lon], targetZoom);
+ if (marker.__trxType !== "bookmark") {
+ const fEntry = locatorEntryForMarker(marker);
+ const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color : locatorFilterColor(marker?.__trxType);
+ setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds);
+ }
+ }
+ setSelectedLocatorMarker(marker);
+ if (typeof marker.openPopup === "function") marker.openPopup();
+ };
+ focusMarker();
+ requestAnimationFrame(() => {
+ requestAnimationFrame(focusMarker);
+ });
+ return true;
+ };
+
+
+
+
+
+
+
+
+ function buildReceiverPopupHtml(rigIds) {
+ const call = T.serverCallsign || T.ownerCallsign || "Receiver";
+ let meta = "";
+ if (T.serverVersion) {
+ meta = `trx-server v${escapeMapHtml(T.serverVersion)}`;
+ if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`;
+ }
+ let rows = "";
+ if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) {
+ rows += `${escapeMapHtml(T.ownerCallsign)} `;
+ }
+ // Show location from first matching rig or active rig
+ const rigSet = rigIds && rigIds.length ? new Set(rigIds) : null;
+ const firstRig = rigSet ? T.serverRigs.find(r => rigSet.has(r.remote)) : null;
+ const popupLat = firstRig ? firstRig.latitude : T.serverLat;
+ const popupLon = firstRig ? firstRig.longitude : T.serverLon;
+ if (popupLat != null && popupLon != null) {
+ const grid = latLonToMaidenhead(popupLat, popupLon);
+ rows += `${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)}) `;
+ }
+ // Show rigs at this location
+ const rigsToShow = rigSet
+ ? T.serverRigs.filter(r => rigSet.has(r.remote))
+ : T.serverRigs;
+ for (const rig of rigsToShow) {
+ const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim();
+ const active = rig.remote === T.serverActiveRigId
+ ? ` ` : "";
+ rows += `${escapeMapHtml(name)}${active} `;
+ }
+ return ``;
+ }
+
+ function buildAprsPopupHtml(call, lat, lon, info, pkt) {
+ const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : (pkt?._ts || null);
+ const distKm = (T.serverLat != null && T.serverLon != null)
+ ? haversineKm(T.serverLat, T.serverLon, lat, lon)
+ : null;
+ const distStr = distKm != null
+ ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
+ : null;
+ const path = pkt?.path || null;
+ const type = pkt?.type || null;
+
+ let meta = [age, distStr].filter(Boolean).join(" · ");
+ let rows = "";
+ if (type) rows += `${escapeMapHtml(type)} `;
+ if (path) rows += `${escapeMapHtml(path)} `;
+ if (lat != null && lon != null)
+ rows += `${lat.toFixed(5)}, ${lon.toFixed(5)} `;
+
+ return ``;
+ }
+
+ function buildAisPopupHtml(msg) {
+ const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null;
+ const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null)
+ ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon)
+ : null;
+ const distStr = distKm != null
+ ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
+ : null;
+ const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · ");
+ let rows = "";
+ rows += `${escapeMapHtml(String(msg.mmsi || "--"))} `;
+ rows += `${escapeMapHtml(String(msg.message_type || "--"))} `;
+ if (distStr) rows += `${distStr} from TRX `;
+ if (msg?.sog_knots != null) rows += `${Number(msg.sog_knots).toFixed(1)} kn `;
+ if (msg?.cog_deg != null) rows += `${Number(msg.cog_deg).toFixed(1)}° `;
+ if (msg?.heading_deg != null) rows += `${Number(msg.heading_deg).toFixed(0)}° `;
+ if (msg?.nav_status != null) rows += `${escapeMapHtml(String(msg.nav_status))} `;
+ if (msg?.lat != null && msg?.lon != null) rows += `${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)} `;
+ const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" Β· ");
+ const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`);
+ const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
+ const vesselTitle = vesselUrl
+ ? `${vesselLabel} `
+ : vesselLabel;
+ return ``;
+ }
+
+ function buildVdesPopupHtml(msg) {
+ const age = formatTimeAgo(msg?.ts_ms);
+ const distKm = (T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null)
+ ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon)
+ : null;
+ const distStr = distKm != null
+ ? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
+ : null;
+ const meta = [
+ age,
+ distStr,
+ msg?.message_label ? escapeMapHtml(msg.message_label) : null,
+ Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null,
+ ].filter(Boolean).join(" · ");
+ let rows = "";
+ if (distStr) rows += `${distStr} from TRX `;
+ rows += `${escapeMapHtml(String(msg?.message_type ?? "--"))} `;
+ if (Number.isFinite(msg?.source_id)) rows += `${escapeMapHtml(String(msg.source_id))} `;
+ if (Number.isFinite(msg?.destination_id)) rows += `${escapeMapHtml(String(msg.destination_id))} `;
+ if (msg?.lat != null && msg?.lon != null) rows += `${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)} `;
+ if (Number.isFinite(msg?.sync_score)) rows += `${(Number(msg.sync_score) * 100).toFixed(0)}% `;
+ if (msg?.fec_state) rows += `${escapeMapHtml(String(msg.fec_state))} `;
+ const info = [
+ msg?.vessel_name,
+ msg?.callsign,
+ msg?.destination,
+ msg?.payload_preview,
+ ].filter(Boolean).map(escapeMapHtml).join(" Β· ");
+ const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position");
+ return ``;
+ }
+
+ function aprsPositionsEqual(a, b) {
+ if (!a || !b) return false;
+ const aLat = Array.isArray(a) ? a[0] : a.lat;
+ const aLon = Array.isArray(a) ? a[1] : a.lon;
+ const bLat = Array.isArray(b) ? b[0] : b.lat;
+ const bLon = Array.isArray(b) ? b[1] : b.lon;
+ return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001;
+ }
+
+ function aisPositionsEqual(a, b) {
+ if (!a || !b) return false;
+ const aLat = Array.isArray(a) ? a[0] : a.lat;
+ const aLon = Array.isArray(a) ? a[1] : a.lon;
+ const bLat = Array.isArray(b) ? b[0] : b.lat;
+ const bLon = Array.isArray(b) ? b[1] : b.lon;
+ return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001;
+ }
+
+ function vdesMarkerKey(msg) {
+ if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`;
+ if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`;
+ if (msg?.lat != null && msg?.lon != null) {
+ return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`;
+ }
+ return null;
+ }
+
+ function _aprsAddMarkerToMap(call, entry) {
+ refreshAprsTrack(call, entry);
+ const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode);
+ const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
+ const marker = icon
+ ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
+ : L.circleMarker([entry.lat, entry.lon], {
+ radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
+ }).addTo(aprsMap).bindPopup(popupContent);
+ marker.__trxType = "aprs";
+ marker.__trxRigIds = entry.rigIds || new Set();
+ marker._aprsCall = call;
+ entry.marker = marker;
+ mapMarkers.add(marker);
+ }
+
+ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
+ const nextPoint = [lat, lon];
+ const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
+ const msgRigId = pkt?.rig_id || T.lastActiveRigId;
+ const existing = stationMarkers.get(call);
+ if (existing) {
+ existing.pkt = pkt;
+ existing.lat = lat;
+ existing.lon = lon;
+ existing.info = info;
+ existing.symbolTable = symbolTable;
+ existing.symbolCode = symbolCode;
+ if (msgRigId) {
+ if (!existing.rigIds) existing.rigIds = new Set();
+ existing.rigIds.add(msgRigId);
+ }
+ if (!Array.isArray(existing.trackHistory)) existing.trackHistory = [];
+ const prevPoint = existing.trackHistory[existing.trackHistory.length - 1];
+ if (!aprsPositionsEqual(prevPoint, nextPoint)) {
+ existing.trackHistory.push({ lat, lon, tsMs });
+ } else if (prevPoint) {
+ prevPoint.tsMs = tsMs;
+ }
+ pruneAprsEntry(call, existing, mapHistoryCutoffMs());
+ if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
+ existing.marker.setLatLng([lat, lon]);
+ existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
+ }
+ } else {
+ const entry = {
+ marker: null,
+ track: null,
+ trackHistory: [{ lat, lon, tsMs }],
+ trackPoints: [nextPoint],
+ type: "aprs",
+ pkt,
+ lat,
+ lon,
+ info,
+ symbolTable,
+ symbolCode,
+ rigIds: new Set(msgRigId ? [msgRigId] : []),
+ };
+ stationMarkers.set(call, entry);
+ pruneAprsEntry(call, entry, mapHistoryCutoffMs());
+ if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
+ if (aprsMap) scheduleDecodeMapMaintenance();
+ }
+ };
+
+ function syncSelectedAisTrackVisibility() {
+ if (!aprsMap) return;
+ const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null;
+ aisMarkers.forEach((entry, key) => {
+ const track = entry?.track;
+ if (!track) return;
+ const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais;
+ const onMap = aprsMap.hasLayer(track);
+ if (shouldShow && !onMap) {
+ track.addTo(aprsMap);
+ }
+ if (!shouldShow && onMap) {
+ track.removeFrom(aprsMap);
+ }
+ });
+ }
+
+ function getAisAccentColor() {
+ return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a";
+ }
+
+ function aisMarkerOptionsFromMessage(msg) {
+ const color = getAisAccentColor();
+ return {
+ heading: msg?.heading_deg,
+ course: msg?.cog_deg,
+ speed: msg?.sog_knots,
+ color,
+ outline: "#00000055",
+ size: 22,
+ };
+ }
+
+ function createAisMarker(lat, lon, msg) {
+ if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") {
+ return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg));
+ }
+ const color = getAisAccentColor();
+ return L.circleMarker([lat, lon], {
+ radius: 6,
+ color,
+ fillColor: color,
+ fillOpacity: 0.82,
+ });
+ }
+
+ function updateAisMarker(marker, msg, popupHtml) {
+ if (!marker) return;
+ marker.setLatLng([msg.lat, msg.lon]);
+ if (typeof marker.setAisState === "function") {
+ marker.setAisState(aisMarkerOptionsFromMessage(msg));
+ }
+ if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") {
+ const color = getAisAccentColor();
+ marker.setStyle({
+ radius: 6,
+ color,
+ fillColor: color,
+ fillOpacity: 0.84,
+ });
+ }
+ marker.setPopupContent(popupHtml);
+ }
+
+ function refreshAisMarkerColors() {
+ const color = getAisAccentColor();
+ aisMarkers.forEach((entry) => {
+ if (entry.marker) {
+ if (typeof entry.marker.setAisState === "function") {
+ entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {}));
+ } else if (typeof entry.marker.setStyle === "function") {
+ entry.marker.setStyle({ color, fillColor: color });
+ }
+ }
+ if (entry.track && typeof entry.track.setStyle === "function") {
+ entry.track.setStyle({ color });
+ }
+ });
+ }
+
+ window.aisMapAddVessel = function(msg) {
+ if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
+ const key = String(msg.mmsi);
+ const popupHtml = buildAisPopupHtml(msg);
+ const nextPoint = [msg.lat, msg.lon];
+ const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now();
+ const msgRigId = msg?.rig_id || T.lastActiveRigId;
+ const existing = aisMarkers.get(key);
+ if (existing) {
+ existing.msg = msg;
+ if (msgRigId) {
+ if (!existing.rigIds) existing.rigIds = new Set();
+ existing.rigIds.add(msgRigId);
+ }
+ if (!Array.isArray(existing.trackHistory)) existing.trackHistory = [];
+ const prevPoint = existing.trackHistory[existing.trackHistory.length - 1];
+ if (!aisPositionsEqual(prevPoint, nextPoint)) {
+ existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs });
+ } else if (prevPoint) {
+ prevPoint.tsMs = tsMs;
+ }
+ pruneAisEntry(key, existing, mapHistoryCutoffMs());
+ if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
+ updateAisMarker(existing.marker, msg, popupHtml);
+ }
+ return;
+ }
+ aisMarkers.set(key, {
+ marker: null,
+ track: null,
+ trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }],
+ trackPoints: [nextPoint],
+ msg,
+ rigIds: new Set(msgRigId ? [msgRigId] : []),
+ });
+ pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs());
+ if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key));
+ scheduleDecodeMapMaintenance();
+ };
+
+ window.vdesMapAddPoint = function(msg) {
+ if (msg == null || msg.lat == null || msg.lon == null) return;
+ const key = vdesMarkerKey(msg);
+ if (!key) return;
+ const popupHtml = buildVdesPopupHtml(msg);
+ const visible = Number.isFinite(Number(msg?._tsMs))
+ && Number(msg._tsMs) >= mapHistoryCutoffMs();
+ const msgRigId = msg?.rig_id || T.lastActiveRigId;
+ const existing = vdesMarkers.get(key);
+ if (existing) {
+ existing.msg = msg;
+ existing.visibleInHistoryWindow = visible;
+ if (msgRigId) {
+ if (!existing.rigIds) existing.rigIds = new Set();
+ existing.rigIds.add(msgRigId);
+ }
+ if (!visible) {
+ if (!C.decodeHistoryMapRenderingDeferred()) {
+ setRetainedMapMarkerVisible(existing.marker, false);
+ } else {
+ C.markDecodeMapSyncPending();
+ }
+ return;
+ }
+ if (!C.decodeHistoryMapRenderingDeferred()) {
+ ensureVdesMarker(key, existing);
+ setRetainedMapMarkerVisible(existing.marker, true);
+ } else {
+ C.markDecodeMapSyncPending();
+ }
+ if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
+ existing.marker.setLatLng([msg.lat, msg.lon]);
+ existing.marker.setPopupContent(popupHtml);
+ }
+ return;
+ }
+ const entry = {
+ marker: null,
+ msg,
+ visibleInHistoryWindow: visible,
+ rigIds: new Set(msgRigId ? [msgRigId] : []),
+ };
+ vdesMarkers.set(key, entry);
+ if (!visible) return;
+ if (!C.decodeHistoryMapRenderingDeferred()) {
+ ensureVdesMarker(key, entry);
+ setRetainedMapMarkerVisible(entry.marker, true);
+ } else {
+ C.markDecodeMapSyncPending();
+ }
+ if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) {
+ entry.marker.setPopupContent(popupHtml);
+ }
+ scheduleDecodeMapMaintenance();
+ };
+
+ let reverseGeocodeLastKey = null;
+ function reverseGeocodeLocation(lat, lon, grid) {
+ const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
+ if (key === reverseGeocodeLastKey) return;
+ reverseGeocodeLastKey = key;
+ const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`;
+ fetch(url, { headers: { "User-Agent": "trx-rs" } })
+ .then((r) => r.ok ? r.json() : Promise.reject(r.status))
+ .then((data) => {
+ const addr = data?.address;
+ if (!addr) return;
+ const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || "";
+ const country = addr.country || "";
+ if (!city && !country) return;
+ const label = city && country ? `${city}, ${country}` : (city || country);
+ T.lastCityLabel = label;
+ if (T.locationSubtitle) {
+ T.locationSubtitle.textContent = `Location: ${grid} Β· ${label}`;
+ }
+ C.updateDocumentTitle(C.activeChannelRds());
+ })
+ .catch(() => {});
+ }
+
+
+ function maidenheadToBounds(grid) {
+ if (!grid || grid.length < 4) return null;
+ const g = grid.toUpperCase();
+ const A = "A".charCodeAt(0);
+ const fieldLon = (g.charCodeAt(0) - A) * 20 - 180;
+ const fieldLat = (g.charCodeAt(1) - A) * 10 - 90;
+ const squareLon = parseInt(g[2], 10) * 2;
+ const squareLat = parseInt(g[3], 10) * 1;
+
+ let lon = fieldLon + squareLon;
+ let lat = fieldLat + squareLat;
+ let lonSpan = 2;
+ let latSpan = 1;
+
+ if (g.length >= 6) {
+ const subLon = (g.charCodeAt(4) - A) * (5 / 60);
+ const subLat = (g.charCodeAt(5) - A) * (2.5 / 60);
+ lon += subLon;
+ lat += subLat;
+ lonSpan = 5 / 60;
+ latSpan = 2.5 / 60;
+ }
+
+ return [
+ [lat, lon],
+ [lat + latSpan, lon + lonSpan],
+ ];
+ }
+
+ function applyMapFilter() {
+ if (!aprsMap) return;
+ const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER);
+ const noneSelected = sourceKeys.every((k) => !mapFilter[k]);
+ mapMarkers.forEach((marker) => {
+ const type = marker.__trxType;
+ const sourceVisible = noneSelected
+ ? DEFAULT_MAP_SOURCE_FILTER[type] !== undefined ? DEFAULT_MAP_SOURCE_FILTER[type] : true
+ : !!mapFilter[type];
+ const rigVisible = !mapRigFilter
+ || marker.__trxType === "bookmark"
+ || (marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter));
+ const visible = marker.__trxHistoryVisible !== false
+ && markerPassesSearchFilter(marker)
+ && markerPassesLocatorFilters(marker)
+ && sourceVisible
+ && rigVisible;
+ const onMap = aprsMap.hasLayer(marker);
+ if (visible && !onMap) {
+ marker.addTo(aprsMap);
+ sendLocatorOverlayToBack(marker);
+ }
+ if (!visible && onMap) marker.removeFrom(aprsMap);
+ });
+ syncSelectedAisTrackVisibility();
+ syncDecodeContactPathVisibility();
+ }
+
+ function updateMapContactPathsToggle() {
+ const btn = document.getElementById("map-contact-paths-toggle");
+ if (!btn) return;
+ btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off";
+ btn.classList.toggle("is-active", mapDecodeContactPathsEnabled);
+ }
+
+ function updateMapP2pPathsToggle() {
+ const btn = document.getElementById("map-p2p-paths-toggle");
+ if (!btn) return;
+ btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off";
+ btn.classList.toggle("is-active", mapP2pRadioPathsEnabled);
+ }
+
+ function scheduleDecodeMapMaintenance() {
+ if (C.decodeHistoryMapRenderingDeferred()) {
+ C.markDecodeMapSyncPending();
+ return;
+ }
+ scheduleUiFrameJob("decode-map-maintenance", () => {
+ rebuildDecodeContactPaths();
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ });
+ }
+
+ function formatDecodeLocatorTime(tsMs) {
+ if (!Number.isFinite(tsMs)) return "--:--:--";
+ return new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ }
+
+ function formatMapPopupFreq(hz) {
+ if (!Number.isFinite(hz)) return "--";
+ const value = Number(hz);
+ if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(6).replace(/\.?0+$/, "")} GHz`;
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(6).replace(/\.?0+$/, "")} MHz`;
+ if (value >= 1_000) return `${(value / 1_000).toFixed(3).replace(/\.?0+$/, "")} kHz`;
+ return `${Math.round(value)} Hz`;
+ }
+
+ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
+ const details = entry?.stationDetails instanceof Map
+ ? Array.from(entry.stationDetails.values())
+ : [];
+ details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0));
+ const title = type === "wspr" ? "WSPR" : "FT8";
+ const rows = details
+ .map((detail) => {
+ const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown"));
+ const freq = formatMapPopupFreq(Number(detail?.freq_hz));
+ const meta = [
+ detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null,
+ Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null,
+ Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null,
+ escapeMapHtml(freq),
+ ].filter(Boolean).join(" Β· ");
+ const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0
+ ? Array.from(detail.remotes)
+ : (detail?.remote ? [detail.remote] : []);
+ const rxHtml = remoteIds
+ .map(rid => {
+ const label = _receiverLabel(rid);
+ return label ? `${escapeMapHtml(label)}
` : "";
+ })
+ .filter(Boolean)
+ .join("");
+ const message = detail?.message
+ ? `${escapeMapHtml(String(detail.message))}
`
+ : "";
+ return `` +
+ `
` +
+ `${station} ` +
+ `${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))} ` +
+ `
` +
+ (meta ? `
${meta}
` : "") +
+ rxHtml +
+ message +
+ `
`;
+ })
+ .join("");
+ const count = Math.max(
+ 1,
+ details.length,
+ entry?.stations instanceof Set ? entry.stations.size : 0,
+ );
+ return `` +
+ `
${escapeMapHtml(grid)}
` +
+ `
${title} Β· ${count} station${count === 1 ? "" : "s"}
` +
+ rows +
+ `
`;
+ }
+
+ function rebuildDecodeContactPaths() {
+ clearDecodeContactPaths();
+ const stationLocators = new Map();
+ const directedMessages = [];
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
+ const grid = String(entry.grid || "").trim().toUpperCase();
+ if (!grid || !(entry.stationDetails instanceof Map)) continue;
+ for (const detail of entry.stationDetails.values()) {
+ const source = String(detail?.source || detail?.station || "").trim().toUpperCase();
+ const target = String(detail?.target || "").trim().toUpperCase();
+ const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0;
+ if (source) {
+ const prev = stationLocators.get(source);
+ if (!prev || tsMs >= prev.tsMs) {
+ stationLocators.set(source, { grid, tsMs });
+ }
+ }
+ if (source && target && source !== target) {
+ const band = bandForHz(Number(detail?.freq_hz));
+ directedMessages.push({
+ source,
+ target,
+ sourceGrid: grid,
+ sourceType: entry.sourceType,
+ tsMs,
+ bandLabel: band?.label || null,
+ remote: detail?.remote || null,
+ });
+ }
+ }
+ }
+ for (const msg of directedMessages) {
+ const targetLocator = stationLocators.get(msg.target);
+ if (!targetLocator) continue;
+ if (msg.sourceGrid === targetLocator.grid) continue;
+ const sourceCenter = locatorToLatLon(msg.sourceGrid);
+ const targetCenter = locatorToLatLon(targetLocator.grid);
+ if (!sourceCenter || !targetCenter) continue;
+ const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon);
+ const key = [msg.source, msg.target].sort().join("::");
+ const prev = decodeContactPaths.get(key);
+ if (prev && prev.tsMs > msg.tsMs) continue;
+ decodeContactPaths.set(key, {
+ pathKey: key,
+ source: msg.source,
+ target: msg.target,
+ sourceGrid: msg.sourceGrid,
+ targetGrid: targetLocator.grid,
+ sourceType: msg.sourceType,
+ bandLabel: msg.bandLabel,
+ from: sourceCenter,
+ to: targetCenter,
+ tsMs: msg.tsMs,
+ distanceKm,
+ distanceText: formatDecodeContactDistance(distanceKm),
+ line: null,
+ labelMarker: null,
+ remote: msg.remote,
+ });
+ }
+ syncDecodeContactPathVisibility();
+ }
+
+ function _receiverLabel(rigId) {
+ if (!rigId) return null;
+ const rig = T.serverRigs.find(r => r.remote === rigId);
+ const name = T.lastRigDisplayNames[rigId] || rigId;
+ if (rig && rig.latitude != null && rig.longitude != null) {
+ const grid = latLonToMaidenhead(rig.latitude, rig.longitude);
+ return `${name} (${grid})`;
+ }
+ return name;
+ }
+
+ function _locatorEntryVisibleOnMap(entry) {
+ return entry?.marker && aprsMap && aprsMap.hasLayer(entry.marker);
+ }
+
+ function _detailPassesRigFilter(detail) {
+ if (!mapRigFilter) return true;
+ if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter);
+ return detail?.remote === mapRigFilter;
+ }
+
+ function renderMapQsoSummary() {
+ const listEl = document.getElementById("map-qso-summary-list");
+ if (!listEl) return;
+
+ const cutoff = _statsHistoryCutoffMs();
+ const entries = Array.from(decodeContactPaths.values())
+ .filter((entry) => entry
+ && Number.isFinite(entry.distanceKm)
+ && _statsDetailPassesRigFilter(entry)
+ && (!entry.tsMs || entry.tsMs >= cutoff))
+ .sort((a, b) => {
+ const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm);
+ if (Math.abs(distanceDelta) > 0.001) return distanceDelta;
+ return Number(b.tsMs || 0) - Number(a.tsMs || 0);
+ })
+ .slice(0, MAP_QSO_SUMMARY_LIMIT);
+
+ if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) {
+ selectedMapQsoKey = null;
+ }
+
+ if (entries.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "map-qso-summary-empty";
+ empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters.";
+ listEl.replaceChildren(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ entries.forEach((entry, index) => {
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "map-qso-card";
+ card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey);
+ card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false");
+ card.addEventListener("click", () => {
+ selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey;
+ syncDecodeContactPathVisibility();
+ if (selectedMapQsoKey && entry.sourceGrid) {
+ navigateToMapLocator(entry.sourceGrid, entry.sourceType);
+ }
+ });
+
+ const head = document.createElement("div");
+ head.className = "map-qso-card-head";
+
+ const rank = document.createElement("span");
+ rank.className = "map-qso-card-rank";
+ rank.textContent = `#${index + 1}`;
+ head.appendChild(rank);
+
+ const distance = document.createElement("span");
+ distance.className = "map-qso-card-distance";
+ distance.textContent = entry.distanceText || "--";
+ head.appendChild(distance);
+
+ const body = document.createElement("div");
+ body.className = "map-qso-card-body";
+
+ const pair = document.createElement("div");
+ pair.className = "map-qso-card-pair";
+ pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`;
+ body.appendChild(pair);
+
+ const meta = document.createElement("div");
+ meta.className = "map-qso-card-meta";
+
+ const sourceType = document.createElement("span");
+ sourceType.className = "map-qso-card-pill";
+ sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase();
+ meta.appendChild(sourceType);
+
+ if (entry.bandLabel) {
+ const band = document.createElement("span");
+ band.className = "map-qso-card-pill map-qso-card-band";
+ band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel));
+ band.textContent = entry.bandLabel;
+ meta.appendChild(band);
+ }
+
+ const ageText = formatTimeAgo(Number(entry.tsMs));
+ if (ageText) {
+ const age = document.createElement("span");
+ age.className = "map-qso-card-pill";
+ age.textContent = ageText;
+ meta.appendChild(age);
+ }
+
+ const rxLabel = _receiverLabel(entry.remote);
+ if (rxLabel) {
+ const rx = document.createElement("span");
+ rx.className = "map-qso-card-pill map-qso-card-rx";
+ rx.textContent = rxLabel;
+ meta.appendChild(rx);
+ }
+
+ body.appendChild(meta);
+
+ const grids = document.createElement("div");
+ grids.className = "map-qso-card-grids";
+ grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`;
+ body.appendChild(grids);
+
+ card.appendChild(head);
+ card.appendChild(body);
+ fragment.appendChild(card);
+ });
+
+ listEl.replaceChildren(fragment);
+ }
+
+ function renderMapSignalSummary() {
+ const listEl = document.getElementById("map-signal-summary-list");
+ if (!listEl) return;
+
+ const cutoff = _statsHistoryCutoffMs();
+ const bestByStation = new Map();
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
+ if (!(entry.stationDetails instanceof Map)) continue;
+ for (const detail of entry.stationDetails.values()) {
+ if (!Number.isFinite(detail?.snr_db)) continue;
+ if (!_statsDetailPassesRigFilter(detail)) continue;
+ if (detail.ts_ms && detail.ts_ms < cutoff) continue;
+ const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
+ if (!station) continue;
+ const snrDb = Number(detail.snr_db);
+ const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0;
+ const prev = bestByStation.get(station);
+ if (!prev || snrDb > prev.snrDb || (snrDb === prev.snrDb && tsMs > prev.tsMs)) {
+ bestByStation.set(station, {
+ station,
+ snrDb,
+ tsMs,
+ grid: entry.grid,
+ sourceType: entry.sourceType,
+ bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null,
+ remote: detail?.remote || null,
+ });
+ }
+ }
+ }
+
+ const entries = Array.from(bestByStation.values())
+ .sort((a, b) => {
+ const delta = b.snrDb - a.snrDb;
+ if (Math.abs(delta) > 0.001) return delta;
+ return b.tsMs - a.tsMs;
+ })
+ .slice(0, MAP_QSO_SUMMARY_LIMIT);
+
+ if (entries.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "map-qso-summary-empty";
+ empty.textContent = "No decoded signals with SNR data in the current map history.";
+ listEl.replaceChildren(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ entries.forEach((entry, index) => {
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "map-qso-card";
+ if (entry.grid) {
+ card.addEventListener("click", () => {
+ navigateToMapLocator(entry.grid, entry.sourceType);
+ });
+ }
+
+ const head = document.createElement("div");
+ head.className = "map-qso-card-head";
+
+ const rank = document.createElement("span");
+ rank.className = "map-qso-card-rank";
+ rank.textContent = `#${index + 1}`;
+ head.appendChild(rank);
+
+ const snr = document.createElement("span");
+ snr.className = "map-qso-card-distance";
+ snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`;
+ head.appendChild(snr);
+
+ const body = document.createElement("div");
+ body.className = "map-qso-card-body";
+
+ const pair = document.createElement("div");
+ pair.className = "map-qso-card-pair";
+ pair.textContent = entry.station;
+ body.appendChild(pair);
+
+ const meta = document.createElement("div");
+ meta.className = "map-qso-card-meta";
+
+ const sourceType = document.createElement("span");
+ sourceType.className = "map-qso-card-pill";
+ sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase();
+ meta.appendChild(sourceType);
+
+ if (entry.bandLabel) {
+ const band = document.createElement("span");
+ band.className = "map-qso-card-pill map-qso-card-band";
+ band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel));
+ band.textContent = entry.bandLabel;
+ meta.appendChild(band);
+ }
+
+ const ageText = formatTimeAgo(Number(entry.tsMs));
+ if (ageText) {
+ const age = document.createElement("span");
+ age.className = "map-qso-card-pill";
+ age.textContent = ageText;
+ meta.appendChild(age);
+ }
+
+ const rxLabel = _receiverLabel(entry.remote);
+ if (rxLabel) {
+ const rx = document.createElement("span");
+ rx.className = "map-qso-card-pill map-qso-card-rx";
+ rx.textContent = rxLabel;
+ meta.appendChild(rx);
+ }
+
+ body.appendChild(meta);
+
+ const grids = document.createElement("div");
+ grids.className = "map-qso-card-grids";
+ grids.textContent = entry.grid || "--";
+ body.appendChild(grids);
+
+ card.appendChild(head);
+ card.appendChild(body);
+ fragment.appendChild(card);
+ });
+ listEl.replaceChildren(fragment);
+ }
+
+ function renderMapWeakSignalSummary() {
+ const listEl = document.getElementById("map-weak-signal-summary-list");
+ if (!listEl) return;
+
+ const cutoff = _statsHistoryCutoffMs();
+ const worstByStation = new Map();
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr")) continue;
+ if (!(entry.stationDetails instanceof Map)) continue;
+ for (const detail of entry.stationDetails.values()) {
+ if (!Number.isFinite(detail?.snr_db)) continue;
+ if (!_statsDetailPassesRigFilter(detail)) continue;
+ if (detail.ts_ms && detail.ts_ms < cutoff) continue;
+ const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
+ if (!station) continue;
+ const snrDb = Number(detail.snr_db);
+ const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0;
+ const prev = worstByStation.get(station);
+ if (!prev || snrDb < prev.snrDb || (snrDb === prev.snrDb && tsMs > prev.tsMs)) {
+ worstByStation.set(station, {
+ station,
+ snrDb,
+ tsMs,
+ grid: entry.grid,
+ sourceType: entry.sourceType,
+ bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null,
+ remote: detail?.remote || null,
+ });
+ }
+ }
+ }
+
+ const entries = Array.from(worstByStation.values())
+ .sort((a, b) => {
+ const delta = a.snrDb - b.snrDb;
+ if (Math.abs(delta) > 0.001) return delta;
+ return b.tsMs - a.tsMs;
+ })
+ .slice(0, MAP_QSO_SUMMARY_LIMIT);
+
+ if (entries.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "map-qso-summary-empty";
+ empty.textContent = "No decoded signals with SNR data in the current map history.";
+ listEl.replaceChildren(empty);
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+ entries.forEach((entry, index) => {
+ const card = document.createElement("button");
+ card.type = "button";
+ card.className = "map-qso-card";
+ if (entry.grid) {
+ card.addEventListener("click", () => {
+ navigateToMapLocator(entry.grid, entry.sourceType);
+ });
+ }
+
+ const head = document.createElement("div");
+ head.className = "map-qso-card-head";
+
+ const rank = document.createElement("span");
+ rank.className = "map-qso-card-rank";
+ rank.textContent = `#${index + 1}`;
+ head.appendChild(rank);
+
+ const snr = document.createElement("span");
+ snr.className = "map-qso-card-distance";
+ snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`;
+ head.appendChild(snr);
+
+ const body = document.createElement("div");
+ body.className = "map-qso-card-body";
+
+ const pair = document.createElement("div");
+ pair.className = "map-qso-card-pair";
+ pair.textContent = entry.station;
+ body.appendChild(pair);
+
+ const meta = document.createElement("div");
+ meta.className = "map-qso-card-meta";
+
+ const sourceType = document.createElement("span");
+ sourceType.className = "map-qso-card-pill";
+ sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase();
+ meta.appendChild(sourceType);
+
+ if (entry.bandLabel) {
+ const band = document.createElement("span");
+ band.className = "map-qso-card-pill map-qso-card-band";
+ band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel));
+ band.textContent = entry.bandLabel;
+ meta.appendChild(band);
+ }
+
+ const ageText = formatTimeAgo(Number(entry.tsMs));
+ if (ageText) {
+ const age = document.createElement("span");
+ age.className = "map-qso-card-pill";
+ age.textContent = ageText;
+ meta.appendChild(age);
+ }
+
+ const rxLabel = _receiverLabel(entry.remote);
+ if (rxLabel) {
+ const rx = document.createElement("span");
+ rx.className = "map-qso-card-pill map-qso-card-rx";
+ rx.textContent = rxLabel;
+ meta.appendChild(rx);
+ }
+
+ body.appendChild(meta);
+
+ const grids = document.createElement("div");
+ grids.className = "map-qso-card-grids";
+ grids.textContent = entry.grid || "--";
+ body.appendChild(grids);
+
+ card.appendChild(head);
+ card.appendChild(body);
+ fragment.appendChild(card);
+ });
+ listEl.replaceChildren(fragment);
+ }
+
+ // ββ Statistics panel βββββββββββββββββββββββββββββββββββββββββββββββββ
+ let statsRigFilter = "";
+ let statsHistoryLimitMinutes = 1440;
+ const statsDecodeLog = []; // {type, ts_ms, remote}
+ const STATS_LOG_MAX = 50000;
+ const STATS_TYPE_COLORS = {
+ ft8: "#4fc3f7", ft4: "#81c784", ft2: "#aed581", wspr: "#ffb74d",
+ aprs: "#ce93d8", hf_aprs: "#ba68c8", ais: "#90a4ae", vdes: "#78909c",
+ cw: "#fff176",
+ };
+ const STATS_DX_BUCKETS = [
+ { label: "0β500 km", min: 0, max: 500 },
+ { label: "500β1k", min: 500, max: 1000 },
+ { label: "1kβ2k", min: 1000, max: 2000 },
+ { label: "2kβ5k", min: 2000, max: 5000 },
+ { label: "5kβ10k", min: 5000, max: 10000 },
+ { label: "10k+ km", min: 10000, max: Infinity },
+ ];
+
+ function _statsHistoryCutoffMs() {
+ return Date.now() - (statsHistoryLimitMinutes * 60 * 1000);
+ }
+
+ function _statsDetailPassesRigFilter(detail) {
+ if (!statsRigFilter) return true;
+ if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter);
+ return detail?.remote === statsRigFilter;
+ }
+
+ function updateStatsRigFilter() {
+ const el = document.getElementById("stats-rig-filter");
+ if (!el) return;
+ const prev = el.value;
+ while (el.options.length > 1) el.remove(1);
+ for (const id of T.lastRigIds) {
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = T.lastRigDisplayNames[id] || id;
+ el.appendChild(opt);
+ }
+ if (prev && T.lastRigIds.includes(prev)) {
+ el.value = prev;
+ } else {
+ el.value = "";
+ statsRigFilter = "";
+ }
+ }
+
+ function statsRecordDecode(type, remote, tsMs) {
+ statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null });
+ if (statsDecodeLog.length > STATS_LOG_MAX) {
+ statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX);
+ }
+ }
+
+ function _statsFilteredLog() {
+ const cutoff = _statsHistoryCutoffMs();
+ return statsDecodeLog.filter((e) => {
+ if (e.ts_ms < cutoff) return false;
+ if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false;
+ return true;
+ });
+ }
+
+ function renderStatsCounters() {
+ const cutoff = _statsHistoryCutoffMs();
+ const log = _statsFilteredLog();
+ const totalDecodes = log.length;
+
+ const uniqueStations = new Set();
+ const uniqueGrids = new Set();
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || !(entry.stationDetails instanceof Map)) continue;
+ for (const detail of entry.stationDetails.values()) {
+ if (detail?.ts_ms && detail.ts_ms < cutoff) continue;
+ if (!_statsDetailPassesRigFilter(detail)) continue;
+ const station = String(detail?.source || detail?.station || "").trim().toUpperCase();
+ if (station) uniqueStations.add(station);
+ }
+ if (entry.grid) {
+ const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some(
+ (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d)
+ );
+ if (hasVisible) uniqueGrids.add(entry.grid);
+ }
+ }
+
+ // Decode rate: decodes in last 60 seconds β per minute
+ const rateWindow = Date.now() - 60000;
+ const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length;
+
+ const setEl = (id, val) => {
+ const el = document.getElementById(id);
+ if (el) el.textContent = String(val);
+ };
+ setEl("stats-total-decodes", totalDecodes.toLocaleString());
+ setEl("stats-unique-stations", uniqueStations.size.toLocaleString());
+ setEl("stats-unique-grids", uniqueGrids.size.toLocaleString());
+ setEl("stats-decode-rate", recentCount.toLocaleString());
+ }
+
+ function _renderBarChart(containerId, data, emptyMsg) {
+ const el = document.getElementById(containerId);
+ if (!el) return;
+ if (!data || data.length === 0 || data.every((d) => d.count === 0)) {
+ el.replaceChildren();
+ const empty = document.createElement("div");
+ empty.className = "stats-bar-empty";
+ empty.textContent = emptyMsg || "No data available.";
+ el.appendChild(empty);
+ return;
+ }
+ const maxVal = Math.max(1, ...data.map((d) => d.count));
+ const fragment = document.createDocumentFragment();
+ for (const item of data) {
+ const row = document.createElement("div");
+ row.className = "stats-bar-row";
+
+ const label = document.createElement("span");
+ label.className = "stats-bar-label";
+ label.textContent = item.label;
+ row.appendChild(label);
+
+ const track = document.createElement("div");
+ track.className = "stats-bar-track";
+ const fill = document.createElement("div");
+ fill.className = "stats-bar-fill";
+ fill.style.width = `${(item.count / maxVal) * 100}%`;
+ fill.style.background = item.color || "var(--accent-green)";
+ track.appendChild(fill);
+ row.appendChild(track);
+
+ const count = document.createElement("span");
+ count.className = "stats-bar-count";
+ count.textContent = item.count.toLocaleString();
+ row.appendChild(count);
+
+ fragment.appendChild(row);
+ }
+ el.replaceChildren(fragment);
+ }
+
+ function renderStatsDecodeTypes() {
+ const log = _statsFilteredLog();
+ const counts = {};
+ for (const e of log) {
+ counts[e.type] = (counts[e.type] || 0) + 1;
+ }
+ const data = Object.entries(counts)
+ .map(([type, count]) => ({
+ label: type.toUpperCase(),
+ count,
+ color: STATS_TYPE_COLORS[type] || "#aaa",
+ }))
+ .sort((a, b) => b.count - a.count);
+ _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history.");
+ }
+
+ function renderStatsBandActivity() {
+ const cutoff = _statsHistoryCutoffMs();
+ const bandCounts = {};
+ for (const entry of locatorMarkers.values()) {
+ if (!entry || !(entry.stationDetails instanceof Map)) continue;
+ for (const detail of entry.stationDetails.values()) {
+ if (detail?.ts_ms && detail.ts_ms < cutoff) continue;
+ if (!_statsDetailPassesRigFilter(detail)) continue;
+ if (!Number.isFinite(detail?.freq_hz)) continue;
+ const band = bandForHz(Number(detail.freq_hz));
+ if (band) {
+ bandCounts[band.label] = (bandCounts[band.label] || 0) + 1;
+ }
+ }
+ }
+ const data = Object.entries(bandCounts)
+ .map(([label, count]) => ({
+ label,
+ count,
+ color: locatorBandChipColor(label),
+ }))
+ .sort((a, b) => b.count - a.count);
+ _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history.");
+ }
+
+ function renderStatsRigCompare() {
+ const section = document.getElementById("stats-rig-compare-section");
+ if (!section) return;
+ if (T.lastRigIds.length < 2) {
+ section.style.display = "none";
+ return;
+ }
+ section.style.display = "";
+ const cutoff = _statsHistoryCutoffMs();
+ const rigCounts = {};
+ for (const e of statsDecodeLog) {
+ if (e.ts_ms < cutoff) continue;
+ const rid = e.remote || "unknown";
+ rigCounts[rid] = (rigCounts[rid] || 0) + 1;
+ }
+ const data = Object.entries(rigCounts)
+ .map(([rid, count]) => ({
+ label: T.lastRigDisplayNames[rid] || rid,
+ count,
+ color: "var(--accent-green)",
+ }))
+ .sort((a, b) => b.count - a.count);
+ _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver.");
+ }
+
+ function renderStatsDxHistogram() {
+ const cutoff = _statsHistoryCutoffMs();
+ const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 }));
+ for (const entry of decodeContactPaths.values()) {
+ if (!entry || !Number.isFinite(entry.distanceKm)) continue;
+ if (entry.tsMs && entry.tsMs < cutoff) continue;
+ if (!_statsDetailPassesRigFilter(entry)) continue;
+ const km = entry.distanceKm;
+ for (const b of buckets) {
+ if (km >= b.min && km < b.max) { b.count++; break; }
+ }
+ }
+ const data = buckets.map((b) => ({
+ label: b.label,
+ count: b.count,
+ color: "#4fc3f7",
+ }));
+ _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history.");
+ }
+
+ let _statsRenderPending = false;
+ let _statsControlsWired = false;
+ function _wireStatsControls() {
+ if (_statsControlsWired) return;
+ const rigEl = document.getElementById("stats-rig-filter");
+ const histEl = document.getElementById("stats-history-limit");
+ if (!rigEl && !histEl) return; // template not yet cloned
+ _statsControlsWired = true;
+ if (rigEl) {
+ rigEl.addEventListener("change", () => {
+ statsRigFilter = rigEl.value;
+ scheduleStatsRender();
+ });
+ }
+ if (histEl) {
+ histEl.value = String(statsHistoryLimitMinutes);
+ histEl.addEventListener("change", () => {
+ statsHistoryLimitMinutes = Number(histEl.value) || 1440;
+ scheduleStatsRender();
+ });
+ }
+ }
+ function scheduleStatsRender() {
+ if (_statsRenderPending) return;
+ _statsRenderPending = true;
+ requestAnimationFrame(() => {
+ _statsRenderPending = false;
+ _wireStatsControls();
+ renderStatsCounters();
+ renderStatsDecodeTypes();
+ renderStatsBandActivity();
+ renderStatsRigCompare();
+ renderStatsDxHistogram();
+ renderMapQsoSummary();
+ renderMapSignalSummary();
+ renderMapWeakSignalSummary();
+ });
+ }
+
+ function buildBookmarkLocatorPopupHtml(grid, bookmarks) {
+ const list = Array.isArray(bookmarks) ? bookmarks : [];
+ const rows = list
+ .map((bm) => {
+ const title = escapeMapHtml(String(bm.name || "Bookmark"));
+ const freq = typeof bmFmtFreq === "function"
+ ? escapeMapHtml(bmFmtFreq(bm.freq_hz))
+ : escapeMapHtml(String(bm.freq_hz || "--"));
+ const mode = bm.mode ? ` Β· ${escapeMapHtml(String(bm.mode))}` : "";
+ return `${title} ${freq}${mode} `;
+ })
+ .join(" ");
+ return `${escapeMapHtml(grid)} Bookmarks: ${list.length || 1}` + (rows ? ` ${rows}` : "");
+ }
+
+ window.syncBookmarkMapLocators = function(bookmarks) {
+ const list = Array.isArray(bookmarks) ? bookmarks : [];
+ const grouped = new Map();
+ for (const bm of list) {
+ const grid = String(bm?.locator || "").trim().toUpperCase();
+ if (!grid) continue;
+ const bounds = maidenheadToBounds(grid);
+ if (!bounds) continue;
+ const key = `bookmark:${grid}`;
+ const bucket = grouped.get(key);
+ if (bucket) {
+ bucket.bookmarks.push(bm);
+ } else {
+ grouped.set(key, { grid, bounds, bookmarks: [bm] });
+ }
+ }
+
+ for (const [key, entry] of locatorMarkers.entries()) {
+ if (!key.startsWith("bookmark:")) continue;
+ if (!grouped.has(key)) {
+ if (entry && entry.marker) {
+ if (entry.marker === selectedLocatorMarker) {
+ setSelectedLocatorMarker(null);
+ clearMapRadioPath();
+ }
+ if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
+ mapMarkers.delete(entry.marker);
+ }
+ locatorMarkers.delete(key);
+ }
+ }
+
+ for (const [key, next] of grouped.entries()) {
+ const existing = locatorMarkers.get(key);
+ const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks);
+ const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz)));
+ if (existing) {
+ existing.grid = next.grid;
+ existing.bounds = next.bounds;
+ existing.bookmarks = next.bookmarks;
+ existing.sourceType = "bookmark";
+ existing.bandMeta = bandMeta;
+ if (existing.marker) {
+ existing.marker.setBounds(next.bounds);
+ existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length));
+ existing.marker.setPopupContent(popupHtml);
+ sendLocatorOverlayToBack(existing.marker);
+ assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta);
+ }
+ continue;
+ }
+
+ const entry = {
+ marker: null,
+ grid: next.grid,
+ bounds: next.bounds,
+ bookmarks: next.bookmarks,
+ sourceType: "bookmark",
+ bandMeta,
+ };
+ locatorMarkers.set(key, entry);
+ if (aprsMap) {
+ entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length))
+ .addTo(aprsMap)
+ .bindPopup(popupHtml);
+ entry.marker.__trxType = "bookmark";
+ sendLocatorOverlayToBack(entry.marker);
+ assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
+ mapMarkers.add(entry.marker);
+ }
+ }
+
+ rebuildMapLocatorFilters();
+ applyMapFilter();
+ };
+
+ window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) {
+ if (!Array.isArray(grids) || grids.length === 0) return;
+ const markerType = type === "wspr" ? "wspr" : (type === "ft4" ? "ft4" : (type === "ft2" ? "ft2" : "ft8"));
+ const msgRigId = details?.rig_id || T.lastActiveRigId;
+ const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
+ const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
+ const locatorDetails = new Map();
+ if (Array.isArray(details?.locator_details)) {
+ for (const locatorDetail of details.locator_details) {
+ const grid = String(locatorDetail?.grid || "").trim().toUpperCase();
+ if (!grid) continue;
+ locatorDetails.set(grid, locatorDetail);
+ }
+ }
+ for (const grid of unique) {
+ const bounds = maidenheadToBounds(grid);
+ if (!bounds) continue;
+ const locatorDetail = locatorDetails.get(grid);
+ const sourceId = locatorDetail?.source && String(locatorDetail.source).trim()
+ ? String(locatorDetail.source).trim().toUpperCase()
+ : "";
+ const targetId = locatorDetail?.target && String(locatorDetail.target).trim()
+ ? String(locatorDetail.target).trim().toUpperCase()
+ : "";
+ const detailStationId = sourceId || stationId;
+ const detailEntry = {
+ station: detailStationId || null,
+ source: sourceId || null,
+ target: targetId || null,
+ ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null,
+ snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null,
+ dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null,
+ freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null,
+ message: String(details?.message || message || "").trim() || null,
+ remote: msgRigId || null,
+ remotes: new Set(msgRigId ? [msgRigId] : []),
+ };
+ const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
+ const key = `${markerType}:${grid}`;
+ const existing = locatorMarkers.get(key);
+ if (existing) {
+ existing.grid = grid;
+ if (!(existing.allStationDetails instanceof Map)) {
+ existing.allStationDetails = existing.stationDetails instanceof Map
+ ? new Map(existing.stationDetails)
+ : new Map();
+ }
+ const prevDetail = existing.allStationDetails.get(detailKey);
+ const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set();
+ if (msgRigId) mergedRemotes.add(msgRigId);
+ existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes });
+ existing.sourceType = markerType;
+ if (msgRigId) {
+ if (!existing.rigIds) existing.rigIds = new Set();
+ existing.rigIds.add(msgRigId);
+ }
+ pruneLocatorEntry(key, existing, mapHistoryCutoffMs());
+ if (existing.marker) sendLocatorOverlayToBack(existing.marker);
+ scheduleDecodeMapMaintenance();
+ continue;
+ }
+
+ const allStationDetails = new Map();
+ allStationDetails.set(detailKey, { ...detailEntry });
+ const entry = {
+ marker: null,
+ grid,
+ stations: new Set(),
+ stationDetails: new Map(),
+ allStationDetails,
+ sourceType: markerType,
+ bandMeta: new Map(),
+ rigIds: new Set(msgRigId ? [msgRigId] : []),
+ };
+ locatorMarkers.set(key, entry);
+ pruneLocatorEntry(key, entry, mapHistoryCutoffMs());
+ if (entry.marker) sendLocatorOverlayToBack(entry.marker);
+ }
+ scheduleDecodeMapMaintenance();
+ };
+
+ // --- Sub-tab navigation ---
+ document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
+ bar.addEventListener("click", (e) => {
+ const btn = e.target.closest(".sub-tab[data-subtab]");
+ if (!btn) return;
+ bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
+ btn.classList.add("active");
+ const parent = bar.parentElement;
+ parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
+ const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
+ if (nextPanel) nextPanel.style.display = "";
+ if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) {
+ requestAnimationFrame(() => {
+ if (window.refreshCwTonePicker) window.refreshCwTonePicker();
+ });
+ }
+ // Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
+ if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
+ window.clearSatPredictionDom();
+ }
+ });
+ });
+
+ window.addEventListener("resize", () => {
+ const mapTab = document.getElementById("tab-map");
+ if (!mapTab || mapTab.style.display === "none") return;
+ sizeAprsMapToViewport();
+ });
+
+
+ function flushDeferredDecodeMapSync() {
+ if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return;
+ T.decodeMapSyncPending = false;
+ scheduleUiFrameJob("decode-map-maintenance", () => {
+ pruneMapHistory();
+ });
+ }
+
+
+ // Auto-init map if the map tab is already visible (e.g. direct /map navigation).
+ // Delegates to _initMapWhenReady() in app.js which handles the Leaflet load race.
+ function autoInitIfVisible() {
+ const panel = document.getElementById("tab-map");
+ if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") {
+ _initMapWhenReady();
+ }
+ }
+
+ // Register module API for core to call
+ modules.map = {
+ initAprsMap,
+ sizeAprsMapToViewport,
+ syncAprsReceiverMarker,
+ updateMapRigFilter,
+ updateStatsRigFilter,
+ statsRecordDecode,
+ scheduleStatsRender,
+ get aprsMap() { return aprsMap; },
+ get stationMarkers() { return stationMarkers; },
+ get locatorMarkers() { return locatorMarkers; },
+ get aisMarkers() { return aisMarkers; },
+ get vdesMarkers() { return vdesMarkers; },
+ get decodeContactPaths() { return decodeContactPaths; },
+ pruneMapHistory,
+ aprsSymbolIcon,
+ buildAprsPopupHtml,
+ buildAisPopupHtml,
+ buildVdesPopupHtml,
+ ensureAprsMarker,
+ ensureAisMarker,
+ ensureVdesMarker,
+ ensureDecodeLocatorMarker,
+ aprsPositionsEqual,
+ aisPositionsEqual,
+ refreshAprsTrack,
+ refreshAisTrack,
+ updateAisMarker,
+ createAisMarker,
+ getAisAccentColor,
+ refreshAisMarkerColors,
+ setMapRadioPathTo,
+ buildReceiverPopupHtml,
+ rebuildDecodeContactPaths,
+ syncDecodeContactPathVisibility,
+ scheduleDecodeMapMaintenance,
+ renderMapLocatorLegend,
+ rebuildMapLocatorFilters,
+ renderMapQsoSummary,
+ renderMapSignalSummary,
+ renderMapWeakSignalSummary,
+ vdesMarkerKey,
+ aisMarkerOptionsFromMessage,
+ materializeBufferedMapLayers,
+ flushDeferredDecodeMapSync,
+ bandForHz,
+ reverseGeocodeLocation,
+ };
+
+ // If the map tab is already visible (direct /map URL), init immediately.
+ autoInitIfVisible();
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js
new file mode 100644
index 00000000..3f6dae8b
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js
@@ -0,0 +1,407 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- AIS Decoder Plugin (server-side decode) ---
+const aisStatus = document.getElementById("ais-status");
+const aisMessagesEl = document.getElementById("ais-messages");
+const aisFilterInput = document.getElementById("ais-filter");
+const aisBarOverlay = document.getElementById("ais-bar-overlay");
+const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
+const aisVesselCountEl = document.getElementById("ais-vessel-count");
+const aisLatestSeenEl = document.getElementById("ais-latest-seen");
+const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
+const AIS_DEFAULT_A_HZ = 161_975_000;
+const AIS_CHANNEL_SPACING_HZ = 50_000;
+let aisFilterText = "";
+let aisMessageHistory = [];
+
+function currentAisHistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneAisMessageHistory() {
+ const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
+ aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
+}
+
+function scheduleAisUi(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleAisHistoryRender() {
+ scheduleAisUi("ais-history", () => renderAisHistory());
+}
+
+function scheduleAisBarUpdate() {
+ scheduleAisUi("ais-bar", () => updateAisBar());
+}
+
+function formatAisMhz(freqHz) {
+ return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
+}
+
+function currentAisChannelPlan() {
+ const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
+ const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
+ const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
+ return {
+ aHz: safeAHz,
+ bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
+ };
+}
+
+function aisChannelInfo(channel) {
+ const plan = currentAisChannelPlan();
+ const ch = String(channel || "").trim().toUpperCase();
+ if (ch === "B") {
+ return {
+ label: "AIS-B",
+ badgeClass: "ais-badge ais-badge-channel-b",
+ freqText: formatAisMhz(plan.bHz),
+ };
+ }
+ return {
+ label: "AIS-A",
+ badgeClass: "ais-badge ais-badge-channel-a",
+ freqText: formatAisMhz(plan.aHz),
+ };
+}
+
+function aisDisplayName(msg) {
+ return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
+}
+
+function aisDisplayNameHtml(msg) {
+ const label = escapeMapHtml(aisDisplayName(msg));
+ const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
+ if (!url) return label;
+ return `${label} `;
+}
+
+function aisTypeLabel(type) {
+ switch (Number(type)) {
+ case 1:
+ case 2:
+ case 3:
+ return "Class A Position";
+ case 4:
+ return "Base Station";
+ case 5:
+ return "Static/Voyage";
+ case 18:
+ return "Class B Position";
+ case 19:
+ return "Class B Extended";
+ case 21:
+ return "Aid to Nav";
+ case 24:
+ return "Class B Static";
+ default:
+ return `Type ${type ?? "--"}`;
+ }
+}
+
+function aisAgeText(tsMs) {
+ if (!Number.isFinite(tsMs)) return "just now";
+ const deltaMs = Math.max(0, Date.now() - tsMs);
+ const seconds = Math.round(deltaMs / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ return `${hours}h ago`;
+}
+
+function aisMotionText(msg) {
+ const parts = [
+ msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
+ msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}Β° COG` : null,
+ msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}Β° HDG` : null,
+ ].filter(Boolean);
+ return parts.join(" Β· ");
+}
+
+function aisRouteText(msg) {
+ return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
+}
+
+function aisDistanceText(msg) {
+ if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
+ return "";
+ }
+ const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
+ if (!Number.isFinite(distKm)) return "";
+ if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
+ return `${distKm.toFixed(1)} km from TRX`;
+}
+
+function aisLatestByVessel(messages) {
+ const byMmsi = new Map();
+ for (const msg of messages) {
+ const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
+ if (!byMmsi.has(key)) byMmsi.set(key, msg);
+ }
+ return Array.from(byMmsi.values());
+}
+
+function updateAisSummary() {
+ const plan = currentAisChannelPlan();
+ if (aisChannelSummaryEl) {
+ aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} Β· B ${formatAisMhz(plan.bHz)}`;
+ }
+
+ const vessels = aisLatestByVessel(aisMessageHistory);
+ if (aisVesselCountEl) {
+ const count = vessels.length;
+ aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
+ }
+
+ if (aisLatestSeenEl) {
+ const latest = aisMessageHistory[0];
+ if (!latest) {
+ aisLatestSeenEl.textContent = "No traffic yet";
+ } else {
+ const channel = aisChannelInfo(latest.channel);
+ aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
+ }
+ }
+}
+
+function renderAisRow(msg) {
+ const row = document.createElement("div");
+ row.className = "ais-message";
+ const ts = msg._ts || new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ const name = aisDisplayName(msg);
+ const nameHtml = aisDisplayNameHtml(msg);
+ const channel = aisChannelInfo(msg.channel);
+ const motion = aisMotionText(msg);
+ const route = aisRouteText(msg);
+ const distance = aisDistanceText(msg);
+ const pos = msg.lat != null && msg.lon != null
+ ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)} `
+ : "";
+ row.dataset.filterText = [
+ name,
+ msg.mmsi,
+ msg.channel,
+ channel.label,
+ msg.vessel_name,
+ msg.callsign,
+ msg.destination,
+ aisTypeLabel(msg.message_type),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toUpperCase();
+ row.innerHTML =
+ `` +
+ `${ts} ` +
+ `${nameHtml} ` +
+ `${escapeMapHtml(channel.label)} ` +
+ `${escapeMapHtml(aisTypeLabel(msg.message_type))} ` +
+ `
` +
+ `` +
+ `MMSI ${escapeMapHtml(String(msg.mmsi))} ` +
+ (route ? `${escapeMapHtml(route)} ` : "") +
+ `${escapeMapHtml(channel.freqText)} ` +
+ `
` +
+ `` +
+ (motion ? `${escapeMapHtml(motion)} ` : `No motion data `) +
+ (distance ? `${escapeMapHtml(distance)} ` : "") +
+ (pos ? `${pos} ` : "") +
+ `${escapeMapHtml(aisAgeText(msg._tsMs))} ` +
+ `
`;
+ applyAisFilterToRow(row);
+ return row;
+}
+
+function applyAisFilterToRow(row) {
+ if (!aisFilterText) {
+ row.style.display = "";
+ return;
+ }
+ const message = row.dataset.filterText || "";
+ row.style.display = message.includes(aisFilterText) ? "" : "none";
+}
+
+function applyAisFilterToAll() {
+ if (!aisMessagesEl) return;
+ const rows = aisMessagesEl.querySelectorAll(".ais-message");
+ rows.forEach((row) => applyAisFilterToRow(row));
+}
+
+function updateAisBar() {
+ if (!aisBarOverlay) return;
+ updateAisSummary();
+
+ const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
+ const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
+ const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
+ const messages = aisLatestByVessel(recent).slice(0, 8);
+ if (!isAis || messages.length === 0) {
+ aisBarOverlay.style.display = "none";
+ aisBarOverlay.innerHTML = "";
+ return;
+ }
+
+ let html = '';
+ for (const msg of messages) {
+ const ts = msg._ts ? `${msg._ts} ` : "";
+ const pin = msg.lat != null && msg.lon != null
+ ? `π `
+ : "";
+ const name = `${aisDisplayNameHtml(msg)} `;
+ const channel = aisChannelInfo(msg.channel);
+ const distance = aisDistanceText(msg);
+ const details = [
+ `MMSI ${escapeMapHtml(String(msg.mmsi))}`,
+ escapeMapHtml(channel.label),
+ msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
+ msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}Β°` : null,
+ distance ? escapeMapHtml(distance) : null,
+ escapeMapHtml(aisAgeText(msg._tsMs)),
+ ]
+ .filter(Boolean)
+ .join(" Β· ");
+ html += `` +
+ `
${ts}${pin}${name}: ${details}
` +
+ `
`;
+ }
+ aisBarOverlay.innerHTML = html;
+ aisBarOverlay.style.display = "flex";
+}
+window.updateAisBar = updateAisBar;
+window.clearAisBar = function() {
+ window.resetAisHistoryView();
+};
+
+window.resetAisHistoryView = function() {
+ if (aisMessagesEl) aisMessagesEl.innerHTML = "";
+ aisMessageHistory = [];
+ updateAisBar();
+ renderAisHistory();
+ if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
+};
+
+function renderAisHistory() {
+ pruneAisMessageHistory();
+ if (!aisMessagesEl) {
+ updateAisSummary();
+ return;
+ }
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < aisMessageHistory.length; i += 1) {
+ fragment.appendChild(renderAisRow(aisMessageHistory[i]));
+ }
+ aisMessagesEl.replaceChildren(fragment);
+ updateAisSummary();
+}
+
+function addAisMessage(msg) {
+ const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ msg._tsMs = tsMs;
+ msg._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+
+ aisMessageHistory.unshift(msg);
+ pruneAisMessageHistory();
+ scheduleAisBarUpdate();
+ scheduleAisHistoryRender();
+
+ if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
+ window.aisMapAddVessel(msg);
+ }
+}
+
+function normalizeServerAisMessage(msg) {
+ return {
+ rig_id: msg.rig_id || null,
+ channel: msg.channel,
+ message_type: msg.message_type,
+ mmsi: msg.mmsi,
+ lat: msg.lat,
+ lon: msg.lon,
+ sog_knots: msg.sog_knots,
+ cog_deg: msg.cog_deg,
+ heading_deg: msg.heading_deg,
+ vessel_name: msg.vessel_name,
+ callsign: msg.callsign,
+ destination: msg.destination,
+ ts_ms: msg.ts_ms,
+ };
+}
+
+window.onServerAisBatch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ if (aisStatus) aisStatus.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerAisMessage(msg);
+ const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
+ next._tsMs = tsMs;
+ next._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
+ window.aisMapAddVessel(next);
+ }
+ normalized.push(next);
+ }
+ normalized.reverse();
+ aisMessageHistory = normalized.concat(aisMessageHistory);
+ pruneAisMessageHistory();
+ scheduleAisBarUpdate();
+ scheduleAisHistoryRender();
+};
+
+window.restoreAisHistory = function(messages) {
+ window.onServerAisBatch(messages);
+};
+
+window.pruneAisHistoryView = function() {
+ pruneAisMessageHistory();
+ updateAisBar();
+ renderAisHistory();
+};
+
+document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_ais_decode");
+ window.resetAisHistoryView();
+ } catch (e) {
+ console.error("AIS history clear failed", e);
+ }
+});
+
+if (aisFilterInput) {
+ aisFilterInput.addEventListener("input", () => {
+ aisFilterText = aisFilterInput.value.trim().toUpperCase();
+ renderAisHistory();
+ });
+}
+
+window.onServerAis = function(msg) {
+ if (aisStatus) aisStatus.textContent = "Receiving";
+ addAisMessage(normalizeServerAisMessage(msg));
+};
+
+updateAisSummary();
+if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.js
new file mode 100644
index 00000000..de616b61
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.js
@@ -0,0 +1,498 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- APRS Decoder Plugin (server-side decode) ---
+const aprsStatus = document.getElementById("aprs-status");
+const aprsPacketsEl = document.getElementById("aprs-packets");
+const aprsFilterInput = document.getElementById("aprs-filter");
+const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
+const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
+const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
+const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
+const aprsTotalCountEl = document.getElementById("aprs-total-count");
+const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
+const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
+const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
+let aprsFilterText = "";
+let aprsPacketHistory = [];
+let aprsBarDismissedAtMs = 0;
+let aprsOnlyPos = false;
+let aprsHideCrc = false;
+let aprsCollapseDup = false;
+let aprsTypeFilter = "all";
+
+function currentAprsHistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneAprsPacketHistory() {
+ const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
+ aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
+}
+
+function scheduleAprsUi(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleAprsHistoryRender() {
+ scheduleAprsUi("aprs-history", () => renderAprsHistory());
+}
+
+function scheduleAprsBarUpdate() {
+ scheduleAprsUi("aprs-bar", () => updateAprsBar());
+}
+
+function renderAprsInfo(pkt) {
+ const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
+ if (bytes && bytes.length > 0) {
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ if (b >= 0x20 && b <= 0x7e) {
+ const ch = String.fromCharCode(b);
+ if (ch === "<") out += "<";
+ else if (ch === ">") out += ">";
+ else if (ch === "&") out += "&";
+ else if (ch === '"') out += """;
+ else out += ch;
+ } else {
+ const hex = b.toString(16).toUpperCase().padStart(2, "0");
+ out += `0x${hex} `;
+ }
+ }
+ return out;
+ }
+ const str = pkt.info || "";
+ let out = "";
+ for (let i = 0; i < str.length; i++) {
+ const code = str.charCodeAt(i);
+ if (code >= 0x20 && code <= 0x7e) {
+ const ch = str[i];
+ if (ch === "<") out += "<";
+ else if (ch === ">") out += ">";
+ else if (ch === "&") out += "&";
+ else if (ch === '"') out += """;
+ else out += ch;
+ } else {
+ const hex = code.toString(16).toUpperCase().padStart(2, "0");
+ out += `0x${hex} `;
+ }
+ }
+ return out;
+}
+
+function aprsPacketCategory(pkt) {
+ const type = String(pkt.type || "").toLowerCase();
+ const info = String(pkt.info || "").toLowerCase();
+ if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
+ if (type.includes("message") || info.startsWith(":")) return "message";
+ if (type.includes("weather") || info.startsWith("_")) return "weather";
+ if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
+ return "other";
+}
+
+function aprsCategoryLabel(category) {
+ switch (category) {
+ case "position": return "Position";
+ case "message": return "Message";
+ case "weather": return "Weather";
+ case "telemetry": return "Telemetry";
+ default: return "Other";
+ }
+}
+
+function aprsAgeText(tsMs) {
+ if (!Number.isFinite(tsMs)) return "just now";
+ const deltaMs = Math.max(0, Date.now() - tsMs);
+ const seconds = Math.round(deltaMs / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ return `${hours}h ago`;
+}
+
+function aprsDistanceText(pkt) {
+ if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
+ const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
+ if (!Number.isFinite(distKm)) return "";
+ if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
+ return `${distKm.toFixed(1)} km from TRX`;
+}
+
+function aprsPacketSignature(pkt) {
+ return [
+ pkt.srcCall || "",
+ pkt.destCall || "",
+ pkt.path || "",
+ pkt.info || "",
+ pkt.type || "",
+ pkt.lat != null ? pkt.lat.toFixed(4) : "",
+ pkt.lon != null ? pkt.lon.toFixed(4) : "",
+ ].join("|");
+}
+
+function aprsHexBytes(bytes) {
+ if (!Array.isArray(bytes) || bytes.length === 0) return "--";
+ return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
+}
+
+function aprsFilterMatch(pkt) {
+ if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
+ if (aprsHideCrc && !pkt.crcOk) return false;
+ if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
+ if (!aprsFilterText) return true;
+ const haystack = [
+ pkt.srcCall,
+ pkt.destCall,
+ pkt.path,
+ pkt.info,
+ pkt.type,
+ pkt.lat != null ? pkt.lat.toFixed(4) : "",
+ pkt.lon != null ? pkt.lon.toFixed(4) : "",
+ aprsPacketCategory(pkt),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toUpperCase();
+ return haystack.includes(aprsFilterText);
+}
+
+function aprsVisiblePackets() {
+ const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
+ return packets.filter(aprsFilterMatch);
+}
+
+function collapseAprsDuplicates(packets) {
+ const seen = new Set();
+ const out = [];
+ for (const pkt of packets) {
+ const key = aprsPacketSignature(pkt);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(pkt);
+ }
+ return out;
+}
+
+function updateAprsSummary() {
+ const visible = aprsVisiblePackets();
+ if (aprsTotalCountEl) {
+ aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
+ }
+ if (aprsVisibleCountEl) {
+ aprsVisibleCountEl.textContent = `${visible.length} shown`;
+ }
+ if (aprsLatestSeenEl) {
+ const latest = aprsPacketHistory[0];
+ if (!latest) {
+ aprsLatestSeenEl.textContent = "No packets yet";
+ } else {
+ aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
+ }
+ }
+}
+
+function updateAprsChipState() {
+ document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
+ btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
+ });
+ aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
+ aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
+ aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
+}
+
+function renderAprsRow(pkt, isFresh) {
+ const row = document.createElement("div");
+ row.className = "aprs-packet";
+ if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
+ if (isFresh) row.classList.add("aprs-packet-new");
+
+ const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ const age = aprsAgeText(pkt._tsMs);
+ const category = aprsPacketCategory(pkt);
+ const categoryLabel = aprsCategoryLabel(category);
+ const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
+ const pathBadge = pkt.path ? `${escapeMapHtml(pkt.path)} ` : "";
+ const crcBadge = pkt.crcOk ? "" : 'CRC Fail ';
+ let symbolHtml = "";
+ if (pkt.symbolTable && pkt.symbolCode) {
+ const sheet = pkt.symbolTable === "/" ? 0 : 1;
+ const code = pkt.symbolCode.charCodeAt(0) - 33;
+ const col = code % 16;
+ const row2 = Math.floor(code / 16);
+ const bgX = -(col * 24);
+ const bgY = -(row2 * 24);
+ symbolHtml = ` `;
+ }
+ const posLink = pkt.lat != null && pkt.lon != null
+ ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)} `
+ : "";
+ const distance = aprsDistanceText(pkt);
+ const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
+
+ row.innerHTML =
+ `` +
+ `${ts} ` +
+ symbolHtml +
+ `${escapeMapHtml(pkt.srcCall)} ` +
+ `>${escapeMapHtml(pkt.destCall || "")} ` +
+ `${escapeMapHtml(categoryLabel)} ` +
+ pathBadge +
+ crcBadge +
+ `
` +
+ `` +
+ `${escapeMapHtml(age)} ` +
+ (distance ? `${escapeMapHtml(distance)} ` : "") +
+ `${escapeMapHtml(pkt.type || "--")} ` +
+ `
` +
+ `` +
+ `${renderAprsInfo(pkt)} ` +
+ (posLink ? `${posLink} ` : "") +
+ `
` +
+ `` +
+ (pkt.lat != null && pkt.lon != null ? `
Map ` : "") +
+ (pkt.lat != null && pkt.lon != null ? `
Copy Coords ` : "") +
+ `
QRZ ` +
+ `
` +
+ `` +
+ `Details ` +
+ `` +
+ `Source ${escapeMapHtml(pkt.srcCall || "--")} ` +
+ `Destination ${escapeMapHtml(pkt.destCall || "--")} ` +
+ `Type ${escapeMapHtml(pkt.type || "--")} ` +
+ `Path ${escapeMapHtml(pkt.path || "--")} ` +
+ `Age ${escapeMapHtml(age)} ` +
+ `CRC ${pkt.crcOk ? "OK" : "Failed"} ` +
+ `Position ${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"} ` +
+ `Info ${escapeMapHtml(pkt.info || "--")} ` +
+ `Info Bytes ${escapeMapHtml(aprsHexBytes(pkt.info_bytes))} ` +
+ `
` +
+ ` `;
+
+ row.querySelectorAll("[data-aprs-map]").forEach((el) => {
+ el.addEventListener("click", (evt) => {
+ evt.preventDefault();
+ const raw = String(el.dataset.aprsMap || "");
+ const [lat, lon] = raw.split(",").map(Number);
+ if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
+ window.navigateToAprsMap(lat, lon);
+ }
+ });
+ });
+
+ const copyBtn = row.querySelector("[data-aprs-copy]");
+ if (copyBtn) {
+ copyBtn.addEventListener("click", async () => {
+ const raw = String(copyBtn.dataset.aprsCopy || "");
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(raw);
+ showHint("Coordinates copied", 1200);
+ }
+ } catch (_e) {
+ showHint("Copy failed", 1500);
+ }
+ });
+ }
+
+ return row;
+}
+
+function renderAprsHistory() {
+ pruneAprsPacketHistory();
+ if (!aprsPacketsEl) {
+ updateAprsSummary();
+ updateAprsChipState();
+ return;
+ }
+ const visible = aprsVisiblePackets();
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < visible.length; i++) {
+ fragment.appendChild(renderAprsRow(visible[i], i === 0));
+ }
+ aprsPacketsEl.replaceChildren(fragment);
+ updateAprsSummary();
+ updateAprsChipState();
+}
+
+function updateAprsBar() {
+ if (!aprsBarOverlay) return;
+ const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
+ const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
+ const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
+ const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
+ const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
+ if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
+ aprsBarOverlay.style.display = "none";
+ aprsBarOverlay.innerHTML = "";
+ return;
+ }
+ let html = '';
+ for (const pkt of frames) {
+ const ts = pkt._ts ? `${pkt._ts} ` : "";
+ const call = `${escapeMapHtml(pkt.srcCall)} `;
+ const dest = escapeMapHtml(pkt.destCall || "");
+ const info = escapeMapHtml(pkt.info || "");
+ const pin = pkt.lat != null && pkt.lon != null
+ ? `π `
+ : "";
+ html += `` +
+ `
${ts}${pin}${call}>${dest}: ${info}
` +
+ `
`;
+ }
+ aprsBarOverlay.innerHTML = html;
+ aprsBarOverlay.style.display = "flex";
+}
+window.updateAprsBar = updateAprsBar;
+window.clearAprsBar = function() {
+ window.resetAprsHistoryView();
+};
+window.closeAprsBar = function() {
+ aprsBarDismissedAtMs = Date.now();
+ if (aprsBarOverlay) {
+ aprsBarOverlay.style.display = "none";
+ aprsBarOverlay.innerHTML = "";
+ }
+};
+
+window.resetAprsHistoryView = function() {
+ if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
+ aprsPacketHistory = [];
+ updateAprsBar();
+ renderAprsHistory();
+ if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
+};
+
+window.pruneAprsHistoryView = function() {
+ pruneAprsPacketHistory();
+ updateAprsBar();
+ renderAprsHistory();
+};
+
+function addAprsPacket(pkt) {
+ const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
+ pkt._tsMs = tsMs;
+ pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+
+ aprsPacketHistory.unshift(pkt);
+ pruneAprsPacketHistory();
+
+ if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
+ window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
+ }
+
+ if (pkt.crcOk) scheduleAprsBarUpdate();
+
+ scheduleAprsHistoryRender();
+}
+
+function normalizeServerAprsPacket(pkt) {
+ return {
+ rig_id: pkt.rig_id || null,
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ srcCall: pkt.src_call,
+ destCall: pkt.dest_call,
+ path: pkt.path,
+ info: pkt.info,
+ info_bytes: pkt.info_bytes,
+ type: pkt.packet_type,
+ crcOk: pkt.crc_ok,
+ ts_ms: pkt.ts_ms,
+ lat: pkt.lat,
+ lon: pkt.lon,
+ symbolTable: pkt.symbol_table,
+ symbolCode: pkt.symbol_code,
+ };
+}
+
+window.onServerAprsBatch = function(packets) {
+ if (!Array.isArray(packets) || packets.length === 0) return;
+ aprsStatus.textContent = "Receiving";
+ const normalized = [];
+ let hasCrcOk = false;
+ for (const pkt of packets) {
+ const next = normalizeServerAprsPacket(pkt);
+ const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
+ next._tsMs = tsMs;
+ next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
+ window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
+ }
+ if (next.crcOk) hasCrcOk = true;
+ normalized.push(next);
+ }
+ normalized.reverse();
+ aprsPacketHistory = normalized.concat(aprsPacketHistory);
+ pruneAprsPacketHistory();
+ if (hasCrcOk) scheduleAprsBarUpdate();
+ scheduleAprsHistoryRender();
+};
+
+window.restoreAprsHistory = function(packets) {
+ window.onServerAprsBatch(packets);
+};
+
+document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_aprs_decode");
+ window.resetAprsHistoryView();
+ } catch (e) {
+ console.error("APRS history clear failed", e);
+ }
+});
+
+if (aprsOnlyPosBtn) {
+ aprsOnlyPosBtn.addEventListener("click", () => {
+ aprsOnlyPos = !aprsOnlyPos;
+ renderAprsHistory();
+ });
+}
+
+if (aprsHideCrcBtn) {
+ aprsHideCrcBtn.addEventListener("click", () => {
+ aprsHideCrc = !aprsHideCrc;
+ renderAprsHistory();
+ });
+}
+
+if (aprsCollapseDupBtn) {
+ aprsCollapseDupBtn.addEventListener("click", () => {
+ aprsCollapseDup = !aprsCollapseDup;
+ renderAprsHistory();
+ });
+}
+
+["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
+ const btn = document.getElementById(`aprs-type-${type}`);
+ if (!btn) return;
+ btn.addEventListener("click", () => {
+ aprsTypeFilter = type;
+ renderAprsHistory();
+ });
+});
+
+if (aprsFilterInput) {
+ aprsFilterInput.addEventListener("input", () => {
+ aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
+ renderAprsHistory();
+ });
+}
+
+// --- Server-side APRS decode handler ---
+window.onServerAprs = function(pkt) {
+ aprsStatus.textContent = "Receiving";
+ addAprsPacket(normalizeServerAprsPacket(pkt));
+};
+
+renderAprsHistory();
+if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.js
new file mode 100644
index 00000000..5499f2ea
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.js
@@ -0,0 +1,410 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+(function () {
+ "use strict";
+
+ function bgdSupportedIds() {
+ return (window.decoderRegistry || [])
+ .filter(function (d) { return d.background_decode; })
+ .map(function (d) { return d.id; });
+ }
+
+ let backgroundDecodeRole = null;
+ let currentRigId = null;
+ let currentConfig = null;
+ let bookmarkList = [];
+ let statusInterval = null;
+ let bgdDirty = false;
+
+ function initBackgroundDecode(rigId, role) {
+ backgroundDecodeRole = role;
+ currentRigId = rigId || null;
+ if (currentRigId) loadBackgroundDecode();
+ startStatusPolling();
+ }
+
+ function setBackgroundDecodeRig(rigId) {
+ const nextRigId = rigId || null;
+ if (nextRigId === currentRigId) return;
+ currentRigId = nextRigId;
+ if (!currentRigId) return;
+ loadBackgroundDecode();
+ }
+
+ function apiGetConfig(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiPutConfig(rigId, config) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiResetConfig(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId), {
+ method: "DELETE",
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetStatus(rigId) {
+ return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetBookmarks() {
+ return fetch("/bookmarks").then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function loadBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
+ .then(function ([config, bookmarks]) {
+ currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
+ bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
+ renderBackgroundDecode();
+ clearBgdDirty();
+ pollBackgroundDecodeStatus();
+ })
+ .catch(function (err) {
+ console.error("background decode load failed", err);
+ });
+ }
+
+ function supportedBookmarks() {
+ return bookmarkList.filter(function (bookmark) {
+ return bookmarkDecoderKinds(bookmark).length > 0;
+ });
+ }
+
+ function bookmarkDecoderKinds(bookmark) {
+ var ids = bgdSupportedIds();
+ var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
+ var explicit = decoders
+ .map(function (item) { return String(item || "").trim().toLowerCase(); })
+ .filter(function (item, index, arr) {
+ return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
+ });
+ if (explicit.length > 0) return explicit;
+ // Fall back: infer from mode via mode-bound entries in the registry.
+ var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
+ return (window.decoderRegistry || [])
+ .filter(function (d) {
+ return d.activation === "mode_bound" && d.background_decode
+ && d.active_modes.indexOf(mode) >= 0;
+ })
+ .map(function (d) { return d.id; });
+ }
+
+ function renderBackgroundDecode() {
+ if (!currentConfig) {
+ currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ setCheckbox("background-decode-enabled", !!currentConfig.enabled);
+ renderBookmarkChecklist();
+
+ const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
+ const panel = document.getElementById("background-decode-panel");
+ if (panel) {
+ panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
+ el.disabled = !isControl;
+ });
+ }
+ const saveBtn = document.getElementById("background-decode-save-btn");
+ const resetBtn = document.getElementById("background-decode-reset-btn");
+ if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
+ if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
+ }
+
+ function renderBookmarkChecklist(filterText) {
+ const container = document.getElementById("bgd-bookmark-checklist");
+ if (!container) return;
+ container.innerHTML = "";
+
+ const selectedIds = new Set(
+ currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
+ );
+ const all = supportedBookmarks();
+ const filter = (filterText || "").trim().toLowerCase();
+
+ const filtered = filter
+ ? all.filter(function (bm) {
+ var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
+ return text.indexOf(filter) >= 0;
+ })
+ : all;
+
+ if (filtered.length === 0) {
+ container.innerHTML = '' +
+ (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
+ '
';
+ return;
+ }
+
+ filtered.forEach(function (bookmark) {
+ var row = document.createElement("label");
+ row.className = "bgd-checklist-row";
+ var decoders = bookmarkDecoderKinds(bookmark);
+ var checked = selectedIds.has(bookmark.id) ? " checked" : "";
+ row.innerHTML =
+ ' ' +
+ '' + escHtml(bookmark.name) + ' ' +
+ '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " Β· " + decoders.join("/").toUpperCase()) + ' ';
+ row.querySelector("input").addEventListener("change", function (e) {
+ onChecklistToggle(bookmark.id, e.target.checked);
+ });
+ container.appendChild(row);
+ });
+ }
+
+ function onChecklistToggle(bookmarkId, checked) {
+ if (!currentConfig) {
+ currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
+ if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
+ currentConfig.bookmark_ids.push(bookmarkId);
+ } else if (!checked) {
+ currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
+ }
+ markBgdDirty();
+ }
+
+ function saveBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ const payload = {
+ remote: rigId,
+ enabled: !!document.getElementById("background-decode-enabled").checked,
+ bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
+ };
+ const btn = document.getElementById("background-decode-save-btn");
+ if (btn) btn.disabled = true;
+ apiPutConfig(rigId, payload)
+ .then(function (saved) {
+ currentConfig = saved;
+ renderBackgroundDecode();
+ clearBgdDirty();
+ pollBackgroundDecodeStatus();
+ showToast("Background decode saved.");
+ })
+ .catch(function (err) {
+ showToast("Save failed: " + err.message, true);
+ })
+ .finally(function () {
+ if (btn) btn.disabled = false;
+ });
+ }
+
+ async function resetBackgroundDecode() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
+ apiResetConfig(rigId)
+ .then(function (saved) {
+ currentConfig = saved;
+ renderBackgroundDecode();
+ clearBgdDirty();
+ pollBackgroundDecodeStatus();
+ showToast("Background decode reset.");
+ })
+ .catch(function (err) {
+ showToast("Reset failed: " + err.message, true);
+ });
+ }
+
+ function startStatusPolling() {
+ if (statusInterval) clearInterval(statusInterval);
+ statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
+ }
+
+ function pollBackgroundDecodeStatus() {
+ const rigId = currentRigId;
+ if (!rigId) return;
+ apiGetStatus(rigId)
+ .then(renderStatus)
+ .catch(function () {});
+ }
+
+ function renderStatus(status) {
+ const card = document.getElementById("background-decode-status-card");
+ if (!card) return;
+ const entries = Array.isArray(status && status.entries) ? status.entries : [];
+ if (!entries.length) {
+ card.textContent = "No background decode bookmarks configured.";
+ return;
+ }
+ const summary = [];
+ if (status.active_rig) {
+ if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
+ if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span Β±" + formatFreq(status.sample_rate / 2));
+ } else {
+ summary.push("This rig is not currently selected for audio.");
+ }
+ let html = summary.length ? '' + escHtml(summary.join(" Β· ")) + "
" : "";
+ html += '';
+ entries.forEach(function (entry) {
+ const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
+ const parts = [];
+ if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
+ if (entry.mode) parts.push(entry.mode);
+ if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
+ parts.push(entry.decoder_kinds.join("/").toUpperCase());
+ }
+ html +=
+ '
' +
+ '
' +
+ '
' + escHtml(name) + '
' +
+ '
' + escHtml(parts.join(" Β· ")) + '
' +
+ '
' +
+ '
' +
+ ' ' +
+ escHtml(prettyState(entry.state)) + '
' +
+ '
';
+ });
+ html += "
";
+ card.innerHTML = html;
+ }
+
+ function prettyState(state) {
+ switch (state) {
+ case "active": return "\u2713 Active";
+ case "out_of_span": return "\u25B3 Out of span";
+ case "waiting_for_spectrum": return "\u25B3 Waiting";
+ case "waiting_for_user": return "\u25B3 No user";
+ case "missing_bookmark": return "\u2717 Missing";
+ case "no_supported_decoders": return "\u2717 Unsupported";
+ case "disabled": return "\u25B3 Disabled";
+ case "handled_by_scheduler": return "\u25B3 Scheduler";
+ case "scheduler_has_control": return "\u25B3 Scheduler";
+ case "handled_by_virtual_channel": return "\u25B3 VChan";
+ default: return "\u25B3 Inactive";
+ }
+ }
+
+ function setCheckbox(id, value) {
+ const el = document.getElementById(id);
+ if (el) el.checked = !!value;
+ }
+
+ function formatFreq(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return "--";
+ if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
+ return hz + " Hz";
+ }
+
+ function escHtml(value) {
+ return String(value == null ? "" : value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function markBgdDirty() {
+ if (bgdDirty) return;
+ bgdDirty = true;
+ var btn = document.getElementById("background-decode-save-btn");
+ if (btn) btn.classList.add("sch-dirty");
+ }
+
+ function clearBgdDirty() {
+ bgdDirty = false;
+ var btn = document.getElementById("background-decode-save-btn");
+ if (btn) btn.classList.remove("sch-dirty");
+ }
+
+ function showToast(msg, isError) {
+ const el = document.getElementById("background-decode-toast");
+ if (!el) return;
+ el.textContent = msg;
+ el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
+ el.style.display = "block";
+ setTimeout(function () {
+ el.style.display = "none";
+ }, 3000);
+ }
+
+ function selectAllBookmarks() {
+ if (!currentConfig) {
+ currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ var ids = supportedBookmarks().map(function (bm) { return bm.id; });
+ currentConfig.bookmark_ids = ids;
+ renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
+ markBgdDirty();
+ }
+
+ function deselectAllBookmarks() {
+ if (!currentConfig) {
+ currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
+ }
+ currentConfig.bookmark_ids = [];
+ renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
+ markBgdDirty();
+ }
+
+ function wireBackgroundDecodeEvents() {
+ const filterInput = document.getElementById("bgd-bookmark-filter");
+ if (filterInput && !filterInput._wired) {
+ filterInput._wired = true;
+ filterInput.addEventListener("input", function () {
+ renderBookmarkChecklist(filterInput.value);
+ });
+ }
+
+ const enabledCb = document.getElementById("background-decode-enabled");
+ if (enabledCb && !enabledCb._wired) {
+ enabledCb._wired = true;
+ enabledCb.addEventListener("change", function () { markBgdDirty(); });
+ }
+
+ const selectAllBtn = document.getElementById("bgd-select-all-btn");
+ if (selectAllBtn && !selectAllBtn._wired) {
+ selectAllBtn._wired = true;
+ selectAllBtn.addEventListener("click", selectAllBookmarks);
+ }
+
+ const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
+ if (deselectAllBtn && !deselectAllBtn._wired) {
+ deselectAllBtn._wired = true;
+ deselectAllBtn.addEventListener("click", deselectAllBookmarks);
+ }
+
+ const saveBtn = document.getElementById("background-decode-save-btn");
+ if (saveBtn && !saveBtn._wired) {
+ saveBtn._wired = true;
+ saveBtn.addEventListener("click", saveBackgroundDecode);
+ }
+
+ const resetBtn = document.getElementById("background-decode-reset-btn");
+ if (resetBtn && !resetBtn._wired) {
+ resetBtn._wired = true;
+ resetBtn.addEventListener("click", resetBackgroundDecode);
+ }
+ }
+
+ window.initBackgroundDecode = initBackgroundDecode;
+ window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
+ window.setBackgroundDecodeRig = setBackgroundDecodeRig;
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js
new file mode 100644
index 00000000..ee784e69
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js
@@ -0,0 +1,807 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- Bookmarks Tab ---
+
+/** Current bookmark scope: "general" or a rig remote name. */
+let bmScope = "general";
+
+/** Build the ?scope= query string for a given or current bookmark scope. */
+function bmScopeParam(prefix, scope) {
+ const sep = prefix ? "&" : "?";
+ return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
+}
+
+var bmList = [];
+var bmRevision = 0;
+/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
+var bmOverlayList = [];
+var bmOverlayRevision = 0;
+let bmFilteredList = [];
+let bmEditId = null;
+let bmEditScope = null;
+let bmCurrentPage = 1;
+const BM_PAGE_SIZE = 25;
+const bmSelected = new Set();
+
+function bmFmtFreq(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return "--";
+ if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
+ if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
+ return hz + "\u202fHz";
+}
+
+function bmEsc(str) {
+ const d = document.createElement("div");
+ d.appendChild(document.createTextNode(String(str)));
+ return d.innerHTML;
+}
+
+function bmCanControl() {
+ return (
+ (typeof authEnabled !== "undefined" && !authEnabled) ||
+ (typeof authRole !== "undefined" && authRole === "control")
+ );
+}
+
+// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
+function bmSyncAccess() {
+ const canCtrl = bmCanControl();
+ const addBtn = document.getElementById("bm-add-btn");
+ const selectAllBtn = document.getElementById("bm-select-all-btn");
+ if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
+ if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
+}
+
+/** The listing scope: always the active rig (to merge general + rig bookmarks). */
+function bmListScope() {
+ const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
+ return rig || "general";
+}
+
+async function bmFetchOverlay() {
+ const overlayScope = bmListScope();
+ try {
+ const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ bmOverlayList = await resp.json();
+ } catch (e) {
+ console.error("Failed to fetch overlay bookmarks:", e);
+ bmOverlayList = [];
+ }
+ bmOverlayRevision++;
+ if (typeof window.syncBookmarkMapLocators === "function") {
+ window.syncBookmarkMapLocators(bmOverlayList);
+ }
+ if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
+}
+
+async function bmFetch(categoryFilter) {
+ let url = "/bookmarks";
+ let hasQuery = false;
+ if (categoryFilter && categoryFilter !== "") {
+ url += "?category=" + encodeURIComponent(categoryFilter);
+ hasQuery = true;
+ }
+ url += bmScopeParam(hasQuery);
+ const overlayPromise = bmFetchOverlay();
+ try {
+ const resp = await fetch(url);
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ bmList = await resp.json();
+ } catch (e) {
+ console.error("Failed to fetch bookmarks:", e);
+ bmList = [];
+ }
+ bmRevision++;
+ bmSelected.clear();
+ bmUpdateSelectionUi();
+ bmSyncAccess();
+ bmApplyFilters();
+ bmRefreshCategoryFilter(categoryFilter);
+ await overlayPromise;
+}
+
+function bmApplyFilters() {
+ const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
+ const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
+ let filtered = modeFilter
+ ? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
+ : bmList;
+ filtered = text
+ ? filtered.filter((bm) =>
+ (bm.name || "").toLowerCase().includes(text) ||
+ (bm.locator || "").toLowerCase().includes(text) ||
+ (bm.category || "").toLowerCase().includes(text) ||
+ (bm.comment || "").toLowerCase().includes(text)
+ )
+ : filtered;
+ bmFilteredList = filtered;
+ bmCurrentPage = 1;
+ bmRender(filtered);
+}
+
+async function bmRefreshCategoryFilter(keepValue) {
+ const sel = document.getElementById("bm-category-filter");
+ const modeSel = document.getElementById("bm-mode-filter");
+ if (!sel && !modeSel) return;
+ try {
+ const resp = await fetch("/bookmarks" + bmScopeParam(false));
+ if (!resp.ok) return;
+ const all = await resp.json();
+ if (sel) {
+ const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
+ while (sel.options.length > 1) sel.remove(1);
+ cats.forEach((cat) => {
+ const opt = document.createElement("option");
+ opt.value = cat;
+ opt.textContent = cat;
+ sel.add(opt);
+ });
+ if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
+ }
+ if (modeSel) {
+ const keepMode = modeSel.value;
+ const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
+ while (modeSel.options.length > 1) modeSel.remove(1);
+ modes.forEach((mode) => {
+ const opt = document.createElement("option");
+ opt.value = mode;
+ opt.textContent = mode;
+ modeSel.add(opt);
+ });
+ if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
+ }
+ } catch (_) {}
+}
+
+function bmRender(list) {
+ const tbody = document.getElementById("bm-tbody");
+ const emptyEl = document.getElementById("bm-empty");
+ const paginatorEl = document.getElementById("bm-paginator");
+ const pageSummaryEl = document.getElementById("bm-page-summary");
+ const pageIndicatorEl = document.getElementById("bm-page-indicator");
+ const prevBtn = document.getElementById("bm-page-prev");
+ const nextBtn = document.getElementById("bm-page-next");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+
+ if (list.length === 0) {
+ if (emptyEl) emptyEl.style.display = "";
+ if (paginatorEl) paginatorEl.style.display = "none";
+ return;
+ }
+ if (emptyEl) emptyEl.style.display = "none";
+
+ const canControl = bmCanControl();
+ const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
+ const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
+ bmCurrentPage = page;
+ const startIndex = (page - 1) * BM_PAGE_SIZE;
+ const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
+ const pageItems = list.slice(startIndex, endIndex);
+
+ const showScope = bmScope !== "general";
+ pageItems.forEach((bm) => {
+ const tr = document.createElement("tr");
+ tr.dataset.bmId = bm.id;
+ const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
+ const locatorCell = bm.locator || "--";
+ const catCell = bm.category || "Uncategorised";
+ const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
+ const commentCell = bm.comment || "";
+ const checked = bmSelected.has(bm.id) ? " checked" : "";
+ const scopeBadge = showScope && bm.scope === "general" ? ' G ' : "";
+ tr.innerHTML =
+ ` ` +
+ `${bmEsc(bm.name)}${scopeBadge} ` +
+ `${bmFmtFreq(bm.freq_hz)} ` +
+ `${bmEsc(bm.mode)} ` +
+ `${bwCell} ` +
+ `${bmEsc(locatorCell)} ` +
+ `${bmEsc(catCell)} ` +
+ `${bmEsc(decoderCell)} ` +
+ `${bmEsc(commentCell)} ` +
+ `` +
+ `Tune ` +
+ (canControl
+ ? `Edit ` +
+ `Delete `
+ : "") +
+ ` `;
+ tbody.appendChild(tr);
+ });
+ bmSyncSelectAllCheckbox();
+
+ if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
+ if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
+ if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
+ if (prevBtn) prevBtn.disabled = page <= 1;
+ if (nextBtn) nextBtn.disabled = page >= totalPages;
+}
+
+function bmChangePage(delta) {
+ const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
+ const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
+ if (nextPage === bmCurrentPage) return;
+ bmCurrentPage = nextPage;
+ bmRender(bmFilteredList);
+}
+
+// Read decoder checkboxes and return an array of selected decoder names.
+function bmReadDecoders() {
+ return (window.decoderRegistry || [])
+ .filter(d => d.bookmark_selectable)
+ .filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
+ .map(d => d.id);
+}
+
+// Set decoder checkboxes to match the given array.
+function bmWriteDecoders(decoders) {
+ const set = new Set(decoders || []);
+ (window.decoderRegistry || [])
+ .filter(d => d.bookmark_selectable)
+ .forEach(d => {
+ const el = document.getElementById("bm-dec-" + d.id);
+ if (el) el.checked = set.has(d.id);
+ });
+}
+
+// Build decoder checkboxes dynamically from the registry.
+function bmBuildDecoderCheckboxes() {
+ const container = document.getElementById("bm-decoder-checkboxes");
+ if (!container) return;
+ container.innerHTML = "";
+ (window.decoderRegistry || [])
+ .filter(d => d.bookmark_selectable)
+ .forEach(d => {
+ const label = document.createElement("label");
+ label.className = "bm-decoder-check";
+ label.innerHTML = ' ' + d.label;
+ container.appendChild(label);
+ });
+}
+
+function bmOpenForm(bm) {
+ const wrap = document.getElementById("bm-form-wrap");
+ if (!wrap) return;
+ bmEditId = bm ? bm.id : null;
+ bmEditScope = bm ? (bm.scope || bmScope) : null;
+
+ // Rebuild decoder checkboxes from registry (handles race where registry
+ // loaded after initial build).
+ bmBuildDecoderCheckboxes();
+
+ document.getElementById("bm-id").value = bm ? bm.id : "";
+ document.getElementById("bm-name").value = bm ? bm.name : "";
+ document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
+ document.getElementById("bm-mode").value = bm ? bm.mode : "";
+ document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
+ document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
+ document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
+ document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
+ bmWriteDecoders(bm ? bm.decoders : []);
+ document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
+
+ wrap.style.display = "flex";
+ document.getElementById("bm-name").focus();
+}
+
+function bmCloseForm() {
+ const wrap = document.getElementById("bm-form-wrap");
+ if (wrap) wrap.style.display = "none";
+ bmEditId = null;
+}
+
+function bmPrefillFromStatus() {
+ // Use globals maintained by app.js (updated by SSE stream)
+ if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
+ document.getElementById("bm-freq").value = Math.round(lastFreqHz);
+ }
+ if (typeof lastModeName === "string" && lastModeName) {
+ document.getElementById("bm-mode").value = lastModeName;
+ }
+ if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
+ document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
+ }
+ // Prefill decoder checkboxes from current toggle button state.
+ const activeDecoders = (window.decoderRegistry || [])
+ .filter(d => d.bookmark_selectable && d.activation === "toggle")
+ .filter(d => {
+ const btn = document.getElementById(d.id + "-decode-toggle-btn");
+ return btn && btn.dataset.enabled === "true";
+ })
+ .map(d => d.id);
+ bmWriteDecoders(activeDecoders);
+}
+
+async function bmSave(e) {
+ e.preventDefault();
+ const id = document.getElementById("bm-id").value;
+ const name = document.getElementById("bm-name").value.trim();
+ const freqStr = document.getElementById("bm-freq").value;
+ const freq_hz = parseInt(freqStr, 10);
+ const mode = document.getElementById("bm-mode").value.trim();
+ const bwStr = document.getElementById("bm-bw").value;
+ const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
+ const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
+ const category = document.getElementById("bm-category-input").value.trim();
+ const comment = document.getElementById("bm-comment").value.trim();
+ const decoders = bmReadDecoders();
+
+ const formError = document.getElementById("bm-form-error");
+ if (formError) formError.textContent = "";
+ if (!name || !Number.isFinite(freq_hz) || !mode) {
+ if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
+ const invalid = !name ? document.getElementById("bm-name")
+ : !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
+ invalid?.focus();
+ return;
+ }
+
+ const body = {
+ name,
+ freq_hz,
+ mode,
+ bandwidth_hz,
+ locator: locator || null,
+ category,
+ comment,
+ decoders,
+ };
+
+ try {
+ let resp;
+ if (id) {
+ resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ } else {
+ resp = await fetch("/bookmarks" + bmScopeParam(false), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ }
+ if (!resp.ok) {
+ const text = await resp.text();
+ if (resp.status === 409) {
+ throw new Error("A bookmark for that frequency already exists.");
+ }
+ throw new Error(text || "HTTP " + resp.status);
+ }
+ bmCloseForm();
+ await bmFetch(document.getElementById("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to save bookmark:", err);
+ if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
+ window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
+ }
+}
+
+async function bmDelete(id) {
+ if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
+ const bm = bmList.find((b) => b.id === id);
+ const scope = bm ? bm.scope : undefined;
+ try {
+ const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
+ method: "DELETE",
+ });
+ if (!resp.ok) throw new Error("HTTP " + resp.status);
+ await bmFetch(document.getElementById("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to delete bookmark:", err);
+ window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
+ }
+}
+
+async function bmApply(bm) {
+ try {
+ // --- Optimistic UI updates (instant, before any network round-trips) ---
+ if (typeof modeEl !== "undefined" && modeEl) {
+ modeEl.value = String(bm.mode || "").toUpperCase();
+ }
+ if (bm.bandwidth_hz) {
+ if (typeof currentBandwidthHz !== "undefined") {
+ currentBandwidthHz = bm.bandwidth_hz;
+ }
+ window.currentBandwidthHz = bm.bandwidth_hz;
+ if (typeof syncBandwidthInput === "function") {
+ syncBandwidthInput(bm.bandwidth_hz);
+ }
+ }
+ if (typeof applyLocalTunedFrequency === "function") {
+ // Set optimistic guard before applying so SSE cannot snap back.
+ if (typeof _freqOptimisticSeq !== "undefined") {
+ ++_freqOptimisticSeq;
+ _freqOptimisticHz = bm.freq_hz;
+ }
+ // Force display so the BW overlay is repositioned even when freq is unchanged.
+ applyLocalTunedFrequency(bm.freq_hz, true);
+ }
+ if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
+ scheduleSpectrumDraw();
+ }
+
+ // Take scheduler control up front, then apply mode before bandwidth so a
+ // late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
+ const tunePromise = (async () => {
+ if (typeof vchanTakeSchedulerControl === "function") {
+ await vchanTakeSchedulerControl();
+ }
+
+ const onVirtual = typeof vchanInterceptMode === "function"
+ && await vchanInterceptMode(bm.mode);
+ if (!onVirtual) {
+ await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
+ }
+
+ if (bm.bandwidth_hz) {
+ const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
+ && await vchanInterceptBandwidth(bm.bandwidth_hz);
+ if (!bwHandledByVchan) {
+ await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
+ }
+ }
+
+ // setRigFrequency is wrapped by vchan.js to redirect to the channel API
+ // when on a virtual channel, so this call works correctly in both cases.
+ // It also does its own optimistic update (applyLocalTunedFrequency) but
+ // that's a no-op since we already set the same value above.
+ if (typeof setRigFrequency === "function") {
+ await setRigFrequency(bm.freq_hz);
+ } else {
+ await postPath("/set_freq?hz=" + bm.freq_hz);
+ }
+ })();
+ // Decoder toggles β fire-and-forget.
+ // - Decoders incompatible with the new mode are always turned off
+ // (even when the bookmark has no explicit decoder selection).
+ // - For compatible decoders, if the bookmark specifies a set, the
+ // toggles are driven to match that set; otherwise they're left
+ // alone.
+ const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
+ const modeUp = (bm.mode || "").toUpperCase();
+ const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
+ d.activation === "toggle"
+ );
+ const decoderPromise = allToggleDecoders.length ? (async () => {
+ let statusUrl = "/status";
+ if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
+ statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
+ }
+ const statusResp = await fetch(statusUrl);
+ if (!statusResp.ok) return;
+ const st = await statusResp.json();
+ const toggles = [];
+ for (const d of allToggleDecoders) {
+ const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
+ const currentlyOn = !!st[statusKey];
+ const compatible = Array.isArray(d.active_modes)
+ && d.active_modes.includes(modeUp);
+ let wanted;
+ if (!compatible) {
+ // Always disable decoders that don't apply to the new mode.
+ wanted = false;
+ } else if (hasDecoders) {
+ wanted = bm.decoders.includes(d.id);
+ } else {
+ // Mode-compatible and no bookmark selection: leave as-is.
+ wanted = currentlyOn;
+ }
+ if (wanted !== currentlyOn) {
+ toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
+ }
+ }
+ if (toggles.length) await Promise.all(toggles);
+ })() : Promise.resolve();
+ // Don't await β let the network calls settle in the background.
+ // Errors are logged but don't block the UI.
+ Promise.all([tunePromise, decoderPromise]).catch(
+ (err) => console.error("Bookmark apply background error:", err)
+ );
+ } catch (err) {
+ console.error("Failed to apply bookmark:", err);
+ }
+}
+
+function bmUpdateSelectionUi() {
+ const count = bmSelected.size;
+ const canCtrl = bmCanControl();
+ const visible = count > 0 && canCtrl;
+ const btn = document.getElementById("bm-del-selected-btn");
+ const countEl = document.getElementById("bm-del-selected-count");
+ if (btn) btn.style.display = visible ? "" : "none";
+ if (countEl) countEl.textContent = count;
+ const moveWrap = document.getElementById("bm-move-selected-wrap");
+ const moveCountEl = document.getElementById("bm-move-selected-count");
+ if (moveWrap) moveWrap.style.display = visible ? "" : "none";
+ if (moveCountEl) moveCountEl.textContent = count;
+ if (visible) bmPopulateMoveTarget();
+ const selectAllBtn = document.getElementById("bm-select-all-btn");
+ if (selectAllBtn && bmCanControl()) {
+ const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
+ selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
+ }
+}
+
+/** Populate the move-target dropdown with all scopes except the current one. */
+function bmPopulateMoveTarget() {
+ const sel = document.getElementById("bm-move-target");
+ if (!sel) return;
+ const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
+ const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
+ const prev = sel.value;
+ sel.innerHTML = "";
+ if (bmScope !== "general") {
+ const opt = document.createElement("option");
+ opt.value = "general";
+ opt.textContent = "General";
+ sel.appendChild(opt);
+ }
+ rigIds.forEach((id) => {
+ if (id === bmScope) return;
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = displayNames[id] || id;
+ sel.appendChild(opt);
+ });
+ if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
+ sel.value = prev;
+ }
+}
+
+async function bmMoveSelected() {
+ const ids = Array.from(bmSelected);
+ if (ids.length === 0) return;
+ const target = document.getElementById("bm-move-target")?.value;
+ if (!target) return;
+ const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
+ if (!await window.trxUi.confirm({
+ title: "Move selected bookmarks?",
+ message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to β${targetLabel}β.`,
+ confirmLabel: "Move",
+ danger: false,
+ })) return;
+ try {
+ // Group selected IDs by their owning scope (skip if already in target).
+ const byScope = {};
+ for (const id of ids) {
+ const bm = bmList.find((b) => b.id === id);
+ const scope = bm?.scope || bmScope;
+ if (scope === target) continue;
+ (byScope[scope] ||= []).push(id);
+ }
+ await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
+ fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: scopeIds, to: target }),
+ }).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
+ ));
+ bmSelected.clear();
+ bmUpdateSelectionUi();
+ await bmFetch(document.getElementById("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to move bookmarks:", err);
+ window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
+ }
+}
+
+function bmSyncSelectAllCheckbox() {
+ const selectAll = document.getElementById("bm-select-all");
+ if (!selectAll) return;
+ const checkboxes = document.querySelectorAll(".bm-row-sel");
+ if (checkboxes.length === 0) {
+ selectAll.checked = false;
+ selectAll.indeterminate = false;
+ return;
+ }
+ const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
+ selectAll.checked = checkedCount === checkboxes.length;
+ selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
+}
+
+async function bmDeleteSelected() {
+ const ids = Array.from(bmSelected);
+ if (ids.length === 0) return;
+ if (!await window.trxUi.confirm({
+ title: "Delete selected bookmarks?",
+ message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
+ confirmLabel: "Delete",
+ })) return;
+ try {
+ // Group selected IDs by their owning scope.
+ const byScope = {};
+ for (const id of ids) {
+ const bm = bmList.find((b) => b.id === id);
+ const scope = bm?.scope || bmScope;
+ (byScope[scope] ||= []).push(id);
+ }
+ await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
+ fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: scopeIds }),
+ }).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
+ ));
+ bmSelected.clear();
+ bmUpdateSelectionUi();
+ await bmFetch(document.getElementById("bm-category-filter").value);
+ } catch (err) {
+ console.error("Failed to delete bookmarks:", err);
+ window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
+ }
+}
+
+/** Populate the scope picker with "General" + one option per rig. */
+function bmPopulateScopePicker() {
+ const picker = document.getElementById("bm-scope-picker");
+ if (!picker) return;
+ const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
+ const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
+ // Preserve current selection if still valid.
+ const prev = picker.value;
+ while (picker.options.length > 1) picker.remove(1);
+ rigIds.forEach((id) => {
+ const opt = document.createElement("option");
+ opt.value = id;
+ opt.textContent = displayNames[id] || id;
+ picker.appendChild(opt);
+ });
+ if (prev && (prev === "general" || rigIds.includes(prev))) {
+ picker.value = prev;
+ } else {
+ picker.value = "general";
+ }
+ bmScope = picker.value;
+}
+
+// --- Event wiring ---
+(function initBookmarks() {
+ // Set initial button visibility (auth may already be resolved by the time
+ // scripts run if auth is disabled; otherwise bmFetch() will sync it).
+ bmSyncAccess();
+
+ // Build decoder checkboxes from registry. The registry is fetched async
+ // so we rebuild once it arrives to ensure checkboxes are present.
+ bmBuildDecoderCheckboxes();
+ if (typeof window.onDecoderRegistryReady === "function") {
+ window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
+ }
+
+ // Scope picker
+ bmPopulateScopePicker();
+ const scopePicker = document.getElementById("bm-scope-picker");
+ if (scopePicker) {
+ scopePicker.addEventListener("change", (e) => {
+ bmScope = e.target.value;
+ bmFetch(document.getElementById("bm-category-filter")?.value || "");
+ });
+ }
+
+ // Refresh list and sync access when the Bookmarks tab is activated
+ document.querySelector(".tab-bar").addEventListener("click", (e) => {
+ const btn = e.target.closest('.tab[data-tab="bookmarks"]');
+ if (!btn) return;
+ bmFetch(document.getElementById("bm-category-filter").value);
+ });
+
+ // Add Bookmark button β open form and prefill from current rig state
+ document.getElementById("bm-add-btn").addEventListener("click", () => {
+ bmOpenForm(null);
+ bmPrefillFromStatus();
+ });
+
+ // Category filter dropdown
+ document.getElementById("bm-category-filter").addEventListener("change", (e) => {
+ bmFetch(e.target.value);
+ });
+
+ // Mode filter dropdown (client-side, no re-fetch)
+ document.getElementById("bm-mode-filter").addEventListener("change", () => {
+ bmApplyFilters();
+ });
+
+ // Text search filter (client-side, no re-fetch)
+ document.getElementById("bm-text-filter").addEventListener("input", () => {
+ bmApplyFilters();
+ });
+
+ document.getElementById("bm-page-prev").addEventListener("click", () => {
+ bmChangePage(-1);
+ });
+
+ document.getElementById("bm-page-next").addEventListener("click", () => {
+ bmChangePage(1);
+ });
+
+ // Form submit
+ document.getElementById("bm-form").addEventListener("submit", bmSave);
+
+ // Form cancel
+ document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
+
+ const formWrap = document.getElementById("bm-form-wrap");
+ if (formWrap) {
+ formWrap.addEventListener("click", (e) => {
+ if (e.target === formWrap) bmCloseForm();
+ });
+ }
+
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
+ bmCloseForm();
+ }
+ });
+
+ // Select-all checkbox
+ document.getElementById("bm-select-all").addEventListener("change", (e) => {
+ const checked = e.target.checked;
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ cb.checked = checked;
+ if (checked) bmSelected.add(cb.dataset.bmId);
+ else bmSelected.delete(cb.dataset.bmId);
+ });
+ bmUpdateSelectionUi();
+ });
+
+ // Select All (across all pages) button
+ document.getElementById("bm-select-all-btn").addEventListener("click", () => {
+ const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
+ if (allSelected) {
+ bmSelected.clear();
+ } else {
+ bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
+ }
+ // Sync visible page checkboxes
+ document.querySelectorAll(".bm-row-sel").forEach((cb) => {
+ cb.checked = bmSelected.has(cb.dataset.bmId);
+ });
+ bmSyncSelectAllCheckbox();
+ bmUpdateSelectionUi();
+ });
+
+ // Delete Selected button
+ document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
+ bmDeleteSelected();
+ });
+
+ // Move Selected button
+ document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
+ bmMoveSelected();
+ });
+
+ // Table action buttons and row checkboxes (event delegation)
+ document.getElementById("bm-tbody").addEventListener("click", async (e) => {
+ const checkbox = e.target.closest(".bm-row-sel");
+ if (checkbox) {
+ if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
+ else bmSelected.delete(checkbox.dataset.bmId);
+ bmSyncSelectAllCheckbox();
+ bmUpdateSelectionUi();
+ return;
+ }
+
+ const tuneBtn = e.target.closest(".bm-tune-btn");
+ const editBtn = e.target.closest(".bm-edit-btn");
+ const delBtn = e.target.closest(".bm-del-btn");
+
+ if (tuneBtn) {
+ const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
+ if (bm) await bmApply(bm);
+ } else if (editBtn) {
+ const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
+ if (bm) bmOpenForm(bm);
+ } else if (delBtn) {
+ await bmDelete(delBtn.dataset.bmId);
+ }
+ });
+
+ // Pre-load bookmarks so spectrum markers are visible immediately.
+ bmFetch("");
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js
new file mode 100644
index 00000000..8ffd2ab1
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js
@@ -0,0 +1,451 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- CW (Morse) Decoder Plugin (server-side decode) ---
+const cwStatusEl = document.getElementById("cw-status");
+const cwOutputEl = document.getElementById("cw-output");
+const cwAutoInput = document.getElementById("cw-auto");
+const cwWpmInput = document.getElementById("cw-wpm");
+const cwToneInput = document.getElementById("cw-tone");
+const cwSignalIndicator = document.getElementById("cw-signal-indicator");
+const cwToneCanvas = document.getElementById("cw-tone-waterfall");
+const cwToneGl = typeof createTrxWebGlRenderer === "function"
+ ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
+ : null;
+const cwTonePickerEl = document.querySelector(".cw-tone-picker");
+const cwToneRangeEl = document.getElementById("cw-tone-range");
+const cwBarOverlay = document.getElementById("cw-bar-overlay");
+const CW_MAX_LINES = 200;
+const CW_TONE_MIN_HZ = 100;
+const CW_TONE_MAX_HZ = 10_000;
+const CW_WPM_MIN = 5;
+const CW_WPM_MAX = 40;
+const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
+const CW_BAR_LINE_GAP_MS = 5000;
+let cwLastAppendTime = 0;
+let cwTonePickerRaf = null;
+let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
+let cwBarCurrentLine = null; // accumulates chars until gap/newline
+let cwBarDismissedAtMs = 0;
+// Tracks a user-initiated auto toggle that is in-flight (POST not yet
+// acknowledged). While set, server-state updates must not override the
+// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
+// does not immediately undo the user's choice.
+let cwAutoLocalOverride = null;
+
+function applyCwAutoUi(enabled) {
+ if (cwAutoInput) cwAutoInput.checked = enabled;
+ if (cwWpmInput) {
+ cwWpmInput.disabled = enabled;
+ cwWpmInput.readOnly = enabled;
+ }
+ if (cwToneInput) {
+ cwToneInput.disabled = enabled;
+ cwToneInput.readOnly = enabled;
+ }
+ if (cwTonePickerEl) {
+ cwTonePickerEl.classList.toggle("is-auto", enabled);
+ }
+}
+window.applyCwAutoUi = applyCwAutoUi;
+
+// Called by app.js render() when a server-state snapshot arrives. Ignores
+// the update while cwAutoLocalOverride is set (user change still in-flight).
+window.applyCwAutoUiFromServer = function(enabled) {
+ if (cwAutoLocalOverride !== null) return;
+ applyCwAutoUi(enabled);
+};
+
+function cwBarFlushCurrentLine() {
+ if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
+ cwBarHistory.unshift(cwBarCurrentLine);
+ if (cwBarHistory.length > 50) cwBarHistory.length = 50;
+ }
+ cwBarCurrentLine = null;
+}
+
+function updateCwBar() {
+ if (!cwBarOverlay) return;
+ const mode = (document.getElementById("mode")?.value || "").toUpperCase();
+ const isCw = mode === "CW" || mode === "CWR";
+ const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
+ const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
+ // Prepend the in-progress line so characters appear immediately
+ const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
+ const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
+ if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
+ cwBarOverlay.style.display = "none";
+ cwBarOverlay.innerHTML = "";
+ return;
+ }
+ let html =
+ '';
+ for (const line of liveLines.slice(0, 8)) {
+ const ts = line.ts ? `${line.ts} ` : "";
+ const meta = [
+ line.wpm ? `${line.wpm} WPM` : null,
+ line.tone_hz ? `${line.tone_hz} Hz` : null,
+ ].filter(Boolean).join(" Β· ");
+ html += `` +
+ `
${ts}${escapeMapHtml(line.text)}` +
+ (meta ? ` ${escapeMapHtml(meta)} ` : "") +
+ `
`;
+ }
+ cwBarOverlay.innerHTML = html;
+ cwBarOverlay.style.display = "flex";
+}
+window.updateCwBar = updateCwBar;
+window.clearCwBar = function() {
+ window.resetCwHistoryView();
+};
+window.closeCwBar = function() {
+ cwBarDismissedAtMs = Date.now();
+ if (cwBarOverlay) {
+ cwBarOverlay.style.display = "none";
+ cwBarOverlay.innerHTML = "";
+ }
+};
+
+function clampCwWpm(wpm) {
+ const numeric = Number(wpm);
+ if (!Number.isFinite(numeric)) return 15;
+ return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
+}
+
+function clampCwTone(tone) {
+ const numeric = Number(tone);
+ if (!Number.isFinite(numeric)) return 700;
+ return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
+}
+
+function currentCwToneRange() {
+ const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
+ const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
+ if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
+ return null;
+ }
+ const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
+ const lowerSideband = mode === "CWR";
+ const upperSideband = mode === "CW";
+ if (!lowerSideband && !upperSideband) return null;
+
+ const toneMinHz = CW_TONE_MIN_HZ;
+ const toneMaxHz = CW_TONE_MAX_HZ;
+ if (toneMaxHz < toneMinHz) {
+ return null;
+ }
+ return {
+ tunedHz,
+ bandwidthHz,
+ toneMinHz,
+ toneMaxHz,
+ toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
+ lowerSideband,
+ mode,
+ };
+}
+
+function cwToneToRfHz(range, toneHz) {
+ if (!range) return NaN;
+ return range.lowerSideband
+ ? range.tunedHz - toneHz
+ : range.tunedHz + toneHz;
+}
+
+function toneClampForRange(tone, range) {
+ const clamped = clampCwTone(tone);
+ if (!range) return clamped;
+ return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
+}
+
+function ensureCwToneCanvasResolution() {
+ if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
+ const rect = cwToneCanvas.getBoundingClientRect();
+ const cssWidth = Math.round(rect.width);
+ const cssHeight = Math.round(rect.height);
+ if (cssWidth < 8 || cssHeight < 8) {
+ return false;
+ }
+ const dpr = window.devicePixelRatio || 1;
+ return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
+}
+
+function drawCwTonePicker() {
+ if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
+ ensureCwToneCanvasResolution();
+ if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
+ const width = cwToneCanvas.width;
+ const height = cwToneCanvas.height;
+ cwToneGl.clear([0, 0, 0, 0]);
+
+ const range = currentCwToneRange();
+ if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
+ if (cwToneRangeEl) {
+ const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
+ if (mode !== "CW" && mode !== "CWR") {
+ cwToneRangeEl.textContent = "CW/CWR mode required";
+ } else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
+ cwToneRangeEl.textContent = "Waiting for spectrum";
+ }
+ }
+ cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
+ return;
+ }
+
+ if (cwToneRangeEl) {
+ const side = range.lowerSideband ? "Lower side" : "Upper side";
+ cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz Β· ${side}`;
+ }
+
+ const bins = window.lastSpectrumData.bins;
+ const sampleRate = Number(window.lastSpectrumData.sample_rate);
+ const centerHz = Number(window.lastSpectrumData.center_hz);
+ const maxIdx = Math.max(1, bins.length - 1);
+ const fullLoHz = centerHz - sampleRate / 2;
+ const tones = new Array(width).fill(-140);
+ for (let x = 0; x < width; x += 1) {
+ const frac = width <= 1 ? 0 : x / (width - 1);
+ const toneHz = range.toneMinHz + frac * range.toneSpanHz;
+ const rfHz = cwToneToRfHz(range, toneHz);
+ const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
+ const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
+ tones[x] = power;
+ }
+
+ const smoothed = new Array(width).fill(-140);
+ const smoothRadius = Math.max(1, Math.round(width / 180));
+ for (let x = 0; x < width; x += 1) {
+ let sum = 0;
+ let count = 0;
+ for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
+ if (i < 0 || i >= width) continue;
+ sum += tones[i];
+ count += 1;
+ }
+ smoothed[x] = count > 0 ? sum / count : tones[x];
+ }
+
+ const sorted = smoothed.slice().sort((a, b) => a - b);
+ const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
+ const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
+ const floorDb = Math.min(q20 - 2, q95 - 10);
+ const ceilDb = Math.max(floorDb + 18, q95 + 2);
+ const dbSpan = Math.max(1, ceilDb - floorDb);
+ const yForDb = (db) => {
+ const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
+ return Math.round((1 - n) * (height - 1));
+ };
+
+ const rootStyle = getComputedStyle(document.documentElement);
+ const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
+ const parseColor = typeof window.trxParseCssColor === "function"
+ ? window.trxParseCssColor
+ : null;
+ const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
+ const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
+
+ cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
+
+ const hGridCount = 4;
+ const gridSegments = [];
+ for (let i = 1; i <= hGridCount; i += 1) {
+ const y = Math.round((i / (hGridCount + 1)) * (height - 1));
+ gridSegments.push(0, y, width, y);
+ }
+ cwToneGl.drawSegments(gridSegments, axisColor, 1);
+
+ const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
+ const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
+ const tickSegments = [];
+ for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
+ const frac = (tone - range.toneMinHz) / range.toneSpanHz;
+ const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
+ tickSegments.push(x, 0, x, height);
+ }
+ cwToneGl.drawSegments(tickSegments, axisColor, 1);
+
+ const linePoints = [];
+ for (let x = 0; x < width; x += 1) {
+ linePoints.push(x, yForDb(smoothed[x]));
+ }
+ cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
+ cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
+
+ const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
+ const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
+ const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
+ const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
+ cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
+ cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
+
+ if (cwAutoInput?.checked) {
+ cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
+ }
+}
+
+async function setCwTone(tone, { syncInput = true } = {}) {
+ const range = currentCwToneRange();
+ const clamped = toneClampForRange(tone, range);
+ if (cwToneInput && syncInput) {
+ cwToneInput.value = clamped;
+ }
+ try {
+ await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
+ } catch (e) {
+ console.error("CW tone set failed", e);
+ }
+ drawCwTonePicker();
+}
+
+if (cwAutoInput) {
+ cwAutoInput.addEventListener("change", async () => {
+ const enabled = cwAutoInput.checked;
+ cwAutoLocalOverride = enabled;
+ applyCwAutoUi(enabled);
+ try {
+ await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
+ drawCwTonePicker();
+ } catch (e) {
+ console.error("CW auto toggle failed", e);
+ } finally {
+ cwAutoLocalOverride = null;
+ }
+ });
+}
+
+if (cwWpmInput) {
+ cwWpmInput.addEventListener("change", async () => {
+ if (cwAutoInput && cwAutoInput.checked) return;
+ const wpm = clampCwWpm(cwWpmInput.value);
+ cwWpmInput.value = wpm;
+ try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
+ catch (e) { console.error("CW WPM set failed", e); }
+ });
+}
+
+if (cwToneInput) {
+ cwToneInput.addEventListener("change", async () => {
+ if (cwAutoInput?.checked) return;
+ await setCwTone(cwToneInput.value);
+ });
+}
+
+if (cwToneCanvas) {
+ cwToneCanvas.addEventListener("click", async (event) => {
+ if (cwAutoInput?.checked) return;
+ const rect = cwToneCanvas.getBoundingClientRect();
+ if (rect.width <= 0) return;
+ const range = currentCwToneRange();
+ if (!range) return;
+ const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
+ const tone = range.toneMinHz + frac * range.toneSpanHz;
+ await setCwTone(tone);
+ });
+}
+
+window.resetCwHistoryView = function() {
+ if (cwOutputEl) cwOutputEl.innerHTML = "";
+ cwLastAppendTime = 0;
+ cwBarHistory = [];
+ cwBarCurrentLine = null;
+ updateCwBar();
+ drawCwTonePicker();
+};
+
+document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_cw_decode");
+ window.resetCwHistoryView();
+ } catch (e) {
+ console.error("CW history clear failed", e);
+ }
+});
+
+// --- Server-side CW decode handler ---
+window.onServerCw = function(evt) {
+ if (cwStatusEl) cwStatusEl.textContent = "Receiving";
+ if (evt.text && cwOutputEl) {
+ // Append decoded text to output
+ const now = Date.now();
+ if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
+ const line = document.createElement("div");
+ line.className = "cw-line";
+ cwOutputEl.appendChild(line);
+ }
+ cwLastAppendTime = now;
+ const lastLine = cwOutputEl.lastElementChild;
+ if (lastLine) {
+ lastLine.textContent += evt.text;
+ }
+ while (cwOutputEl.children.length > CW_MAX_LINES) {
+ cwOutputEl.removeChild(cwOutputEl.firstChild);
+ }
+ cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
+ }
+ // Bar history accumulation (regardless of pause state)
+ if (evt.text) {
+ const now = Date.now();
+ if (evt.text === "\n") {
+ cwBarFlushCurrentLine();
+ } else {
+ if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
+ cwBarFlushCurrentLine();
+ const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
+ }
+ cwBarCurrentLine.text += evt.text;
+ cwBarCurrentLine.lastMs = now;
+ if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
+ if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
+ }
+ updateCwBar();
+ }
+ if (cwSignalIndicator) {
+ cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
+ }
+ if (!cwAutoInput || cwAutoInput.checked) {
+ if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
+ cwWpmInput.value = clampCwWpm(evt.wpm);
+ }
+ if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
+ cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
+ }
+ }
+ if (cwTonePickerRaf != null) return;
+ cwTonePickerRaf = requestAnimationFrame(() => {
+ cwTonePickerRaf = null;
+ drawCwTonePicker();
+ });
+};
+
+window.restoreCwHistory = function(events) {
+ if (!Array.isArray(events) || events.length === 0) return;
+ if (cwStatusEl) cwStatusEl.textContent = "Receiving";
+ for (const evt of events) {
+ window.onServerCw(evt);
+ }
+};
+
+window.refreshCwTonePicker = function refreshCwTonePicker() {
+ ensureCwToneCanvasResolution();
+ drawCwTonePicker();
+};
+window.addEventListener("resize", () => {
+ if (ensureCwToneCanvasResolution()) drawCwTonePicker();
+});
+applyCwAutoUi(!!cwAutoInput?.checked);
+updateCwBar();
+ensureCwToneCanvasResolution();
+drawCwTonePicker();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js
new file mode 100644
index 00000000..cc27230a
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js
@@ -0,0 +1,207 @@
+// --- FT2 Decoder Plugin (server-side decode) ---
+// SPDX-FileCopyrightText: 2026 Stan Grams
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+function ft8RenderMessageFt2(message) {
+ if (typeof renderFt8Message === "function") return renderFt8Message(message);
+ if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
+ return message;
+}
+
+const ft2Status = document.getElementById("ft2-status");
+const ft2PeriodEl = document.getElementById("ft2-period");
+const ft2MessagesEl = document.getElementById("ft2-messages");
+const ft2FilterInput = document.getElementById("ft2-filter");
+const FT2_PERIOD_MS = 3750;
+const FT2_MAX_DOM_ROWS = 200;
+let ft2FilterText = "";
+let ft2MessageHistory = [];
+
+function currentFt2HistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneFt2MessageHistory() {
+ const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
+ ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
+}
+
+function scheduleFt2Ui(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
+
+function normalizeFt2DisplayFreqHz(freqHz) {
+ const rawHz = Number(freqHz);
+ if (!Number.isFinite(rawHz)) return null;
+ const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
+ if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
+ return baseHz + rawHz;
+ }
+ return rawHz;
+}
+
+function updateFt2PeriodTimer() {
+ if (!ft2PeriodEl) return;
+ const nowMs = Date.now();
+ const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
+ ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
+}
+
+updateFt2PeriodTimer();
+setInterval(updateFt2PeriodTimer, 250);
+
+function renderFt2Row(msg) {
+ const row = document.createElement("div");
+ row.className = "ft8-row";
+ const rawMessage = (msg.message || "").toString();
+ row.dataset.message = rawMessage.toUpperCase();
+ row.dataset.decoder = "ft2";
+ row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
+ const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
+ const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
+ const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
+ const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
+ const renderedMessage = ft8RenderMessageFt2(rawMessage);
+ const tsMs = msg._tsMs ?? msg.ts_ms;
+ const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
+ row.innerHTML = `${timeStr} ${snr} ${dt} ${freq} ${renderedMessage} `;
+ return row;
+}
+
+function renderFt2History() {
+ pruneFt2MessageHistory();
+ if (!ft2MessagesEl) return;
+ const filter = ft2FilterText;
+ const fragment = document.createDocumentFragment();
+ let rendered = 0;
+ for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
+ const msg = ft2MessageHistory[i];
+ if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
+ fragment.appendChild(renderFt2Row(msg));
+ rendered++;
+ }
+ ft2MessagesEl.replaceChildren(fragment);
+}
+
+function addFt2Message(msg) {
+ msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ ft2MessageHistory.unshift(msg);
+ pruneFt2MessageHistory();
+ window.setFt8FamilyBarDecoder?.("ft2");
+ window.updateFt8Bar?.();
+ scheduleFt2HistoryRender();
+}
+
+function normalizeServerFt2Message(msg) {
+ const raw = (msg.message || "").toString();
+ const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
+ const grids = locatorDetails.length > 0
+ ? locatorDetails.map((d) => d.grid)
+ : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
+ const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
+ const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
+ return {
+ raw, grids, station, rfHz, locatorDetails,
+ history: {
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
+ freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
+ message: msg.message,
+ },
+ };
+}
+
+window.onServerFt2Batch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ if (ft2Status) ft2Status.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerFt2Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
+ }
+ next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
+ normalized.push(next.history);
+ }
+ normalized.reverse();
+ ft2MessageHistory = normalized.concat(ft2MessageHistory);
+ pruneFt2MessageHistory();
+ window.setFt8FamilyBarDecoder?.("ft2");
+ window.updateFt8Bar?.();
+ scheduleFt2HistoryRender();
+};
+
+window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
+window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
+
+window.resetFt2HistoryView = function() {
+ if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
+ ft2MessageHistory = [];
+ window.updateFt8Bar?.();
+ renderFt2History();
+};
+
+function buildFt2BarFrames() {
+ const cutoffMs = Date.now() - 15 * 60 * 1000;
+ const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
+ const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
+ if (messages.length === 0) {
+ return { count: 0, newestTsMs: 0, html: "" };
+ }
+ let html = "";
+ for (const msg of messages) {
+ const tsMs = msg._tsMs ?? msg.ts_ms;
+ const ts = tsMs ? `${fmtTime(tsMs)} ` : "";
+ const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
+ const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
+ const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
+ const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
+ const detail = [snr, dt, rf].filter(Boolean).join(" Β· ");
+ const text = ft8RenderMessageFt2((msg.message || "").toString());
+ html += `${ts}${text} ${detail ? ` Β· ${detail}` : ""}
`;
+ }
+ return { count: messages.length, newestTsMs, html };
+}
+window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
+
+if (ft2FilterInput) {
+ ft2FilterInput.addEventListener("input", () => {
+ ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
+ renderFt2History();
+ });
+}
+
+const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
+ft2DecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
+ await postPath("/toggle_ft2_decode");
+ } catch (e) {
+ console.error("FT2 toggle failed", e);
+ }
+});
+
+document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_ft2_decode");
+ window.resetFt2HistoryView();
+ } catch (e) { console.error("FT2 history clear failed", e); }
+});
+
+window.onServerFt2 = function(msg) {
+ if (ft2Status) ft2Status.textContent = "Receiving";
+ const next = normalizeServerFt2Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
+ }
+ addFt2Message(next.history);
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js
new file mode 100644
index 00000000..58a07985
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js
@@ -0,0 +1,207 @@
+// --- FT4 Decoder Plugin (server-side decode) ---
+// SPDX-FileCopyrightText: 2026 Stan Grams
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+function ft8RenderMessage(message) {
+ if (typeof renderFt8Message === "function") return renderFt8Message(message);
+ if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
+ return message;
+}
+
+const ft4Status = document.getElementById("ft4-status");
+const ft4PeriodEl = document.getElementById("ft4-period");
+const ft4MessagesEl = document.getElementById("ft4-messages");
+const ft4FilterInput = document.getElementById("ft4-filter");
+const FT4_PERIOD_MS = 7500;
+const FT4_MAX_DOM_ROWS = 200;
+let ft4FilterText = "";
+let ft4MessageHistory = [];
+
+function currentFt4HistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneFt4MessageHistory() {
+ const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
+ ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
+}
+
+function scheduleFt4Ui(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
+
+function normalizeFt4DisplayFreqHz(freqHz) {
+ const rawHz = Number(freqHz);
+ if (!Number.isFinite(rawHz)) return null;
+ const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
+ if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
+ return baseHz + rawHz;
+ }
+ return rawHz;
+}
+
+function updateFt4PeriodTimer() {
+ if (!ft4PeriodEl) return;
+ const nowMs = Date.now();
+ const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
+ ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
+}
+
+updateFt4PeriodTimer();
+setInterval(updateFt4PeriodTimer, 250);
+
+function renderFt4Row(msg) {
+ const row = document.createElement("div");
+ row.className = "ft8-row";
+ const rawMessage = (msg.message || "").toString();
+ row.dataset.message = rawMessage.toUpperCase();
+ row.dataset.decoder = "ft4";
+ row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
+ const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
+ const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
+ const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
+ const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
+ const renderedMessage = ft8RenderMessage(rawMessage);
+ const tsMs = msg._tsMs ?? msg.ts_ms;
+ const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
+ row.innerHTML = `${timeStr} ${snr} ${dt} ${freq} ${renderedMessage} `;
+ return row;
+}
+
+function renderFt4History() {
+ pruneFt4MessageHistory();
+ if (!ft4MessagesEl) return;
+ const filter = ft4FilterText;
+ const fragment = document.createDocumentFragment();
+ let rendered = 0;
+ for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
+ const msg = ft4MessageHistory[i];
+ if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
+ fragment.appendChild(renderFt4Row(msg));
+ rendered++;
+ }
+ ft4MessagesEl.replaceChildren(fragment);
+}
+
+function addFt4Message(msg) {
+ msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ ft4MessageHistory.unshift(msg);
+ pruneFt4MessageHistory();
+ window.setFt8FamilyBarDecoder?.("ft4");
+ window.updateFt8Bar?.();
+ scheduleFt4HistoryRender();
+}
+
+function normalizeServerFt4Message(msg) {
+ const raw = (msg.message || "").toString();
+ const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
+ const grids = locatorDetails.length > 0
+ ? locatorDetails.map((d) => d.grid)
+ : (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
+ const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
+ const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
+ return {
+ raw, grids, station, rfHz, locatorDetails,
+ history: {
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
+ freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
+ message: msg.message,
+ },
+ };
+}
+
+window.onServerFt4Batch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ if (ft4Status) ft4Status.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerFt4Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
+ }
+ next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
+ normalized.push(next.history);
+ }
+ normalized.reverse();
+ ft4MessageHistory = normalized.concat(ft4MessageHistory);
+ pruneFt4MessageHistory();
+ window.setFt8FamilyBarDecoder?.("ft4");
+ window.updateFt8Bar?.();
+ scheduleFt4HistoryRender();
+};
+
+window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
+window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
+
+window.resetFt4HistoryView = function() {
+ if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
+ ft4MessageHistory = [];
+ window.updateFt8Bar?.();
+ renderFt4History();
+};
+
+function buildFt4BarFrames() {
+ const cutoffMs = Date.now() - 15 * 60 * 1000;
+ const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
+ const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
+ if (messages.length === 0) {
+ return { count: 0, newestTsMs: 0, html: "" };
+ }
+ let html = "";
+ for (const msg of messages) {
+ const tsMs = msg._tsMs ?? msg.ts_ms;
+ const ts = tsMs ? `${fmtTime(tsMs)} ` : "";
+ const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
+ const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
+ const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
+ const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
+ const detail = [snr, dt, rf].filter(Boolean).join(" Β· ");
+ const text = ft8RenderMessage((msg.message || "").toString());
+ html += `${ts}${text} ${detail ? ` Β· ${detail}` : ""}
`;
+ }
+ return { count: messages.length, newestTsMs, html };
+}
+window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
+
+if (ft4FilterInput) {
+ ft4FilterInput.addEventListener("input", () => {
+ ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
+ renderFt4History();
+ });
+}
+
+const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
+ft4DecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
+ await postPath("/toggle_ft4_decode");
+ } catch (e) {
+ console.error("FT4 toggle failed", e);
+ }
+});
+
+document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_ft4_decode");
+ window.resetFt4HistoryView();
+ } catch (e) { console.error("FT4 history clear failed", e); }
+});
+
+window.onServerFt4 = function(msg) {
+ if (ft4Status) ft4Status.textContent = "Receiving";
+ const next = normalizeServerFt4Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
+ }
+ addFt4Message(next.history);
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js
new file mode 100644
index 00000000..c9c4edff
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js
@@ -0,0 +1,486 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- FT8 Decoder Plugin (server-side decode) ---
+const ft8Status = document.getElementById("ft8-status");
+const ft8PeriodEl = document.getElementById("ft8-period");
+const ft8MessagesEl = document.getElementById("ft8-messages");
+const ft8FilterInput = document.getElementById("ft8-filter");
+const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
+const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
+const FT8_PERIOD_SECONDS = 15;
+const FT8_MAX_DOM_ROWS = 200;
+const FT8_BAR_DECODER_LABELS = {
+ ft8: "FT8",
+ ft4: "FT4",
+ ft2: "FT2",
+};
+let ft8FilterText = "";
+let ft8MessageHistory = [];
+let ft8BarActiveDecoder = "ft8";
+const ft8BarBuilders = {};
+const ft8BarDismissedAtMsByDecoder = {
+ ft8: 0,
+ ft4: 0,
+ ft2: 0,
+};
+
+function currentFt8HistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneFt8MessageHistory() {
+ const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
+ ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
+}
+
+function scheduleFt8Ui(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleFt8HistoryRender() {
+ scheduleFt8Ui("ft8-history", () => renderFt8History());
+}
+
+function scheduleFt8BarUpdate() {
+ scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
+}
+
+window.registerFt8FamilyBarRenderer = function(decoder, builder) {
+ if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
+ ft8BarBuilders[decoder] = builder;
+};
+
+window.setFt8FamilyBarDecoder = function(decoder) {
+ if (!FT8_BAR_DECODER_LABELS[decoder]) return;
+ ft8BarActiveDecoder = decoder;
+ scheduleFt8BarUpdate();
+};
+
+function normalizeFt8DisplayFreqHz(freqHz) {
+ const rawHz = Number(freqHz);
+ if (!Number.isFinite(rawHz)) return null;
+ const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
+ if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
+ return baseHz + rawHz;
+ }
+ return rawHz;
+}
+
+function fmtTime(tsMs) {
+ if (!tsMs) return "--:--:--";
+ return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+}
+
+function updateFt8PeriodTimer() {
+ if (!ft8PeriodEl) return;
+ const nowSec = Math.floor(Date.now() / 1000);
+ const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
+ ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
+}
+
+updateFt8PeriodTimer();
+setInterval(updateFt8PeriodTimer, 500);
+
+function renderFt8Row(msg) {
+ const row = document.createElement("div");
+ row.className = "ft8-row";
+ const rawMessage = (msg.message || "").toString();
+ row.dataset.message = rawMessage.toUpperCase();
+ row.dataset.decoder = "ft8";
+ row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
+ const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
+ const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
+ const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
+ const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
+ const renderedMessage = renderFt8Message(rawMessage);
+ row.innerHTML = `${fmtTime(msg.ts_ms)} ${snr} ${dt} ${freq} ${renderedMessage} `;
+ applyFt8FilterToRow(row);
+ return row;
+}
+
+function renderFt8History() {
+ pruneFt8MessageHistory();
+ if (!ft8MessagesEl) return;
+ const fragment = document.createDocumentFragment();
+ const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
+ for (let i = 0; i < limit; i += 1) {
+ fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
+ }
+ ft8MessagesEl.replaceChildren(fragment);
+}
+
+function addFt8Message(msg) {
+ msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ ft8MessageHistory.unshift(msg);
+ pruneFt8MessageHistory();
+ ft8BarActiveDecoder = "ft8";
+ scheduleFt8BarUpdate();
+ scheduleFt8HistoryRender();
+}
+
+function normalizeServerFt8Message(msg) {
+ const raw = (msg.message || "").toString();
+ const locatorDetails = ft8ExtractLocatorDetails(raw);
+ const grids = locatorDetails.length > 0
+ ? locatorDetails.map((detail) => detail.grid)
+ : ft8ExtractAllGrids(raw);
+ const station = ft8ExtractLikelyCallsign(raw);
+ const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
+ return {
+ raw,
+ grids,
+ station,
+ rfHz,
+ locatorDetails,
+ history: {
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ ts_ms: msg.ts_ms,
+ snr_db: msg.snr_db,
+ dt_s: msg.dt_s,
+ freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
+ message: msg.message,
+ },
+ };
+}
+
+window.onServerFt8Batch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ ft8Status.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerFt8Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
+ ...msg,
+ freq_hz: next.rfHz,
+ locator_details: next.locatorDetails,
+ });
+ }
+ next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
+ normalized.push(next.history);
+ }
+ normalized.reverse();
+ ft8MessageHistory = normalized.concat(ft8MessageHistory);
+ pruneFt8MessageHistory();
+ ft8BarActiveDecoder = "ft8";
+ scheduleFt8BarUpdate();
+ scheduleFt8HistoryRender();
+};
+
+window.restoreFt8History = function(messages) {
+ window.onServerFt8Batch(messages);
+};
+
+window.pruneFt8HistoryView = function() {
+ pruneFt8MessageHistory();
+ updateFt8Bar();
+ renderFt8History();
+};
+
+function ft8BarRfText(msg) {
+ const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
+ if (!Number.isFinite(displayFreqHz)) return null;
+ return `${displayFreqHz.toFixed(0)} Hz`;
+}
+
+function buildFt8BarFrames() {
+ const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
+ const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
+ const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
+ if (messages.length === 0) {
+ return { count: 0, newestTsMs: 0, html: "" };
+ }
+ let html = "";
+ for (const msg of messages) {
+ const ts = msg.ts_ms ? `${fmtTime(msg.ts_ms)} ` : "";
+ const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
+ const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
+ const rf = ft8BarRfText(msg);
+ const detail = [snr, dt, rf].filter(Boolean).join(" Β· ");
+ const text = ft8EscapeHtml((msg.message || "").toString());
+ html += `${ts}${text} ${detail ? ` Β· ${detail}` : ""}
`;
+ }
+ return { count: messages.length, newestTsMs, html };
+}
+
+function updateFt8Bar() {
+ if (!ft8BarOverlay) return;
+ const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
+ const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
+ const decoder = ft8BarActiveDecoder;
+ const builder = ft8BarBuilders[decoder];
+ const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
+ const result = typeof builder === "function" ? builder() : null;
+ const newestTsMs = Number(result?.newestTsMs) || 0;
+ if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
+ ft8BarOverlay.style.display = "none";
+ ft8BarOverlay.innerHTML = "";
+ return;
+ }
+
+ ft8BarOverlay.innerHTML = `${result.html}`;
+ ft8BarOverlay.style.display = "flex";
+}
+window.updateFt8Bar = updateFt8Bar;
+window.clearFt8Bar = function() {
+ const decoder = ft8BarActiveDecoder;
+ if (decoder === "ft4") {
+ window.resetFt4HistoryView?.();
+ return;
+ }
+ if (decoder === "ft2") {
+ window.resetFt2HistoryView?.();
+ return;
+ }
+ window.resetFt8HistoryView?.();
+};
+window.closeFt8Bar = function() {
+ ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
+ if (ft8BarOverlay) {
+ ft8BarOverlay.style.display = "none";
+ ft8BarOverlay.innerHTML = "";
+ }
+};
+window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
+
+function renderFt8Message(message) {
+ let out = "";
+ let i = 0;
+ while (i < message.length) {
+ const ch = message[i];
+ if (ft8IsAlphaNum(ch)) {
+ let j = i + 1;
+ while (j < message.length && ft8IsAlphaNum(message[j])) j++;
+ const token = message.slice(i, j);
+ const grid = token.toUpperCase();
+ if (ft8IsMaidenheadGridToken(grid)) {
+ out += `${grid} `;
+ } else {
+ out += ft8EscapeHtml(token);
+ }
+ i = j;
+ } else {
+ out += ft8EscapeHtml(ch);
+ i += 1;
+ }
+ }
+ return out;
+}
+
+function ft8TokenizeMessage(message) {
+ return String(message || "")
+ .toUpperCase()
+ .split(/[^A-Z0-9/]+/)
+ .filter(Boolean);
+}
+
+function ft8ExtractAllGrids(message) {
+ const out = [];
+ const seen = new Set();
+ let i = 0;
+ while (i < message.length) {
+ if (ft8IsAlphaNum(message[i])) {
+ let j = i + 1;
+ while (j < message.length && ft8IsAlphaNum(message[j])) j++;
+ const token = message.slice(i, j);
+ const grid = token.toUpperCase();
+ if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
+ seen.add(grid);
+ out.push(grid);
+ }
+ i = j;
+ } else {
+ i += 1;
+ }
+ }
+ return out;
+}
+
+function ft8ExtractLocatorDetails(message) {
+ const tokens = ft8TokenizeMessage(message);
+ const grids = ft8ExtractAllGrids(String(message || ""));
+ if (tokens.length === 0 || grids.length === 0) return [];
+ const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
+ const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
+ const callsigns = [];
+ for (let i = 0; i < limit; i += 1) {
+ if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
+ }
+
+ let source = null;
+ let target = null;
+ const head = tokens[0];
+ if (callsigns.length > 0) {
+ if (head === "CQ" || head === "DE" || head === "QRZ") {
+ source = callsigns[0];
+ } else if (callsigns.length >= 2) {
+ target = callsigns[0];
+ source = callsigns[1];
+ } else {
+ source = callsigns[0];
+ }
+ }
+
+ return grids.map((grid) => ({
+ grid,
+ station: source || null,
+ source: source || null,
+ target: target || null,
+ }));
+}
+
+function ft8ExtractLikelyCallsign(message) {
+ const locatorDetails = ft8ExtractLocatorDetails(message);
+ if (locatorDetails.length > 0 && locatorDetails[0].station) {
+ return locatorDetails[0].station;
+ }
+ const tokens = ft8TokenizeMessage(message);
+ for (const token of tokens) {
+ if (ft8IsLikelyCallsignToken(token)) return token;
+ }
+ return null;
+}
+
+function ft8IsLikelyCallsignToken(token) {
+ if (!token) return false;
+ if (token.length < 3 || token.length > 12) return false;
+ if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
+ if (ft8IsMaidenheadGridToken(token)) return false;
+ return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
+}
+
+function ft8IsFarewellToken(token) {
+ const normalized = String(token || "").trim().toUpperCase();
+ return normalized === "RR73" || normalized === "73" || normalized === "RR";
+}
+
+function ft8IsMaidenheadGridToken(token) {
+ const normalized = String(token || "").trim().toUpperCase();
+ return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
+}
+
+function ft8EscapeHtml(input) {
+ return input
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+}
+
+function ft8IsAlphaNum(ch) {
+ return /[A-Za-z0-9]/.test(ch);
+}
+
+function activateFt8HistoryLocator(targetEl) {
+ const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
+ if (!locatorEl) return false;
+ const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
+ if (!grid) return false;
+ if (typeof window.navigateToMapLocator === "function") {
+ window.navigateToMapLocator(grid, "ft8");
+ }
+ return true;
+}
+
+function applyFt8FilterToRow(row) {
+ if (!ft8FilterText) {
+ row.style.display = "";
+ return;
+ }
+ const message = row.dataset.message || "";
+ row.style.display = message.includes(ft8FilterText) ? "" : "none";
+}
+
+function applyFt8FilterToAll() {
+ const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
+ rows.forEach((row) => applyFt8FilterToRow(row));
+}
+
+function updateFt8RowRf(row) {
+ const freqEl = row.querySelector(".ft8-freq");
+ if (!freqEl) return;
+ const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
+ const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
+ if (Number.isFinite(displayFreqHz)) {
+ freqEl.textContent = displayFreqHz.toFixed(0);
+ } else {
+ freqEl.textContent = "--";
+ }
+}
+
+window.updateFt8RfDisplay = function() {
+ const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
+ rows.forEach((row) => updateFt8RowRf(row));
+ updateFt8Bar();
+};
+
+window.resetFt8HistoryView = function() {
+ ft8MessagesEl.innerHTML = "";
+ ft8MessageHistory = [];
+ updateFt8Bar();
+ renderFt8History();
+ if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
+};
+
+if (ft8FilterInput) {
+ ft8FilterInput.addEventListener("input", () => {
+ ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
+ renderFt8History();
+ });
+}
+
+if (ft8MessagesEl) {
+ ft8MessagesEl.addEventListener("click", (event) => {
+ if (!activateFt8HistoryLocator(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ ft8MessagesEl.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ if (!activateFt8HistoryLocator(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ });
+}
+
+const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
+ft8DecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
+ await postPath("/toggle_ft8_decode");
+ } catch (e) {
+ console.error("FT8 toggle failed", e);
+ }
+});
+
+document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_ft8_decode");
+ window.resetFt8HistoryView();
+ } catch (e) {
+ console.error("FT8 history clear failed", e);
+ }
+});
+
+// --- Server-side FT8 decode handler ---
+window.onServerFt8 = function(msg) {
+ ft8Status.textContent = "Receiving";
+ const next = normalizeServerFt8Message(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
+ ...msg,
+ freq_hz: next.rfHz,
+ locator_details: next.locatorDetails,
+ });
+ }
+ addFt8Message(next.history);
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.js
new file mode 100644
index 00000000..0b5c9dca
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.js
@@ -0,0 +1,444 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
+const hfAprsStatus = document.getElementById("hf-aprs-status");
+const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
+const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
+const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
+const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
+const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
+const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
+const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
+const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
+let hfAprsFilterText = "";
+let hfAprsPacketHistory = [];
+let hfAprsOnlyPos = false;
+let hfAprsHideCrc = false;
+let hfAprsCollapseDup = false;
+let hfAprsTypeFilter = "all";
+
+function currentHfAprsHistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneHfAprsPacketHistory() {
+ const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
+ hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
+}
+
+function scheduleHfAprsHistoryRender() {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
+ return;
+ }
+ renderHfAprsHistory();
+}
+
+function hfAprsPacketCategory(pkt) {
+ const type = String(pkt.type || "").toLowerCase();
+ const info = String(pkt.info || "").toLowerCase();
+ if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
+ if (type.includes("message") || info.startsWith(":")) return "message";
+ if (type.includes("weather") || info.startsWith("_")) return "weather";
+ if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
+ return "other";
+}
+
+function hfAprsCategoryLabel(category) {
+ switch (category) {
+ case "position": return "Position";
+ case "message": return "Message";
+ case "weather": return "Weather";
+ case "telemetry": return "Telemetry";
+ default: return "Other";
+ }
+}
+
+function hfAprsAgeText(tsMs) {
+ if (!Number.isFinite(tsMs)) return "just now";
+ const deltaMs = Math.max(0, Date.now() - tsMs);
+ const seconds = Math.round(deltaMs / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ return `${hours}h ago`;
+}
+
+function hfAprsDistanceText(pkt) {
+ if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
+ const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
+ if (!Number.isFinite(distKm)) return "";
+ if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
+ return `${distKm.toFixed(1)} km from TRX`;
+}
+
+function hfAprsPacketSignature(pkt) {
+ return [
+ pkt.srcCall || "",
+ pkt.destCall || "",
+ pkt.path || "",
+ pkt.info || "",
+ pkt.type || "",
+ pkt.lat != null ? pkt.lat.toFixed(4) : "",
+ pkt.lon != null ? pkt.lon.toFixed(4) : "",
+ ].join("|");
+}
+
+function hfAprsHexBytes(bytes) {
+ if (!Array.isArray(bytes) || bytes.length === 0) return "--";
+ return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
+}
+
+function hfAprsFilterMatch(pkt) {
+ if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
+ if (hfAprsHideCrc && !pkt.crcOk) return false;
+ if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
+ if (!hfAprsFilterText) return true;
+ const haystack = [
+ pkt.srcCall,
+ pkt.destCall,
+ pkt.path,
+ pkt.info,
+ pkt.type,
+ pkt.lat != null ? pkt.lat.toFixed(4) : "",
+ pkt.lon != null ? pkt.lon.toFixed(4) : "",
+ hfAprsPacketCategory(pkt),
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toUpperCase();
+ return haystack.includes(hfAprsFilterText);
+}
+
+function hfAprsVisiblePackets() {
+ const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
+ return packets.filter(hfAprsFilterMatch);
+}
+
+function collapseHfAprsDuplicates(packets) {
+ const seen = new Set();
+ const out = [];
+ for (const pkt of packets) {
+ const key = hfAprsPacketSignature(pkt);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(pkt);
+ }
+ return out;
+}
+
+function updateHfAprsSummary() {
+ const visible = hfAprsVisiblePackets();
+ if (hfAprsTotalCountEl) {
+ hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
+ }
+ if (hfAprsVisibleCountEl) {
+ hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
+ }
+ if (hfAprsLatestSeenEl) {
+ const latest = hfAprsPacketHistory[0];
+ if (!latest) {
+ hfAprsLatestSeenEl.textContent = "No packets yet";
+ } else {
+ hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
+ }
+ }
+}
+
+function updateHfAprsChipState() {
+ document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
+ btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
+ });
+ hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
+ hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
+ hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
+}
+
+function renderHfAprsInfo(pkt) {
+ const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
+ if (bytes && bytes.length > 0) {
+ let out = "";
+ for (let i = 0; i < bytes.length; i++) {
+ const b = bytes[i];
+ if (b >= 0x20 && b <= 0x7e) {
+ const ch = String.fromCharCode(b);
+ if (ch === "<") out += "<";
+ else if (ch === ">") out += ">";
+ else if (ch === "&") out += "&";
+ else if (ch === '"') out += """;
+ else out += ch;
+ } else {
+ const hex = b.toString(16).toUpperCase().padStart(2, "0");
+ out += `0x${hex} `;
+ }
+ }
+ return out;
+ }
+ const str = pkt.info || "";
+ let out = "";
+ for (let i = 0; i < str.length; i++) {
+ const code = str.charCodeAt(i);
+ if (code >= 0x20 && code <= 0x7e) {
+ const ch = str[i];
+ if (ch === "<") out += "<";
+ else if (ch === ">") out += ">";
+ else if (ch === "&") out += "&";
+ else if (ch === '"') out += """;
+ else out += ch;
+ } else {
+ const hex = code.toString(16).toUpperCase().padStart(2, "0");
+ out += `0x${hex} `;
+ }
+ }
+ return out;
+}
+
+function renderHfAprsRow(pkt, isFresh) {
+ const row = document.createElement("div");
+ row.className = "aprs-packet";
+ if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
+ if (isFresh) row.classList.add("aprs-packet-new");
+
+ const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ const age = hfAprsAgeText(pkt._tsMs);
+ const category = hfAprsPacketCategory(pkt);
+ const categoryLabel = hfAprsCategoryLabel(category);
+ const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
+ const pathBadge = pkt.path ? `${escapeMapHtml(pkt.path)} ` : "";
+ const crcBadge = pkt.crcOk ? "" : 'CRC Fail ';
+ const hfBadge = 'HF ';
+ let symbolHtml = "";
+ if (pkt.symbolTable && pkt.symbolCode) {
+ const sheet = pkt.symbolTable === "/" ? 0 : 1;
+ const code = pkt.symbolCode.charCodeAt(0) - 33;
+ const col = code % 16;
+ const row2 = Math.floor(code / 16);
+ const bgX = -(col * 24);
+ const bgY = -(row2 * 24);
+ symbolHtml = ` `;
+ }
+ const posLink = pkt.lat != null && pkt.lon != null
+ ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)} `
+ : "";
+ const distance = hfAprsDistanceText(pkt);
+ const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
+
+ row.innerHTML =
+ `` +
+ `${ts} ` +
+ hfBadge +
+ symbolHtml +
+ `${escapeMapHtml(pkt.srcCall)} ` +
+ `>${escapeMapHtml(pkt.destCall || "")} ` +
+ `${escapeMapHtml(categoryLabel)} ` +
+ pathBadge +
+ crcBadge +
+ `
` +
+ `` +
+ `${escapeMapHtml(age)} ` +
+ (distance ? `${escapeMapHtml(distance)} ` : "") +
+ `${escapeMapHtml(pkt.type || "--")} ` +
+ `
` +
+ `` +
+ `${renderHfAprsInfo(pkt)} ` +
+ (posLink ? `${posLink} ` : "") +
+ `
` +
+ `` +
+ (pkt.lat != null && pkt.lon != null ? `
Map ` : "") +
+ (pkt.lat != null && pkt.lon != null ? `
Copy Coords ` : "") +
+ `
QRZ ` +
+ `
` +
+ `` +
+ `Details ` +
+ `` +
+ `Source ${escapeMapHtml(pkt.srcCall || "--")} ` +
+ `Destination ${escapeMapHtml(pkt.destCall || "--")} ` +
+ `Type ${escapeMapHtml(pkt.type || "--")} ` +
+ `Path ${escapeMapHtml(pkt.path || "--")} ` +
+ `Age ${escapeMapHtml(age)} ` +
+ `CRC ${pkt.crcOk ? "OK" : "Failed"} ` +
+ `Position ${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"} ` +
+ `Info ${escapeMapHtml(pkt.info || "--")} ` +
+ `Info Bytes ${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))} ` +
+ `
` +
+ ` `;
+
+ row.querySelectorAll("[data-aprs-map]").forEach((el) => {
+ el.addEventListener("click", (evt) => {
+ evt.preventDefault();
+ const raw = String(el.dataset.aprsMap || "");
+ const [lat, lon] = raw.split(",").map(Number);
+ if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
+ window.navigateToAprsMap(lat, lon);
+ }
+ });
+ });
+
+ const copyBtn = row.querySelector("[data-aprs-copy]");
+ if (copyBtn) {
+ copyBtn.addEventListener("click", async () => {
+ const raw = String(copyBtn.dataset.aprsCopy || "");
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(raw);
+ showHint("Coordinates copied", 1200);
+ }
+ } catch (_e) {
+ showHint("Copy failed", 1500);
+ }
+ });
+ }
+
+ return row;
+}
+
+function renderHfAprsHistory() {
+ pruneHfAprsPacketHistory();
+ if (!hfAprsPacketsEl) {
+ updateHfAprsSummary();
+ updateHfAprsChipState();
+ return;
+ }
+ const visible = hfAprsVisiblePackets();
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < visible.length; i++) {
+ fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
+ }
+ hfAprsPacketsEl.replaceChildren(fragment);
+ updateHfAprsSummary();
+ updateHfAprsChipState();
+}
+
+window.resetHfAprsHistoryView = function() {
+ if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
+ hfAprsPacketHistory = [];
+ renderHfAprsHistory();
+};
+
+window.pruneHfAprsHistoryView = function() {
+ pruneHfAprsPacketHistory();
+ renderHfAprsHistory();
+};
+
+function addHfAprsPacket(pkt) {
+ const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
+ pkt._tsMs = tsMs;
+ pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+
+ hfAprsPacketHistory.unshift(pkt);
+ pruneHfAprsPacketHistory();
+
+ scheduleHfAprsHistoryRender();
+}
+
+function normalizeServerHfAprsPacket(pkt) {
+ return {
+ rig_id: pkt.rig_id || null,
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ srcCall: pkt.src_call,
+ destCall: pkt.dest_call,
+ path: pkt.path,
+ info: pkt.info,
+ info_bytes: pkt.info_bytes,
+ type: pkt.packet_type,
+ crcOk: pkt.crc_ok,
+ ts_ms: pkt.ts_ms,
+ lat: pkt.lat,
+ lon: pkt.lon,
+ symbolTable: pkt.symbol_table,
+ symbolCode: pkt.symbol_code,
+ };
+}
+
+window.onServerHfAprsBatch = function(packets) {
+ if (!Array.isArray(packets) || packets.length === 0) return;
+ if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
+ const normalized = [];
+ for (const pkt of packets) {
+ const next = normalizeServerHfAprsPacket(pkt);
+ const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
+ next._tsMs = tsMs;
+ next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ normalized.push(next);
+ }
+ normalized.reverse();
+ hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
+ pruneHfAprsPacketHistory();
+ scheduleHfAprsHistoryRender();
+};
+
+window.restoreHfAprsHistory = function(packets) {
+ window.onServerHfAprsBatch(packets);
+};
+
+const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
+hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
+ await postPath("/toggle_hf_aprs_decode");
+ } catch (e) {
+ console.error("HF APRS toggle failed", e);
+ }
+});
+
+document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_hf_aprs_decode");
+ window.resetHfAprsHistoryView();
+ } catch (e) {
+ console.error("HF APRS history clear failed", e);
+ }
+});
+
+if (hfAprsOnlyPosBtn) {
+ hfAprsOnlyPosBtn.addEventListener("click", () => {
+ hfAprsOnlyPos = !hfAprsOnlyPos;
+ renderHfAprsHistory();
+ });
+}
+
+if (hfAprsHideCrcBtn) {
+ hfAprsHideCrcBtn.addEventListener("click", () => {
+ hfAprsHideCrc = !hfAprsHideCrc;
+ renderHfAprsHistory();
+ });
+}
+
+if (hfAprsCollapseDupBtn) {
+ hfAprsCollapseDupBtn.addEventListener("click", () => {
+ hfAprsCollapseDup = !hfAprsCollapseDup;
+ renderHfAprsHistory();
+ });
+}
+
+["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
+ const btn = document.getElementById(`hf-aprs-type-${type}`);
+ if (!btn) return;
+ btn.addEventListener("click", () => {
+ hfAprsTypeFilter = type;
+ renderHfAprsHistory();
+ });
+});
+
+if (hfAprsFilterInput) {
+ hfAprsFilterInput.addEventListener("input", () => {
+ hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
+ renderHfAprsHistory();
+ });
+}
+
+// --- Server-side HF APRS decode handler ---
+window.onServerHfAprs = function(pkt) {
+ if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
+ addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
+};
+
+renderHfAprsHistory();
+if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.js
new file mode 100644
index 00000000..166ff0e9
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.js
@@ -0,0 +1,321 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// Satellite Pass Scheduling UI
+// Manages the satellite overlay section within the background decoding scheduler.
+// Communicates with scheduler.js via a thin window API for shared state access.
+
+(function () {
+ "use strict";
+
+ // ββ DOM references (cached once) ββββββββββββββββββββββββββββββββββ
+ const dom = {
+ enabled: document.getElementById("scheduler-sat-enabled"),
+ pretune: document.getElementById("scheduler-sat-pretune"),
+ body: document.getElementById("scheduler-sat-body"),
+ tbody: document.getElementById("scheduler-sat-tbody"),
+ addBtn: document.getElementById("scheduler-sat-add-btn"),
+ passStatus: document.getElementById("scheduler-sat-pass-status"),
+ formWrap: document.getElementById("sch-sat-form-wrap"),
+ formTitle: document.getElementById("sch-sat-form-title"),
+ form: document.getElementById("sch-sat-form"),
+ formCancel: document.getElementById("sch-sat-form-cancel"),
+ preset: document.getElementById("scheduler-sat-preset"),
+ name: document.getElementById("scheduler-sat-name"),
+ norad: document.getElementById("scheduler-sat-norad"),
+ bookmark: document.getElementById("scheduler-sat-bookmark"),
+ minEl: document.getElementById("scheduler-sat-min-el"),
+ priority: document.getElementById("scheduler-sat-priority"),
+ centerHz: document.getElementById("scheduler-sat-center-hz"),
+ };
+
+ // ββ Local state βββββββββββββββββββββββββββββββββββββββββββββββββββ
+ let editIdx = null; // null = adding, number = editing
+
+ // ββ Scheduler bridge ββββββββββββββββββββββββββββββββββββββββββββββ
+ // These accessors call into scheduler.js via window.schedulerBridge,
+ // which is set up by scheduler.js after it initializes.
+ function getBridge() {
+ return window.schedulerBridge || {};
+ }
+
+ function getConfig() {
+ const b = getBridge();
+ return typeof b.getConfig === "function" ? b.getConfig() : null;
+ }
+
+ function getStatus() {
+ const b = getBridge();
+ return typeof b.getStatus === "function" ? b.getStatus() : null;
+ }
+
+ function getBookmarks() {
+ const b = getBridge();
+ return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
+ }
+
+ function markDirty() {
+ var b = getBridge();
+ if (typeof b.markDirty === "function") b.markDirty();
+ }
+
+ function bmName(id) {
+ const bm = getBookmarks().find(function (b) { return b.id === id; });
+ return bm ? bm.name : String(id || "");
+ }
+
+ function escHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function formatFreq(hz) {
+ if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
+ return hz + " Hz";
+ }
+
+ // ββ Satellite config helpers ββββββββββββββββββββββββββββββββββββββ
+ function getSatelliteEntries() {
+ var config = getConfig();
+ return (config && config.satellites && Array.isArray(config.satellites.entries))
+ ? config.satellites.entries
+ : [];
+ }
+
+ function ensureSatelliteConfig() {
+ var config = getConfig();
+ if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
+ if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
+ if (!config.satellites.entries) config.satellites.entries = [];
+ return config.satellites;
+ }
+
+ function collectSatelliteConfig() {
+ var enabled = dom.enabled ? dom.enabled.checked : false;
+ var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
+ return {
+ enabled: enabled,
+ pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
+ entries: getSatelliteEntries(),
+ };
+ }
+
+ // ββ Render: section βββββββββββββββββββββββββββββββββββββββββββββββ
+ function renderSection() {
+ var config = getConfig();
+ var satCfg = (config && config.satellites) || {};
+ var enabled = !!satCfg.enabled;
+
+ if (dom.enabled) dom.enabled.checked = enabled;
+ if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
+ if (dom.body) dom.body.style.display = enabled ? "" : "none";
+
+ renderEntries();
+ renderPassStatus();
+ }
+
+ // ββ Render: entries table βββββββββββββββββββββββββββββββββββββββββ
+ function renderEntries() {
+ if (!dom.tbody) return;
+ var entries = getSatelliteEntries();
+ var frag = document.createDocumentFragment();
+
+ entries.forEach(function (entry, idx) {
+ var tr = document.createElement("tr");
+
+ var tdSat = document.createElement("td");
+ tdSat.textContent = entry.satellite || "";
+ tr.appendChild(tdSat);
+
+ var tdNorad = document.createElement("td");
+ tdNorad.textContent = entry.norad_id || "";
+ tr.appendChild(tdNorad);
+
+ var tdBm = document.createElement("td");
+ tdBm.textContent = bmName(entry.bookmark_id);
+ tr.appendChild(tdBm);
+
+ var tdEl = document.createElement("td");
+ tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
+ tr.appendChild(tdEl);
+
+ var tdPrio = document.createElement("td");
+ tdPrio.textContent = entry.priority || 0;
+ tr.appendChild(tdPrio);
+
+ var tdActions = document.createElement("td");
+
+ var editBtn = document.createElement("button");
+ editBtn.className = "sch-write";
+ editBtn.type = "button";
+ editBtn.textContent = "Edit";
+ editBtn.addEventListener("click", function () {
+ openForm(entry, idx);
+ });
+ tdActions.appendChild(editBtn);
+
+ var removeBtn = document.createElement("button");
+ removeBtn.className = "sch-write";
+ removeBtn.type = "button";
+ removeBtn.textContent = "Remove";
+ removeBtn.addEventListener("click", function () {
+ removeEntry(idx);
+ });
+ tdActions.appendChild(removeBtn);
+
+ tr.appendChild(tdActions);
+ frag.appendChild(tr);
+ });
+
+ dom.tbody.replaceChildren(frag);
+ }
+
+ // ββ Render: pass status βββββββββββββββββββββββββββββββββββββββββββ
+ function renderPassStatus() {
+ if (!dom.passStatus) return;
+ var entries = getSatelliteEntries();
+ if (entries.length === 0) {
+ dom.passStatus.innerHTML = "";
+ return;
+ }
+ var status = getStatus();
+ if (status && status.active_satellite) {
+ dom.passStatus.innerHTML =
+ 'PASS ACTIVE: ' +
+ escHtml(status.active_satellite) +
+ ' ';
+ } else {
+ dom.passStatus.innerHTML =
+ 'No satellite pass active. Predictions available in the SAT tab. ';
+ }
+ }
+
+ // ββ Render: bookmark dropdown βββββββββββββββββββββββββββββββββββββ
+ function renderBookmarkSelect(selectedId) {
+ if (!dom.bookmark) return;
+ dom.bookmark.innerHTML = 'β none β ';
+ getBookmarks().forEach(function (bm) {
+ var opt = document.createElement("option");
+ opt.value = bm.id;
+ opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
+ if (bm.id === selectedId) opt.selected = true;
+ dom.bookmark.appendChild(opt);
+ });
+ }
+
+ // ββ Entry management ββββββββββββββββββββββββββββββββββββββββββββββ
+ function removeEntry(idx) {
+ var sat = ensureSatelliteConfig();
+ sat.entries.splice(idx, 1);
+ renderEntries();
+ markDirty();
+ }
+
+ // ββ Form: open ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function openForm(entry, idx) {
+ editIdx = (idx != null) ? idx : null;
+
+ if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
+ if (dom.preset) dom.preset.value = "";
+ if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
+ if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
+ if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
+ if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
+ if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
+
+ renderBookmarkSelect(entry ? entry.bookmark_id : null);
+
+ if (dom.formWrap) {
+ dom.formWrap.style.display = "flex";
+ if (dom.name) dom.name.focus();
+ }
+ }
+
+ // ββ Form: close βββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function closeForm() {
+ if (dom.formWrap) dom.formWrap.style.display = "none";
+ editIdx = null;
+ }
+
+ // ββ Form: submit ββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function onFormSubmit(e) {
+ e.preventDefault();
+
+ var satellite = dom.name ? dom.name.value.trim() : "";
+ var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
+ var bmId = dom.bookmark ? dom.bookmark.value : "";
+
+ if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; }
+ if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; }
+ if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; }
+
+ var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
+ var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
+ var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
+
+ var sat = ensureSatelliteConfig();
+
+ var entryData = {
+ satellite: satellite,
+ norad_id: noradId,
+ bookmark_id: bmId,
+ min_elevation_deg: isNaN(minEl) ? 5 : minEl,
+ priority: isNaN(prio) ? 0 : prio,
+ center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
+ bookmark_ids: [],
+ };
+
+ if (editIdx !== null) {
+ var existing = sat.entries[editIdx];
+ entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
+ sat.entries[editIdx] = entryData;
+ } else {
+ entryData.id = "sat_" + Date.now().toString(36);
+ sat.entries.push(entryData);
+ }
+
+ closeForm();
+ renderEntries();
+ markDirty();
+ }
+
+ // ββ Preset change handler βββββββββββββββββββββββββββββββββββββββββ
+ function onPresetChange() {
+ if (!dom.preset || !dom.preset.value) return;
+ var parts = dom.preset.value.split("|");
+ if (dom.name) dom.name.value = parts[0] || "";
+ if (dom.norad) dom.norad.value = parts[1] || "";
+ }
+
+ // ββ Wire all events βββββββββββββββββββββββββββββββββββββββββββββββ
+ function wireEvents() {
+ if (dom.enabled) {
+ dom.enabled.addEventListener("change", function () {
+ if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
+ markDirty();
+ });
+ }
+ if (dom.pretune) {
+ dom.pretune.addEventListener("input", function () {
+ markDirty();
+ });
+ }
+ if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
+ if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
+ if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
+ if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
+ }
+
+ // ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ window.satScheduler = {
+ wireEvents: wireEvents,
+ renderSection: renderSection,
+ renderPassStatus: renderPassStatus,
+ collectSatelliteConfig: collectSatelliteConfig,
+ };
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.js
new file mode 100644
index 00000000..6c7e39fc
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.js
@@ -0,0 +1,546 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- SAT Plugin ---
+// Live view: decoder state, latest image card
+// History view: filterable table of all decoded images
+// Predictions view: next 24 h passes for ham satellites
+
+// ββ DOM references (cached once) βββββββββββββββββββββββββββββββββββ
+const satDom = {
+ status: document.getElementById("sat-status"),
+ liveView: document.getElementById("sat-live-view"),
+ historyView: document.getElementById("sat-history-view"),
+ predictionsView: document.getElementById("sat-predictions-view"),
+ liveLatest: document.getElementById("sat-live-latest"),
+ historyList: document.getElementById("sat-history-list"),
+ historyCount: document.getElementById("sat-history-count"),
+ filterInput: document.getElementById("sat-filter"),
+ sortSelect: document.getElementById("sat-sort"),
+ typeFilter: document.getElementById("sat-type-filter"),
+ lrptState: document.getElementById("sat-lrpt-state"),
+ viewLiveBtn: document.getElementById("sat-view-live"),
+ viewHistoryBtn: document.getElementById("sat-view-history"),
+ viewPredBtn: document.getElementById("sat-view-predictions"),
+ predFilter: document.getElementById("sat-pred-filter"),
+ predMinEl: document.getElementById("sat-pred-min-el"),
+ predCategory: document.getElementById("sat-pred-category"),
+ predCurrentList: document.getElementById("sat-pred-current-list"),
+ predUpcomingList: document.getElementById("sat-pred-list"),
+ predCurrentSec: document.getElementById("sat-pred-current-section"),
+ predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
+ predStatus: document.getElementById("sat-pred-status"),
+};
+
+// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+let satImageHistory = [];
+const SAT_MAX_IMAGES = 100;
+const SAT_PRED_PAGE_SIZE = 50;
+let satPredShowAll = false;
+let satFilterText = "";
+let satActiveView = "live"; // "live" | "history" | "predictions"
+let satPredData = [];
+let satPredFilterText = "";
+let satPredMinEl = 0;
+let satPredCategory = "all";
+let satPredSatCount = 0;
+let satPredCountdownTimer = null;
+
+// ββ UI scheduler helper βββββββββββββββββββββββββββββββββββββββββββββ
+function scheduleSatUi(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+// ββ View switching ββββββββββββββββββββββββββββββββββββββββββββββββββ
+function switchSatView(view) {
+ const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
+ satActiveView = view;
+ if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
+ if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
+ if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
+ if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
+ if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
+ if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
+ if (leavingPredictions) clearPredictionDom();
+ if (view === "history") {
+ renderSatHistoryTable();
+ } else if (view === "predictions") {
+ satPredShowAll = false;
+ loadSatPredictions();
+ }
+}
+
+function clearPredictionDom() {
+ stopCountdownTimer();
+ if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
+ if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
+}
+window.clearSatPredictionDom = clearPredictionDom;
+
+satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
+satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
+satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
+
+// ββ Live view: decoder state ββββββββββββββββββββββββββββββββββββββββ
+let _lastSatLrptOn = null;
+window.updateSatLiveState = function (update) {
+ if (!satDom.lrptState) return;
+ const lrptOn = !!update.lrpt_decode_enabled;
+ if (lrptOn !== _lastSatLrptOn) {
+ _lastSatLrptOn = lrptOn;
+ satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
+ satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
+ if (satDom.status) {
+ if (lrptOn) {
+ satDom.status.textContent = "Decoder active \u2014 waiting for signal";
+ } else {
+ satDom.status.textContent = "Decoder idle";
+ }
+ }
+ }
+};
+
+function renderSatLatestCard() {
+ if (!satDom.liveLatest) return;
+ if (satImageHistory.length === 0) {
+ satDom.liveLatest.innerHTML =
+ 'No images decoded yet. Enable a decoder and wait for a satellite pass.
';
+ return;
+ }
+
+ const img = satImageHistory[0];
+ const decoder = img._decoder || "unknown";
+ const typeName = "Meteor LRPT";
+ const satellite = img.satellite || "";
+ const channels = img.channels || img.channel_a || "";
+ const lines = img.mcu_count || img.line_count || 0;
+ const unit = "MCU rows";
+ const ts = img._ts || "--";
+ const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
+
+ let meta = [typeName];
+ if (satellite) meta.push(satellite);
+ if (channels) meta.push(channels);
+ meta.push(`${lines} ${unit}`);
+ meta.push(`${date} ${ts}`);
+
+ let html = ``;
+ html += `
Latest decoded image
`;
+ html += `
${meta.join(" · ")}
`;
+ if (img.path) {
+ html += `
Download PNG `;
+ }
+ if (img.geo_bounds) {
+ html += `
Show on Map `;
+ }
+ html += `
`;
+ satDom.liveLatest.innerHTML = html;
+}
+
+// ββ History view: table βββββββββββββββββββββββββββββββββββββββββββββ
+function getSatFilteredHistory() {
+ let items = satImageHistory;
+
+ const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
+ if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
+
+ if (satFilterText) {
+ items = items.filter((i) => {
+ const haystack = [
+ "meteor lrpt",
+ i.satellite || "",
+ i.channels || "",
+ i.channel_a || "",
+ i.channel_b || "",
+ ].join(" ").toUpperCase();
+ return haystack.includes(satFilterText);
+ });
+ }
+
+ const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
+ if (sortVal === "oldest") items = items.slice().reverse();
+
+ return items;
+}
+
+function renderSatHistoryRow(img) {
+ const row = document.createElement("div");
+ row.className = "sat-history-row";
+
+ const decoder = img._decoder || "unknown";
+ const typeName = "Meteor LRPT";
+ const typeClass = "sat-type-lrpt";
+ const ts = img._ts || "--";
+ const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
+ const satellite = img.satellite || "--";
+ const channels = img.channels || "--";
+ const lines = img.mcu_count || img.line_count || 0;
+ const unit = "MCU";
+ let link = img.path
+ ? `PNG `
+ : "--";
+ if (img.geo_bounds) {
+ link += ` Map `;
+ }
+
+ row.innerHTML = [
+ `${date} ${ts} `,
+ `${typeName} `,
+ `${satellite} `,
+ `${channels} `,
+ `${lines} ${unit} `,
+ `${link} `,
+ ].join("");
+
+ return row;
+}
+
+function renderSatHistoryTable() {
+ if (!satDom.historyList) return;
+ const items = getSatFilteredHistory();
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < items.length; i += 1) {
+ fragment.appendChild(renderSatHistoryRow(items[i]));
+ }
+ satDom.historyList.replaceChildren(fragment);
+
+ if (satDom.historyCount) {
+ const total = satImageHistory.length;
+ const shown = items.length;
+ satDom.historyCount.textContent =
+ total === 0
+ ? "No images yet"
+ : shown === total
+ ? `${total} image${total === 1 ? "" : "s"}`
+ : `${shown} of ${total} images`;
+ }
+}
+
+// ββ Add image to history ββββββββββββββββββββββββββββββββββββββββββββ
+function addSatImage(img, decoder) {
+ const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
+ img._tsMs = tsMs;
+ img._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ img._decoder = decoder;
+
+ satImageHistory.unshift(img);
+ if (satImageHistory.length > SAT_MAX_IMAGES) {
+ satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
+ }
+
+ scheduleSatUi("sat-latest", () => renderSatLatestCard());
+ if (satActiveView === "history") {
+ scheduleSatUi("sat-history", () => renderSatHistoryTable());
+ }
+}
+
+// ββ Server callbacks ββββββββββββββββββββββββββββββββββββββββββββββββ
+window.onServerLrptProgress = function (msg) {
+ if (satDom.status && msg.mcu_count > 0) {
+ satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
+ }
+};
+
+window.onServerLrptImage = function (msg) {
+ if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
+ addSatImage(msg, "lrpt");
+ if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
+ window.addSatMapOverlay(msg);
+ }
+};
+
+window.resetSatHistoryView = function () {
+ satImageHistory = [];
+ if (satDom.historyList) satDom.historyList.innerHTML = "";
+ renderSatLatestCard();
+ renderSatHistoryTable();
+ if (window.clearSatMapOverlays) window.clearSatMapOverlays();
+};
+
+window.pruneSatHistoryView = function () {
+ renderSatHistoryTable();
+ renderSatLatestCard();
+};
+
+// ββ Toggle buttons ββββββββββββββββββββββββββββββββββββββββββββββββββ
+const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
+lrptDecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
+ await postPath("/toggle_lrpt_decode");
+ } catch (e) {
+ console.error("LRPT toggle failed", e);
+ }
+});
+
+// ββ Filter / sort event listeners βββββββββββββββββββββββββββββββββββ
+satDom.filterInput?.addEventListener("input", () => {
+ satFilterText = satDom.filterInput.value.trim().toUpperCase();
+ renderSatHistoryTable();
+});
+
+satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
+satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
+
+// ββ Settings: clear history βββββββββββββββββββββββββββββββββββββββββ
+document
+ .getElementById("settings-clear-sat-history")
+ ?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_lrpt_decode");
+ window.resetSatHistoryView();
+ } catch (e) {
+ console.error("Weather satellite history clear failed", e);
+ }
+ });
+
+// ββ Predictions: helpers ββββββββββββββββββββββββββββββββββββββββββββ
+function azToCardinal(deg) {
+ const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
+ return dirs[Math.round(deg / 45) % 8];
+}
+
+function formatPredTime(ms) {
+ const d = new Date(ms);
+ const now = new Date();
+ const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+ const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : "";
+ const hh = String(d.getUTCHours()).padStart(2, "0");
+ const mm = String(d.getUTCMinutes()).padStart(2, "0");
+ return `${day}${hh}:${mm}`;
+}
+
+function formatPredDuration(s) {
+ if (s >= 60) return `${Math.round(s / 60)} min`;
+ return `${s}s`;
+}
+
+function formatCountdown(ms) {
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
+ const m = Math.floor(totalSec / 60);
+ const s = totalSec % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+function elevationClass(deg) {
+ if (deg >= 45) return "sat-pred-el-high";
+ if (deg >= 10) return "sat-pred-el-mid";
+ return "sat-pred-el-low";
+}
+
+// ββ Predictions: countdown timer management βββββββββββββββββββββββββ
+function stopCountdownTimer() {
+ if (satPredCountdownTimer) {
+ clearInterval(satPredCountdownTimer);
+ satPredCountdownTimer = null;
+ }
+}
+
+function startCountdownTimer(container) {
+ const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : [];
+ if (countdownEls.length === 0) return;
+
+ satPredCountdownTimer = setInterval(() => {
+ if (satActiveView !== "predictions") {
+ stopCountdownTimer();
+ return;
+ }
+ const n = Date.now();
+ let anyActive = false;
+ for (const el of countdownEls) {
+ const los = parseInt(el.dataset.los, 10);
+ const rem = los - n;
+ if (rem > 0) {
+ el.textContent = formatCountdown(rem);
+ anyActive = true;
+ } else {
+ el.textContent = "0:00";
+ }
+ }
+ if (!anyActive) {
+ stopCountdownTimer();
+ renderSatPredictions(getFilteredPredictions());
+ }
+ }, 1000);
+}
+
+// ββ Predictions: row builders βββββββββββββββββββββββββββββββββββββββ
+function buildCurrentPassRow(pass, now) {
+ const row = document.createElement("div");
+ row.className = "sat-pred-row-current";
+ const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
+ const remaining = Math.max(0, pass.los_ms - now);
+ row.innerHTML = [
+ `${pass.satellite} `,
+ `${pass.max_elevation_deg.toFixed(1)}\u00B0 `,
+ `${formatPredTime(pass.aos_ms)} `,
+ `${formatPredTime(pass.los_ms)} `,
+ `${formatCountdown(remaining)} `,
+ `${dir} `,
+ ].join("");
+ return row;
+}
+
+function buildUpcomingPassRow(pass) {
+ const row = document.createElement("div");
+ row.className = "sat-pred-row";
+ const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
+ row.innerHTML = [
+ `${formatPredTime(pass.aos_ms)} `,
+ `${pass.satellite} `,
+ `${pass.max_elevation_deg.toFixed(1)}\u00B0 `,
+ `${formatPredDuration(pass.duration_s)} `,
+ `${dir} `,
+ ].join("");
+ return row;
+}
+
+// ββ Predictions: filter state βββββββββββββββββββββββββββββββββββββββ
+function getFilteredPredictions() {
+ let items = satPredData;
+ if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
+ if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
+ if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
+ return items;
+}
+
+function applyPredFilters() {
+ renderSatPredictions(getFilteredPredictions());
+}
+
+satDom.predFilter?.addEventListener("input", () => {
+ satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
+ applyPredFilters();
+});
+
+satDom.predMinEl?.addEventListener("change", () => {
+ satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
+ applyPredFilters();
+});
+
+satDom.predCategory?.addEventListener("change", () => {
+ satPredCategory = satDom.predCategory.value;
+ applyPredFilters();
+});
+
+// ββ Predictions: main render ββββββββββββββββββββββββββββββββββββββββ
+function renderSatPredictions(passes, error) {
+ stopCountdownTimer();
+
+ if (error) {
+ if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
+ if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
+ if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
+ if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
+ if (satDom.predStatus) satDom.predStatus.textContent = error;
+ return;
+ }
+
+ if (!Array.isArray(passes) || passes.length === 0) {
+ if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
+ if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
+ if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
+ if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
+ if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
+ return;
+ }
+
+ const now = Date.now();
+ const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
+ const upcoming = passes.filter((p) => p.aos_ms > now);
+
+ // ββ Current passes ββ
+ if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
+ if (satDom.predCurrentList) {
+ if (current.length === 0) {
+ satDom.predCurrentList.innerHTML = "";
+ } else {
+ const frag = document.createDocumentFragment();
+ for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
+ satDom.predCurrentList.replaceChildren(frag);
+ }
+ }
+
+ // ββ Upcoming passes ββ
+ const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
+ const visibleUpcoming = upcoming.slice(0, upcomingLimit);
+ const hiddenCount = upcoming.length - visibleUpcoming.length;
+ if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
+ if (satDom.predUpcomingList) {
+ const frag = document.createDocumentFragment();
+ for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
+ if (hiddenCount > 0) {
+ const moreRow = document.createElement("div");
+ moreRow.className = "sat-pred-row";
+ moreRow.style.cursor = "pointer";
+ moreRow.style.textAlign = "center";
+ moreRow.innerHTML = `Show ${hiddenCount} more passes\u2026 `;
+ moreRow.addEventListener("click", () => {
+ satPredShowAll = true;
+ renderSatPredictions(getFilteredPredictions());
+ });
+ frag.appendChild(moreRow);
+ }
+ satDom.predUpcomingList.replaceChildren(frag);
+ }
+
+ // ββ Status ββ
+ if (satDom.predStatus) {
+ let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
+ if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
+ satDom.predStatus.textContent = text;
+ }
+
+ // ββ Countdown timer ββ
+ if (current.length > 0 && satActiveView === "predictions") {
+ startCountdownTimer(satDom.predCurrentList);
+ }
+}
+
+// ββ Predictions: data loading βββββββββββββββββββββββββββββββββββββββ
+async function loadSatPredictions() {
+ if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
+ if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
+ if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
+ try {
+ const resp = await fetch("/sat_passes");
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const data = await resp.json();
+ satPredSatCount = data.satellite_count || 0;
+ if (data.error) {
+ satPredData = [];
+ renderSatPredictions([], data.error);
+ } else {
+ satPredData = data.passes || [];
+ renderSatPredictions(getFilteredPredictions());
+ }
+ } catch (e) {
+ renderSatPredictions([], `Failed to load predictions: ${e.message}`);
+ }
+}
+
+// ββ Navigate to map centered on satellite image bounds ββββββββββββββ
+window.satShowOnMap = function (south, west, north, east) {
+ if (typeof window.enableMapSourceFilter === "function") {
+ window.enableMapSourceFilter("sat");
+ }
+ const lat = (south + north) / 2;
+ const lon = (west + east) / 2;
+ if (window.navigateToAprsMap) {
+ window.navigateToAprsMap(lat, lon);
+ }
+};
+
+// ββ Initial render ββββββββββββββββββββββββββββββββββββββββββββββββββ
+renderSatLatestCard();
+renderSatHistoryTable();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js
new file mode 100644
index 00000000..bb95d5e1
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js
@@ -0,0 +1,1526 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// Background Decoding Scheduler UI
+
+(function () {
+ "use strict";
+
+ // -------------------------------------------------------------------------
+ // State
+ // -------------------------------------------------------------------------
+ let schedulerRole = null; // "control" | "rx" | null
+ let currentRigId = null;
+ let currentConfig = null;
+ let currentSchedulerStatus = null;
+ let bookmarkList = []; // [{id, name, freq_hz, mode}, ...]
+ let statusInterval = null;
+ let interleaveTicker = null;
+ let schedulerStepPending = false;
+ let schEntryEditIdx = null; // null = adding, number = editing that index
+ let schedulerDirty = false; // true when unsaved changes exist
+ // Satellite entry editing state moved to sat-scheduler.js
+
+ // -------------------------------------------------------------------------
+ // Init
+ // -------------------------------------------------------------------------
+ function initScheduler(rigId, role) {
+ schedulerRole = role;
+ currentRigId = rigId || null;
+ if (currentRigId) loadScheduler();
+ startStatusPolling();
+ startInterleaveTicker();
+ }
+
+ function destroyScheduler() {
+ if (statusInterval) {
+ clearInterval(statusInterval);
+ statusInterval = null;
+ }
+ if (interleaveTicker) {
+ clearInterval(interleaveTicker);
+ interleaveTicker = null;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Active rig (mirrors top-bar rig picker in app.js)
+ // -------------------------------------------------------------------------
+ function setSchedulerRig(rigId) {
+ const nextRigId = rigId || null;
+ if (nextRigId === currentRigId) return;
+ currentRigId = nextRigId;
+ renderSchedulerInterleaveStatus();
+ if (!currentRigId) return;
+ loadScheduler();
+ pollStatus();
+ }
+
+ // -------------------------------------------------------------------------
+ // API helpers
+ // -------------------------------------------------------------------------
+ function apiGetScheduler(rigId) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiPutScheduler(rigId, config) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(config),
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiDeleteScheduler(rigId) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId), {
+ method: "DELETE",
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetStatus(rigId) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then(
+ function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ }
+ );
+ }
+
+ function apiActivateSchedulerEntry(rigId, entryId) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ entry_id: entryId }),
+ }).then(function (r) {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ });
+ }
+
+ function apiGetBookmarks() {
+ // Fetch merged general + rig-specific bookmarks in a single request.
+ var url = currentRigId
+ ? "/bookmarks?scope=" + encodeURIComponent(currentRigId)
+ : "/bookmarks";
+ return fetch(url).then(function (r) { return r.ok ? r.json() : []; });
+ }
+
+ // -------------------------------------------------------------------------
+ // Load config + bookmarks
+ // -------------------------------------------------------------------------
+ function loadScheduler() {
+ const rig = currentRigId;
+ if (!rig) return;
+
+ Promise.all([apiGetScheduler(rig), apiGetBookmarks()])
+ .then(function ([config, bms]) {
+ currentConfig = config;
+ bookmarkList = Array.isArray(bms) ? bms : [];
+ populateTsBookmarkSelect();
+ renderScheduler();
+ clearSchedulerDirty();
+ renderSchedulerInterleaveStatus();
+ })
+ .catch(function (e) {
+ console.error("scheduler load failed", e);
+ renderSchedulerInterleaveStatus();
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Status polling
+ // -------------------------------------------------------------------------
+ function startStatusPolling() {
+ if (statusInterval) clearInterval(statusInterval);
+ statusInterval = setInterval(pollStatus, 15000);
+ pollStatus();
+ }
+
+ function startInterleaveTicker() {
+ if (interleaveTicker) clearInterval(interleaveTicker);
+ interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1000);
+ renderSchedulerInterleaveStatus();
+ }
+
+ function schedulerUtcSeconds() {
+ return Math.floor(Date.now() / 1000);
+ }
+
+ function schedulerUtcMinuteInfo() {
+ const secs = schedulerUtcSeconds();
+ const secsIntoDay = ((secs % 86400) + 86400) % 86400;
+ return {
+ minuteOfDay: Math.floor(secsIntoDay / 60),
+ secondOfMinute: secsIntoDay % 60,
+ };
+ }
+
+ function schedulerEntryIsActive(entry, nowMin) {
+ const start = Number(entry && entry.start_min);
+ const end = Number(entry && entry.end_min);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return false;
+ if (start === end) return true;
+ if (start < end) return nowMin >= start && nowMin < end;
+ return nowMin >= start || nowMin < end;
+ }
+
+ function schedulerEntryCurrentWindowStart(entry, nowMin) {
+ const start = Number(entry && entry.start_min);
+ const end = Number(entry && entry.end_min);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY;
+ if (start === end) return 0;
+ if (start < end) return start;
+ return nowMin >= start ? start : (start - 1440);
+ }
+
+ function schedulerEntryDisplayName(entry) {
+ if (!entry) return "Scheduler entry";
+ if (entry.label) return String(entry.label);
+ const bookmarkName = bmName(entry.bookmark_id);
+ return bookmarkName || "Scheduler entry";
+ }
+
+ function schedulerInterleaveState(config) {
+ if (!config || config.mode !== "time_span") {
+ return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 };
+ }
+ const entries = Array.isArray(config.entries) ? config.entries : [];
+ const minuteInfo = schedulerUtcMinuteInfo();
+ const nowMin = minuteInfo.minuteOfDay;
+ const active = entries.filter(function (entry) {
+ return schedulerEntryIsActive(entry, nowMin);
+ });
+ if (active.length === 0) {
+ return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 };
+ }
+ if (active.length === 1) {
+ return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
+ }
+ // Exclusive entry wins outright β no interleaving.
+ var exclIdx = active.findIndex(function (e) { return e.exclusive; });
+ if (exclIdx >= 0) {
+ return { activeEntries: [active[exclIdx]], currentIndex: 0, remainingSec: 0, cycleMin: 0 };
+ }
+ const defaultInterleave = Number(config.interleave_min);
+ const durations = active.map(function (entry) {
+ const own = Number(entry && entry.interleave_min);
+ if (Number.isFinite(own) && own > 0) return Math.floor(own);
+ if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave);
+ return 0;
+ });
+ const cycleMin = durations.reduce(function (sum, value) { return sum + value; }, 0);
+ if (!(cycleMin > 0)) {
+ return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
+ }
+ const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id
+ ? String(currentSchedulerStatus.last_entry_id)
+ : "";
+ const statusIndex = statusEntryId
+ ? active.findIndex(function (entry) { return String(entry && entry.id || "") === statusEntryId; })
+ : -1;
+ const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc))
+ ? Number(currentSchedulerStatus.last_applied_utc)
+ : null;
+ if (statusIndex >= 0 && statusAppliedUtc != null) {
+ const manualDurationMin = durations[statusIndex];
+ const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc);
+ const remainingSec = (manualDurationMin > 0)
+ ? Math.max(1, (manualDurationMin * 60) - elapsedSec)
+ : 0;
+ if (remainingSec > 0) {
+ return {
+ activeEntries: active,
+ currentIndex: statusIndex,
+ remainingSec: remainingSec,
+ cycleMin: cycleMin,
+ };
+ }
+ }
+ const overlapStart = active.reduce(function (maxStart, entry) {
+ return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin));
+ }, Number.NEGATIVE_INFINITY);
+ if (!Number.isFinite(overlapStart)) {
+ return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
+ }
+ const nowMinPrecise = minuteInfo.minuteOfDay + (minuteInfo.secondOfMinute / 60);
+ const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin;
+ let cumulative = 0;
+ let slotStart = 0;
+ let currentIndex = 0;
+ let currentDuration = 0;
+ for (let i = 0; i < durations.length; i += 1) {
+ const nextCumulative = cumulative + durations[i];
+ if (posMin < nextCumulative) {
+ slotStart = cumulative;
+ cumulative = nextCumulative;
+ currentIndex = i;
+ currentDuration = durations[i];
+ break;
+ }
+ cumulative = nextCumulative;
+ }
+ if (!(currentDuration > 0)) {
+ return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 };
+ }
+ const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60));
+ const remainingSec = Math.max(1, (currentDuration * 60) - elapsedSlotSec);
+ return {
+ activeEntries: active,
+ currentIndex: currentIndex,
+ remainingSec: remainingSec,
+ cycleMin: cycleMin,
+ };
+ }
+
+ function renderSchedulerInterleaveStatus() {
+ const wrap = document.getElementById("scheduler-cycle-status");
+ if (!wrap) return;
+
+ const state = schedulerInterleaveState(currentConfig);
+ const isActive = state.activeEntries.length > 1 && state.cycleMin > 0;
+
+ wrap.style.display = isActive ? "" : "none";
+
+ if (isActive) {
+ var activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]);
+ var totalSlotSec = state.cycleMin > 0
+ ? (state.cycleMin * 60) / state.activeEntries.length
+ : 0;
+ var elapsedPct = totalSlotSec > 0
+ ? Math.min(100, Math.max(0, ((totalSlotSec - state.remainingSec) / totalSlotSec) * 100))
+ : 0;
+
+ var ringFill = document.getElementById("interleave-ring-fill");
+ if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct));
+
+ var nameEl = document.getElementById("interleave-active-name");
+ if (nameEl) nameEl.textContent = activeName;
+
+ var countdownEl = document.getElementById("interleave-countdown");
+ if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s Β· " + state.cycleMin + "m cycle";
+ }
+
+ // Also update the timeline needle if visible
+ renderTimelineNeedle();
+ renderSchedulerStepControls();
+ }
+
+ function renderSchedulerStepControls() {
+ const prevBtn = document.getElementById("scheduler-prev-btn");
+ const nextBtn = document.getElementById("scheduler-next-btn");
+ if (!prevBtn || !nextBtn) return;
+ const state = schedulerInterleaveState(currentConfig);
+ const enabled =
+ schedulerRole === "control" &&
+ !!currentRigId &&
+ !schedulerStepPending &&
+ state.activeEntries.length > 1;
+ prevBtn.disabled = !enabled;
+ nextBtn.disabled = !enabled;
+ const hint = enabled
+ ? "Select a different active scheduler entry"
+ : "Available only when multiple scheduler entries are active";
+ prevBtn.title = hint;
+ nextBtn.title = hint;
+ }
+
+ function pollStatus() {
+ const rig = currentRigId;
+ if (!rig) return;
+ apiGetStatus(rig)
+ .then(function (st) {
+ currentSchedulerStatus = st || null;
+ renderStatus(st);
+ renderSchedulerInterleaveStatus();
+ renderActivityLog();
+ renderSatPassStatus();
+ })
+ .catch(function () {});
+ }
+
+ function renderStatus(st) {
+ const el = document.getElementById("scheduler-status-card");
+ if (!el) return;
+ if (!st || (!st.active && !st.last_bookmark_id)) {
+ el.textContent = "No activity yet.";
+ return;
+ }
+ const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : "";
+ const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries)
+ ? currentConfig.entries.find(function (item) { return String(item && item.id || "") === statusEntryId; })
+ : null;
+ const name = entry
+ ? schedulerEntryDisplayName(entry)
+ : (st.last_bookmark_name || st.last_bookmark_id || "β");
+ let ts = "";
+ if (st.last_applied_utc) {
+ const d = new Date(st.last_applied_utc * 1000);
+ ts = " at " + d.toUTCString();
+ }
+ const satLabel = st.active_satellite
+ ? " [SAT: " + st.active_satellite + "]"
+ : "";
+ var details = "";
+ if (st.freq_hz) {
+ details += formatFreq(st.freq_hz);
+ if (st.mode) details += " \u00B7 " + st.mode;
+ if (st.active_decoders && st.active_decoders.length > 0) {
+ details += " \u00B7 " + st.active_decoders.join(", ") + " active";
+ }
+ }
+ if (details) {
+ el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts +
+ '' + escHtml(details) + ' ';
+ } else {
+ el.textContent = "Last applied: " + name + satLabel + ts;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Activity log
+ // -------------------------------------------------------------------------
+ function apiGetSchedulerLog(rigId) {
+ return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function (r) {
+ return r.ok ? r.json() : [];
+ });
+ }
+
+ function renderActivityLog() {
+ var wrap = document.getElementById("scheduler-activity-log-wrap");
+ var container = document.getElementById("scheduler-activity-log");
+ if (!wrap || !container || !currentRigId) return;
+
+ apiGetSchedulerLog(currentRigId).then(function (entries) {
+ if (!entries || entries.length === 0) {
+ wrap.style.display = "none";
+ return;
+ }
+ wrap.style.display = "";
+ var html = entries.slice().reverse().map(function (e) {
+ var d = new Date(e.utc * 1000);
+ var ts = d.toUTCString();
+ var action = e.action || "unknown";
+ var label = e.entry_label || "";
+ var bm = e.bookmark_name || "";
+ return '' +
+ '' + escHtml(ts) + ' ' +
+ '' + escHtml(action) + ' ' +
+ (bm ? '' + escHtml(bm) + ' ' : '') +
+ (label ? ' (' + escHtml(label) + ') ' : '') +
+ '
';
+ }).join("");
+ container.innerHTML = html;
+ }).catch(function () {});
+ }
+
+ // -------------------------------------------------------------------------
+ // Render the full scheduler panel
+ // -------------------------------------------------------------------------
+ function renderScheduler() {
+ const panel = document.getElementById("scheduler-panel");
+ if (!panel) return;
+
+ const mode = (currentConfig && currentConfig.mode) || "disabled";
+ const isControl = schedulerRole === "control";
+
+ // Mode selector
+ setSelected("scheduler-mode-select", mode);
+
+ // Show/hide main-view scheduler controls (visible when base mode active OR satellites enabled)
+ const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled;
+ const controlRow = document.querySelector(".scheduler-control-row");
+ if (controlRow) controlRow.style.display = (mode !== "disabled" || satEnabled) ? "" : "none";
+
+ // Show/hide sections
+ const glSection = document.getElementById("scheduler-grayline-section");
+ const tsSection = document.getElementById("scheduler-timespan-section");
+ if (glSection) glSection.style.display = mode === "grayline" ? "" : "none";
+ if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none";
+
+ // Satellite overlay (always visible β independent of mode)
+ renderSatelliteSection();
+
+ // Grayline inputs
+ if (mode === "grayline" && currentConfig && currentConfig.grayline) {
+ const gl = currentConfig.grayline;
+ // Prefer saved value; fall back to server coordinates from app.js globals.
+ const lat = gl.lat != null ? gl.lat : (typeof serverLat !== "undefined" ? serverLat : "");
+ const lon = gl.lon != null ? gl.lon : (typeof serverLon !== "undefined" ? serverLon : "");
+ setInputValue("scheduler-gl-lat", lat != null ? lat : "");
+ setInputValue("scheduler-gl-lon", lon != null ? lon : "");
+ var gridEl = document.getElementById("scheduler-gl-grid");
+ if (gridEl && lat !== "" && lon !== "") {
+ gridEl.value = latLonToGrid(lat, lon);
+ }
+ setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20);
+ renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id);
+ renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id);
+ renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id);
+ renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id);
+ } else if (mode === "grayline") {
+ // No saved grayline config yet β pre-fill coords from server if available.
+ const lat = typeof serverLat !== "undefined" ? serverLat : "";
+ const lon = typeof serverLon !== "undefined" ? serverLon : "";
+ setInputValue("scheduler-gl-lat", lat != null ? lat : "");
+ setInputValue("scheduler-gl-lon", lon != null ? lon : "");
+ var gridEl2 = document.getElementById("scheduler-gl-grid");
+ if (gridEl2 && lat !== "" && lon !== "") {
+ gridEl2.value = latLonToGrid(lat, lon);
+ }
+ setInputValue("scheduler-gl-window", 20);
+ renderBookmarkSelect("scheduler-gl-dawn", null);
+ renderBookmarkSelect("scheduler-gl-day", null);
+ renderBookmarkSelect("scheduler-gl-dusk", null);
+ renderBookmarkSelect("scheduler-gl-night", null);
+ } else {
+ renderBookmarkSelect("scheduler-gl-dawn", null);
+ renderBookmarkSelect("scheduler-gl-day", null);
+ renderBookmarkSelect("scheduler-gl-dusk", null);
+ renderBookmarkSelect("scheduler-gl-night", null);
+ }
+
+ // Interleave input
+ const ilEl = document.getElementById("scheduler-ts-interleave");
+ if (ilEl) {
+ const il = currentConfig && currentConfig.interleave_min;
+ ilEl.value = il ? il : "";
+ }
+
+ // TimeSpan entries
+ renderTimespanEntries();
+
+ // Enable/disable controls
+ const formEls = panel.querySelectorAll("input, select, button.sch-write");
+ formEls.forEach(function (el) {
+ el.disabled = !isControl;
+ });
+ const saveBtn = document.getElementById("scheduler-save-btn");
+ if (saveBtn) {
+ saveBtn.style.display = isControl ? "" : "none";
+ }
+ const resetBtn = document.getElementById("scheduler-reset-btn");
+ if (resetBtn) {
+ resetBtn.style.display = isControl ? "" : "none";
+ }
+ }
+
+ function setSelected(id, value) {
+ const el = document.getElementById(id);
+ if (el) el.value = value;
+ }
+
+ function setInputValue(id, value) {
+ const el = document.getElementById(id);
+ if (el) el.value = value;
+ }
+
+ function renderBookmarkSelect(id, selectedId) {
+ const sel = document.getElementById(id);
+ if (!sel) return;
+ sel.innerHTML = 'β none β ';
+ bookmarkList.forEach(function (bm) {
+ const opt = document.createElement("option");
+ opt.value = bm.id;
+ opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
+ if (bm.id === selectedId) opt.selected = true;
+ sel.appendChild(opt);
+ });
+ }
+
+ function formatFreq(hz) {
+ if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
+ return hz + " Hz";
+ }
+
+ // -------------------------------------------------------------------------
+ // Entry form (inline card below Add Entry button)
+ // -------------------------------------------------------------------------
+ function schOpenEntryForm(entry, idx) {
+ schEntryEditIdx = (idx != null) ? idx : null;
+
+ const titleEl = document.getElementById("sch-entry-form-title");
+ if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry";
+
+ const startEl = document.getElementById("scheduler-ts-start");
+ const endEl = document.getElementById("scheduler-ts-end");
+ const bmEl = document.getElementById("scheduler-ts-bookmark");
+ const labelEl = document.getElementById("scheduler-ts-label");
+ const ilEl = document.getElementById("scheduler-ts-entry-interleave");
+ const centerHzEl = document.getElementById("scheduler-ts-center-hz");
+
+ if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : "";
+ if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : "";
+ if (bmEl) bmEl.value = entry ? (entry.bookmark_id || "") : "";
+ if (labelEl) labelEl.value = entry ? (entry.label || "") : "";
+ if (ilEl) ilEl.value = entry && entry.interleave_min ? entry.interleave_min : "";
+ if (centerHzEl) centerHzEl.value = entry && entry.center_hz ? entry.center_hz : "";
+
+ const recordEl = document.getElementById("scheduler-ts-entry-record");
+ if (recordEl) recordEl.checked = !!(entry && entry.record);
+
+ pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : [];
+ renderExtraBmList();
+
+ const wrap = document.getElementById("sch-entry-form-wrap");
+ if (wrap) {
+ wrap.style.display = "block";
+ if (startEl) startEl.focus();
+ }
+ }
+
+ function schCloseEntryForm() {
+ const wrap = document.getElementById("sch-entry-form-wrap");
+ if (wrap) wrap.style.display = "none";
+ schEntryEditIdx = null;
+ pendingExtraBmIds = [];
+ }
+
+ function schEntryFormSubmit(e) {
+ e.preventDefault();
+
+ const startEl = document.getElementById("scheduler-ts-start");
+ const endEl = document.getElementById("scheduler-ts-end");
+ const bmEl = document.getElementById("scheduler-ts-bookmark");
+ const labelEl = document.getElementById("scheduler-ts-label");
+ const ilEl = document.getElementById("scheduler-ts-entry-interleave");
+ const centerHzEl = document.getElementById("scheduler-ts-center-hz");
+ if (!startEl || !endEl || !bmEl) return;
+
+ const bmId = bmEl.value;
+ if (!bmId) {
+ window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" });
+ return;
+ }
+
+ const startMin = hhmmToMin(startEl.value);
+ const endMin = hhmmToMin(endEl.value);
+ const label = labelEl ? labelEl.value.trim() : "";
+ const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN;
+ const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null;
+ const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN;
+ const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null;
+ const extraBmIds = pendingExtraBmIds.slice();
+
+ if (!currentConfig) {
+ currentConfig = { remote: currentRigId, mode: "time_span", entries: [] };
+ }
+ if (!currentConfig.entries) currentConfig.entries = [];
+
+ const recordCb = document.getElementById("scheduler-ts-entry-record");
+ const entryRecord = recordCb ? recordCb.checked : false;
+
+ const entryData = {
+ start_min: startMin,
+ end_min: endMin,
+ bookmark_id: bmId,
+ label: label || null,
+ interleave_min: entryInterleave,
+ center_hz: centerHz,
+ bookmark_ids: extraBmIds,
+ record: entryRecord,
+ };
+
+ if (schEntryEditIdx !== null) {
+ const existing = currentConfig.entries[schEntryEditIdx];
+ entryData.id = existing ? existing.id : ("ts_" + Date.now().toString(36));
+ currentConfig.entries[schEntryEditIdx] = entryData;
+ } else {
+ entryData.id = "ts_" + Date.now().toString(36);
+ currentConfig.entries.push(entryData);
+ }
+
+ schCloseEntryForm();
+ renderTimespanEntries();
+ markSchedulerDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // 24h Timeline Bar
+ // -------------------------------------------------------------------------
+ var TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"];
+
+ function renderTimeline() {
+ var container = document.getElementById("scheduler-ts-timeline");
+ if (!container) return;
+ var entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : [];
+ if (entries.length === 0) {
+ container.innerHTML = "";
+ return;
+ }
+
+ var W = 1000;
+ var H = 80;
+ var BAR_Y = 6;
+ var BAR_H = 30;
+ var TICK_Y = BAR_Y + BAR_H + 2;
+
+ var svg = '';
+
+ // Background bar
+ svg += ' ';
+
+ // Entry segments
+ entries.forEach(function (entry, idx) {
+ var start = Number(entry.start_min);
+ var end = Number(entry.end_min);
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return;
+ var color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length];
+
+ if (start === end) {
+ // All-day entry
+ svg += ' ';
+ } else if (start < end) {
+ var x = (start / 1440) * W;
+ var w = ((end - start) / 1440) * W;
+ svg += ' ';
+ } else {
+ // Wrap-around: two segments
+ var x1 = (start / 1440) * W;
+ var w1 = W - x1;
+ svg += ' ';
+ var w2 = (end / 1440) * W;
+ svg += ' ';
+ }
+ });
+
+ // Interleave stripes for overlapping entries
+ var interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0;
+ if (interleaveMin > 0 && entries.length > 1) {
+ // Find overlap regions where 2+ entries are active
+ for (var m = 0; m < 1440; m += interleaveMin) {
+ var overlapping = [];
+ entries.forEach(function (entry, idx) {
+ if (schedulerEntryIsActive(entry, m)) {
+ overlapping.push(idx);
+ }
+ });
+ if (overlapping.length > 1) {
+ var stripeX = (m / 1440) * W;
+ var stripeW = Math.max(1, (interleaveMin / 1440) * W);
+ // Determine which entry "owns" this stripe via cycle position
+ var cyclePos = m % (interleaveMin * overlapping.length);
+ var ownerSlot = Math.floor(cyclePos / interleaveMin);
+ var ownerIdx = overlapping[ownerSlot % overlapping.length];
+ var stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length];
+ svg += ' ';
+ }
+ }
+ }
+
+ // Tick marks every 3 hours
+ for (var h = 0; h <= 24; h += 3) {
+ var tx = (h / 24) * W;
+ svg += ' ';
+ if (h < 24) {
+ svg += '' + String(h).padStart(2, "0") + ' ';
+ }
+ }
+
+ // Local time ticks
+ var LOCAL_TICK_Y = TICK_Y + 18;
+ for (var h = 0; h < 24; h += 3) {
+ var localMin = h * 60;
+ var utcOffset = new Date().getTimezoneOffset(); // offset in minutes (negative for east of UTC)
+ var utcMin = (localMin + utcOffset + 1440) % 1440;
+ var tx = (utcMin / 1440) * W;
+ svg += '' + String(h).padStart(2, "0") + 'L ';
+ }
+
+ // Current time needle
+ svg += '' + timelineNeedleSvg() + ' ';
+
+ svg += ' ';
+ container.innerHTML = svg;
+
+ // Wire click events on segments
+ container.querySelectorAll(".sch-timeline-seg").forEach(function (seg) {
+ seg.addEventListener("click", function () {
+ var i = parseInt(seg.getAttribute("data-idx"), 10);
+ var entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null;
+ if (entry) schOpenEntryForm(entry, i);
+ });
+ });
+
+ // Click-to-add on empty timeline region
+ var svgEl = container.querySelector('svg');
+ if (svgEl) {
+ svgEl.addEventListener('click', function (e) {
+ // Only trigger if clicking on the background bar, not on a segment
+ if (e.target.classList.contains('sch-timeline-seg')) return;
+ var rect = svgEl.getBoundingClientRect();
+ var xPct = (e.clientX - rect.left) / rect.width;
+ var clickMin = Math.floor(xPct * 1440);
+ var startHour = Math.floor(clickMin / 60);
+ var startMin = startHour * 60;
+ var endMin = ((startHour + 1) % 24) * 60;
+
+ // Pre-fill the entry form with the clicked hour
+ schOpenEntryForm(null, null);
+ var startEl = document.getElementById('scheduler-ts-start');
+ var endEl = document.getElementById('scheduler-ts-end');
+ if (startEl) startEl.value = minToHHMM(startMin);
+ if (endEl) endEl.value = minToHHMM(endMin);
+ });
+ svgEl.style.cursor = 'crosshair';
+ }
+ }
+
+ function timelineNeedleSvg() {
+ var info = schedulerUtcMinuteInfo();
+ var nowMin = info.minuteOfDay + (info.secondOfMinute / 60);
+ var x = (nowMin / 1440) * 1000;
+ return ' ' +
+ ' ';
+ }
+
+ function renderTimelineNeedle() {
+ var g = document.getElementById("sch-timeline-needle-g");
+ if (g) g.innerHTML = timelineNeedleSvg();
+ }
+
+ // -------------------------------------------------------------------------
+ // Inline row editing
+ // -------------------------------------------------------------------------
+ function schInlineEdit(tr, entry, idx) {
+ var bmOptions = bookmarkList.map(function (bm) {
+ var sel = bm.id === entry.bookmark_id ? ' selected' : '';
+ return '' + escHtml(bm.name) + ' ';
+ }).join('');
+
+ var extraBmOptions = 'β add channel β ' + bookmarkList.map(function (bm) {
+ return '' + escHtml(bm.name) + ' ';
+ }).join('');
+
+ var inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : [];
+
+ tr.innerHTML =
+ '\u2807 ' +
+ ' ' +
+ ' ' +
+ '' + (entry.center_hz ? formatFreq(entry.center_hz) : '\u2014') + ' ' +
+ '' + bmOptions + ' ' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ ' ' +
+ ' ' +
+ ' Excl. ' +
+ ' ' +
+ 'Save Cancel ';
+
+ tr.classList.add('sch-inline-editing');
+
+ var chipsContainer = tr.querySelector('.sch-inline-extra-chips');
+ var extraPick = tr.querySelector('.sch-inline-extra-pick');
+ var extraAddBtn = tr.querySelector('.sch-inline-extra-add');
+
+ function renderInlineExtraChips() {
+ chipsContainer.innerHTML = '';
+ inlineExtraIds.forEach(function (id, i) {
+ var chip = document.createElement('span');
+ chip.className = 'sch-extra-bm-chip';
+ var rmBtn = document.createElement('span');
+ rmBtn.className = 'sch-extra-bm-chip-rm';
+ rmBtn.textContent = '\u00D7';
+ rmBtn.title = 'Remove';
+ rmBtn.addEventListener('click', function () {
+ inlineExtraIds.splice(i, 1);
+ renderInlineExtraChips();
+ });
+ chip.appendChild(rmBtn);
+ chip.appendChild(document.createTextNode(' ' + bmName(id)));
+ chipsContainer.appendChild(chip);
+ });
+ Array.from(extraPick.options).forEach(function (opt) {
+ if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value);
+ });
+ }
+ renderInlineExtraChips();
+
+ extraAddBtn.addEventListener('click', function () {
+ if (!extraPick.value) return;
+ if (!inlineExtraIds.includes(extraPick.value)) {
+ inlineExtraIds.push(extraPick.value);
+ renderInlineExtraChips();
+ }
+ extraPick.value = '';
+ });
+
+ // Wire exclusive checkbox to disable interleave input.
+ var exclEl = tr.querySelector('[data-field="exclusive"]');
+ var ilInput = tr.querySelector('[data-field="interleave"]');
+ if (exclEl && ilInput) {
+ exclEl.addEventListener('change', function () {
+ ilInput.disabled = exclEl.checked;
+ if (exclEl.checked) ilInput.value = '';
+ });
+ }
+
+ tr.querySelector('.sch-inline-save').addEventListener('click', function () {
+ var startEl = tr.querySelector('[data-field="start"]');
+ var endEl = tr.querySelector('[data-field="end"]');
+ var bmEl = tr.querySelector('[data-field="bookmark"]');
+ var labelEl = tr.querySelector('[data-field="label"]');
+ var ilEl = tr.querySelector('[data-field="interleave"]');
+ var recEl = tr.querySelector('[data-field="record"]');
+ var exEl = tr.querySelector('[data-field="exclusive"]');
+
+ if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; }
+
+ entry.start_min = hhmmToMin(startEl.value);
+ entry.end_min = hhmmToMin(endEl.value);
+ entry.bookmark_id = bmEl.value;
+ entry.label = labelEl.value.trim() || null;
+ entry.exclusive = exEl ? exEl.checked : false;
+ var ilVal = parseInt(ilEl.value, 10);
+ entry.interleave_min = entry.exclusive ? null : ((!isNaN(ilVal) && ilVal > 0) ? ilVal : null);
+ entry.bookmark_ids = inlineExtraIds.slice();
+ entry.record = recEl.checked;
+
+ currentConfig.entries[idx] = entry;
+ renderTimespanEntries();
+ markSchedulerDirty();
+ });
+
+ tr.querySelector('.sch-inline-cancel').addEventListener('click', function () {
+ renderTimespanEntries();
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // TimeSpan entries table
+ // -------------------------------------------------------------------------
+ function renderTimespanEntries() {
+ const tbody = document.getElementById("scheduler-ts-tbody");
+ if (!tbody) return;
+ tbody.innerHTML = "";
+ const entries =
+ currentConfig && Array.isArray(currentConfig.entries)
+ ? currentConfig.entries
+ : [];
+ entries.forEach(function (entry, idx) {
+ const tr = document.createElement("tr");
+ if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id &&
+ entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) {
+ tr.classList.add("sch-active");
+ }
+ const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "β";
+ const allDay = entry.start_min === entry.end_min;
+ const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "β";
+ const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : [];
+ const extraCell = extraIds.length
+ ? extraIds.map(function (id) { return escHtml(bmName(id)); }).join(", ")
+ : "β";
+ tr.innerHTML =
+ '\u2807 ' +
+ '' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ') ') + ' ' +
+ '' + (allDay ? "\u2014" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ') ') + ' ' +
+ '' + centerCell + ' ' +
+ '' + escHtml(bmName(entry.bookmark_id)) + ' ' +
+ '' + extraCell + ' ' +
+ '' + escHtml(entry.label || "") + ' ' +
+ '' + il + ' ' +
+ '' + (entry.record ? 'Yes' : '') + ' ' +
+ '' +
+ 'Edit ' +
+ 'Remove ' +
+ ' ';
+ tbody.appendChild(tr);
+ });
+ tbody.querySelectorAll(".sch-edit-btn").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ const i = parseInt(btn.dataset.idx, 10);
+ const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null;
+ if (entry) schInlineEdit(btn.closest('tr'), entry, i);
+ });
+ });
+ tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) {
+ btn.addEventListener("click", function () {
+ removeEntry(parseInt(btn.dataset.idx, 10));
+ });
+ });
+
+ // Drag-to-reorder
+ (function () {
+ var handles = tbody.querySelectorAll('.sch-drag-handle');
+ var dragIdx = null;
+
+ handles.forEach(function (handle, idx) {
+ var row = handle.parentElement;
+
+ handle.addEventListener('dragstart', function (e) {
+ dragIdx = idx;
+ row.classList.add('sch-dragging');
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', String(idx));
+ });
+
+ row.addEventListener('dragover', function (e) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ row.classList.add('sch-drag-over');
+ });
+
+ row.addEventListener('dragleave', function () {
+ row.classList.remove('sch-drag-over');
+ });
+
+ row.addEventListener('drop', function (e) {
+ e.preventDefault();
+ row.classList.remove('sch-drag-over');
+ if (dragIdx === null || dragIdx === idx) return;
+ var entries = currentConfig.entries;
+ var moved = entries.splice(dragIdx, 1)[0];
+ entries.splice(idx, 0, moved);
+ renderTimespanEntries();
+ markSchedulerDirty();
+ });
+
+ handle.addEventListener('dragend', function () {
+ row.classList.remove('sch-dragging');
+ dragIdx = null;
+ });
+ });
+ })();
+
+ renderTimeline();
+ }
+
+ function bmName(id) {
+ const bm = bookmarkList.find(function (b) { return b.id === id; });
+ return bm ? bm.name : String(id || "");
+ }
+
+ function minToLocal(min) {
+ // Convert UTC minutes-since-midnight to local time string
+ var now = new Date();
+ var utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ var utcMs = utcMidnight.getTime() + min * 60000;
+ var local = new Date(utcMs);
+ return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0");
+ }
+
+ function minToHHMM(min) {
+ const h = Math.floor(min / 60) % 24;
+ const m = min % 60;
+ return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
+ }
+
+ function hhmmToMin(str) {
+ const parts = str.split(":");
+ return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10);
+ }
+
+ function gridToLatLon(grid) {
+ grid = String(grid).toUpperCase().trim();
+ if (grid.length < 4) return null;
+ var lonField = grid.charCodeAt(0) - 65;
+ var latField = grid.charCodeAt(1) - 65;
+ var lonSquare = parseInt(grid.charAt(2), 10);
+ var latSquare = parseInt(grid.charAt(3), 10);
+ if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null;
+ var lon = lonField * 20 + lonSquare * 2 - 180;
+ var lat = latField * 10 + latSquare * 1 - 90;
+ if (grid.length >= 6) {
+ var lonSub = grid.charCodeAt(4) - 65;
+ var latSub = grid.charCodeAt(5) - 65;
+ if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) {
+ lon += lonSub * (2 / 24) + (1 / 24);
+ lat += latSub * (1 / 24) + (0.5 / 24);
+ }
+ } else {
+ lon += 1; // center of square
+ lat += 0.5;
+ }
+ return { lat: lat, lon: lon };
+ }
+
+ function latLonToGrid(lat, lon) {
+ lon = parseFloat(lon) + 180;
+ lat = parseFloat(lat) + 90;
+ if (isNaN(lon) || isNaN(lat)) return "";
+ var lonField = String.fromCharCode(65 + Math.floor(lon / 20));
+ var latField = String.fromCharCode(65 + Math.floor(lat / 10));
+ var lonSquare = Math.floor((lon % 20) / 2);
+ var latSquare = Math.floor(lat % 10);
+ var lonSub = String.fromCharCode(97 + Math.floor(((lon % 2) / 2) * 24));
+ var latSub = String.fromCharCode(97 + Math.floor((lat % 1) * 24));
+ return lonField + latField + lonSquare + latSquare + lonSub + latSub;
+ }
+
+ function escHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function schedulerSelectRelativeEntry(delta) {
+ const state = schedulerInterleaveState(currentConfig);
+ if (!currentRigId || schedulerStepPending || state.activeEntries.length <= 1) return;
+ const count = state.activeEntries.length;
+ const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0;
+ const targetIndex = (currentIndex + delta + count) % count;
+ const target = state.activeEntries[targetIndex];
+ if (!target || !target.id) return;
+
+ schedulerStepPending = true;
+ renderSchedulerStepControls();
+
+ Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null)
+ .then(function () {
+ return apiActivateSchedulerEntry(currentRigId, target.id);
+ })
+ .then(function (status) {
+ currentSchedulerStatus = status || null;
+ return Promise.resolve(
+ typeof vchanToggleSchedulerRelease === "function"
+ ? vchanToggleSchedulerRelease()
+ : null
+ ).then(function () {
+ renderStatus(status);
+ renderSchedulerInterleaveStatus();
+ showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + ".");
+ pollStatus();
+ });
+ })
+ .catch(function (e) {
+ console.error("scheduler entry selection failed", e);
+ showSchedulerToast("Scheduler entry selection failed: " + e.message, true);
+ })
+ .finally(function () {
+ schedulerStepPending = false;
+ renderSchedulerStepControls();
+ });
+ }
+
+ function removeEntry(idx) {
+ if (!currentConfig || !currentConfig.entries) return;
+ currentConfig.entries.splice(idx, 1);
+ renderTimespanEntries();
+ markSchedulerDirty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Bookmark existence check
+ // -------------------------------------------------------------------------
+ function bookmarkExists(id) {
+ if (!id) return true; // null/empty is allowed
+ return bookmarkList.some(function (bm) { return bm.id === id; });
+ }
+
+ // -------------------------------------------------------------------------
+ // Save
+ // -------------------------------------------------------------------------
+ function saveScheduler() {
+ const rig = currentRigId;
+ if (!rig) return;
+
+ const modeEl = document.getElementById("scheduler-mode-select");
+ const mode = modeEl ? modeEl.value : "disabled";
+
+ const config = {
+ remote: rig,
+ mode,
+ grayline: null,
+ entries: [],
+ };
+
+ if (mode === "grayline") {
+ const lat = parseFloat(document.getElementById("scheduler-gl-lat").value);
+ const lon = parseFloat(document.getElementById("scheduler-gl-lon").value);
+ const win = parseInt(document.getElementById("scheduler-gl-window").value, 10);
+ config.grayline = {
+ lat: isNaN(lat) ? 0 : lat,
+ lon: isNaN(lon) ? 0 : lon,
+ transition_window_min: isNaN(win) ? 20 : win,
+ dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null,
+ day_bookmark_id: selectVal("scheduler-gl-day") || null,
+ dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null,
+ night_bookmark_id: selectVal("scheduler-gl-night") || null,
+ };
+ } else if (mode === "time_span") {
+ config.entries =
+ currentConfig && currentConfig.entries ? currentConfig.entries : [];
+ const ilVal = parseInt(document.getElementById("scheduler-ts-interleave").value, 10);
+ config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal;
+ }
+
+ // Satellite overlay β saved regardless of base mode.
+ config.satellites = collectSatelliteConfig();
+
+ // Validate bookmark existence before saving
+ var missingBmErrors = [];
+ if (mode === "grayline" && config.grayline) {
+ var gl = config.grayline;
+ var glFields = [
+ ["dawn_bookmark_id", "Grayline dawn"],
+ ["day_bookmark_id", "Grayline day"],
+ ["dusk_bookmark_id", "Grayline dusk"],
+ ["night_bookmark_id", "Grayline night"],
+ ];
+ glFields.forEach(function (pair) {
+ if (!bookmarkExists(gl[pair[0]])) missingBmErrors.push(pair[1] + " (bookmark " + gl[pair[0]] + ")");
+ });
+ }
+ if (mode === "time_span" && Array.isArray(config.entries)) {
+ config.entries.forEach(function (entry, idx) {
+ var label = entry.label || "Entry #" + (idx + 1);
+ if (!bookmarkExists(entry.bookmark_id)) {
+ missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")");
+ }
+ var extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : [];
+ extras.forEach(function (id) {
+ if (!bookmarkExists(id)) {
+ missingBmErrors.push(label + " extra channel (" + id + ")");
+ }
+ });
+ });
+ }
+ if (config.satellites && Array.isArray(config.satellites.entries)) {
+ config.satellites.entries.forEach(function (sat, idx) {
+ var satLabel = sat.name || "Satellite #" + (idx + 1);
+ if (!bookmarkExists(sat.bookmark_id)) {
+ missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")");
+ }
+ });
+ }
+ if (missingBmErrors.length > 0) {
+ showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true);
+ return;
+ }
+
+ const btn = document.getElementById("scheduler-save-btn");
+ if (btn) btn.disabled = true;
+
+ apiPutScheduler(rig, config)
+ .then(function (saved) {
+ currentConfig = saved;
+ renderScheduler();
+ clearSchedulerDirty();
+ showSchedulerToast("Scheduler saved.");
+ })
+ .catch(function (e) {
+ showSchedulerToast("Save failed: " + e.message, true);
+ })
+ .finally(function () {
+ if (btn) btn.disabled = false;
+ });
+ }
+
+ function selectVal(id) {
+ const el = document.getElementById(id);
+ return el ? el.value : "";
+ }
+
+ async function resetScheduler() {
+ const rig = currentRigId;
+ if (!rig) return;
+ if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return;
+
+ apiDeleteScheduler(rig)
+ .then(function () {
+ currentConfig = {
+ remote: rig,
+ mode: "disabled",
+ grayline: null,
+ entries: [],
+ };
+ renderScheduler();
+ clearSchedulerDirty();
+ showSchedulerToast("Scheduler reset.");
+ })
+ .catch(function (e) {
+ showSchedulerToast("Reset failed: " + e.message, true);
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Dirty-state tracking
+ // -------------------------------------------------------------------------
+ function markSchedulerDirty() {
+ if (schedulerDirty) return;
+ schedulerDirty = true;
+ var btn = document.getElementById("scheduler-save-btn");
+ if (btn) btn.classList.add("sch-dirty");
+ }
+
+ function clearSchedulerDirty() {
+ schedulerDirty = false;
+ var btn = document.getElementById("scheduler-save-btn");
+ if (btn) btn.classList.remove("sch-dirty");
+ }
+
+ // -------------------------------------------------------------------------
+ // Toast helper
+ // -------------------------------------------------------------------------
+ function showSchedulerToast(msg, isError) {
+ const el = document.getElementById("scheduler-toast");
+ if (!el) return;
+ el.textContent = msg;
+ el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
+ el.style.display = "block";
+ setTimeout(function () {
+ el.style.display = "none";
+ }, 3000);
+ }
+
+ // -------------------------------------------------------------------------
+ // Wire events (called once DOM is ready)
+ // -------------------------------------------------------------------------
+ function wireSchedulerEvents() {
+ const modeEl = document.getElementById("scheduler-mode-select");
+ if (modeEl) {
+ modeEl.addEventListener("change", function () {
+ if (!currentConfig) currentConfig = { remote: currentRigId, mode: modeEl.value, entries: [] };
+ currentConfig.mode = modeEl.value;
+ renderScheduler();
+ });
+ }
+
+ const saveBtn = document.getElementById("scheduler-save-btn");
+ if (saveBtn) saveBtn.addEventListener("click", saveScheduler);
+
+ const resetBtn = document.getElementById("scheduler-reset-btn");
+ if (resetBtn) resetBtn.addEventListener("click", resetScheduler);
+
+ const addBtn = document.getElementById("scheduler-ts-add-btn");
+ if (addBtn) addBtn.addEventListener("click", function () { schOpenEntryForm(null, null); });
+
+ const entryForm = document.getElementById("sch-entry-form");
+ if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit);
+
+ const cancelBtn = document.getElementById("sch-entry-form-cancel");
+ if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm);
+
+ const prevBtn = document.getElementById("scheduler-prev-btn");
+ if (prevBtn) prevBtn.addEventListener("click", function () {
+ schedulerSelectRelativeEntry(-1);
+ });
+
+ const nextBtn = document.getElementById("scheduler-next-btn");
+ if (nextBtn) nextBtn.addEventListener("click", function () {
+ schedulerSelectRelativeEntry(1);
+ });
+
+ // Dirty-state: mark dirty on any user input/change within the scheduler panel
+ var schPanel = document.getElementById("scheduler-panel");
+ if (schPanel && !schPanel._dirtyWired) {
+ schPanel._dirtyWired = true;
+ schPanel.addEventListener("input", function (e) {
+ // Ignore the entry-form inputs (they don't affect saved config until submitted)
+ if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return;
+ markSchedulerDirty();
+ });
+ schPanel.addEventListener("change", function (e) {
+ if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return;
+ markSchedulerDirty();
+ });
+ }
+
+ // Grid square β lat/lon sync
+ var gridEl = document.getElementById("scheduler-gl-grid");
+ if (gridEl) {
+ gridEl.addEventListener("input", function () {
+ var ll = gridToLatLon(gridEl.value);
+ if (ll) {
+ setInputValue("scheduler-gl-lat", ll.lat.toFixed(3));
+ setInputValue("scheduler-gl-lon", ll.lon.toFixed(3));
+ markSchedulerDirty();
+ }
+ });
+ }
+ var latEl = document.getElementById("scheduler-gl-lat");
+ var lonEl = document.getElementById("scheduler-gl-lon");
+ [latEl, lonEl].forEach(function (el) {
+ if (el) {
+ el.addEventListener("input", function () {
+ var la = parseFloat(document.getElementById("scheduler-gl-lat").value);
+ var lo = parseFloat(document.getElementById("scheduler-gl-lon").value);
+ var gEl = document.getElementById("scheduler-gl-grid");
+ if (gEl && !isNaN(la) && !isNaN(lo)) {
+ gEl.value = latLonToGrid(la, lo);
+ }
+ });
+ }
+ });
+
+ wireExtraBmAdd();
+ wireSatelliteEvents();
+ }
+
+ function populateTsBookmarkSelect() {
+ const sel = document.getElementById("scheduler-ts-bookmark");
+ const extraSel = document.getElementById("scheduler-ts-extra-bm-pick");
+ [sel, extraSel].forEach(function (el) {
+ if (!el) return;
+ const prev = el.value;
+ el.innerHTML = 'β select bookmark β ';
+ bookmarkList.forEach(function (bm) {
+ const opt = document.createElement("option");
+ opt.value = bm.id;
+ opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
+ el.appendChild(opt);
+ });
+ if (prev) el.value = prev;
+ });
+ }
+
+ // Pending extra bookmark IDs for the entry being composed in the add form.
+ let pendingExtraBmIds = [];
+
+ function renderExtraBmList() {
+ var container = document.getElementById("scheduler-ts-extra-bm-list");
+ if (!container) return;
+ container.innerHTML = "";
+ pendingExtraBmIds.forEach(function (id, idx) {
+ var bm = bookmarkList.find(function (b) { return b.id === id; });
+ var chip = document.createElement("span");
+ chip.className = "sch-extra-bm-chip";
+ var rmBtn = document.createElement("span");
+ rmBtn.className = "sch-extra-bm-chip-rm";
+ rmBtn.textContent = "\u00D7";
+ rmBtn.title = "Remove";
+ rmBtn.addEventListener("click", function () {
+ pendingExtraBmIds.splice(idx, 1);
+ renderExtraBmList();
+ });
+ chip.appendChild(rmBtn);
+ var label = document.createTextNode(" " + (bm ? bm.name : id));
+ chip.appendChild(label);
+ container.appendChild(chip);
+ });
+
+ // Disable already-added bookmarks in dropdown
+ var pick = document.getElementById("scheduler-ts-extra-bm-pick");
+ if (pick) {
+ Array.from(pick.options).forEach(function (opt) {
+ if (opt.value) {
+ opt.disabled = pendingExtraBmIds.includes(opt.value);
+ }
+ });
+ }
+ }
+
+ function wireExtraBmAdd() {
+ const addBtn = document.getElementById("scheduler-ts-extra-bm-add");
+ if (!addBtn || addBtn._wired) return;
+ addBtn._wired = true;
+ addBtn.addEventListener("click", function () {
+ const pick = document.getElementById("scheduler-ts-extra-bm-pick");
+ if (!pick || !pick.value) return;
+ if (!pendingExtraBmIds.includes(pick.value)) {
+ pendingExtraBmIds.push(pick.value);
+ renderExtraBmList();
+ }
+ pick.value = "";
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // Satellite overlay (delegated to sat-scheduler.js)
+ // -------------------------------------------------------------------------
+ function renderSatelliteSection() {
+ if (window.satScheduler) window.satScheduler.renderSection();
+ }
+
+ function renderSatPassStatus() {
+ if (window.satScheduler) window.satScheduler.renderPassStatus();
+ }
+
+ function collectSatelliteConfig() {
+ return window.satScheduler
+ ? window.satScheduler.collectSatelliteConfig()
+ : { enabled: false, pretune_secs: 60, entries: [] };
+ }
+
+ function wireSatelliteEvents() {
+ // Expose bridge for sat-scheduler.js to access shared state.
+ window.schedulerBridge = {
+ getConfig: function () { return currentConfig; },
+ getStatus: function () { return currentSchedulerStatus; },
+ getBookmarks: function () { return bookmarkList; },
+ markDirty: function () { markSchedulerDirty(); },
+ };
+ if (window.satScheduler) window.satScheduler.wireEvents();
+ }
+
+ // -------------------------------------------------------------------------
+ // Keyboard shortcuts for scheduler control
+ // -------------------------------------------------------------------------
+ function isInputFocused() {
+ var el = document.activeElement;
+ if (!el) return false;
+ var tag = el.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
+ }
+
+ document.addEventListener("keydown", function (e) {
+ if (isInputFocused()) return;
+
+ if (e.shiftKey && e.key === "R") {
+ e.preventDefault();
+ // Toggle release to scheduler
+ var releaseBtn = document.getElementById("scheduler-release-btn");
+ if (releaseBtn && !releaseBtn.disabled) releaseBtn.click();
+ } else if (e.shiftKey && e.key === "N") {
+ e.preventDefault();
+ schedulerSelectRelativeEntry(1);
+ } else if (e.shiftKey && e.key === "P") {
+ e.preventDefault();
+ schedulerSelectRelativeEntry(-1);
+ }
+ });
+
+ // -------------------------------------------------------------------------
+ // Public API
+ // -------------------------------------------------------------------------
+ // Persist details open/closed state
+ (function () {
+ var details = document.querySelector(".sch-ts-details");
+ if (!details) return;
+ var key = "sch-details-open";
+ var saved = localStorage.getItem(key);
+ if (saved !== null) details.open = saved === "1";
+ details.addEventListener("toggle", function () {
+ localStorage.setItem(key, details.open ? "1" : "0");
+ });
+ })();
+
+ window.initScheduler = initScheduler;
+ window.destroyScheduler = destroyScheduler;
+ window.wireSchedulerEvents = wireSchedulerEvents;
+ window.setSchedulerRig = setSchedulerRig;
+
+ // Auto-initialize if the app has already booted (lazy-load case).
+ // When loaded eagerly, initSettingsUI() in app.js calls initScheduler();
+ // when loaded lazily (e.g. settings tab click after boot), the app has
+ // already passed that point, so we must self-initialize here.
+ if (typeof authRole !== "undefined" && authRole !== null) {
+ initScheduler(typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null, authRole);
+ wireSchedulerEvents();
+ }
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js
new file mode 100644
index 00000000..d1509172
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js
@@ -0,0 +1,565 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- Virtual Channels Plugin ---
+//
+// Handles the `session` and `channels` SSE events emitted by /events and
+// provides the channel picker UI (SDR-only, shown when filter_controls is set).
+
+let vchanSessionId = null;
+let vchanRigId = null;
+let vchanChannels = [];
+let vchanActiveId = null;
+let schedulerReleaseState = null;
+let schedulerReleasePollTimer = null;
+
+function vchanFmtFreq(hz) {
+ if (!Number.isFinite(hz) || hz <= 0) return "--";
+ if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
+ if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
+ if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
+ return hz + "\u202fHz";
+}
+
+function schedulerReleaseSummaryText(state) {
+ if (!state) return "Scheduler is controlling the rig.";
+ const connected = Number(state.connected_sessions) || 0;
+ const released = Number(state.released_sessions) || 0;
+ if (connected === 0) return "Scheduler can control the rig.";
+ if (state.all_released) {
+ return connected === 1
+ ? "Scheduler is controlling the rig."
+ : `Scheduler is controlling the rig for all ${connected} users.`;
+ }
+ if (!state.current_session_released) {
+ const othersReleased = Math.max(released, 0);
+ return othersReleased > 0
+ ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
+ : "You are holding control. Release it to return control to the scheduler.";
+ }
+ const blocking = Math.max(connected - released, 0);
+ return blocking > 0
+ ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
+ : "Scheduler can control the rig.";
+}
+
+function vchanRenderSchedulerRelease() {
+ const btn = document.getElementById("scheduler-release-btn");
+ const status = document.getElementById("scheduler-release-status");
+ if (!btn || !status) return;
+ const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
+ btn.disabled = !vchanSessionId || currentReleased;
+ btn.classList.toggle("active", !currentReleased);
+ btn.textContent = "Release to Scheduler";
+ status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
+}
+
+async function vchanPollSchedulerRelease() {
+ if (!vchanSessionId) {
+ schedulerReleaseState = null;
+ vchanRenderSchedulerRelease();
+ return;
+ }
+ try {
+ const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ schedulerReleaseState = await resp.json();
+ vchanRenderSchedulerRelease();
+ } catch (e) {
+ console.error("scheduler release status failed", e);
+ }
+}
+
+function vchanStartSchedulerReleasePolling() {
+ if (schedulerReleasePollTimer) {
+ clearInterval(schedulerReleasePollTimer);
+ }
+ schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
+}
+
+async function vchanToggleSchedulerRelease() {
+ if (!vchanSessionId) return;
+ const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
+ try {
+ const resp = await fetch("/scheduler-control", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ schedulerReleaseState = await resp.json();
+ vchanRenderSchedulerRelease();
+ } catch (e) {
+ console.error("scheduler release toggle failed", e);
+ }
+}
+
+async function vchanTakeSchedulerControl() {
+ if (!vchanSessionId) return;
+ if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
+ try {
+ const resp = await fetch("/scheduler-control", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: vchanSessionId, released: false }),
+ });
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ schedulerReleaseState = await resp.json();
+ vchanRenderSchedulerRelease();
+ } catch (e) {
+ console.error("scheduler control takeover failed", e);
+ }
+}
+window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
+
+// Called by app.js when the SSE `session` event arrives.
+function vchanHandleSession(data) {
+ try {
+ const d = JSON.parse(data);
+ vchanSessionId = d.session_id || null;
+ vchanPollSchedulerRelease();
+ } catch (e) {
+ console.warn("vchan: bad session event", e);
+ }
+}
+
+// Called by app.js when the SSE `channels` event arrives.
+function vchanHandleChannels(data) {
+ try {
+ const d = JSON.parse(data);
+ vchanRigId = d.remote || null;
+ vchanChannels = d.channels || [];
+ const ids = new Set(vchanChannels.map(c => c.id));
+ if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
+ // First channels event for this session β auto-subscribe to channel 0
+ // so we join the same tuned channel as other users on this rig.
+ // Use a direct subscribe (no scheduler control takeover) to avoid
+ // side-effects on initial connect.
+ vchanAutoJoinPrimary(vchanChannels[0].id);
+ } else if (vchanActiveId && !ids.has(vchanActiveId)) {
+ // Active channel was evicted β fall back to channel 0 and reconnect audio.
+ vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
+ vchanReconnectAudio();
+ }
+ vchanRender();
+ vchanRenderSchedulerRelease();
+ if (typeof renderRdsOverlays === "function") renderRdsOverlays();
+ } catch (e) {
+ console.warn("vchan: bad channels event", e);
+ }
+}
+
+function vchanRender() {
+ const picker = document.getElementById("vchan-picker");
+ if (!picker) return;
+ picker.innerHTML = "";
+
+ vchanChannels.forEach(ch => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} Β· ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
+ if (ch.id === vchanActiveId) btn.classList.add("active");
+
+ const label = document.createElement("span");
+ label.className = "vchan-label";
+ label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
+ btn.appendChild(label);
+
+ if (!ch.permanent) {
+ const del = document.createElement("span");
+ del.className = "vchan-del";
+ del.textContent = "\u00d7";
+ del.title = "Delete channel";
+ del.addEventListener("click", e => {
+ e.stopPropagation();
+ vchanDelete(ch.id);
+ });
+ btn.appendChild(del);
+ }
+
+ btn.addEventListener("click", () => {
+ if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
+ });
+
+ picker.appendChild(btn);
+ });
+
+ // "+" button β allocate a new channel at the current VFO frequency.
+ const addBtn = document.createElement("button");
+ addBtn.type = "button";
+ addBtn.className = "vchan-add";
+ addBtn.textContent = "+";
+ addBtn.title = "Allocate new virtual channel at current frequency";
+ addBtn.addEventListener("click", vchanAllocate);
+ picker.appendChild(addBtn);
+
+ vchanSyncAccentUI();
+ if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
+ updateDocumentTitle(activeChannelRds());
+ }
+ vchanRenderSchedulerRelease();
+}
+
+async function vchanAllocate() {
+ if (!vchanSessionId || !vchanRigId) return;
+
+ // Use the last known rig frequency and mode as the starting point.
+ const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
+ ? lastFreqHz
+ : 0;
+ const modeEl = document.getElementById("mode");
+ const mode = modeEl ? (modeEl.value || "USB") : "USB";
+
+ try {
+ const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
+ });
+ if (!resp.ok) {
+ const msg = await resp.text().catch(() => String(resp.status));
+ console.warn("vchan: allocate failed β", msg);
+ return;
+ }
+ const ch = await resp.json();
+ vchanActiveId = ch.id;
+ // The SSE `channels` event will trigger vchanRender(); optimistically
+ // mark active so the picker feels responsive even before the event arrives.
+ vchanRender();
+ vchanReconnectAudio();
+ } catch (e) {
+ console.error("vchan: allocate error", e);
+ }
+}
+
+async function vchanDelete(channelId) {
+ if (!vchanRigId) return;
+ try {
+ const resp = await fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
+ { method: "DELETE" }
+ );
+ if (!resp.ok) {
+ console.warn("vchan: delete failed", resp.status);
+ }
+ // Channel list updates via SSE `channels` event.
+ } catch (e) {
+ console.error("vchan: delete error", e);
+ }
+}
+
+// Lightweight auto-join for initial connect: registers the session on
+// channel 0 without taking scheduler control or reconnecting audio
+// (audio isn't started yet at this point).
+async function vchanAutoJoinPrimary(channelId) {
+ if (!vchanSessionId || !vchanRigId) return;
+ try {
+ const resp = await fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: vchanSessionId }),
+ }
+ );
+ if (!resp.ok) {
+ console.warn("vchan: auto-join primary failed", resp.status);
+ return;
+ }
+ vchanActiveId = channelId;
+ vchanRender();
+ } catch (e) {
+ console.error("vchan: auto-join error", e);
+ }
+}
+
+async function vchanSubscribe(channelId) {
+ if (!vchanSessionId || !vchanRigId) return;
+ try {
+ await vchanTakeSchedulerControl();
+ const resp = await fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: vchanSessionId }),
+ }
+ );
+ if (!resp.ok) {
+ console.warn("vchan: subscribe failed", resp.status);
+ return;
+ }
+ vchanActiveId = channelId;
+ vchanRender();
+ vchanSyncModeDisplay();
+ vchanReconnectAudio();
+ } catch (e) {
+ console.error("vchan: subscribe error", e);
+ }
+}
+
+// Reconnect the audio WebSocket to the appropriate endpoint:
+// - virtual channel: /audio?channel_id=
+// - primary channel: /audio (no param)
+// Always updates _audioChannelOverride so that starting audio later
+// connects to the correct channel. Only reconnects if RX audio is active.
+function vchanReconnectAudio() {
+ // Always update the override so startRxAudio picks up the right URL,
+ // even when audio isn't currently running.
+ const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
+ if (typeof _audioChannelOverride !== "undefined") {
+ _audioChannelOverride = ch ? ch.id : null;
+ }
+ if (typeof rxActive === "undefined" || !rxActive) return;
+ if (typeof stopRxAudio === "function") stopRxAudio();
+ // Delay so the server has time to set up the per-channel encoder.
+ // The server-side audio_ws handler also polls for up to 2 s, so this
+ // just needs to be long enough for the WS upgrade to reach the server.
+ setTimeout(() => {
+ if (typeof startRxAudio === "function") startRxAudio();
+ }, 300);
+}
+
+// Called by app.js from applyCapabilities().
+// Shows the channel picker only for SDR rigs.
+function vchanApplyCapabilities(caps) {
+ const picker = document.getElementById("vchan-picker");
+ if (!picker) return;
+ picker.style.display = (caps && caps.filter_controls) ? "" : "none";
+ vchanRenderSchedulerRelease();
+}
+
+// ---------------------------------------------------------------------------
+// Freq / mode interception + UI accent
+// ---------------------------------------------------------------------------
+
+// Returns true when the active channel is a non-primary (virtual) channel.
+function vchanIsOnVirtual() {
+ if (!vchanActiveId || vchanChannels.length === 0) return false;
+ return vchanActiveId !== vchanChannels[0].id;
+}
+
+function vchanActiveChannel() {
+ return vchanChannels.find(c => c.id === vchanActiveId) || null;
+}
+
+// Update the main freq input to show the virtual channel's frequency.
+function vchanUpdateFreqDisplay() {
+ const ch = vchanActiveChannel();
+ if (!ch) return;
+ const el = document.getElementById("freq");
+ if (!el) return;
+ if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
+ el.value = formatFreqForStep(ch.freq_hz, jogUnit);
+ } else {
+ el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
+ }
+}
+
+// Sync the mode picker to the active virtual channel's mode.
+// Called whenever the active channel changes or the channel list is refreshed.
+function vchanSyncModeDisplay() {
+ const modeEl = document.getElementById("mode");
+ if (!modeEl) return;
+ if (vchanIsOnVirtual()) {
+ const ch = vchanActiveChannel();
+ if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
+ }
+ // When on primary channel, app.js rig-state updates handle the picker.
+ const modeUpper = (modeEl.value || "").toUpperCase();
+ if (typeof lastModeName !== "undefined") {
+ if (modeUpper === "WFM" && lastModeName !== "WFM") {
+ if (typeof setJogDivisor === "function") setJogDivisor(10);
+ if (typeof resetRdsDisplay === "function") resetRdsDisplay();
+ } else if (modeUpper !== "WFM" && lastModeName === "WFM") {
+ if (typeof resetRdsDisplay === "function") resetRdsDisplay();
+ }
+ lastModeName = modeUpper;
+ }
+ if (typeof updateWfmControls === "function") updateWfmControls();
+ if (typeof updateSdrSquelchControlVisibility === "function") {
+ updateSdrSquelchControlVisibility();
+ }
+ if (typeof refreshRdsUi === "function") {
+ refreshRdsUi();
+ } else if (typeof positionRdsPsOverlay === "function") {
+ positionRdsPsOverlay();
+ }
+}
+
+// Sync the BW input to the active virtual channel's bandwidth.
+function vchanSyncBwDisplay() {
+ if (!vchanIsOnVirtual()) return;
+ const ch = vchanActiveChannel();
+ if (!ch) return;
+ const bwEl = document.getElementById("spectrum-bw-input");
+ if (!bwEl) return;
+ // bandwidth_hz == 0 means mode-default; derive it from the channel mode.
+ let bwHz = ch.bandwidth_hz || 0;
+ if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
+ bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
+ }
+ if (bwHz > 0) {
+ bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
+ if (typeof currentBandwidthHz !== "undefined") {
+ currentBandwidthHz = bwHz;
+ window.currentBandwidthHz = bwHz;
+ } else {
+ window.currentBandwidthHz = bwHz;
+ }
+ }
+}
+
+// Add / remove the vchan accent class from the freq and BW inputs.
+function vchanSyncAccentUI() {
+ const onVirtual = vchanIsOnVirtual();
+ const freqEl = document.getElementById("freq");
+ const bwEl = document.getElementById("spectrum-bw-input");
+ if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
+ if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
+ if (onVirtual) {
+ vchanUpdateFreqDisplay();
+ vchanSyncModeDisplay();
+ vchanSyncBwDisplay();
+ } else if (typeof _origRefreshFreqDisplay === "function") {
+ _origRefreshFreqDisplay();
+ }
+ if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
+ updateDocumentTitle(activeChannelRds());
+ }
+}
+
+// Saved reference to the original refreshFreqDisplay from app.js.
+let _origRefreshFreqDisplay = null;
+
+function vchanSetChannelFreq(freqHz) {
+ if (!vchanRigId || !vchanActiveId) return;
+ // Validate against current SDR capture window.
+ if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
+ lastSpectrumData.sample_rate > 0) {
+ const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
+ const center = Number(lastSpectrumData.center_hz);
+ if (Math.abs(freqHz - center) > halfSpan) {
+ if (typeof showHint === "function") {
+ showHint(
+ `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz Β±${(halfSpan / 1e3).toFixed(0)} kHz)`,
+ 3000
+ );
+ }
+ return;
+ }
+ }
+ // Fire-and-forget: scheduler control + channel freq PUT run in background.
+ vchanTakeSchedulerControl();
+ fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
+ }
+ ).catch(e => console.error("vchan: set freq error", e));
+}
+
+async function vchanSetChannelBandwidth(bwHz) {
+ if (!vchanRigId || !vchanActiveId) return;
+ try {
+ await vchanTakeSchedulerControl();
+ const resp = await fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }),
+ }
+ );
+ if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
+ } catch (e) {
+ console.error("vchan: set bw error", e);
+ }
+}
+
+async function vchanSetChannelMode(mode) {
+ if (!vchanRigId || !vchanActiveId) return;
+ try {
+ await vchanTakeSchedulerControl();
+ const resp = await fetch(
+ `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ mode }),
+ }
+ );
+ if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
+ } catch (e) {
+ console.error("vchan: set mode error", e);
+ }
+}
+
+// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
+// sending /set_mode to the server. Returns true if the change was handled
+// by the virtual channel (caller should skip the server request).
+window.vchanInterceptMode = async function(mode) {
+ if (!vchanIsOnVirtual()) return false;
+ await vchanSetChannelMode(mode);
+ return true;
+};
+
+// Called by app.js bandwidth setters before sending /set_bandwidth to the
+// server. Returns true if the change was handled by the virtual channel.
+window.vchanInterceptBandwidth = async function(bwHz) {
+ if (!vchanIsOnVirtual()) return false;
+ await vchanSetChannelBandwidth(bwHz);
+ return true;
+};
+
+// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
+// frequency changes are redirected to the active virtual channel instead of
+// the server when on a non-primary channel.
+(function() {
+ const _orig = window.setRigFrequency;
+ window.setRigFrequency = function(freqHz) {
+ if (vchanIsOnVirtual()) {
+ // Optimistic local update first, then fire-and-forget channel API.
+ if (typeof applyLocalTunedFrequency === "function") {
+ if (typeof _freqOptimisticSeq !== "undefined") {
+ ++_freqOptimisticSeq;
+ _freqOptimisticHz = Math.round(freqHz);
+ }
+ applyLocalTunedFrequency(Math.round(freqHz));
+ }
+ vchanSetChannelFreq(freqHz);
+ return;
+ }
+ // Scheduler control is fire-and-forget β don't block the freq change.
+ vchanTakeSchedulerControl();
+ if (typeof _orig === "function") _orig(freqHz);
+ };
+})();
+
+(function initSchedulerReleaseControl() {
+ const btn = document.getElementById("scheduler-release-btn");
+ if (btn) {
+ btn.addEventListener("click", () => {
+ vchanToggleSchedulerRelease();
+ });
+ }
+ vchanStartSchedulerReleasePolling();
+ vchanRenderSchedulerRelease();
+})();
+
+// Wrap refreshFreqDisplay so the main freq field stays in sync with the
+// active virtual channel's frequency (SSE rig-state updates would otherwise
+// constantly overwrite it with channel 0's freq).
+(function() {
+ _origRefreshFreqDisplay = window.refreshFreqDisplay;
+ window.refreshFreqDisplay = function() {
+ if (vchanIsOnVirtual()) {
+ vchanUpdateFreqDisplay();
+ return;
+ }
+ if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
+ };
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.js
new file mode 100644
index 00000000..eac37c1c
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.js
@@ -0,0 +1,352 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- VDES Decoder Plugin (server-side decode) ---
+const vdesStatus = document.getElementById("vdes-status");
+const vdesMessagesEl = document.getElementById("vdes-messages");
+const vdesFilterInput = document.getElementById("vdes-filter");
+const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
+const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
+const vdesFrameCountEl = document.getElementById("vdes-frame-count");
+const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
+const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
+let vdesFilterText = "";
+let vdesMessageHistory = [];
+
+function currentVdesHistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneVdesMessageHistory() {
+ const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
+ vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
+}
+
+function scheduleVdesUi(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+function scheduleVdesHistoryRender() {
+ scheduleVdesUi("vdes-history", () => renderVdesHistory());
+}
+
+function scheduleVdesBarUpdate() {
+ scheduleVdesUi("vdes-bar", () => updateVdesBar());
+}
+
+function currentVdesCenterText() {
+ const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
+ const hz = raw ? Number(raw) : 0;
+ if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
+ return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
+}
+
+function vdesAgeText(tsMs) {
+ if (!Number.isFinite(tsMs)) return "just now";
+ const deltaMs = Math.max(0, Date.now() - tsMs);
+ const seconds = Math.round(deltaMs / 1000);
+ if (seconds < 5) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.round(minutes / 60);
+ return `${hours}h ago`;
+}
+
+function vdesHexPreview(rawBytes) {
+ if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
+ return rawBytes
+ .slice(0, 20)
+ .map((value) => Number(value).toString(16).padStart(2, "0"))
+ .join(" ")
+ .toUpperCase();
+}
+
+function updateVdesSummary() {
+ pruneVdesMessageHistory();
+ if (vdesChannelSummaryEl) {
+ vdesChannelSummaryEl.textContent = currentVdesCenterText();
+ }
+ if (vdesFrameCountEl) {
+ const count = vdesMessageHistory.length;
+ vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
+ }
+ if (vdesLatestSeenEl) {
+ const latest = vdesMessageHistory[0];
+ vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
+ }
+}
+
+function applyVdesFilterToRow(row) {
+ if (!vdesFilterText) {
+ row.style.display = "";
+ return;
+ }
+ const text = row.dataset.filterText || "";
+ row.style.display = text.includes(vdesFilterText) ? "" : "none";
+}
+
+function applyVdesFilterToAll() {
+ if (!vdesMessagesEl) return;
+ vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
+}
+
+function renderVdesRow(msg) {
+ const row = document.createElement("div");
+ row.className = "vdes-message";
+ const ts = msg._ts || new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ const title = msg.vessel_name || "VDES Burst";
+ const label = msg.callsign || "VDES";
+ const info = msg.destination || "";
+ const labelText = msg.message_label || "";
+ const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
+ const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
+ const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
+ const fecText = msg.fec_state || "";
+ const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
+ const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
+ const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
+ const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
+ const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
+ const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
+ const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
+ const previewText = msg.payload_preview || "";
+ const rawHex = vdesHexPreview(msg.raw_bytes);
+ row.dataset.filterText = [
+ title,
+ label,
+ labelText,
+ info,
+ srcText,
+ dstText,
+ sessionText,
+ asmText,
+ countText,
+ ackText,
+ cqiText,
+ previewText,
+ linkText,
+ syncText,
+ phaseText,
+ fecText,
+ rawHex,
+ msg.message_type,
+ msg.bit_len,
+ ]
+ .filter(Boolean)
+ .join(" ")
+ .toUpperCase();
+ row.innerHTML =
+ `` +
+ `${ts} ` +
+ `${escapeMapHtml(title)} ` +
+ `${escapeMapHtml(label)} ` +
+ (labelText ? `${escapeMapHtml(labelText)} ` : "") +
+ (linkText ? `${escapeMapHtml(linkText)} ` : "") +
+ (srcText ? `${escapeMapHtml(srcText)} ` : "") +
+ (dstText ? `${escapeMapHtml(dstText)} ` : "") +
+ (syncText ? `${escapeMapHtml(syncText)} ` : "") +
+ (phaseText ? `${escapeMapHtml(phaseText)} ` : "") +
+ `T${escapeMapHtml(String(msg.message_type ?? "--"))} ` +
+ `
` +
+ `` +
+ `${escapeMapHtml(currentVdesCenterText())} ` +
+ `${escapeMapHtml(`${msg.bit_len || 0} bits`)} ` +
+ (sessionText ? `${escapeMapHtml(sessionText)} ` : "") +
+ (asmText ? `${escapeMapHtml(asmText)} ` : "") +
+ (countText ? `${escapeMapHtml(countText)} ` : "") +
+ (ackText ? `${escapeMapHtml(ackText)} ` : "") +
+ (cqiText ? `${escapeMapHtml(cqiText)} ` : "") +
+ (info ? `${escapeMapHtml(info)} ` : "") +
+ (fecText ? `${escapeMapHtml(fecText)} ` : "") +
+ `${escapeMapHtml(vdesAgeText(msg._tsMs))} ` +
+ `
` +
+ `` +
+ (previewText ? `${escapeMapHtml(previewText)} ` : "") +
+ (previewText ? `Β· ` : "") +
+ `${escapeMapHtml(rawHex)} ` +
+ `
`;
+ applyVdesFilterToRow(row);
+ return row;
+}
+
+function updateVdesBar() {
+ if (!vdesBarOverlay) return;
+ updateVdesSummary();
+ const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
+ const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
+ const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
+ if (!isVdes || messages.length === 0) {
+ vdesBarOverlay.style.display = "none";
+ vdesBarOverlay.innerHTML = "";
+ return;
+ }
+
+ let html = '';
+ for (const msg of messages) {
+ const ts = msg._ts ? `${msg._ts} ` : "";
+ const label = escapeMapHtml(msg.callsign || "VDES");
+ const title = escapeMapHtml(msg.vessel_name || "Burst");
+ const detail = [
+ `${msg.bit_len || 0} bits`,
+ msg.message_label ? escapeMapHtml(msg.message_label) : null,
+ Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
+ Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
+ Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
+ Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
+ Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
+ Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
+ msg.destination ? escapeMapHtml(msg.destination) : null,
+ escapeMapHtml(vdesAgeText(msg._tsMs)),
+ ]
+ .filter(Boolean)
+ .join(" Β· ");
+ html += `${ts}${title} ${label} : ${detail}
`;
+ }
+ vdesBarOverlay.innerHTML = html;
+ vdesBarOverlay.style.display = "flex";
+}
+window.updateVdesBar = updateVdesBar;
+window.clearVdesBar = function() {
+ window.resetVdesHistoryView();
+};
+
+window.resetVdesHistoryView = function() {
+ if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
+ vdesMessageHistory = [];
+ updateVdesBar();
+ renderVdesHistory();
+};
+
+function renderVdesHistory() {
+ pruneVdesMessageHistory();
+ if (!vdesMessagesEl) {
+ updateVdesSummary();
+ return;
+ }
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < vdesMessageHistory.length; i += 1) {
+ fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
+ }
+ vdesMessagesEl.replaceChildren(fragment);
+ updateVdesSummary();
+}
+
+function addVdesMessage(msg) {
+ const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ msg._tsMs = tsMs;
+ msg._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+
+ vdesMessageHistory.unshift(msg);
+ pruneVdesMessageHistory();
+ scheduleVdesBarUpdate();
+ scheduleVdesHistoryRender();
+}
+
+function normalizeServerVdesMessage(msg) {
+ return {
+ rig_id: msg.rig_id || null,
+ message_type: msg.message_type,
+ bit_len: msg.bit_len,
+ raw_bytes: msg.raw_bytes,
+ lat: msg.lat,
+ lon: msg.lon,
+ vessel_name: msg.vessel_name,
+ callsign: msg.callsign,
+ destination: msg.destination,
+ message_label: msg.message_label,
+ session_id: msg.session_id,
+ source_id: msg.source_id,
+ destination_id: msg.destination_id,
+ data_count: msg.data_count,
+ asm_identifier: msg.asm_identifier,
+ ack_nack_mask: msg.ack_nack_mask,
+ channel_quality: msg.channel_quality,
+ payload_preview: msg.payload_preview,
+ link_id: msg.link_id,
+ sync_score: msg.sync_score,
+ sync_errors: msg.sync_errors,
+ phase_rotation: msg.phase_rotation,
+ fec_state: msg.fec_state,
+ ts_ms: msg.ts_ms,
+ };
+}
+
+window.onServerVdesBatch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ if (vdesStatus) vdesStatus.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerVdesMessage(msg);
+ const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
+ next._tsMs = tsMs;
+ next._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
+ window.vdesMapAddPoint(next);
+ }
+ normalized.push(next);
+ }
+ normalized.reverse();
+ vdesMessageHistory = normalized.concat(vdesMessageHistory);
+ pruneVdesMessageHistory();
+ scheduleVdesBarUpdate();
+ scheduleVdesHistoryRender();
+};
+
+window.restoreVdesHistory = function(messages) {
+ window.onServerVdesBatch(messages);
+};
+
+document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_vdes_decode");
+ window.resetVdesHistoryView();
+ } catch (e) {
+ console.error("VDES history clear failed", e);
+ }
+});
+
+if (vdesFilterInput) {
+ vdesFilterInput.addEventListener("input", () => {
+ vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
+ renderVdesHistory();
+ });
+}
+
+window.onServerVdes = function(msg) {
+ if (vdesStatus) vdesStatus.textContent = "Receiving";
+ const next = normalizeServerVdesMessage(msg);
+ addVdesMessage(next);
+ if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
+ window.vdesMapAddPoint(next);
+ }
+};
+
+window.pruneVdesHistoryView = function() {
+ pruneVdesMessageHistory();
+ updateVdesBar();
+ renderVdesHistory();
+};
+
+updateVdesSummary();
+if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.js
new file mode 100644
index 00000000..d216a380
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.js
@@ -0,0 +1,386 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// ---------------------------------------------------------------------------
+// wefax.js β WEFAX decoder plugin for trx-frontend-http
+// Live view: decoder state, live canvas, latest image card
+// History view: filterable table of all decoded images
+// ---------------------------------------------------------------------------
+
+// ββ DOM references (cached once) βββββββββββββββββββββββββββββββββββ
+var wefaxDom = {
+ status: document.getElementById('wefax-status'),
+ liveView: document.getElementById('wefax-live-view'),
+ historyView: document.getElementById('wefax-history-view'),
+ liveContainer: document.getElementById('wefax-live-container'),
+ liveInfo: document.getElementById('wefax-live-info'),
+ liveCanvas: document.getElementById('wefax-live-canvas'),
+ liveLatest: document.getElementById('wefax-live-latest'),
+ historyList: document.getElementById('wefax-history-list'),
+ historyCount: document.getElementById('wefax-history-count'),
+ filterInput: document.getElementById('wefax-filter'),
+ sortSelect: document.getElementById('wefax-sort'),
+ toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
+ clearBtn: document.getElementById('wefax-clear-btn'),
+ viewLiveBtn: document.getElementById('wefax-view-live'),
+ viewHistoryBtn: document.getElementById('wefax-view-history'),
+};
+
+// ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+var wefaxImageHistory = [];
+var WEFAX_MAX_IMAGES = 100;
+var wefaxLiveCtx = null;
+var wefaxLiveLineCount = 0;
+var wefaxLivePixelsPerLine = 1809;
+var wefaxActiveView = 'live';
+var wefaxFilterText = '';
+
+// ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+function currentWefaxHistoryRetentionMs() {
+ return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
+}
+
+function pruneWefaxHistory() {
+ var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
+ wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
+}
+
+function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+}
+
+function scheduleWefaxUi(key, job) {
+ if (typeof window.trxScheduleUiFrameJob === 'function') {
+ window.trxScheduleUiFrameJob(key, job);
+ return;
+ }
+ job();
+}
+
+// ββ View switching ββββββββββββββββββββββββββββββββββββββββββββββββββ
+function switchWefaxView(view) {
+ wefaxActiveView = view;
+ if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
+ if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
+
+ [wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function (btn) {
+ if (btn) btn.classList.remove('sat-view-active');
+ });
+ if (view === 'live' && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add('sat-view-active');
+ if (view === 'history' && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add('sat-view-active');
+
+ if (view === 'history') renderWefaxHistoryTable();
+}
+
+if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', function () { switchWefaxView('live'); });
+if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
+
+// ββ Live canvas rendering βββββββββββββββββββββββββββββββββββββββββββ
+function resetLiveCanvas(pixelsPerLine) {
+ wefaxLivePixelsPerLine = pixelsPerLine;
+ wefaxLiveLineCount = 0;
+ wefaxDom.liveCanvas.width = pixelsPerLine;
+ wefaxDom.liveCanvas.height = 800;
+ wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
+ wefaxLiveCtx.fillStyle = '#000';
+ wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
+ if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
+}
+
+function paintLine(lineBytes) {
+ if (!wefaxLiveCtx) return;
+ var y = wefaxLiveLineCount;
+
+ if (y >= wefaxDom.liveCanvas.height) {
+ var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
+ wefaxDom.liveCanvas.height *= 2;
+ wefaxLiveCtx.putImageData(old, 0, 0);
+ }
+
+ var w = wefaxLivePixelsPerLine;
+ var imgData = wefaxLiveCtx.createImageData(w, 1);
+ var d = imgData.data;
+ for (var x = 0; x < w; x++) {
+ var v = x < lineBytes.length ? lineBytes[x] : 0;
+ var i = x * 4;
+ d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
+ }
+ wefaxLiveCtx.putImageData(imgData, 0, y);
+ wefaxLiveLineCount++;
+}
+
+// ββ Live view: latest image card ββββββββββββββββββββββββββββββββββββ
+function renderWefaxLatestCard() {
+ if (!wefaxDom.liveLatest) return;
+ if (wefaxImageHistory.length === 0) {
+ wefaxDom.liveLatest.innerHTML =
+ 'No images decoded yet. Enable the decoder and tune to a WEFAX station.
';
+ return;
+ }
+
+ var img = wefaxImageHistory[0];
+ var ts = img._ts || '--';
+ var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
+ var meta = [
+ img.ioc + ' IOC',
+ img.lpm + ' LPM',
+ img.line_count + ' lines',
+ date + ' ' + ts,
+ ].join(' \u00b7 ');
+
+ var imgSrc = img._dataUrl
+ ? img._dataUrl
+ : img.path
+ ? '/images/' + escapeHtml(img.path.split('/').pop())
+ : null;
+
+ var html = '';
+ html += '
Latest decoded image
';
+ html += '
' + escapeHtml(meta) + '
';
+ if (imgSrc) {
+ html += '
View full image ';
+ }
+ html += '
';
+ wefaxDom.liveLatest.innerHTML = html;
+}
+
+// ββ History view: table βββββββββββββββββββββββββββββββββββββββββββββ
+function getWefaxFilteredHistory() {
+ var items = wefaxImageHistory;
+
+ if (wefaxFilterText) {
+ items = items.filter(function (i) {
+ var haystack = [
+ String(i.ioc || ''),
+ String(i.lpm || ''),
+ String(i.line_count || ''),
+ ].join(' ').toUpperCase();
+ return haystack.indexOf(wefaxFilterText) >= 0;
+ });
+ }
+
+ var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
+ if (sortVal === 'oldest') items = items.slice().reverse();
+
+ return items;
+}
+
+function renderWefaxHistoryRow(img) {
+ var row = document.createElement('div');
+ row.className = 'sat-history-row';
+
+ var ts = img._ts || '--';
+ var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
+ var ioc = img.ioc || '--';
+ var lpm = img.lpm || '--';
+ var lines = img.line_count || 0;
+
+ var imgSrc = img._dataUrl
+ ? img._dataUrl
+ : img.path
+ ? '/images/' + escapeHtml(img.path.split('/').pop())
+ : null;
+ var link = imgSrc
+ ? 'View '
+ : '--';
+
+ row.innerHTML = [
+ '' + escapeHtml(date + ' ' + ts) + ' ',
+ '' + escapeHtml(String(ioc)) + ' ',
+ '' + escapeHtml(String(lpm)) + ' ',
+ '' + lines + ' ',
+ '' + link + ' ',
+ ].join('');
+
+ return row;
+}
+
+function renderWefaxHistoryTable() {
+ if (!wefaxDom.historyList) return;
+ pruneWefaxHistory();
+ var items = getWefaxFilteredHistory();
+ var fragment = document.createDocumentFragment();
+ for (var i = 0; i < items.length; i++) {
+ fragment.appendChild(renderWefaxHistoryRow(items[i]));
+ }
+ wefaxDom.historyList.replaceChildren(fragment);
+
+ if (wefaxDom.historyCount) {
+ var total = wefaxImageHistory.length;
+ var shown = items.length;
+ wefaxDom.historyCount.textContent =
+ total === 0
+ ? 'No images yet'
+ : shown === total
+ ? total + ' image' + (total === 1 ? '' : 's')
+ : shown + ' of ' + total + ' images';
+ }
+}
+
+// ββ Add image to history ββββββββββββββββββββββββββββββββββββββββββββ
+function addWefaxImage(msg) {
+ var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ msg._tsMs = tsMs;
+ msg._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+
+ // Capture the live canvas as a data URI for thumbnails.
+ if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
+ var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
+ wefaxDom.liveCanvas.height = wefaxLiveLineCount;
+ wefaxLiveCtx.putImageData(trimmed, 0, 0);
+ try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
+ }
+
+ wefaxImageHistory.unshift(msg);
+ if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
+ wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
+ }
+
+ scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
+ if (wefaxActiveView === 'history') {
+ scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
+ }
+}
+
+// ββ SSE event handlers (public API) βββββββββββββββββββββββββββββββββ
+window.onServerWefaxProgress = function (msg) {
+ // State-only update (no image data): show decoder state in status.
+ if (msg.state && !msg.line_data) {
+ if (wefaxDom.status) {
+ wefaxDom.status.textContent = msg.state;
+ // Highlight active states, dim idle/scanning.
+ wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
+ }
+ return;
+ }
+
+ if (msg.line_count <= 1 || !wefaxLiveCtx) {
+ resetLiveCanvas(msg.pixels_per_line || 1809);
+ }
+
+ if (msg.line_data) {
+ var binary = atob(msg.line_data);
+ var bytes = new Uint8Array(binary.length);
+ for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ paintLine(bytes);
+ }
+
+ if (wefaxDom.liveInfo) {
+ wefaxDom.liveInfo.textContent =
+ 'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
+ }
+ if (wefaxDom.status) {
+ wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
+ wefaxDom.status.style.color = 'var(--text-accent)';
+ }
+};
+
+window.onServerWefax = function (msg) {
+ addWefaxImage(msg);
+
+ if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
+ if (wefaxDom.status) {
+ wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
+ wefaxDom.status.style.color = '';
+ }
+};
+
+window.restoreWefaxHistory = function (messages) {
+ if (!messages || !messages.length) return;
+ for (var i = 0; i < messages.length; i++) {
+ var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
+ messages[i]._tsMs = tsMs;
+ messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+ }
+ wefaxImageHistory = messages.concat(wefaxImageHistory);
+ pruneWefaxHistory();
+ scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
+ if (wefaxActiveView === 'history') {
+ scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
+ }
+};
+
+window.pruneWefaxHistoryView = function () {
+ pruneWefaxHistory();
+ renderWefaxHistoryTable();
+ renderWefaxLatestCard();
+};
+
+window.resetWefaxHistoryView = function () {
+ wefaxImageHistory = [];
+ if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
+ if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
+ wefaxLiveCtx = null;
+ wefaxLiveLineCount = 0;
+ renderWefaxLatestCard();
+ renderWefaxHistoryTable();
+ if (wefaxDom.status) {
+ wefaxDom.status.textContent = 'Idle';
+ wefaxDom.status.style.color = '';
+ }
+};
+
+// ββ Filter / sort handlers ββββββββββββββββββββββββββββββββββββββββββ
+if (wefaxDom.filterInput) {
+ wefaxDom.filterInput.addEventListener('input', function () {
+ wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
+ scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
+ });
+}
+if (wefaxDom.sortSelect) {
+ wefaxDom.sortSelect.addEventListener('change', function () {
+ scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
+ });
+}
+
+// ββ Toggle button sync ββββββββββββββββββββββββββββββββββββββββββββββ
+// Sync the Enable/Disable button from the SSE state update. This is
+// belt-and-suspenders alongside app.js _decoderToggles β guarantees the
+// WEFAX button always reflects the server state.
+window.syncWefaxToggle = function (enabled) {
+ if (!wefaxDom.toggleBtn) return;
+ wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
+ wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
+ wefaxDom.toggleBtn.style.borderColor = enabled ? '#00d17f' : '';
+ wefaxDom.toggleBtn.style.color = enabled ? '#00d17f' : '';
+};
+
+// ββ Button handlers βββββββββββββββββββββββββββββββββββββββββββββββββ
+if (wefaxDom.toggleBtn) {
+ wefaxDom.toggleBtn.addEventListener('click', async function () {
+ try {
+ if (window.takeSchedulerControlForDecoderDisable) {
+ await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
+ }
+ await postPath('/toggle_wefax_decode');
+ } catch (e) {
+ console.error('WEFAX toggle failed', e);
+ }
+ });
+}
+if (wefaxDom.clearBtn) {
+ wefaxDom.clearBtn.addEventListener('click', async function () {
+ try {
+ await postPath('/clear_wefax_decode');
+ window.resetWefaxHistoryView();
+ } catch (e) {
+ console.error('WEFAX clear failed', e);
+ }
+ });
+}
+
+// ββ Initial render ββββββββββββββββββββββββββββββββββββββββββββββββββ
+renderWefaxLatestCard();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js
new file mode 100644
index 00000000..a5392325
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js
@@ -0,0 +1,292 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// --- WSPR Decoder Plugin (server-side decode) ---
+const wsprStatus = document.getElementById("wspr-status");
+const wsprPeriodEl = document.getElementById("wspr-period");
+const wsprMessagesEl = document.getElementById("wspr-messages");
+const wsprFilterInput = document.getElementById("wspr-filter");
+const WSPR_PERIOD_SECONDS = 120;
+let wsprFilterText = "";
+let wsprMessageHistory = [];
+
+function currentWsprHistoryRetentionMs() {
+ return typeof window.getDecodeHistoryRetentionMs === "function"
+ ? window.getDecodeHistoryRetentionMs()
+ : 24 * 60 * 60 * 1000;
+}
+
+function pruneWsprMessageHistory() {
+ const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
+ wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
+}
+
+function scheduleWsprHistoryRender() {
+ if (typeof window.trxScheduleUiFrameJob === "function") {
+ window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
+ return;
+ }
+ renderWsprHistory();
+}
+
+function fmtWsprTime(tsMs) {
+ if (!tsMs) return "--:--:--";
+ return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+}
+
+function updateWsprPeriodTimer() {
+ if (!wsprPeriodEl) return;
+ const nowSec = Math.floor(Date.now() / 1000);
+ const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
+ const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
+ const ss = String(remaining % 60).padStart(2, "0");
+ wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
+}
+
+updateWsprPeriodTimer();
+setInterval(updateWsprPeriodTimer, 500);
+
+function renderWsprRow(msg) {
+ const row = document.createElement("div");
+ row.className = "ft8-row";
+ row.dataset.decoder = "wspr";
+ const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
+ const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
+ const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
+ const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
+ const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
+ const message = (msg.message || "").toString();
+ row.dataset.message = message.toUpperCase();
+ row.innerHTML = `${fmtWsprTime(msg.ts_ms)} ${snr} ${dt} ${freq} ${renderWsprMessage(message)} `;
+ applyWsprFilterToRow(row);
+ return row;
+}
+
+function renderWsprHistory() {
+ pruneWsprMessageHistory();
+ if (!wsprMessagesEl) return;
+ const fragment = document.createDocumentFragment();
+ for (let i = 0; i < wsprMessageHistory.length; i += 1) {
+ fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
+ }
+ wsprMessagesEl.replaceChildren(fragment);
+}
+
+function addWsprMessage(msg) {
+ msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
+ wsprMessageHistory.unshift(msg);
+ pruneWsprMessageHistory();
+ scheduleWsprHistoryRender();
+}
+
+function normalizeServerWsprMessage(msg) {
+ const raw = (msg.message || "").toString();
+ const grids = extractAllGrids(raw);
+ const station = extractLikelyCallsign(raw);
+ const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
+ const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
+ ? (baseHz + Number(msg.freq_hz))
+ : (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
+ return {
+ raw,
+ grids,
+ station,
+ rfHz,
+ history: {
+ receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
+ ts_ms: msg.ts_ms,
+ snr_db: msg.snr_db,
+ dt_s: msg.dt_s,
+ freq_hz: msg.freq_hz,
+ message: raw,
+ },
+ };
+}
+
+window.onServerWsprBatch = function(messages) {
+ if (!Array.isArray(messages) || messages.length === 0) return;
+ wsprStatus.textContent = "Receiving";
+ const normalized = [];
+ for (const msg of messages) {
+ const next = normalizeServerWsprMessage(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
+ ...msg,
+ freq_hz: next.rfHz,
+ });
+ }
+ next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
+ normalized.push(next.history);
+ }
+ normalized.reverse();
+ wsprMessageHistory = normalized.concat(wsprMessageHistory);
+ pruneWsprMessageHistory();
+ scheduleWsprHistoryRender();
+};
+
+window.restoreWsprHistory = function(messages) {
+ window.onServerWsprBatch(messages);
+};
+
+window.pruneWsprHistoryView = function() {
+ pruneWsprMessageHistory();
+ renderWsprHistory();
+};
+
+function escapeWsprHtml(input) {
+ return input
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll("\"", """);
+}
+
+function renderWsprMessage(message) {
+ let out = "";
+ let i = 0;
+ while (i < message.length) {
+ const ch = message[i];
+ if (isAlphaNum(ch)) {
+ let j = i + 1;
+ while (j < message.length && isAlphaNum(message[j])) j++;
+ const token = message.slice(i, j);
+ const grid = token.toUpperCase();
+ if (isMaidenheadGridToken(grid)) {
+ out += `${grid} `;
+ } else {
+ out += escapeWsprHtml(token);
+ }
+ i = j;
+ } else {
+ out += escapeWsprHtml(ch);
+ i += 1;
+ }
+ }
+ return out;
+}
+
+function extractAllGrids(message) {
+ const out = [];
+ const seen = new Set();
+ const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
+ for (const token of parts) {
+ if (!token) continue;
+ if (isMaidenheadGridToken(token) && !seen.has(token)) {
+ seen.add(token);
+ out.push(token);
+ }
+ }
+ return out;
+}
+
+function extractLikelyCallsign(message) {
+ const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
+ for (const token of parts) {
+ if (!token) continue;
+ if (token.length < 3 || token.length > 12) continue;
+ if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
+ if (isMaidenheadGridToken(token)) continue;
+ if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
+ }
+ return null;
+}
+
+function isFtxFarewellToken(token) {
+ const normalized = String(token || "").trim().toUpperCase();
+ return normalized === "RR73" || normalized === "73" || normalized === "RR";
+}
+
+function isMaidenheadGridToken(token) {
+ const normalized = String(token || "").trim().toUpperCase();
+ return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
+}
+
+function isAlphaNum(ch) {
+ return /[A-Za-z0-9]/.test(ch);
+}
+
+function activateWsprHistoryLocator(targetEl) {
+ const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
+ if (!locatorEl) return false;
+ const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
+ if (!grid) return false;
+ if (typeof window.navigateToMapLocator === "function") {
+ window.navigateToMapLocator(grid, "wspr");
+ }
+ return true;
+}
+
+function applyWsprFilterToRow(row) {
+ if (!wsprFilterText) {
+ row.style.display = "";
+ return;
+ }
+ const message = row.dataset.message || "";
+ row.style.display = message.includes(wsprFilterText) ? "" : "none";
+}
+
+function applyWsprFilterToAll() {
+ const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
+ rows.forEach((row) => applyWsprFilterToRow(row));
+}
+
+window.resetWsprHistoryView = function() {
+ wsprMessagesEl.innerHTML = "";
+ wsprMessageHistory = [];
+ renderWsprHistory();
+ if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
+};
+
+if (wsprFilterInput) {
+ wsprFilterInput.addEventListener("input", () => {
+ wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
+ renderWsprHistory();
+ });
+}
+
+if (wsprMessagesEl) {
+ wsprMessagesEl.addEventListener("click", (event) => {
+ if (!activateWsprHistoryLocator(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ });
+ wsprMessagesEl.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ if (!activateWsprHistoryLocator(event.target)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ });
+}
+
+const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
+wsprDecodeToggleBtn?.addEventListener("click", async () => {
+ try {
+ await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
+ await postPath("/toggle_wspr_decode");
+ } catch (e) {
+ console.error("WSPR toggle failed", e);
+ }
+});
+
+document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
+ if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
+ try {
+ await postPath("/clear_wspr_decode");
+ window.resetWsprHistoryView();
+ } catch (e) {
+ console.error("WSPR history clear failed", e);
+ }
+});
+
+window.onServerWspr = function(msg) {
+ wsprStatus.textContent = "Receiving";
+ const next = normalizeServerWsprMessage(msg);
+ if (next.grids.length > 0 && window.mapAddLocator) {
+ window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
+ ...msg,
+ freq_hz: next.rfHz,
+ });
+ }
+ addWsprMessage(next.history);
+};
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.js
new file mode 100644
index 00000000..7f57cc5a
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.js
@@ -0,0 +1,265 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+// Spectrum screenshot module (loaded on demand when user triggers screenshot).
+// Communicates with app.js core via window.trx namespace.
+(function () {
+ "use strict";
+ const T = window.trx;
+
+ function isVisibleForSnapshot(el) {
+ if (!el) return false;
+ const style = getComputedStyle(el);
+ if (style.display === "none" || style.visibility === "hidden") return false;
+ const opacity = Number(style.opacity);
+ if (Number.isFinite(opacity) && opacity <= 0) return false;
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ }
+
+ function drawRoundedRectPath(ctx, x, y, w, h, r) {
+ const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
+ ctx.beginPath();
+ ctx.moveTo(x + radius, y);
+ ctx.lineTo(x + w - radius, y);
+ ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
+ ctx.lineTo(x + w, y + h - radius);
+ ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
+ ctx.lineTo(x + radius, y + h);
+ ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
+ ctx.lineTo(x, y + radius);
+ ctx.quadraticCurveTo(x, y, x + radius, y);
+ ctx.closePath();
+ }
+
+ function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
+ if (!isVisibleForSnapshot(el)) return null;
+ const rect = el.getBoundingClientRect();
+ const style = getComputedStyle(el);
+ const x = rect.left - rootRect.left;
+ const y = rect.top - rootRect.top;
+ const w = rect.width;
+ const h = rect.height;
+ const radius = parseFloat(style.borderTopLeftRadius) || 0;
+ const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
+ const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
+ const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
+
+ const bgAlpha = Math.min(bg[3], maxAlpha);
+ if (bgAlpha > 0.01) {
+ drawRoundedRectPath(ctx, x, y, w, h, radius);
+ ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
+ ctx.fill();
+ }
+ const borderAlpha = Math.min(border[3], maxAlpha);
+ if (borderWidth > 0 && borderAlpha > 0.01) {
+ drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
+ ctx.lineWidth = borderWidth;
+ ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
+ ctx.stroke();
+ }
+ return { x, y, w, h, style };
+ }
+
+ function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
+ const words = String(text || "").split(/\s+/).filter(Boolean);
+ if (!words.length) return;
+ let line = "";
+ let lineIdx = 0;
+ for (let i = 0; i < words.length; i += 1) {
+ const candidate = line ? `${line} ${words[i]}` : words[i];
+ if (ctx.measureText(candidate).width <= maxWidth || !line) {
+ line = candidate;
+ continue;
+ }
+ ctx.fillText(line, x, y + lineIdx * lineHeight);
+ lineIdx += 1;
+ if (lineIdx >= maxLines) return;
+ line = words[i];
+ }
+ if (line && lineIdx < maxLines) {
+ ctx.fillText(line, x, y + lineIdx * lineHeight);
+ }
+ }
+
+ function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
+ const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
+ if (!chrome) return;
+ const text = (fallbackText == null ? el.innerText : fallbackText) || "";
+ const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
+ if (!clean) return;
+ const style = chrome.style;
+ const fontSize = parseFloat(style.fontSize) || 12;
+ const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
+ const padX = 6;
+ const padY = 4;
+ const maxWidth = Math.max(20, chrome.w - padX * 2);
+ const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
+ ctx.fillStyle = style.color || "#ffffff";
+ ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
+ ctx.textBaseline = "top";
+ const lines = clean.split(/\n+/);
+ let lineCursor = 0;
+ for (const line of lines) {
+ if (lineCursor >= maxLines) break;
+ drawWrappedText(
+ ctx,
+ line,
+ chrome.x + padX,
+ chrome.y + padY + lineCursor * lineHeight,
+ maxWidth,
+ lineHeight,
+ maxLines - lineCursor,
+ );
+ lineCursor += 1;
+ }
+ }
+
+ function drawAxisLabels(ctx, axisEl, rootRect) {
+ if (!isVisibleForSnapshot(axisEl)) return;
+ for (const node of axisEl.children) {
+ if (!(node instanceof HTMLElement)) continue;
+ if (!(node.matches("span") || node.matches("button"))) continue;
+ if (!isVisibleForSnapshot(node)) continue;
+ const chrome = drawElementChrome(ctx, node, rootRect);
+ const text = (node.textContent || "").trim();
+ if (!chrome || !text) continue;
+ const style = chrome.style;
+ ctx.fillStyle = style.color || "#ffffff";
+ ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
+ ctx.textBaseline = "middle";
+ ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
+ }
+ }
+
+ function buildSpectrumSnapshotCanvas() {
+ const rootEl = document.querySelector(".signal-visual-block");
+ const spectrumPanelEl = document.getElementById("spectrum-panel");
+ if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
+ return null;
+ }
+ for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
+ const gl = renderer?.gl;
+ if (!gl) continue;
+ try {
+ if (typeof gl.flush === "function") gl.flush();
+ if (typeof gl.finish === "function") gl.finish();
+ } catch (_) {
+ // Ignore transient WebGL state errors and capture the last good frame.
+ }
+ }
+ const rootRect = rootEl.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ const out = document.createElement("canvas");
+ out.width = Math.max(1, Math.round(rootRect.width * dpr));
+ out.height = Math.max(1, Math.round(rootRect.height * dpr));
+ const ctx = out.getContext("2d");
+ if (!ctx) return null;
+ ctx.scale(dpr, dpr);
+
+ const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, rootRect.width, rootRect.height);
+
+ const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
+ const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
+ for (const canvas of canvases) {
+ if (!canvas || !isVisibleForSnapshot(canvas)) continue;
+ const rect = canvas.getBoundingClientRect();
+ ctx.drawImage(
+ canvas,
+ rect.left - rootRect.left,
+ rect.top - rootRect.top,
+ rect.width,
+ rect.height,
+ );
+ }
+
+ // Decoder overlays over the signal view.
+ // Cap background alpha to avoid opaque blocks (backdrop-filter can't be
+ // replicated on canvas, so frosted-glass overlays would otherwise obscure
+ // the spectrum).
+ const decoderOverlayIds = [
+ "ais-bar-overlay",
+ "vdes-bar-overlay",
+ "ft8-bar-overlay",
+ "aprs-bar-overlay",
+ "rds-ps-overlay",
+ ];
+ for (const id of decoderOverlayIds) {
+ const overlayEl = document.getElementById(id);
+ if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
+ drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
+ }
+
+ // Spectrum axis labels and bookmark chips (includes freq bar).
+ const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
+ const spectrumDbAxis = document.getElementById("spectrum-db-axis");
+ drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
+ drawAxisLabels(ctx, spectrumDbAxis, rootRect);
+ drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
+ drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
+ drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
+
+ return out;
+ }
+
+ function clickCanvasDownload(href, fileName) {
+ const a = document.createElement("a");
+ a.href = href;
+ a.download = fileName;
+ a.rel = "noopener";
+ a.style.display = "none";
+ document.body.appendChild(a);
+ a.click();
+ requestAnimationFrame(() => a.remove());
+ }
+
+ function saveCanvasAsPng(canvas, fileName) {
+ if (!canvas) return Promise.resolve(false);
+ if (typeof canvas.toBlob === "function") {
+ return new Promise((resolve) => {
+ try {
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ resolve(false);
+ return;
+ }
+ const url = URL.createObjectURL(blob);
+ clickCanvasDownload(url, fileName);
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ resolve(true);
+ }, "image/png");
+ } catch (_) {
+ resolve(false);
+ }
+ });
+ }
+ try {
+ clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
+ return Promise.resolve(true);
+ } catch (_) {
+ return Promise.resolve(false);
+ }
+ }
+
+ async function captureSpectrumScreenshot() {
+ const snapshotCanvas = buildSpectrumSnapshotCanvas();
+ if (!snapshotCanvas) {
+ T.showHint("Spectrum view not ready", 1300);
+ return false;
+ }
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
+ T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
+ return saved;
+ }
+
+ // Register module API
+ window.trx.modules.screenshot = {
+ captureSpectrumScreenshot,
+ buildSpectrumSnapshotCanvas,
+ saveCanvasAsPng,
+ };
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js
new file mode 100644
index 00000000..9c01455b
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js
@@ -0,0 +1,355 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+"use strict";
+
+// Shared UI primitives. Keeping these outside app.js prevents navigation,
+// feedback, dialogs, and layout preferences from growing separate state models.
+(function initUiCore() {
+ const api = window.trxUi = window.trxUi || {};
+
+ function ensureLiveRegions() {
+ if (!document.getElementById("toast-region")) {
+ const region = document.createElement("div");
+ region.id = "toast-region";
+ region.className = "toast-region";
+ region.setAttribute("aria-live", "polite");
+ region.setAttribute("aria-atomic", "false");
+ document.body.appendChild(region);
+ }
+ if (!document.getElementById("ui-confirm-dialog")) {
+ const dialog = document.createElement("dialog");
+ dialog.id = "ui-confirm-dialog";
+ dialog.className = "ui-dialog";
+ dialog.innerHTML = `
+ `;
+ document.body.appendChild(dialog);
+ }
+ }
+
+ api.notify = function notify(message, options = {}) {
+ ensureLiveRegions();
+ const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
+ const toast = document.createElement("div");
+ toast.className = `toast toast-${kind}`;
+ toast.setAttribute("role", kind === "error" ? "alert" : "status");
+ const text = document.createElement("span");
+ text.textContent = message;
+ toast.appendChild(text);
+ if (action && typeof action.run === "function") {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.textContent = action.label || "Retry";
+ button.addEventListener("click", () => { action.run(); toast.remove(); });
+ toast.appendChild(button);
+ }
+ document.getElementById("toast-region").appendChild(toast);
+ requestAnimationFrame(() => toast.classList.add("toast-visible"));
+ if (duration > 0) setTimeout(() => toast.remove(), duration);
+ return toast;
+ };
+
+ api.confirm = function confirmAction(options = {}) {
+ ensureLiveRegions();
+ const dialog = document.getElementById("ui-confirm-dialog");
+ document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
+ document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
+ const confirmButton = dialog.querySelector('[value="confirm"]');
+ confirmButton.textContent = options.confirmLabel || "Confirm";
+ confirmButton.classList.toggle("danger", options.danger !== false);
+ return new Promise((resolve) => {
+ const finish = () => resolve(dialog.returnValue === "confirm");
+ dialog.addEventListener("close", finish, { once: true });
+ dialog.showModal();
+ });
+ };
+
+ api.setButtonState = function setButtonState(button, options = {}) {
+ if (!button) return;
+ const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
+ button.classList.toggle("is-active", active);
+ button.classList.toggle("is-busy", busy);
+ button.setAttribute("aria-pressed", String(active));
+ button.setAttribute("aria-busy", String(busy));
+ button.disabled = disabled || busy;
+ const label = active ? activeLabel : inactiveLabel;
+ if (label) button.textContent = label;
+ };
+
+ api.prepareTabList = function prepareTabList(bar, kind = "primary") {
+ if (!bar) return;
+ if (bar._accessibleTabsPrepared) return;
+ bar._accessibleTabsPrepared = true;
+ const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
+ const buttons = Array.from(bar.querySelectorAll(selector));
+ bar.setAttribute("role", "tablist");
+ buttons.forEach((button, index) => {
+ button.setAttribute("role", "tab");
+ button.setAttribute("aria-selected", String(button.classList.contains("active")));
+ button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
+ const key = button.dataset.tab || button.dataset.subtab;
+ button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
+ const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
+ if (panel) {
+ if (!button.id) button.id = `${kind}-tab-${key}`;
+ panel.setAttribute("role", "tabpanel");
+ panel.setAttribute("aria-labelledby", button.id);
+ }
+ });
+ bar.addEventListener("keydown", (event) => {
+ if (!buttons.includes(event.target)) return;
+ const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
+ : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
+ if (!direction) return;
+ event.preventDefault();
+ const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
+ next.focus();
+ next.click();
+ });
+ };
+
+ api.syncSelectedTab = function syncSelectedTab(bar, selected) {
+ if (!bar) return;
+ bar.querySelectorAll('[role="tab"]').forEach((tab) => {
+ const active = tab === selected;
+ tab.setAttribute("aria-selected", String(active));
+ tab.tabIndex = active ? 0 : -1;
+ });
+ };
+
+ const layouts = {
+ compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
+ broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
+ digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
+ full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
+ };
+ const layoutCapabilities = { broadcast: false, digital: false };
+ let activeRigId = null;
+
+ function layoutStorageKey() {
+ return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
+ }
+
+ function savedLayoutName() {
+ return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
+ }
+
+ function layoutAvailable(layout) {
+ return !layout.capability || layoutCapabilities[layout.capability] === true;
+ }
+
+ function unavailableLayoutMessage() {
+ const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
+ return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
+ }
+
+ function refreshLayoutOptions() {
+ const select = document.getElementById("operator-layout-select");
+ if (!select) return;
+ const previous = select.value || document.body.dataset.operatorLayout || "compact";
+ select.replaceChildren();
+ Object.entries(layouts).forEach(([value, layout]) => {
+ if (!layoutAvailable(layout)) return;
+ select.add(new Option(layout.label, value));
+ });
+ const available = Array.from(select.options).some(option => option.value === previous);
+ select.value = available ? previous : "compact";
+ if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
+ select.title = unavailableLayoutMessage();
+ }
+
+ api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
+ Object.keys(layoutCapabilities).forEach((name) => {
+ if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
+ });
+ refreshLayoutOptions();
+ const select = document.getElementById("operator-layout-select");
+ const saved = savedLayoutName();
+ if (select && Array.from(select.options).some(option => option.value === saved)) {
+ select.value = saved;
+ api.applyLayout(saved, { persist: false });
+ }
+ };
+
+ api.setActiveRig = function setActiveRig(rigId) {
+ activeRigId = typeof rigId === "string" && rigId ? rigId : null;
+ const saved = savedLayoutName();
+ const select = document.getElementById("operator-layout-select");
+ if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
+ api.applyLayout(select?.value || saved, { persist: false });
+ };
+
+ api.applyLayout = function applyLayout(name, options = {}) {
+ const requestedLayout = layouts[name];
+ const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
+ const layout = layouts[permittedName];
+ document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
+ if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
+ const details = document.getElementById("advanced-radio-controls");
+ if (details) details.open = layout.advanced;
+ const audioDetails = document.getElementById("audio-controls");
+ if (audioDetails) audioDetails.open = layout.audio;
+ const schedulerDetails = document.getElementById("scheduler-controls");
+ if (schedulerDetails) schedulerDetails.open = layout.scheduler;
+ if (options.navigate && typeof window.navigateToTab === "function") {
+ window.navigateToTab(layout.preferredTab);
+ }
+ };
+
+ function installLayoutControls() {
+ const actions = document.querySelector(".top-bar-actions");
+ if (actions && !document.getElementById("operator-layout-select")) {
+ const label = document.createElement("label");
+ label.className = "operator-layout-picker";
+ label.innerHTML = 'Operator layout ';
+ const select = label.querySelector("select");
+ const savedLayout = savedLayoutName();
+ actions.insertBefore(label, actions.firstChild);
+ select.value = savedLayout;
+ refreshLayoutOptions();
+ if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
+ select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
+ api.applyLayout(select.value);
+ }
+
+ const tray = document.querySelector(".controls-tray");
+ if (tray && !document.getElementById("advanced-radio-controls")) {
+ const details = document.createElement("details");
+ details.id = "advanced-radio-controls";
+ details.className = "advanced-radio-controls";
+ details.innerHTML = 'Advanced radio controls
';
+ const body = details.querySelector(".advanced-radio-body");
+ ["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
+ const element = document.getElementById(id);
+ if (element) body.appendChild(element);
+ });
+ tray.appendChild(details);
+ api.applyLayout(savedLayoutName(), { persist: false });
+ }
+ }
+
+ function installMobileMore() {
+ const nav = document.querySelector(".tab-bar-nav");
+ if (!nav || document.getElementById("mobile-more-btn")) return;
+ const more = document.createElement("button");
+ more.id = "mobile-more-btn";
+ more.className = "tab mobile-more-btn";
+ more.type = "button";
+ more.innerHTML = 'β’β’β’ More ';
+ more.setAttribute("aria-haspopup", "menu");
+ more.setAttribute("aria-expanded", "false");
+ const menu = document.createElement("div");
+ menu.id = "mobile-more-menu";
+ menu.className = "mobile-more-menu";
+ menu.setAttribute("role", "menu");
+ more.setAttribute("aria-controls", menu.id);
+ const closeMore = (restoreFocus = false) => {
+ if (!menu.classList.contains("is-open")) return;
+ menu.classList.remove("is-open");
+ more.setAttribute("aria-expanded", "false");
+ if (restoreFocus) more.focus();
+ };
+ api.closeMobileOverlays = closeMore;
+ ["statistics", "recorder", "settings", "about"].forEach((tabName) => {
+ const source = nav.querySelector(`[data-tab="${tabName}"]`);
+ if (!source) return;
+ const item = document.createElement("button");
+ item.type = "button";
+ item.setAttribute("role", "menuitem");
+ item.dataset.navigateTab = tabName;
+ item.textContent = source.textContent.trim();
+ item.addEventListener("click", () => {
+ if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
+ closeMore();
+ });
+ menu.appendChild(item);
+ });
+ more.addEventListener("click", () => {
+ const open = menu.classList.toggle("is-open");
+ more.setAttribute("aria-expanded", String(open));
+ if (open) menu.querySelector('[role="menuitem"]')?.focus();
+ });
+ document.addEventListener("click", (event) => {
+ if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
+ });
+ document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") closeMore(true);
+ });
+ window.addEventListener("resize", () => closeMore());
+ window.addEventListener("popstate", () => closeMore());
+ nav.append(more, menu);
+ }
+
+ function installDecoderPicker() {
+ const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
+ if (!bar || document.getElementById("decoder-tab-select")) return;
+ const select = document.createElement("select");
+ select.id = "decoder-tab-select";
+ select.className = "decoder-tab-select";
+ select.setAttribute("aria-label", "Decoder view");
+ const groups = [
+ ["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
+ ["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
+ ];
+ groups.forEach(([label, ids]) => {
+ const group = document.createElement("optgroup");
+ group.label = label;
+ ids.forEach((id) => {
+ const button = bar.querySelector(`[data-subtab="${id}"]`);
+ if (button) group.appendChild(new Option(button.textContent.trim(), id));
+ });
+ select.appendChild(group);
+ });
+ select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
+ bar.insertAdjacentElement("afterend", select);
+ }
+
+ function installDecoderBadges() {
+ const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
+ if (!bar) return;
+ bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
+ const id = button.dataset.subtab;
+ if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
+ const dot = document.createElement("span");
+ dot.className = "decoder-state-dot";
+ dot.setAttribute("aria-hidden", "true");
+ button.appendChild(dot);
+ const status = document.getElementById(`${id}-status`);
+ if (!status) return;
+ const sync = () => {
+ const value = status.textContent.toLowerCase();
+ const state = /receiv|decod|connected|listening/.test(value) ? "active"
+ : /error|fail|disconnected/.test(value) ? "error" : "idle";
+ dot.dataset.state = state;
+ button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
+ };
+ new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
+ sync();
+ });
+ }
+
+ api.init = function init() {
+ ensureLiveRegions();
+ installLayoutControls();
+ installMobileMore();
+ installDecoderPicker();
+ installDecoderBadges();
+ api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
+ document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
+ window.addEventListener("unhandledrejection", (event) => {
+ const message = event.reason?.message || "An operation failed unexpectedly";
+ api.notify(message, { kind: "error" });
+ });
+ };
+
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
+ else api.init();
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/webgl-renderer.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/webgl-renderer.js
new file mode 100644
index 00000000..29a05575
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/webgl-renderer.js
@@ -0,0 +1,535 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+(function initTrxWebGl(global) {
+ "use strict";
+
+ const cssColorCache = new Map();
+ let cssColorProbe = null;
+
+ function clearCssColorCache() {
+ cssColorCache.clear();
+ }
+
+ function ensureCssColorProbe() {
+ if (cssColorProbe) return cssColorProbe;
+ const el = document.createElement("span");
+ el.style.position = "absolute";
+ el.style.left = "-9999px";
+ el.style.top = "-9999px";
+ el.style.pointerEvents = "none";
+ el.style.opacity = "0";
+ document.body.appendChild(el);
+ cssColorProbe = el;
+ return cssColorProbe;
+ }
+
+ function parseRgbString(value) {
+ const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
+ if (!m) return null;
+ const parts = m[1].split(",").map((p) => p.trim());
+ if (parts.length < 3) return null;
+ const r = Number(parts[0]);
+ const g = Number(parts[1]);
+ const b = Number(parts[2]);
+ const a = parts.length > 3 ? Number(parts[3]) : 1;
+ if (![r, g, b, a].every(Number.isFinite)) return null;
+ return [
+ Math.max(0, Math.min(1, r / 255)),
+ Math.max(0, Math.min(1, g / 255)),
+ Math.max(0, Math.min(1, b / 255)),
+ Math.max(0, Math.min(1, a)),
+ ];
+ }
+
+ function parseHexColor(value) {
+ const raw = String(value || "").trim();
+ if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
+ let hex = raw.slice(1);
+ if (hex.length === 3 || hex.length === 4) {
+ hex = hex.split("").map((ch) => ch + ch).join("");
+ }
+ if (!(hex.length === 6 || hex.length === 8)) return null;
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
+ const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
+ return [r, g, b, a];
+ }
+
+ function parseCssColor(value) {
+ const key = String(value ?? "");
+ if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
+
+ let parsed = parseHexColor(key) || parseRgbString(key);
+ if (!parsed) {
+ const probe = ensureCssColorProbe();
+ probe.style.color = "";
+ probe.style.color = key;
+ const computed = getComputedStyle(probe).color;
+ parsed = parseRgbString(computed) || [0, 0, 0, 1];
+ }
+ cssColorCache.set(key, parsed.slice());
+ return parsed.slice();
+ }
+
+ function hslToRgba(h, s, l, a = 1) {
+ const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
+ const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
+ const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
+
+ const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
+ const p = 2 * lig - q;
+ const hueToRgb = (t) => {
+ let tt = t;
+ if (tt < 0) tt += 1;
+ if (tt > 1) tt -= 1;
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
+ if (tt < 1 / 2) return q;
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
+ return p;
+ };
+
+ const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
+ const g = sat === 0 ? lig : hueToRgb(hue);
+ const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
+ return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
+ }
+
+ function normalizeColor(input, alphaMul = 1) {
+ let rgba;
+ if (Array.isArray(input)) {
+ const arr = input.map((v) => Number(v));
+ if (arr.length >= 4) {
+ rgba = [arr[0], arr[1], arr[2], arr[3]];
+ } else {
+ rgba = [0, 0, 0, 1];
+ }
+ } else if (typeof input === "string") {
+ rgba = parseCssColor(input);
+ } else if (input && typeof input === "object") {
+ rgba = [
+ Number(input.r) || 0,
+ Number(input.g) || 0,
+ Number(input.b) || 0,
+ Number(input.a ?? 1),
+ ];
+ } else {
+ rgba = [0, 0, 0, 1];
+ }
+ const out = [
+ Math.max(0, Math.min(1, rgba[0])),
+ Math.max(0, Math.min(1, rgba[1])),
+ Math.max(0, Math.min(1, rgba[2])),
+ Math.max(0, Math.min(1, rgba[3] * alphaMul)),
+ ];
+ return out;
+ }
+
+ function compileShader(gl, type, source) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ const log = gl.getShaderInfoLog(shader) || "shader compile error";
+ gl.deleteShader(shader);
+ throw new Error(log);
+ }
+ return shader;
+ }
+
+ function createProgram(gl, vertexSrc, fragmentSrc) {
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
+ const program = gl.createProgram();
+ gl.attachShader(program, vs);
+ gl.attachShader(program, fs);
+ gl.linkProgram(program);
+ gl.deleteShader(vs);
+ gl.deleteShader(fs);
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ const log = gl.getProgramInfoLog(program) || "program link error";
+ gl.deleteProgram(program);
+ throw new Error(log);
+ }
+ return program;
+ }
+
+ function pushColoredVertex(target, x, y, rgba) {
+ target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
+ }
+
+ function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const len = Math.hypot(dx, dy);
+ if (!(len > 0.0001)) return;
+ const nx = (-dy / len) * halfW;
+ const ny = (dx / len) * halfW;
+
+ const ax = x0 - nx, ay = y0 - ny;
+ const bx = x0 + nx, by = y0 + ny;
+ const cx = x1 + nx, cy = y1 + ny;
+ const dx2 = x1 - nx, dy2 = y1 - ny;
+
+ pushColoredVertex(out, ax, ay, rgba);
+ pushColoredVertex(out, bx, by, rgba);
+ pushColoredVertex(out, cx, cy, rgba);
+
+ pushColoredVertex(out, ax, ay, rgba);
+ pushColoredVertex(out, cx, cy, rgba);
+ pushColoredVertex(out, dx2, dy2, rgba);
+ }
+
+ class TrxWebGlRenderer {
+ constructor(canvas, options = {}) {
+ this.canvas = canvas;
+ this.options = { alpha: true, premultipliedAlpha: false, ...options };
+ this.gl =
+ canvas?.getContext("webgl", this.options) ||
+ canvas?.getContext("experimental-webgl", this.options) ||
+ null;
+ this.ready = !!this.gl;
+ this.textures = new Map();
+ // Reusable scratch buffers β avoids per-draw-call Float32Array allocation
+ // and lets us use bufferSubData instead of bufferData (no GPU realloc).
+ this._colorScratch = new Float32Array(4096 * 6); // grows as needed
+ this._colorGpuSize = 0; // current GPU buffer size (floats)
+ this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts Γ (xy+uv)
+ if (!this.ready) return;
+
+ const gl = this.gl;
+ gl.disable(gl.DEPTH_TEST);
+ gl.disable(gl.CULL_FACE);
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+
+ const colorVertexSrc =
+ "attribute vec2 a_pos;\n" +
+ "attribute vec4 a_color;\n" +
+ "uniform vec2 u_resolution;\n" +
+ "varying vec4 v_color;\n" +
+ "void main() {\n" +
+ " vec2 zeroToOne = a_pos / u_resolution;\n" +
+ " vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
+ " gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
+ " v_color = a_color;\n" +
+ "}\n";
+ const colorFragmentSrc =
+ "precision mediump float;\n" +
+ "varying vec4 v_color;\n" +
+ "void main() {\n" +
+ " gl_FragColor = v_color;\n" +
+ "}\n";
+
+ const textureVertexSrc =
+ "attribute vec2 a_pos;\n" +
+ "attribute vec2 a_uv;\n" +
+ "uniform vec2 u_resolution;\n" +
+ "varying vec2 v_uv;\n" +
+ "void main() {\n" +
+ " vec2 zeroToOne = a_pos / u_resolution;\n" +
+ " vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
+ " gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
+ " v_uv = a_uv;\n" +
+ "}\n";
+ const textureFragmentSrc =
+ "precision mediump float;\n" +
+ "varying vec2 v_uv;\n" +
+ "uniform sampler2D u_tex;\n" +
+ "uniform float u_alpha;\n" +
+ "void main() {\n" +
+ " vec4 c = texture2D(u_tex, v_uv);\n" +
+ " gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
+ "}\n";
+
+ this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
+ this.colorBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
+ this._colorGpuSize = this._colorScratch.length;
+ this.colorLoc = {
+ pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
+ color: gl.getAttribLocation(this.colorProgram, "a_color"),
+ resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
+ };
+
+ this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
+ this.textureBuffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
+ this.textureLoc = {
+ pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
+ uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
+ resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
+ alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
+ tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
+ };
+ }
+
+ ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
+ if (!this.ready) return false;
+ const nextW = Math.max(1, Math.round(cssWidth * dpr));
+ const nextH = Math.max(1, Math.round(cssHeight * dpr));
+ const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
+ if (changed) {
+ this.canvas.width = nextW;
+ this.canvas.height = nextH;
+ }
+ this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
+ return changed;
+ }
+
+ clear(color) {
+ if (!this.ready) return;
+ const gl = this.gl;
+ const rgba = normalizeColor(color);
+ gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ }
+
+ drawTriangles(vertices) {
+ this._drawColorGeometry(vertices, this.gl.TRIANGLES);
+ }
+
+ drawTriangleStrip(vertices) {
+ this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
+ }
+
+ _drawColorGeometry(vertices, mode) {
+ if (!this.ready || !vertices || vertices.length === 0) return;
+ const gl = this.gl;
+ const count = vertices.length;
+
+ // Grow scratch buffer if needed (doubles each time to amortise copies).
+ if (count > this._colorScratch.length) {
+ let newLen = this._colorScratch.length;
+ while (newLen < count) newLen *= 2;
+ this._colorScratch = new Float32Array(newLen);
+ }
+
+ // Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
+ this._colorScratch.set(vertices);
+ const view = this._colorScratch.subarray(0, count);
+
+ gl.useProgram(this.colorProgram);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
+
+ // Only reallocate the GPU buffer when it is too small; otherwise use
+ // bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
+ if (count > this._colorGpuSize) {
+ gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
+ this._colorGpuSize = this._colorScratch.length;
+ } else {
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
+ }
+
+ gl.enableVertexAttribArray(this.colorLoc.pos);
+ gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
+ gl.enableVertexAttribArray(this.colorLoc.color);
+ gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
+ gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
+ gl.drawArrays(mode, 0, count / 6);
+ }
+
+ fillRect(x, y, w, h, color) {
+ if (w <= 0 || h <= 0) return;
+ const rgba = normalizeColor(color);
+ const v = [];
+ pushColoredVertex(v, x, y, rgba);
+ pushColoredVertex(v, x + w, y, rgba);
+ pushColoredVertex(v, x + w, y + h, rgba);
+ pushColoredVertex(v, x, y, rgba);
+ pushColoredVertex(v, x + w, y + h, rgba);
+ pushColoredVertex(v, x, y + h, rgba);
+ this._drawColorGeometry(v, this.gl.TRIANGLES);
+ }
+
+ fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
+ if (w <= 0 || h <= 0) return;
+ const tl = normalizeColor(colorTL);
+ const tr = normalizeColor(colorTR);
+ const br = normalizeColor(colorBR);
+ const bl = normalizeColor(colorBL);
+ const v = [];
+ pushColoredVertex(v, x, y, tl);
+ pushColoredVertex(v, x + w, y, tr);
+ pushColoredVertex(v, x + w, y + h, br);
+ pushColoredVertex(v, x, y, tl);
+ pushColoredVertex(v, x + w, y + h, br);
+ pushColoredVertex(v, x, y + h, bl);
+ this._drawColorGeometry(v, this.gl.TRIANGLES);
+ }
+
+ drawPolyline(points, color, width = 1) {
+ if (!Array.isArray(points) || points.length < 4) return;
+ const rgba = normalizeColor(color);
+ const halfW = Math.max(0.5, Number(width) || 1) / 2;
+ const verts = [];
+ for (let i = 0; i < points.length - 2; i += 2) {
+ segmentToQuadVertices(
+ verts,
+ points[i], points[i + 1],
+ points[i + 2], points[i + 3],
+ halfW,
+ rgba,
+ );
+ }
+ this._drawColorGeometry(verts, this.gl.TRIANGLES);
+ }
+
+ drawSegments(segments, color, width = 1) {
+ if (!Array.isArray(segments) || segments.length < 4) return;
+ const rgba = normalizeColor(color);
+ const halfW = Math.max(0.5, Number(width) || 1) / 2;
+ const verts = [];
+ for (let i = 0; i < segments.length - 3; i += 4) {
+ segmentToQuadVertices(
+ verts,
+ segments[i], segments[i + 1],
+ segments[i + 2], segments[i + 3],
+ halfW,
+ rgba,
+ );
+ }
+ this._drawColorGeometry(verts, this.gl.TRIANGLES);
+ }
+
+ drawFilledArea(points, baselineY, color) {
+ if (!Array.isArray(points) || points.length < 4) return;
+ const rgba = normalizeColor(color);
+ const verts = [];
+ for (let i = 0; i < points.length; i += 2) {
+ pushColoredVertex(verts, points[i], baselineY, rgba);
+ pushColoredVertex(verts, points[i], points[i + 1], rgba);
+ }
+ this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
+ }
+
+ drawPoints(points, size, color) {
+ if (!Array.isArray(points) || points.length < 2) return;
+ const radius = Math.max(1, Number(size) || 1);
+ const rgba = normalizeColor(color);
+ const verts = [];
+ for (let i = 0; i < points.length; i += 2) {
+ const x = points[i] - radius;
+ const y = points[i + 1] - radius;
+ const w = radius * 2;
+ const h = radius * 2;
+ pushColoredVertex(verts, x, y, rgba);
+ pushColoredVertex(verts, x + w, y, rgba);
+ pushColoredVertex(verts, x + w, y + h, rgba);
+ pushColoredVertex(verts, x, y, rgba);
+ pushColoredVertex(verts, x + w, y + h, rgba);
+ pushColoredVertex(verts, x, y + h, rgba);
+ }
+ this._drawColorGeometry(verts, this.gl.TRIANGLES);
+ }
+
+ drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
+ const dash = Math.max(1, Number(dashLen) || 1);
+ const gap = Math.max(1, Number(gapLen) || 1);
+ const top = Math.min(y0, y1);
+ const bottom = Math.max(y0, y1);
+ const segments = [];
+ for (let y = top; y < bottom; y += dash + gap) {
+ const segEnd = Math.min(bottom, y + dash);
+ segments.push(x, y, x, segEnd);
+ }
+ this.drawSegments(segments, color, width);
+ }
+
+ uploadRgbaTexture(name, width, height, data, filter = "linear") {
+ if (!this.ready || !name || !data) return null;
+ const gl = this.gl;
+ let entry = this.textures.get(name);
+ if (!entry) {
+ const texture = gl.createTexture();
+ entry = { texture, width: 0, height: 0 };
+ this.textures.set(name, entry);
+ }
+ gl.bindTexture(gl.TEXTURE_2D, entry.texture);
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ if (entry.width !== width || entry.height !== height) {
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ width,
+ height,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ data,
+ );
+ entry.width = width;
+ entry.height = height;
+ } else {
+ gl.texSubImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ 0,
+ 0,
+ width,
+ height,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ data,
+ );
+ }
+ return entry.texture;
+ }
+
+ drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
+ if (!this.ready || !name || w <= 0 || h <= 0) return;
+ const entry = this.textures.get(name);
+ if (!entry) return;
+ const gl = this.gl;
+ const s = this._texScratch;
+ const x2 = x + w, y2 = y + h;
+ if (flipY) {
+ s[0]=x; s[1]=y; s[2]=0; s[3]=1;
+ s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
+ s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
+ s[12]=x; s[13]=y; s[14]=0;s[15]=1;
+ s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
+ s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
+ } else {
+ s[0]=x; s[1]=y; s[2]=0; s[3]=0;
+ s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
+ s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
+ s[12]=x; s[13]=y; s[14]=0;s[15]=0;
+ s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
+ s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
+ }
+ gl.useProgram(this.textureProgram);
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
+ gl.enableVertexAttribArray(this.textureLoc.pos);
+ gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
+ gl.enableVertexAttribArray(this.textureLoc.uv);
+ gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
+ gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
+ gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, entry.texture);
+ gl.uniform1i(this.textureLoc.tex, 0);
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
+ }
+ }
+
+ function createRenderer(canvas, options) {
+ return new TrxWebGlRenderer(canvas, options);
+ }
+
+ global.trxParseCssColor = parseCssColor;
+ global.trxHslToRgba = hslToRgba;
+ global.createTrxWebGlRenderer = createRenderer;
+ global.trxClearCssColorCache = clearCssColorCache;
+})(window);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs
new file mode 100644
index 00000000..517348e1
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs
@@ -0,0 +1,12 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import assert from "node:assert/strict";
+import test from "node:test";
+import { readFile } from "node:fs/promises";
+
+test("index loads the shared UI before the application", async () => {
+ const html = await readFile(new URL("../../assets/web/index.html", import.meta.url), "utf8");
+ assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js"));
+});
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json
new file mode 100644
index 00000000..a2d9e6d4
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "exactOptionalPropertyTypes": true,
+ "useUnknownInCatchVariables": true,
+ "allowJs": true,
+ "checkJs": false,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM"],
+ "types": []
+ },
+ "include": ["src/**/*.ts", "src/**/*.js"]
+}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
index 68afc3b7..adbf1db6 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs
@@ -11,30 +11,31 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
-pub const APP_JS: &str = include_str!("../assets/web/app.js");
-pub const UI_CORE_JS: &str = include_str!("../assets/web/ui-core.js");
-pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
-pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
-pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
-pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
+pub const APP_JS: &str = include_str!("../assets/web/generated/app.js");
+pub const UI_CORE_JS: &str = include_str!("../assets/web/generated/ui-core.js");
+pub const MAP_CORE_JS: &str = include_str!("../assets/web/generated/map-core.js");
+pub const SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js");
+pub const DECODE_HISTORY_WORKER_JS: &str =
+ include_str!("../assets/web/generated/decode-history-worker.js");
+pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/generated/webgl-renderer.js");
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
- include_str!("../assets/web/leaflet-ais-tracksymbol.js");
-pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
-pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
-pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
-pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
-pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
-pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js");
-pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js");
-pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
-pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
-pub const SAT_JS: &str = include_str!("../assets/web/plugins/sat.js");
-pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
-pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
-pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
-pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/plugins/sat-scheduler.js");
-pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js");
-pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
+ include_str!("../assets/web/generated/leaflet-ais-tracksymbol.js");
+pub const AIS_JS: &str = include_str!("../assets/web/generated/ais.js");
+pub const VDES_JS: &str = include_str!("../assets/web/generated/vdes.js");
+pub const APRS_JS: &str = include_str!("../assets/web/generated/aprs.js");
+pub const HF_APRS_JS: &str = include_str!("../assets/web/generated/hf-aprs.js");
+pub const FT8_JS: &str = include_str!("../assets/web/generated/ft8.js");
+pub const FT4_JS: &str = include_str!("../assets/web/generated/ft4.js");
+pub const FT2_JS: &str = include_str!("../assets/web/generated/ft2.js");
+pub const WSPR_JS: &str = include_str!("../assets/web/generated/wspr.js");
+pub const CW_JS: &str = include_str!("../assets/web/generated/cw.js");
+pub const SAT_JS: &str = include_str!("../assets/web/generated/sat.js");
+pub const WEFAX_JS: &str = include_str!("../assets/web/generated/wefax.js");
+pub const BOOKMARKS_JS: &str = include_str!("../assets/web/generated/bookmarks.js");
+pub const SCHEDULER_JS: &str = include_str!("../assets/web/generated/scheduler.js");
+pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/generated/sat-scheduler.js");
+pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/generated/background-decode.js");
+pub const VCHAN_JS: &str = include_str!("../assets/web/generated/vchan.js");
pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
// Vendored DSEG14 Classic font