[feat](trx-frontend-http): spectrum view UI/UX improvements

Add 8 enhancements to the spectrum display:

1. Noise floor reference line — dashed horizontal line at estimated
   noise floor (15th-percentile heuristic)
2. Peak frequency labels — top 5 strongest peaks labeled with
   frequency text on the spectrum canvas
3. Crosshair lines — vertical + horizontal guide lines follow
   cursor on hover for precise frequency/dB reading
4. Zoom indicator + minimap — shows current zoom level (e.g. "4.0x")
   and a minimap showing the visible window within the full span
5. dB range control — new Range input alongside Floor, with Auto
   button updating both; allows direct control of vertical span
6. Keyboard shortcuts — Arrow Left/Right to pan, +/- to zoom,
   0 to reset zoom; documented in hint bar
7. Full waterfall panel — WebGL waterfall canvas below the spectrum
   plot, synchronized with zoom/pan, with scroll/click/drag support
8. Signal overlay extended — overlay height now includes waterfall
   canvas for consistent BW/bookmark/freq marker coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-26 20:44:08 +01:00
committed by Stan Grams
parent caa7603489
commit 36be58a537
3 changed files with 399 additions and 1 deletions
@@ -1142,6 +1142,10 @@ function signalOverlayHeight() {
getComputedStyle(spectrumPanelEl).display !== "none";
if (spectrumVisible) {
height += spectrumCanvasEl.clientHeight || 0;
const wfCanvas = document.getElementById("spectrum-waterfall-canvas");
if (wfCanvas && wfCanvas.clientHeight > 0) {
height += wfCanvas.clientHeight;
}
}
return Math.floor(height);
}
@@ -2660,6 +2664,7 @@ function updateSpectrumAutoHeight() {
if (lastSpectrumData) {
scheduleSpectrumDraw();
scheduleOverviewDraw();
scheduleSpectrumWaterfallDraw();
}
}
@@ -8673,6 +8678,10 @@ let waterfallGamma = 1.0;
const SPECTRUM_HEADROOM_DB = 20;
const SPECTRUM_SMOOTH_ALPHA = 0.42;
// Crosshair state (CSS coords relative to spectrum canvas).
let spectrumCrosshairX = null;
let spectrumCrosshairY = null;
// BW-strip drag state.
let _bwDragEdge = null; // "left" | "right" | null
let _bwDragStartX = 0;
@@ -8797,6 +8806,13 @@ function buildSpectrumPeakHoldBins(currentBins) {
return peakBins;
}
// Estimate noise floor as the 15th-percentile of visible bins (same heuristic as Auto).
function estimateNoiseFloorDb(bins) {
if (!Array.isArray(bins) || bins.length === 0) return null;
const sorted = bins.slice().sort((a, b) => a - b);
return sorted[Math.floor(sorted.length * 0.15)];
}
function buildSpectrumRenderData(frame) {
if (!frame || !Array.isArray(frame.bins)) return frame;
const prev = lastSpectrumRenderData;
@@ -8979,6 +8995,9 @@ function startSpectrumStreaming() {
overviewWaterfallRows = [];
overviewWaterfallPushCount = 0;
overviewWfResetTextureCache();
spectrumWfRows = [];
spectrumWfPushCount = 0;
spectrumWfTexReady = false;
scheduleOverviewDraw();
clearSpectrumCanvas();
updateRdsPsOverlay(null);
@@ -9011,6 +9030,7 @@ function startSpectrumStreaming() {
settlePendingSpectrumFrameWaiters(lastSpectrumData);
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
pushOverviewWaterfallFrame(lastSpectrumData);
pushSpectrumWaterfallFrame(lastSpectrumData);
refreshCenterFreqDisplay();
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
scheduleSpectrumDraw();
@@ -9069,6 +9089,9 @@ function stopSpectrumStreaming() {
overviewWaterfallRows = [];
overviewWaterfallPushCount = 0;
overviewWfResetTextureCache();
spectrumWfRows = [];
spectrumWfPushCount = 0;
spectrumWfTexReady = false;
scheduleOverviewDraw();
updateRdsPsOverlay(null);
clearSpectrumCanvas();
@@ -9341,6 +9364,7 @@ function scheduleSpectrumDraw() {
if (lastSpectrumRenderData) {
drawSpectrum(lastSpectrumRenderData);
if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw();
}
});
}
@@ -9406,6 +9430,19 @@ function drawSpectrum(data) {
spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr));
// ── Noise floor reference line ──
const noiseDb = estimateNoiseFloorDb(bins);
if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) {
const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange));
const nfSegments = [];
const dashLen = Math.max(4, Math.round(6 * dpr));
const gapLen = Math.max(3, Math.round(5 * dpr));
for (let x = 0; x < W; x += dashLen + gapLen) {
nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY);
}
spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8));
}
const markerPeaks = visibleSpectrumPeakIndices(data);
if (markerPeaks.length > 0) {
spectrumTmpMarkerPoints.length = 0;
@@ -9415,11 +9452,200 @@ function drawSpectrum(data) {
spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
}
// ── Peak frequency labels (top 5 strongest) ──
if (markerPeaks.length > 0) {
const topPeaks = markerPeaks.slice(0, 5);
const labelEl = document.getElementById("spectrum-peak-labels");
if (labelEl) {
labelEl.innerHTML = "";
const cssW = spectrumCanvas.clientWidth || 640;
const cssH = spectrumCanvas.clientHeight || 160;
for (const idx of topPeaks) {
const peakHz = loHz + (idx / (n - 1)) * fullSpanHz;
const peakDb = bins[idx];
if (peakDb < DB_MIN + 6) continue; // skip near-floor peaks
const xFrac = (peakHz - range.visLoHz) / range.visSpanHz;
if (xFrac < 0.02 || xFrac > 0.98) continue;
const yFrac = 1 - (Math.max(DB_MIN, Math.min(DB_MAX, peakDb)) - DB_MIN) / dbRange;
const span = document.createElement("span");
span.className = "spectrum-peak-label";
span.textContent = formatSpectrumFreq(peakHz);
span.style.left = (xFrac * cssW) + "px";
span.style.top = Math.max(2, yFrac * cssH - 16) + "px";
labelEl.appendChild(span);
}
}
}
// ── Crosshair lines ──
if (spectrumCrosshairX != null && spectrumCrosshairY != null) {
const cx = spectrumCrosshairX * dpr;
const cy = spectrumCrosshairY * dpr;
const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5);
spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6));
spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6));
}
// ── Zoom indicator ──
const zoomEl = document.getElementById("spectrum-zoom-indicator");
if (zoomEl) {
if (spectrumZoom > 1.01) {
zoomEl.textContent = spectrumZoom.toFixed(1) + "x";
zoomEl.style.display = "block";
} else {
zoomEl.style.display = "none";
}
}
// ── Zoom minimap ──
const minimapEl = document.getElementById("spectrum-minimap");
if (minimapEl) {
if (spectrumZoom > 1.01) {
minimapEl.style.display = "block";
const viewFrac = 1 / spectrumZoom;
const halfVis = viewFrac / 2;
const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
const viewL = panClamped - halfVis;
const viewR = panClamped + halfVis;
const inner = minimapEl.querySelector(".minimap-view");
if (inner) {
inner.style.left = (viewL * 100) + "%";
inner.style.width = ((viewR - viewL) * 100) + "%";
}
} else {
minimapEl.style.display = "none";
}
}
updateSpectrumFreqAxis(range);
updateBookmarkAxis(range);
drawSignalOverlay();
}
// ── Full waterfall panel below spectrum ───────────────────────────────────────
const spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas");
const spectrumWaterfallGl = (typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas)
? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions)
: null;
let spectrumWfRows = [];
let spectrumWfPushCount = 0;
let spectrumWfTexData = null;
let spectrumWfTexWidth = 0;
let spectrumWfTexHeight = 0;
let spectrumWfTexPushCount = 0;
let spectrumWfTexPalKey = "";
let spectrumWfTexReady = false;
let spectrumWfDrawPending = false;
const SPECTRUM_WF_TEX_MAX_W = 1024;
function pushSpectrumWaterfallFrame(data) {
if (!spectrumWaterfallCanvas || !data || !Array.isArray(data.bins) || data.bins.length === 0) return;
spectrumWfRows.push(data.bins.slice());
spectrumWfPushCount++;
trimSpectrumWaterfallRows();
scheduleSpectrumWaterfallDraw();
}
function trimSpectrumWaterfallRows() {
if (!spectrumWaterfallCanvas) return;
const dpr = window.devicePixelRatio || 1;
const maxRows = Math.max(1, Math.floor((spectrumWaterfallCanvas.clientHeight || 120) * dpr));
while (spectrumWfRows.length > maxRows) {
spectrumWfRows.shift();
}
}
function scheduleSpectrumWaterfallDraw() {
if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return;
spectrumWfDrawPending = true;
requestAnimationFrame(() => {
spectrumWfDrawPending = false;
drawSpectrumWaterfall();
});
}
function drawSpectrumWaterfall() {
if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return;
if (!lastSpectrumData || spectrumWfRows.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const cssW = spectrumWaterfallCanvas.clientWidth || 640;
const cssH = spectrumWaterfallCanvas.clientHeight || 120;
spectrumWaterfallGl.ensureSize(cssW, cssH, dpr);
const W = spectrumWaterfallCanvas.width;
const H = spectrumWaterfallCanvas.height;
if (W <= 0 || H <= 0) return;
const pal = canvasPalette();
const maxVisible = Math.max(1, Math.floor(H));
const rows = spectrumWfRows.slice(-maxVisible);
if (rows.length === 0) return;
const iW = Math.max(96, Math.min(SPECTRUM_WF_TEX_MAX_W, Math.ceil(W / 2)));
const iH = Math.max(1, rows.length);
const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115;
const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90);
const view = spectrumVisibleRange(lastSpectrumData);
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
const palKey = `swf|${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
const rowStride = iW * 4;
const expectedSize = iW * iH * 4;
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH;
const palChanged = spectrumWfTexPalKey !== palKey;
const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0;
let texUpdated = false;
if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) {
spectrumWfTexData = new Uint8Array(expectedSize);
}
spectrumWfTexWidth = iW;
spectrumWfTexHeight = iH;
function renderRow(dstY, srcBins) {
if (!Array.isArray(srcBins) || srcBins.length === 0) return;
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
const spanBins = Math.max(1, endIdx - startIdx);
const rowBase = dstY * rowStride;
for (let x = 0; x < iW; x++) {
const frac = x / Math.max(1, iW - 1);
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins));
const c = waterfallColorRgba(srcBins[binIdx], pal, minDb, maxDb);
const p = rowBase + x * 4;
spectrumWfTexData[p + 0] = Math.round(c[0] * 255);
spectrumWfTexData[p + 1] = Math.round(c[1] * 255);
spectrumWfTexData[p + 2] = Math.round(c[2] * 255);
spectrumWfTexData[p + 3] = Math.round(c[3] * 255);
}
}
if (needsFull) {
for (let y = 0; y < iH; y++) renderRow(y, rows[y]);
spectrumWfTexPushCount = spectrumWfPushCount;
spectrumWfTexPalKey = palKey;
texUpdated = true;
} else if (newPushes > 0) {
const newCount = Math.min(newPushes, iH);
if (newCount >= iH) {
for (let y = 0; y < iH; y++) renderRow(y, rows[y]);
} else {
const shiftBytes = newCount * rowStride;
spectrumWfTexData.copyWithin(0, shiftBytes);
const startRow = iH - newCount;
for (let y = startRow; y < iH; y++) renderRow(y, rows[y]);
}
spectrumWfTexPushCount = spectrumWfPushCount;
spectrumWfTexPalKey = palKey;
texUpdated = true;
}
if (texUpdated || !spectrumWfTexReady) {
spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, spectrumWfTexData, "linear");
spectrumWfTexReady = true;
}
spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
}
function bmHexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
@@ -10008,6 +10234,46 @@ window.addEventListener("keydown", (event) => {
void captureSpectrumScreenshot();
return;
}
// Spectrum keyboard navigation
if (lastSpectrumData && spectrumCanvas) {
// Arrow Left/Right — pan spectrum
if (key === "arrowleft" || key === "arrowright") {
event.preventDefault();
const step = 0.1 / spectrumZoom;
spectrumPanFrac += key === "arrowleft" ? -step : step;
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// +/= — zoom in
if (key === "+" || key === "=") {
event.preventDefault();
const cssW = spectrumCanvas.clientWidth || 640;
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25);
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// - — zoom out
if (key === "-") {
event.preventDefault();
const cssW = spectrumCanvas.clientWidth || 640;
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25);
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
// 0 — reset zoom
if (key === "0") {
event.preventDefault();
spectrumZoom = 1;
spectrumPanFrac = 0.5;
scheduleSpectrumDraw();
scheduleOverviewDraw();
return;
}
}
}, { capture: true });
// ── Zoom helpers ──────────────────────────────────────────────────────────────
@@ -10069,6 +10335,19 @@ if (overviewCanvas) {
});
}
// Full waterfall panel interactions.
if (spectrumWaterfallCanvas) {
spectrumWaterfallCanvas.addEventListener("wheel", (e) => {
handleSpectrumWheel(e, spectrumWaterfallCanvas);
}, { passive: false });
spectrumWaterfallCanvas.addEventListener("click", (e) => {
handleSpectrumClick(e, spectrumWaterfallCanvas);
});
spectrumWaterfallCanvas.addEventListener("mousedown", (e) => {
onSpectrumMouseDown(e, spectrumWaterfallCanvas);
});
}
// ── BW strip edge hit-test (CSS pixels) ──────────────────────────────────────
function getBwEdgeHit(cssX, cssW, range) {
@@ -10293,10 +10572,20 @@ if (spectrumCanvas) {
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";
// Update crosshair position
spectrumCrosshairX = cssX;
spectrumCrosshairY = e.clientY - rect.top;
scheduleSpectrumDraw();
});
spectrumCanvas.addEventListener("mouseleave", () => {
if (spectrumTooltip) spectrumTooltip.style.display = "none";
spectrumCanvas.style.cursor = "crosshair";
spectrumCrosshairX = null;
spectrumCrosshairY = null;
// Clear peak labels on leave
const labelEl = document.getElementById("spectrum-peak-labels");
if (labelEl) labelEl.innerHTML = "";
scheduleSpectrumDraw();
});
}
@@ -10333,6 +10622,18 @@ if (spectrumCenterRightBtn) {
});
}
const rangeInput = document.getElementById("spectrum-range-input");
if (rangeInput) {
rangeInput.value = spectrumRange;
rangeInput.addEventListener("change", () => {
const v = Number(rangeInput.value);
if (!isNaN(v) && v >= 10) {
spectrumRange = v;
if (lastSpectrumData) scheduleSpectrumDraw();
}
});
}
if (autoBtn) {
autoBtn.addEventListener("click", () => {
if (!lastSpectrumData) return;
@@ -10343,6 +10644,7 @@ if (spectrumCenterRightBtn) {
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;
if (rangeInput) rangeInput.value = spectrumRange;
scheduleSpectrumDraw();
});
}