feat(http-frontend): BW bookmark on spectrum, dB floor control, remove FIR taps
Replace the BW slider + FIR taps filter panel with a visual bandwidth bookmark drawn directly on the spectrum canvas: - Semi-transparent amber gradient strip spanning dialFreq ± BW/2 - Rounded-top bookmark tab at the top of the strip showing the current BW - Draggable left/right edge handles (cursor: ew-resize) that adjust bandwidth live and send set_bandwidth on mouse-up; range clamped per-mode defaults - Y-axis now labeled with dB values (floor to ceiling) drawn on canvas - Configurable floor level via number input below spectrum (default -100 dB) - Auto button fits floor/range to current noise floor and peak level - Remove FIR taps selector (internal DSP implementation detail) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -258,10 +258,6 @@ function applyCapabilities(caps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filters panel
|
|
||||||
const filtersPanel = document.getElementById("filters-panel");
|
|
||||||
if (filtersPanel) filtersPanel.style.display = caps.filter_controls ? "" : "none";
|
|
||||||
|
|
||||||
// Spectrum panel (SDR-only)
|
// Spectrum panel (SDR-only)
|
||||||
const spectrumPanel = document.getElementById("spectrum-panel");
|
const spectrumPanel = document.getElementById("spectrum-panel");
|
||||||
if (spectrumPanel) {
|
if (spectrumPanel) {
|
||||||
@@ -867,17 +863,10 @@ function render(update) {
|
|||||||
applyCapabilities(update.info.capabilities);
|
applyCapabilities(update.info.capabilities);
|
||||||
}
|
}
|
||||||
// Sync filter state (SDR backends only)
|
// Sync filter state (SDR backends only)
|
||||||
if (update.filter) {
|
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
|
||||||
const bwSlider = document.getElementById("bw-slider");
|
currentBandwidthHz = update.filter.bandwidth_hz;
|
||||||
const bwValue = document.getElementById("bw-value");
|
const bwLabel = document.getElementById("spectrum-bw-label");
|
||||||
const firSelect = document.getElementById("fir-taps-select");
|
if (bwLabel) bwLabel.textContent = "BW: " + formatBwLabel(currentBandwidthHz);
|
||||||
if (bwSlider && typeof update.filter.bandwidth_hz === "number") {
|
|
||||||
bwSlider.value = update.filter.bandwidth_hz;
|
|
||||||
if (bwValue) bwValue.textContent = formatBwLabel(update.filter.bandwidth_hz);
|
|
||||||
}
|
|
||||||
if (firSelect && typeof update.filter.fir_taps === "number") {
|
|
||||||
firSelect.value = String(update.filter.fir_taps);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
||||||
lastFreqHz = update.status.freq.hz;
|
lastFreqHz = update.status.freq.hz;
|
||||||
@@ -896,8 +885,9 @@ function render(update) {
|
|||||||
// When filter panel is active (SDR backend), update the BW slider range
|
// 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
|
// to match the new mode — but only if the server hasn't already sent a
|
||||||
// filter state that overrides it.
|
// filter state that overrides it.
|
||||||
const fp = document.getElementById("filters-panel");
|
// When SDR backend is active (spectrum visible), apply BW default for new
|
||||||
if (fp && fp.style.display !== "none" && !update.filter) {
|
// mode — but only if the server hasn't already pushed a filter_state.
|
||||||
|
if (lastSpectrumData && !update.filter) {
|
||||||
applyBwDefaultForMode(mode, false);
|
applyBwDefaultForMode(mode, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1508,56 +1498,20 @@ function formatBwLabel(hz) {
|
|||||||
return hz + " Hz";
|
return hz + " Hz";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply mode-specific BW slider defaults and optionally send to server.
|
// Current receive bandwidth (Hz) — updated by server sync and BW drag.
|
||||||
|
let currentBandwidthHz = 3_000;
|
||||||
|
|
||||||
|
// Apply mode-specific BW default and optionally push to server.
|
||||||
async function applyBwDefaultForMode(mode, sendToServer) {
|
async function applyBwDefaultForMode(mode, sendToServer) {
|
||||||
const bwSlider = document.getElementById("bw-slider");
|
const [def] = mwDefaultsForMode(mode);
|
||||||
const bwValue = document.getElementById("bw-value");
|
currentBandwidthHz = def;
|
||||||
if (!bwSlider) return;
|
const bwLabel = document.getElementById("spectrum-bw-label");
|
||||||
const [def, min, max, step] = mwDefaultsForMode(mode);
|
if (bwLabel) bwLabel.textContent = "BW: " + formatBwLabel(def);
|
||||||
bwSlider.min = String(min);
|
|
||||||
bwSlider.max = String(max);
|
|
||||||
bwSlider.step = String(step);
|
|
||||||
bwSlider.value = String(def);
|
|
||||||
if (bwValue) bwValue.textContent = formatBwLabel(def);
|
|
||||||
if (sendToServer) {
|
if (sendToServer) {
|
||||||
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
|
try { await postPath(`/set_bandwidth?hz=${def}`); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(function () {
|
|
||||||
const bwSlider = document.getElementById("bw-slider");
|
|
||||||
const bwValue = document.getElementById("bw-value");
|
|
||||||
const firSelect = document.getElementById("fir-taps-select");
|
|
||||||
|
|
||||||
if (bwSlider) {
|
|
||||||
bwSlider.addEventListener("input", () => {
|
|
||||||
const hz = Number(bwSlider.value);
|
|
||||||
if (bwValue) bwValue.textContent = formatBwLabel(hz);
|
|
||||||
});
|
|
||||||
bwSlider.addEventListener("change", async () => {
|
|
||||||
const hz = Number(bwSlider.value);
|
|
||||||
try {
|
|
||||||
await postPath(`/set_bandwidth?hz=${encodeURIComponent(hz)}`);
|
|
||||||
} catch (err) {
|
|
||||||
showHint("Bandwidth set failed", 2000);
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firSelect) {
|
|
||||||
firSelect.addEventListener("change", async () => {
|
|
||||||
const taps = Number(firSelect.value);
|
|
||||||
try {
|
|
||||||
await postPath(`/set_fir_taps?taps=${encodeURIComponent(taps)}`);
|
|
||||||
} catch (err) {
|
|
||||||
showHint("FIR taps set failed", 2000);
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Tab navigation ---
|
// --- Tab navigation ---
|
||||||
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||||
const btn = e.target.closest(".tab[data-tab]");
|
const btn = e.target.closest(".tab[data-tab]");
|
||||||
@@ -2404,6 +2358,15 @@ let lastSpectrumData = null;
|
|||||||
let spectrumZoom = 1;
|
let spectrumZoom = 1;
|
||||||
let spectrumPanFrac = 0.5;
|
let spectrumPanFrac = 0.5;
|
||||||
|
|
||||||
|
// Y-axis level: floor = bottom dB value shown; range = total dB span.
|
||||||
|
let spectrumFloor = -100;
|
||||||
|
let spectrumRange = 80;
|
||||||
|
|
||||||
|
// BW-strip drag state.
|
||||||
|
let _bwDragEdge = null; // "left" | "right" | null
|
||||||
|
let _bwDragStartX = 0;
|
||||||
|
let _bwDragStartBwHz = 0;
|
||||||
|
|
||||||
// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
|
// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
|
||||||
// panFrac so the view never scrolls past the edges.
|
// panFrac so the view never scrolls past the edges.
|
||||||
function spectrumVisibleRange(data) {
|
function spectrumVisibleRange(data) {
|
||||||
@@ -2490,29 +2453,107 @@ function drawSpectrum(data) {
|
|||||||
|
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
|
|
||||||
const DB_MIN = -80, DB_MAX = 0, dbRange = DB_MAX - DB_MIN;
|
const DB_MIN = spectrumFloor;
|
||||||
|
const DB_MAX = spectrumFloor + spectrumRange;
|
||||||
|
const dbRange = DB_MAX - DB_MIN;
|
||||||
const fullSpanHz = data.sample_rate;
|
const fullSpanHz = data.sample_rate;
|
||||||
const loHz = data.center_hz - fullSpanHz / 2;
|
const loHz = data.center_hz - fullSpanHz / 2;
|
||||||
|
|
||||||
// Horizontal dBFS grid
|
// Horizontal dB grid lines
|
||||||
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
for (let db = DB_MIN; db <= DB_MAX; db += 20) {
|
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));
|
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
||||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map bin index → screen x (bins outside the visible window go off-screen and are clipped)
|
// Y-axis dB labels (left side)
|
||||||
function binX(i) {
|
ctx.save();
|
||||||
const hz = loHz + (i / (n - 1)) * fullSpanHz;
|
ctx.font = `${Math.round(9 * dpr)}px monospace`;
|
||||||
|
ctx.fillStyle = "rgba(180,200,220,0.45)";
|
||||||
|
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;
|
return ((hz - range.visLoHz) / range.visSpanHz) * W;
|
||||||
}
|
}
|
||||||
|
function binX(i) {
|
||||||
|
return hzToX(loHz + (i / (n - 1)) * fullSpanHz);
|
||||||
|
}
|
||||||
function binY(i) {
|
function binY(i) {
|
||||||
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
||||||
return H * (1 - (db - DB_MIN) / dbRange);
|
return H * (1 - (db - DB_MIN) / dbRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spectrum fill
|
// ── BW strip (drawn before spectrum so traces appear on top) ──────────────
|
||||||
|
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) {
|
||||||
|
// Warm amber gradient fill
|
||||||
|
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, H);
|
||||||
|
|
||||||
|
// Edge handle bars
|
||||||
|
const EDGE = 5 * dpr;
|
||||||
|
ctx.fillStyle = "rgba(240,173,78,0.30)";
|
||||||
|
ctx.fillRect(xL, 0, EDGE, H);
|
||||||
|
ctx.fillRect(xR - EDGE, 0, EDGE, H);
|
||||||
|
|
||||||
|
// Edge border lines
|
||||||
|
ctx.strokeStyle = "rgba(240,173,78,0.70)";
|
||||||
|
ctx.lineWidth = 1.5 * dpr;
|
||||||
|
ctx.beginPath(); ctx.moveTo(xL, 0); ctx.lineTo(xL, H); ctx.stroke();
|
||||||
|
ctx.beginPath(); ctx.moveTo(xR, 0); ctx.lineTo(xR, H); ctx.stroke();
|
||||||
|
|
||||||
|
// Top bookmark tab centered on the dial frequency
|
||||||
|
const xMid = hzToX(lastFreqHz);
|
||||||
|
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 tabX = Math.max(0, Math.min(W - tw - PAD * 2, xMid - (tw + PAD * 2) / 2));
|
||||||
|
const r = 3 * dpr;
|
||||||
|
// Rounded-top tab shape (flat bottom)
|
||||||
|
ctx.fillStyle = "rgba(240,173,78,0.85)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tabX + r, 0);
|
||||||
|
ctx.lineTo(tabX + tw + PAD * 2 - r, 0);
|
||||||
|
ctx.arcTo(tabX + tw + PAD * 2, 0, tabX + tw + PAD * 2, r, r);
|
||||||
|
ctx.lineTo(tabX + tw + PAD * 2, TAB_H);
|
||||||
|
ctx.lineTo(tabX, TAB_H);
|
||||||
|
ctx.lineTo(tabX, r);
|
||||||
|
ctx.arcTo(tabX, 0, tabX + r, 0, r);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
// Tab text
|
||||||
|
ctx.fillStyle = "#0a0f18";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(bwText, tabX + PAD, TAB_H - 4 * dpr);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spectrum fill ─────────────────────────────────────────────────────────
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(binX(0), H);
|
ctx.moveTo(binX(0), H);
|
||||||
@@ -2523,7 +2564,7 @@ function drawSpectrum(data) {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Spectrum line
|
// ── Spectrum line ─────────────────────────────────────────────────────────
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = "#00e676";
|
ctx.strokeStyle = "#00e676";
|
||||||
@@ -2535,9 +2576,9 @@ function drawSpectrum(data) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
|
||||||
// Tuned-frequency marker
|
// ── Tuned-frequency marker ────────────────────────────────────────────────
|
||||||
if (lastFreqHz != null) {
|
if (lastFreqHz != null) {
|
||||||
const xf = ((lastFreqHz - range.visLoHz) / range.visSpanHz) * W;
|
const xf = hzToX(lastFreqHz);
|
||||||
if (xf >= 0 && xf <= W) {
|
if (xf >= 0 && xf <= W) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.setLineDash([4 * dpr, 4 * dpr]);
|
ctx.setLineDash([4 * dpr, 4 * dpr]);
|
||||||
@@ -2611,17 +2652,60 @@ if (spectrumCanvas) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mouse drag to pan ─────────────────────────────────────────────────────────
|
// ── 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 _sDragStart = null; // { clientX, panFrac }
|
||||||
let _sDragMoved = false;
|
let _sDragMoved = false;
|
||||||
|
|
||||||
if (spectrumCanvas) {
|
if (spectrumCanvas) {
|
||||||
spectrumCanvas.addEventListener("mousedown", (e) => {
|
spectrumCanvas.addEventListener("mousedown", (e) => {
|
||||||
if (e.button !== 0) return;
|
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;
|
||||||
|
_sDragStart = null;
|
||||||
|
_sDragMoved = true; // suppress click-to-tune
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
_sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
|
_sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
|
||||||
_sDragMoved = false;
|
_sDragMoved = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("mousemove", (e) => {
|
window.addEventListener("mousemove", (e) => {
|
||||||
|
if (_bwDragEdge && lastSpectrumData) {
|
||||||
|
const rect = spectrumCanvas.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;
|
||||||
|
const bwLabel = document.getElementById("spectrum-bw-label");
|
||||||
|
if (bwLabel) bwLabel.textContent = "BW: " + formatBwLabel(newBw);
|
||||||
|
drawSpectrum(lastSpectrumData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!_sDragStart || !lastSpectrumData) return;
|
if (!_sDragStart || !lastSpectrumData) return;
|
||||||
const rect = spectrumCanvas.getBoundingClientRect();
|
const rect = spectrumCanvas.getBoundingClientRect();
|
||||||
const dx = e.clientX - _sDragStart.clientX;
|
const dx = e.clientX - _sDragStart.clientX;
|
||||||
@@ -2629,7 +2713,15 @@ if (spectrumCanvas) {
|
|||||||
spectrumPanFrac = _sDragStart.panFrac - (dx / rect.width) / spectrumZoom;
|
spectrumPanFrac = _sDragStart.panFrac - (dx / rect.width) / spectrumZoom;
|
||||||
drawSpectrum(lastSpectrumData);
|
drawSpectrum(lastSpectrumData);
|
||||||
});
|
});
|
||||||
window.addEventListener("mouseup", () => { _sDragStart = null; });
|
|
||||||
|
window.addEventListener("mouseup", async () => {
|
||||||
|
if (_bwDragEdge) {
|
||||||
|
try { await postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`); } catch (_) {}
|
||||||
|
_bwDragEdge = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sDragStart = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Touch: pinch-to-zoom + single-finger pan ──────────────────────────────────
|
// ── Touch: pinch-to-zoom + single-finger pan ──────────────────────────────────
|
||||||
@@ -2686,17 +2778,19 @@ if (spectrumCanvas) {
|
|||||||
spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; });
|
spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Hover tooltip ─────────────────────────────────────────────────────────────
|
// ── Hover tooltip + cursor ────────────────────────────────────────────────────
|
||||||
if (spectrumCanvas) {
|
if (spectrumCanvas) {
|
||||||
spectrumCanvas.addEventListener("mousemove", (e) => {
|
spectrumCanvas.addEventListener("mousemove", (e) => {
|
||||||
if (!lastSpectrumData || !spectrumTooltip) return;
|
if (!lastSpectrumData || !spectrumTooltip) return;
|
||||||
const rect = spectrumCanvas.getBoundingClientRect();
|
const rect = spectrumCanvas.getBoundingClientRect();
|
||||||
const cssX = e.clientX - rect.left;
|
const cssX = e.clientX - rect.left;
|
||||||
const range = spectrumVisibleRange(lastSpectrumData);
|
const range = spectrumVisibleRange(lastSpectrumData);
|
||||||
const hz = canvasXToHz(cssX, rect.width, range);
|
// 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);
|
||||||
spectrumTooltip.textContent = formatSpectrumFreq(hz);
|
spectrumTooltip.textContent = formatSpectrumFreq(hz);
|
||||||
spectrumTooltip.style.display = "block";
|
spectrumTooltip.style.display = "block";
|
||||||
// Keep tooltip inside canvas
|
|
||||||
const tw = spectrumTooltip.offsetWidth;
|
const tw = spectrumTooltip.offsetWidth;
|
||||||
let tx = cssX + 10;
|
let tx = cssX + 10;
|
||||||
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
||||||
@@ -2705,6 +2799,7 @@ if (spectrumCanvas) {
|
|||||||
});
|
});
|
||||||
spectrumCanvas.addEventListener("mouseleave", () => {
|
spectrumCanvas.addEventListener("mouseleave", () => {
|
||||||
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
||||||
|
spectrumCanvas.style.cursor = "crosshair";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2719,3 +2814,33 @@ if (spectrumCanvas) {
|
|||||||
postPath(`/set_freq?hz=${targetHz}`).catch(() => {});
|
postPath(`/set_freq?hz=${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) drawSpectrum(lastSpectrumData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 + 10);
|
||||||
|
if (floorInput) floorInput.value = spectrumFloor;
|
||||||
|
drawSpectrum(lastSpectrumData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@@ -64,7 +64,14 @@
|
|||||||
<div id="spectrum-tooltip"></div>
|
<div id="spectrum-tooltip"></div>
|
||||||
<div id="spectrum-freq-axis"></div>
|
<div id="spectrum-freq-axis"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="spectrum-hint">Scroll to zoom · Drag to pan · Double-click to reset</div>
|
<div id="spectrum-controls">
|
||||||
|
<span id="spectrum-bw-label">BW: --</span>
|
||||||
|
<div id="spectrum-level-row">
|
||||||
|
<label id="spectrum-floor-label">Floor <input type="number" id="spectrum-floor-input" value="-100" step="5" /> dB</label>
|
||||||
|
<button id="spectrum-auto-btn" type="button">Auto</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spectrum-hint">Scroll to zoom · Drag to pan · Double-click to reset · Drag BW edges to resize</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="status">
|
<div class="status">
|
||||||
<div class="full-row freq-row">
|
<div class="full-row freq-row">
|
||||||
@@ -146,26 +153,6 @@
|
|||||||
<button id="tx-limit-btn" type="button">Set</button>
|
<button id="tx-limit-btn" type="button">Set</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="filters-panel" style="display:none;">
|
|
||||||
<div class="label"><span>Filters</span></div>
|
|
||||||
<div class="inline" style="gap: 0.8rem; flex-wrap: wrap; align-items: center;">
|
|
||||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
|
||||||
<span style="color:var(--text-muted); font-size:0.85rem; white-space:nowrap;">BW</span>
|
|
||||||
<input type="range" id="bw-slider" min="1000" max="500000" step="1000" value="3000" style="width:120px;" />
|
|
||||||
<span id="bw-value" style="min-width:4rem; font-size:0.9rem;">3.0 kHz</span>
|
|
||||||
</label>
|
|
||||||
<label style="display:flex; align-items:center; gap:0.4rem;">
|
|
||||||
<span style="color:var(--text-muted); font-size:0.85rem; white-space:nowrap;">FIR taps</span>
|
|
||||||
<select id="fir-taps-select" class="status-input" style="width:auto; height:var(--control-height);">
|
|
||||||
<option value="16">16</option>
|
|
||||||
<option value="32">32</option>
|
|
||||||
<option value="64" selected>64</option>
|
|
||||||
<option value="128">128</option>
|
|
||||||
<option value="256">256</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="full-row label-below-row" id="audio-row">
|
<div class="full-row label-below-row" id="audio-row">
|
||||||
<div class="label"><span>Audio</span></div>
|
<div class="label"><span>Audio</span></div>
|
||||||
|
|||||||
@@ -634,6 +634,50 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
#spectrum-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 3px 4px 0;
|
||||||
|
gap: 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
#spectrum-bw-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--accent-yellow);
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 5rem;
|
||||||
|
}
|
||||||
|
#spectrum-level-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
#spectrum-floor-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
#spectrum-floor-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;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.73rem;
|
||||||
|
}
|
||||||
#spectrum-hint {
|
#spectrum-hint {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
Reference in New Issue
Block a user