diff --git a/web/app.js b/web/app.js index a40136a..7d809c9 100644 --- a/web/app.js +++ b/web/app.js @@ -1,120 +1,259 @@ -const spectrumCanvas = document.getElementById('spectrum'); -const waterfallCanvas = document.getElementById('waterfall'); -const timelineCanvas = document.getElementById('timeline'); -const statusEl = document.getElementById('status'); -const metaEl = document.getElementById('meta'); -const timelineRangeEl = document.getElementById('timelineRange'); -const drawerEl = document.getElementById('eventDrawer'); -const drawerCloseBtn = document.getElementById('drawerClose'); -const detailCenterEl = document.getElementById('detailCenter'); -const detailBwEl = document.getElementById('detailBw'); -const detailStartEl = document.getElementById('detailStart'); -const detailEndEl = document.getElementById('detailEnd'); -const detailSnrEl = document.getElementById('detailSnr'); -const detailDurEl = document.getElementById('detailDur'); -const detailSpectrogram = document.getElementById('detailSpectrogram'); -const configStatusEl = document.getElementById('configStatus'); -const centerInput = document.getElementById('centerInput'); -const spanInput = document.getElementById('spanInput'); -const sampleRateSelect = document.getElementById('sampleRateSelect'); -const fftSelect = document.getElementById('fftSelect'); -const bwSelect = document.getElementById('bwSelect'); -const gainRange = document.getElementById('gainRange'); -const gainInput = document.getElementById('gainInput'); -const thresholdRange = document.getElementById('thresholdRange'); -const thresholdInput = document.getElementById('thresholdInput'); -const agcToggle = document.getElementById('agcToggle'); -const dcToggle = document.getElementById('dcToggle'); -const iqToggle = document.getElementById('iqToggle'); -const avgSelect = document.getElementById('avgSelect'); -const maxHoldToggle = document.getElementById('maxHoldToggle'); -const maxHoldReset = document.getElementById('maxHoldReset'); -const gpuToggle = document.getElementById('gpuToggle'); +const qs = (id) => document.getElementById(id); + +const navCanvas = qs('navCanvas'); +const spectrumCanvas = qs('spectrum'); +const waterfallCanvas = qs('waterfall'); +const occupancyCanvas = qs('occupancy'); +const timelineCanvas = qs('timeline'); +const detailSpectrogram = qs('detailSpectrogram'); + +const wsBadge = qs('wsBadge'); +const metaLine = qs('metaLine'); +const heroSubtitle = qs('heroSubtitle'); +const configStatusEl = qs('configStatus'); +const timelineRangeEl = qs('timelineRange'); + +const metricCenter = qs('metricCenter'); +const metricSpan = qs('metricSpan'); +const metricRes = qs('metricRes'); +const metricSignals = qs('metricSignals'); +const metricGpu = qs('metricGpu'); +const metricSource = qs('metricSource'); + +const centerInput = qs('centerInput'); +const spanInput = qs('spanInput'); +const sampleRateSelect = qs('sampleRateSelect'); +const bwSelect = qs('bwSelect'); +const fftSelect = qs('fftSelect'); +const gainRange = qs('gainRange'); +const gainInput = qs('gainInput'); +const thresholdRange = qs('thresholdRange'); +const thresholdInput = qs('thresholdInput'); +const agcToggle = qs('agcToggle'); +const dcToggle = qs('dcToggle'); +const iqToggle = qs('iqToggle'); +const avgSelect = qs('avgSelect'); +const maxHoldToggle = qs('maxHoldToggle'); +const gpuToggle = qs('gpuToggle'); + +const signalList = qs('signalList'); +const eventList = qs('eventList'); +const signalCountBadge = qs('signalCountBadge'); +const eventCountBadge = qs('eventCountBadge'); + +const healthBuffer = qs('healthBuffer'); +const healthDropped = qs('healthDropped'); +const healthResets = qs('healthResets'); +const healthAge = qs('healthAge'); +const healthGpu = qs('healthGpu'); +const healthFps = qs('healthFps'); + +const drawerEl = qs('eventDrawer'); +const drawerCloseBtn = qs('drawerClose'); +const detailSubtitle = qs('detailSubtitle'); +const detailCenterEl = qs('detailCenter'); +const detailBwEl = qs('detailBw'); +const detailStartEl = qs('detailStart'); +const detailEndEl = qs('detailEnd'); +const detailSnrEl = qs('detailSnr'); +const detailDurEl = qs('detailDur'); +const detailClassEl = qs('detailClass'); +const jumpToEventBtn = qs('jumpToEventBtn'); +const exportEventBtn = qs('exportEventBtn'); +const liveListenEventBtn = qs('liveListenEventBtn'); + +const followBtn = qs('followBtn'); +const fitBtn = qs('fitBtn'); +const resetMaxBtn = qs('resetMaxBtn'); +const timelineFollowBtn = qs('timelineFollowBtn'); +const timelineFreezeBtn = qs('timelineFreezeBtn'); + +const modeButtons = Array.from(document.querySelectorAll('.mode-btn')); +const railTabs = Array.from(document.querySelectorAll('.rail-tab')); +const tabPanels = Array.from(document.querySelectorAll('.tab-panel')); const presetButtons = Array.from(document.querySelectorAll('.preset-btn')); +const liveListenBtn = qs('liveListenBtn'); let latest = null; -let zoom = 1.0; -let pan = 0.0; -let isDragging = false; -let dragStartX = 0; -let dragStartPan = 0; -let timelineDirty = true; -let detailDirty = false; let currentConfig = null; -let isSyncingConfig = false; -let pendingConfigUpdate = null; -let pendingSettingsUpdate = null; -let configTimer = null; -let settingsTimer = null; -const GAIN_MAX = 60; +let stats = { buffer_samples: 0, dropped: 0, resets: 0, last_sample_ago_ms: -1 }; +let gpuInfo = { available: false, active: false, error: '' }; + +let zoom = 1; +let pan = 0; +let followLive = true; +let maxHold = false; let avgAlpha = 0; let avgSpectrum = null; -let maxHold = false; let maxSpectrum = null; let lastFFTSize = null; -let stats = { buffer_samples: 0, dropped: 0, resets: 0 }; -let gpuInfo = { available: false, active: false, error: '' }; +let pendingConfigUpdate = null; +let pendingSettingsUpdate = null; +let configTimer = null; +let settingsTimer = null; +let isSyncingConfig = false; + +let isDraggingSpectrum = false; +let dragStartX = 0; +let dragStartPan = 0; +let navDrag = false; +let timelineFrozen = false; + +let renderFrames = 0; +let renderFps = 0; +let lastFpsTs = performance.now(); + +let wsReconnectTimer = null; +let eventsFetchInFlight = false; const events = []; const eventsById = new Map(); let lastEventEndMs = 0; -let eventsFetchInFlight = false; -let timelineRects = []; let selectedEventId = null; +let timelineRects = []; +let liveSignalRects = []; -function resize() { - const dpr = window.devicePixelRatio || 1; - const rect1 = spectrumCanvas.getBoundingClientRect(); - spectrumCanvas.width = rect1.width * dpr; - spectrumCanvas.height = rect1.height * dpr; - const rect2 = waterfallCanvas.getBoundingClientRect(); - waterfallCanvas.width = rect2.width * dpr; - waterfallCanvas.height = rect2.height * dpr; - const rect3 = timelineCanvas.getBoundingClientRect(); - timelineCanvas.width = rect3.width * dpr; - timelineCanvas.height = rect3.height * dpr; - const rect4 = detailSpectrogram.getBoundingClientRect(); - detailSpectrogram.width = rect4.width * dpr; - detailSpectrogram.height = rect4.height * dpr; - timelineDirty = true; - detailDirty = true; -} - -window.addEventListener('resize', resize); -resize(); +const GAIN_MAX = 60; +const timelineWindowMs = 5 * 60 * 1000; function setConfigStatus(text) { - if (configStatusEl) { - configStatusEl.textContent = text; + configStatusEl.textContent = text; +} + +function setWsBadge(text, kind = 'neutral') { + wsBadge.textContent = text; + wsBadge.style.borderColor = kind === 'ok' + ? 'rgba(124, 251, 131, 0.35)' + : kind === 'bad' + ? 'rgba(255, 107, 129, 0.35)' + : 'rgba(112, 150, 207, 0.18)'; +} + +function toMHz(hz) { return hz / 1e6; } +function fromMHz(mhz) { return mhz * 1e6; } +function fmtMHz(hz, digits = 3) { return `${(hz / 1e6).toFixed(digits)} MHz`; } +function fmtKHz(hz, digits = 2) { return `${(hz / 1e3).toFixed(digits)} kHz`; } +function fmtHz(hz) { + if (hz >= 1e6) return `${(hz / 1e6).toFixed(3)} MHz`; + if (hz >= 1e3) return `${(hz / 1e3).toFixed(2)} kHz`; + return `${hz.toFixed(0)} Hz`; +} +function fmtMs(ms) { + if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +function colorMap(v) { + const x = Math.max(0, Math.min(1, v)); + const r = Math.floor(255 * Math.pow(x, 0.55)); + const g = Math.floor(255 * Math.pow(x, 1.08)); + const b = Math.floor(220 * Math.pow(1 - x, 1.15)); + return [r, g, b]; +} + +function snrColor(snr) { + const norm = Math.max(0, Math.min(1, (snr + 5) / 35)); + const [r, g, b] = colorMap(norm); + return `rgb(${r}, ${g}, ${b})`; +} + +function binForFreq(freq, centerHz, sampleRate, n) { + return Math.floor((freq - (centerHz - sampleRate / 2)) / (sampleRate / n)); +} + +function maxInBinRange(spectrum, b0, b1) { + const n = spectrum.length; + let start = Math.max(0, Math.min(n - 1, b0)); + let end = Math.max(0, Math.min(n - 1, b1)); + if (end < start) [start, end] = [end, start]; + let max = -1e9; + for (let i = start; i <= end; i++) { + if (spectrum[i] > max) max = spectrum[i]; } + return max; } -function toMHz(hz) { - return hz / 1e6; +function processSpectrum(spectrum) { + if (!spectrum) return spectrum; + let base = spectrum; + if (avgAlpha > 0) { + if (!avgSpectrum || avgSpectrum.length !== spectrum.length) { + avgSpectrum = spectrum.slice(); + } else { + for (let i = 0; i < spectrum.length; i++) { + avgSpectrum[i] = avgAlpha * spectrum[i] + (1 - avgAlpha) * avgSpectrum[i]; + } + } + base = avgSpectrum; + } + if (maxHold) { + if (!maxSpectrum || maxSpectrum.length !== base.length) { + maxSpectrum = base.slice(); + } else { + for (let i = 0; i < base.length; i++) { + if (base[i] > maxSpectrum[i]) maxSpectrum[i] = base[i]; + } + } + base = maxSpectrum; + } + return base; } -function fromMHz(mhz) { - return mhz * 1e6; +function resetProcessingCaches() { + avgSpectrum = null; + maxSpectrum = null; +} + +function resizeCanvas(canvas) { + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const width = Math.max(1, Math.floor(rect.width * dpr)); + const height = Math.max(1, Math.floor(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } +} + +function resizeAll() { + [navCanvas, spectrumCanvas, waterfallCanvas, occupancyCanvas, timelineCanvas, detailSpectrogram].forEach(resizeCanvas); +} +window.addEventListener('resize', resizeAll); +resizeAll(); + + +function setSelectValueOrNearest(selectEl, numericValue) { + if (!selectEl) return; + const options = Array.from(selectEl.options || []); + const exact = options.find(o => Number.parseFloat(o.value) === numericValue); + if (exact) { + selectEl.value = exact.value; + return; + } + let best = options[0]; + let bestDist = Infinity; + for (const opt of options) { + const dist = Math.abs(Number.parseFloat(opt.value) - numericValue); + if (dist < bestDist) { + best = opt; + bestDist = dist; + } + } + if (best) selectEl.value = best.value; } function applyConfigToUI(cfg) { if (!cfg) return; isSyncingConfig = true; centerInput.value = toMHz(cfg.center_hz).toFixed(6); - if (sampleRateSelect) { - sampleRateSelect.value = toMHz(cfg.sample_rate).toFixed(3).replace(/\.0+$/, '').replace(/\.$/, ''); - } - const spanMHz = toMHz(cfg.sample_rate / zoom); - spanInput.value = spanMHz.toFixed(3); - fftSelect.value = String(cfg.fft_size); + setSelectValueOrNearest(sampleRateSelect, cfg.sample_rate / 1e6); + setSelectValueOrNearest(bwSelect, cfg.tuner_bw_khz || 1536); + setSelectValueOrNearest(fftSelect, cfg.fft_size); if (lastFFTSize !== cfg.fft_size) { - avgSpectrum = null; - maxSpectrum = null; + resetProcessingCaches(); lastFFTSize = cfg.fft_size; } - if (bwSelect) { - bwSelect.value = String(cfg.tuner_bw_khz || 1536); - } const uiGain = Math.max(0, Math.min(GAIN_MAX, GAIN_MAX - cfg.gain_db)); gainRange.value = uiGain; gainInput.value = uiGain; @@ -123,23 +262,21 @@ function applyConfigToUI(cfg) { agcToggle.checked = !!cfg.agc; dcToggle.checked = !!cfg.dc_block; iqToggle.checked = !!cfg.iq_balance; - if (gpuToggle) gpuToggle.checked = !!cfg.use_gpu_fft; + gpuToggle.checked = !!cfg.use_gpu_fft; + maxHoldToggle.checked = maxHold; + spanInput.value = (cfg.sample_rate / zoom / 1e6).toFixed(3); isSyncingConfig = false; } async function loadConfig() { try { const res = await fetch('/api/config'); - if (!res.ok) { - setConfigStatus('Failed to load'); - return; - } - const data = await res.json(); - currentConfig = data; + if (!res.ok) throw new Error('config'); + currentConfig = await res.json(); applyConfigToUI(currentConfig); - setConfigStatus('Synced'); - } catch (err) { - setConfigStatus('Offline'); + setConfigStatus('Config synced'); + } catch { + setConfigStatus('Config offline'); } } @@ -147,38 +284,32 @@ async function loadStats() { try { const res = await fetch('/api/stats'); if (!res.ok) return; - const data = await res.json(); - stats = data || stats; - } catch (err) { - // ignore - } + stats = await res.json(); + } catch {} } async function loadGPU() { try { const res = await fetch('/api/gpu'); if (!res.ok) return; - const data = await res.json(); - gpuInfo = data || gpuInfo; - } catch (err) { - // ignore - } + gpuInfo = await res.json(); + } catch {} } function queueConfigUpdate(partial) { if (isSyncingConfig) return; pendingConfigUpdate = { ...(pendingConfigUpdate || {}), ...partial }; - setConfigStatus('Updating...'); - if (configTimer) clearTimeout(configTimer); - configTimer = setTimeout(sendConfigUpdate, 200); + setConfigStatus('Applying…'); + clearTimeout(configTimer); + configTimer = setTimeout(sendConfigUpdate, 180); } function queueSettingsUpdate(partial) { if (isSyncingConfig) return; pendingSettingsUpdate = { ...(pendingSettingsUpdate || {}), ...partial }; - setConfigStatus('Updating...'); - if (settingsTimer) clearTimeout(settingsTimer); - settingsTimer = setTimeout(sendSettingsUpdate, 100); + setConfigStatus('Applying…'); + clearTimeout(settingsTimer); + settingsTimer = setTimeout(sendSettingsUpdate, 120); } async function sendConfigUpdate() { @@ -191,16 +322,12 @@ async function sendConfigUpdate() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!res.ok) { - setConfigStatus('Apply failed'); - return; - } - const data = await res.json(); - currentConfig = data; + if (!res.ok) throw new Error('apply'); + currentConfig = await res.json(); applyConfigToUI(currentConfig); - setConfigStatus('Applied'); - } catch (err) { - setConfigStatus('Offline'); + setConfigStatus('Config applied'); + } catch { + setConfigStatus('Config apply failed'); } } @@ -214,78 +341,99 @@ async function sendSettingsUpdate() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - if (!res.ok) { - setConfigStatus('Apply failed'); - return; - } - const data = await res.json(); - currentConfig = data; + if (!res.ok) throw new Error('apply'); + currentConfig = await res.json(); applyConfigToUI(currentConfig); - setConfigStatus('Applied'); - } catch (err) { - setConfigStatus('Offline'); + setConfigStatus('Settings applied'); + } catch { + setConfigStatus('Settings apply failed'); } } -function colorMap(v) { - // v in [0..1] - const r = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 0.6)))); - const g = Math.min(255, Math.max(0, Math.floor(255 * Math.pow(v, 1.1)))); - const b = Math.min(255, Math.max(0, Math.floor(180 * Math.pow(1 - v, 1.2)))); - return [r, g, b]; +function updateHeroMetrics() { + if (!latest) return; + const span = latest.sample_rate / zoom; + const binHz = latest.sample_rate / Math.max(1, latest.spectrum_db?.length || latest.fft_size || 1); + metricCenter.textContent = fmtMHz(latest.center_hz, 6); + metricSpan.textContent = fmtHz(span); + metricRes.textContent = `${binHz.toFixed(1)} Hz/bin`; + metricSignals.textContent = String(latest.signals?.length || 0); + metricGpu.textContent = gpuInfo.active ? 'ON' : (gpuInfo.available ? 'OFF' : 'N/A'); + metricSource.textContent = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms} ms` : 'n/a'; + + const gpuText = gpuInfo.active ? 'GPU active' : (gpuInfo.available ? 'GPU ready' : 'GPU n/a'); + metaLine.textContent = `${fmtMHz(latest.center_hz, 3)} · ${fmtHz(span)} span · ${gpuText}`; + heroSubtitle.textContent = `${latest.signals?.length || 0} live signals · ${events.length} recent events tracked`; + + healthBuffer.textContent = String(stats.buffer_samples ?? '-'); + healthDropped.textContent = String(stats.dropped ?? '-'); + healthResets.textContent = String(stats.resets ?? '-'); + healthAge.textContent = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms} ms` : 'n/a'; + healthGpu.textContent = gpuInfo.error ? `${gpuInfo.active ? 'ON' : 'OFF'} · ${gpuInfo.error}` : (gpuInfo.active ? 'ON' : (gpuInfo.available ? 'Ready' : 'N/A')); + healthFps.textContent = `${renderFps.toFixed(0)} fps`; } -function binForFreq(freq, centerHz, sampleRate, n) { - return Math.floor((freq - (centerHz - sampleRate / 2)) / (sampleRate / n)); -} +function renderBandNavigator() { + if (!latest) return; + const ctx = navCanvas.getContext('2d'); + const w = navCanvas.width; + const h = navCanvas.height; + ctx.clearRect(0, 0, w, h); -function maxInBinRange(spectrum, b0, b1) { - const n = spectrum.length; - let start = Math.max(0, Math.min(n - 1, b0)); - let end = Math.max(0, Math.min(n - 1, b1)); - if (end < start) { - const tmp = start; - start = end; - end = tmp; - } - let max = -1e9; - for (let i = start; i <= end; i++) { - const v = spectrum[i]; - if (v > max) max = v; + const display = processSpectrum(latest.spectrum_db); + const minDb = -120; + const maxDb = 0; + + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + + ctx.strokeStyle = 'rgba(102, 169, 255, 0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const idx = Math.min(display.length - 1, Math.floor((x / w) * display.length)); + const v = display[idx]; + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 10) - 5; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); } - return max; + ctx.stroke(); + + const span = latest.sample_rate / zoom; + const fullStart = latest.center_hz - latest.sample_rate / 2; + const viewStart = latest.center_hz - span / 2 + pan * span; + const viewEnd = latest.center_hz + span / 2 + pan * span; + const x1 = ((viewStart - fullStart) / latest.sample_rate) * w; + const x2 = ((viewEnd - fullStart) / latest.sample_rate) * w; + + ctx.fillStyle = 'rgba(102, 240, 209, 0.10)'; + ctx.strokeStyle = 'rgba(102, 240, 209, 0.85)'; + ctx.lineWidth = 2; + ctx.fillRect(x1, 4, Math.max(2, x2 - x1), h - 8); + ctx.strokeRect(x1, 4, Math.max(2, x2 - x1), h - 8); } -function processSpectrum(spectrum) { - if (!spectrum) return spectrum; - let base = spectrum; - if (avgAlpha > 0) { - if (!avgSpectrum || avgSpectrum.length !== spectrum.length) { - avgSpectrum = spectrum.slice(); - } else { - for (let i = 0; i < spectrum.length; i++) { - avgSpectrum[i] = avgAlpha * spectrum[i] + (1 - avgAlpha) * avgSpectrum[i]; - } - } - base = avgSpectrum; +function drawSpectrumGrid(ctx, w, h, startHz, endHz) { + ctx.strokeStyle = 'rgba(86, 109, 148, 0.18)'; + ctx.lineWidth = 1; + for (let i = 1; i < 6; i++) { + const y = (h / 6) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); } - if (maxHold) { - if (!maxSpectrum || maxSpectrum.length !== base.length) { - maxSpectrum = base.slice(); - } else { - for (let i = 0; i < base.length; i++) { - if (base[i] > maxSpectrum[i]) maxSpectrum[i] = base[i]; - } - } - base = maxSpectrum; + for (let i = 1; i < 8; i++) { + const x = (w / 8) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + const hz = startHz + (i / 8) * (endHz - startHz); + ctx.fillStyle = 'rgba(173, 192, 220, 0.72)'; + ctx.font = `${Math.max(11, Math.floor(h / 26))}px Inter, sans-serif`; + ctx.fillText((hz / 1e6).toFixed(3), x + 4, h - 8); } - return base; -} - -function snrColor(snr) { - const norm = Math.max(0, Math.min(1, (snr + 5) / 30)); - const [r, g, b] = colorMap(norm); - return `rgb(${r}, ${g}, ${b})`; } function renderSpectrum() { @@ -295,66 +443,84 @@ function renderSpectrum() { const h = spectrumCanvas.height; ctx.clearRect(0, 0, w, h); - // Grid - ctx.strokeStyle = '#13263b'; - ctx.lineWidth = 1; - for (let i = 1; i < 10; i++) { - const y = (h / 10) * i; - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(w, y); - ctx.stroke(); - } - - const { spectrum_db, sample_rate, center_hz } = latest; - const display = processSpectrum(spectrum_db); + const display = processSpectrum(latest.spectrum_db); const n = display.length; - const span = sample_rate / zoom; - const startHz = center_hz - span / 2 + pan * span; - const endHz = center_hz + span / 2 + pan * span; - if (!isSyncingConfig && spanInput) { - spanInput.value = (span / 1e6).toFixed(3); - } + const span = latest.sample_rate / zoom; + const startHz = latest.center_hz - span / 2 + pan * span; + const endHz = latest.center_hz + span / 2 + pan * span; + spanInput.value = (span / 1e6).toFixed(3); + + drawSpectrumGrid(ctx, w, h, startHz, endHz); const minDb = -120; const maxDb = 0; - ctx.strokeStyle = '#48d1b8'; + const fill = ctx.createLinearGradient(0, 0, 0, h); + fill.addColorStop(0, 'rgba(102, 240, 209, 0.20)'); + fill.addColorStop(1, 'rgba(102, 240, 209, 0.02)'); + + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const f1 = startHz + (x / w) * (endHz - startHz); + const f2 = startHz + ((x + 1) / w) * (endHz - startHz); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + ctx.strokeStyle = '#66f0d1'; ctx.lineWidth = 2; ctx.beginPath(); + liveSignalRects = []; for (let x = 0; x < w; x++) { const f1 = startHz + (x / w) * (endHz - startHz); const f2 = startHz + ((x + 1) / w) * (endHz - startHz); - const b0 = binForFreq(f1, center_hz, sample_rate, n); - const b1 = binForFreq(f2, center_hz, sample_rate, n); + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); const v = maxInBinRange(display, b0, b1); - const y = h - ((v - minDb) / (maxDb - minDb)) * h; + const y = h - ((v - minDb) / (maxDb - minDb)) * (h - 18) - 6; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); - // Signals overlay - ctx.strokeStyle = '#ffb454'; - ctx.lineWidth = 2; - if (latest.signals) { - for (const s of latest.signals) { + if (Array.isArray(latest.signals)) { + latest.signals.forEach((s, index) => { const left = s.center_hz - s.bw_hz / 2; const right = s.center_hz + s.bw_hz / 2; - if (right < startHz || left > endHz) continue; + if (right < startHz || left > endHz) return; const x1 = ((left - startHz) / (endHz - startHz)) * w; const x2 = ((right - startHz) / (endHz - startHz)) * w; - ctx.beginPath(); - ctx.moveTo(x1, h - 4); - ctx.lineTo(x2, h - 4); - ctx.stroke(); - } + const boxW = Math.max(2, x2 - x1); + const color = snrColor(s.snr_db || 0); + + ctx.fillStyle = color.replace('rgb', 'rgba').replace(')', ', 0.14)'); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.fillRect(x1, 10, boxW, h - 28); + ctx.strokeRect(x1, 10, boxW, h - 28); + ctx.fillStyle = color; + ctx.font = '12px Inter, sans-serif'; + const label = `${(s.center_hz / 1e6).toFixed(4)} MHz`; + ctx.fillText(label, Math.max(4, x1 + 4), 24 + (index % 3) * 16); + + liveSignalRects.push({ + x: x1, + y: 10, + w: boxW, + h: h - 28, + signal: s, + }); + }); } - - const binHz = sample_rate / n; - const gpuState = gpuInfo.active ? 'GPU:ON' : (gpuInfo.available ? 'GPU:OFF' : 'GPU:N/A'); - const lastAge = stats.last_sample_ago_ms >= 0 ? `${stats.last_sample_ago_ms}ms` : 'n/a'; - metaEl.textContent = `Center ${(center_hz/1e6).toFixed(3)} MHz | Span ${(span/1e6).toFixed(3)} MHz | Res ${binHz.toFixed(1)} Hz/bin | Buf ${stats.buffer_samples} Drop ${stats.dropped} Reset ${stats.resets} Last ${lastAge} | ${gpuState}`; } function renderWaterfall() { @@ -363,16 +529,14 @@ function renderWaterfall() { const w = waterfallCanvas.width; const h = waterfallCanvas.height; - // Scroll down - const image = ctx.getImageData(0, 0, w, h); - ctx.putImageData(image, 0, 1); + const prev = ctx.getImageData(0, 0, w, h - 1); + ctx.putImageData(prev, 0, 1); - const { spectrum_db, sample_rate, center_hz } = latest; - const display = processSpectrum(spectrum_db); + const display = processSpectrum(latest.spectrum_db); const n = display.length; - const span = sample_rate / zoom; - const startHz = center_hz - span / 2 + pan * span; - const endHz = center_hz + span / 2 + pan * span; + const span = latest.sample_rate / zoom; + const startHz = latest.center_hz - span / 2 + pan * span; + const endHz = latest.center_hz + span / 2 + pan * span; const minDb = -120; const maxDb = 0; @@ -380,38 +544,76 @@ function renderWaterfall() { for (let x = 0; x < w; x++) { const f1 = startHz + (x / w) * (endHz - startHz); const f2 = startHz + ((x + 1) / w) * (endHz - startHz); - const b0 = binForFreq(f1, center_hz, sample_rate, n); - const b1 = binForFreq(f2, center_hz, sample_rate, n); - if (b0 < n && b1 >= 0) { - const v = maxInBinRange(display, b0, b1); - const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); - const [r, g, b] = colorMap(norm); - row.data[x * 4 + 0] = r; - row.data[x * 4 + 1] = g; - row.data[x * 4 + 2] = b; - row.data[x * 4 + 3] = 255; - } else { - row.data[x * 4 + 3] = 255; - } + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); + const [r, g, b] = colorMap(norm); + row.data[x * 4] = r; + row.data[x * 4 + 1] = g; + row.data[x * 4 + 2] = b; + row.data[x * 4 + 3] = 255; } ctx.putImageData(row, 0, 0); } +function renderOccupancy() { + const ctx = occupancyCanvas.getContext('2d'); + const w = occupancyCanvas.width; + const h = occupancyCanvas.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); + + if (!latest || events.length === 0) return; + + const bins = new Array(Math.max(32, Math.min(160, Math.floor(w / 8)))).fill(0); + const bandStart = latest.center_hz - latest.sample_rate / 2; + const bandEnd = latest.center_hz + latest.sample_rate / 2; + const now = Date.now(); + const windowStart = now - timelineWindowMs; + + for (const ev of events) { + if (ev.end_ms < windowStart || ev.start_ms > now) continue; + const left = ev.center_hz - ev.bandwidth_hz / 2; + const right = ev.center_hz + ev.bandwidth_hz / 2; + const normL = Math.max(0, Math.min(1, (left - bandStart) / (bandEnd - bandStart))); + const normR = Math.max(0, Math.min(1, (right - bandStart) / (bandEnd - bandStart))); + let b0 = Math.floor(normL * bins.length); + let b1 = Math.floor(normR * bins.length); + if (b1 < b0) [b0, b1] = [b1, b0]; + for (let i = Math.max(0, b0); i <= Math.min(bins.length - 1, b1); i++) { + bins[i] += Math.max(0.3, (ev.snr_db || 0) / 12 + 1); + } + } + + const maxBin = Math.max(1, ...bins); + bins.forEach((v, i) => { + const norm = v / maxBin; + const [r, g, b] = colorMap(norm); + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + const x = (i / bins.length) * w; + const bw = Math.ceil(w / bins.length) + 1; + ctx.fillRect(x, 0, bw, h); + }); +} + function renderTimeline() { const ctx = timelineCanvas.getContext('2d'); const w = timelineCanvas.width; const h = timelineCanvas.height; ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); if (events.length === 0) { timelineRangeEl.textContent = 'No events yet'; return; } - const now = Date.now(); - const windowMs = 5 * 60 * 1000; - const endMs = now; - const startMs = endMs - windowMs; + const endMs = Date.now(); + const startMs = endMs - timelineWindowMs; + timelineRangeEl.textContent = `${new Date(startMs).toLocaleTimeString()} - ${new Date(endMs).toLocaleTimeString()}`; let minHz = Infinity; let maxHz = -Infinity; @@ -429,7 +631,7 @@ function renderTimeline() { maxHz = 1; } - ctx.strokeStyle = '#13263b'; + ctx.strokeStyle = 'rgba(86, 109, 148, 0.18)'; ctx.lineWidth = 1; for (let i = 1; i < 6; i++) { const y = (h / 6) * i; @@ -438,54 +640,57 @@ function renderTimeline() { ctx.lineTo(w, y); ctx.stroke(); } + for (let i = 1; i < 8; i++) { + const x = (w / 8) * i; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } timelineRects = []; for (const ev of events) { if (ev.end_ms < startMs || ev.start_ms > endMs) continue; + const x1 = ((Math.max(ev.start_ms, startMs) - startMs) / (endMs - startMs)) * w; const x2 = ((Math.min(ev.end_ms, endMs) - startMs) / (endMs - startMs)) * w; - const bw = Math.max(ev.bandwidth_hz, 1); - const topHz = ev.center_hz + bw / 2; - const bottomHz = ev.center_hz - bw / 2; + const topHz = ev.center_hz + ev.bandwidth_hz / 2; + const bottomHz = ev.center_hz - ev.bandwidth_hz / 2; const y1 = ((maxHz - topHz) / (maxHz - minHz)) * h; const y2 = ((maxHz - bottomHz) / (maxHz - minHz)) * h; - const rectH = Math.max(2, y2 - y1); - - ctx.fillStyle = snrColor(ev.snr_db || 0); - ctx.fillRect(x1, y1, Math.max(2, x2 - x1), rectH); - const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: rectH, id: ev.id }; + const rect = { x: x1, y: y1, w: Math.max(2, x2 - x1), h: Math.max(3, y2 - y1), id: ev.id }; timelineRects.push(rect); + + ctx.fillStyle = snrColor(ev.snr_db || 0).replace('rgb', 'rgba').replace(')', ', 0.85)'); + ctx.fillRect(rect.x, rect.y, rect.w, rect.h); } if (selectedEventId) { - const hit = timelineRects.find((r) => r.id === selectedEventId); + const hit = timelineRects.find(r => r.id === selectedEventId); if (hit) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.strokeRect(hit.x - 1, hit.y - 1, hit.w + 2, hit.h + 2); } } - - const startLabel = new Date(startMs).toLocaleTimeString(); - const endLabel = new Date(endMs).toLocaleTimeString(); - timelineRangeEl.textContent = `${startLabel} - ${endLabel}`; } -function renderDetailSpectrogram(ev) { +function renderDetailSpectrogram() { + const ev = eventsById.get(selectedEventId); const ctx = detailSpectrogram.getContext('2d'); const w = detailSpectrogram.width; const h = detailSpectrogram.height; ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#071018'; + ctx.fillRect(0, 0, w, h); if (!latest || !ev) return; - const span = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 3, latest.sample_rate / 8)); - const startHz = ev.center_hz - span / 2; - const endHz = ev.center_hz + span / 2; - - const { spectrum_db, sample_rate, center_hz } = latest; - const display = processSpectrum(spectrum_db); + const display = processSpectrum(latest.spectrum_db); const n = display.length; + const localSpan = Math.min(latest.sample_rate, Math.max(ev.bandwidth_hz * 4, latest.sample_rate / 10)); + const startHz = ev.center_hz - localSpan / 2; + const endHz = ev.center_hz + localSpan / 2; const minDb = -120; const maxDb = 0; @@ -493,164 +698,315 @@ function renderDetailSpectrogram(ev) { for (let x = 0; x < w; x++) { const f1 = startHz + (x / w) * (endHz - startHz); const f2 = startHz + ((x + 1) / w) * (endHz - startHz); - const b0 = binForFreq(f1, center_hz, sample_rate, n); - const b1 = binForFreq(f2, center_hz, sample_rate, n); - if (b0 < n && b1 >= 0) { - const v = maxInBinRange(display, b0, b1); - const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); - const [r, g, b] = colorMap(norm); - row.data[x * 4 + 0] = r; - row.data[x * 4 + 1] = g; - row.data[x * 4 + 2] = b; - row.data[x * 4 + 3] = 255; - } else { - row.data[x * 4 + 3] = 255; - } + const b0 = binForFreq(f1, latest.center_hz, latest.sample_rate, n); + const b1 = binForFreq(f2, latest.center_hz, latest.sample_rate, n); + const v = maxInBinRange(display, b0, b1); + const norm = Math.max(0, Math.min(1, (v - minDb) / (maxDb - minDb))); + const [r, g, b] = colorMap(norm); + row.data[x * 4] = r; + row.data[x * 4 + 1] = g; + row.data[x * 4 + 2] = b; + row.data[x * 4 + 3] = 255; + } + + for (let y = 0; y < h; y++) ctx.putImageData(row, 0, y); + + const centerX = w / 2; + ctx.strokeStyle = 'rgba(255,255,255,0.65)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(centerX, 0); + ctx.lineTo(centerX, h); + ctx.stroke(); +} + +function renderLists() { + const signals = Array.isArray(latest?.signals) ? [...latest.signals] : []; + signals.sort((a, b) => (b.snr_db || 0) - (a.snr_db || 0)); + signalCountBadge.textContent = `${signals.length} live`; + metricSignals.textContent = String(signals.length); + + if (signals.length === 0) { + signalList.innerHTML = '