From 36be58a5374556b7d2cb1707aa647edcdad63486 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Thu, 26 Mar 2026 20:44:08 +0100 Subject: [PATCH] [feat](trx-frontend-http): spectrum view UI/UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 302 ++++++++++++++++++ .../trx-frontend-http/assets/web/index.html | 7 +- .../trx-frontend-http/assets/web/style.css | 91 ++++++ 3 files changed, 399 insertions(+), 1 deletion(-) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 10590f9..17d6eaa 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -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(); }); } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 99689e2..7959218 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -118,9 +118,13 @@
+ + +
+
@@ -149,11 +153,12 @@ +
-
Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize
+
Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset
Pinch to zoom · Drag to pan · Drag BW edges to resize
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 01a2123..66eab9f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -34,6 +34,7 @@ --card-base-max-width: 1280px; --card-max-width: 1600px; --card-bookmark-gutter: 9.5rem; + --spectrum-waterfall-height: 120px; --spectrum-bookmark-side-width: 6.5rem; --spectrum-bookmark-side-offset: 8.85rem; } @@ -2743,6 +2744,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { } #spectrum-bw-label, #spectrum-floor-label, + #spectrum-range-label, #spectrum-peak-hold-label { flex: 1 1 100%; justify-content: space-between; @@ -2757,6 +2759,7 @@ button:focus-visible, input:focus-visible, select:focus-visible { } #spectrum-bw-input, #spectrum-floor-input, + #spectrum-range-input, #overview-peak-hold { flex: 1 1 auto; min-width: 3rem; @@ -3105,6 +3108,76 @@ button:focus-visible, input:focus-visible, select:focus-visible { font-weight: 700; pointer-events: none; } +/* ── Peak labels on spectrum ── */ +#spectrum-peak-labels { + position: absolute; + top: 0; + left: 0; + right: 0; + height: var(--spectrum-plot-height); + pointer-events: none; + z-index: 6; + overflow: hidden; +} +.spectrum-peak-label { + position: absolute; + transform: translateX(-50%); + font-size: 0.58rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + color: var(--text-muted); + font-weight: 600; + white-space: nowrap; + pointer-events: none; + text-shadow: 0 1px 3px color-mix(in srgb, var(--bg) 80%, transparent); + opacity: 0.85; +} +/* ── Zoom indicator ── */ +#spectrum-zoom-indicator { + display: none; + position: absolute; + top: 6px; + right: 3.5rem; + z-index: 9; + font-size: 0.68rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-weight: 700; + color: var(--text-muted); + background: color-mix(in srgb, var(--spectrum-bg) 75%, transparent); + padding: 1px 6px; + border-radius: 3px; + pointer-events: none; +} +/* ── Zoom minimap ── */ +#spectrum-minimap { + display: none; + position: absolute; + bottom: 22px; + right: 3.5rem; + z-index: 9; + width: 80px; + height: 10px; + background: color-mix(in srgb, var(--spectrum-bg) 60%, transparent); + border: 1px solid color-mix(in srgb, var(--border-light) 50%, transparent); + border-radius: 3px; + pointer-events: none; + overflow: hidden; +} +.minimap-view { + position: absolute; + top: 0; + height: 100%; + background: color-mix(in srgb, var(--accent-yellow) 40%, transparent); + border-radius: 2px; +} +/* ── Full waterfall canvas ── */ +#spectrum-waterfall-canvas { + display: block; + width: 100%; + height: var(--spectrum-waterfall-height, 120px); + background: var(--spectrum-bg); + cursor: crosshair; + touch-action: none; +} #spectrum-bookmark-axis { position: absolute; top: calc(-1 * var(--overview-plot-height)); @@ -3308,6 +3381,24 @@ button:focus-visible, input:focus-visible, select:focus-visible { text-align: right; height: 1.5rem; } +#spectrum-range-label { + display: flex; + align-items: center; + gap: 0.3rem; + font-size: 0.75rem; + color: var(--text-muted); +} +#spectrum-range-input { + width: 3.4rem; + padding: 1px 4px; + font-size: 0.75rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--input-bg); + color: var(--text); + text-align: right; + height: 1.5rem; +} #spectrum-auto-btn { height: 1.5rem; min-height: 0;