[feat](trx-frontend): improve spectrum tuning controls

Co-authored-by: OpenAI Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-02 18:16:36 +01:00
parent 17be874ee3
commit 1b6acb0fca
3 changed files with 192 additions and 12 deletions
@@ -1158,6 +1158,103 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) {
positionRdsPsOverlay();
}
function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
const [, , maxBw] = mwDefaultsForMode(mode);
return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz);
}
function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
if (!lastSpectrumData || !Number.isFinite(freqHz)) return null;
const sampleRate = Number(lastSpectrumData.sample_rate);
const currentCenterHz = Number(lastSpectrumData.center_hz);
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) {
return null;
}
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
const halfSpanHz = sampleRate / 2;
const requiredHalfSpanHz = safeBw / 2 + SPECTRUM_COVERAGE_MARGIN_HZ;
if (requiredHalfSpanHz * 2 >= sampleRate) {
return alignFreqToRigStep(Math.round(freqHz));
}
const currentLoHz = currentCenterHz - halfSpanHz;
const currentHiHz = currentCenterHz + halfSpanHz;
const requiredLoHz = freqHz - requiredHalfSpanHz;
const requiredHiHz = freqHz + requiredHalfSpanHz;
if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) {
return null;
}
let nextCenterHz = currentCenterHz;
if (requiredLoHz < currentLoHz) {
nextCenterHz = requiredLoHz + halfSpanHz;
}
if (requiredHiHz > currentHiHz) {
nextCenterHz = requiredHiHz - halfSpanHz;
}
return alignFreqToRigStep(Math.round(nextCenterHz));
}
async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz);
if (!Number.isFinite(nextCenterHz)) return;
if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return;
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
if (centerFreqEl && !centerFreqDirty) {
centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit);
}
}
async function setRigFrequency(freqHz) {
const targetHz = Math.round(freqHz);
await postPath(`/set_freq?hz=${targetHz}`);
applyLocalTunedFrequency(targetHz);
await ensureTunedBandwidthCoverage(targetHz);
}
function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) {
if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null;
const sampleRate = Number(lastSpectrumData.sample_rate);
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return null;
const safeBw = Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0);
const halfSpanHz = sampleRate / 2;
const requiredHalfSpanHz = safeBw / 2 + SPECTRUM_COVERAGE_MARGIN_HZ;
if (requiredHalfSpanHz * 2 >= sampleRate) {
return alignFreqToRigStep(Math.round(centerHz));
}
const minFreqHz = centerHz - halfSpanHz + requiredHalfSpanHz;
const maxFreqHz = centerHz + halfSpanHz - requiredHalfSpanHz;
if (freqHz >= minFreqHz && freqHz <= maxFreqHz) {
return null;
}
const clampedHz = Math.max(minFreqHz, Math.min(maxFreqHz, freqHz));
return alignFreqToRigStep(Math.round(clampedHz));
}
async function shiftSpectrumCenter(direction) {
if (!lastSpectrumData || !Number.isFinite(direction) || direction === 0) return;
const sampleRate = Number(lastSpectrumData.sample_rate);
const currentCenterHz = Number(lastSpectrumData.center_hz);
if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) return;
const stepHz = Math.max(50_000, Math.round(sampleRate * 0.35));
const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz));
showHint("Shifting spectrum…", 900);
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
if (centerFreqEl && !centerFreqDirty) {
centerFreqEl.value = formatFreqForStep(nextCenterHz, jogUnit);
}
const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz);
if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) {
await postPath(`/set_freq?hz=${nextFreqHz}`);
applyLocalTunedFrequency(nextFreqHz);
}
}
function refreshCenterFreqDisplay() {
if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return;
centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit);
@@ -1168,7 +1265,8 @@ function parseFreqInput(val, defaultStep) {
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 rawNumber = match[1];
let num = parseFloat(rawNumber.replace(",", "."));
const unit = match[2] || "";
if (Number.isNaN(num)) return null;
if (unit.startsWith("gh") || unit === "g") {
@@ -1178,6 +1276,18 @@ function parseFreqInput(val, defaultStep) {
} else if (unit.startsWith("kh") || unit === "k") {
num *= 1_000;
} else if (!unit) {
const mode = (modeEl?.value || "").toUpperCase();
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
if (mode === "WFM") {
if (hasDecimalSeparator && num >= 50 && num < 200) {
num *= 1_000_000;
return Math.round(num);
}
if (!hasDecimalSeparator && num >= 875 && num <= 1080) {
num = (num / 10) * 1_000_000;
return Math.round(num);
}
}
// Use currently selected input unit when user omits suffix.
if (defaultStep >= 1_000_000) {
num *= 1_000_000;
@@ -1314,6 +1424,7 @@ let serverActiveRigId = null;
let serverLat = null;
let serverLon = null;
let initialMapZoom = 10;
const SPECTRUM_COVERAGE_MARGIN_HZ = 50_000;
function updateFooterBuildInfo() {
const serverEl = document.getElementById("footer-server-build");
@@ -1909,8 +2020,7 @@ async function applyFreqFromInput() {
freqEl.disabled = true;
showHint("Setting frequency…");
try {
await postPath(`/set_freq?hz=${parsed}`);
applyLocalTunedFrequency(parsed);
await setRigFrequency(parsed);
showHint("Freq set", 1500);
} catch (err) {
showHint("Set freq failed", 2000);
@@ -1961,6 +2071,11 @@ if (centerFreqEl) {
applyCenterFreqFromInput();
}
});
centerFreqEl.addEventListener("wheel", (e) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
jogFreq(direction);
}, { passive: false });
}
freqEl.addEventListener("wheel", (e) => {
e.preventDefault();
@@ -2009,8 +2124,7 @@ async function jogFreq(direction) {
jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`;
showHint("Setting frequency…");
try {
await postPath(`/set_freq?hz=${newHz}`);
applyLocalTunedFrequency(newHz);
await setRigFrequency(newHz);
showHint("Freq set", 1000);
} catch (err) {
showHint("Set freq failed", 2000);
@@ -2256,7 +2370,12 @@ async function applyBandwidthFromInput() {
currentBandwidthHz = clamped;
syncBandwidthInput(clamped);
if (lastSpectrumData) scheduleSpectrumDraw();
try { await postPath(`/set_bandwidth?hz=${clamped}`); } catch (_) {}
try {
await postPath(`/set_bandwidth?hz=${clamped}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
}
function estimateBandwidthAroundPeak(data, centerHz) {
@@ -2326,6 +2445,9 @@ async function applyAutoBandwidth() {
if (lastSpectrumData) scheduleSpectrumDraw();
try {
await postPath(`/set_bandwidth?hz=${estimated}`);
if (Number.isFinite(lastFreqHz)) {
await ensureTunedBandwidthCoverage(lastFreqHz);
}
} catch (_) {}
}
@@ -3539,6 +3661,8 @@ window.addEventListener("beforeunload", () => {
const spectrumCanvas = document.getElementById("spectrum-canvas");
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
const spectrumTooltip = document.getElementById("spectrum-tooltip");
const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn");
const spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn");
let spectrumSource = null;
let spectrumReconnectTimer = null;
let spectrumDrawPending = false;
@@ -3908,8 +4032,7 @@ 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);
await setRigFrequency(targetHz);
showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200);
} catch (_) {
showHint("Set freq failed", 1500);
@@ -4673,8 +4796,7 @@ if (spectrumCanvas) {
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); })
setRigFrequency(targetHz)
.catch(() => {});
});
}
@@ -4716,12 +4838,22 @@ if (overviewCanvas) {
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); })
setRigFrequency(targetHz)
.catch(() => {});
});
}
if (spectrumCenterLeftBtn) {
spectrumCenterLeftBtn.addEventListener("click", () => {
shiftSpectrumCenter(-1).catch(() => {});
});
}
if (spectrumCenterRightBtn) {
spectrumCenterRightBtn.addEventListener("click", () => {
shiftSpectrumCenter(1).catch(() => {});
});
}
// ── Spectrum floor input + Auto level ────────────────────────────────────────
(function () {
const floorInput = document.getElementById("spectrum-floor-input");
@@ -81,7 +81,9 @@
<div id="spectrum-bookmark-axis"></div>
<div id="spectrum-panel" style="display:none;">
<div class="spectrum-wrap">
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<canvas id="spectrum-canvas"></canvas>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
<div id="spectrum-tooltip"></div>
<div id="spectrum-freq-axis"></div>
</div>
@@ -1390,6 +1390,17 @@ button:focus-visible, input:focus-visible, select:focus-visible {
gap: 0.5rem;
padding-top: 0.45rem;
}
.spectrum-edge-shift {
width: 1.5rem;
height: 2.35rem;
font-size: 1.1rem;
}
.spectrum-edge-shift-left {
left: 0.28rem;
}
.spectrum-edge-shift-right {
right: 0.28rem;
}
#spectrum-bw-row,
#spectrum-level-row {
gap: 0.4rem;
@@ -1498,6 +1509,41 @@ button:focus-visible, input:focus-visible, select:focus-visible {
cursor: crosshair;
touch-action: none;
}
.spectrum-edge-shift {
position: absolute;
top: calc(var(--spectrum-plot-height) / 2);
transform: translateY(-50%);
z-index: 8;
width: 1.7rem;
height: 2.8rem;
border: 1px solid color-mix(in srgb, var(--border-light) 85%, transparent);
background: color-mix(in srgb, var(--card-bg) 78%, transparent);
color: var(--accent-yellow);
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
box-shadow: 0 8px 18px rgba(0,0,0,0.18);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.spectrum-edge-shift:hover {
background: color-mix(in srgb, var(--btn-bg) 84%, transparent);
color: var(--text-heading);
border-color: var(--accent-yellow);
}
.spectrum-edge-shift:active {
transform: translateY(-50%) scale(0.97);
}
.spectrum-edge-shift-left {
left: 0.45rem;
}
.spectrum-edge-shift-right {
right: 0.45rem;
}
#spectrum-freq-axis {
position: relative;
height: 18px;