e337b6d2de
Add an optional website URL to config and use it for the web header title when present, falling back to the version title otherwise. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
4506 lines
154 KiB
JavaScript
4506 lines
154 KiB
JavaScript
// --- Persistent settings (localStorage) ---
|
|
const STORAGE_PREFIX = "trx_";
|
|
function saveSetting(key, value) {
|
|
try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {}
|
|
}
|
|
function loadSetting(key, fallback) {
|
|
try {
|
|
const v = localStorage.getItem(STORAGE_PREFIX + key);
|
|
return v !== null ? JSON.parse(v) : fallback;
|
|
} catch(e) { return fallback; }
|
|
}
|
|
|
|
// --- Authentication ---
|
|
let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access)
|
|
let authEnabled = true;
|
|
|
|
async function checkAuthStatus() {
|
|
try {
|
|
const resp = await fetch("/auth/session");
|
|
if (resp.status === 404) {
|
|
// Auth API not exposed -> treat as auth-disabled mode.
|
|
return { authenticated: true, role: "control", auth_disabled: true };
|
|
}
|
|
if (!resp.ok) return { authenticated: false };
|
|
const data = await resp.json();
|
|
return data;
|
|
} catch (e) {
|
|
console.error("Auth check failed:", e);
|
|
return { authenticated: false };
|
|
}
|
|
}
|
|
|
|
async function authLogin(passphrase) {
|
|
try {
|
|
const resp = await fetch("/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ passphrase }),
|
|
});
|
|
if (resp.status === 404) {
|
|
return { authenticated: true, role: "control", auth_disabled: true };
|
|
}
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error(text || "Login failed");
|
|
}
|
|
const data = await resp.json();
|
|
return data;
|
|
} catch (e) {
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function authLogout() {
|
|
try {
|
|
const resp = await fetch("/auth/logout", { method: "POST" });
|
|
if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed");
|
|
authRole = null;
|
|
// Disconnect and show auth gate without page reload
|
|
disconnect();
|
|
document.getElementById("content").style.display = "none";
|
|
document.getElementById("loading").style.display = "none";
|
|
document.getElementById("auth-passphrase").value = "";
|
|
updateAuthUI();
|
|
|
|
// Check if guest mode is available after logout
|
|
const authStatus = await checkAuthStatus();
|
|
const allowGuest = authStatus.role === "rx";
|
|
showAuthGate(allowGuest);
|
|
} catch (e) {
|
|
console.error("Logout failed:", e);
|
|
showAuthError("Logout failed");
|
|
}
|
|
}
|
|
|
|
function showAuthGate(allowGuest = false) {
|
|
if (!authEnabled) return;
|
|
document.getElementById("loading").style.display = "none";
|
|
document.getElementById("content").style.display = "none";
|
|
const authGate = document.getElementById("auth-gate");
|
|
authGate.style.display = "flex";
|
|
authGate.style.flexDirection = "column";
|
|
authGate.style.justifyContent = "center";
|
|
authGate.style.alignItems = "stretch";
|
|
const signalVisualBlock = document.querySelector(".signal-visual-block");
|
|
if (signalVisualBlock) {
|
|
signalVisualBlock.style.display = "none";
|
|
}
|
|
|
|
// Hide all tab panels
|
|
document.querySelectorAll(".tab-panel").forEach(panel => {
|
|
panel.style.display = "none";
|
|
});
|
|
|
|
// Show guest button if guest mode is available
|
|
const guestBtn = document.getElementById("auth-guest-btn");
|
|
if (guestBtn) {
|
|
guestBtn.style.display = allowGuest ? "block" : "none";
|
|
}
|
|
|
|
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
|
btn.classList.toggle("active", btn.dataset.tab === "main");
|
|
});
|
|
syncTopBarAccess();
|
|
}
|
|
|
|
function hideAuthGate() {
|
|
const authGate = document.getElementById("auth-gate");
|
|
authGate.style.display = "none";
|
|
document.getElementById("loading").style.display = "block";
|
|
const signalVisualBlock = document.querySelector(".signal-visual-block");
|
|
if (signalVisualBlock) {
|
|
signalVisualBlock.style.display = "";
|
|
}
|
|
|
|
// Show Main tab by default and hide all other tabs
|
|
document.querySelectorAll(".tab-panel").forEach(panel => {
|
|
panel.style.display = "none";
|
|
});
|
|
const mainTab = document.getElementById("tab-main");
|
|
if (mainTab) {
|
|
mainTab.style.display = "";
|
|
}
|
|
|
|
// Mark Main tab button as active
|
|
document.querySelectorAll(".tab-bar .tab").forEach(btn => {
|
|
btn.classList.remove("active");
|
|
});
|
|
const mainTabBtn = document.querySelector(".tab-bar .tab[data-tab='main']");
|
|
if (mainTabBtn) {
|
|
mainTabBtn.classList.add("active");
|
|
}
|
|
syncTopBarAccess();
|
|
}
|
|
|
|
function showAuthError(msg) {
|
|
const el = document.getElementById("auth-error");
|
|
el.textContent = msg;
|
|
el.style.display = "block";
|
|
setTimeout(() => {
|
|
el.style.display = "none";
|
|
}, 5000);
|
|
}
|
|
|
|
function updateAuthUI() {
|
|
const badge = document.getElementById("auth-badge");
|
|
const badgeRole = document.getElementById("auth-role-badge");
|
|
const headerAuthBtn = document.getElementById("header-auth-btn");
|
|
|
|
if (!authEnabled) {
|
|
if (badge) badge.style.display = "none";
|
|
if (headerAuthBtn) headerAuthBtn.style.display = "none";
|
|
syncTopBarAccess();
|
|
return;
|
|
}
|
|
|
|
if (authRole) {
|
|
badge.style.display = "block";
|
|
badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
|
if (headerAuthBtn) {
|
|
headerAuthBtn.textContent = "Logout";
|
|
headerAuthBtn.style.display = "block";
|
|
}
|
|
} else {
|
|
badge.style.display = "none";
|
|
if (headerAuthBtn) {
|
|
headerAuthBtn.textContent = "Login";
|
|
headerAuthBtn.style.display = "block";
|
|
}
|
|
}
|
|
syncTopBarAccess();
|
|
}
|
|
|
|
function applyAuthRestrictions() {
|
|
if (!authRole) return;
|
|
|
|
// Disable TX/PTT/frequency/mode/VFO controls for rx role
|
|
if (authRole === "rx") {
|
|
const pttBtn = document.getElementById("ptt-btn");
|
|
const powerBtn = document.getElementById("power-btn");
|
|
const lockBtn = document.getElementById("lock-btn");
|
|
const freqInput = document.getElementById("freq");
|
|
const centerFreqInput = document.getElementById("center-freq");
|
|
const modeSelect = document.getElementById("mode");
|
|
const txLimitInput = document.getElementById("tx-limit");
|
|
const txLimitBtn = document.getElementById("tx-limit-btn");
|
|
const txAudioBtn = document.getElementById("tx-audio-btn");
|
|
const txLimitRow = document.getElementById("tx-limit-row");
|
|
const vfoPicker = document.getElementById("vfo-picker");
|
|
const jogUp = document.getElementById("jog-up");
|
|
const jogDown = document.getElementById("jog-down");
|
|
const jogButtons = document.querySelectorAll(".jog-step button");
|
|
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
|
|
|
// Disable TX buttons
|
|
if (pttBtn) pttBtn.disabled = true;
|
|
if (powerBtn) powerBtn.disabled = true;
|
|
if (lockBtn) lockBtn.disabled = true;
|
|
if (txAudioBtn) txAudioBtn.disabled = true;
|
|
if (txLimitBtn) txLimitBtn.disabled = true;
|
|
|
|
// Disable frequency/mode inputs
|
|
if (freqInput) freqInput.disabled = true;
|
|
if (centerFreqInput) centerFreqInput.disabled = true;
|
|
if (modeSelect) modeSelect.disabled = true;
|
|
if (txLimitInput) txLimitInput.disabled = true;
|
|
|
|
// Disable VFO selector
|
|
vfoButtons.forEach(btn => btn.disabled = true);
|
|
|
|
// Disable jog controls
|
|
const jogWheel = document.getElementById("jog-wheel");
|
|
if (jogUp) jogUp.disabled = true;
|
|
if (jogDown) jogDown.disabled = true;
|
|
if (jogWheel) jogWheel.style.opacity = "0.5";
|
|
jogButtons.forEach(btn => btn.disabled = true);
|
|
|
|
// Disable plugin enable/disable buttons and decode history clear buttons
|
|
// Note: sig-clear-btn is allowed for RX (clears local measurements only)
|
|
const pluginToggleBtns = [
|
|
"ft8-decode-toggle-btn",
|
|
"wspr-decode-toggle-btn",
|
|
"cw-auto",
|
|
"aprs-clear-btn",
|
|
"ft8-clear-btn",
|
|
"wspr-clear-btn",
|
|
"cw-clear-btn"
|
|
];
|
|
pluginToggleBtns.forEach(id => {
|
|
const btn = document.getElementById(id);
|
|
if (btn && btn.tagName === "BUTTON") {
|
|
btn.disabled = true;
|
|
} else if (btn && btn.type === "checkbox") {
|
|
btn.disabled = true;
|
|
}
|
|
});
|
|
|
|
// Hide TX-specific UI but keep controls visible (disabled)
|
|
if (txLimitRow) txLimitRow.style.opacity = "0.5";
|
|
}
|
|
}
|
|
|
|
function applyCapabilities(caps) {
|
|
if (!caps) return;
|
|
lastHasTx = !!caps.tx;
|
|
|
|
// PTT / TX controls
|
|
const pttBtn = document.getElementById("ptt-btn");
|
|
const txPowerCol = document.getElementById("tx-power-col");
|
|
const txMetersRow = document.getElementById("tx-meters");
|
|
const txAudioBtn = document.getElementById("tx-audio-btn");
|
|
const txVolSlider = document.getElementById("tx-vol");
|
|
const txVolControl = txVolSlider ? txVolSlider.closest(".vol-label") : null;
|
|
if (txPowerCol) txPowerCol.style.display = caps.tx ? "" : "none";
|
|
if (pttBtn) pttBtn.style.display = caps.tx ? "" : "none";
|
|
if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
|
|
if (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none";
|
|
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
|
|
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
|
|
stopTxAudio();
|
|
}
|
|
|
|
// TX limit row
|
|
const txLimitRow = document.getElementById("tx-limit-row");
|
|
if (txLimitRow && !caps.tx_limit) txLimitRow.style.display = "none";
|
|
|
|
// VFO row
|
|
const vfoRow = document.getElementById("vfo-row");
|
|
if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none";
|
|
|
|
// Signal meter row
|
|
document.querySelectorAll(".full-row.label-below-row").forEach(row => {
|
|
const label = row.querySelector(".label span");
|
|
if (label && label.textContent === "Signal") {
|
|
row.style.display = (caps.signal_meter && !caps.filter_controls) ? "" : "none";
|
|
}
|
|
});
|
|
|
|
// Spectrum panel (SDR-only)
|
|
const spectrumPanel = document.getElementById("spectrum-panel");
|
|
const centerFreqField = document.getElementById("center-freq-field");
|
|
if (spectrumPanel) {
|
|
if (caps.filter_controls) {
|
|
spectrumPanel.style.display = "";
|
|
if (centerFreqField) centerFreqField.style.display = "";
|
|
startSpectrumStreaming();
|
|
} else {
|
|
spectrumPanel.style.display = "none";
|
|
if (centerFreqField) centerFreqField.style.display = "none";
|
|
stopSpectrumStreaming();
|
|
}
|
|
}
|
|
}
|
|
|
|
const freqEl = document.getElementById("freq");
|
|
const centerFreqEl = document.getElementById("center-freq");
|
|
const wavelengthEl = document.getElementById("wavelength");
|
|
const modeEl = document.getElementById("mode");
|
|
const bandLabel = document.getElementById("band-label");
|
|
const powerBtn = document.getElementById("power-btn");
|
|
const powerHint = document.getElementById("power-hint");
|
|
const vfoPicker = document.getElementById("vfo-picker");
|
|
const signalBar = document.getElementById("signal-bar");
|
|
const signalValue = document.getElementById("signal-value");
|
|
const pttBtn = document.getElementById("ptt-btn");
|
|
const txLimitInput = document.getElementById("tx-limit");
|
|
const txLimitBtn = document.getElementById("tx-limit-btn");
|
|
const txLimitRow = document.getElementById("tx-limit-row");
|
|
const lockBtn = document.getElementById("lock-btn");
|
|
const txMeters = document.getElementById("tx-meters");
|
|
const pwrBar = document.getElementById("pwr-bar");
|
|
const pwrValue = document.getElementById("pwr-value");
|
|
const swrBar = document.getElementById("swr-bar");
|
|
const swrValue = document.getElementById("swr-value");
|
|
const loadingEl = document.getElementById("loading");
|
|
const contentEl = document.getElementById("content");
|
|
const serverSubtitle = document.getElementById("server-subtitle");
|
|
const rigSubtitle = document.getElementById("rig-subtitle");
|
|
const ownerSubtitle = document.getElementById("owner-subtitle");
|
|
const loadingTitle = document.getElementById("loading-title");
|
|
const loadingSub = document.getElementById("loading-sub");
|
|
const overviewCanvas = document.getElementById("overview-canvas");
|
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
|
const signalVisualBlockEl = document.querySelector(".signal-visual-block");
|
|
const overviewPeakHoldEl = document.getElementById("overview-peak-hold");
|
|
const themeToggleBtn = document.getElementById("theme-toggle");
|
|
const headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
|
|
const headerStylePickSelect = document.getElementById("header-style-pick-select");
|
|
const rdsPsOverlay = document.getElementById("rds-ps-overlay");
|
|
let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000));
|
|
|
|
function syncTopBarAccess() {
|
|
const loggedOut = authEnabled && !authRole;
|
|
const tabBar = document.getElementById("tab-bar");
|
|
const rigSwitch = document.querySelector(".header-rig-switch");
|
|
if (tabBar) tabBar.style.display = "";
|
|
|
|
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
|
const isMain = btn.dataset.tab === "main";
|
|
btn.style.display = !loggedOut || isMain ? "" : "none";
|
|
btn.disabled = false;
|
|
});
|
|
|
|
if (rigSwitch) {
|
|
rigSwitch.style.display = loggedOut ? "none" : "";
|
|
}
|
|
|
|
if (headerRigSwitchSelect) {
|
|
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
|
}
|
|
}
|
|
|
|
let overviewDrawPending = false;
|
|
let lastSpectrumData = null;
|
|
let rdsFrameCount = 0;
|
|
let lastControl;
|
|
let lastTxEn = null;
|
|
let lastHasTx = true;
|
|
let lastRendered = null;
|
|
let hintTimer = null;
|
|
let sigMeasuring = false;
|
|
let sigLastSUnits = null;
|
|
let sigMeasureTimer = null;
|
|
let sigMeasureLastTickMs = 0;
|
|
let sigMeasureAccumMs = 0;
|
|
let sigMeasureWeighted = 0;
|
|
let sigMeasurePeak = null;
|
|
let lastFreqHz = null;
|
|
let centerFreqDirty = false;
|
|
let jogUnit = loadSetting("jogUnit", 1000); // base unit: 1, 1000, 1000000
|
|
let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100
|
|
let jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
|
|
let minFreqStepHz = 1;
|
|
let lastModeName = "";
|
|
const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
|
|
function vfoColor(idx) {
|
|
if (idx < VFO_COLORS.length) return VFO_COLORS[idx];
|
|
// Deterministic pseudo-random hue for extra VFOs
|
|
const hue = ((idx * 137) % 360);
|
|
return `hsl(${hue}, 70%, 55%)`;
|
|
}
|
|
let jogAngle = 0;
|
|
let lastClientCount = null;
|
|
let lastLocked = false;
|
|
let lastRigIds = [];
|
|
let lastRigDisplayNames = {};
|
|
let lastActiveRigId = null;
|
|
const originalTitle = document.title;
|
|
const savedTheme = loadSetting("theme", null);
|
|
|
|
function currentTheme() {
|
|
return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
|
}
|
|
|
|
function updateDocumentTitle(rds = null) {
|
|
if (!Number.isFinite(lastFreqHz)) {
|
|
document.title = originalTitle;
|
|
return;
|
|
}
|
|
const parts = [formatFreq(lastFreqHz)];
|
|
const ps = rds?.program_service;
|
|
if (ps && ps.length > 0) {
|
|
parts.push(ps);
|
|
}
|
|
parts.push(originalTitle);
|
|
document.title = parts.join(" - ");
|
|
}
|
|
|
|
function setTheme(theme) {
|
|
const next = theme === "light" ? "light" : "dark";
|
|
document.documentElement.setAttribute("data-theme", next);
|
|
saveSetting("theme", next);
|
|
if (themeToggleBtn) {
|
|
themeToggleBtn.textContent = next === "dark" ? "☀️ Light" : "🌙 Dark";
|
|
themeToggleBtn.title = next === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
|
}
|
|
}
|
|
|
|
// ── Style / palette system ────────────────────────────────────────────────────
|
|
const CANVAS_PALETTE = {
|
|
original: {
|
|
dark: {
|
|
bg: "#0a0f18",
|
|
spectrumLine: "#00e676", spectrumFill: "rgba(0,230,118,0.10)",
|
|
spectrumGrid: "rgba(255,255,255,0.06)", spectrumLabel: "rgba(180,200,220,0.45)",
|
|
waveformLine: "rgba(94,234,212,0.92)", waveformPeak: "rgba(251,191,36,0.88)",
|
|
waveformGrid: "rgba(148,163,184,0.12)", waveformLabel: "rgba(203,213,225,0.72)",
|
|
waterfallHue: [225, 30], waterfallSat: 88, waterfallLight: [16, 68], waterfallAlpha: [0.28, 0.86],
|
|
},
|
|
light: {
|
|
bg: "#eef3fb",
|
|
spectrumLine: "#007a47", spectrumFill: "rgba(0,110,70,0.12)",
|
|
spectrumGrid: "rgba(0,30,80,0.10)", spectrumLabel: "rgba(30,50,90,0.55)",
|
|
waveformLine: "rgba(17,94,89,0.95)", waveformPeak: "rgba(217,119,6,0.9)",
|
|
waveformGrid: "rgba(71,85,105,0.14)", waveformLabel: "rgba(51,65,85,0.72)",
|
|
waterfallHue: [210, 35], waterfallSat: 82, waterfallLight: [92, 40], waterfallAlpha: [0.42, 0.80],
|
|
},
|
|
},
|
|
arctic: {
|
|
dark: {
|
|
bg: "#1e2530",
|
|
spectrumLine: "#88c0d0", spectrumFill: "rgba(136,192,208,0.12)",
|
|
spectrumGrid: "rgba(216,222,233,0.08)", spectrumLabel: "rgba(216,222,233,0.55)",
|
|
waveformLine: "rgba(136,192,208,0.92)", waveformPeak: "rgba(235,203,139,0.88)",
|
|
waveformGrid: "rgba(216,222,233,0.10)", waveformLabel: "rgba(216,222,233,0.65)",
|
|
waterfallHue: [212, 188], waterfallSat: 70, waterfallLight: [14, 58], waterfallAlpha: [0.28, 0.82],
|
|
},
|
|
light: {
|
|
bg: "#dde1e9",
|
|
spectrumLine: "#5e81ac", spectrumFill: "rgba(94,129,172,0.14)",
|
|
spectrumGrid: "rgba(46,52,64,0.08)", spectrumLabel: "rgba(46,52,64,0.55)",
|
|
waveformLine: "rgba(94,129,172,0.95)", waveformPeak: "rgba(208,135,112,0.9)",
|
|
waveformGrid: "rgba(46,52,64,0.12)", waveformLabel: "rgba(46,52,64,0.65)",
|
|
waterfallHue: [215, 195], waterfallSat: 65, waterfallLight: [88, 45], waterfallAlpha: [0.35, 0.78],
|
|
},
|
|
},
|
|
lime: {
|
|
dark: {
|
|
bg: "#181815",
|
|
spectrumLine: "#a6e22e", spectrumFill: "rgba(166,226,46,0.10)",
|
|
spectrumGrid: "rgba(248,248,242,0.05)", spectrumLabel: "rgba(248,248,242,0.45)",
|
|
waveformLine: "rgba(166,226,46,0.92)", waveformPeak: "rgba(230,219,116,0.88)",
|
|
waveformGrid: "rgba(248,248,242,0.08)", waveformLabel: "rgba(248,248,242,0.65)",
|
|
waterfallHue: [70, 38], waterfallSat: 80, waterfallLight: [12, 62], waterfallAlpha: [0.25, 0.88],
|
|
},
|
|
light: {
|
|
bg: "#ede8d8",
|
|
spectrumLine: "#5f8700", spectrumFill: "rgba(95,135,0,0.12)",
|
|
spectrumGrid: "rgba(39,40,34,0.08)", spectrumLabel: "rgba(39,40,34,0.50)",
|
|
waveformLine: "rgba(95,135,0,0.95)", waveformPeak: "rgba(176,120,0,0.9)",
|
|
waveformGrid: "rgba(39,40,34,0.10)", waveformLabel: "rgba(39,40,34,0.60)",
|
|
waterfallHue: [75, 42], waterfallSat: 75, waterfallLight: [90, 42], waterfallAlpha: [0.35, 0.78],
|
|
},
|
|
},
|
|
contrast: {
|
|
dark: {
|
|
bg: "#000000",
|
|
spectrumLine: "#00ff88", spectrumFill: "rgba(0,255,136,0.12)",
|
|
spectrumGrid: "rgba(255,255,255,0.12)", spectrumLabel: "rgba(255,255,255,0.70)",
|
|
waveformLine: "rgba(0,255,136,0.95)", waveformPeak: "rgba(255,204,0,0.92)",
|
|
waveformGrid: "rgba(255,255,255,0.15)", waveformLabel: "rgba(255,255,255,0.80)",
|
|
waterfallHue: [150, 60], waterfallSat: 100, waterfallLight: [8, 55], waterfallAlpha: [0.30, 0.95],
|
|
},
|
|
light: {
|
|
bg: "#f4f4f4",
|
|
spectrumLine: "#005cc5", spectrumFill: "rgba(0,92,197,0.12)",
|
|
spectrumGrid: "rgba(0,0,0,0.12)", spectrumLabel: "rgba(0,0,0,0.65)",
|
|
waveformLine: "rgba(0,92,197,0.95)", waveformPeak: "rgba(180,60,0,0.9)",
|
|
waveformGrid: "rgba(0,0,0,0.14)", waveformLabel: "rgba(0,0,0,0.70)",
|
|
waterfallHue: [220, 180], waterfallSat: 100, waterfallLight: [90, 42], waterfallAlpha: [0.35, 0.82],
|
|
},
|
|
},
|
|
"neon-disco": {
|
|
dark: {
|
|
bg: "#090010",
|
|
spectrumLine: "#ff10e0", spectrumFill: "rgba(255,16,224,0.12)",
|
|
spectrumGrid: "rgba(255,16,224,0.10)", spectrumLabel: "rgba(240,200,255,0.55)",
|
|
waveformLine: "rgba(57,255,20,0.92)", waveformPeak: "rgba(255,16,224,0.88)",
|
|
waveformGrid: "rgba(255,16,224,0.10)", waveformLabel: "rgba(240,200,255,0.65)",
|
|
waterfallHue: [300, 120], waterfallSat: 100, waterfallLight: [8, 55], waterfallAlpha: [0.30, 0.92],
|
|
},
|
|
light: {
|
|
bg: "#f0d8ff",
|
|
spectrumLine: "#cc00a8", spectrumFill: "rgba(204,0,168,0.12)",
|
|
spectrumGrid: "rgba(100,0,150,0.10)", spectrumLabel: "rgba(50,0,80,0.55)",
|
|
waveformLine: "rgba(31,136,0,0.95)", waveformPeak: "rgba(180,0,120,0.9)",
|
|
waveformGrid: "rgba(50,0,80,0.10)", waveformLabel: "rgba(50,0,80,0.65)",
|
|
waterfallHue: [300, 120], waterfallSat: 90, waterfallLight: [90, 45], waterfallAlpha: [0.35, 0.80],
|
|
},
|
|
},
|
|
};
|
|
|
|
function currentStyle() {
|
|
return document.documentElement.getAttribute("data-style") || "original";
|
|
}
|
|
|
|
function canvasPalette() {
|
|
const s = currentStyle();
|
|
const t = currentTheme();
|
|
return (CANVAS_PALETTE[s] ?? CANVAS_PALETTE.original)[t];
|
|
}
|
|
|
|
function setStyle(style) {
|
|
const remapped = style === "nord" ? "arctic" : style === "monokai" ? "lime" : style;
|
|
const valid = ["original", "arctic", "lime", "contrast", "neon-disco"];
|
|
const next = valid.includes(remapped) ? remapped : "original";
|
|
if (next === "original") {
|
|
document.documentElement.removeAttribute("data-style");
|
|
} else {
|
|
document.documentElement.setAttribute("data-style", next);
|
|
}
|
|
saveSetting("style", next);
|
|
if (headerStylePickSelect) headerStylePickSelect.value = next;
|
|
scheduleOverviewDraw();
|
|
if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw();
|
|
}
|
|
|
|
if (overviewPeakHoldEl) {
|
|
if (!Number.isFinite(overviewPeakHoldMs) || overviewPeakHoldMs <= 0) {
|
|
overviewPeakHoldMs = 2000;
|
|
}
|
|
overviewPeakHoldEl.value = String(overviewPeakHoldMs);
|
|
overviewPeakHoldEl.addEventListener("change", () => {
|
|
overviewPeakHoldMs = Math.max(0, Number(overviewPeakHoldEl.value) || 0);
|
|
saveSetting("overviewPeakHoldMs", overviewPeakHoldMs);
|
|
scheduleOverviewDraw();
|
|
});
|
|
}
|
|
|
|
if (savedTheme === "light" || savedTheme === "dark") {
|
|
setTheme(savedTheme);
|
|
} else {
|
|
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
|
setTheme(prefersLight ? "light" : "dark");
|
|
}
|
|
|
|
const savedStyle = loadSetting("style", "original");
|
|
setStyle(savedStyle);
|
|
|
|
if (themeToggleBtn) {
|
|
themeToggleBtn.addEventListener("click", () => {
|
|
setTheme(currentTheme() === "dark" ? "light" : "dark");
|
|
updateMapBaseLayerForTheme(currentTheme());
|
|
scheduleOverviewDraw();
|
|
if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw();
|
|
});
|
|
}
|
|
|
|
if (headerStylePickSelect) {
|
|
headerStylePickSelect.addEventListener("change", () => {
|
|
setStyle(headerStylePickSelect.value);
|
|
updateMapBaseLayerForTheme(currentTheme());
|
|
});
|
|
}
|
|
|
|
function readyText() {
|
|
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
|
|
}
|
|
|
|
function rigBadgeColor(rigId) {
|
|
const text = (rigId || "rx").toString();
|
|
let hash = 0;
|
|
for (let i = 0; i < text.length; i++) {
|
|
hash = ((hash * 33) + text.charCodeAt(i)) >>> 0;
|
|
}
|
|
const hue = hash % 360;
|
|
return `hsl(${hue}, 62%, 52%)`;
|
|
}
|
|
|
|
window.getDecodeRigMeta = function() {
|
|
const rigId = lastActiveRigId || "local";
|
|
return {
|
|
rigId,
|
|
label: lastRigDisplayNames[rigId] || rigId,
|
|
color: rigBadgeColor(rigId),
|
|
};
|
|
};
|
|
|
|
function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
|
|
if (!selectEl) return;
|
|
const selectedBefore = selectEl.value;
|
|
selectEl.innerHTML = "";
|
|
rigIds.forEach((id) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = id;
|
|
opt.textContent = id;
|
|
selectEl.appendChild(opt);
|
|
});
|
|
const preferred = (typeof activeRigId === "string" && rigIds.includes(activeRigId))
|
|
? activeRigId
|
|
: selectedBefore;
|
|
if (preferred && rigIds.includes(preferred)) {
|
|
selectEl.value = preferred;
|
|
}
|
|
selectEl.disabled = disabled;
|
|
}
|
|
|
|
function updateRigSubtitle(activeRigId) {
|
|
if (!rigSubtitle) return;
|
|
const name = (activeRigId && lastRigDisplayNames[activeRigId]) || activeRigId || "--";
|
|
rigSubtitle.textContent = `Rig: ${name}`;
|
|
}
|
|
|
|
function applyRigList(activeRigId, rigIds, displayNames) {
|
|
if (!Array.isArray(rigIds)) return;
|
|
lastRigIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
|
if (displayNames && typeof displayNames === "object") {
|
|
lastRigDisplayNames = { ...displayNames };
|
|
}
|
|
const aboutList = document.getElementById("about-rig-list");
|
|
if (aboutList) {
|
|
aboutList.textContent = lastRigIds.length ? lastRigIds.join(", ") : "--";
|
|
}
|
|
if (typeof activeRigId === "string" && activeRigId.length > 0) {
|
|
lastActiveRigId = activeRigId;
|
|
const aboutActive = document.getElementById("about-active-rig");
|
|
if (aboutActive) aboutActive.textContent = activeRigId;
|
|
}
|
|
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
|
populateRigPicker(headerRigSwitchSelect, lastRigIds, activeRigId, disableSwitch);
|
|
updateRigSubtitle(activeRigId);
|
|
}
|
|
|
|
async function refreshRigList() {
|
|
try {
|
|
const resp = await fetch("/rigs", { cache: "no-store" });
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
const rigs = Array.isArray(data.rigs) ? data.rigs : [];
|
|
const rigIds = rigs.map((r) => r && r.rig_id).filter(Boolean);
|
|
const displayNames = {};
|
|
rigs.forEach((r) => {
|
|
if (!r || !r.rig_id) return;
|
|
if (typeof r.display_name === "string" && r.display_name.length > 0) {
|
|
displayNames[r.rig_id] = r.display_name;
|
|
} else {
|
|
displayNames[r.rig_id] = r.rig_id;
|
|
}
|
|
});
|
|
serverRigs = rigs;
|
|
serverActiveRigId = data.active_rig_id || null;
|
|
applyRigList(data.active_rig_id, rigIds, displayNames);
|
|
} catch (e) {
|
|
// Non-fatal: SSE/status path still drives main UI.
|
|
}
|
|
}
|
|
|
|
function showHint(msg, duration) {
|
|
powerHint.textContent = msg;
|
|
if (hintTimer) clearTimeout(hintTimer);
|
|
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
|
|
}
|
|
let supportedModes = [];
|
|
let supportedBands = [];
|
|
let freqDirty = false;
|
|
let initialized = false;
|
|
let lastEventAt = Date.now();
|
|
let es;
|
|
let esHeartbeat;
|
|
let reconnectTimer = null;
|
|
let overviewSignalSamples = [];
|
|
let overviewSignalTimer = null;
|
|
let overviewWaterfallRows = [];
|
|
let overviewWaterfallPushCount = 0; // monotonically increments on every push
|
|
const HEADER_SIG_WINDOW_MS = 10_000;
|
|
|
|
// Offscreen waterfall cache — reused across frames to avoid full redraws
|
|
let _wfOC = null; // OffscreenCanvas
|
|
let _wfOCPalKey = ""; // palette signature when offscreen was last built
|
|
let _wfOCPushCount = 0; // overviewWaterfallPushCount when offscreen was last updated
|
|
|
|
function _wfResetOffscreen() { _wfOC = null; _wfOCPushCount = 0; _wfOCPalKey = ""; }
|
|
function _wfPalKey(pal) {
|
|
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}`;
|
|
}
|
|
|
|
function resizeHeaderSignalCanvas() {
|
|
if (!overviewCanvas) return;
|
|
const cssW = Math.floor(overviewCanvas.clientWidth);
|
|
const cssH = Math.floor(overviewCanvas.clientHeight);
|
|
if (cssW <= 0 || cssH <= 0) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const nextW = Math.floor(cssW * dpr);
|
|
const nextH = Math.floor(cssH * dpr);
|
|
if (overviewCanvas.width !== nextW || overviewCanvas.height !== nextH) {
|
|
overviewCanvas.width = nextW;
|
|
overviewCanvas.height = nextH;
|
|
_wfResetOffscreen();
|
|
trimOverviewWaterfallRows();
|
|
}
|
|
positionRdsPsOverlay();
|
|
drawHeaderSignalGraph();
|
|
}
|
|
|
|
function signalOverlayHeight() {
|
|
if (!overviewCanvas) return 0;
|
|
let height = overviewCanvas.clientHeight || 0;
|
|
const spectrumCanvasEl = document.getElementById("spectrum-canvas");
|
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
|
const spectrumVisible =
|
|
spectrumCanvasEl &&
|
|
spectrumCanvasEl.clientHeight > 0 &&
|
|
spectrumPanelEl &&
|
|
getComputedStyle(spectrumPanelEl).display !== "none";
|
|
if (spectrumVisible) {
|
|
height += spectrumCanvasEl.clientHeight || 0;
|
|
}
|
|
return Math.floor(height);
|
|
}
|
|
|
|
function drawSignalOverlay() {
|
|
if (!signalOverlayCanvas || !signalVisualBlockEl) return;
|
|
if (!lastSpectrumData) {
|
|
signalOverlayCanvas.style.height = "0";
|
|
signalOverlayCanvas.width = 0;
|
|
signalOverlayCanvas.height = 0;
|
|
return;
|
|
}
|
|
const cssW = Math.floor(signalVisualBlockEl.clientWidth);
|
|
const cssH = signalOverlayHeight();
|
|
signalOverlayCanvas.style.height = cssH > 0 ? `${cssH}px` : "0";
|
|
if (cssW <= 0 || cssH <= 0) {
|
|
signalOverlayCanvas.width = 0;
|
|
signalOverlayCanvas.height = 0;
|
|
return;
|
|
}
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const nextW = Math.floor(cssW * dpr);
|
|
const nextH = Math.floor(cssH * dpr);
|
|
if (signalOverlayCanvas.width !== nextW || signalOverlayCanvas.height !== nextH) {
|
|
signalOverlayCanvas.width = nextW;
|
|
signalOverlayCanvas.height = nextH;
|
|
}
|
|
|
|
const ctx = signalOverlayCanvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, cssW, cssH);
|
|
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
const hzToX = (hz) => ((hz - range.visLoHz) / range.visSpanHz) * cssW;
|
|
|
|
if (lastFreqHz != null && currentBandwidthHz > 0) {
|
|
const halfBw = currentBandwidthHz / 2;
|
|
const xL = hzToX(lastFreqHz - halfBw);
|
|
const xR = hzToX(lastFreqHz + halfBw);
|
|
const stripW = xR - xL;
|
|
if (stripW > 1) {
|
|
const grd = ctx.createLinearGradient(xL, 0, xR, 0);
|
|
grd.addColorStop(0, "rgba(240,173,78,0.05)");
|
|
grd.addColorStop(0.2, "rgba(240,173,78,0.14)");
|
|
grd.addColorStop(0.5, "rgba(240,173,78,0.19)");
|
|
grd.addColorStop(0.8, "rgba(240,173,78,0.14)");
|
|
grd.addColorStop(1, "rgba(240,173,78,0.05)");
|
|
ctx.fillStyle = grd;
|
|
ctx.fillRect(xL, 0, stripW, cssH);
|
|
|
|
const edgeW = 5;
|
|
ctx.fillStyle = "rgba(240,173,78,0.30)";
|
|
ctx.fillRect(xL, 0, edgeW, cssH);
|
|
ctx.fillRect(xR - edgeW, 0, edgeW, cssH);
|
|
|
|
ctx.strokeStyle = "rgba(240,173,78,0.70)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath(); ctx.moveTo(xL, 0); ctx.lineTo(xL, cssH); ctx.stroke();
|
|
ctx.beginPath(); ctx.moveTo(xR, 0); ctx.lineTo(xR, cssH); ctx.stroke();
|
|
}
|
|
}
|
|
|
|
if (lastFreqHz != null) {
|
|
const xf = hzToX(lastFreqHz);
|
|
if (xf >= 0 && xf <= cssW) {
|
|
ctx.save();
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.strokeStyle = "#ff1744";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xf, 0);
|
|
ctx.lineTo(xf, cssH);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function scheduleOverviewDraw() {
|
|
if (!overviewCanvas || overviewDrawPending) return;
|
|
overviewDrawPending = true;
|
|
requestAnimationFrame(() => {
|
|
overviewDrawPending = false;
|
|
drawHeaderSignalGraph();
|
|
});
|
|
}
|
|
|
|
function pushHeaderSignalSample(sUnits) {
|
|
if (!overviewCanvas) return;
|
|
const now = Date.now();
|
|
const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0;
|
|
overviewSignalSamples.push({ t: now, v: sample });
|
|
while (overviewSignalSamples.length && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) {
|
|
overviewSignalSamples.shift();
|
|
}
|
|
scheduleOverviewDraw();
|
|
}
|
|
|
|
function trimOverviewWaterfallRows() {
|
|
if (!overviewCanvas) return;
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const maxRows = Math.max(1, Math.floor(overviewCanvas.height / dpr));
|
|
while (overviewWaterfallRows.length > maxRows) {
|
|
overviewWaterfallRows.shift();
|
|
}
|
|
}
|
|
|
|
function overviewVisibleBinWindow(data, binCount) {
|
|
if (!data || !Number.isFinite(data.sample_rate) || binCount <= 1) {
|
|
return { startIdx: 0, endIdx: Math.max(0, binCount - 1) };
|
|
}
|
|
const range = spectrumVisibleRange(data);
|
|
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
|
const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate;
|
|
const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate;
|
|
const maxIdx = binCount - 1;
|
|
const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx)));
|
|
const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx)));
|
|
return { startIdx, endIdx };
|
|
}
|
|
|
|
function pushOverviewWaterfallFrame(data) {
|
|
if (!overviewCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return;
|
|
overviewWaterfallRows.push(data.bins.slice());
|
|
overviewWaterfallPushCount++;
|
|
trimOverviewWaterfallRows();
|
|
scheduleOverviewDraw();
|
|
}
|
|
|
|
function startHeaderSignalSampling() {
|
|
if (!overviewCanvas || overviewSignalTimer) return;
|
|
overviewSignalTimer = setInterval(() => {
|
|
pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0);
|
|
}, 120);
|
|
}
|
|
|
|
function drawHeaderSignalGraph() {
|
|
if (!overviewCanvas) return;
|
|
const ctx = overviewCanvas.getContext("2d");
|
|
if (!ctx) return;
|
|
const pal = canvasPalette();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const w = overviewCanvas.width / dpr;
|
|
const h = overviewCanvas.height / dpr;
|
|
if (w <= 0 || h <= 0) return;
|
|
|
|
ctx.save();
|
|
ctx.scale(dpr, dpr);
|
|
ctx.clearRect(0, 0, w, h);
|
|
if (lastSpectrumData && overviewWaterfallRows.length > 0) {
|
|
drawOverviewWaterfall(ctx, w, h, pal);
|
|
} else {
|
|
drawOverviewSignalHistory(ctx, w, h, pal);
|
|
}
|
|
ctx.restore();
|
|
positionRdsPsOverlay();
|
|
drawSignalOverlay();
|
|
}
|
|
|
|
function _wfDrawRows(oct, rows, startRowIdx, endRowIdx, iW, iH, pal) {
|
|
// Draw rows[startRowIdx..endRowIdx) into oct, positioned at the canvas bottom.
|
|
// rowH is computed relative to the total row count (all of `rows`).
|
|
const total = rows.length;
|
|
const rowH = iH / total;
|
|
const columnStep = Math.max(1, Math.ceil(iW / 320));
|
|
for (let ri = startRowIdx; ri < endRowIdx; ri++) {
|
|
const bins = rows[ri];
|
|
if (!Array.isArray(bins) || bins.length === 0) continue;
|
|
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length);
|
|
const spanBins = Math.max(1, endIdx - startIdx);
|
|
const y = iH - (total - ri) * rowH;
|
|
for (let x = 0; x < iW; x += columnStep) {
|
|
const frac = x / Math.max(1, iW - 1);
|
|
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins));
|
|
oct.fillStyle = waterfallColor(bins[binIdx], pal);
|
|
oct.fillRect(x, y, columnStep + 0.75, rowH + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function drawOverviewWaterfall(ctx, w, h, pal) {
|
|
const maxVisible = Math.max(1, Math.floor(h));
|
|
const rows = overviewWaterfallRows.slice(-maxVisible);
|
|
if (rows.length === 0) return;
|
|
|
|
const iW = Math.ceil(w);
|
|
const iH = Math.ceil(h);
|
|
const palKey = _wfPalKey(pal);
|
|
const steadyState = rows.length >= maxVisible;
|
|
// How many rows were pushed since the offscreen was last updated
|
|
const newPushes = overviewWaterfallPushCount - _wfOCPushCount;
|
|
|
|
// Detect conditions that require a full redraw
|
|
const sizeChanged = !_wfOC || _wfOC.width !== iW || _wfOC.height !== iH;
|
|
const palChanged = _wfOCPalKey !== palKey;
|
|
const needsFull = sizeChanged || palChanged || _wfOCPushCount === 0;
|
|
|
|
if (sizeChanged || !_wfOC) {
|
|
_wfOC = new OffscreenCanvas(iW, iH);
|
|
_wfOCPushCount = 0;
|
|
}
|
|
const oct = _wfOC.getContext("2d");
|
|
|
|
if (needsFull) {
|
|
oct.clearRect(0, 0, iW, iH);
|
|
_wfDrawRows(oct, rows, 0, rows.length, iW, iH, pal);
|
|
_wfOCPushCount = overviewWaterfallPushCount;
|
|
_wfOCPalKey = palKey;
|
|
} else if (steadyState && newPushes > 0) {
|
|
// Steady state: scroll up and paint only the new rows at the bottom.
|
|
// newPushes new rows are at the tail of `rows`; each replaces one old row.
|
|
const newCount = Math.min(newPushes, rows.length);
|
|
const rowH = iH / rows.length;
|
|
const scrollPx = Math.round(newCount * rowH);
|
|
if (scrollPx > 0 && scrollPx < iH) {
|
|
const img = oct.getImageData(0, scrollPx, iW, iH - scrollPx);
|
|
oct.putImageData(img, 0, 0);
|
|
oct.clearRect(0, iH - scrollPx, iW, scrollPx);
|
|
}
|
|
_wfDrawRows(oct, rows, rows.length - newCount, rows.length, iW, iH, pal);
|
|
_wfOCPushCount = overviewWaterfallPushCount;
|
|
}
|
|
|
|
ctx.drawImage(_wfOC, 0, 0, w, h);
|
|
}
|
|
|
|
function drawOverviewSignalHistory(ctx, w, h, pal) {
|
|
const now = Date.now();
|
|
const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS);
|
|
if (samples.length === 0) return;
|
|
|
|
const maxVal = 20;
|
|
const windowStart = now - HEADER_SIG_WINDOW_MS;
|
|
const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * w;
|
|
const toY = (v) => h - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (h - 3) - 1.5;
|
|
|
|
const gridMarkers = [
|
|
{ val: 0, label: "S0" },
|
|
{ val: 9, label: "S9" },
|
|
{ val: 18, label: "S9+" },
|
|
];
|
|
ctx.strokeStyle = pal.waveformGrid;
|
|
ctx.lineWidth = 1;
|
|
ctx.font = "11px sans-serif";
|
|
ctx.fillStyle = pal.waveformLabel;
|
|
ctx.textAlign = "right";
|
|
ctx.textBaseline = "middle";
|
|
for (const marker of gridMarkers) {
|
|
const y = toY(marker.val);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(w, y);
|
|
ctx.stroke();
|
|
ctx.fillText(marker.label, w - 6, Math.max(8, Math.min(h - 8, y + 6)));
|
|
}
|
|
|
|
ctx.beginPath();
|
|
samples.forEach((sample, idx) => {
|
|
const x = toX(sample.t);
|
|
const y = toY(sample.v);
|
|
if (idx === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
});
|
|
ctx.strokeStyle = pal.waveformLine;
|
|
ctx.lineWidth = 1.6;
|
|
ctx.stroke();
|
|
|
|
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
|
if (holdMs > 0) {
|
|
ctx.beginPath();
|
|
for (let i = 0; i < samples.length; i++) {
|
|
let peak = samples[i].v;
|
|
for (let j = i; j >= 0; j--) {
|
|
if (samples[i].t - samples[j].t > holdMs) break;
|
|
if (samples[j].v > peak) peak = samples[j].v;
|
|
}
|
|
const x = toX(samples[i].t);
|
|
const y = toY(peak);
|
|
if (i === 0) ctx.moveTo(x, y);
|
|
else ctx.lineTo(x, y);
|
|
}
|
|
ctx.strokeStyle = pal.waveformPeak;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function waterfallColor(db, pal) {
|
|
const clamped = Math.max(-120, Math.min(-10, Number.isFinite(db) ? db : -120));
|
|
const t = (clamped + 120) / 110;
|
|
const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]);
|
|
const light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]);
|
|
const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[0]);
|
|
return `hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`;
|
|
}
|
|
|
|
function formatFreq(hz) {
|
|
if (!Number.isFinite(hz)) return "--";
|
|
if (hz >= 1_000_000_000) {
|
|
return `${(hz / 1_000_000_000).toFixed(3)} GHz`;
|
|
}
|
|
if (hz >= 10_000_000) {
|
|
return `${(hz / 1_000_000).toFixed(3)} MHz`;
|
|
}
|
|
return `${(hz / 1_000).toFixed(1)} kHz`;
|
|
}
|
|
|
|
function formatFreqForStep(hz, step) {
|
|
if (!Number.isFinite(hz)) return "--";
|
|
if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6);
|
|
if (step >= 1_000) return (hz / 1_000).toFixed(3);
|
|
if (step >= 1) return String(Math.round(hz));
|
|
return formatFreq(hz);
|
|
}
|
|
|
|
function formatWavelength(hz) {
|
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
|
const meters = 299_792_458 / hz;
|
|
if (meters >= 1) return `${Math.round(meters)} m`;
|
|
return `${Math.round(meters * 100)} cm`;
|
|
}
|
|
|
|
function refreshWavelengthDisplay(hz) {
|
|
if (!wavelengthEl) return;
|
|
wavelengthEl.textContent = formatWavelength(hz);
|
|
}
|
|
|
|
function refreshFreqDisplay() {
|
|
if (lastFreqHz == null || freqDirty) return;
|
|
freqEl.value = formatFreqForStep(lastFreqHz, jogUnit);
|
|
refreshWavelengthDisplay(lastFreqHz);
|
|
}
|
|
|
|
function positionRdsPsOverlay() {
|
|
if (!rdsPsOverlay || !lastSpectrumData || lastFreqHz == null || !overviewCanvas) return;
|
|
const width = overviewCanvas.clientWidth || overviewCanvas.width || 0;
|
|
if (width <= 0) {
|
|
return;
|
|
}
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
if (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) {
|
|
return;
|
|
}
|
|
const rel = (lastFreqHz - range.visLoHz) / range.visSpanHz;
|
|
const clamped = Math.max(0.06, Math.min(0.94, rel));
|
|
rdsPsOverlay.style.left = `${clamped * width}px`;
|
|
}
|
|
|
|
function resetRdsDisplay() {
|
|
rdsFrameCount = 0;
|
|
updateRdsPsOverlay(null);
|
|
}
|
|
|
|
function resetWfmStereoIndicator() {
|
|
if (!wfmStFlagEl) return;
|
|
wfmStFlagEl.textContent = "MO";
|
|
wfmStFlagEl.classList.remove("wfm-st-flag-stereo");
|
|
wfmStFlagEl.classList.add("wfm-st-flag-mono");
|
|
}
|
|
|
|
function applyLocalTunedFrequency(hz, forceDisplay = false) {
|
|
if (!Number.isFinite(hz)) return;
|
|
const freqChanged = lastFreqHz !== hz;
|
|
if (freqChanged) {
|
|
resetRdsDisplay();
|
|
resetWfmStereoIndicator();
|
|
}
|
|
lastFreqHz = hz;
|
|
updateDocumentTitle(lastSpectrumData?.rds ?? null);
|
|
refreshWavelengthDisplay(lastFreqHz);
|
|
if (forceDisplay) {
|
|
freqDirty = false;
|
|
}
|
|
if (forceDisplay || !freqDirty) {
|
|
refreshFreqDisplay();
|
|
}
|
|
window.ft8BaseHz = lastFreqHz;
|
|
if (window.updateFt8RfDisplay) {
|
|
window.updateFt8RfDisplay();
|
|
}
|
|
if (lastSpectrumData) {
|
|
scheduleSpectrumDraw();
|
|
}
|
|
positionRdsPsOverlay();
|
|
}
|
|
|
|
function refreshCenterFreqDisplay() {
|
|
if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return;
|
|
centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit);
|
|
}
|
|
|
|
function parseFreqInput(val, defaultStep) {
|
|
if (!val) return null;
|
|
const trimmed = val.trim().toLowerCase();
|
|
const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/);
|
|
if (!match) return null;
|
|
let num = parseFloat(match[1].replace(",", "."));
|
|
const unit = match[2] || "";
|
|
if (Number.isNaN(num)) return null;
|
|
if (unit.startsWith("gh") || unit === "g") {
|
|
num *= 1_000_000_000;
|
|
} else if (unit.startsWith("mh") || unit === "m") {
|
|
num *= 1_000_000;
|
|
} else if (unit.startsWith("kh") || unit === "k") {
|
|
num *= 1_000;
|
|
} else if (!unit) {
|
|
// Use currently selected input unit when user omits suffix.
|
|
if (defaultStep >= 1_000_000) {
|
|
num *= 1_000_000;
|
|
} else if (defaultStep >= 1_000) {
|
|
num *= 1_000;
|
|
} else if (defaultStep >= 1) {
|
|
// already Hz
|
|
} else {
|
|
// Fallback heuristic.
|
|
if (num >= 1_000_000) {
|
|
// Assume already Hz.
|
|
} else if (num >= 1_000) {
|
|
num *= 1_000;
|
|
} else {
|
|
num *= 1_000_000;
|
|
}
|
|
}
|
|
}
|
|
return Math.round(num);
|
|
}
|
|
|
|
function normalizeMinFreqStep(cap) {
|
|
const val = Number(cap && cap.min_freq_step_hz);
|
|
if (!Number.isFinite(val) || val < 1) return 1;
|
|
return Math.round(val);
|
|
}
|
|
|
|
function alignFreqToRigStep(hz) {
|
|
if (!Number.isFinite(hz)) return hz;
|
|
const step = Math.max(1, minFreqStepHz);
|
|
return Math.round(hz / step) * step;
|
|
}
|
|
|
|
function updateJogStepSupport(cap) {
|
|
const nextMinStep = normalizeMinFreqStep(cap);
|
|
minFreqStepHz = nextMinStep;
|
|
|
|
const stepRoot = document.getElementById("jog-step");
|
|
if (!stepRoot) return;
|
|
const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]"));
|
|
if (buttons.length === 0) return;
|
|
|
|
buttons.forEach((btn) => {
|
|
const base = Number(btn.dataset.baseStep || btn.dataset.step);
|
|
if (Number.isFinite(base) && base > 0) {
|
|
btn.dataset.baseStep = String(Math.round(base));
|
|
btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz));
|
|
}
|
|
});
|
|
|
|
const steps = buttons
|
|
.map((btn) => Number(btn.dataset.step))
|
|
.filter((s) => Number.isFinite(s) && s > 0);
|
|
if (steps.length === 0) return;
|
|
|
|
const current = Number(jogUnit);
|
|
const desired =
|
|
Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz);
|
|
|
|
jogUnit = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), steps[0]);
|
|
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
|
saveSetting("jogUnit", jogUnit);
|
|
saveSetting("jogStep", jogStep);
|
|
|
|
buttons.forEach((btn) => {
|
|
btn.classList.toggle("active", Number(btn.dataset.step) === jogUnit);
|
|
});
|
|
|
|
refreshFreqDisplay();
|
|
refreshCenterFreqDisplay();
|
|
}
|
|
|
|
function normalizeMode(modeVal) {
|
|
if (typeof modeVal === "string") return modeVal;
|
|
if (modeVal && typeof modeVal === "object") {
|
|
const entries = Object.entries(modeVal);
|
|
if (entries.length > 0) {
|
|
const [variant, value] = entries[0];
|
|
if (variant === "Other" && typeof value === "string") return value;
|
|
return variant;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function updateSupportedBands(cap) {
|
|
if (cap && Array.isArray(cap.supported_bands)) {
|
|
supportedBands = cap.supported_bands
|
|
.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number" && b.tx_allowed === true)
|
|
.map((b) => ({ low: b.low_hz, high: b.high_hz }));
|
|
} else {
|
|
supportedBands = [];
|
|
}
|
|
}
|
|
|
|
function freqAllowed(hz) {
|
|
if (!Number.isFinite(hz)) return false;
|
|
if (supportedBands.length === 0) return true; // if unknown, don't block
|
|
return supportedBands.some((b) => hz >= b.low && hz <= b.high);
|
|
}
|
|
|
|
// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit).
|
|
// Above S9, returns 9 + (overshoot in S-unit-equivalent, i.e. dB/10).
|
|
function dbmToSUnits(dbm) {
|
|
if (!Number.isFinite(dbm)) return 0;
|
|
// Guard against bogus backend values to keep display in a realistic range.
|
|
const clampedDbm = Math.max(-140, Math.min(20, dbm));
|
|
if (clampedDbm <= -121) return 0;
|
|
if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10;
|
|
return (clampedDbm + 121) / 6;
|
|
}
|
|
|
|
function formatSignal(sUnits) {
|
|
if (!Number.isFinite(sUnits) || sUnits <= 9) return `S${Math.max(0, sUnits || 0).toFixed(1)}`;
|
|
// S9+60dB is already extremely strong; cap anything beyond that.
|
|
const overDb = Math.min(60, (sUnits - 9) * 10);
|
|
return `S9 + ${overDb.toFixed(0)}dB`;
|
|
}
|
|
|
|
function setDisabled(disabled) {
|
|
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
|
if (el) el.disabled = disabled;
|
|
});
|
|
}
|
|
|
|
let serverVersion = null;
|
|
let serverBuildDate = null;
|
|
let serverCallsign = null;
|
|
let ownerCallsign = null;
|
|
let ownerWebsiteUrl = null;
|
|
let serverRigs = [];
|
|
let serverActiveRigId = null;
|
|
let serverLat = null;
|
|
let serverLon = null;
|
|
let initialMapZoom = 10;
|
|
|
|
function updateFooterBuildInfo() {
|
|
const serverEl = document.getElementById("footer-server-build");
|
|
if (!serverEl) return;
|
|
const ver = serverVersion || "--";
|
|
const build = serverBuildDate || "--";
|
|
serverEl.textContent = `trx-server v${ver} ${build}`;
|
|
}
|
|
|
|
function updateTitle() {
|
|
const titleEl = document.getElementById("rig-title");
|
|
if (titleEl) {
|
|
if (ownerWebsiteUrl) {
|
|
const label = ownerCallsign || displayLabelFromUrl(ownerWebsiteUrl);
|
|
titleEl.innerHTML =
|
|
`<a class="title-link" href="${escapeMapHtml(ownerWebsiteUrl)}" target="_blank" rel="noopener">${escapeMapHtml(label)}</a>`;
|
|
} else {
|
|
titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs";
|
|
}
|
|
}
|
|
updateDocumentTitle(lastSpectrumData?.rds ?? null);
|
|
}
|
|
|
|
function displayLabelFromUrl(url) {
|
|
try {
|
|
const host = new URL(url).hostname.replace(/^www\./i, "");
|
|
return host || url;
|
|
} catch (_e) {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
function render(update) {
|
|
if (!update) return;
|
|
if (update.server_version) serverVersion = update.server_version;
|
|
if (update.server_build_date) serverBuildDate = update.server_build_date;
|
|
if (update.server_callsign) serverCallsign = update.server_callsign;
|
|
if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) {
|
|
ownerCallsign = update.owner_callsign;
|
|
}
|
|
if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) {
|
|
ownerWebsiteUrl = update.owner_website_url;
|
|
}
|
|
if (update.server_latitude != null) serverLat = update.server_latitude;
|
|
if (update.server_longitude != null) serverLon = update.server_longitude;
|
|
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
|
|
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
|
|
}
|
|
updateTitle();
|
|
updateFooterBuildInfo();
|
|
|
|
initialized = !!update.initialized;
|
|
const hasUsableSnapshot =
|
|
!!update.info &&
|
|
!!update.status &&
|
|
!!update.status.freq &&
|
|
typeof update.status.freq.hz === "number";
|
|
if (!initialized) {
|
|
const fallbackRigName = originalTitle || "Rig";
|
|
const manu = (update.info && update.info.manufacturer) || fallbackRigName;
|
|
const model = (update.info && update.info.model) || fallbackRigName;
|
|
const rev = (update.info && update.info.revision) || "";
|
|
const parts = [manu, model, rev].filter(Boolean).join(" ");
|
|
if (!hasUsableSnapshot) {
|
|
loadingTitle.textContent = `Initializing ${parts}…`;
|
|
loadingSub.textContent = "";
|
|
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
|
|
loadingEl.style.display = "";
|
|
if (contentEl) contentEl.style.display = "none";
|
|
powerHint.textContent = "Initializing rig…";
|
|
setDisabled(true);
|
|
return;
|
|
}
|
|
loadingEl.style.display = "none";
|
|
if (contentEl) contentEl.style.display = "";
|
|
powerHint.textContent = "Rig not fully initialized yet";
|
|
} else {
|
|
loadingEl.style.display = "none";
|
|
if (contentEl) contentEl.style.display = "";
|
|
}
|
|
// Server subtitle: "trx-server vX.Y.Z hosted by CALL"
|
|
if (serverSubtitle) {
|
|
if (update.server_version && update.server_callsign) {
|
|
const safeCallsign = escapeMapHtml(update.server_callsign);
|
|
const encodedCallsign = encodeURIComponent(update.server_callsign);
|
|
serverSubtitle.innerHTML =
|
|
`trx-server v${update.server_version} hosted by <a href="https://qrzcq.com/call/${encodedCallsign}" target="_blank" rel="noopener">${safeCallsign}</a>`;
|
|
} else if (update.server_version) {
|
|
serverSubtitle.textContent = `trx-server v${update.server_version}`;
|
|
} else if (update.server_callsign) {
|
|
const safeCallsign = escapeMapHtml(update.server_callsign);
|
|
const encodedCallsign = encodeURIComponent(update.server_callsign);
|
|
serverSubtitle.innerHTML =
|
|
`trx-server hosted by <a href="https://qrzcq.com/call/${encodedCallsign}" target="_blank" rel="noopener">${safeCallsign}</a>`;
|
|
}
|
|
}
|
|
updateRigSubtitle(update.active_rig_id);
|
|
if (ownerSubtitle) {
|
|
if (ownerCallsign) {
|
|
const safeOwner = escapeMapHtml(ownerCallsign);
|
|
const encodedOwner = encodeURIComponent(ownerCallsign);
|
|
ownerSubtitle.innerHTML =
|
|
`Owner: <a href="https://qrzcq.com/call/${encodedOwner}" target="_blank" rel="noopener">${safeOwner}</a>`;
|
|
} else {
|
|
ownerSubtitle.textContent = "Owner: --";
|
|
}
|
|
}
|
|
setDisabled(false);
|
|
if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) {
|
|
const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean);
|
|
if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) {
|
|
supportedModes = modes;
|
|
modeEl.innerHTML = "";
|
|
supportedModes.forEach((m) => {
|
|
const opt = document.createElement("option");
|
|
opt.value = m;
|
|
opt.textContent = m;
|
|
modeEl.appendChild(opt);
|
|
});
|
|
}
|
|
}
|
|
if (update.info && update.info.capabilities) {
|
|
updateJogStepSupport(update.info.capabilities);
|
|
updateSupportedBands(update.info.capabilities);
|
|
applyCapabilities(update.info.capabilities);
|
|
}
|
|
// Sync filter state (SDR backends only)
|
|
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
|
|
currentBandwidthHz = update.filter.bandwidth_hz;
|
|
syncBandwidthInput(currentBandwidthHz);
|
|
if (
|
|
sdrGainEl
|
|
&& typeof update.filter.sdr_gain_db === "number"
|
|
&& document.activeElement !== sdrGainEl
|
|
) {
|
|
sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db));
|
|
}
|
|
if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") {
|
|
wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us);
|
|
}
|
|
if (wfmAudioModeEl && typeof update.filter.wfm_stereo === "boolean") {
|
|
const nextMode = update.filter.wfm_stereo ? "stereo" : "mono";
|
|
if (wfmAudioModeEl.value !== nextMode) {
|
|
wfmAudioModeEl.value = nextMode;
|
|
saveSetting("wfmAudioMode", nextMode);
|
|
}
|
|
}
|
|
if (wfmDenoiseEl && typeof update.filter.wfm_denoise === "boolean") {
|
|
const nextDenoise = update.filter.wfm_denoise ? "on" : "off";
|
|
if (wfmDenoiseEl.value !== nextDenoise) {
|
|
wfmDenoiseEl.value = nextDenoise;
|
|
saveSetting("wfmDenoise", nextDenoise);
|
|
}
|
|
}
|
|
if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") {
|
|
const detected = update.filter.wfm_stereo_detected;
|
|
wfmStFlagEl.textContent = detected ? "ST" : "MO";
|
|
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
|
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
|
|
}
|
|
}
|
|
if (sdrGainControlsEl && typeof update.show_sdr_gain_control === "boolean") {
|
|
sdrGainControlsEl.style.display = update.show_sdr_gain_control ? "" : "none";
|
|
}
|
|
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
|
applyLocalTunedFrequency(update.status.freq.hz, true);
|
|
}
|
|
if (update.status && update.status.mode) {
|
|
const mode = normalizeMode(update.status.mode);
|
|
const modeUpper = mode ? mode.toUpperCase() : "";
|
|
modeEl.value = modeUpper;
|
|
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
|
setJogDivisor(10);
|
|
}
|
|
lastModeName = modeUpper;
|
|
updateWfmControls();
|
|
// When filter panel is active (SDR backend), update the BW slider range
|
|
// to match the new mode — but only if the server hasn't already sent a
|
|
// filter state that overrides it.
|
|
// When SDR backend is active (spectrum visible), apply BW default for new
|
|
// mode — but only if the server hasn't already pushed a filter_state.
|
|
if (lastSpectrumData && !update.filter) {
|
|
applyBwDefaultForMode(mode, false);
|
|
}
|
|
}
|
|
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
|
const aprsStatus = document.getElementById("aprs-status");
|
|
const cwStatus = document.getElementById("cw-status");
|
|
const ft8Status = document.getElementById("ft8-status");
|
|
const wsprStatus = document.getElementById("wspr-status");
|
|
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
|
|
aprsStatus.textContent = "Connected, listening for packets";
|
|
}
|
|
if (window.updateAprsBar) window.updateAprsBar();
|
|
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
|
|
cwStatus.textContent = "Connected, listening for packets";
|
|
}
|
|
const ft8Enabled = !!update.ft8_decode_enabled;
|
|
if (ft8Status && (!ft8Enabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && ft8Status.textContent === "Receiving") {
|
|
ft8Status.textContent = "Connected, listening for packets";
|
|
}
|
|
const wsprEnabled = !!update.wspr_decode_enabled;
|
|
if (wsprStatus && (!wsprEnabled || (modeUpper !== "DIG" && modeUpper !== "USB")) && wsprStatus.textContent === "Receiving") {
|
|
wsprStatus.textContent = "Connected, listening for packets";
|
|
}
|
|
if (update.status && typeof update.status.tx_en === "boolean") {
|
|
lastTxEn = update.status.tx_en;
|
|
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
|
|
if (update.status.tx_en) {
|
|
pttBtn.style.background = "var(--accent-red)";
|
|
pttBtn.style.borderColor = "var(--accent-red)";
|
|
pttBtn.style.color = "white";
|
|
} else {
|
|
pttBtn.style.background = "";
|
|
pttBtn.style.borderColor = "";
|
|
pttBtn.style.color = "";
|
|
}
|
|
}
|
|
const ft8ToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
|
if (ft8ToggleBtn) {
|
|
const ft8On = !!update.ft8_decode_enabled;
|
|
ft8ToggleBtn.textContent = ft8On ? "Disable FT8" : "Enable FT8";
|
|
ft8ToggleBtn.style.borderColor = ft8On ? "#00d17f" : "";
|
|
ft8ToggleBtn.style.color = ft8On ? "#00d17f" : "";
|
|
}
|
|
const wsprToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
|
if (wsprToggleBtn) {
|
|
const wsprOn = !!update.wspr_decode_enabled;
|
|
wsprToggleBtn.textContent = wsprOn ? "Disable WSPR" : "Enable WSPR";
|
|
wsprToggleBtn.style.borderColor = wsprOn ? "#00d17f" : "";
|
|
wsprToggleBtn.style.color = wsprOn ? "#00d17f" : "";
|
|
}
|
|
const cwAutoEl = document.getElementById("cw-auto");
|
|
const cwWpmEl = document.getElementById("cw-wpm");
|
|
const cwToneEl = document.getElementById("cw-tone");
|
|
if (cwAutoEl && typeof update.cw_auto === "boolean") {
|
|
cwAutoEl.checked = update.cw_auto;
|
|
}
|
|
if (cwWpmEl && typeof update.cw_wpm === "number") {
|
|
cwWpmEl.value = update.cw_wpm;
|
|
}
|
|
if (cwToneEl && typeof update.cw_tone_hz === "number") {
|
|
cwToneEl.value = update.cw_tone_hz;
|
|
}
|
|
if (cwWpmEl && cwToneEl && typeof update.cw_auto === "boolean") {
|
|
const disabled = update.cw_auto;
|
|
cwWpmEl.disabled = disabled;
|
|
cwWpmEl.readOnly = disabled;
|
|
cwToneEl.disabled = disabled;
|
|
cwToneEl.readOnly = disabled;
|
|
}
|
|
let activeFreqColor = "var(--accent-green)";
|
|
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
|
|
const entries = update.status.vfo.entries;
|
|
const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null;
|
|
vfoPicker.innerHTML = "";
|
|
entries.forEach((entry, idx) => {
|
|
const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null;
|
|
if (hz === null) return;
|
|
const mode = entry.mode ? normalizeMode(entry.mode) : "";
|
|
const modeText = mode ? ` [${mode}]` : "";
|
|
const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`;
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = label;
|
|
const color = vfoColor(idx);
|
|
if (activeIdx === idx) {
|
|
btn.classList.add("active");
|
|
btn.style.color = color;
|
|
activeFreqColor = color;
|
|
} else btn.addEventListener("click", async () => {
|
|
btn.disabled = true;
|
|
showHint("Toggling VFO…");
|
|
try {
|
|
await postPath("/toggle_vfo");
|
|
showHint("VFO toggled", 1200);
|
|
} catch (err) {
|
|
showHint("VFO toggle failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
vfoPicker.appendChild(btn);
|
|
});
|
|
} else {
|
|
vfoPicker.innerHTML = "<button type=\"button\" class=\"active\">--</button>";
|
|
}
|
|
if (freqEl) {
|
|
freqEl.style.color = activeFreqColor;
|
|
}
|
|
if (update.status && update.status.rx && typeof update.status.rx.sig === "number") {
|
|
const sUnits = dbmToSUnits(update.status.rx.sig);
|
|
sigLastSUnits = sUnits;
|
|
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, (sUnits / 9) * 100)) : 100;
|
|
signalBar.style.width = `${pct}%`;
|
|
signalValue.textContent = formatSignal(sUnits);
|
|
} else {
|
|
sigLastSUnits = null;
|
|
signalBar.style.width = "0%";
|
|
signalValue.textContent = "--";
|
|
}
|
|
if (bandLabel) {
|
|
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
|
|
}
|
|
if (typeof update.enabled === "boolean") {
|
|
powerBtn.disabled = false;
|
|
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
|
|
} else {
|
|
powerBtn.disabled = true;
|
|
powerBtn.textContent = "Toggle Power";
|
|
powerHint.textContent = "State unknown";
|
|
}
|
|
lastControl = update.enabled;
|
|
|
|
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
|
|
txLimitInput.value = update.status.tx.limit;
|
|
txLimitRow.style.display = "";
|
|
} else {
|
|
txLimitInput.value = "";
|
|
txLimitRow.style.display = "none";
|
|
}
|
|
|
|
if (typeof update.clients === "number") lastClientCount = update.clients;
|
|
// Populate About tab
|
|
if (update.server_version) {
|
|
document.getElementById("about-server-ver").textContent = `trx-server v${update.server_version}`;
|
|
}
|
|
document.getElementById("about-server-addr").textContent = location.host;
|
|
if (update.server_callsign) {
|
|
document.getElementById("about-server-call").textContent = update.server_callsign;
|
|
}
|
|
if (update.pskreporter_status) {
|
|
document.getElementById("about-pskreporter").textContent = update.pskreporter_status;
|
|
}
|
|
if (update.info) {
|
|
const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" ");
|
|
if (parts) document.getElementById("about-rig-info").textContent = parts;
|
|
const access = update.info.access;
|
|
if (access) {
|
|
if (access.Serial) {
|
|
const serialPath = access.Serial.path || access.Serial.port || "?";
|
|
document.getElementById("about-rig-access").textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`;
|
|
} else if (access.Tcp) {
|
|
document.getElementById("about-rig-access").textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`;
|
|
} else {
|
|
const key = Object.keys(access)[0];
|
|
if (key) document.getElementById("about-rig-access").textContent = key;
|
|
}
|
|
}
|
|
if (update.info.capabilities) {
|
|
const cap = update.info.capabilities;
|
|
if (Array.isArray(cap.supported_modes) && cap.supported_modes.length) {
|
|
document.getElementById("about-modes").textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", ");
|
|
}
|
|
if (typeof cap.num_vfos === "number") {
|
|
document.getElementById("about-vfos").textContent = cap.num_vfos;
|
|
}
|
|
}
|
|
}
|
|
if (typeof update.clients === "number") {
|
|
document.getElementById("about-clients").textContent = update.clients;
|
|
}
|
|
if (typeof update.active_rig_id === "string" && update.active_rig_id.length > 0) {
|
|
document.getElementById("about-active-rig").textContent = update.active_rig_id;
|
|
}
|
|
if (Array.isArray(update.rig_ids)) {
|
|
applyRigList(update.active_rig_id, update.rig_ids);
|
|
}
|
|
if (typeof update.rigctl_clients === "number") {
|
|
document.getElementById("about-rigctl-clients").textContent = update.rigctl_clients;
|
|
}
|
|
if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0) {
|
|
document.getElementById("about-rigctl-endpoint").textContent = update.rigctl_addr;
|
|
}
|
|
powerHint.textContent = readyText();
|
|
lastLocked = update.status && update.status.lock === true;
|
|
lockBtn.textContent = lastLocked ? "Unlock" : "Lock";
|
|
|
|
const tx = update.status && update.status.tx ? update.status.tx : null;
|
|
txMeters.style.display = lastHasTx ? "" : "none";
|
|
if (tx && typeof tx.power === "number") {
|
|
const pct = Math.max(0, Math.min(100, tx.power));
|
|
pwrBar.style.width = `${pct}%`;
|
|
pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`;
|
|
} else {
|
|
pwrBar.style.width = "0%";
|
|
pwrValue.textContent = "PWR --";
|
|
}
|
|
if (tx && typeof tx.swr === "number") {
|
|
const swr = Math.max(1, tx.swr);
|
|
const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100));
|
|
swrBar.style.width = `${pct}%`;
|
|
swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`;
|
|
} else {
|
|
swrBar.style.width = "0%";
|
|
swrValue.textContent = "SWR --";
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect(delayMs = 1000) {
|
|
if (reconnectTimer) return;
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
connect();
|
|
}, delayMs);
|
|
}
|
|
|
|
async function pollFreshSnapshot() {
|
|
try {
|
|
const resp = await fetch("/status", { cache: "no-store" });
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
render(data);
|
|
refreshRigList();
|
|
lastEventAt = Date.now();
|
|
} catch (e) {
|
|
// Ignore network errors; connect() retry loop handles reconnection.
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
if (es) {
|
|
es.close();
|
|
}
|
|
if (esHeartbeat) {
|
|
clearInterval(esHeartbeat);
|
|
}
|
|
pollFreshSnapshot();
|
|
es = new EventSource("/events");
|
|
lastEventAt = Date.now();
|
|
es.onopen = () => {
|
|
pollFreshSnapshot();
|
|
refreshRigList();
|
|
};
|
|
es.onmessage = (evt) => {
|
|
try {
|
|
if (evt.data === lastRendered) return;
|
|
const data = JSON.parse(evt.data);
|
|
lastRendered = evt.data;
|
|
render(data);
|
|
lastEventAt = Date.now();
|
|
if (data.initialized) {
|
|
powerHint.textContent = readyText();
|
|
}
|
|
} catch (e) {
|
|
console.error("Bad event data", e);
|
|
}
|
|
};
|
|
es.onerror = () => {
|
|
// Check if this is an auth error by looking at readyState
|
|
if (es.readyState === EventSource.CLOSED) {
|
|
powerHint.textContent = "Disconnected, retrying…";
|
|
es.close();
|
|
pollFreshSnapshot();
|
|
scheduleReconnect(1000);
|
|
}
|
|
};
|
|
|
|
esHeartbeat = setInterval(() => {
|
|
const now = Date.now();
|
|
if (now - lastEventAt > 15000) {
|
|
es.close();
|
|
pollFreshSnapshot();
|
|
scheduleReconnect(250);
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function disconnect() {
|
|
// Close event sources
|
|
if (es) {
|
|
es.close();
|
|
es = null;
|
|
}
|
|
if (decodeSource) {
|
|
decodeSource.close();
|
|
decodeSource = null;
|
|
}
|
|
stopSpectrumStreaming();
|
|
// Clear timers
|
|
if (esHeartbeat) {
|
|
clearInterval(esHeartbeat);
|
|
esHeartbeat = null;
|
|
}
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
async function postPath(path) {
|
|
const resp = await fetch(path, { method: "POST" });
|
|
if (authEnabled && resp.status === 401) {
|
|
// Not authenticated - return to login
|
|
authRole = null;
|
|
if (es) es.close();
|
|
showAuthGate();
|
|
throw new Error("Authentication required");
|
|
}
|
|
if (resp.status === 403) {
|
|
// Authenticated but insufficient permissions - don't redirect
|
|
throw new Error("Insufficient permissions");
|
|
}
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error(text || resp.statusText);
|
|
}
|
|
return resp;
|
|
}
|
|
|
|
async function switchRigFromSelect(selectEl) {
|
|
if (!selectEl || !selectEl.value) {
|
|
showHint("No rig selected", 1500);
|
|
return;
|
|
}
|
|
if (authRole === "rx") {
|
|
showHint("Control role required", 1500);
|
|
return;
|
|
}
|
|
if (!lastRigIds.includes(selectEl.value)) {
|
|
showHint("Unknown rig", 1500);
|
|
return;
|
|
}
|
|
selectEl.disabled = true;
|
|
showHint("Switching rig…");
|
|
try {
|
|
await postPath(`/select_rig?rig_id=${encodeURIComponent(selectEl.value)}`);
|
|
refreshRigList();
|
|
showHint("Rig switch requested", 1500);
|
|
} catch (err) {
|
|
showHint("Rig switch failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
|
selectEl.disabled = disableSwitch;
|
|
}
|
|
}
|
|
|
|
if (headerRigSwitchSelect) {
|
|
headerRigSwitchSelect.addEventListener("change", () => { switchRigFromSelect(headerRigSwitchSelect); });
|
|
}
|
|
|
|
powerBtn.addEventListener("click", async () => {
|
|
powerBtn.disabled = true;
|
|
showHint("Sending...");
|
|
try {
|
|
await postPath("/toggle_power");
|
|
showHint("Toggled, waiting for update…");
|
|
} catch (err) {
|
|
showHint("Toggle failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
powerBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
pttBtn.addEventListener("click", async () => {
|
|
pttBtn.disabled = true;
|
|
showHint("Toggling PTT…");
|
|
try {
|
|
const desired = lastTxEn ? "false" : "true";
|
|
await postPath(`/set_ptt?ptt=${desired}`);
|
|
showHint("PTT command sent", 1500);
|
|
} catch (err) {
|
|
showHint("PTT toggle failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
pttBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
async function applyFreqFromInput() {
|
|
const parsedRaw = parseFreqInput(freqEl.value, jogUnit);
|
|
const parsed = alignFreqToRigStep(parsedRaw);
|
|
if (parsed === null) {
|
|
showHint("Freq missing", 1500);
|
|
return;
|
|
}
|
|
if (!freqAllowed(parsed)) {
|
|
showHint("Out of supported bands", 1500);
|
|
return;
|
|
}
|
|
freqDirty = false;
|
|
freqEl.disabled = true;
|
|
showHint("Setting frequency…");
|
|
try {
|
|
await postPath(`/set_freq?hz=${parsed}`);
|
|
applyLocalTunedFrequency(parsed);
|
|
showHint("Freq set", 1500);
|
|
} catch (err) {
|
|
showHint("Set freq failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
freqEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function applyCenterFreqFromInput() {
|
|
if (!centerFreqEl) return;
|
|
const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit);
|
|
const parsed = alignFreqToRigStep(parsedRaw);
|
|
if (parsed === null) {
|
|
showHint("Central freq missing", 1500);
|
|
return;
|
|
}
|
|
if (!freqAllowed(parsed)) {
|
|
showHint("Out of supported bands", 1500);
|
|
return;
|
|
}
|
|
centerFreqDirty = false;
|
|
centerFreqEl.disabled = true;
|
|
showHint("Setting central frequency…");
|
|
try {
|
|
await postPath(`/set_center_freq?hz=${parsed}`);
|
|
showHint("Central freq set", 1500);
|
|
} catch (err) {
|
|
showHint("Set central freq failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
centerFreqEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
freqEl.addEventListener("keydown", (e) => {
|
|
freqDirty = true;
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyFreqFromInput();
|
|
}
|
|
});
|
|
if (centerFreqEl) {
|
|
centerFreqEl.addEventListener("keydown", (e) => {
|
|
centerFreqDirty = true;
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyCenterFreqFromInput();
|
|
}
|
|
});
|
|
}
|
|
freqEl.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const direction = e.deltaY < 0 ? 1 : -1;
|
|
jogFreq(direction);
|
|
}, { passive: false });
|
|
|
|
// --- Jog wheel ---
|
|
const jogWheel = document.getElementById("jog-wheel");
|
|
const jogIndicator = document.getElementById("jog-indicator");
|
|
const jogDownBtn = document.getElementById("jog-down");
|
|
const jogUpBtn = document.getElementById("jog-up");
|
|
const jogStepEl = document.getElementById("jog-step");
|
|
const jogMultEl = document.getElementById("jog-mult");
|
|
const VALID_JOG_DIVISORS = new Set([1, 10]);
|
|
|
|
function applyJogStep() {
|
|
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
|
saveSetting("jogUnit", jogUnit);
|
|
saveSetting("jogMult", jogMult);
|
|
saveSetting("jogStep", jogStep);
|
|
refreshFreqDisplay();
|
|
refreshCenterFreqDisplay();
|
|
}
|
|
|
|
function setJogDivisor(divisor) {
|
|
const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1;
|
|
jogMult = next;
|
|
if (jogMultEl) {
|
|
jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => {
|
|
b.classList.toggle("active", parseInt(b.dataset.mult, 10) === jogMult);
|
|
});
|
|
}
|
|
applyJogStep();
|
|
}
|
|
|
|
async function jogFreq(direction) {
|
|
if (lastLocked) { showHint("Locked", 1500); return; }
|
|
if (lastFreqHz === null) return;
|
|
const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep);
|
|
if (!freqAllowed(newHz)) {
|
|
showHint("Out of supported bands", 1500);
|
|
return;
|
|
}
|
|
jogAngle = (jogAngle + direction * 15) % 360;
|
|
jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`;
|
|
showHint("Setting frequency…");
|
|
try {
|
|
await postPath(`/set_freq?hz=${newHz}`);
|
|
applyLocalTunedFrequency(newHz);
|
|
showHint("Freq set", 1000);
|
|
} catch (err) {
|
|
showHint("Set freq failed", 2000);
|
|
console.error(err);
|
|
}
|
|
}
|
|
|
|
jogDownBtn.addEventListener("click", () => jogFreq(-1));
|
|
jogUpBtn.addEventListener("click", () => jogFreq(1));
|
|
|
|
jogWheel.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const direction = e.deltaY < 0 ? 1 : -1;
|
|
jogFreq(direction);
|
|
}, { passive: false });
|
|
|
|
// Touch drag on jog wheel
|
|
let jogTouchY = null;
|
|
jogWheel.addEventListener("touchstart", (e) => {
|
|
e.preventDefault();
|
|
jogTouchY = e.touches[0].clientY;
|
|
}, { passive: false });
|
|
jogWheel.addEventListener("touchmove", (e) => {
|
|
e.preventDefault();
|
|
if (jogTouchY === null) return;
|
|
const dy = jogTouchY - e.touches[0].clientY;
|
|
if (Math.abs(dy) > 12) {
|
|
jogFreq(dy > 0 ? 1 : -1);
|
|
jogTouchY = e.touches[0].clientY;
|
|
}
|
|
}, { passive: false });
|
|
jogWheel.addEventListener("touchend", () => { jogTouchY = null; });
|
|
|
|
// Mouse drag on jog wheel
|
|
let jogMouseY = null;
|
|
jogWheel.addEventListener("mousedown", (e) => {
|
|
e.preventDefault();
|
|
jogMouseY = e.clientY;
|
|
jogWheel.style.cursor = "grabbing";
|
|
});
|
|
window.addEventListener("mousemove", (e) => {
|
|
if (jogMouseY === null) return;
|
|
const dy = jogMouseY - e.clientY;
|
|
if (Math.abs(dy) > 10) {
|
|
jogFreq(dy > 0 ? 1 : -1);
|
|
jogMouseY = e.clientY;
|
|
}
|
|
});
|
|
window.addEventListener("mouseup", () => {
|
|
jogMouseY = null;
|
|
if (jogWheel) jogWheel.style.cursor = "grab";
|
|
});
|
|
|
|
// Step unit selector
|
|
jogStepEl.addEventListener("click", (e) => {
|
|
const btn = e.target.closest("button[data-step]");
|
|
if (!btn) return;
|
|
jogUnit = parseInt(btn.dataset.step, 10);
|
|
jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
applyJogStep();
|
|
});
|
|
|
|
// Step multiplier selector
|
|
if (jogMultEl) {
|
|
jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => {
|
|
const divisor = parseInt(btn.dataset.mult, 10);
|
|
if (!VALID_JOG_DIVISORS.has(divisor)) {
|
|
btn.remove();
|
|
}
|
|
});
|
|
jogMultEl.addEventListener("click", (e) => {
|
|
const btn = e.target.closest("button[data-mult]");
|
|
if (!btn) return;
|
|
setJogDivisor(parseInt(btn.dataset.mult, 10));
|
|
});
|
|
}
|
|
|
|
// Restore active jog step buttons from saved settings
|
|
{
|
|
const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]"));
|
|
const activeUnit =
|
|
unitBtns.find((b) => parseInt(b.dataset.step, 10) === jogUnit) ||
|
|
unitBtns.find((b) => parseInt(b.dataset.step, 10) === 1000) ||
|
|
unitBtns[0];
|
|
if (activeUnit) {
|
|
jogUnit = parseInt(activeUnit.dataset.step, 10);
|
|
unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit));
|
|
}
|
|
if (jogMultEl) {
|
|
const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]"));
|
|
const activeMult =
|
|
multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) ||
|
|
multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) ||
|
|
multBtns[0];
|
|
if (activeMult) {
|
|
jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult, 10))
|
|
? parseInt(activeMult.dataset.mult, 10)
|
|
: 1;
|
|
multBtns.forEach((b) => b.classList.toggle("active", b === activeMult));
|
|
} else {
|
|
jogMult = 1;
|
|
}
|
|
}
|
|
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
|
}
|
|
|
|
async function applyModeFromPicker() {
|
|
const mode = modeEl.value || "";
|
|
if (!mode) {
|
|
showHint("Mode missing", 1500);
|
|
return;
|
|
}
|
|
updateWfmControls();
|
|
modeEl.disabled = true;
|
|
showHint("Setting mode…");
|
|
try {
|
|
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
|
|
showHint("Mode set", 1500);
|
|
if (mode.toUpperCase() === "WFM") {
|
|
setJogDivisor(10);
|
|
}
|
|
// Apply sensible default bandwidth for the new mode and push to server.
|
|
await applyBwDefaultForMode(mode, true);
|
|
} catch (err) {
|
|
showHint("Set mode failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
modeEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
modeEl.addEventListener("change", applyModeFromPicker);
|
|
|
|
txLimitInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
txLimitBtn.click();
|
|
}
|
|
});
|
|
|
|
txLimitBtn.addEventListener("click", async () => {
|
|
const limit = txLimitInput.value;
|
|
if (limit === "" || limit === "--") {
|
|
showHint("Limit missing", 1500);
|
|
return;
|
|
}
|
|
txLimitBtn.disabled = true;
|
|
showHint("Setting TX limit…");
|
|
try {
|
|
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
|
showHint("TX limit set", 1500);
|
|
} catch (err) {
|
|
showHint("TX limit failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
txLimitBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
lockBtn.addEventListener("click", async () => {
|
|
lockBtn.disabled = true;
|
|
showHint("Toggling lock…");
|
|
try {
|
|
const nextLock = lockBtn.textContent === "Lock";
|
|
await postPath(nextLock ? "/lock" : "/unlock");
|
|
showHint("Lock toggled", 1500);
|
|
} catch (err) {
|
|
showHint("Lock toggle failed", 2000);
|
|
console.error(err);
|
|
} finally {
|
|
lockBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// --- Filter controls ---
|
|
|
|
// Per-mode defaults: [default bandwidth Hz, min Hz, max Hz, step Hz]
|
|
const MODE_BW_DEFAULTS = {
|
|
CW: [500, 50, 2_000, 50],
|
|
CWR: [500, 50, 2_000, 50],
|
|
LSB: [2_700, 300, 6_000, 100],
|
|
USB: [2_700, 300, 6_000, 100],
|
|
AM: [9_000, 500, 20_000, 500],
|
|
FM: [12_500, 2_500, 25_000, 500],
|
|
WFM: [180_000, 50_000,300_000,5_000],
|
|
DIG: [3_000, 300, 6_000, 100],
|
|
PKT: [25_000, 300, 50_000, 500],
|
|
};
|
|
const MODE_BW_FALLBACK = [3_000, 300, 500_000, 100];
|
|
|
|
function mwDefaultsForMode(mode) {
|
|
return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK;
|
|
}
|
|
|
|
function formatBwLabel(hz) {
|
|
if (hz >= 1000) return (hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1) + " kHz";
|
|
return hz + " Hz";
|
|
}
|
|
|
|
// Current receive bandwidth (Hz) — updated by server sync and BW drag.
|
|
let currentBandwidthHz = 3_000;
|
|
const spectrumBwInput = document.getElementById("spectrum-bw-input");
|
|
const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn");
|
|
const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn");
|
|
|
|
function formatBandwidthInputKhz(hz) {
|
|
const khz = hz / 1000;
|
|
if (Math.abs(Math.round(khz) - khz) < 0.0001) return String(Math.round(khz));
|
|
if (Math.abs(Math.round(khz * 10) - khz * 10) < 0.0001) return khz.toFixed(1);
|
|
return khz.toFixed(2);
|
|
}
|
|
|
|
function syncBandwidthInput(hz) {
|
|
if (!spectrumBwInput || !Number.isFinite(hz) || hz <= 0) return;
|
|
const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
|
spectrumBwInput.min = String(minBw / 1000);
|
|
spectrumBwInput.max = String(maxBw / 1000);
|
|
spectrumBwInput.step = String(stepBw / 1000);
|
|
spectrumBwInput.value = formatBandwidthInputKhz(hz);
|
|
}
|
|
|
|
// Apply mode-specific BW default and optionally push to server.
|
|
async function applyBwDefaultForMode(mode, sendToServer) {
|
|
const [def] = mwDefaultsForMode(mode);
|
|
currentBandwidthHz = def;
|
|
syncBandwidthInput(def);
|
|
if (sendToServer) {
|
|
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
async function applyBandwidthFromInput() {
|
|
if (!spectrumBwInput) return;
|
|
const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
|
const nextKhz = Number(spectrumBwInput.value);
|
|
const next = Math.round(nextKhz * 1000);
|
|
if (!Number.isFinite(next) || next <= 0) {
|
|
syncBandwidthInput(currentBandwidthHz);
|
|
return;
|
|
}
|
|
const clamped = Math.max(minBw, Math.min(maxBw, next));
|
|
currentBandwidthHz = clamped;
|
|
syncBandwidthInput(clamped);
|
|
if (lastSpectrumData) scheduleSpectrumDraw();
|
|
try { await postPath(`/set_bandwidth?hz=${clamped}`); } catch (_) {}
|
|
}
|
|
|
|
function estimateBandwidthAroundPeak(data, centerHz) {
|
|
if (!data || !Array.isArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) {
|
|
return null;
|
|
}
|
|
|
|
const bins = data.bins;
|
|
const maxIdx = bins.length - 1;
|
|
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
|
const centerIdx = Math.max(
|
|
1,
|
|
Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
const searchRadius = Math.max(6, Math.min(120, Math.round(maxIdx * 0.03)));
|
|
const searchLo = Math.max(1, centerIdx - searchRadius);
|
|
const searchHi = Math.min(maxIdx - 1, centerIdx + searchRadius);
|
|
|
|
let peakIdx = centerIdx;
|
|
for (let i = searchLo; i <= searchHi; i++) {
|
|
if (bins[i] > bins[peakIdx]) peakIdx = i;
|
|
}
|
|
|
|
const sorted = [...bins].sort((a, b) => a - b);
|
|
const noise = sorted[Math.floor(sorted.length * 0.2)];
|
|
const peak = bins[peakIdx];
|
|
const threshold = Math.max(noise + 4, peak - Math.max(8, (peak - noise) * 0.35));
|
|
|
|
let left = peakIdx;
|
|
let right = peakIdx;
|
|
let belowCount = 0;
|
|
for (let i = peakIdx; i > 1; i--) {
|
|
if (bins[i] < threshold) belowCount += 1;
|
|
else belowCount = 0;
|
|
if (belowCount >= 2) break;
|
|
left = i;
|
|
}
|
|
|
|
belowCount = 0;
|
|
for (let i = peakIdx; i < maxIdx - 1; i++) {
|
|
if (bins[i] < threshold) belowCount += 1;
|
|
else belowCount = 0;
|
|
if (belowCount >= 2) break;
|
|
right = i;
|
|
}
|
|
|
|
const shoulderPad = Math.max(1, Math.round((right - left) * 0.08));
|
|
left = Math.max(0, left - shoulderPad);
|
|
right = Math.min(maxIdx, right + shoulderPad);
|
|
|
|
const hzPerBin = data.sample_rate / maxIdx;
|
|
const rawBw = Math.max(hzPerBin, (right - left) * hzPerBin);
|
|
const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
|
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
|
|
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
|
|
}
|
|
|
|
async function applyAutoBandwidth() {
|
|
if (!lastSpectrumData || lastFreqHz == null) return;
|
|
const estimated = estimateBandwidthAroundPeak(lastSpectrumData, lastFreqHz);
|
|
if (!Number.isFinite(estimated) || estimated <= 0) {
|
|
syncBandwidthInput(currentBandwidthHz);
|
|
return;
|
|
}
|
|
currentBandwidthHz = estimated;
|
|
syncBandwidthInput(estimated);
|
|
if (lastSpectrumData) scheduleSpectrumDraw();
|
|
try {
|
|
await postPath(`/set_bandwidth?hz=${estimated}`);
|
|
} catch (_) {}
|
|
}
|
|
|
|
if (spectrumBwInput) {
|
|
spectrumBwInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyBandwidthFromInput();
|
|
}
|
|
});
|
|
}
|
|
if (spectrumBwSetBtn) {
|
|
spectrumBwSetBtn.addEventListener("click", () => { applyBandwidthFromInput(); });
|
|
}
|
|
if (spectrumBwAutoBtn) {
|
|
spectrumBwAutoBtn.addEventListener("click", () => { applyAutoBandwidth(); });
|
|
}
|
|
|
|
// --- Tab navigation ---
|
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
|
const btn = e.target.closest(".tab[data-tab]");
|
|
if (!btn) return;
|
|
if (authEnabled && !authRole && btn.dataset.tab !== "main") return;
|
|
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
|
document.getElementById(`tab-${btn.dataset.tab}`).style.display = "";
|
|
if (btn.dataset.tab === "map") {
|
|
initAprsMap();
|
|
sizeAprsMapToViewport();
|
|
if (aprsMap) setTimeout(() => aprsMap.invalidateSize(), 50);
|
|
}
|
|
});
|
|
|
|
// --- Auth startup sequence ---
|
|
async function initializeApp() {
|
|
showAuthGate(false);
|
|
const authStatus = await checkAuthStatus();
|
|
authEnabled = !authStatus.auth_disabled;
|
|
|
|
if (!authEnabled) {
|
|
authRole = "control";
|
|
hideAuthGate();
|
|
updateAuthUI();
|
|
connect();
|
|
resizeHeaderSignalCanvas();
|
|
startHeaderSignalSampling();
|
|
return;
|
|
}
|
|
|
|
if (authStatus.authenticated) {
|
|
// User has valid session
|
|
authRole = authStatus.role;
|
|
hideAuthGate();
|
|
updateAuthUI();
|
|
applyAuthRestrictions();
|
|
connect();
|
|
resizeHeaderSignalCanvas();
|
|
startHeaderSignalSampling();
|
|
} else {
|
|
// No valid session - show auth gate
|
|
// Guest button is shown if guest mode is available (role granted without auth)
|
|
const allowGuest = authStatus.role === "rx";
|
|
showAuthGate(allowGuest);
|
|
}
|
|
}
|
|
|
|
// Setup auth form
|
|
document.getElementById("auth-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const passphrase = document.getElementById("auth-passphrase").value;
|
|
const btn = document.querySelector("#auth-form button[type=submit]");
|
|
btn.disabled = true;
|
|
btn.textContent = "Logging in...";
|
|
|
|
try {
|
|
const result = await authLogin(passphrase);
|
|
authRole = result.role;
|
|
document.getElementById("auth-passphrase").value = "";
|
|
hideAuthGate();
|
|
updateAuthUI();
|
|
applyAuthRestrictions();
|
|
connect();
|
|
resizeHeaderSignalCanvas();
|
|
startHeaderSignalSampling();
|
|
} catch (err) {
|
|
showAuthError("Invalid passphrase");
|
|
console.error("Login error:", err);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = "Login";
|
|
}
|
|
});
|
|
|
|
// Setup guest button
|
|
const guestBtn = document.getElementById("auth-guest-btn");
|
|
if (guestBtn) {
|
|
guestBtn.addEventListener("click", async () => {
|
|
authRole = "rx";
|
|
document.getElementById("auth-passphrase").value = "";
|
|
hideAuthGate();
|
|
updateAuthUI();
|
|
applyAuthRestrictions();
|
|
connect();
|
|
resizeHeaderSignalCanvas();
|
|
startHeaderSignalSampling();
|
|
});
|
|
}
|
|
|
|
// Setup header auth button (Login/Logout)
|
|
const headerAuthBtn = document.getElementById("header-auth-btn");
|
|
if (headerAuthBtn) {
|
|
headerAuthBtn.addEventListener("click", async () => {
|
|
if (authRole) {
|
|
// Logged in - show logout confirmation
|
|
if (confirm("Are you sure you want to logout?")) {
|
|
await authLogout();
|
|
}
|
|
} else {
|
|
// Not logged in - show auth gate
|
|
showAuthGate(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start the app
|
|
initializeApp();
|
|
window.addEventListener("resize", resizeHeaderSignalCanvas);
|
|
|
|
// --- Leaflet Map (lazy-initialized) ---
|
|
let aprsMap = null;
|
|
let aprsMapBaseLayer = null;
|
|
let aprsMapReceiverMarker = null;
|
|
let aprsRadioPath = null;
|
|
const stationMarkers = new Map();
|
|
const locatorMarkers = new Map();
|
|
const mapMarkers = new Set();
|
|
const mapFilter = { aprs: true, ft8: true, wspr: true };
|
|
|
|
window.clearMapMarkersByType = function(type) {
|
|
if (type === "aprs") {
|
|
stationMarkers.forEach((entry) => {
|
|
if (entry && entry.marker) {
|
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
|
mapMarkers.delete(entry.marker);
|
|
}
|
|
});
|
|
stationMarkers.clear();
|
|
return;
|
|
}
|
|
|
|
if (type === "ft8" || type === "wspr") {
|
|
const prefix = `${type}:`;
|
|
for (const [key, entry] of locatorMarkers.entries()) {
|
|
if (!key.startsWith(prefix)) continue;
|
|
if (entry && entry.marker) {
|
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
|
mapMarkers.delete(entry.marker);
|
|
}
|
|
locatorMarkers.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
function mapTileSpecForTheme(theme) {
|
|
if (theme === "dark") {
|
|
return {
|
|
url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
|
|
options: {
|
|
maxZoom: 19,
|
|
subdomains: "abcd",
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
|
options: {
|
|
maxZoom: 19,
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
},
|
|
};
|
|
}
|
|
|
|
function updateMapBaseLayerForTheme(theme) {
|
|
if (!aprsMap) return;
|
|
if (aprsMapBaseLayer) {
|
|
aprsMap.removeLayer(aprsMapBaseLayer);
|
|
aprsMapBaseLayer = null;
|
|
}
|
|
const spec = mapTileSpecForTheme(theme);
|
|
aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap);
|
|
}
|
|
|
|
function initAprsMap() {
|
|
const mapEl = document.getElementById("aprs-map");
|
|
if (!mapEl) return;
|
|
sizeAprsMapToViewport();
|
|
if (aprsMap) return;
|
|
|
|
const hasLocation = serverLat != null && serverLon != null;
|
|
const center = hasLocation ? [serverLat, serverLon] : [20, 0];
|
|
const zoom = hasLocation ? initialMapZoom : 2;
|
|
|
|
aprsMap = L.map("aprs-map").setView(center, zoom);
|
|
updateMapBaseLayerForTheme(currentTheme());
|
|
|
|
if (hasLocation) {
|
|
aprsMapReceiverMarker = L.circleMarker([serverLat, serverLon], {
|
|
radius: 8, className: "trx-receiver-marker", fillOpacity: 0.8
|
|
}).addTo(aprsMap).bindPopup("");
|
|
}
|
|
|
|
// Rebuild popup content on open (keeps age/distance/rig list fresh)
|
|
aprsMap.on("popupopen", function(e) {
|
|
const marker = e.popup._source;
|
|
if (aprsRadioPath) { aprsRadioPath.remove(); aprsRadioPath = null; }
|
|
|
|
if (marker === aprsMapReceiverMarker) {
|
|
e.popup.setContent(buildReceiverPopupHtml());
|
|
return;
|
|
}
|
|
|
|
if (!marker || !marker._aprsCall) return;
|
|
const entry = stationMarkers.get(marker._aprsCall);
|
|
if (!entry) return;
|
|
const ll = marker.getLatLng();
|
|
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
|
if (serverLat != null && serverLon != null) {
|
|
aprsRadioPath = L.polyline(
|
|
[[serverLat, serverLon], [ll.lat, ll.lng]],
|
|
{ className: "aprs-radio-path", weight: 2, interactive: false }
|
|
).addTo(aprsMap);
|
|
}
|
|
});
|
|
|
|
aprsMap.on("popupclose", function() {
|
|
if (aprsRadioPath) { aprsRadioPath.remove(); aprsRadioPath = null; }
|
|
});
|
|
|
|
// Materialise any stations that were buffered before the map was ready
|
|
for (const [call, entry] of stationMarkers) {
|
|
if (entry.type === "aprs" && !entry.marker && entry.lat != null && entry.lon != null) {
|
|
_aprsAddMarkerToMap(call, entry);
|
|
}
|
|
}
|
|
applyMapFilter();
|
|
|
|
const aprsFilter = document.getElementById("map-filter-aprs");
|
|
const ft8Filter = document.getElementById("map-filter-ft8");
|
|
const wsprFilter = document.getElementById("map-filter-wspr");
|
|
if (aprsFilter) {
|
|
aprsFilter.addEventListener("change", () => {
|
|
mapFilter.aprs = aprsFilter.checked;
|
|
applyMapFilter();
|
|
});
|
|
}
|
|
if (ft8Filter) {
|
|
ft8Filter.addEventListener("change", () => {
|
|
mapFilter.ft8 = ft8Filter.checked;
|
|
applyMapFilter();
|
|
});
|
|
}
|
|
if (wsprFilter) {
|
|
wsprFilter.addEventListener("change", () => {
|
|
mapFilter.wspr = wsprFilter.checked;
|
|
applyMapFilter();
|
|
});
|
|
}
|
|
}
|
|
|
|
function sizeAprsMapToViewport() {
|
|
const mapEl = document.getElementById("aprs-map");
|
|
if (!mapEl) return;
|
|
const mapRect = mapEl.getBoundingClientRect();
|
|
const footer = document.querySelector(".footer");
|
|
let bottom = window.innerHeight;
|
|
if (footer) {
|
|
const fr = footer.getBoundingClientRect();
|
|
if (fr.top > mapRect.top + 50) bottom = fr.top;
|
|
}
|
|
const target = Math.max(150, Math.floor(bottom - mapRect.top - 8));
|
|
mapEl.style.height = `${target}px`;
|
|
if (aprsMap) aprsMap.invalidateSize();
|
|
}
|
|
|
|
function aprsSymbolIcon(symbolTable, symbolCode) {
|
|
if (!symbolTable || !symbolCode) return null;
|
|
const sheet = symbolTable === "/" ? 0 : 1;
|
|
const code = symbolCode.charCodeAt(0) - 33;
|
|
const col = code % 16;
|
|
const row = Math.floor(code / 16);
|
|
const bgX = -(col * 24);
|
|
const bgY = -(row * 24);
|
|
const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`;
|
|
return L.divIcon({
|
|
className: "",
|
|
html: `<div style="width:24px;height:24px;background:url('${url}') ${bgX}px ${bgY}px / 384px 192px no-repeat;"></div>`,
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 12],
|
|
popupAnchor: [0, -12]
|
|
});
|
|
}
|
|
|
|
window.navigateToAprsMap = function(lat, lon) {
|
|
// Activate the map tab
|
|
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
|
|
const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']");
|
|
if (mapTabBtn) mapTabBtn.classList.add("active");
|
|
document.querySelectorAll(".tab-panel").forEach((p) => (p.style.display = "none"));
|
|
const mapPanel = document.getElementById("tab-map");
|
|
if (mapPanel) mapPanel.style.display = "";
|
|
initAprsMap();
|
|
sizeAprsMapToViewport();
|
|
if (aprsMap) {
|
|
setTimeout(() => {
|
|
aprsMap.invalidateSize();
|
|
aprsMap.setView([lat, lon], 13);
|
|
}, 50);
|
|
}
|
|
};
|
|
|
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
const R = 6371;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat / 2) ** 2
|
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function formatTimeAgo(tsMs) {
|
|
if (!tsMs) return null;
|
|
const secs = Math.round((Date.now() - tsMs) / 1000);
|
|
if (secs < 60) return `${secs}s ago`;
|
|
const mins = Math.round(secs / 60);
|
|
if (mins < 60) return `${mins} min ago`;
|
|
const hrs = Math.floor(mins / 60);
|
|
const remMins = mins % 60;
|
|
return remMins > 0 ? `${hrs}h ${remMins}min ago` : `${hrs}h ago`;
|
|
}
|
|
|
|
function buildReceiverPopupHtml() {
|
|
const call = serverCallsign || ownerCallsign || "Receiver";
|
|
let meta = "";
|
|
if (serverVersion) {
|
|
meta = `trx-server v${escapeMapHtml(serverVersion)}`;
|
|
if (serverBuildDate) meta += ` · ${escapeMapHtml(serverBuildDate)}`;
|
|
}
|
|
let rows = "";
|
|
if (ownerCallsign && ownerCallsign !== serverCallsign) {
|
|
rows += `<tr><td class="aprs-popup-label">Owner</td><td>${escapeMapHtml(ownerCallsign)}</td></tr>`;
|
|
}
|
|
if (serverLat != null && serverLon != null) {
|
|
rows += `<tr><td class="aprs-popup-label">QTH</td><td>${serverLat.toFixed(5)}, ${serverLon.toFixed(5)}</td></tr>`;
|
|
}
|
|
for (const rig of serverRigs) {
|
|
const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim();
|
|
const active = rig.rig_id === serverActiveRigId
|
|
? ` <span class="receiver-popup-active">active</span>` : "";
|
|
rows += `<tr><td class="aprs-popup-label">Rig</td><td>${escapeMapHtml(name)}${active}</td></tr>`;
|
|
}
|
|
return `<div class="aprs-popup">` +
|
|
`<div class="aprs-popup-call">${escapeMapHtml(call)}</div>` +
|
|
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
|
|
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
|
`</div>`;
|
|
}
|
|
|
|
function buildAprsPopupHtml(call, lat, lon, info, pkt) {
|
|
const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : (pkt?._ts || null);
|
|
const distKm = (serverLat != null && serverLon != null)
|
|
? haversineKm(serverLat, serverLon, lat, lon)
|
|
: null;
|
|
const distStr = distKm != null
|
|
? (distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`)
|
|
: null;
|
|
const path = pkt?.path || null;
|
|
const type = pkt?.type || null;
|
|
|
|
let meta = [age, distStr].filter(Boolean).join(" · ");
|
|
let rows = "";
|
|
if (type) rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(type)}</td></tr>`;
|
|
if (path) rows += `<tr><td class="aprs-popup-label">Path</td><td>${escapeMapHtml(path)}</td></tr>`;
|
|
if (lat != null && lon != null)
|
|
rows += `<tr><td class="aprs-popup-label">Pos</td><td>${lat.toFixed(5)}, ${lon.toFixed(5)}</td></tr>`;
|
|
|
|
return `<div class="aprs-popup">` +
|
|
`<div class="aprs-popup-call">${escapeMapHtml(call)}</div>` +
|
|
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
|
|
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
|
(info ? `<div class="aprs-popup-info">${escapeMapHtml(info)}</div>` : "") +
|
|
`</div>`;
|
|
}
|
|
|
|
function _aprsAddMarkerToMap(call, entry) {
|
|
const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode);
|
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
|
const marker = icon
|
|
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
|
: L.circleMarker([entry.lat, entry.lon], {
|
|
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
|
}).addTo(aprsMap).bindPopup(popupContent);
|
|
marker.__trxType = "aprs";
|
|
marker._aprsCall = call;
|
|
entry.marker = marker;
|
|
mapMarkers.add(marker);
|
|
}
|
|
|
|
window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
|
const existing = stationMarkers.get(call);
|
|
if (existing) {
|
|
// Update stored data (preserves original _tsMs if pkt is newer)
|
|
existing.pkt = pkt;
|
|
existing.lat = lat;
|
|
existing.lon = lon;
|
|
existing.info = info;
|
|
existing.symbolTable = symbolTable;
|
|
existing.symbolCode = symbolCode;
|
|
if (aprsMap && existing.marker) {
|
|
existing.marker.setLatLng([lat, lon]);
|
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
|
}
|
|
} else {
|
|
const entry = { marker: null, type: "aprs", pkt, lat, lon, info, symbolTable, symbolCode };
|
|
stationMarkers.set(call, entry);
|
|
if (aprsMap) {
|
|
_aprsAddMarkerToMap(call, entry);
|
|
applyMapFilter();
|
|
}
|
|
}
|
|
};
|
|
|
|
function maidenheadToBounds(grid) {
|
|
if (!grid || grid.length < 4) return null;
|
|
const g = grid.toUpperCase();
|
|
const A = "A".charCodeAt(0);
|
|
const fieldLon = (g.charCodeAt(0) - A) * 20 - 180;
|
|
const fieldLat = (g.charCodeAt(1) - A) * 10 - 90;
|
|
const squareLon = parseInt(g[2], 10) * 2;
|
|
const squareLat = parseInt(g[3], 10) * 1;
|
|
|
|
let lon = fieldLon + squareLon;
|
|
let lat = fieldLat + squareLat;
|
|
let lonSpan = 2;
|
|
let latSpan = 1;
|
|
|
|
if (g.length >= 6) {
|
|
const subLon = (g.charCodeAt(4) - A) * (5 / 60);
|
|
const subLat = (g.charCodeAt(5) - A) * (2.5 / 60);
|
|
lon += subLon;
|
|
lat += subLat;
|
|
lonSpan = 5 / 60;
|
|
latSpan = 2.5 / 60;
|
|
}
|
|
|
|
return [
|
|
[lat, lon],
|
|
[lat + latSpan, lon + lonSpan],
|
|
];
|
|
}
|
|
|
|
function applyMapFilter() {
|
|
if (!aprsMap) return;
|
|
mapMarkers.forEach((marker) => {
|
|
const type = marker.__trxType;
|
|
const visible =
|
|
(type === "aprs" && mapFilter.aprs) ||
|
|
(type === "ft8" && mapFilter.ft8) ||
|
|
(type === "wspr" && mapFilter.wspr);
|
|
const onMap = aprsMap.hasLayer(marker);
|
|
if (visible && !onMap) marker.addTo(aprsMap);
|
|
if (!visible && onMap) marker.removeFrom(aprsMap);
|
|
});
|
|
}
|
|
|
|
function escapeMapHtml(input) {
|
|
return String(input)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll("\"", """);
|
|
}
|
|
|
|
function locatorStyleForCount(count, type) {
|
|
const safeCount = Math.max(1, Number.isFinite(count) ? count : 1);
|
|
const intensity = Math.min(1, Math.log2(safeCount + 1) / 5);
|
|
const isWspr = type === "wspr";
|
|
return {
|
|
color: isWspr ? "#ff8f2a" : "#ffb020",
|
|
opacity: 0.45 + intensity * 0.5,
|
|
weight: 1 + intensity * 1.2,
|
|
fillColor: isWspr ? "#ff6a3d" : "#ff9b1a",
|
|
fillOpacity: 0.18 + intensity * 0.55,
|
|
};
|
|
}
|
|
|
|
window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null) {
|
|
if (!aprsMap) initAprsMap();
|
|
if (!aprsMap) return;
|
|
if (!Array.isArray(grids) || grids.length === 0) return;
|
|
const markerType = type === "wspr" ? "wspr" : "ft8";
|
|
const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))];
|
|
const locatorsLines = unique.map((g) => escapeMapHtml(g)).join("<br>");
|
|
for (const grid of unique) {
|
|
const bounds = maidenheadToBounds(grid);
|
|
if (!bounds) continue;
|
|
const key = `${markerType}:${grid}`;
|
|
const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : "";
|
|
const existing = locatorMarkers.get(key);
|
|
if (existing) {
|
|
if (stationId) existing.stations.add(stationId);
|
|
const count = existing.stations.size || 1;
|
|
existing.marker.setStyle(locatorStyleForCount(count, markerType));
|
|
existing.marker.setPopupContent(
|
|
`<b>${escapeMapHtml(grid)}</b><br>Stations: ${count}<br>${locatorsLines}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const stations = new Set();
|
|
if (stationId) stations.add(stationId);
|
|
const count = stations.size || 1;
|
|
const marker = L.rectangle(bounds, locatorStyleForCount(count, markerType))
|
|
.addTo(aprsMap)
|
|
.bindPopup(`<b>${escapeMapHtml(grid)}</b><br>Stations: ${count}<br>${locatorsLines}`);
|
|
marker.__trxType = markerType;
|
|
locatorMarkers.set(key, { marker, stations });
|
|
mapMarkers.add(marker);
|
|
}
|
|
applyMapFilter();
|
|
};
|
|
|
|
// --- Sub-tab navigation (Plugins tab) ---
|
|
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
|
bar.addEventListener("click", (e) => {
|
|
const btn = e.target.closest(".sub-tab[data-subtab]");
|
|
if (!btn) return;
|
|
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
const parent = bar.parentElement;
|
|
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
|
parent.querySelector(`#subtab-${btn.dataset.subtab}`).style.display = "";
|
|
});
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
const mapTab = document.getElementById("tab-map");
|
|
if (!mapTab || mapTab.style.display === "none") return;
|
|
sizeAprsMapToViewport();
|
|
});
|
|
|
|
// --- Signal measurement ---
|
|
const sigMeasureBtn = document.getElementById("sig-measure-btn");
|
|
const sigClearBtn = document.getElementById("sig-clear-btn");
|
|
const sigResult = document.getElementById("sig-result");
|
|
|
|
function resetSignalMeasurementState() {
|
|
sigMeasureLastTickMs = 0;
|
|
sigMeasureAccumMs = 0;
|
|
sigMeasureWeighted = 0;
|
|
sigMeasurePeak = null;
|
|
}
|
|
|
|
function updateSignalMeasurement(nowMs) {
|
|
if (!sigMeasuring) return;
|
|
if (sigMeasureLastTickMs === 0) {
|
|
sigMeasureLastTickMs = nowMs;
|
|
return;
|
|
}
|
|
const dt = Math.max(0, nowMs - sigMeasureLastTickMs);
|
|
sigMeasureLastTickMs = nowMs;
|
|
if (!Number.isFinite(sigLastSUnits)) return;
|
|
|
|
sigMeasureAccumMs += dt;
|
|
sigMeasureWeighted += sigLastSUnits * dt;
|
|
if (sigMeasurePeak === null || sigLastSUnits > sigMeasurePeak) {
|
|
sigMeasurePeak = sigLastSUnits;
|
|
}
|
|
}
|
|
|
|
function stopSignalMeasurement() {
|
|
if (sigMeasureTimer) {
|
|
clearInterval(sigMeasureTimer);
|
|
sigMeasureTimer = null;
|
|
}
|
|
sigMeasuring = false;
|
|
sigMeasureBtn.textContent = "Measure";
|
|
sigMeasureBtn.style.borderColor = "";
|
|
sigMeasureBtn.style.color = "";
|
|
}
|
|
|
|
sigMeasureBtn.addEventListener("click", () => {
|
|
if (!sigMeasuring) {
|
|
resetSignalMeasurementState();
|
|
sigMeasuring = true;
|
|
sigMeasureBtn.textContent = "Stop (0.0s)";
|
|
sigMeasureBtn.style.borderColor = "#00d17f";
|
|
sigMeasureBtn.style.color = "#00d17f";
|
|
sigMeasureTimer = setInterval(() => {
|
|
const now = Date.now();
|
|
updateSignalMeasurement(now);
|
|
sigMeasureBtn.textContent = `Stop (${(sigMeasureAccumMs / 1000).toFixed(1)}s)`;
|
|
}, 200);
|
|
} else {
|
|
updateSignalMeasurement(Date.now());
|
|
stopSignalMeasurement();
|
|
if (sigMeasureAccumMs > 0) {
|
|
const avg = sigMeasureWeighted / sigMeasureAccumMs;
|
|
const peak = sigMeasurePeak;
|
|
sigResult.textContent = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1000).toFixed(1)}s)`;
|
|
}
|
|
}
|
|
});
|
|
|
|
sigClearBtn.addEventListener("click", () => {
|
|
stopSignalMeasurement();
|
|
resetSignalMeasurementState();
|
|
sigResult.textContent = "";
|
|
});
|
|
|
|
// --- Audio streaming ---
|
|
const rxAudioBtn = document.getElementById("rx-audio-btn");
|
|
const txAudioBtn = document.getElementById("tx-audio-btn");
|
|
const RX_AUDIO_LABEL = "Play Audio";
|
|
const TX_AUDIO_LABEL = "Transmit Audio";
|
|
const audioStatus = document.getElementById("audio-status");
|
|
const audioLevelFill = document.getElementById("audio-level-fill");
|
|
const audioRow = document.getElementById("audio-row");
|
|
const wfmControlsCol = document.getElementById("wfm-controls-col");
|
|
const wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
|
|
const wfmAudioModeEl = document.getElementById("wfm-audio-mode");
|
|
const wfmDenoiseEl = document.getElementById("wfm-denoise");
|
|
const sdrGainControlsEl = document.getElementById("sdr-gain-controls");
|
|
const sdrGainEl = document.getElementById("sdr-gain-db");
|
|
const sdrGainSetBtn = document.getElementById("sdr-gain-set");
|
|
const wfmStFlagEl = document.getElementById("wfm-st-flag");
|
|
|
|
// Hide audio row if audio is not configured on the server
|
|
fetch("/audio", { method: "GET" }).then((r) => {
|
|
if (r.status === 404) audioRow.style.display = "none";
|
|
}).catch(() => {});
|
|
|
|
let audioWs = null;
|
|
let audioCtx = null;
|
|
let rxActive = false;
|
|
let txActive = false;
|
|
let txStream = null;
|
|
let txProcessor = null;
|
|
let streamInfo = null;
|
|
let opusDecoder = null;
|
|
let txEncoder = null;
|
|
let nextPlayTime = 0;
|
|
let lastLevelUpdate = 0;
|
|
let rxGainNode = null;
|
|
let txGainNode = null;
|
|
const rxVolSlider = document.getElementById("rx-vol");
|
|
const txVolSlider = document.getElementById("tx-vol");
|
|
const TX_TIMEOUT_SECS = 120;
|
|
let txTimeoutTimer = null;
|
|
let txTimeoutRemaining = 0;
|
|
let txTimeoutInterval = null;
|
|
const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
|
|
const MAX_RX_BUFFER_SECS = 0.25;
|
|
const TARGET_RX_BUFFER_SECS = 0.04;
|
|
const MIN_RX_JITTER_SAMPLES = 512;
|
|
|
|
if (rxAudioBtn) {
|
|
rxAudioBtn.textContent = RX_AUDIO_LABEL;
|
|
rxAudioBtn.setAttribute("aria-label", RX_AUDIO_LABEL);
|
|
}
|
|
if (txAudioBtn) {
|
|
txAudioBtn.textContent = TX_AUDIO_LABEL;
|
|
txAudioBtn.setAttribute("aria-label", TX_AUDIO_LABEL);
|
|
}
|
|
|
|
function setAudioLevel(levelPct) {
|
|
if (!audioLevelFill) return;
|
|
const clamped = Math.max(0, Math.min(100, Number.isFinite(levelPct) ? levelPct : 0));
|
|
audioLevelFill.style.width = `${clamped}%`;
|
|
}
|
|
|
|
function levelFromChannels(channels, frameCount) {
|
|
if (!Array.isArray(channels) || channels.length === 0 || !Number.isFinite(frameCount) || frameCount <= 0) {
|
|
return 0;
|
|
}
|
|
let sumSquares = 0;
|
|
let samples = 0;
|
|
for (const channel of channels) {
|
|
if (!channel) continue;
|
|
const limit = Math.min(frameCount, channel.length);
|
|
for (let i = 0; i < limit; i++) {
|
|
const sample = channel[i];
|
|
sumSquares += sample * sample;
|
|
}
|
|
samples += limit;
|
|
}
|
|
if (samples <= 0) return 0;
|
|
const rms = Math.sqrt(sumSquares / samples);
|
|
return Math.min(100, rms * 220);
|
|
}
|
|
|
|
if (wfmAudioModeEl) {
|
|
wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
|
|
wfmAudioModeEl.addEventListener("change", () => {
|
|
saveSetting("wfmAudioMode", wfmAudioModeEl.value);
|
|
const enabled = wfmAudioModeEl.value !== "mono";
|
|
postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => {});
|
|
});
|
|
}
|
|
if (wfmDenoiseEl) {
|
|
wfmDenoiseEl.value = loadSetting("wfmDenoise", "on");
|
|
wfmDenoiseEl.addEventListener("change", () => {
|
|
saveSetting("wfmDenoise", wfmDenoiseEl.value);
|
|
const enabled = wfmDenoiseEl.value !== "off";
|
|
postPath(`/set_wfm_denoise?enabled=${enabled ? "true" : "false"}`).catch(() => {});
|
|
});
|
|
}
|
|
if (wfmDeemphasisEl) {
|
|
wfmDeemphasisEl.addEventListener("change", () => {
|
|
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {});
|
|
});
|
|
}
|
|
function submitSdrGain() {
|
|
if (!sdrGainEl) return;
|
|
const parsed = Number.parseFloat(sdrGainEl.value);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return;
|
|
postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {});
|
|
}
|
|
if (sdrGainSetBtn) {
|
|
sdrGainSetBtn.addEventListener("click", submitSdrGain);
|
|
}
|
|
if (sdrGainEl) {
|
|
sdrGainEl.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") {
|
|
ev.preventDefault();
|
|
submitSdrGain();
|
|
}
|
|
});
|
|
}
|
|
function updateWfmControls() {
|
|
if (!wfmControlsCol) return;
|
|
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
|
wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
|
}
|
|
|
|
// Show compatibility warning for non-Chromium browsers
|
|
if (!hasWebCodecs) {
|
|
rxAudioBtn.disabled = true;
|
|
txAudioBtn.disabled = true;
|
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
|
}
|
|
|
|
function resetTxTimeout() {
|
|
txTimeoutRemaining = TX_TIMEOUT_SECS;
|
|
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
|
|
txTimeoutTimer = setTimeout(() => {
|
|
console.warn("PTT safety timeout — stopping TX");
|
|
stopTxAudio();
|
|
}, TX_TIMEOUT_SECS * 1000);
|
|
}
|
|
|
|
function startTxTimeoutCountdown() {
|
|
txTimeoutRemaining = TX_TIMEOUT_SECS;
|
|
if (txTimeoutInterval) clearInterval(txTimeoutInterval);
|
|
txTimeoutInterval = setInterval(() => {
|
|
txTimeoutRemaining--;
|
|
if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) {
|
|
audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function clearTxTimeout() {
|
|
if (txTimeoutTimer) { clearTimeout(txTimeoutTimer); txTimeoutTimer = null; }
|
|
if (txTimeoutInterval) { clearInterval(txTimeoutInterval); txTimeoutInterval = null; }
|
|
txTimeoutRemaining = 0;
|
|
}
|
|
|
|
function resetRxDecoder() {
|
|
if (opusDecoder) {
|
|
try { opusDecoder.close(); } catch (e) {}
|
|
opusDecoder = null;
|
|
}
|
|
nextPlayTime = 0;
|
|
}
|
|
|
|
function configureRxStream(nextInfo) {
|
|
const nextSampleRate = (nextInfo && nextInfo.sample_rate) || 48000;
|
|
const sampleRateChanged = !audioCtx || audioCtx.sampleRate !== nextSampleRate;
|
|
streamInfo = nextInfo;
|
|
updateWfmControls();
|
|
resetRxDecoder();
|
|
if (sampleRateChanged && audioCtx) {
|
|
audioCtx.close().catch(() => {});
|
|
audioCtx = null;
|
|
rxGainNode = null;
|
|
}
|
|
if (!audioCtx) {
|
|
audioCtx = new AudioContext({ sampleRate: nextSampleRate });
|
|
audioCtx.resume().catch(() => {});
|
|
}
|
|
if (!rxGainNode) {
|
|
rxGainNode = audioCtx.createGain();
|
|
rxGainNode.connect(audioCtx.destination);
|
|
}
|
|
rxGainNode.gain.value = rxVolSlider.value / 100;
|
|
rxActive = true;
|
|
setAudioLevel(0);
|
|
rxAudioBtn.style.borderColor = "#00d17f";
|
|
rxAudioBtn.style.color = "#00d17f";
|
|
audioStatus.textContent = "RX";
|
|
}
|
|
|
|
function extractAudioFrameChannels(frame) {
|
|
const channels = Math.max(1, frame.numberOfChannels || 1);
|
|
const frames = Math.max(0, frame.numberOfFrames || 0);
|
|
const format = String(frame.format || "").toLowerCase();
|
|
const isPlanar = format.includes("planar");
|
|
|
|
if (!isPlanar) {
|
|
const interleaved = new Float32Array(frames * channels);
|
|
frame.copyTo(interleaved, { planeIndex: 0 });
|
|
const out = Array.from({ length: channels }, () => new Float32Array(frames));
|
|
for (let i = 0; i < frames; i++) {
|
|
for (let ch = 0; ch < channels; ch++) {
|
|
out[ch][i] = interleaved[i * channels + ch];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const out = [];
|
|
for (let ch = 0; ch < channels; ch++) {
|
|
let len = frames;
|
|
try {
|
|
len = Math.max(frames, Math.floor(frame.allocationSize({ planeIndex: ch }) / 4));
|
|
} catch (e) {}
|
|
const plane = new Float32Array(len);
|
|
frame.copyTo(plane, { planeIndex: ch });
|
|
out.push(plane.length === frames ? plane : plane.subarray(0, frames));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function startRxAudio() {
|
|
if (rxActive) { stopRxAudio(); return; }
|
|
if (!hasWebCodecs) {
|
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
|
return;
|
|
}
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
audioWs = new WebSocket(`${proto}//${location.host}/audio`);
|
|
audioWs.binaryType = "arraybuffer";
|
|
audioStatus.textContent = "Connecting…";
|
|
|
|
audioWs.onopen = () => {
|
|
audioStatus.textContent = "Connected";
|
|
};
|
|
|
|
audioWs.onmessage = (evt) => {
|
|
if (typeof evt.data === "string") {
|
|
// Stream info JSON
|
|
try {
|
|
configureRxStream(JSON.parse(evt.data));
|
|
} catch (e) {
|
|
console.error("Audio stream info parse error", e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Binary Opus data — decode via WebCodecs AudioDecoder if available
|
|
if (!audioCtx) return;
|
|
const data = new Uint8Array(evt.data);
|
|
|
|
// Use WebCodecs AudioDecoder for Opus if available
|
|
if (typeof AudioDecoder !== "undefined" && !opusDecoder) {
|
|
try {
|
|
const channels = (streamInfo && streamInfo.channels) || 1;
|
|
const sampleRate = (streamInfo && streamInfo.sample_rate) || 48000;
|
|
opusDecoder = new AudioDecoder({
|
|
output: (frame) => {
|
|
const frameChannels = extractAudioFrameChannels(frame);
|
|
const levelNow = Date.now();
|
|
if (levelNow - lastLevelUpdate >= 50) {
|
|
setAudioLevel(levelFromChannels(frameChannels, frame.numberOfFrames));
|
|
lastLevelUpdate = levelNow;
|
|
}
|
|
const forceMono = frame.numberOfChannels >= 2
|
|
&& wfmAudioModeEl
|
|
&& wfmAudioModeEl.value === "mono"
|
|
&& modeEl
|
|
&& (modeEl.value || "").toUpperCase() === "WFM";
|
|
const outChannels = forceMono ? 1 : frameChannels.length;
|
|
const ab = audioCtx.createBuffer(outChannels, frame.numberOfFrames, frame.sampleRate);
|
|
if (forceMono) {
|
|
const monoData = new Float32Array(frame.numberOfFrames);
|
|
for (let ch = 0; ch < frameChannels.length; ch++) {
|
|
const plane = frameChannels[ch];
|
|
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] += plane[i];
|
|
}
|
|
const inv = 1 / Math.max(1, frameChannels.length);
|
|
for (let i = 0; i < frame.numberOfFrames; i++) monoData[i] *= inv;
|
|
ab.copyToChannel(monoData, 0);
|
|
} else {
|
|
for (let ch = 0; ch < frameChannels.length; ch++) {
|
|
ab.copyToChannel(frameChannels[ch], ch);
|
|
}
|
|
}
|
|
const src = audioCtx.createBufferSource();
|
|
src.buffer = ab;
|
|
src.connect(rxGainNode);
|
|
const now = audioCtx.currentTime;
|
|
const sampleRate = (streamInfo && streamInfo.sample_rate) || frame.sampleRate || 48000;
|
|
const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sampleRate));
|
|
const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs);
|
|
if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) {
|
|
nextPlayTime = now + targetLeadSecs;
|
|
}
|
|
if (!nextPlayTime || nextPlayTime < now + minLeadSecs) {
|
|
nextPlayTime = now + targetLeadSecs;
|
|
}
|
|
const schedTime = nextPlayTime || (now + targetLeadSecs);
|
|
src.start(schedTime);
|
|
nextPlayTime = schedTime + ab.duration;
|
|
frame.close();
|
|
},
|
|
error: (e) => { console.error("AudioDecoder error", e); }
|
|
});
|
|
opusDecoder.configure({
|
|
codec: "opus",
|
|
sampleRate: sampleRate,
|
|
numberOfChannels: channels,
|
|
});
|
|
} catch (e) {
|
|
console.warn("WebCodecs AudioDecoder not available for Opus", e);
|
|
opusDecoder = null;
|
|
}
|
|
}
|
|
if (opusDecoder) {
|
|
try {
|
|
opusDecoder.decode(new EncodedAudioChunk({
|
|
type: "key",
|
|
timestamp: performance.now() * 1000,
|
|
data: data,
|
|
}));
|
|
} catch (e) {
|
|
// Ignore decode errors for individual frames
|
|
}
|
|
}
|
|
};
|
|
|
|
audioWs.onclose = () => {
|
|
// If TX was active when WS closed, release PTT
|
|
if (txActive) { stopTxAudio(); }
|
|
rxActive = false;
|
|
streamInfo = null;
|
|
updateWfmControls();
|
|
rxAudioBtn.style.borderColor = "";
|
|
rxAudioBtn.style.color = "";
|
|
audioStatus.textContent = "Off";
|
|
setAudioLevel(0);
|
|
rxGainNode = null;
|
|
if (opusDecoder) {
|
|
try { opusDecoder.close(); } catch(e) {}
|
|
opusDecoder = null;
|
|
}
|
|
nextPlayTime = 0;
|
|
};
|
|
|
|
audioWs.onerror = () => {
|
|
audioStatus.textContent = "Error";
|
|
};
|
|
}
|
|
|
|
function stopRxAudio() {
|
|
rxActive = false;
|
|
streamInfo = null;
|
|
if (audioWs) { audioWs.close(); audioWs = null; }
|
|
if (audioCtx) { audioCtx.close(); audioCtx = null; }
|
|
updateWfmControls();
|
|
rxGainNode = null;
|
|
if (opusDecoder) {
|
|
try { opusDecoder.close(); } catch(e) {}
|
|
opusDecoder = null;
|
|
}
|
|
nextPlayTime = 0;
|
|
rxAudioBtn.style.borderColor = "";
|
|
rxAudioBtn.style.color = "";
|
|
audioStatus.textContent = "Off";
|
|
setAudioLevel(0);
|
|
}
|
|
|
|
function startTxAudio() {
|
|
if (txActive) { stopTxAudio(); return; }
|
|
if (!hasWebCodecs) {
|
|
audioStatus.textContent = "Audio requires Chrome/Edge";
|
|
return;
|
|
}
|
|
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) {
|
|
audioStatus.textContent = "RX first";
|
|
return;
|
|
}
|
|
if (!streamInfo) return;
|
|
|
|
navigator.mediaDevices.getUserMedia({
|
|
audio: { sampleRate: streamInfo.sample_rate || 48000, channelCount: streamInfo.channels || 1 }
|
|
}).then(async (stream) => {
|
|
txStream = stream;
|
|
txActive = true;
|
|
txAudioBtn.style.borderColor = "#e55353";
|
|
txAudioBtn.style.color = "#e55353";
|
|
audioStatus.textContent = "RX+TX";
|
|
|
|
// Start PTT safety timeout
|
|
resetTxTimeout();
|
|
startTxTimeoutCountdown();
|
|
|
|
// Engage PTT automatically
|
|
try { await postPath("/set_ptt?ptt=true"); } catch (e) { console.error("PTT on failed", e); }
|
|
|
|
const sampleRate = streamInfo.sample_rate || 48000;
|
|
const channels = streamInfo.channels || 1;
|
|
const encoder = new AudioEncoder({
|
|
output: (chunk) => {
|
|
const buf = new ArrayBuffer(chunk.byteLength);
|
|
chunk.copyTo(buf);
|
|
if (audioWs && audioWs.readyState === WebSocket.OPEN) {
|
|
audioWs.send(buf);
|
|
}
|
|
},
|
|
error: (e) => { console.error("AudioEncoder error", e); }
|
|
});
|
|
encoder.configure({
|
|
codec: "opus",
|
|
sampleRate: sampleRate,
|
|
numberOfChannels: channels,
|
|
bitrate: (streamInfo.bitrate_bps || 24000),
|
|
});
|
|
txEncoder = encoder;
|
|
|
|
// Use AudioWorklet or ScriptProcessor to feed encoder
|
|
if (!audioCtx) audioCtx = new AudioContext({ sampleRate: sampleRate });
|
|
const source = audioCtx.createMediaStreamSource(stream);
|
|
const frameDuration = (streamInfo.frame_duration_ms || 20) / 1000;
|
|
const frameSize = Math.floor(sampleRate * frameDuration);
|
|
// Use ScriptProcessorNode (deprecated but widely supported)
|
|
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
|
|
let tsCounter = 0;
|
|
processor.onaudioprocess = (e) => {
|
|
if (!txActive || !txEncoder) return;
|
|
const input = e.inputBuffer;
|
|
// Reset PTT safety timeout on each audio callback
|
|
resetTxTimeout();
|
|
// Use mono (channel 0) for f32-planar format
|
|
const monoData = input.getChannelData(0);
|
|
try {
|
|
const frame = new AudioData({
|
|
format: "f32-planar",
|
|
sampleRate: input.sampleRate,
|
|
numberOfFrames: input.length,
|
|
numberOfChannels: 1,
|
|
timestamp: tsCounter,
|
|
data: monoData,
|
|
});
|
|
tsCounter += (input.length / input.sampleRate) * 1_000_000;
|
|
txEncoder.encode(frame);
|
|
frame.close();
|
|
} catch (e) {
|
|
// Ignore
|
|
}
|
|
};
|
|
txGainNode = audioCtx.createGain();
|
|
txGainNode.gain.value = txVolSlider.value / 100;
|
|
source.connect(txGainNode);
|
|
txGainNode.connect(processor);
|
|
processor.connect(audioCtx.destination);
|
|
txProcessor = { source, processor };
|
|
}).catch((err) => {
|
|
console.error("getUserMedia failed:", err);
|
|
audioStatus.textContent = "Mic denied";
|
|
});
|
|
}
|
|
|
|
async function stopTxAudio() {
|
|
if (!txActive) return;
|
|
txActive = false;
|
|
clearTxTimeout();
|
|
|
|
// Release PTT automatically
|
|
try { await postPath("/set_ptt?ptt=false"); } catch (e) { console.error("PTT off failed", e); }
|
|
|
|
if (txStream) {
|
|
txStream.getTracks().forEach(t => t.stop());
|
|
txStream = null;
|
|
}
|
|
if (txProcessor) {
|
|
txProcessor.source.disconnect();
|
|
txProcessor.processor.disconnect();
|
|
txProcessor = null;
|
|
}
|
|
if (txEncoder) {
|
|
try { txEncoder.close(); } catch(e) {}
|
|
txEncoder = null;
|
|
}
|
|
txGainNode = null;
|
|
txAudioBtn.style.borderColor = "";
|
|
txAudioBtn.style.color = "";
|
|
audioStatus.textContent = rxActive ? "RX" : "Off";
|
|
}
|
|
|
|
rxAudioBtn.addEventListener("click", startRxAudio);
|
|
txAudioBtn.addEventListener("click", startTxAudio);
|
|
|
|
const rxVolPct = document.getElementById("rx-vol-pct");
|
|
const txVolPct = document.getElementById("tx-vol-pct");
|
|
|
|
// Restore saved volumes
|
|
rxVolSlider.value = loadSetting("rxVol", 80);
|
|
txVolSlider.value = loadSetting("txVol", 80);
|
|
rxVolPct.textContent = `${rxVolSlider.value}%`;
|
|
txVolPct.textContent = `${txVolSlider.value}%`;
|
|
|
|
function updateVolSlider(slider, pctEl, gainNode) {
|
|
pctEl.textContent = `${slider.value}%`;
|
|
if (gainNode) gainNode.gain.value = slider.value / 100;
|
|
}
|
|
|
|
rxVolSlider.addEventListener("input", () => { updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); saveSetting("rxVol", Number(rxVolSlider.value)); });
|
|
txVolSlider.addEventListener("input", () => { updateVolSlider(txVolSlider, txVolPct, txGainNode); saveSetting("txVol", Number(txVolSlider.value)); });
|
|
|
|
function volWheel(slider, pctEl, getGain, storageKey) {
|
|
slider.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
const step = e.deltaY < 0 ? 2 : -2;
|
|
slider.value = Math.max(0, Math.min(100, Number(slider.value) + step));
|
|
updateVolSlider(slider, pctEl, getGain());
|
|
saveSetting(storageKey, Number(slider.value));
|
|
}, { passive: false });
|
|
}
|
|
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
|
|
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
|
|
|
|
document.getElementById("copyright-year").textContent = new Date().getFullYear();
|
|
|
|
// --- Server-side decode SSE ---
|
|
let decodeSource = null;
|
|
let decodeConnected = false;
|
|
function updateDecodeStatus(text) {
|
|
const aprs = document.getElementById("aprs-status");
|
|
const cw = document.getElementById("cw-status");
|
|
const ft8 = document.getElementById("ft8-status");
|
|
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
|
|
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
|
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
|
}
|
|
function connectDecode() {
|
|
if (decodeSource) { decodeSource.close(); }
|
|
if (window.resetAprsHistoryView) window.resetAprsHistoryView();
|
|
if (window.resetCwHistoryView) window.resetCwHistoryView();
|
|
if (window.resetFt8HistoryView) window.resetFt8HistoryView();
|
|
if (window.resetWsprHistoryView) window.resetWsprHistoryView();
|
|
decodeSource = new EventSource("/decode");
|
|
decodeSource.onopen = () => {
|
|
decodeConnected = true;
|
|
updateDecodeStatus("Connected, listening for packets");
|
|
};
|
|
decodeSource.onmessage = (evt) => {
|
|
try {
|
|
const msg = JSON.parse(evt.data);
|
|
if (msg.type === "aprs" && window.onServerAprs) window.onServerAprs(msg);
|
|
if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg);
|
|
if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg);
|
|
if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg);
|
|
} catch (e) {
|
|
// ignore parse errors
|
|
}
|
|
};
|
|
decodeSource.onerror = () => {
|
|
// readyState CLOSED (2) = server rejected (404/error), CONNECTING (0) = temporary drop
|
|
const wasClosed = decodeSource.readyState === 2;
|
|
decodeSource.close();
|
|
decodeConnected = false;
|
|
if (wasClosed) {
|
|
updateDecodeStatus("Decode not available (check client audio config)");
|
|
setTimeout(connectDecode, 10000);
|
|
} else {
|
|
updateDecodeStatus("Decode disconnected, retrying…");
|
|
setTimeout(connectDecode, 5000);
|
|
}
|
|
};
|
|
}
|
|
connectDecode();
|
|
|
|
// Release PTT on page unload to prevent stuck transmit
|
|
window.addEventListener("beforeunload", () => {
|
|
if (txActive) {
|
|
navigator.sendBeacon("/set_ptt?ptt=false", "");
|
|
}
|
|
});
|
|
|
|
|
|
// ── Spectrum display ─────────────────────────────────────────────────────────
|
|
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
|
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
|
let spectrumSource = null;
|
|
let spectrumReconnectTimer = null;
|
|
let spectrumDrawPending = false;
|
|
let spectrumAxisKey = "";
|
|
let lastSpectrumRenderData = null;
|
|
|
|
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
|
// bandwidth at the centre of the visible window.
|
|
let spectrumZoom = 1;
|
|
let spectrumPanFrac = 0.5;
|
|
|
|
// Y-axis level: floor = bottom dB value shown; range = total dB span.
|
|
let spectrumFloor = -115;
|
|
let spectrumRange = 90;
|
|
const SPECTRUM_HEADROOM_DB = 20;
|
|
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
|
|
|
// BW-strip drag state.
|
|
let _bwDragEdge = null; // "left" | "right" | null
|
|
let _bwDragStartX = 0;
|
|
let _bwDragStartBwHz = 0;
|
|
let _bwDragCanvas = null;
|
|
|
|
function spectrumBgColor() {
|
|
return canvasPalette().bg;
|
|
}
|
|
|
|
function buildSpectrumRenderData(frame) {
|
|
if (!frame || !Array.isArray(frame.bins)) return frame;
|
|
const prev = lastSpectrumRenderData;
|
|
const canBlend =
|
|
prev &&
|
|
Array.isArray(prev.bins) &&
|
|
prev.bins.length === frame.bins.length &&
|
|
prev.sample_rate === frame.sample_rate &&
|
|
prev.center_hz === frame.center_hz;
|
|
const bins = frame.bins.map((value, idx) => {
|
|
if (!canBlend) return value;
|
|
const prevValue = prev.bins[idx];
|
|
return prevValue + (value - prevValue) * SPECTRUM_SMOOTH_ALPHA;
|
|
});
|
|
return { ...frame, bins };
|
|
}
|
|
|
|
// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
|
|
// panFrac so the view never scrolls past the edges.
|
|
function spectrumVisibleRange(data) {
|
|
const fullSpanHz = data.sample_rate;
|
|
const loHz = data.center_hz - fullSpanHz / 2;
|
|
const halfVis = 0.5 / spectrumZoom;
|
|
spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
|
|
const visCenterHz = loHz + spectrumPanFrac * fullSpanHz;
|
|
const visSpanHz = fullSpanHz / spectrumZoom;
|
|
return {
|
|
loHz,
|
|
hiHz: loHz + fullSpanHz,
|
|
visLoHz: visCenterHz - visSpanHz / 2,
|
|
visHiHz: visCenterHz + visSpanHz / 2,
|
|
fullSpanHz,
|
|
visSpanHz,
|
|
};
|
|
}
|
|
|
|
function canvasXToHz(cssX, cssW, range) {
|
|
return range.visLoHz + (cssX / cssW) * range.visSpanHz;
|
|
}
|
|
|
|
function nearestSpectrumPeak(cssX, cssW, data) {
|
|
if (!data || !Array.isArray(data.bins) || data.bins.length === 0 || cssW <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const bins = data.bins;
|
|
const maxIdx = bins.length - 1;
|
|
const range = spectrumVisibleRange(data);
|
|
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
|
const targetHz = canvasXToHz(cssX, cssW, range);
|
|
const targetIdx = Math.max(
|
|
0,
|
|
Math.min(maxIdx, Math.round(((targetHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
|
|
const visStartIdx = Math.max(
|
|
0,
|
|
Math.min(maxIdx, Math.floor(((range.visLoHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
const visEndIdx = Math.max(
|
|
visStartIdx,
|
|
Math.min(maxIdx, Math.ceil(((range.visHiHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
const visSpanBins = Math.max(1, visEndIdx - visStartIdx);
|
|
const searchRadius = Math.max(3, Math.min(80, Math.round((24 / cssW) * visSpanBins)));
|
|
const searchLo = Math.max(1, targetIdx - searchRadius);
|
|
const searchHi = Math.min(maxIdx - 1, targetIdx + searchRadius);
|
|
|
|
let windowMax = -Infinity;
|
|
const localPeaks = [];
|
|
for (let i = searchLo; i <= searchHi; i++) {
|
|
const val = bins[i];
|
|
if (val > windowMax) windowMax = val;
|
|
if (val >= bins[i - 1] && val >= bins[i + 1]) {
|
|
localPeaks.push(i);
|
|
}
|
|
}
|
|
|
|
const candidates = localPeaks.filter((i) => bins[i] >= windowMax - 6);
|
|
const ranked = (candidates.length ? candidates : localPeaks).sort((a, b) => {
|
|
const dist = Math.abs(a - targetIdx) - Math.abs(b - targetIdx);
|
|
if (dist !== 0) return dist;
|
|
return bins[b] - bins[a];
|
|
});
|
|
|
|
let snappedIdx = ranked[0];
|
|
if (snappedIdx == null) {
|
|
snappedIdx = targetIdx;
|
|
for (let i = searchLo; i <= searchHi; i++) {
|
|
if (bins[i] > bins[snappedIdx]) snappedIdx = i;
|
|
}
|
|
}
|
|
|
|
return {
|
|
index: snappedIdx,
|
|
hz: Math.round(fullLoHz + (snappedIdx / maxIdx) * data.sample_rate),
|
|
db: bins[snappedIdx],
|
|
};
|
|
}
|
|
|
|
function nearestSpectrumPeakHz(cssX, cssW, data) {
|
|
return nearestSpectrumPeak(cssX, cssW, data)?.hz ?? null;
|
|
}
|
|
|
|
function spectrumTargetHzAt(cssX, cssW, data) {
|
|
if (!data) return null;
|
|
const range = spectrumVisibleRange(data);
|
|
return nearestSpectrumPeakHz(cssX, cssW, data)
|
|
?? Math.round(canvasXToHz(cssX, cssW, range));
|
|
}
|
|
|
|
function visibleSpectrumPeakIndices(data, limit = 24) {
|
|
if (!data || !Array.isArray(data.bins) || data.bins.length < 3) {
|
|
return [];
|
|
}
|
|
|
|
const bins = data.bins;
|
|
const maxIdx = bins.length - 1;
|
|
const range = spectrumVisibleRange(data);
|
|
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
|
const visStartIdx = Math.max(
|
|
1,
|
|
Math.min(maxIdx - 1, Math.floor(((range.visLoHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
const visEndIdx = Math.max(
|
|
visStartIdx,
|
|
Math.min(maxIdx - 1, Math.ceil(((range.visHiHz - fullLoHz) / data.sample_rate) * maxIdx)),
|
|
);
|
|
|
|
const peaks = [];
|
|
for (let i = visStartIdx; i <= visEndIdx; i++) {
|
|
const v = bins[i];
|
|
if (v >= bins[i - 1] && v >= bins[i + 1]) {
|
|
peaks.push(i);
|
|
}
|
|
}
|
|
if (peaks.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const peakValues = peaks.map((i) => bins[i]).sort((a, b) => a - b);
|
|
const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))];
|
|
|
|
return peaks
|
|
.filter((i) => bins[i] >= cutoff)
|
|
.sort((a, b) => bins[b] - bins[a])
|
|
.slice(0, limit)
|
|
.sort((a, b) => a - b);
|
|
}
|
|
|
|
// Format a frequency according to the current jog-step unit.
|
|
function formatSpectrumFreq(hz) {
|
|
if (jogUnit >= 1_000_000) return (hz / 1e6).toFixed(3) + " MHz";
|
|
if (jogUnit >= 1_000) return (hz / 1e3).toFixed(3) + " kHz";
|
|
return hz.toFixed(0) + " Hz";
|
|
}
|
|
|
|
// ── Streaming ────────────────────────────────────────────────────────────────
|
|
function scheduleSpectrumReconnect() {
|
|
if (spectrumReconnectTimer !== null) return;
|
|
spectrumReconnectTimer = setTimeout(() => {
|
|
spectrumReconnectTimer = null;
|
|
startSpectrumStreaming();
|
|
}, 1000);
|
|
}
|
|
|
|
function startSpectrumStreaming() {
|
|
if (spectrumSource !== null) return;
|
|
spectrumSource = new EventSource("/spectrum");
|
|
spectrumSource.onmessage = (evt) => {
|
|
if (evt.data === "null") {
|
|
lastSpectrumData = null;
|
|
lastSpectrumRenderData = null;
|
|
overviewWaterfallRows = [];
|
|
overviewWaterfallPushCount = 0;
|
|
_wfResetOffscreen();
|
|
scheduleOverviewDraw();
|
|
clearSpectrumCanvas();
|
|
updateRdsPsOverlay(null);
|
|
return;
|
|
}
|
|
try {
|
|
lastSpectrumData = JSON.parse(evt.data);
|
|
lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData);
|
|
rdsFrameCount++;
|
|
pushOverviewWaterfallFrame(lastSpectrumData);
|
|
refreshCenterFreqDisplay();
|
|
scheduleSpectrumDraw();
|
|
updateRdsPsOverlay(lastSpectrumData.rds);
|
|
} catch (_) {}
|
|
};
|
|
spectrumSource.onerror = () => {
|
|
if (spectrumSource) {
|
|
spectrumSource.close();
|
|
spectrumSource = null;
|
|
}
|
|
scheduleSpectrumReconnect();
|
|
};
|
|
}
|
|
|
|
function stopSpectrumStreaming() {
|
|
if (spectrumSource !== null) {
|
|
spectrumSource.close();
|
|
spectrumSource = null;
|
|
}
|
|
if (spectrumReconnectTimer !== null) {
|
|
clearTimeout(spectrumReconnectTimer);
|
|
spectrumReconnectTimer = null;
|
|
}
|
|
spectrumDrawPending = false;
|
|
lastSpectrumData = null;
|
|
lastSpectrumRenderData = null;
|
|
rdsFrameCount = 0;
|
|
overviewWaterfallRows = [];
|
|
overviewWaterfallPushCount = 0;
|
|
_wfResetOffscreen();
|
|
scheduleOverviewDraw();
|
|
updateRdsPsOverlay(null);
|
|
clearSpectrumCanvas();
|
|
}
|
|
|
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
|
function clearSpectrumCanvas() {
|
|
if (!spectrumCanvas) return;
|
|
const ctx = spectrumCanvas.getContext("2d");
|
|
ctx.fillStyle = spectrumBgColor();
|
|
ctx.fillRect(0, 0, spectrumCanvas.width, spectrumCanvas.height);
|
|
}
|
|
|
|
function formatOverlayPs(ps) {
|
|
return String(ps ?? "")
|
|
.slice(0, 8)
|
|
.padEnd(8, "_")
|
|
.replaceAll(" ", "_");
|
|
}
|
|
|
|
function formatOverlayPi(pi) {
|
|
return pi != null
|
|
? `PI 0x${pi.toString(16).toUpperCase().padStart(4, "0")}`
|
|
: "PI --";
|
|
}
|
|
|
|
function formatOverlayPty(pty, ptyName) {
|
|
if (ptyName) return ptyName;
|
|
return pty != null ? String(pty) : "--";
|
|
}
|
|
|
|
function overlayTrafficFlagHtml(label, active) {
|
|
const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
|
|
return `<span class="rds-flag ${stateClass}">${label}</span>`;
|
|
}
|
|
|
|
function formatRdsFlag(value, yes = "Yes", no = "No") {
|
|
if (value == null) return "--";
|
|
return value ? yes : no;
|
|
}
|
|
|
|
function formatRdsAudio(value) {
|
|
if (value == null) return "--";
|
|
return value ? "Music" : "Speech";
|
|
}
|
|
|
|
function formatMinuteTimestamp(date = new Date()) {
|
|
const yyyy = date.getFullYear();
|
|
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
const dd = String(date.getDate()).padStart(2, "0");
|
|
const hh = String(date.getHours()).padStart(2, "0");
|
|
const min = String(date.getMinutes()).padStart(2, "0");
|
|
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
|
|
}
|
|
|
|
function buildRdsRawPayload(rds) {
|
|
return {
|
|
time: formatMinuteTimestamp(),
|
|
freq_hz: Number.isFinite(lastFreqHz) ? Math.round(lastFreqHz) : null,
|
|
...rds,
|
|
};
|
|
}
|
|
|
|
function formatRdsAfMHz(hz) {
|
|
return `${(hz / 1_000_000).toFixed(1)} MHz`;
|
|
}
|
|
|
|
async function tuneRdsAlternativeFrequency(hz) {
|
|
if (!Number.isFinite(hz) || hz <= 0) return;
|
|
const targetHz = Math.round(hz);
|
|
try {
|
|
await postPath(`/set_freq?hz=${targetHz}`);
|
|
applyLocalTunedFrequency(targetHz);
|
|
showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200);
|
|
} catch (_) {
|
|
showHint("Set freq failed", 1500);
|
|
}
|
|
}
|
|
|
|
function renderRdsAlternativeFrequencies(list) {
|
|
const afEl = document.getElementById("rds-af-list");
|
|
if (!afEl) return;
|
|
const afs = Array.isArray(list)
|
|
? list
|
|
.filter((hz) => Number.isFinite(hz) && hz > 0)
|
|
.map((hz) => Math.round(hz))
|
|
: [];
|
|
const afKey = afs.join(",");
|
|
if (!afs.length) {
|
|
if (afEl.dataset.afKey === "") return;
|
|
afEl.dataset.afKey = "";
|
|
afEl.textContent = "--";
|
|
return;
|
|
}
|
|
if (afEl.dataset.afKey === afKey) return;
|
|
afEl.dataset.afKey = afKey;
|
|
afEl.innerHTML = "";
|
|
for (const hz of afs) {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "rds-af-btn";
|
|
btn.dataset.hz = String(hz);
|
|
btn.textContent = formatRdsAfMHz(hz);
|
|
afEl.appendChild(btn);
|
|
}
|
|
if (!afEl.childElementCount) afEl.textContent = "--";
|
|
}
|
|
|
|
async function copyRdsPsToClipboard() {
|
|
const rds = lastSpectrumData?.rds;
|
|
const ps = rds?.program_service;
|
|
if (!rds || !ps || ps.length === 0) {
|
|
showHint("No RDS PS", 1200);
|
|
return;
|
|
}
|
|
const freqMhz = Number.isFinite(lastFreqHz) ? (Math.round((lastFreqHz / 100_000)) / 10).toFixed(1) : "--.-";
|
|
const piHex = rds.pi != null
|
|
? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}`
|
|
: "--";
|
|
const clipPs = formatOverlayPs(ps);
|
|
const clipText = `${formatMinuteTimestamp()} - ${freqMhz} MHz - ${piHex} - ${clipPs}`;
|
|
try {
|
|
await navigator.clipboard.writeText(clipText);
|
|
showHint("RDS copied", 1200);
|
|
} catch (_) {
|
|
showHint("Clipboard failed", 1500);
|
|
}
|
|
}
|
|
|
|
async function copyRdsRawToClipboard() {
|
|
const rawEl = document.getElementById("rds-raw");
|
|
const rawText = rawEl?.textContent ?? "";
|
|
if (!rawText || rawText === "--") {
|
|
showHint("No RDS JSON", 1200);
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(rawText);
|
|
showHint("RDS JSON copied", 1200);
|
|
} catch (_) {
|
|
showHint("Clipboard failed", 1500);
|
|
}
|
|
}
|
|
|
|
if (rdsPsOverlay) {
|
|
rdsPsOverlay.addEventListener("click", () => { copyRdsPsToClipboard(); });
|
|
}
|
|
const rdsPsValueEl = document.getElementById("rds-ps");
|
|
if (rdsPsValueEl) {
|
|
rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); });
|
|
}
|
|
const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
|
|
if (rdsRawCopyBtn) {
|
|
rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); });
|
|
}
|
|
const rdsAfListEl = document.getElementById("rds-af-list");
|
|
if (rdsAfListEl) {
|
|
rdsAfListEl.addEventListener("click", (event) => {
|
|
const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null;
|
|
const hz = Number(btn?.dataset?.hz);
|
|
if (btn && Number.isFinite(hz)) {
|
|
tuneRdsAlternativeFrequency(hz);
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateRdsPsOverlay(rds) {
|
|
updateDocumentTitle(rds);
|
|
// Overview strip overlay
|
|
if (rdsPsOverlay) {
|
|
const ps = rds?.program_service;
|
|
const hasPs = !!(ps && ps.length > 0);
|
|
const hasPi = rds?.pi != null;
|
|
if (hasPs || hasPi) {
|
|
const mainText = hasPs
|
|
? formatOverlayPs(ps)
|
|
: formatOverlayPi(rds?.pi);
|
|
const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback";
|
|
const metaText = hasPs
|
|
? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}`
|
|
: (rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : ""));
|
|
const trafficFlags =
|
|
`<span class="rds-ps-flags">` +
|
|
`${overlayTrafficFlagHtml("TP", rds?.traffic_program)}` +
|
|
`${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}` +
|
|
`</span>`;
|
|
rdsPsOverlay.innerHTML =
|
|
`<span class="${mainClass}">${escapeMapHtml(mainText)}</span>` +
|
|
`<span class="rds-ps-meta">` +
|
|
`<span class="rds-ps-meta-text">${escapeMapHtml(metaText)}</span>` +
|
|
`${trafficFlags}` +
|
|
`</span>`;
|
|
positionRdsPsOverlay();
|
|
rdsPsOverlay.style.display = "flex";
|
|
} else {
|
|
rdsPsOverlay.innerHTML = "";
|
|
rdsPsOverlay.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// RDS debug panel
|
|
const statusEl = document.getElementById("rds-status");
|
|
const modeEl = document.getElementById("rds-mode");
|
|
const piEl = document.getElementById("rds-pi");
|
|
const psEl = document.getElementById("rds-ps");
|
|
const ptyEl = document.getElementById("rds-pty");
|
|
const ptyNameEl = document.getElementById("rds-pty-name");
|
|
const ptynEl = document.getElementById("rds-ptyn");
|
|
const tpEl = document.getElementById("rds-tp");
|
|
const taEl = document.getElementById("rds-ta");
|
|
const musicEl = document.getElementById("rds-music");
|
|
const stereoEl = document.getElementById("rds-stereo");
|
|
const compEl = document.getElementById("rds-compressed");
|
|
const headEl = document.getElementById("rds-artificial-head");
|
|
const dynPtyEl = document.getElementById("rds-dynamic-pty");
|
|
const afEl = document.getElementById("rds-af-list");
|
|
const rtEl = document.getElementById("rds-radio-text");
|
|
const rawEl = document.getElementById("rds-raw");
|
|
if (!statusEl) return;
|
|
|
|
// Always show the current mode, frame counter, and a sanitised spectrum snapshot
|
|
if (modeEl) modeEl.textContent = document.getElementById("mode")?.value || "--";
|
|
const framesEl = document.getElementById("rds-frames");
|
|
if (framesEl) framesEl.textContent = String(rdsFrameCount);
|
|
|
|
if (!rds) {
|
|
statusEl.textContent = "No signal";
|
|
statusEl.className = "rds-value rds-no-signal";
|
|
piEl.textContent = "--";
|
|
psEl.textContent = "--";
|
|
ptyEl.textContent = "--";
|
|
ptyNameEl.textContent = "--";
|
|
if (ptynEl) ptynEl.textContent = "--";
|
|
if (tpEl) tpEl.textContent = "--";
|
|
if (taEl) taEl.textContent = "--";
|
|
if (musicEl) musicEl.textContent = "--";
|
|
if (stereoEl) stereoEl.textContent = "--";
|
|
if (compEl) compEl.textContent = "--";
|
|
if (headEl) headEl.textContent = "--";
|
|
if (dynPtyEl) dynPtyEl.textContent = "--";
|
|
if (afEl) afEl.textContent = "--";
|
|
if (rtEl) rtEl.textContent = "--";
|
|
if (rawEl && lastSpectrumData) {
|
|
const { bins: _b, ...rest } = lastSpectrumData;
|
|
rawEl.textContent = JSON.stringify({
|
|
time: formatMinuteTimestamp(),
|
|
freq_hz: Number.isFinite(lastFreqHz) ? Math.round(lastFreqHz) : null,
|
|
...rest,
|
|
}, null, 2);
|
|
}
|
|
return;
|
|
}
|
|
|
|
statusEl.textContent = "Decoding";
|
|
statusEl.className = "rds-value rds-decoding";
|
|
piEl.textContent = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--";
|
|
psEl.textContent = rds.program_service ?? "--";
|
|
ptyEl.textContent = rds.pty_name ?? (rds.pty != null ? String(rds.pty) : "--");
|
|
ptyNameEl.textContent = rds.pty != null ? String(rds.pty) : "--";
|
|
if (ptynEl) ptynEl.textContent = rds.program_type_name_long ?? "--";
|
|
if (tpEl) tpEl.textContent = formatRdsFlag(rds.traffic_program);
|
|
if (taEl) taEl.textContent = formatRdsFlag(rds.traffic_announcement);
|
|
if (musicEl) musicEl.textContent = formatRdsAudio(rds.music);
|
|
if (stereoEl) stereoEl.textContent = formatRdsFlag(rds.stereo);
|
|
if (compEl) compEl.textContent = formatRdsFlag(rds.compressed);
|
|
if (headEl) headEl.textContent = formatRdsFlag(rds.artificial_head);
|
|
if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(rds.dynamic_pty);
|
|
renderRdsAlternativeFrequencies(rds.alternative_frequencies_hz);
|
|
if (rtEl) rtEl.textContent = rds.radio_text ?? "--";
|
|
rawEl.textContent = JSON.stringify(buildRdsRawPayload(rds), null, 2);
|
|
}
|
|
|
|
function scheduleSpectrumDraw() {
|
|
if (spectrumDrawPending) return;
|
|
spectrumDrawPending = true;
|
|
requestAnimationFrame(() => {
|
|
spectrumDrawPending = false;
|
|
if (lastSpectrumRenderData) {
|
|
drawSpectrum(lastSpectrumRenderData);
|
|
if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
|
|
}
|
|
});
|
|
}
|
|
|
|
function drawSpectrum(data) {
|
|
if (!spectrumCanvas) return;
|
|
|
|
// HiDPI sizing
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const cssW = spectrumCanvas.clientWidth || 640;
|
|
const cssH = spectrumCanvas.clientHeight || 160;
|
|
const W = Math.round(cssW * dpr);
|
|
const H = Math.round(cssH * dpr);
|
|
if (spectrumCanvas.width !== W || spectrumCanvas.height !== H) {
|
|
spectrumCanvas.width = W;
|
|
spectrumCanvas.height = H;
|
|
}
|
|
|
|
const ctx = spectrumCanvas.getContext("2d");
|
|
const pal = canvasPalette();
|
|
const range = spectrumVisibleRange(data);
|
|
const bins = data.bins;
|
|
const n = bins.length;
|
|
|
|
// Background
|
|
ctx.fillStyle = pal.bg;
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
if (!n) return;
|
|
|
|
const DB_MIN = spectrumFloor;
|
|
const DB_MAX = spectrumFloor + spectrumRange;
|
|
const dbRange = DB_MAX - DB_MIN;
|
|
const fullSpanHz = data.sample_rate;
|
|
const loHz = data.center_hz - fullSpanHz / 2;
|
|
|
|
// Horizontal dB grid lines
|
|
ctx.strokeStyle = pal.spectrumGrid;
|
|
ctx.lineWidth = 1;
|
|
const gridStep = spectrumRange > 100 ? 20 : 10;
|
|
for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
|
|
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
|
}
|
|
|
|
// Y-axis dB labels (left side)
|
|
ctx.save();
|
|
ctx.font = `${Math.round(9 * dpr)}px monospace`;
|
|
ctx.fillStyle = pal.spectrumLabel;
|
|
ctx.textAlign = "left";
|
|
for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
|
|
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
|
if (y > 8 * dpr && y < H - 2 * dpr) {
|
|
ctx.fillText(`${db}`, 4 * dpr, y - 2 * dpr);
|
|
}
|
|
}
|
|
ctx.restore();
|
|
|
|
// Coordinate helpers
|
|
function hzToX(hz) {
|
|
return ((hz - range.visLoHz) / range.visSpanHz) * W;
|
|
}
|
|
function binX(i) {
|
|
return hzToX(loHz + (i / (n - 1)) * fullSpanHz);
|
|
}
|
|
function binY(i) {
|
|
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
|
return H * (1 - (db - DB_MIN) / dbRange);
|
|
}
|
|
|
|
// ── BW strip (drawn before spectrum so traces appear on top) ──────────────
|
|
if (lastFreqHz != null && currentBandwidthHz > 0) {
|
|
if (_bwDragEdge) {
|
|
const xMid = hzToX(lastFreqHz);
|
|
// Bottom bookmark tab centered on the dial frequency, shown only while resizing BW
|
|
const bwText = formatBwLabel(currentBandwidthHz);
|
|
ctx.save();
|
|
ctx.font = `bold ${Math.round(10 * dpr)}px sans-serif`;
|
|
const tw = ctx.measureText(bwText).width;
|
|
const PAD = 6 * dpr;
|
|
const TAB_H = 16 * dpr;
|
|
const TAB_OFFSET = 4 * dpr;
|
|
const tabX = Math.max(0, Math.min(W - tw - PAD * 2, xMid - (tw + PAD * 2) / 2));
|
|
const tabBottom = H - TAB_OFFSET;
|
|
const tabY = tabBottom - TAB_H;
|
|
const r = 3 * dpr;
|
|
// Rounded-bottom tab shape (flat top)
|
|
ctx.fillStyle = "rgba(240,173,78,0.85)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(tabX, tabY);
|
|
ctx.lineTo(tabX + tw + PAD * 2, tabY);
|
|
ctx.lineTo(tabX + tw + PAD * 2, tabBottom - r);
|
|
ctx.arcTo(tabX + tw + PAD * 2, tabBottom, tabX + tw + PAD * 2 - r, tabBottom, r);
|
|
ctx.lineTo(tabX + r, tabBottom);
|
|
ctx.arcTo(tabX, tabBottom, tabX, tabBottom - r, r);
|
|
ctx.lineTo(tabX, tabY);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
// Tab text
|
|
ctx.fillStyle = spectrumBgColor();
|
|
ctx.textAlign = "left";
|
|
ctx.fillText(bwText, tabX + PAD, tabBottom - 4 * dpr);
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
// ── Spectrum fill ─────────────────────────────────────────────────────────
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.moveTo(binX(0), H);
|
|
for (let i = 0; i < n; i++) ctx.lineTo(binX(i), binY(i));
|
|
ctx.lineTo(binX(n - 1), H);
|
|
ctx.closePath();
|
|
ctx.fillStyle = pal.spectrumFill;
|
|
ctx.fill();
|
|
ctx.restore();
|
|
|
|
// ── Spectrum line ─────────────────────────────────────────────────────────
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = pal.spectrumLine;
|
|
ctx.lineWidth = Math.max(1, dpr);
|
|
for (let i = 0; i < n; i++) {
|
|
const x = binX(i), y = binY(i);
|
|
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
}
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
// ── Peak markers for easier snap-tune targeting ──────────────────────────
|
|
const markerPeaks = visibleSpectrumPeakIndices(data);
|
|
if (markerPeaks.length > 0) {
|
|
ctx.save();
|
|
ctx.fillStyle = pal.waveformPeak;
|
|
ctx.strokeStyle = pal.bg;
|
|
ctx.lineWidth = Math.max(1, dpr * 0.75);
|
|
const radius = Math.max(2, dpr * 1.6);
|
|
for (const idx of markerPeaks) {
|
|
const x = binX(idx);
|
|
const y = binY(idx);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y - radius * 0.35, radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
updateSpectrumFreqAxis(range);
|
|
drawSignalOverlay();
|
|
}
|
|
|
|
function updateSpectrumFreqAxis(range) {
|
|
if (!spectrumFreqAxis) return;
|
|
const spanHz = range.visSpanHz;
|
|
// Pick a step that gives ~5 labels
|
|
const targets = [100, 200, 500, 1e3, 2e3, 5e3, 10e3, 20e3, 50e3,
|
|
100e3, 200e3, 500e3, 1e6, 2e6, 5e6, 10e6];
|
|
const ideal = spanHz / 5;
|
|
const stepHz = targets.reduce((best, s) =>
|
|
Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, targets[0]);
|
|
const axisKey = [
|
|
Math.round(range.visLoHz),
|
|
Math.round(range.visHiHz),
|
|
Math.round(stepHz),
|
|
spectrumFreqAxis.clientWidth || 0,
|
|
].join(":");
|
|
if (axisKey === spectrumAxisKey) return;
|
|
spectrumAxisKey = axisKey;
|
|
|
|
const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz;
|
|
spectrumFreqAxis.innerHTML = "";
|
|
const axisWidth = spectrumFreqAxis.clientWidth || 0;
|
|
const edgePad = 6;
|
|
for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) {
|
|
const frac = (hz - range.visLoHz) / range.visSpanHz;
|
|
if (frac < 0 || frac > 1) continue;
|
|
const label = hz >= 1e6
|
|
? (hz / 1e6).toFixed(stepHz < 1e6 ? (stepHz < 100e3 ? 3 : 1) : 0) + " M"
|
|
: hz >= 1e3
|
|
? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k"
|
|
: hz.toFixed(0);
|
|
const span = document.createElement("span");
|
|
span.textContent = label;
|
|
spectrumFreqAxis.appendChild(span);
|
|
const labelWidth = span.offsetWidth || 0;
|
|
if (axisWidth > 0 && labelWidth > 0) {
|
|
const minCenter = edgePad + labelWidth / 2;
|
|
const maxCenter = axisWidth - edgePad - labelWidth / 2;
|
|
const desiredCenter = frac * axisWidth;
|
|
const clampedCenter = Math.max(minCenter, Math.min(maxCenter, desiredCenter));
|
|
span.style.left = `${clampedCenter}px`;
|
|
} else {
|
|
span.style.left = (frac * 100).toFixed(2) + "%";
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
|
function spectrumZoomAt(cssX, cssW, data, factor) {
|
|
const range = spectrumVisibleRange(data);
|
|
const hzAtCursor = canvasXToHz(cssX, cssW, range);
|
|
const frac = cssX / cssW;
|
|
spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor));
|
|
// Recompute so the pixel under the cursor keeps the same frequency
|
|
const newVisSpan = data.sample_rate / spectrumZoom;
|
|
const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan;
|
|
const loHz = data.center_hz - data.sample_rate / 2;
|
|
spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate;
|
|
}
|
|
|
|
// ── Scroll to zoom ────────────────────────────────────────────────────────────
|
|
if (spectrumCanvas) {
|
|
spectrumCanvas.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
if (!lastSpectrumData) return;
|
|
if (e.ctrlKey) {
|
|
const direction = e.deltaY < 0 ? 1 : -1;
|
|
jogFreq(direction);
|
|
return;
|
|
}
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
|
spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor);
|
|
scheduleSpectrumDraw();
|
|
}, { passive: false });
|
|
}
|
|
|
|
if (overviewCanvas) {
|
|
overviewCanvas.addEventListener("wheel", (e) => {
|
|
e.preventDefault();
|
|
if (!lastSpectrumData) return;
|
|
if (e.ctrlKey) {
|
|
const direction = e.deltaY < 0 ? 1 : -1;
|
|
jogFreq(direction);
|
|
return;
|
|
}
|
|
const rect = overviewCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
|
spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor);
|
|
scheduleSpectrumDraw();
|
|
scheduleOverviewDraw();
|
|
}, { passive: false });
|
|
}
|
|
|
|
// ── BW strip edge hit-test (CSS pixels) ──────────────────────────────────────
|
|
function getBwEdgeHit(cssX, cssW, range) {
|
|
if (!lastFreqHz || !currentBandwidthHz || !lastSpectrumData) return null;
|
|
const halfBw = currentBandwidthHz / 2;
|
|
const xL = ((lastFreqHz - halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
|
const xR = ((lastFreqHz + halfBw - range.visLoHz) / range.visSpanHz) * cssW;
|
|
const HIT = 8;
|
|
if (Math.abs(cssX - xL) < HIT) return "left";
|
|
if (Math.abs(cssX - xR) < HIT) return "right";
|
|
return null;
|
|
}
|
|
|
|
// ── Mouse drag to pan / BW resize ─────────────────────────────────────────────
|
|
let _sDragStart = null; // { clientX, panFrac }
|
|
let _sDragMoved = false;
|
|
|
|
if (spectrumCanvas) {
|
|
spectrumCanvas.addEventListener("mousedown", (e) => {
|
|
if (e.button !== 0) return;
|
|
if (lastSpectrumData) {
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
const edge = getBwEdgeHit(cssX, rect.width, range);
|
|
if (edge) {
|
|
_bwDragEdge = edge;
|
|
_bwDragStartX = cssX;
|
|
_bwDragStartBwHz = currentBandwidthHz;
|
|
_bwDragCanvas = spectrumCanvas;
|
|
_sDragStart = null;
|
|
_sDragMoved = true; // suppress click-to-tune
|
|
return;
|
|
}
|
|
}
|
|
_sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
|
|
_sDragMoved = false;
|
|
});
|
|
|
|
window.addEventListener("mousemove", (e) => {
|
|
if (_bwDragEdge && lastSpectrumData) {
|
|
const dragCanvas = _bwDragCanvas || spectrumCanvas;
|
|
if (!dragCanvas) return;
|
|
const rect = dragCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
const dxHz = ((cssX - _bwDragStartX) / rect.width) * range.visSpanHz;
|
|
let newBw = _bwDragEdge === "right"
|
|
? _bwDragStartBwHz + dxHz * 2
|
|
: _bwDragStartBwHz - dxHz * 2;
|
|
const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
|
newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw)));
|
|
currentBandwidthHz = newBw;
|
|
syncBandwidthInput(newBw);
|
|
scheduleSpectrumDraw();
|
|
scheduleOverviewDraw();
|
|
return;
|
|
}
|
|
if (!_sDragStart || !lastSpectrumData) return;
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
const dx = e.clientX - _sDragStart.clientX;
|
|
if (Math.abs(dx) > 3) _sDragMoved = true;
|
|
spectrumPanFrac = _sDragStart.panFrac - (dx / rect.width) / spectrumZoom;
|
|
scheduleSpectrumDraw();
|
|
});
|
|
|
|
window.addEventListener("mouseup", async () => {
|
|
if (_bwDragEdge) {
|
|
try { await postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`); } catch (_) {}
|
|
_bwDragEdge = null;
|
|
_bwDragCanvas = null;
|
|
return;
|
|
}
|
|
_sDragStart = null;
|
|
});
|
|
}
|
|
|
|
// ── Touch: pinch-to-zoom + single-finger pan ──────────────────────────────────
|
|
let _sTouch = null;
|
|
|
|
if (spectrumCanvas) {
|
|
spectrumCanvas.addEventListener("touchstart", (e) => {
|
|
e.preventDefault();
|
|
if (e.touches.length === 2) {
|
|
const t0 = e.touches[0], t1 = e.touches[1];
|
|
_sTouch = {
|
|
type: "pinch",
|
|
dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY),
|
|
midX: (t0.clientX + t1.clientX) / 2,
|
|
zoom: spectrumZoom,
|
|
panFrac: spectrumPanFrac,
|
|
};
|
|
} else if (e.touches.length === 1) {
|
|
_sTouch = { type: "pan", clientX: e.touches[0].clientX, panFrac: spectrumPanFrac };
|
|
}
|
|
}, { passive: false });
|
|
|
|
spectrumCanvas.addEventListener("touchmove", (e) => {
|
|
e.preventDefault();
|
|
if (!_sTouch || !lastSpectrumData) return;
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
if (_sTouch.type === "pinch" && e.touches.length === 2) {
|
|
const t0 = e.touches[0], t1 = e.touches[1];
|
|
const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
|
const newMidX = (t0.clientX + t1.clientX) / 2;
|
|
const scale = newDist / _sTouch.dist;
|
|
const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale));
|
|
const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2;
|
|
// Compute Hz under original midpoint in original view
|
|
const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom;
|
|
const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2;
|
|
const midFrac = (_sTouch.midX - rect.left) / rect.width;
|
|
const midHz = oldVisLo + midFrac * oldVisSpan;
|
|
const newVisSpan = lastSpectrumData.sample_rate / newZoom;
|
|
const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan;
|
|
spectrumZoom = newZoom;
|
|
spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate;
|
|
// Pan contribution from mid shift
|
|
const dxMid = newMidX - _sTouch.midX;
|
|
spectrumPanFrac -= (dxMid / rect.width) / spectrumZoom;
|
|
scheduleSpectrumDraw();
|
|
} else if (_sTouch.type === "pan" && e.touches.length === 1) {
|
|
const dx = e.touches[0].clientX - _sTouch.clientX;
|
|
spectrumPanFrac = _sTouch.panFrac - (dx / rect.width) / spectrumZoom;
|
|
scheduleSpectrumDraw();
|
|
}
|
|
}, { passive: false });
|
|
|
|
spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; });
|
|
}
|
|
|
|
// ── Hover tooltip + cursor ────────────────────────────────────────────────────
|
|
if (spectrumCanvas) {
|
|
spectrumCanvas.addEventListener("mousemove", (e) => {
|
|
if (!lastSpectrumData || !spectrumTooltip) return;
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
// Change cursor when hovering near BW strip edges
|
|
const edge = getBwEdgeHit(cssX, rect.width, range);
|
|
spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
|
|
const hz = canvasXToHz(cssX, rect.width, range);
|
|
const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData);
|
|
const peakHz = peak?.hz ?? null;
|
|
const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : null;
|
|
if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) {
|
|
spectrumTooltip.textContent = peakDb
|
|
? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}`
|
|
: `Peak ${formatSpectrumFreq(peakHz)}`;
|
|
} else {
|
|
const baseText = formatSpectrumFreq(peakHz ?? hz);
|
|
spectrumTooltip.textContent = peakDb ? `${baseText} · ${peakDb}` : baseText;
|
|
}
|
|
spectrumTooltip.style.display = "block";
|
|
const tw = spectrumTooltip.offsetWidth;
|
|
let tx = cssX + 10;
|
|
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
|
spectrumTooltip.style.left = tx + "px";
|
|
spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px";
|
|
});
|
|
spectrumCanvas.addEventListener("mouseleave", () => {
|
|
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
|
spectrumCanvas.style.cursor = "crosshair";
|
|
});
|
|
}
|
|
|
|
// ── Click to tune (only when not dragging) ────────────────────────────────────
|
|
if (spectrumCanvas) {
|
|
spectrumCanvas.addEventListener("click", (e) => {
|
|
if (_sDragMoved) { _sDragMoved = false; return; }
|
|
if (!lastSpectrumData) return;
|
|
const rect = spectrumCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData);
|
|
if (!Number.isFinite(targetHz)) return;
|
|
postPath(`/set_freq?hz=${targetHz}`)
|
|
.then(() => { applyLocalTunedFrequency(targetHz); })
|
|
.catch(() => {});
|
|
});
|
|
}
|
|
|
|
if (overviewCanvas) {
|
|
overviewCanvas.addEventListener("mousemove", (e) => {
|
|
if (!lastSpectrumData) return;
|
|
const rect = overviewCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
const edge = getBwEdgeHit(cssX, rect.width, range);
|
|
overviewCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
|
|
});
|
|
|
|
overviewCanvas.addEventListener("mouseleave", () => {
|
|
overviewCanvas.style.cursor = "crosshair";
|
|
});
|
|
|
|
overviewCanvas.addEventListener("mousedown", (e) => {
|
|
if (e.button !== 0 || !lastSpectrumData) return;
|
|
const rect = overviewCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const range = spectrumVisibleRange(lastSpectrumData);
|
|
const edge = getBwEdgeHit(cssX, rect.width, range);
|
|
if (!edge) return;
|
|
_bwDragEdge = edge;
|
|
_bwDragStartX = cssX;
|
|
_bwDragStartBwHz = currentBandwidthHz;
|
|
_bwDragCanvas = overviewCanvas;
|
|
_sDragStart = null;
|
|
_sDragMoved = true;
|
|
e.preventDefault();
|
|
});
|
|
|
|
overviewCanvas.addEventListener("click", (e) => {
|
|
if (_sDragMoved) { _sDragMoved = false; return; }
|
|
if (!lastSpectrumData) return;
|
|
const rect = overviewCanvas.getBoundingClientRect();
|
|
const cssX = e.clientX - rect.left;
|
|
const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData);
|
|
if (!Number.isFinite(targetHz)) return;
|
|
postPath(`/set_freq?hz=${targetHz}`)
|
|
.then(() => { applyLocalTunedFrequency(targetHz); })
|
|
.catch(() => {});
|
|
});
|
|
}
|
|
|
|
// ── Spectrum floor input + Auto level ────────────────────────────────────────
|
|
(function () {
|
|
const floorInput = document.getElementById("spectrum-floor-input");
|
|
const autoBtn = document.getElementById("spectrum-auto-btn");
|
|
|
|
if (floorInput) {
|
|
floorInput.addEventListener("change", () => {
|
|
const v = Number(floorInput.value);
|
|
if (!isNaN(v)) {
|
|
spectrumFloor = v;
|
|
if (lastSpectrumData) scheduleSpectrumDraw();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (autoBtn) {
|
|
autoBtn.addEventListener("click", () => {
|
|
if (!lastSpectrumData) return;
|
|
const sorted = [...lastSpectrumData.bins].sort((a, b) => a - b);
|
|
// Use 15th-percentile as noise floor, peak for top
|
|
const noise = sorted[Math.floor(sorted.length * 0.15)];
|
|
const peak = sorted[sorted.length - 1];
|
|
spectrumFloor = Math.floor(noise / 10) * 10 - 10;
|
|
spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
|
|
if (floorInput) floorInput.value = spectrumFloor;
|
|
scheduleSpectrumDraw();
|
|
});
|
|
}
|
|
})();
|