// --- FT8 Decoder Plugin (server-side decode) --- const ft8Status = document.getElementById("ft8-status"); const ft8PeriodEl = document.getElementById("ft8-period"); const ft8MessagesEl = document.getElementById("ft8-messages"); const ft8FilterInput = document.getElementById("ft8-filter"); const ft8PauseBtn = document.getElementById("ft8-pause-btn"); const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); const FT8_MAX_MESSAGES = 200; const FT8_BAR_WINDOW_MS = 15 * 60 * 1000; const FT8_PERIOD_SECONDS = 15; let ft8FilterText = ""; let ft8MessageHistory = []; let ft8Paused = false; let ft8BufferedWhilePaused = 0; function normalizeFt8DisplayFreqHz(freqHz) { const rawHz = Number(freqHz); if (!Number.isFinite(rawHz)) return null; const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) { return baseHz + rawHz; } return rawHz; } function fmtTime(tsMs) { if (!tsMs) return "--:--:--"; return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } function updateFt8PeriodTimer() { if (!ft8PeriodEl) return; const nowSec = Math.floor(Date.now() / 1000); const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS); ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`; } updateFt8PeriodTimer(); setInterval(updateFt8PeriodTimer, 500); function renderFt8Row(msg) { const row = document.createElement("div"); row.className = "ft8-row"; const rawMessage = (msg.message || "").toString(); row.dataset.message = rawMessage.toUpperCase(); row.dataset.decoder = "ft8"; row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; const renderedMessage = renderFt8Message(rawMessage); row.innerHTML = `${fmtTime(msg.ts_ms)}${snr}${dt}${freq}${renderedMessage}`; applyFt8FilterToRow(row); return row; } function updateFt8PauseUi() { if (!ft8PauseBtn) return; ft8PauseBtn.textContent = ft8Paused ? "Resume" : "Pause"; ft8PauseBtn.classList.toggle("active", ft8Paused); } function renderFt8History() { if (!ft8MessagesEl || ft8Paused) { updateFt8PauseUi(); return; } ft8MessagesEl.innerHTML = ""; for (let i = 0; i < ft8MessageHistory.length; i += 1) { ft8MessagesEl.appendChild(renderFt8Row(ft8MessageHistory[i])); } updateFt8PauseUi(); } function addFt8Message(msg) { ft8MessageHistory.unshift(msg); if (ft8MessageHistory.length > FT8_MAX_MESSAGES) ft8MessageHistory.length = FT8_MAX_MESSAGES; updateFt8Bar(); if (ft8Paused) { ft8BufferedWhilePaused += 1; updateFt8PauseUi(); return; } renderFt8History(); } function ft8BarRfText(msg) { const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); if (!Number.isFinite(displayFreqHz)) return null; return `${displayFreqHz.toFixed(0)} Hz`; } function updateFt8Bar() { if (!ft8BarOverlay) return; const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB"; const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS; const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8); if (!isFt8Mode || messages.length === 0) { ft8BarOverlay.style.display = "none"; ft8BarOverlay.innerHTML = ""; return; } let html = '
'; for (const msg of messages) { const ts = msg.ts_ms ? `` : ""; const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; const rf = ft8BarRfText(msg); const detail = [snr, dt, rf].filter(Boolean).join(" · "); const text = escapeHtml((msg.message || "").toString()); html += ``; } ft8BarOverlay.innerHTML = html; ft8BarOverlay.style.display = "flex"; } window.updateFt8Bar = updateFt8Bar; window.clearFt8Bar = function() { document.getElementById("ft8-clear-btn")?.click(); }; function renderFt8Message(message) { let out = ""; let i = 0; while (i < message.length) { const ch = message[i]; if (isAlphaNum(ch)) { let j = i + 1; while (j < message.length && isAlphaNum(message[j])) j++; const token = message.slice(i, j); const grid = token.toUpperCase(); if (isMaidenheadGridToken(grid)) { out += `${grid}`; } else { out += escapeHtml(token); } i = j; } else { out += escapeHtml(ch); i += 1; } } return out; } function extractAllGrids(message) { const out = []; const seen = new Set(); let i = 0; while (i < message.length) { if (isAlphaNum(message[i])) { let j = i + 1; while (j < message.length && isAlphaNum(message[j])) j++; const token = message.slice(i, j); const grid = token.toUpperCase(); if (isMaidenheadGridToken(grid) && !seen.has(grid)) { seen.add(grid); out.push(grid); } i = j; } else { i += 1; } } return out; } function extractLikelyCallsign(message) { const tokens = String(message || "") .toUpperCase() .split(/[^A-Z0-9/]+/) .filter(Boolean); if (tokens.length === 0) return null; const head = tokens[0]; if (head === "CQ" || head === "DE" || head === "QRZ") { if (isLikelyCallsignToken(tokens[1])) return tokens[1]; for (let i = 1; i < tokens.length; i += 1) { if (isLikelyCallsignToken(tokens[i])) return tokens[i]; } return null; } // Directed messages are usually "