refactor: convert CW plugin to TypeScript

This commit is contained in:
sjg
2026-08-01 12:18:51 +02:00
parent d96624be4f
commit 03bdc10b02
4 changed files with 588 additions and 477 deletions
@@ -1,386 +1,397 @@
"use strict"; "use strict";
const cwStatusEl = document.getElementById("cw-status"); (() => {
const cwOutputEl = document.getElementById("cw-output"); // src/plugins/cw.ts
const cwAutoInput = document.getElementById("cw-auto"); var cwWindow = window;
const cwWpmInput = document.getElementById("cw-wpm"); var cwStatusEl = document.getElementById("cw-status");
const cwToneInput = document.getElementById("cw-tone"); var cwOutputEl = document.getElementById("cw-output");
const cwSignalIndicator = document.getElementById("cw-signal-indicator"); var cwAutoInput = document.getElementById("cw-auto");
const cwToneCanvas = document.getElementById("cw-tone-waterfall"); var cwWpmInput = document.getElementById("cw-wpm");
const cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; var cwToneInput = document.getElementById("cw-tone");
const cwTonePickerEl = document.querySelector(".cw-tone-picker"); var cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneRangeEl = document.getElementById("cw-tone-range"); var cwToneCanvas = document.getElementById("cw-tone-waterfall");
const cwBarOverlay = document.getElementById("cw-bar-overlay"); var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
const CW_MAX_LINES = 200; var cwTonePickerEl = document.querySelector(".cw-tone-picker");
const CW_TONE_MIN_HZ = 100; var cwToneRangeEl = document.getElementById("cw-tone-range");
const CW_TONE_MAX_HZ = 1e4; var cwBarOverlay = document.getElementById("cw-bar-overlay");
const CW_WPM_MIN = 5; var CW_MAX_LINES = 200;
const CW_WPM_MAX = 40; var CW_TONE_MIN_HZ = 100;
const CW_BAR_WINDOW_MS = 15 * 60 * 1e3; var CW_TONE_MAX_HZ = 1e4;
const CW_BAR_LINE_GAP_MS = 5e3; var CW_WPM_MIN = 5;
let cwLastAppendTime = 0; var CW_WPM_MAX = 40;
let cwTonePickerRaf = null; var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
let cwBarHistory = []; var CW_BAR_LINE_GAP_MS = 5e3;
let cwBarCurrentLine = null; var cwLastAppendTime = 0;
let cwBarDismissedAtMs = 0; var cwTonePickerRaf = null;
let cwAutoLocalOverride = null; var cwBarHistory = [];
function applyCwAutoUi(enabled) { var cwBarCurrentLine = null;
if (cwAutoInput) cwAutoInput.checked = enabled; 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;
cwWpmInput.readOnly = enabled;
}
if (cwToneInput) {
cwToneInput.disabled = enabled;
cwToneInput.readOnly = enabled;
}
if (cwTonePickerEl) {
cwTonePickerEl.classList.toggle("is-auto", enabled);
}
}
cwWindow.applyCwAutoUi = applyCwAutoUi;
cwWindow.applyCwAutoUiFromServer = function(enabled) {
if (cwAutoLocalOverride !== null) return;
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);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
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 = "";
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-actions"><span class="aprs-bar-window">Last 15 minutes</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><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">&times;</button></span></div>`;
for (const line of liveLines.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [
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}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
}
cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex";
}
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) {
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) {
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(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 = (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;
return {
tunedHz,
bandwidthHz,
toneMinHz,
toneMaxHz,
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
lowerSideband,
mode
};
}
function cwToneToRfHz(range, toneHz) {
if (!range) return NaN;
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
const cssHeight = Math.round(rect.height);
if (cssWidth < 8 || cssHeight < 8) {
return false;
}
const dpr = window.devicePixelRatio || 1;
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
const width = cwToneCanvas.width;
const height = cwToneCanvas.height;
cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange();
if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
if (cwToneRangeEl) {
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required";
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
return;
}
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
}
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);
for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
const rfHz = cwToneToRfHz(range, toneHz);
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
tones[x] = power;
}
const smoothed = new Array(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] ?? -140;
count += 1;
}
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;
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
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 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 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]);
const hGridCount = 4;
const gridSegments = [];
for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round(i / (hGridCount + 1) * (height - 1));
gridSegments.push(0, y, width, y);
}
cwToneGl.drawSegments(gridSegments, axisColor, 1);
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = [];
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))));
tickSegments.push(x, 0, x, height);
}
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
for (let x = 0; x < width; x += 1) {
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))] ?? -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 } = {}) {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = String(clamped);
}
try {
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", () => {
void (async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (error) {
console.error("CW auto toggle failed", error);
} finally {
cwAutoLocalOverride = null;
}
})();
});
}
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.disabled = enabled; cwWpmInput.addEventListener("change", () => {
cwWpmInput.readOnly = enabled; void (async () => {
if (cwAutoInput?.checked) return;
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 (cwToneInput) { if (cwToneInput) {
cwToneInput.disabled = enabled; cwToneInput.addEventListener("change", () => {
cwToneInput.readOnly = enabled; if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
});
} }
if (cwTonePickerEl) { if (cwToneCanvas) {
cwTonePickerEl.classList.toggle("is-auto", enabled); cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
const range = currentCwToneRange();
if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz;
void setCwTone(tone);
});
} }
} cwWindow.resetCwHistoryView = function() {
window.applyCwAutoUi = applyCwAutoUi; if (cwOutputEl) cwOutputEl.innerHTML = "";
window.applyCwAutoUiFromServer = function(enabled) { cwLastAppendTime = 0;
if (cwAutoLocalOverride !== null) return; cwBarHistory = [];
applyCwAutoUi(enabled); cwBarCurrentLine = null;
};
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);
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
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-actions"><span class="aprs-bar-window">Last 15 minutes</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><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">&times;</button></span></div>`;
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
].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() {
window.resetCwHistoryView();
};
window.closeCwBar = function() {
cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) {
cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = "";
}
};
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) {
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;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null;
}
const mode = String(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,
toneMinHz,
toneMaxHz,
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
lowerSideband,
mode
};
}
function cwToneToRfHz(range, toneHz) {
if (!range) return NaN;
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
}
function toneClampForRange(tone, range) {
const clamped = clampCwTone(tone);
if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
}
function ensureCwToneCanvasResolution() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width);
const cssHeight = Math.round(rect.height);
if (cssWidth < 8 || cssHeight < 8) {
return false;
}
const dpr = window.devicePixelRatio || 1;
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
}
function drawCwTonePicker() {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
const width = cwToneCanvas.width;
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 (cwToneRangeEl) {
const mode = String(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) {
cwToneRangeEl.textContent = "Waiting for spectrum";
}
}
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
return;
}
if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side";
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
}
const bins = window.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate);
const centerHz = Number(window.lastSpectrumData.center_hz);
const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2;
const tones = new Array(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;
const rfHz = cwToneToRfHz(range, toneHz);
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
tones[x] = power;
}
const smoothed = new Array(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];
count += 1;
}
smoothed[x] = count > 0 ? sum / count : tones[x];
}
const sorted = smoothed.slice().sort((a, b) => a - b);
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
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 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 : 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]);
const hGridCount = 4;
const gridSegments = [];
for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round(i / (hGridCount + 1) * (height - 1));
gridSegments.push(0, y, width, y);
}
cwToneGl.drawSegments(gridSegments, axisColor, 1);
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = [];
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))));
tickSegments.push(x, 0, x, height);
}
cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = [];
for (let x = 0; x < width; x += 1) {
linePoints.push(x, yForDb(smoothed[x]));
}
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))]);
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 } = {}) {
const range = currentCwToneRange();
const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) {
cwToneInput.value = clamped;
}
try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) {
console.error("CW tone set failed", e);
}
drawCwTonePicker();
}
if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => {
const enabled = cwAutoInput.checked;
cwAutoLocalOverride = enabled;
applyCwAutoUi(enabled);
try {
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
drawCwTonePicker();
} catch (e) {
console.error("CW auto toggle failed", e);
} 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 (cwAutoInput?.checked) return;
await setCwTone(cwToneInput.value);
});
}
if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => {
if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return;
const range = currentCwToneRange();
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);
});
}
window.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;
try {
await postPath("/clear_cw_decode");
window.resetCwHistoryView();
} catch (e) {
console.error("CW history clear failed", e);
}
});
window.onServerCw = function(evt) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) {
const now = Date.now();
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
const line = document.createElement("div");
line.className = "cw-line";
cwOutputEl.appendChild(line);
}
cwLastAppendTime = now;
const lastLine = cwOutputEl.lastElementChild;
if (lastLine) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
cwOutputEl.removeChild(cwOutputEl.firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
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(); updateCwBar();
}
if (cwSignalIndicator) {
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = clampCwWpm(evt.wpm);
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
}
}
if (cwTonePickerRaf != null) return;
cwTonePickerRaf = requestAnimationFrame(() => {
cwTonePickerRaf = null;
drawCwTonePicker(); drawCwTonePicker();
};
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 cwWindow.postPath?.("/clear_cw_decode");
cwWindow.resetCwHistoryView?.();
} catch (error) {
console.error("CW history clear failed", error);
}
})();
}); });
}; cwWindow.onServerCw = function(evt) {
window.restoreCwHistory = function(events) { if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (!Array.isArray(events) || events.length === 0) return; if (evt.text && cwOutputEl) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; const now = Date.now();
for (const evt of events) { if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
window.onServerCw(evt); const line = document.createElement("div");
} line.className = "cw-line";
}; cwOutputEl.appendChild(line);
window.refreshCwTonePicker = function refreshCwTonePicker() { }
cwLastAppendTime = now;
const lastLine = cwOutputEl.lastElementChild;
if (lastLine) {
lastLine.textContent += evt.text;
}
while (cwOutputEl.children.length > CW_MAX_LINES) {
const firstChild = cwOutputEl.firstChild;
if (!firstChild) break;
cwOutputEl.removeChild(firstChild);
}
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
}
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";
}
if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
cwWpmInput.value = String(clampCwWpm(evt.wpm));
}
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
}
}
if (cwTonePickerRaf != null) return;
cwTonePickerRaf = requestAnimationFrame(() => {
cwTonePickerRaf = null;
drawCwTonePicker();
});
};
cwWindow.restoreCwHistory = function(events) {
if (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) {
cwWindow.onServerCw?.(evt);
}
};
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution();
drawCwTonePicker();
};
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
drawCwTonePicker(); drawCwTonePicker();
}; })();
window.addEventListener("resize", () => {
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
});
applyCwAutoUi(!!cwAutoInput?.checked);
updateCwBar();
ensureCwToneCanvasResolution();
drawCwTonePicker();
@@ -26,7 +26,6 @@ await build({
aprs: path.join(sourceDir, "plugins", "aprs.js"), aprs: path.join(sourceDir, "plugins", "aprs.js"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"), "background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
cw: path.join(sourceDir, "plugins", "cw.js"),
ft8: path.join(sourceDir, "plugins", "ft8.js"), ft8: path.join(sourceDir, "plugins", "ft8.js"),
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
sat: path.join(sourceDir, "plugins", "sat.js"), sat: path.join(sourceDir, "plugins", "sat.js"),
@@ -50,6 +49,7 @@ await build({
ft2: path.join(sourceDir, "plugins", "ft2.ts"), ft2: path.join(sourceDir, "plugins", "ft2.ts"),
ft4: path.join(sourceDir, "plugins", "ft4.ts"), ft4: path.join(sourceDir, "plugins", "ft4.ts"),
wspr: path.join(sourceDir, "plugins", "wspr.ts"), wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -2,16 +2,57 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // 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) --- // --- CW (Morse) Decoder Plugin (server-side decode) ---
const cwStatusEl = document.getElementById("cw-status"); const cwStatusEl = document.getElementById("cw-status");
const cwOutputEl = document.getElementById("cw-output"); const cwOutputEl = document.getElementById("cw-output");
const cwAutoInput = document.getElementById("cw-auto"); const cwAutoInput = document.getElementById("cw-auto") as HTMLInputElement | null;
const cwWpmInput = document.getElementById("cw-wpm"); const cwWpmInput = document.getElementById("cw-wpm") as HTMLInputElement | null;
const cwToneInput = document.getElementById("cw-tone"); const cwToneInput = document.getElementById("cw-tone") as HTMLInputElement | null;
const cwSignalIndicator = document.getElementById("cw-signal-indicator"); const cwSignalIndicator = document.getElementById("cw-signal-indicator");
const cwToneCanvas = document.getElementById("cw-tone-waterfall"); const cwToneCanvas = document.getElementById("cw-tone-waterfall") as HTMLCanvasElement | null;
const cwToneGl = typeof createTrxWebGlRenderer === "function" const cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
: 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");
@@ -24,17 +65,23 @@ const CW_WPM_MAX = 40;
const CW_BAR_WINDOW_MS = 15 * 60 * 1000; const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
const CW_BAR_LINE_GAP_MS = 5000; const CW_BAR_LINE_GAP_MS = 5000;
let cwLastAppendTime = 0; let cwLastAppendTime = 0;
let cwTonePickerRaf = null; let cwTonePickerRaf: number | null = null;
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}] let cwBarHistory: CwLine[] = [];
let cwBarCurrentLine = null; // accumulates chars until gap/newline let cwBarCurrentLine: CwLine | null = null;
let cwBarDismissedAtMs = 0; let cwBarDismissedAtMs = 0;
// 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
// does not immediately undo the user's choice. // 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 (cwAutoInput) cwAutoInput.checked = enabled;
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.disabled = enabled; cwWpmInput.disabled = enabled;
@@ -48,16 +95,16 @@ function applyCwAutoUi(enabled) {
cwTonePickerEl.classList.toggle("is-auto", 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 // Called by app.js render() when a server-state snapshot arrives. Ignores
// the update while cwAutoLocalOverride is set (user change still in-flight). // the update while cwAutoLocalOverride is set (user change still in-flight).
window.applyCwAutoUiFromServer = function(enabled) { cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
if (cwAutoLocalOverride !== null) return; if (cwAutoLocalOverride !== null) return;
applyCwAutoUi(enabled); applyCwAutoUi(enabled);
}; };
function cwBarFlushCurrentLine() { function cwBarFlushCurrentLine(): void {
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
cwBarHistory.unshift(cwBarCurrentLine); cwBarHistory.unshift(cwBarCurrentLine);
if (cwBarHistory.length > 50) cwBarHistory.length = 50; if (cwBarHistory.length > 50) cwBarHistory.length = 50;
@@ -65,15 +112,15 @@ function cwBarFlushCurrentLine() {
cwBarCurrentLine = null; cwBarCurrentLine = null;
} }
function updateCwBar() { function updateCwBar(): void {
if (!cwBarOverlay) return; 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 isCw = mode === "CW" || mode === "CWR";
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
// Prepend the in-progress line so characters appear immediately // Prepend the in-progress line so characters appear immediately
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; 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) { if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
cwBarOverlay.innerHTML = ""; cwBarOverlay.innerHTML = "";
@@ -94,22 +141,22 @@ function updateCwBar() {
for (const line of liveLines.slice(0, 8)) { for (const line of liveLines.slice(0, 8)) {
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : ""; const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
const meta = [ const meta = [
line.wpm ? `${line.wpm} WPM` : null, line.wpm ? `${String(line.wpm)} WPM` : null,
line.tone_hz ? `${line.tone_hz} Hz` : null, line.tone_hz ? `${String(line.tone_hz)} Hz` : null,
].filter(Boolean).join(" · "); ].filter(Boolean).join(" · ");
html += `<div class="aprs-bar-frame">` + html += `<div class="aprs-bar-frame">` +
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` + `<div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` +
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") +
`</div></div>`; `</div></div>`;
} }
cwBarOverlay.innerHTML = html; cwBarOverlay.innerHTML = html;
cwBarOverlay.style.display = "flex"; cwBarOverlay.style.display = "flex";
} }
window.updateCwBar = updateCwBar; cwWindow.updateCwBar = updateCwBar;
window.clearCwBar = function() { cwWindow.clearCwBar = function() {
window.resetCwHistoryView(); cwWindow.resetCwHistoryView?.();
}; };
window.closeCwBar = function() { cwWindow.closeCwBar = function() {
cwBarDismissedAtMs = Date.now(); cwBarDismissedAtMs = Date.now();
if (cwBarOverlay) { if (cwBarOverlay) {
cwBarOverlay.style.display = "none"; cwBarOverlay.style.display = "none";
@@ -117,34 +164,31 @@ window.closeCwBar = function() {
} }
}; };
function clampCwWpm(wpm) { function clampCwWpm(wpm: unknown): number {
const numeric = Number(wpm); const numeric = Number(wpm);
if (!Number.isFinite(numeric)) return 15; if (!Number.isFinite(numeric)) return 15;
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric))); 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); const numeric = Number(tone);
if (!Number.isFinite(numeric)) return 700; if (!Number.isFinite(numeric)) return 700;
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
} }
function currentCwToneRange() { function currentCwToneRange(): CwToneRange | null {
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN; const tunedHz = Number.isFinite(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN; const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
return null; 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 lowerSideband = mode === "CWR";
const upperSideband = mode === "CW"; const upperSideband = mode === "CW";
if (!lowerSideband && !upperSideband) return null; if (!lowerSideband && !upperSideband) return null;
const toneMinHz = CW_TONE_MIN_HZ; const toneMinHz = CW_TONE_MIN_HZ;
const toneMaxHz = CW_TONE_MAX_HZ; const toneMaxHz = CW_TONE_MAX_HZ;
if (toneMaxHz < toneMinHz) {
return null;
}
return { return {
tunedHz, tunedHz,
bandwidthHz, bandwidthHz,
@@ -156,20 +200,20 @@ function currentCwToneRange() {
}; };
} }
function cwToneToRfHz(range, toneHz) { function cwToneToRfHz(range: CwToneRange | null, toneHz: number): number {
if (!range) return NaN; if (!range) return NaN;
return range.lowerSideband return range.lowerSideband
? range.tunedHz - toneHz ? range.tunedHz - toneHz
: range.tunedHz + toneHz; : range.tunedHz + toneHz;
} }
function toneClampForRange(tone, range) { function toneClampForRange(tone: unknown, range: CwToneRange | null): number {
const clamped = clampCwTone(tone); const clamped = clampCwTone(tone);
if (!range) return clamped; if (!range) return clamped;
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
} }
function ensureCwToneCanvasResolution() { function ensureCwToneCanvasResolution(): boolean {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
const rect = cwToneCanvas.getBoundingClientRect(); const rect = cwToneCanvas.getBoundingClientRect();
const cssWidth = Math.round(rect.width); const cssWidth = Math.round(rect.width);
@@ -181,7 +225,7 @@ function ensureCwToneCanvasResolution() {
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
} }
function drawCwTonePicker() { function drawCwTonePicker(): void {
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
@@ -190,12 +234,12 @@ function drawCwTonePicker() {
cwToneGl.clear([0, 0, 0, 0]); cwToneGl.clear([0, 0, 0, 0]);
const range = currentCwToneRange(); 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) { 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") { if (mode !== "CW" && mode !== "CWR") {
cwToneRangeEl.textContent = "CW/CWR mode required"; 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"; cwToneRangeEl.textContent = "Waiting for spectrum";
} }
} }
@@ -205,15 +249,15 @@ function drawCwTonePicker() {
if (cwToneRangeEl) { if (cwToneRangeEl) {
const side = range.lowerSideband ? "Lower side" : "Upper side"; 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 bins = cwWindow.lastSpectrumData.bins;
const sampleRate = Number(window.lastSpectrumData.sample_rate); const sampleRate = cwWindow.lastSpectrumData.sample_rate;
const centerHz = Number(window.lastSpectrumData.center_hz); const centerHz = cwWindow.lastSpectrumData.center_hz;
const maxIdx = Math.max(1, bins.length - 1); const maxIdx = Math.max(1, bins.length - 1);
const fullLoHz = centerHz - sampleRate / 2; 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) { for (let x = 0; x < width; x += 1) {
const frac = width <= 1 ? 0 : x / (width - 1); const frac = width <= 1 ? 0 : x / (width - 1);
const toneHz = range.toneMinHz + frac * range.toneSpanHz; const toneHz = range.toneMinHz + frac * range.toneSpanHz;
@@ -223,17 +267,17 @@ function drawCwTonePicker() {
tones[x] = power; 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)); const smoothRadius = Math.max(1, Math.round(width / 180));
for (let x = 0; x < width; x += 1) { for (let x = 0; x < width; x += 1) {
let sum = 0; let sum = 0;
let count = 0; let count = 0;
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
if (i < 0 || i >= width) continue; if (i < 0 || i >= width) continue;
sum += tones[i]; sum += tones[i] ?? -140;
count += 1; 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 sorted = smoothed.slice().sort((a, b) => a - b);
@@ -242,23 +286,23 @@ function drawCwTonePicker() {
const floorDb = Math.min(q20 - 2, q95 - 10); const floorDb = Math.min(q20 - 2, q95 - 10);
const ceilDb = Math.max(floorDb + 18, q95 + 2); const ceilDb = Math.max(floorDb + 18, q95 + 2);
const dbSpan = Math.max(1, ceilDb - floorDb); 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)); const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
return Math.round((1 - n) * (height - 1)); return Math.round((1 - n) * (height - 1));
}; };
const rootStyle = getComputedStyle(document.documentElement); const rootStyle = getComputedStyle(document.documentElement);
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
const parseColor = typeof window.trxParseCssColor === "function" const parseColor = typeof cwWindow.trxParseCssColor === "function"
? window.trxParseCssColor ? cwWindow.trxParseCssColor
: null; : null;
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; const accentRgba: Rgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; 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]); cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
const hGridCount = 4; const hGridCount = 4;
const gridSegments = []; const gridSegments: number[] = [];
for (let i = 1; i <= hGridCount; i += 1) { for (let i = 1; i <= hGridCount; i += 1) {
const y = Math.round((i / (hGridCount + 1)) * (height - 1)); const y = Math.round((i / (hGridCount + 1)) * (height - 1));
gridSegments.push(0, y, width, y); gridSegments.push(0, y, width, y);
@@ -267,7 +311,7 @@ function drawCwTonePicker() {
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
const tickSegments = []; const tickSegments: number[] = [];
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
const frac = (tone - range.toneMinHz) / range.toneSpanHz; const frac = (tone - range.toneMinHz) / range.toneSpanHz;
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); 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); cwToneGl.drawSegments(tickSegments, axisColor, 1);
const linePoints = []; const linePoints: number[] = [];
for (let x = 0; x < width; x += 1) { 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.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); 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 currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); 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.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]); 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 range = currentCwToneRange();
const clamped = toneClampForRange(tone, range); const clamped = toneClampForRange(tone, range);
if (cwToneInput && syncInput) { if (cwToneInput && syncInput) {
cwToneInput.value = clamped; cwToneInput.value = String(clamped);
} }
try { try {
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
} catch (e) { } catch (e) {
console.error("CW tone set failed", e); console.error("CW tone set failed", e);
} }
@@ -309,40 +353,43 @@ async function setCwTone(tone, { syncInput = true } = {}) {
} }
if (cwAutoInput) { if (cwAutoInput) {
cwAutoInput.addEventListener("change", async () => { cwAutoInput.addEventListener("change", () => {
const enabled = cwAutoInput.checked; void (async () => {
cwAutoLocalOverride = enabled; const enabled = cwAutoInput.checked;
applyCwAutoUi(enabled); cwAutoLocalOverride = enabled;
try { applyCwAutoUi(enabled);
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); try {
drawCwTonePicker(); await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
} catch (e) { drawCwTonePicker();
console.error("CW auto toggle failed", e); } catch (error: unknown) {
} finally { console.error("CW auto toggle failed", error);
cwAutoLocalOverride = null; } finally {
} cwAutoLocalOverride = null;
}
})();
}); });
} }
if (cwWpmInput) { if (cwWpmInput) {
cwWpmInput.addEventListener("change", async () => { cwWpmInput.addEventListener("change", () => {
if (cwAutoInput && cwAutoInput.checked) return; void (async () => {
const wpm = clampCwWpm(cwWpmInput.value); if (cwAutoInput?.checked) return;
cwWpmInput.value = wpm; const wpm = clampCwWpm(cwWpmInput.value);
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); } cwWpmInput.value = String(wpm);
catch (e) { console.error("CW WPM set failed", e); } try { await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
catch (error: unknown) { console.error("CW WPM set failed", error); }
})();
}); });
} }
if (cwToneInput) { if (cwToneInput) {
cwToneInput.addEventListener("change", async () => { cwToneInput.addEventListener("change", () => {
if (cwAutoInput?.checked) return; if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
await setCwTone(cwToneInput.value);
}); });
} }
if (cwToneCanvas) { if (cwToneCanvas) {
cwToneCanvas.addEventListener("click", async (event) => { cwToneCanvas.addEventListener("click", (event) => {
if (cwAutoInput?.checked) return; if (cwAutoInput?.checked) return;
const rect = cwToneCanvas.getBoundingClientRect(); const rect = cwToneCanvas.getBoundingClientRect();
if (rect.width <= 0) return; if (rect.width <= 0) return;
@@ -350,11 +397,11 @@ if (cwToneCanvas) {
if (!range) return; if (!range) return;
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const tone = range.toneMinHz + frac * range.toneSpanHz; const tone = range.toneMinHz + frac * range.toneSpanHz;
await setCwTone(tone); void setCwTone(tone);
}); });
} }
window.resetCwHistoryView = function() { cwWindow.resetCwHistoryView = function() {
if (cwOutputEl) cwOutputEl.innerHTML = ""; if (cwOutputEl) cwOutputEl.innerHTML = "";
cwLastAppendTime = 0; cwLastAppendTime = 0;
cwBarHistory = []; cwBarHistory = [];
@@ -363,18 +410,20 @@ window.resetCwHistoryView = function() {
drawCwTonePicker(); drawCwTonePicker();
}; };
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; void (async () => {
try { if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
await postPath("/clear_cw_decode"); try {
window.resetCwHistoryView(); await cwWindow.postPath?.("/clear_cw_decode");
} catch (e) { cwWindow.resetCwHistoryView?.();
console.error("CW history clear failed", e); } catch (error: unknown) {
} console.error("CW history clear failed", error);
}
})();
}); });
// --- Server-side CW decode handler --- // --- Server-side CW decode handler ---
window.onServerCw = function(evt) { cwWindow.onServerCw = function(evt: CwEvent) {
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (cwStatusEl) cwStatusEl.textContent = "Receiving";
if (evt.text && cwOutputEl) { if (evt.text && cwOutputEl) {
// Append decoded text to output // Append decoded text to output
@@ -390,7 +439,9 @@ window.onServerCw = function(evt) {
lastLine.textContent += evt.text; lastLine.textContent += evt.text;
} }
while (cwOutputEl.children.length > CW_MAX_LINES) { 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; cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
} }
@@ -417,10 +468,10 @@ window.onServerCw = function(evt) {
} }
if (!cwAutoInput || cwAutoInput.checked) { if (!cwAutoInput || cwAutoInput.checked) {
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { 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))) { 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; 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 (!Array.isArray(events) || events.length === 0) return;
if (cwStatusEl) cwStatusEl.textContent = "Receiving"; if (cwStatusEl) cwStatusEl.textContent = "Receiving";
for (const evt of events) { for (const evt of events) {
window.onServerCw(evt); cwWindow.onServerCw?.(evt);
} }
}; };
window.refreshCwTonePicker = function refreshCwTonePicker() { cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
ensureCwToneCanvasResolution(); ensureCwToneCanvasResolution();
drawCwTonePicker(); 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");
});