Complete TypeScript frontend migration #22

Merged
sjg merged 51 commits from feat/typescript-frontend-migration into main 2026-08-01 23:06:19 +02:00
4 changed files with 588 additions and 477 deletions
Showing only changes of commit 03bdc10b02 - Show all commits
@@ -1,29 +1,35 @@
"use strict";
const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto");
const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone");
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
const cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range");
const cwBarOverlay = document.getElementById("cw-bar-overlay");
const CW_MAX_LINES = 200;
const CW_TONE_MIN_HZ = 100;
const CW_TONE_MAX_HZ = 1e4;
const CW_WPM_MIN = 5;
const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
const CW_BAR_LINE_GAP_MS = 5e3;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
let cwBarHistory = [];
let cwBarCurrentLine = null;
let cwBarDismissedAtMs = 0;
let cwAutoLocalOverride = null;
function applyCwAutoUi(enabled) {
(() => {
// src/plugins/cw.ts
var cwWindow = window;
var cwStatusEl = document.getElementById("cw-status");
var cwOutputEl = document.getElementById("cw-output");
var cwAutoInput = document.getElementById("cw-auto");
var cwWpmInput = document.getElementById("cw-wpm");
var cwToneInput = document.getElementById("cw-tone");
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
var cwToneRangeEl = document.getElementById("cw-tone-range");
var cwBarOverlay = document.getElementById("cw-bar-overlay");
var CW_MAX_LINES = 200;
var CW_TONE_MIN_HZ = 100;
var CW_TONE_MAX_HZ = 1e4;
var CW_WPM_MIN = 5;
var CW_WPM_MAX = 40;
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
var CW_BAR_LINE_GAP_MS = 5e3;
var cwLastAppendTime = 0;
var cwTonePickerRaf = null;
var cwBarHistory = [];
var cwBarCurrentLine = null;
var cwBarDismissedAtMs = 0;
var cwAutoLocalOverride = null;
function escapeCwHtml(input) {
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function applyCwAutoUi(enabled) {
if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) {
cwWpmInput.disabled = enabled;
@@ -36,27 +42,27 @@ function applyCwAutoUi(enabled) {
if (cwTonePickerEl) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
window.applyCwAutoUi = applyCwAutoUi;
window.applyCwAutoUiFromServer = function(enabled) {
}
cwWindow.applyCwAutoUi = applyCwAutoUi;
cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
};
function cwBarFlushCurrentLine() {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
}
cwBarCurrentLine = null;
}
function updateCwBar() {
}
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);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
@@ -66,50 +72,47 @@ function updateCwBar() {
for (const line of liveLines.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
line.wpm ? `${String(line.wpm)} WPM` : null,
line.tone_hz ? `${String(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>`;
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
window.updateCwBar = updateCwBar;
window.clearCwBar = function() {
window.resetCwHistoryView();
};
window.closeCwBar = function() {
}
cwWindow.updateCwBar = updateCwBar;
cwWindow.clearCwBar = function() {
cwWindow.resetCwHistoryView?.();
};
cwWindow.closeCwBar = function() {
cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
}
};
function clampCwWpm(wpm) {
};
function clampCwWpm(wpm) {
const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
}
function clampCwTone(tone) {
}
function clampCwTone(tone) {
const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
}
function currentCwToneRange() {
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
}
function currentCwToneRange() {
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const lowerSideband = mode === "CWR";
const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ;
if (toneMaxHz < toneMinHz) {
return null;
}
return {
tunedHz,
bandwidthHz,
@@ -119,17 +122,17 @@ function currentCwToneRange() {
lowerSideband,
mode
};
}
function cwToneToRfHz(range, toneHz) {
}
function cwToneToRfHz(range, toneHz) {
if (!range) return NaN;
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
}
function toneClampForRange(tone, range) {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
}
function ensureCwToneCanvasResolution() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
@@ -139,8 +142,8 @@ function ensureCwToneCanvasResolution() {
}
const dpr = window.devicePixelRatio || 1;
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
}
function drawCwTonePicker() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
@@ -148,12 +151,12 @@ function drawCwTonePicker() {
const height = cwToneCanvas.height;
cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange();
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
@@ -162,11 +165,11 @@ function drawCwTonePicker() {
}
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
}
const bins = window.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate);
const centerHz = Number(window.lastSpectrumData.center_hz);
const bins = cwWindow.lastSpectrumData.bins;
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
const centerHz = cwWindow.lastSpectrumData.center_hz;
const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(width).fill(-140);
@@ -185,10 +188,10 @@ function drawCwTonePicker() {
let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue;
sum += tones[i];
sum += tones[i] ?? -140;
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x];
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
}
const sorted = smoothed.slice().sort((a, b) => a - b);
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
@@ -202,7 +205,7 @@ function drawCwTonePicker() {
};
const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
const parseColor = typeof cwWindow.trxParseCssColor === "function" ? cwWindow.trxParseCssColor : null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
@@ -224,68 +227,71 @@ function drawCwTonePicker() {
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x]));
linePoints.push(x, yForDb(smoothed[x] ?? -140));
}
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
if (cwAutoInput?.checked) {
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
}
}
async function setCwTone(tone, { syncInput = true } = {}) {
}
async function setCwTone(tone, { syncInput = true } = {}) {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = clamped;
cwToneInput.value = String(clamped);
}
try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
drawCwTonePicker();
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", () => {
void (async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
} catch (error) {
console.error("CW auto toggle failed", error);
} finally {
cwAutoLocalOverride = null;
}
})();
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = wpm;
try {
await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
} catch (e) {
console.error("CW WPM set failed", e);
}
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
if (cwWpmInput) {
cwWpmInput.addEventListener("change", () => {
void (async () => {
if (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value);
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = String(wpm);
try {
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
} catch (error) {
console.error("CW WPM set failed", error);
}
})();
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => {
}
if (cwToneInput) {
cwToneInput.addEventListener("change", () => {
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
@@ -293,27 +299,29 @@ if (cwToneCanvas) {
if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz;
await setCwTone(tone);
void setCwTone(tone);
});
}
window.resetCwHistoryView = function() {
}
cwWindow.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
cwBarCurrentLine = null;
updateCwBar();
drawCwTonePicker();
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
} catch (e) {
console.error("CW history clear failed", e);
await cwWindow.postPath?.("/clear_cw_decode");
cwWindow.resetCwHistoryView?.();
} catch (error) {
console.error("CW history clear failed", error);
}
});
window.onServerCw = function(evt) {
})();
});
cwWindow.onServerCw = function(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const now = Date.now();
@@ -328,7 +336,9 @@ window.onServerCw = function(evt) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
cwOutputEl.removeChild(cwOutputEl.firstChild);
const firstChild = cwOutputEl.firstChild;
if (!firstChild) break;
cwOutputEl.removeChild(firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
@@ -354,10 +364,10 @@ window.onServerCw = function(evt) {
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = clampCwWpm(evt.wpm);
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
}
}
if (cwTonePickerRaf != null) return;
@@ -365,22 +375,23 @@ window.onServerCw = function(evt) {
cwTonePickerRaf = null;
drawCwTonePicker();
});
};
window.restoreCwHistory = function(events) {
};
cwWindow.restoreCwHistory = function(events) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
window.onServerCw(evt);
cwWindow.onServerCw?.(evt);
}
};
window.refreshCwTonePicker = function refreshCwTonePicker() {
};
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
window.addEventListener("resize", () => {
};
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
})();
@@ -26,7 +26,6 @@ await build({
aprs: path.join(sourceDir, "plugins", "aprs.js"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
cw: path.join(sourceDir, "plugins", "cw.js"),
ft8: path.join(sourceDir, "plugins", "ft8.js"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
sat: path.join(sourceDir, "plugins", "sat.js"),
@@ -50,6 +49,7 @@ await build({
ft2: path.join(sourceDir, "plugins", "ft2.ts"),
ft4: path.join(sourceDir, "plugins", "ft4.ts"),
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
},
outdir: outputDir,
bundle: true,
@@ -2,16 +2,57 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
export {};
type Rgba = [number, number, number, number];
interface CwRenderer {
ready: boolean;
ensureSize(width: number, height: number, dpr: number): boolean;
clear(color: Rgba): void;
fillRect(x: number, y: number, width: number, height: number, color: Rgba): void;
drawSegments(points: number[], color: Rgba, width: number): void;
drawFilledArea(points: number[], baseline: number, color: Rgba): void;
drawPolyline(points: number[], color: Rgba, width: number): void;
drawPoints(points: number[], size: number, color: Rgba): void;
}
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
interface CwToneRange {
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
toneSpanHz: number; lowerSideband: boolean; mode: string;
}
interface CwBridge {
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options: WebGLContextAttributes) => CwRenderer;
trxParseCssColor?: (color: string) => Rgba;
lastFreqHz?: number;
currentBandwidthHz?: number;
lastSpectrumData?: CwSpectrum;
escapeMapHtml?: (input: string) => string;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
applyCwAutoUi?: (enabled: boolean) => void;
applyCwAutoUiFromServer?: (enabled: boolean) => void;
updateCwBar?: () => void;
clearCwBar?: () => void;
closeCwBar?: () => void;
resetCwHistoryView?: () => void;
onServerCw?: (event: CwEvent) => void;
restoreCwHistory?: (events: CwEvent[]) => void;
refreshCwTonePicker?: () => void;
}
const cwWindow = window as unknown as CwBridge;
// --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto");
const cwWpmInput = document.getElementById("cw-wpm");
const cwToneInput = document.getElementById("cw-tone");
const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
const cwToneGl = typeof createTrxWebGlRenderer === "function"
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
: null;
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
const cwToneRangeEl = document.getElementById("cw-tone-range");
@@ -24,17 +65,23 @@ const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0;
let cwTonePickerRaf = null;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
let cwBarCurrentLine = null; // accumulates chars until gap/newline
let cwTonePickerRaf: number | null = null;
let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine: CwLine | null = null;
let cwBarDismissedAtMs = 0;
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
// acknowledged). While set, server-state updates must not override the
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
// does not immediately undo the user's choice.
let cwAutoLocalOverride = null;
let cwAutoLocalOverride: boolean | null = null;
function applyCwAutoUi(enabled) {
function escapeCwHtml(input: string): string {
return cwWindow.escapeMapHtml?.(input) ?? input
.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
.replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
function applyCwAutoUi(enabled: boolean): void {
if (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) {
cwWpmInput.disabled = enabled;
@@ -48,16 +95,16 @@ function applyCwAutoUi(enabled) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
window.applyCwAutoUi = applyCwAutoUi;
cwWindow.applyCwAutoUi = applyCwAutoUi;
// Called by app.js render() when a server-state snapshot arrives. Ignores
// the update while cwAutoLocalOverride is set (user change still in-flight).
window.applyCwAutoUiFromServer = function(enabled) {
cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled);
};
function cwBarFlushCurrentLine() {
function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
@@ -65,15 +112,15 @@ function cwBarFlushCurrentLine() {
cwBarCurrentLine = null;
}
function updateCwBar() {
function updateCwBar(): void {
if (!cwBarOverlay) return;
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
@@ -94,22 +141,22 @@ function updateCwBar() {
for (const line of liveLines.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,
line.wpm ? `${String(line.wpm)} WPM` : null,
line.tone_hz ? `${String(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 class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
`</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
window.updateCwBar = updateCwBar;
window.clearCwBar = function() {
window.resetCwHistoryView();
cwWindow.updateCwBar = updateCwBar;
cwWindow.clearCwBar = function() {
cwWindow.resetCwHistoryView?.();
};
window.closeCwBar = function() {
cwWindow.closeCwBar = function() {
cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) {
cwBarOverlay.style.display = "none";
@@ -117,34 +164,31 @@ window.closeCwBar = function() {
}
};
function clampCwWpm(wpm) {
function clampCwWpm(wpm: unknown): number {
const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
}
function clampCwTone(tone) {
function clampCwTone(tone: unknown): number {
const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
}
function currentCwToneRange() {
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
function currentCwToneRange(): CwToneRange | null {
const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
const lowerSideband = mode === "CWR";
const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ;
if (toneMaxHz < toneMinHz) {
return null;
}
return {
tunedHz,
bandwidthHz,
@@ -156,20 +200,20 @@ function currentCwToneRange() {
};
}
function cwToneToRfHz(range, toneHz) {
function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
if (!range) return NaN;
return range.lowerSideband
? range.tunedHz - toneHz
: range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
function ensureCwToneCanvasResolution(): boolean {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
@@ -181,7 +225,7 @@ function ensureCwToneCanvasResolution() {
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
function drawCwTonePicker(): void {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
@@ -190,12 +234,12 @@ function drawCwTonePicker() {
cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange();
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
@@ -205,15 +249,15 @@ function drawCwTonePicker() {
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
}
const bins = window.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate);
const centerHz = Number(window.lastSpectrumData.center_hz);
const bins = cwWindow.lastSpectrumData.bins;
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
const centerHz = cwWindow.lastSpectrumData.center_hz;
const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(width).fill(-140);
const tones: number[] = new Array<number>(width).fill(-140);
for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
@@ -223,17 +267,17 @@ function drawCwTonePicker() {
tones[x] = power;
}
const smoothed = new Array(width).fill(-140);
const smoothed: number[] = new Array<number>(width).fill(-140);
const smoothRadius = Math.max(1, Math.round(width / 180));
for (let x = 0; x < width; x += 1) {
let sum = 0;
let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue;
sum += tones[i];
sum += tones[i] ?? -140;
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x];
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
}
const sorted = smoothed.slice().sort((a, b) => a - b);
@@ -242,23 +286,23 @@ function drawCwTonePicker() {
const floorDb = Math.min(q20 - 2, q95 - 10);
const ceilDb = Math.max(floorDb + 18, q95 + 2);
const dbSpan = Math.max(1, ceilDb - floorDb);
const yForDb = (db) => {
const yForDb = (db: number): number => {
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
return Math.round((1 - n) * (height - 1));
};
const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof window.trxParseCssColor === "function"
? window.trxParseCssColor
const parseColor = typeof cwWindow.trxParseCssColor === "function"
? cwWindow.trxParseCssColor
: null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor: Rgba = [230 / 255, 235 / 255, 245 / 255, 0.15];
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
const hGridCount = 4;
const gridSegments = [];
const gridSegments: number[] = [];
for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
gridSegments.push(0, y, width, y);
@@ -267,7 +311,7 @@ function drawCwTonePicker() {
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = [];
const tickSegments: number[] = [];
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
@@ -275,9 +319,9 @@ function drawCwTonePicker() {
}
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
const linePoints: number[] = [];
for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x]));
linePoints.push(x, yForDb(smoothed[x] ?? -140));
}
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
@@ -285,7 +329,7 @@ function drawCwTonePicker() {
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))] ?? -140);
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
@@ -294,14 +338,14 @@ function drawCwTonePicker() {
}
}
async function setCwTone(tone, { syncInput = true } = {}) {
async function setCwTone(tone: unknown, { syncInput = true }: { syncInput?: boolean } = {}): Promise<void> {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = clamped;
cwToneInput.value = String(clamped);
}
try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
@@ -309,40 +353,43 @@ async function setCwTone(tone, { syncInput = true } = {}) {
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
cwAutoInput.addEventListener("change", () => {
void (async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
} catch (error: unknown) {
console.error("CW auto toggle failed", error);
} finally {
cwAutoLocalOverride = null;
}
})();
});
}
if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => {
if (cwAutoInput && cwAutoInput.checked) return;
cwWpmInput.addEventListener("change", () => {
void (async () => {
if (cwAutoInput?.checked) return;
const wpm = clampCwWpm(cwWpmInput.value);
cwWpmInput.value = wpm;
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (e) { console.error("CW WPM set failed", e); }
cwWpmInput.value = String(wpm);
try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (error: unknown) { console.error("CW WPM set failed", error); }
})();
});
}
if (cwToneInput) {
cwToneInput.addEventListener("change", async () => {
if (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value);
cwToneInput.addEventListener("change", () => {
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => {
cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
@@ -350,11 +397,11 @@ if (cwToneCanvas) {
if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz;
await setCwTone(tone);
void setCwTone(tone);
});
}
window.resetCwHistoryView = function() {
cwWindow.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0;
cwBarHistory = [];
@@ -363,18 +410,20 @@ window.resetCwHistoryView = function() {
drawCwTonePicker();
};
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
void (async () => {
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
} catch (e) {
console.error("CW history clear failed", e);
await cwWindow.postPath?.("/clear_cw_decode");
cwWindow.resetCwHistoryView?.();
} catch (error: unknown) {
console.error("CW history clear failed", error);
}
})();
});
// --- Server-side CW decode handler ---
window.onServerCw = function(evt) {
cwWindow.onServerCw = function(evt: CwEvent) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
// Append decoded text to output
@@ -390,7 +439,9 @@ window.onServerCw = function(evt) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
cwOutputEl.removeChild(cwOutputEl.firstChild);
const firstChild = cwOutputEl.firstChild;
if (!firstChild) break;
cwOutputEl.removeChild(firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
@@ -417,10 +468,10 @@ window.onServerCw = function(evt) {
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = clampCwWpm(evt.wpm);
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
}
}
if (cwTonePickerRaf != null) return;
@@ -430,15 +481,15 @@ window.onServerCw = function(evt) {
});
};
window.restoreCwHistory = function(events) {
cwWindow.restoreCwHistory = function(events: CwEvent[]) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
window.onServerCw(evt);
cwWindow.onServerCw?.(evt);
}
};
window.refreshCwTonePicker = function refreshCwTonePicker() {
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
class ElementFixture {
constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; }
appendChild(child) { this.children.push(child); return child; }
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
get firstChild() { return this.children[0] ?? null; }
get lastElementChild() { return this.children.at(-1) ?? null; }
get scrollHeight() { return this.children.length; }
}
test("CW entry appends server-decoded text and registers lifecycle callbacks", async () => {
const output = new ElementFixture();
const status = new ElementFixture();
const elements = new Map([["cw-output", output], ["cw-status", status]]);
const window = { trxUi: { confirm: async () => true }, addEventListener() {} };
const context = vm.createContext({
window,
document: {
documentElement: {},
getElementById: (id) => elements.get(id) ?? null,
querySelector: () => null,
createElement: () => new ElementFixture(),
},
requestAnimationFrame(callback) { callback(); return 1; },
getComputedStyle: () => ({ getPropertyValue: () => "" }),
Date,
Number,
String,
Math,
console,
});
const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context);
window.onServerCw({ text: "CQ", wpm: 18, tone_hz: 700, signal_on: true });
assert.equal(status.textContent, "Receiving");
assert.equal(output.children.length, 1);
assert.equal(output.children[0].textContent, "CQ");
assert.equal(typeof window.resetCwHistoryView, "function");
assert.equal(typeof window.restoreCwHistory, "function");
});