[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:
@@ -1142,6 +1142,10 @@ function signalOverlayHeight() {
|
|||||||
getComputedStyle(spectrumPanelEl).display !== "none";
|
getComputedStyle(spectrumPanelEl).display !== "none";
|
||||||
if (spectrumVisible) {
|
if (spectrumVisible) {
|
||||||
height += spectrumCanvasEl.clientHeight || 0;
|
height += spectrumCanvasEl.clientHeight || 0;
|
||||||
|
const wfCanvas = document.getElementById("spectrum-waterfall-canvas");
|
||||||
|
if (wfCanvas && wfCanvas.clientHeight > 0) {
|
||||||
|
height += wfCanvas.clientHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Math.floor(height);
|
return Math.floor(height);
|
||||||
}
|
}
|
||||||
@@ -2660,6 +2664,7 @@ function updateSpectrumAutoHeight() {
|
|||||||
if (lastSpectrumData) {
|
if (lastSpectrumData) {
|
||||||
scheduleSpectrumDraw();
|
scheduleSpectrumDraw();
|
||||||
scheduleOverviewDraw();
|
scheduleOverviewDraw();
|
||||||
|
scheduleSpectrumWaterfallDraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8673,6 +8678,10 @@ let waterfallGamma = 1.0;
|
|||||||
const SPECTRUM_HEADROOM_DB = 20;
|
const SPECTRUM_HEADROOM_DB = 20;
|
||||||
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
const SPECTRUM_SMOOTH_ALPHA = 0.42;
|
||||||
|
|
||||||
|
// Crosshair state (CSS coords relative to spectrum canvas).
|
||||||
|
let spectrumCrosshairX = null;
|
||||||
|
let spectrumCrosshairY = null;
|
||||||
|
|
||||||
// BW-strip drag state.
|
// BW-strip drag state.
|
||||||
let _bwDragEdge = null; // "left" | "right" | null
|
let _bwDragEdge = null; // "left" | "right" | null
|
||||||
let _bwDragStartX = 0;
|
let _bwDragStartX = 0;
|
||||||
@@ -8797,6 +8806,13 @@ function buildSpectrumPeakHoldBins(currentBins) {
|
|||||||
return peakBins;
|
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) {
|
function buildSpectrumRenderData(frame) {
|
||||||
if (!frame || !Array.isArray(frame.bins)) return frame;
|
if (!frame || !Array.isArray(frame.bins)) return frame;
|
||||||
const prev = lastSpectrumRenderData;
|
const prev = lastSpectrumRenderData;
|
||||||
@@ -8979,6 +8995,9 @@ function startSpectrumStreaming() {
|
|||||||
overviewWaterfallRows = [];
|
overviewWaterfallRows = [];
|
||||||
overviewWaterfallPushCount = 0;
|
overviewWaterfallPushCount = 0;
|
||||||
overviewWfResetTextureCache();
|
overviewWfResetTextureCache();
|
||||||
|
spectrumWfRows = [];
|
||||||
|
spectrumWfPushCount = 0;
|
||||||
|
spectrumWfTexReady = false;
|
||||||
scheduleOverviewDraw();
|
scheduleOverviewDraw();
|
||||||
clearSpectrumCanvas();
|
clearSpectrumCanvas();
|
||||||
updateRdsPsOverlay(null);
|
updateRdsPsOverlay(null);
|
||||||
@@ -9011,6 +9030,7 @@ function startSpectrumStreaming() {
|
|||||||
settlePendingSpectrumFrameWaiters(lastSpectrumData);
|
settlePendingSpectrumFrameWaiters(lastSpectrumData);
|
||||||
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
|
pushSpectrumPeakHoldFrame(lastSpectrumRenderData);
|
||||||
pushOverviewWaterfallFrame(lastSpectrumData);
|
pushOverviewWaterfallFrame(lastSpectrumData);
|
||||||
|
pushSpectrumWaterfallFrame(lastSpectrumData);
|
||||||
refreshCenterFreqDisplay();
|
refreshCenterFreqDisplay();
|
||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
scheduleSpectrumDraw();
|
scheduleSpectrumDraw();
|
||||||
@@ -9069,6 +9089,9 @@ function stopSpectrumStreaming() {
|
|||||||
overviewWaterfallRows = [];
|
overviewWaterfallRows = [];
|
||||||
overviewWaterfallPushCount = 0;
|
overviewWaterfallPushCount = 0;
|
||||||
overviewWfResetTextureCache();
|
overviewWfResetTextureCache();
|
||||||
|
spectrumWfRows = [];
|
||||||
|
spectrumWfPushCount = 0;
|
||||||
|
spectrumWfTexReady = false;
|
||||||
scheduleOverviewDraw();
|
scheduleOverviewDraw();
|
||||||
updateRdsPsOverlay(null);
|
updateRdsPsOverlay(null);
|
||||||
clearSpectrumCanvas();
|
clearSpectrumCanvas();
|
||||||
@@ -9341,6 +9364,7 @@ function scheduleSpectrumDraw() {
|
|||||||
if (lastSpectrumRenderData) {
|
if (lastSpectrumRenderData) {
|
||||||
drawSpectrum(lastSpectrumRenderData);
|
drawSpectrum(lastSpectrumRenderData);
|
||||||
if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
|
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));
|
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);
|
const markerPeaks = visibleSpectrumPeakIndices(data);
|
||||||
if (markerPeaks.length > 0) {
|
if (markerPeaks.length > 0) {
|
||||||
spectrumTmpMarkerPoints.length = 0;
|
spectrumTmpMarkerPoints.length = 0;
|
||||||
@@ -9415,11 +9452,200 @@ function drawSpectrum(data) {
|
|||||||
spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
|
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);
|
updateSpectrumFreqAxis(range);
|
||||||
updateBookmarkAxis(range);
|
updateBookmarkAxis(range);
|
||||||
drawSignalOverlay();
|
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) {
|
function bmHexToRgba(hex, alpha) {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
@@ -10008,6 +10234,46 @@ window.addEventListener("keydown", (event) => {
|
|||||||
void captureSpectrumScreenshot();
|
void captureSpectrumScreenshot();
|
||||||
return;
|
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 });
|
}, { capture: true });
|
||||||
|
|
||||||
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
// ── 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) ──────────────────────────────────────
|
// ── BW strip edge hit-test (CSS pixels) ──────────────────────────────────────
|
||||||
function getBwEdgeHit(cssX, cssW, range) {
|
function getBwEdgeHit(cssX, cssW, range) {
|
||||||
@@ -10293,10 +10572,20 @@ if (spectrumCanvas) {
|
|||||||
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
||||||
spectrumTooltip.style.left = tx + "px";
|
spectrumTooltip.style.left = tx + "px";
|
||||||
spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "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", () => {
|
spectrumCanvas.addEventListener("mouseleave", () => {
|
||||||
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
||||||
spectrumCanvas.style.cursor = "crosshair";
|
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) {
|
if (autoBtn) {
|
||||||
autoBtn.addEventListener("click", () => {
|
autoBtn.addEventListener("click", () => {
|
||||||
if (!lastSpectrumData) return;
|
if (!lastSpectrumData) return;
|
||||||
@@ -10343,6 +10644,7 @@ if (spectrumCenterRightBtn) {
|
|||||||
spectrumFloor = Math.floor(noise / 10) * 10 - 10;
|
spectrumFloor = Math.floor(noise / 10) * 10 - 10;
|
||||||
spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
|
spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
|
||||||
if (floorInput) floorInput.value = spectrumFloor;
|
if (floorInput) floorInput.value = spectrumFloor;
|
||||||
|
if (rangeInput) rangeInput.value = spectrumRange;
|
||||||
scheduleSpectrumDraw();
|
scheduleSpectrumDraw();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,9 +118,13 @@
|
|||||||
<div id="spectrum-bookmark-axis"></div>
|
<div id="spectrum-bookmark-axis"></div>
|
||||||
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
|
||||||
<canvas id="spectrum-canvas"></canvas>
|
<canvas id="spectrum-canvas"></canvas>
|
||||||
|
<div id="spectrum-peak-labels" aria-hidden="true"></div>
|
||||||
|
<div id="spectrum-zoom-indicator" aria-hidden="true"></div>
|
||||||
|
<div id="spectrum-minimap" aria-hidden="true"><div class="minimap-view"></div></div>
|
||||||
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
<div id="spectrum-db-axis" aria-hidden="true"></div>
|
||||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||||
<div id="spectrum-tooltip"></div>
|
<div id="spectrum-tooltip"></div>
|
||||||
|
<canvas id="spectrum-waterfall-canvas"></canvas>
|
||||||
<div id="spectrum-freq-axis">
|
<div id="spectrum-freq-axis">
|
||||||
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||||
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||||
@@ -149,11 +153,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-115" step="5" /> dB</label>
|
||||||
|
<label id="spectrum-range-label">Range <input type="number" id="spectrum-range-input" value="90" step="10" min="10" /> dB</label>
|
||||||
<button id="spectrum-auto-btn" type="button">Auto</button>
|
<button id="spectrum-auto-btn" type="button">Auto</button>
|
||||||
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
<label id="spectrum-gamma-label">Contrast <input type="range" id="spectrum-gamma-input" min="0.2" max="3.0" step="0.1" value="1.0" /><span id="spectrum-gamma-value">1.0</span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize</div>
|
<div id="spectrum-hint" class="spectrum-hint-mouse">Scroll to zoom · Ctrl+Scroll to tune · Drag to pan · Drag BW edges to resize · +/- zoom · Arrows pan · 0 reset</div>
|
||||||
<div id="spectrum-hint-touch" class="spectrum-hint-touch">Pinch to zoom · Drag to pan · Drag BW edges to resize</div>
|
<div id="spectrum-hint-touch" class="spectrum-hint-touch">Pinch to zoom · Drag to pan · Drag BW edges to resize</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="signal-split-control" title="Set waterfall and waveform split" aria-label="Set waterfall and waveform split">
|
<div id="signal-split-control" title="Set waterfall and waveform split" aria-label="Set waterfall and waveform split">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
--card-base-max-width: 1280px;
|
--card-base-max-width: 1280px;
|
||||||
--card-max-width: 1600px;
|
--card-max-width: 1600px;
|
||||||
--card-bookmark-gutter: 9.5rem;
|
--card-bookmark-gutter: 9.5rem;
|
||||||
|
--spectrum-waterfall-height: 120px;
|
||||||
--spectrum-bookmark-side-width: 6.5rem;
|
--spectrum-bookmark-side-width: 6.5rem;
|
||||||
--spectrum-bookmark-side-offset: 8.85rem;
|
--spectrum-bookmark-side-offset: 8.85rem;
|
||||||
}
|
}
|
||||||
@@ -2743,6 +2744,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
}
|
}
|
||||||
#spectrum-bw-label,
|
#spectrum-bw-label,
|
||||||
#spectrum-floor-label,
|
#spectrum-floor-label,
|
||||||
|
#spectrum-range-label,
|
||||||
#spectrum-peak-hold-label {
|
#spectrum-peak-hold-label {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2757,6 +2759,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
}
|
}
|
||||||
#spectrum-bw-input,
|
#spectrum-bw-input,
|
||||||
#spectrum-floor-input,
|
#spectrum-floor-input,
|
||||||
|
#spectrum-range-input,
|
||||||
#overview-peak-hold {
|
#overview-peak-hold {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
@@ -3105,6 +3108,76 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
pointer-events: none;
|
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 {
|
#spectrum-bookmark-axis {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(-1 * var(--overview-plot-height));
|
top: calc(-1 * var(--overview-plot-height));
|
||||||
@@ -3308,6 +3381,24 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
height: 1.5rem;
|
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 {
|
#spectrum-auto-btn {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user