[feat](trx-frontend-http): add CW live bar overlay

Show a live decode bar on the overview strip when in CW/CWR mode,
matching the APRS and AIS bar pattern. Accumulates decoded characters
into lines (split on newline events or >5s gaps), keeps a 15-minute
rolling history, and shows up to 8 recent lines with timestamp and
WPM/tone metadata. Clears on resetCwHistoryView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-06 22:28:41 +01:00
parent 175abe35f9
commit 4272094882
2 changed files with 74 additions and 0 deletions
@@ -85,6 +85,7 @@
<div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div>
<div id="ft8-bar-overlay" aria-live="polite" aria-label="Recent FT8 decodes"></div>
<div id="aprs-bar-overlay" aria-live="polite" aria-label="Recent APRS frames"></div>
<div id="cw-bar-overlay" aria-live="polite" aria-label="Recent CW decodes"></div>
</div>
<div id="spectrum-panel" style="display:none;">
<div class="spectrum-wrap">
@@ -12,15 +12,20 @@ const cwToneGl = typeof createTrxWebGlRenderer === "function"
: null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range");
const cwBarOverlay = document.getElementById("cw-bar-overlay");
const CW_MAX_LINES = 200;
const CW_TONE_MIN_HZ = 100;
const CW_TONE_MAX_HZ = 10_000;
const CW_WPM_MIN = 5;
const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
let cwPaused = false;
let cwBufferedWhilePaused = 0;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
let cwBarCurrentLine = null; // accumulates chars until gap/newline
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
@@ -50,6 +55,52 @@ window.applyCwAutoUiFromServer = function(enabled) {
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
if (!isCw || recent.length === 0) {
cwBarOverlay.style.display = "none";
return;
}
let html =
'<div class="aprs-bar-header">' +
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
' onclick="window.clearCwBar()"' +
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
' aria-label="Clear CW overlay">Clear</span></span>' +
'<span class="aprs-bar-window">Last 15 minutes</span>' +
'</div>';
for (const line of recent.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [
line.wpm ? `${line.wpm} WPM` : null,
line.tone_hz ? `${line.tone_hz} Hz` : null,
].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
`</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
window.updateCwBar = updateCwBar;
window.clearCwBar = function() {
document.getElementById("cw-clear-btn")?.click();
};
function clampCwWpm(wpm) {
const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15;
@@ -291,7 +342,10 @@ window.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBufferedWhilePaused = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
updateCwPauseUi();
updateCwBar();
drawCwTonePicker();
};
@@ -331,6 +385,24 @@ window.onServerCw = function(evt) {
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
// Bar history accumulation (regardless of pause state)
if (evt.text) {
const now = Date.now();
if (evt.text === "\n") {
cwBarFlushCurrentLine();
} else {
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
cwBarFlushCurrentLine();
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
}
cwBarCurrentLine.text += evt.text;
cwBarCurrentLine.lastMs = now;
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
}
updateCwBar();
}
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
@@ -371,5 +443,6 @@ window.addEventListener("resize", () => {
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwPauseUi();
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();