feat(http-frontend): move spectrum above controls, add zoom/pan/tooltip
Spectrum panel is now placed above the freq/mode/jog row, spanning the full card width. Key improvements: - Scroll wheel zooms in/out at the cursor position (up to 64x); double- click resets to full bandwidth view. - Mouse drag pans the visible window; click-to-tune is suppressed when a drag has occurred. - Touch pinch-to-zoom and single-finger drag-to-pan supported. - Hover tooltip shows the frequency under the cursor, formatted to the currently selected unit (MHz/kHz/Hz, matching the jog-step selection). - Frequency axis labels update to reflect the zoomed visible range. - Canvas height increased to 160 px; axis bar styled with card bg. - A small hint line below the panel explains the controls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -2342,12 +2342,50 @@ window.addEventListener("beforeunload", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Spectrum display ────────────────────────────────────────────────────────
|
||||
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
let spectrumPollTimer = null;
|
||||
let lastSpectrumData = null;
|
||||
|
||||
// ── Spectrum display ─────────────────────────────────────────────────────────
|
||||
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
||||
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
||||
let spectrumPollTimer = null;
|
||||
let lastSpectrumData = null;
|
||||
|
||||
// Zoom / pan state. zoom >= 1; panFrac in [0,1] is the fraction of the full
|
||||
// bandwidth at the centre of the visible window.
|
||||
let spectrumZoom = 1;
|
||||
let spectrumPanFrac = 0.5;
|
||||
|
||||
// Returns { loHz, hiHz, visLoHz, visHiHz, fullSpanHz, visSpanHz } and clamps
|
||||
// panFrac so the view never scrolls past the edges.
|
||||
function spectrumVisibleRange(data) {
|
||||
const fullSpanHz = data.sample_rate;
|
||||
const loHz = data.center_hz - fullSpanHz / 2;
|
||||
const halfVis = 0.5 / spectrumZoom;
|
||||
spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
|
||||
const visCenterHz = loHz + spectrumPanFrac * fullSpanHz;
|
||||
const visSpanHz = fullSpanHz / spectrumZoom;
|
||||
return {
|
||||
loHz,
|
||||
hiHz: loHz + fullSpanHz,
|
||||
visLoHz: visCenterHz - visSpanHz / 2,
|
||||
visHiHz: visCenterHz + visSpanHz / 2,
|
||||
fullSpanHz,
|
||||
visSpanHz,
|
||||
};
|
||||
}
|
||||
|
||||
function canvasXToHz(cssX, cssW, range) {
|
||||
return range.visLoHz + (cssX / cssW) * range.visSpanHz;
|
||||
}
|
||||
|
||||
// Format a frequency according to the current jog-step unit.
|
||||
function formatSpectrumFreq(hz) {
|
||||
if (jogStep >= 1_000_000) return (hz / 1e6).toFixed(3) + " MHz";
|
||||
if (jogStep >= 1_000) return (hz / 1e3).toFixed(3) + " kHz";
|
||||
return hz.toFixed(0) + " Hz";
|
||||
}
|
||||
|
||||
// ── Polling ──────────────────────────────────────────────────────────────────
|
||||
function startSpectrumPolling() {
|
||||
if (spectrumPollTimer !== null) return;
|
||||
spectrumPollTimer = setInterval(fetchSpectrum, 200);
|
||||
@@ -2355,10 +2393,7 @@ function startSpectrumPolling() {
|
||||
}
|
||||
|
||||
function stopSpectrumPolling() {
|
||||
if (spectrumPollTimer !== null) {
|
||||
clearInterval(spectrumPollTimer);
|
||||
spectrumPollTimer = null;
|
||||
}
|
||||
if (spectrumPollTimer !== null) { clearInterval(spectrumPollTimer); spectrumPollTimer = null; }
|
||||
lastSpectrumData = null;
|
||||
clearSpectrumCanvas();
|
||||
}
|
||||
@@ -2366,56 +2401,51 @@ function stopSpectrumPolling() {
|
||||
async function fetchSpectrum() {
|
||||
try {
|
||||
const resp = await fetch("/spectrum", { cache: "no-store" });
|
||||
if (resp.status === 204) {
|
||||
lastSpectrumData = null;
|
||||
clearSpectrumCanvas();
|
||||
return;
|
||||
}
|
||||
if (resp.status === 204) { lastSpectrumData = null; clearSpectrumCanvas(); return; }
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
lastSpectrumData = data;
|
||||
drawSpectrum(data);
|
||||
} catch (_) {
|
||||
// ignore fetch errors (connection lost etc.)
|
||||
}
|
||||
lastSpectrumData = await resp.json();
|
||||
drawSpectrum(lastSpectrumData);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||
function clearSpectrumCanvas() {
|
||||
if (!spectrumCanvas) return;
|
||||
const ctx = spectrumCanvas.getContext("2d");
|
||||
const w = spectrumCanvas.width, h = spectrumCanvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = "#0a0f18";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillRect(0, 0, spectrumCanvas.width, spectrumCanvas.height);
|
||||
}
|
||||
|
||||
function drawSpectrum(data) {
|
||||
if (!spectrumCanvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = spectrumCanvas.clientWidth || 600;
|
||||
const cssH = spectrumCanvas.clientHeight || 120;
|
||||
|
||||
// HiDPI sizing
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cssW = spectrumCanvas.clientWidth || 640;
|
||||
const cssH = spectrumCanvas.clientHeight || 160;
|
||||
const W = Math.round(cssW * dpr);
|
||||
const H = Math.round(cssH * dpr);
|
||||
if (spectrumCanvas.width !== W || spectrumCanvas.height !== H) {
|
||||
spectrumCanvas.width = W;
|
||||
spectrumCanvas.width = W;
|
||||
spectrumCanvas.height = H;
|
||||
}
|
||||
|
||||
const ctx = spectrumCanvas.getContext("2d");
|
||||
const ctx = spectrumCanvas.getContext("2d");
|
||||
const range = spectrumVisibleRange(data);
|
||||
const bins = data.bins;
|
||||
const n = bins.length;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#0a0f18";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
const bins = data.bins;
|
||||
const n = bins.length;
|
||||
if (!n) return;
|
||||
|
||||
// dBFS range for display
|
||||
const DB_MIN = -80;
|
||||
const DB_MAX = 0;
|
||||
const dbRange = DB_MAX - DB_MIN;
|
||||
const DB_MIN = -80, DB_MAX = 0, dbRange = DB_MAX - DB_MIN;
|
||||
const fullSpanHz = data.sample_rate;
|
||||
const loHz = data.center_hz - fullSpanHz / 2;
|
||||
|
||||
// Grid lines (horizontal dBFS)
|
||||
// Horizontal dBFS grid
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let db = DB_MIN; db <= DB_MAX; db += 20) {
|
||||
@@ -2423,89 +2453,220 @@ function drawSpectrum(data) {
|
||||
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)
|
||||
function binX(i) {
|
||||
const hz = loHz + (i / (n - 1)) * fullSpanHz;
|
||||
return ((hz - range.visLoHz) / range.visSpanHz) * W;
|
||||
}
|
||||
function binY(i) {
|
||||
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
||||
return H * (1 - (db - DB_MIN) / dbRange);
|
||||
}
|
||||
|
||||
// Spectrum fill
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(binX(0), H);
|
||||
for (let i = 0; i < n; i++) ctx.lineTo(binX(i), binY(i));
|
||||
ctx.lineTo(binX(n - 1), H);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = "rgba(0,230,118,0.10)";
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Spectrum line
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = "#00e676";
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = (i / (n - 1)) * W;
|
||||
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
||||
const y = H * (1 - (db - DB_MIN) / dbRange);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
const x = binX(i), y = binY(i);
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Fill under spectrum line
|
||||
ctx.lineTo(W, H); ctx.lineTo(0, H); ctx.closePath();
|
||||
ctx.fillStyle = "rgba(0,230,118,0.08)";
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// Tuned-frequency marker
|
||||
if (lastFreqHz != null && data.center_hz && data.sample_rate) {
|
||||
const halfBw = data.sample_rate / 2;
|
||||
const loHz = data.center_hz - halfBw;
|
||||
const hiHz = data.center_hz + halfBw;
|
||||
const frac = (lastFreqHz - loHz) / (hiHz - loHz);
|
||||
if (frac >= 0 && frac <= 1) {
|
||||
const xf = Math.round(frac * W);
|
||||
if (lastFreqHz != null) {
|
||||
const xf = ((lastFreqHz - range.visLoHz) / range.visSpanHz) * W;
|
||||
if (xf >= 0 && xf <= W) {
|
||||
ctx.save();
|
||||
ctx.setLineDash([4 * dpr, 4 * dpr]);
|
||||
ctx.strokeStyle = "#ff1744";
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
ctx.lineWidth = Math.max(1, dpr);
|
||||
ctx.beginPath(); ctx.moveTo(xf, 0); ctx.lineTo(xf, H); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency axis labels
|
||||
updateSpectrumFreqAxis(data);
|
||||
updateSpectrumFreqAxis(range);
|
||||
}
|
||||
|
||||
function updateSpectrumFreqAxis(data) {
|
||||
if (!spectrumFreqAxis || !data.center_hz || !data.sample_rate) return;
|
||||
const halfBw = data.sample_rate / 2;
|
||||
const loHz = data.center_hz - halfBw;
|
||||
const hiHz = data.center_hz + halfBw;
|
||||
function updateSpectrumFreqAxis(range) {
|
||||
if (!spectrumFreqAxis) return;
|
||||
const spanHz = range.visSpanHz;
|
||||
// Pick a step that gives ~5 labels
|
||||
const targets = [100, 200, 500, 1e3, 2e3, 5e3, 10e3, 20e3, 50e3,
|
||||
100e3, 200e3, 500e3, 1e6, 2e6, 5e6, 10e6];
|
||||
const ideal = spanHz / 5;
|
||||
const stepHz = targets.reduce((best, s) =>
|
||||
Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, targets[0]);
|
||||
|
||||
// Choose label step: aim for ~5 labels
|
||||
const spanMHz = (hiHz - loHz) / 1e6;
|
||||
let stepMHz = 1;
|
||||
if (spanMHz <= 1) stepMHz = 0.1;
|
||||
else if (spanMHz <= 2) stepMHz = 0.2;
|
||||
else if (spanMHz <= 5) stepMHz = 0.5;
|
||||
else if (spanMHz <= 10) stepMHz = 1;
|
||||
else if (spanMHz <= 20) stepMHz = 2;
|
||||
else stepMHz = 5;
|
||||
|
||||
const stepHz = stepMHz * 1e6;
|
||||
const firstHz = Math.ceil(loHz / stepHz) * stepHz;
|
||||
|
||||
// Rebuild axis spans
|
||||
const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz;
|
||||
spectrumFreqAxis.innerHTML = "";
|
||||
for (let hz = firstHz; hz <= hiHz; hz += stepHz) {
|
||||
const frac = (hz - loHz) / (hiHz - loHz);
|
||||
const pct = (frac * 100).toFixed(2);
|
||||
for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) {
|
||||
const frac = (hz - range.visLoHz) / range.visSpanHz;
|
||||
if (frac < 0 || frac > 1) continue;
|
||||
const label = hz >= 1e6
|
||||
? (hz / 1e6).toFixed(stepMHz < 1 ? 1 : 0) + " MHz"
|
||||
: (hz / 1e3).toFixed(0) + " kHz";
|
||||
? (hz / 1e6).toFixed(stepHz < 1e6 ? (stepHz < 100e3 ? 3 : 1) : 0) + " M"
|
||||
: hz >= 1e3
|
||||
? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k"
|
||||
: hz.toFixed(0);
|
||||
const span = document.createElement("span");
|
||||
span.textContent = label;
|
||||
span.style.left = pct + "%";
|
||||
span.style.left = (frac * 100).toFixed(2) + "%";
|
||||
spectrumFreqAxis.appendChild(span);
|
||||
}
|
||||
}
|
||||
|
||||
// Click on spectrum canvas → tune to that frequency
|
||||
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
||||
function spectrumZoomAt(cssX, cssW, data, factor) {
|
||||
const range = spectrumVisibleRange(data);
|
||||
const hzAtCursor = canvasXToHz(cssX, cssW, range);
|
||||
const frac = cssX / cssW;
|
||||
spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor));
|
||||
// Recompute so the pixel under the cursor keeps the same frequency
|
||||
const newVisSpan = data.sample_rate / spectrumZoom;
|
||||
const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan;
|
||||
const loHz = data.center_hz - data.sample_rate / 2;
|
||||
spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate;
|
||||
}
|
||||
|
||||
// ── Scroll to zoom ────────────────────────────────────────────────────────────
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("wheel", (e) => {
|
||||
e.preventDefault();
|
||||
if (!lastSpectrumData) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const cssX = e.clientX - rect.left;
|
||||
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
||||
spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor);
|
||||
drawSpectrum(lastSpectrumData);
|
||||
}, { passive: false });
|
||||
|
||||
// Double-click → reset zoom/pan
|
||||
spectrumCanvas.addEventListener("dblclick", () => {
|
||||
spectrumZoom = 1;
|
||||
spectrumPanFrac = 0.5;
|
||||
if (lastSpectrumData) drawSpectrum(lastSpectrumData);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Mouse drag to pan ─────────────────────────────────────────────────────────
|
||||
let _sDragStart = null; // { clientX, panFrac }
|
||||
let _sDragMoved = false;
|
||||
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("mousedown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
_sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
|
||||
_sDragMoved = false;
|
||||
});
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!_sDragStart || !lastSpectrumData) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const dx = e.clientX - _sDragStart.clientX;
|
||||
if (Math.abs(dx) > 3) _sDragMoved = true;
|
||||
spectrumPanFrac = _sDragStart.panFrac - (dx / rect.width) / spectrumZoom;
|
||||
drawSpectrum(lastSpectrumData);
|
||||
});
|
||||
window.addEventListener("mouseup", () => { _sDragStart = null; });
|
||||
}
|
||||
|
||||
// ── Touch: pinch-to-zoom + single-finger pan ──────────────────────────────────
|
||||
let _sTouch = null;
|
||||
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("touchstart", (e) => {
|
||||
e.preventDefault();
|
||||
if (e.touches.length === 2) {
|
||||
const t0 = e.touches[0], t1 = e.touches[1];
|
||||
_sTouch = {
|
||||
type: "pinch",
|
||||
dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY),
|
||||
midX: (t0.clientX + t1.clientX) / 2,
|
||||
zoom: spectrumZoom,
|
||||
panFrac: spectrumPanFrac,
|
||||
};
|
||||
} else if (e.touches.length === 1) {
|
||||
_sTouch = { type: "pan", clientX: e.touches[0].clientX, panFrac: spectrumPanFrac };
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
spectrumCanvas.addEventListener("touchmove", (e) => {
|
||||
e.preventDefault();
|
||||
if (!_sTouch || !lastSpectrumData) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
if (_sTouch.type === "pinch" && e.touches.length === 2) {
|
||||
const t0 = e.touches[0], t1 = e.touches[1];
|
||||
const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||||
const newMidX = (t0.clientX + t1.clientX) / 2;
|
||||
const scale = newDist / _sTouch.dist;
|
||||
const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale));
|
||||
const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2;
|
||||
// Compute Hz under original midpoint in original view
|
||||
const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom;
|
||||
const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2;
|
||||
const midFrac = (_sTouch.midX - rect.left) / rect.width;
|
||||
const midHz = oldVisLo + midFrac * oldVisSpan;
|
||||
const newVisSpan = lastSpectrumData.sample_rate / newZoom;
|
||||
const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan;
|
||||
spectrumZoom = newZoom;
|
||||
spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate;
|
||||
// Pan contribution from mid shift
|
||||
const dxMid = newMidX - _sTouch.midX;
|
||||
spectrumPanFrac -= (dxMid / rect.width) / spectrumZoom;
|
||||
drawSpectrum(lastSpectrumData);
|
||||
} else if (_sTouch.type === "pan" && e.touches.length === 1) {
|
||||
const dx = e.touches[0].clientX - _sTouch.clientX;
|
||||
spectrumPanFrac = _sTouch.panFrac - (dx / rect.width) / spectrumZoom;
|
||||
drawSpectrum(lastSpectrumData);
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; });
|
||||
}
|
||||
|
||||
// ── Hover tooltip ─────────────────────────────────────────────────────────────
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("mousemove", (e) => {
|
||||
if (!lastSpectrumData || !spectrumTooltip) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const cssX = e.clientX - rect.left;
|
||||
const range = spectrumVisibleRange(lastSpectrumData);
|
||||
const hz = canvasXToHz(cssX, rect.width, range);
|
||||
spectrumTooltip.textContent = formatSpectrumFreq(hz);
|
||||
spectrumTooltip.style.display = "block";
|
||||
// Keep tooltip inside canvas
|
||||
const tw = spectrumTooltip.offsetWidth;
|
||||
let tx = cssX + 10;
|
||||
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";
|
||||
});
|
||||
spectrumCanvas.addEventListener("mouseleave", () => {
|
||||
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// ── Click to tune (only when not dragging) ────────────────────────────────────
|
||||
if (spectrumCanvas) {
|
||||
spectrumCanvas.addEventListener("click", (e) => {
|
||||
if (!lastSpectrumData || !lastSpectrumData.center_hz || !lastSpectrumData.sample_rate) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const frac = (e.clientX - rect.left) / rect.width;
|
||||
const halfBw = lastSpectrumData.sample_rate / 2;
|
||||
const loHz = lastSpectrumData.center_hz - halfBw;
|
||||
const hiHz = lastSpectrumData.center_hz + halfBw;
|
||||
const targetHz = Math.round(loHz + frac * (hiHz - loHz));
|
||||
setFreq(targetHz);
|
||||
if (_sDragMoved) { _sDragMoved = false; return; }
|
||||
if (!lastSpectrumData) return;
|
||||
const rect = spectrumCanvas.getBoundingClientRect();
|
||||
const range = spectrumVisibleRange(lastSpectrumData);
|
||||
const targetHz = Math.round(canvasXToHz(e.clientX - rect.left, rect.width, range));
|
||||
postPath(`/set_freq?hz=${targetHz}`).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,6 +58,14 @@
|
||||
<div id="loading-sub" style="color:#9aa4b5;"></div>
|
||||
</div>
|
||||
<div id="content" style="display:none;">
|
||||
<div id="spectrum-panel" style="display:none;">
|
||||
<div class="spectrum-wrap">
|
||||
<canvas id="spectrum-canvas"></canvas>
|
||||
<div id="spectrum-tooltip"></div>
|
||||
<div id="spectrum-freq-axis"></div>
|
||||
</div>
|
||||
<div id="spectrum-hint">Scroll to zoom · Drag to pan · Double-click to reset</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div class="full-row freq-row">
|
||||
<div class="inline freq-inline">
|
||||
@@ -158,13 +166,6 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="spectrum-panel" style="display:none;">
|
||||
<div class="label"><span>Spectrum</span></div>
|
||||
<div class="spectrum-wrap">
|
||||
<canvas id="spectrum-canvas"></canvas>
|
||||
<div id="spectrum-freq-axis"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="full-row label-below-row" id="audio-row">
|
||||
<div class="label"><span>Audio</span></div>
|
||||
|
||||
@@ -586,17 +586,22 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
|
||||
|
||||
/* ── Spectrum display ─────────────────────────────────────────────────── */
|
||||
#spectrum-panel {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.spectrum-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
#spectrum-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
height: 160px;
|
||||
background: #0a0f18;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
#spectrum-freq-axis {
|
||||
position: relative;
|
||||
@@ -604,10 +609,35 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
width: 100%;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 0 6px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
}
|
||||
#spectrum-freq-axis span {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
top: 2px;
|
||||
}
|
||||
#spectrum-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
background: rgba(10,15,24,0.85);
|
||||
color: #00e676;
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0,230,118,0.3);
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
#spectrum-hint {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
margin-top: 2px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user