/** * waterfall.js — waterfall/spectrum panel (Icom IC-7300 / IC-7610 style) * - Deep navy noise floor, high contrast signals (cyan→green→yellow→red/white) * - Horizontal dBm grid with markings (-10dB, -20dB, ...) * - Mode-aware filter overlay (USB / LSB / CW passband shading) + red carrier VFO line * - Persistent DX spot flags pinned above spectrum (callsign + mode + needle) — * sourced ONLY from the real DX Cluster (see getVisibleSpots below); no * fake/simulated callsigns are ever shown as clickable flags, since * clicking one really retunes the radio (see tuneToClick). * - Simulated spectrum shape (noise + generic CW/SSB-looking bumps) only * when there's no real scope data at all (radio not connected yet) - * decorative only, never labeled with a callsign or clickable. * - Icom-style touch softkeys: CENT/FIX, SPAN, PEAK, SPOTS, SPEED, REF LEVEL */ (function () { 'use strict'; const S = window.AppState; let specCanvas = null, wfCanvas = null, axisCanvas = null; let specCtx = null, wfCtx = null, axisCtx = null; let source = 'sim'; let audioAnalyser = null; let animFrame = null; let lastScopeData = null; let lastMeta = { centerHz: 14205000, spanHz: 25000, loHz: 14192500, hiHz: 14217500, mode: 'USB', dataMode: false, filterHz: 2400, scopeMode: 0, outOfRange: false, }; let _refLevelDb = -15; // Icom REF LEVEL (-30 to 0 dB) — cosmetic only, see cycleRefLevel() let _speedMultiplier = 1; let _shiftAccum = 0; // fractional waterfall-scroll accumulator, see drawFrame() let _spotsEnabled = true; let _peakEnabled = true; let _peakHold = null; let _peakDecay = 0.985; let _simTick = 0; let collapsed = false; // ── Simulated spectrum shape (decorative only — see file header) ───────────── // Used ONLY to shape the fake noise+signal curve shown before any real scope // data has arrived. These are NOT shown as DX spot flags and are NOT // clickable - getVisibleSpots() (below) returns ONLY real DX Cluster spots. const _simStations = [ { mode: 'CW', offsetKhz: -11, type: 'cw', morsePattern: [1,0,1,0,3,0,1,0, 0,0, 3,0,1,0,3,0, 0,0,0, 1,0,1,0,1], morseStep: 0, rfActive: true }, { mode: 'CW', offsetKhz: -4.5, type: 'cw', morsePattern: [3,0,3,0,1,0,3,0, 0,0, 3,0,1,0,3], morseStep: 0, rfActive: true }, { mode: 'SSB', offsetKhz: 1.2, type: 'ssb', speechTimer: 0, currentLevel: 0.8, rfActive: true }, { mode: 'SSB', offsetKhz: 19, type: 'ssb', speechTimer: 10, currentLevel: 0.6, rfActive: true } ]; let _simAnchorFreq = 0; function ensureSimAnchors(center) { if (!center) center = 14205000; // If center moved by more than 100 kHz (band change), re-anchor simulation stations if (!_simAnchorFreq || Math.abs(center - _simAnchorFreq) > 100000) { _simAnchorFreq = center; for (let st of _simStations) { st.fixedFreqHz = Math.round(center + st.offsetKhz * 1000); } } } // ── IC-7300 palette: deep dark navy → blue → cyan → yellow → red → white ──── const PALETTE = (() => { const p = new Uint32Array(256); for (let i = 0; i < 256; i++) { let r = 0, g = 0, b = 0; if (i < 30) { // Dark navy noise floor b = Math.round(i * 2.5); } else if (i < 75) { // Navy → cobalt blue r = 0; g = Math.round((i - 30) * 1.8); b = 100 + Math.round((i - 30) * 3); } else if (i < 130) { // Blue → electric cyan r = 0; g = 180 + Math.round((i - 75) * 1.3); b = 255; } else if (i < 185) { // Cyan → emerald green → yellow r = Math.round((i - 130) * 4.6); g = 255; b = Math.max(0, 255 - Math.round((i - 130) * 4.6)); } else if (i < 235) { // Yellow → orange → bright red r = 255; g = Math.max(0, 255 - Math.round((i - 185) * 5.1)); b = 0; } else { // Peak saturation: red → pure white r = 255; g = Math.round((i - 235) * 12); b = Math.round((i - 235) * 12); } p[i] = (255 << 24) | (b << 16) | (g << 8) | r; // ABGR } return p; })(); // ── Init ────────────────────────────────────────────────────────────────────── function init() { specCanvas = document.getElementById('wf-spectrum'); wfCanvas = document.getElementById('wf-waterfall'); axisCanvas = document.getElementById('wf-axis'); if (!specCanvas || !wfCanvas) return; if (!axisCanvas) { axisCanvas = document.createElement('canvas'); axisCanvas.id = 'wf-axis'; wfCanvas.parentElement?.insertBefore(axisCanvas, wfCanvas); } resize(); specCtx = specCanvas.getContext('2d'); wfCtx = wfCanvas.getContext('2d'); axisCtx = axisCanvas.getContext('2d'); attachClickToTune(); window.addEventListener('resize', resize); // ResizeObserver on .tile-wf guarantees instantaneous responsive sizing // on initial page load and whenever layout changes (e.g. fullscreen) const roTarget = document.querySelector('.tile-wf'); if (roTarget && window.ResizeObserver) { const ro = new ResizeObserver(() => { resize(); }); ro.observe(roTarget); } // FIX (2026-10-20, live report: "button span jego nazwa wyswietla sie // dopiero jak go wybierzesz... ponowne logowanie i znow button jest bez // nazwy") - restoring the persisted span used to happen ONLY in a // separate 'app:ready' listener below, which can fire BEFORE this init() // (and therefore before #wf-btn-span even exists in the DOM, since // init() itself is what's gated on the canvases existing) - that // listener's own _updateSoftkeys() call (inside setSpan()) then found // the button not-yet-rendered and silently skipped it, leaving the // - scopeMode: 0=Center, 1=Fixed, 2/3=SCROLL (nie sterowane // z tego menu - traktujemy jak Fixed przy wyswietlaniu). btnCent.value = lastMeta.scopeMode !== 0 ? '1' : '0'; } const btnSpan = document.getElementById('wf-btn-span'); if (btnSpan) { //