[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:
@@ -85,6 +85,7 @@
|
|||||||
<div id="vdes-bar-overlay" aria-live="polite" aria-label="Recent VDES bursts"></div>
|
<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="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="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>
|
||||||
<div id="spectrum-panel" style="display:none;">
|
<div id="spectrum-panel" style="display:none;">
|
||||||
<div class="spectrum-wrap">
|
<div class="spectrum-wrap">
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
|||||||
: null;
|
: null;
|
||||||
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
|
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
const CW_MAX_LINES = 200;
|
const CW_MAX_LINES = 200;
|
||||||
const CW_TONE_MIN_HZ = 100;
|
const CW_TONE_MIN_HZ = 100;
|
||||||
const CW_TONE_MAX_HZ = 10_000;
|
const CW_TONE_MAX_HZ = 10_000;
|
||||||
const CW_WPM_MIN = 5;
|
const CW_WPM_MIN = 5;
|
||||||
const CW_WPM_MAX = 40;
|
const CW_WPM_MAX = 40;
|
||||||
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const CW_BAR_LINE_GAP_MS = 5000;
|
||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf = null;
|
let cwTonePickerRaf = null;
|
||||||
let cwPaused = false;
|
let cwPaused = false;
|
||||||
let cwBufferedWhilePaused = 0;
|
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
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||||
@@ -50,6 +55,52 @@ window.applyCwAutoUiFromServer = function(enabled) {
|
|||||||
applyCwAutoUi(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) {
|
function clampCwWpm(wpm) {
|
||||||
const numeric = Number(wpm);
|
const numeric = Number(wpm);
|
||||||
if (!Number.isFinite(numeric)) return 15;
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
@@ -291,7 +342,10 @@ window.resetCwHistoryView = function() {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBufferedWhilePaused = 0;
|
cwBufferedWhilePaused = 0;
|
||||||
|
cwBarHistory = [];
|
||||||
|
cwBarCurrentLine = null;
|
||||||
updateCwPauseUi();
|
updateCwPauseUi();
|
||||||
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,6 +385,24 @@ window.onServerCw = function(evt) {
|
|||||||
}
|
}
|
||||||
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
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) {
|
if (cwSignalIndicator) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
@@ -371,5 +443,6 @@ window.addEventListener("resize", () => {
|
|||||||
});
|
});
|
||||||
applyCwAutoUi(!!cwAutoInput?.checked);
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
updateCwPauseUi();
|
updateCwPauseUi();
|
||||||
|
updateCwBar();
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
|
|||||||
Reference in New Issue
Block a user