// --- 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; } } // --- 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) { badge.style.display = "block"; badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; if (headerAuthBtn) { headerAuthBtn.textContent = "Logout"; headerAuthBtn.style.display = "block"; } } else { 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", "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" ]; 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; if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none"; if (pttBtn) pttBtn.style.display = caps.tx ? "" : "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 && !caps.tx_limit) txLimitRow.style.display = "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 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 headerStylePickSelect = document.getElementById("header-style-pick-select"); const rdsPsOverlay = document.getElementById("rds-ps-overlay"); let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); let decodeHistoryRetentionMin = 24 * 60; let primaryRds = null; let vchanRdsById = 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; function markDecodeMapSyncPending() { decodeMapSyncPending = true; } function flushDeferredDecodeMapSync() { if (!decodeMapSyncPending || decodeHistoryReplayActive || !aprsMap) return; decodeMapSyncPending = false; scheduleUiFrameJob("decode-map-maintenance", () => { pruneMapHistory(); }); } function setDecodeHistoryReplayActive(active) { decodeHistoryReplayActive = !!active; if (!decodeHistoryReplayActive) { flushDeferredDecodeMapSync(); } } function decodeHistoryMapRenderingDeferred() { return decodeHistoryReplayActive || !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 hintTimer = null; let sigMeasuring = false; let sigLastSUnits = null; 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 = ""; 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; let lastRigIds = []; let lastRigDisplayNames = {}; let lastActiveRigId = null; 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"; } 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; 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.innerHTML = ""; 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 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; lastRigIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); 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 disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); 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 || ""); updateMapRigFilter(); } 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 lastRigIds) { const opt = document.createElement("option"); opt.value = id; opt.textContent = lastRigDisplayNames[id] || id; el.appendChild(opt); } if (prev && lastRigIds.includes(prev)) { el.value = prev; } else { el.value = ""; mapRigFilter = ""; } } 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; serverActiveRigId = data.active_remote || null; applyRigList(data.active_remote, rigIds, displayNames); } catch (e) { // Non-fatal: SSE/status path still drives main UI. } } function showHint(msg, duration) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); } let supportedModes = []; let supportedBands = []; let lastUnsupportedFreqPopupAt = 0; let freqDirty = false; let initialized = false; let lastEventAt = Date.now(); let es; let esHeartbeat; 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; 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; } 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 dpr = window.devicePixelRatio || 1; const maxRows = Math.max(1, Math.floor(overviewCanvas.height / dpr)); while (overviewWaterfallRows.length > maxRows) { overviewWaterfallRows.shift(); } } 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 || !Array.isArray(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(); } 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; function renderRow(dstY, srcBins) { if (!Array.isArray(srcBins) || srcBins.length === 0) return; const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; for (let x = 0; x < iW; x++) { const frac = x / Math.max(1, iW - 1); const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); const c = waterfallColorRgba(srcBins[binIdx], pal, minDb, maxDb); const p = rowBase + x * 4; overviewWfTexData[p + 0] = Math.round(c[0] * 255); overviewWfTexData[p + 1] = Math.round(c[1] * 255); overviewWfTexData[p + 2] = Math.round(c[2] * 255); overviewWfTexData[p + 3] = Math.round(c[3] * 255); } } 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})`); } 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)}` + `` ); } 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.innerHTML = ""; 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(); // 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 = "--"; }); // FT8/FT4/WSPR history tables if (typeof window.ft8ClearHistory === "function") window.ft8ClearHistory(); if (typeof window.ft4ClearHistory === "function") window.ft4ClearHistory(); if (typeof window.ft2ClearHistory === "function") window.ft2ClearHistory(); if (typeof window.wsprClearHistory === "function") window.wsprClearHistory(); } function resetWfmStereoIndicator() { if (!wfmStFlagEl) return; wfmStFlagEl.textContent = "MO"; wfmStFlagEl.classList.remove("wfm-st-flag-stereo"); wfmStFlagEl.classList.add("wfm-st-flag-mono"); } // ββ 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) { primaryRds = null; resetRdsDisplay(); resetWfmStereoIndicator(); } 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 (lastSpectrumData) { scheduleSpectrumDraw(); } 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 || !Array.isArray(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 || !Array.isArray(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.alert(message); } // 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 <= 9) return `S${Math.max(0, sUnits || 0).toFixed(1)}`; // S9+60dB is already extremely strong; cap anything beyond that. const overDb = Math.min(60, (sUnits - 9) * 10); return `S9 + ${overDb.toFixed(0)}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 tabMainEl = document.getElementById("tab-main"); const contentEl = document.getElementById("content"); 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`); if (lastSpectrumData) { scheduleSpectrumDraw(); scheduleOverviewDraw(); } } function beginSpectrumResize(clientY) { const tabMainEl = document.getElementById("tab-main"); const contentEl = document.getElementById("content"); 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 = ""; reverseGeocodeLocation(serverLat, serverLon, grid); } if (aprsMap) 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.innerHTML = ""; 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); 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); } 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"; } 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") { 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, true); } } if (update.status && 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() : ""; const aisStatus = document.getElementById("ais-status"); const vdesStatus = document.getElementById("vdes-status"); const aprsStatus = document.getElementById("aprs-status"); const cwStatus = document.getElementById("cw-status"); const ft8Status = document.getElementById("ft8-status"); const wsprStatus = document.getElementById("wspr-status"); setModeBoundDecodeStatus( aisStatus, ["AIS"], "Select AIS mode to decode", "Connected, listening for packets", ); if (window.updateAisBar) window.updateAisBar(); setModeBoundDecodeStatus( vdesStatus, ["VDES"], "Select VDES mode to decode", "Connected, listening for bursts", ); if (window.updateVdesBar) window.updateVdesBar(); setModeBoundDecodeStatus( aprsStatus, ["PKT"], "Select PKT mode to decode", "Connected, listening for packets", ); if (window.updateAprsBar) window.updateAprsBar(); if (window.updateFt8Bar) window.updateFt8Bar(); setModeBoundDecodeStatus( cwStatus, ["CW", "CWR"], "Select CW mode to decode", "Connected, listening for CW", ); const ft8Enabled = !!update.ft8_decode_enabled; if (ft8Status && (!ft8Enabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && ft8Status.textContent === "Receiving") { ft8Status.textContent = "Connected, listening for packets"; } const wsprEnabled = !!update.wspr_decode_enabled; if (wsprStatus && (!wsprEnabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && wsprStatus.textContent === "Receiving") { wsprStatus.textContent = "Connected, listening for packets"; } if (update.status && typeof update.status.tx_en === "boolean") { lastTxEn = update.status.tx_en; pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off"; 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 = ""; } } const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn"); if (ft8ToggleBtn) { const ft8On = !!update.ft8_decode_enabled; ft8ToggleBtn.dataset.enabled = ft8On ? "true" : "false"; ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8"; ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : ""; ft8ToggleBtn.style.color = ft8On ? "#00d17f" : ""; } const ft4ToggleBtn = document.getElementById("ft4-decode-toggle-btn"); if (ft4ToggleBtn) { const ft4On = !!update.ft4_decode_enabled; ft4ToggleBtn.dataset.enabled = ft4On ? "true" : "false"; ft4ToggleBtn.textContent = ft4On ? "Disable FT4" : "Enable FT4"; ft4ToggleBtn.style.borderColor = ft4On ? "#00d17f" : ""; ft4ToggleBtn.style.color = ft4On ? "#00d17f" : ""; } const ft2ToggleBtn = document.getElementById("ft2-decode-toggle-btn"); if (ft2ToggleBtn) { const ft2On = !!update.ft2_decode_enabled; ft2ToggleBtn.dataset.enabled = ft2On ? "true" : "false"; ft2ToggleBtn.textContent = ft2On ? "Disable FT2" : "Enable FT2"; ft2ToggleBtn.style.borderColor = ft2On ? "#00d17f" : ""; ft2ToggleBtn.style.color = ft2On ? "#00d17f" : ""; } const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn"); if (wsprToggleBtn) { const wsprOn = !!update.wspr_decode_enabled; wsprToggleBtn.dataset.enabled = wsprOn ? "true" : "false"; wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR"; wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : ""; wsprToggleBtn.style.color = wsprOn ? "#00d17f" : ""; } const hfAprsToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); if (hfAprsToggleBtn) { const hfAprsOn = !!update.hf_aprs_decode_enabled; hfAprsToggleBtn.dataset.enabled = hfAprsOn ? "true" : "false"; hfAprsToggleBtn.textContent = hfAprsOn ? "Disable HF APRS" : "Enable HF APRS"; hfAprsToggleBtn.style.borderColor = hfAprsOn ? "#00d17f" : ""; hfAprsToggleBtn.style.color = hfAprsOn ? "#00d17f" : ""; } const cwAutoEl = document.getElementById("cw-auto"); const cwWpmEl = document.getElementById("cw-wpm"); const cwToneEl = document.getElementById("cw-tone"); 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.innerHTML = ""; 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") { const sUnits = dbmToSUnits(update.status.rx.sig); sigLastSUnits = sUnits; const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.textContent = formatSignal(sUnits); } else { sigLastSUnits = null; signalBar.style.width = "0%"; signalValue.textContent = "--"; } if (bandLabel) { bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; } if (typeof update.enabled === "boolean") { powerBtn.disabled = false; powerBtn.textContent = update.enabled ? "Power Off" : "Power On"; } else { powerBtn.disabled = true; powerBtn.textContent = "Toggle Power"; 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 if (update.server_version) { document.getElementById("about-server-ver").textContent = `trx-server v${update.server_version}`; } document.getElementById("about-server-addr").textContent = location.host; if (update.server_callsign) { document.getElementById("about-server-call").textContent = update.server_callsign; } if (update.pskreporter_status) { document.getElementById("about-pskreporter").textContent = update.pskreporter_status; } if (update.aprs_is_status) { document.getElementById("about-aprs-is").textContent = update.aprs_is_status; } if (update.info) { const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" "); if (parts) document.getElementById("about-rig-info").textContent = parts; const access = update.info.access; if (access) { if (access.Serial) { const serialPath = access.Serial.path || access.Serial.port || "?"; document.getElementById("about-rig-access").textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; } else if (access.Tcp) { document.getElementById("about-rig-access").textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; } else { const key = Object.keys(access)[0]; if (key) document.getElementById("about-rig-access").textContent = key; } } if (update.info.capabilities) { const cap = update.info.capabilities; if (Array.isArray(cap.supported_modes) && cap.supported_modes.length) { document.getElementById("about-modes").textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", "); } if (typeof cap.num_vfos === "number") { document.getElementById("about-vfos").textContent = cap.num_vfos; } } } if (typeof update.clients === "number") { document.getElementById("about-clients").textContent = update.clients; } if (lastActiveRigId) { document.getElementById("about-active-rig").textContent = lastActiveRigId; } if (Array.isArray(update.remotes)) { applyRigList(update.active_remote, update.remotes); } if (typeof update.rigctl_clients === "number") { document.getElementById("about-rigctl-clients").textContent = update.rigctl_clients; } if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0) { document.getElementById("about-rigctl-endpoint").textContent = update.rigctl_addr; } powerHint.textContent = readyText(); lastLocked = update.status && update.status.lock === true; lockBtn.textContent = lastLocked ? "Unlock" : "Lock"; 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); } pollFreshSnapshot(); const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events"; es = new EventSource(eventsUrl); lastEventAt = Date.now(); es.onopen = () => { setConnLostOverlay(false); 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"; setConnLostOverlay(true, "trx-server connection lost", "trx-client is running but cannot reach the radio server"); } else { setConnLostOverlay(false); 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(); // 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) { // Auto-append remote so each tab targets its own rig. // Skip when the caller already included remote (e.g. /select_rig). if (lastActiveRigId && !path.includes("remote=")) { const sep = path.includes("?") ? "&" : "?"; path = `${path}${sep}remote=${encodeURIComponent(lastActiveRigId)}`; } 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; lastActiveRigId = selectEl.value; if (prevRig && prevRig !== lastActiveRigId) { resetDecoderStateOnRigSwitch(); } updateRigSubtitle(lastActiveRigId); if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); // Reconnect decode SSE so history is re-fetched with the new rig filter. connectDecode(); // Switch this session's rig and reconnect SSE to the new rig's // state channel. try { const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; await postPath(`/select_rig?remote=${encodeURIComponent(selectEl.value)}${sidParam}`); connect(); } catch (err) { console.error("select_rig failed:", err); } // Reconnect spectrum SSE to the new rig's spectrum channel. stopSpectrumStreaming(); startSpectrumStreaming(); // Reconnect audio to the new rig if audio is active. if (rxActive) { stopRxAudio(); startRxAudio(); } showHint(`Rig: ${lastActiveRigId}`, 1500); } if (headerRigSwitchSelect) { headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); }); } powerBtn.addEventListener("click", async () => { powerBtn.disabled = true; showHint("Sending..."); try { await postPath("/toggle_power"); showHint("Toggled, waiting for updateβ¦"); } catch (err) { showHint("Toggle failed", 2000); console.error(err); } finally { powerBtn.disabled = false; } }); pttBtn.addEventListener("click", async () => { pttBtn.disabled = 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 { pttBtn.disabled = 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; centerFreqEl.disabled = 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 { centerFreqEl.disabled = false; } } freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { e.preventDefault(); applyFreqFromInput(); } }); if (centerFreqEl) { centerFreqEl.addEventListener("keydown", (e) => { centerFreqDirty = true; if (e.key === "Enter") { e.preventDefault(); applyCenterFreqFromInput(); } }); 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(); modeEl.disabled = 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 { modeEl.disabled = 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; } txLimitBtn.disabled = 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 { txLimitBtn.disabled = false; } }); lockBtn.addEventListener("click", async () => { lockBtn.disabled = true; showHint("Toggling lockβ¦"); try { const nextLock = lockBtn.textContent === "Lock"; await postPath(nextLock ? "/lock" : "/unlock"); showHint("Lock toggled", 1500); } catch (err) { showHint("Lock toggle failed", 2000); console.error(err); } finally { lockBtn.disabled = 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], "AMC-QUAM": [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, 50_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 (_) {} } } 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 (_) {} } function estimateBandwidthAroundPeak(data, centerHz) { if (!data || !Array.isArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { return null; } const bins = data.bins; const maxIdx = bins.length - 1; 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 searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03))); const searchLo = Math.max(1, centerIdx - searchRadius); const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius); let peakIdx = centerIdx; for (let i = searchLo; i <= searchHi; i++) { if (bins[i] > bins[peakIdx]) peakIdx = i; } const sorted = [...bins].sort((a, b) => a - b); const noise = sorted[Math.floor(sorted.length * 0.2)]; const peak = bins[peakIdx]; const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35)); let left = peakIdx; let right = peakIdx; let belowCount = 0; for (let i = peakIdx; i > 1; i--) { if (bins[i] < threshold) belowCount += 1; else belowCount = 0; if (belowCount >= 2) break; left = i; } belowCount = 0; for (let i = peakIdx; i < maxIdx - 1; i++) { if (bins[i] < threshold) belowCount += 1; else belowCount = 0; if (belowCount >= 2) break; right = i; } const shoulderPad = Math.max(1, Math.round((right - left) * 0.08)); left = Math.max(0, left - shoulderPad); right = Math.min(maxIdx, right + shoulderPad); const hzPerBin = data.sample_rate / maxIdx; const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin); const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); 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; const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz); if (!Number.isFinite(estimated) || estimated <= 0) { syncBandwidthInput(currentBandwidthHz); return; } currentBandwidthHz = estimated; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(estimated); positionFastOverlay(lastFreqHz, estimated); if (lastSpectrumData) { scheduleSpectrumDraw(); } try { if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; await postPath(`/set_bandwidth?hz=${estimated}`); if (Number.isFinite(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz); } } catch (_) {} } 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 --- const TAB_ORDER = ["main", "bookmarks", "decoders", "map", "settings", "about"]; const TAB_PATHS = { main: "/", bookmarks: "/bookmarks", decoders: "/decoders", map: "/map", 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); } function navigateToTab(name, options = {}) { const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") return; const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); document.getElementById(`tab-${name}`).style.display = ""; if (updateHistory) { updateTabHistory(name, replaceHistory); } scheduleSpectrumLayout(); if (name === "map") { initAprsMap(); sizeAprsMapToViewport(); if (aprsMap) setTimeout(() => aprsMap.invalidateSize(), 50); } } 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 (confirm("Are you sure you want to logout?")) { await authLogout(); } } else { // Not logged in - show auth gate showAuthGate(false); } }); } // Start the app initializeApp(); window.addEventListener("resize", resizeHeaderSignalCanvas); // --- Leaflet Map (lazy-initialized) --- let aprsMap = null; let aprsMapBaseLayer = null; let aprsMapReceiverMarker = null; let aprsRadioPath = null; 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 }; 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 && !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 { 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 && !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 { 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 && !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 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) { 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 || decodeHistoryReplayActive) { 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 = 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 === "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 = 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 = 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 = 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() { if (aprsRadioPath) { aprsRadioPath.remove(); aprsRadioPath = null; } } 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); } renderMapQsoSummary(); renderMapSignalSummary(); renderMapWeakSignalSummary(); updateMapPathsAnimationClass(); } function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path") { clearMapRadioPath(); if (!mapP2pRadioPathsEnabled || serverLat == null || serverLon == null || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { return; } aprsRadioPath = L.polyline( [[serverLat, serverLon], [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.innerHTML = ""; 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.innerHTML = ""; 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.innerHTML = ""; 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 = `