/** * wsjtx_scope.js — waterfall for the WSJT-X tab / our own FT8 decoder. * WebGL version: spectral data held as a GPU texture (LUMINANCE, nBins x * MAX_ROWS), updated for every new column via texSubImage2D (without * re-uploading the whole texture). The color palette is a separate 1D * lookup texture, and value->color mapping happens in the fragment * shader. Hardware bilinear interpolation (gl.LINEAR) gives smooth * transitions at no extra CPU cost — unlike the previous version (canvas * 2D + offscreen + drawImage), all the scaling/interpolation work happens on the GPU. * * Separate from js/waterfall.js (which handles the whole radio's wide CI-V scope). * * Functions (API identical to the canvas 2D version, so wsjtx.js/index.html * don't need changes): * init, onWaterfallData, onTxFreqUpdate, onSplitStatus, setRxFreq, * toggleTxFreeze */ (function () { 'use strict'; // Decoded callsigns are already regex-validated by wsjtx.js's // _extractCall before they ever reach this file, but escaping here too // costs nothing and matches this project's general XSS-defensive // convention for anything interpolated into innerHTML. function _esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } let canvas = null, gl = null; let fMin = 100, fMax = 3000, nBins = 200; const MAX_ROWS = 300; // Data texture: LUMINANCE, width=nBins, height=MAX_ROWS. Every new row // overwrites the next line in the CPU-side buffer, and writeRow points at // which texture line (cyclically) to write the next column into — this // way we DON'T need to shift the whole history in memory or re-upload // the whole texture on every new column. let dataTex = null; let dataBuf = null; // Uint8Array(nBins * MAX_ROWS), CPU-side mirror let writeRow = 0; // which row (0..MAX_ROWS-1) to write next // Position (writeRow) where the LATEST decode-period boundary started // (xx:00/15/30/45 for FT8, every 7.5s for FT4) — used to draw a thin // separator line on the waterfall, at the same place as the line in Band // Activity. null until the first boundary is detected after start. let periodBoundaryRow = null; let _lastWindowSlot = null; // to detect a TRANSITION between windows (not just state) let _scopeDecodeMode = 'FT8'; // synced with WSJTX._decodeMode via setDecodeMode below let rowsFilled = 0; // how many rows already have real data (grows up to MAX_ROWS) let paletteTex = null; let txFreqHz = 1000; // default initial value, overwritten by the real // state from the backend; NOT 1500Hz — see the // comment in webapp.py about the IC-7300 USB-D notch let txFrozen = false; let rxFreqHz = 1000; // RX marker, INDEPENDENT of TX — starts at the same // value, but can be moved separately (dragging or a // manually typed value) // Drag state: which marker is currently grabbed ('tx'|'rx'|null). // Detected on mousedown based on the distance to each marker — if the // click is close to an existing line, we drag ONLY that marker; // otherwise we treat it as a new spot and move BOTH at once. let _dragging = null; const DRAG_HIT_PX = 8; // radius (in logical CSS px, not device px) for detecting a click on a marker let _needsRender = false; let _rafStarted = false; // Palette Adjust (REF/ZERO/GAIN) — see the comment at uPaletteRef in the // fragment shader. Default values = a no-op relative to the base palette. let paletteRef = 0.15; let paletteZero = 0.0; let paletteGain = 1.0; function setPaletteReference(v) { paletteRef = v; _requestRender(); } function setPaletteZero(v) { paletteZero = v; _requestRender(); } function setPaletteGain(v) { paletteGain = v; _requestRender(); } // Waterfall palette: dark navy (silence/noise) -> blue -> cyan -> green -> // yellow -> white (strong signals). Most of the range (background noise) // stays dark and muted, only strong signals get bright. Typical // background/noise is SATURATED blue [1,12,144], not near-black like in // the previous version. Curve: black only for absolute silence, quickly // transitions to saturated blue (typical noise), then cyan/green/ // yellow/white for progressively stronger signals. const PALETTE_STOPS = [ [0, [0, 0, 10]], [15, [0, 5, 60]], [40, [1, 12, 144]], // "typowy szum" [90, [0, 60, 190]], [140, [0, 150, 210]], [180, [90, 215, 150]], [210, [235, 225, 65]], [235, [255, 165, 40]], [255, [255, 255, 255]], ]; function _buildPaletteRGBA() { const out = new Uint8Array(256 * 4); for (let i = 0; i < 256; i++) { let lo = PALETTE_STOPS[0], hi = PALETTE_STOPS[PALETTE_STOPS.length - 1]; for (let s = 0; s < PALETTE_STOPS.length - 1; s++) { if (i >= PALETTE_STOPS[s][0] && i <= PALETTE_STOPS[s + 1][0]) { lo = PALETTE_STOPS[s]; hi = PALETTE_STOPS[s + 1]; break; } } const span = hi[0] - lo[0]; const t = span > 0 ? (i - lo[0]) / span : 0; out[i * 4 + 0] = Math.round(lo[1][0] + t * (hi[1][0] - lo[1][0])); out[i * 4 + 1] = Math.round(lo[1][1] + t * (hi[1][1] - lo[1][1])); out[i * 4 + 2] = Math.round(lo[1][2] + t * (hi[1][2] - lo[1][2])); out[i * 4 + 3] = 255; } return out; } // ── Shadery ────────────────────────────────────────────────────────────────── const VERT_SRC = ` attribute vec2 aPos; varying vec2 vUv; void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`; const FRAG_SRC = ` precision mediump float; varying vec2 vUv; uniform sampler2D uData; uniform sampler2D uPalette; uniform float uWriteRow; uniform float uRowsFilled; uniform float uMaxRows; uniform float uTxX; uniform float uRxX; uniform float uHasRx; uniform float uTxFrozen; uniform float uAspectPx; uniform float uAspectPxY; uniform float uPeriodBoundaryRow; uniform float uHasPeriodBoundary; uniform float uPaletteRef; uniform float uPaletteZero; uniform float uPaletteGain; void main() { float rowsFilled = min(uRowsFilled, uMaxRows); if (rowsFilled < 1.0) { gl_FragColor = vec4(0.0,0.0,0.0,1.0); return; } // CRITICAL: the screen-pixel -> data-row mapping scale must be FIXED // (uMaxRows), NOT change along with rowsFilled. An earlier version used // rowsVisible=min(rowsFilled,uMaxRows) as the multiplier — that meant // that until the buffer filled up (the first ~90s of running), EVERY // screen pixel got remapped to a different data row on every new column // (because the multiplier grew every frame), producing visible // "jumping"/"ghosting" — already-drawn signals shifted visually even // though the underlying data hadn't changed. Now: a fixed scale // (uMaxRows rows always corresponds to the full screen height), and // rows not yet filled simply stay black until real data arrives there. float rowFromTopF = (1.0 - vUv.y) * uMaxRows; rowFromTopF = min(rowFromTopF, uMaxRows - 1.0); if (rowFromTopF >= rowsFilled) { gl_FragColor = vec4(0.0,0.0,0.0,1.0); return; } // MANUAL sampling in LOGICAL space (not directly in texture space), to // avoid automatic GPU interpolation at the wrap boundary of the // circular buffer (where texRow=writeRow-1 (newest) and // texRow=writeRow (oldest) are physically adjacent in the texture, even // though logically they're opposite ends of the history — interpolating // between them produced a visible "seam"/artifact once per full buffer cycle). float rowFromTop0 = floor(rowFromTopF); float rowFromTop1 = min(rowFromTop0 + 1.0, rowsFilled - 1.0); float frac = rowFromTopF - rowFromTop0; float texRow0 = mod(uWriteRow - 1.0 - rowFromTop0 + uMaxRows * 4.0, uMaxRows); float texRow1 = mod(uWriteRow - 1.0 - rowFromTop1 + uMaxRows * 4.0, uMaxRows); float v0 = texture2D(uData, vec2(vUv.x, (texRow0 + 0.5) / uMaxRows)).r; float v1 = texture2D(uData, vec2(vUv.x, (texRow1 + 0.5) / uMaxRows)).r; float v = mix(v0, v1, frac); // Palette Adjust (REF/ZERO/GAIN). At the default values (ref=0.15, // zero=0, gain=1.0) this is a no-op — v passes through unchanged, so // the baseline looks exactly like before these sliders were added. // REF: shifts brightness relative to the palette's built-in reference // point (0.15, where PALETTE_STOPS has "typical noise"). ZERO: clips // the bottom (crushes weak signals to black). GAIN: contrast relative // to the middle of the range. float vAdj = v + (uPaletteRef - 0.15); vAdj = max(0.0, vAdj - uPaletteZero); vAdj = (vAdj - 0.5) * uPaletteGain + 0.5; vAdj = clamp(vAdj, 0.0, 1.0); vec3 color = texture2D(uPalette, vec2(vAdj, 0.5)).rgb; // A thin horizontal line at the decode-period boundary (xx:00/15/30/45 // for FT8, every 7.5s for FT4) — indicates that a NEW window starts // below this line. Synchronized with the equivalent line in Band // Activity (the same boundary-detection logic, see _windowSlot in // wsjtx.js and the code in onWaterfallData above). The position is // computed with the SAME transform (mod with buffer wraparound) as the // texture data read, so the line scrolls perfectly together with the // data, without "floating" relative to it. if (uHasPeriodBoundary > 0.5) { float boundaryRowFromTop = mod(uWriteRow - 1.0 - uPeriodBoundaryRow + uMaxRows * 4.0, uMaxRows); if (boundaryRowFromTop < rowsFilled) { float rowPx = boundaryRowFromTop / uMaxRows; float curPx = 1.0 - vUv.y; float lineHalfHeight = 0.75 / uAspectPxY; if (abs(curPx - rowPx) < lineHalfHeight) { color = mix(color, vec3(1.0, 1.0, 1.0), 0.55); } } } float lineHalfWidth = 1.0 / uAspectPx; // TX line: red when free, orange when frozen in place if (abs(vUv.x - uTxX) < lineHalfWidth * 1.5) { color = uTxFrozen > 0.5 ? vec3(1.0, 0.55, 0.0) : vec3(1.0, 0.27, 0.27); } // RX line: green (only when different from TX) if (uHasRx > 0.5 && abs(vUv.x - uRxX) < lineHalfWidth * 1.5) { color = vec3(0.27, 0.85, 0.33); } gl_FragColor = vec4(color, 1.0); }`; function _compileShader(type, src) { const sh = gl.createShader(type); gl.shaderSource(sh, src); gl.compileShader(sh); if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { console.error('[wsjtx_scope] shader compile error:', gl.getShaderInfoLog(sh)); gl.deleteShader(sh); return null; } return sh; } let prog = null, uLoc = {}; function _initGL() { gl = canvas.getContext('webgl', { antialias: true, preserveDrawingBuffer: false }) || canvas.getContext('experimental-webgl'); if (!gl) { console.warn('[wsjtx_scope] WebGL unavailable, scope will not work'); return false; } const vs = _compileShader(gl.VERTEX_SHADER, VERT_SRC); const fs = _compileShader(gl.FRAGMENT_SHADER, FRAG_SRC); if (!vs || !fs) return false; prog = gl.createProgram(); gl.attachShader(prog, vs); gl.attachShader(prog, fs); gl.linkProgram(prog); if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { console.error('[wsjtx_scope] program link error:', gl.getProgramInfoLog(prog)); return false; } gl.useProgram(prog); const quad = new Float32Array([-1,-1, 1,-1, -1,1, -1,1, 1,-1, 1,1]); const quadBuf = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); gl.bufferData(gl.ARRAY_BUFFER, quad, gl.STATIC_DRAW); const aPos = gl.getAttribLocation(prog, 'aPos'); gl.enableVertexAttribArray(aPos); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); uLoc = { uData: gl.getUniformLocation(prog, 'uData'), uPalette: gl.getUniformLocation(prog, 'uPalette'), uWriteRow: gl.getUniformLocation(prog, 'uWriteRow'), uRowsFilled: gl.getUniformLocation(prog, 'uRowsFilled'), uMaxRows: gl.getUniformLocation(prog, 'uMaxRows'), uTxX: gl.getUniformLocation(prog, 'uTxX'), uRxX: gl.getUniformLocation(prog, 'uRxX'), uHasRx: gl.getUniformLocation(prog, 'uHasRx'), uSplitX: gl.getUniformLocation(prog, 'uSplitX'), uSplitOn: gl.getUniformLocation(prog, 'uSplitOn'), uTxFrozen: gl.getUniformLocation(prog, 'uTxFrozen'), uAspectPx: gl.getUniformLocation(prog, 'uAspectPx'), uAspectPxY: gl.getUniformLocation(prog, 'uAspectPxY'), uPeriodBoundaryRow: gl.getUniformLocation(prog, 'uPeriodBoundaryRow'), uHasPeriodBoundary: gl.getUniformLocation(prog, 'uHasPeriodBoundary'), uPaletteRef: gl.getUniformLocation(prog, 'uPaletteRef'), uPaletteZero: gl.getUniformLocation(prog, 'uPaletteZero'), uPaletteGain: gl.getUniformLocation(prog, 'uPaletteGain'), }; dataBuf = new Uint8Array(nBins * MAX_ROWS); dataTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, dataTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, nBins, MAX_ROWS, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, dataBuf); paletteTex = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, paletteTex); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 256, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, _buildPaletteRGBA()); return true; } function _reallocDataTextureIfNeeded() { if (dataBuf && dataBuf.length === nBins * MAX_ROWS) return; dataBuf = new Uint8Array(nBins * MAX_ROWS); writeRow = 0; rowsFilled = 0; periodBoundaryRow = null; _lastWindowSlot = null; if (gl && dataTex) { gl.bindTexture(gl.TEXTURE_2D, dataTex); gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, nBins, MAX_ROWS, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, dataBuf); } } let _listenersAttached = false; function init() { canvas = document.getElementById('wj-scope-canvas'); if (!canvas) { console.warn('[scope] no canvas'); return; } // If GL is already initialized — just resize and render if (gl) { _resizeCanvas(); _renderAxis(); _renderCqFlags(); _requestRender(); return; } // init() can now be called more than once (mobile re-triggers it when // the FT8 tab becomes visible, since the first attempt at boot happens // while the tab is still display:none — see mobile.js::switchTab) — only // attach the listeners once, or a retry would register them twice. if (_listenersAttached) { _waitAndInit(30); return; } _listenersAttached = true; canvas.addEventListener('mousedown', _onMouseDown); window.addEventListener('mousemove', _onMouseMove); window.addEventListener('mouseup', _onMouseUp); document.getElementById('wj-scope-cq-flags')?.addEventListener('click', _onCqFlagClick); // Touch support (added for the mobile mini-waterfall — this file // previously only handled mouse events, which desktop browsers never // needed touch equivalents for, but a phone has no mouse at all: no // touch listeners meant the waterfall rendered but tapping/dragging the // TX/RX markers (or picking a Hound slot) silently did nothing). // {passive:false} is required because touchmove calls preventDefault() // to stop the page from scrolling while dragging a marker. canvas.addEventListener('touchstart', _onTouchStart, { passive: false }); window.addEventListener('touchmove', _onTouchMove, { passive: false }); window.addEventListener('touchend', _onTouchEnd); window.addEventListener('touchcancel', _onTouchEnd); if (window.ResizeObserver) { const ro = new ResizeObserver(() => { _resizeCanvas(); _renderAxis(); _renderCqFlags(); _requestRender(); }); ro.observe(canvas); } else { window.addEventListener('resize', () => { _resizeCanvas(); _renderAxis(); _renderCqFlags(); _requestRender(); }); } // Wait until the canvas has dimensions before initializing WebGL function _waitAndInit(tries) { const rect = canvas.getBoundingClientRect(); console.log('[scope] init attempt', tries, 'rect:', rect.width, 'x', rect.height); if (rect.width < 1 || rect.height < 1) { if (tries > 0) setTimeout(() => _waitAndInit(tries - 1), 100); else console.warn('[scope] canvas has zero dimensions after 30 attempts'); return; } _resizeCanvas(); if (!_initGL()) { console.warn('[scope] _initGL returned false'); return; } _renderAxis(); _renderCqFlags(); _requestRender(); _updateLabels(); _startRenderLoop(); console.log('[scope] initialized, canvas:', canvas.width, 'x', canvas.height); } _waitAndInit(30); } // Frequency scale above the waterfall (e.g. "500", "1000", "1500"...), in // the style of the reference JTDX. Rendered as lightweight HTML elements // positioned as a percentage of the container's width — simpler and // crisper (native browser font) than drawing text in WebGL/Canvas. // ── CQ-caller flags (clickable, like waterfall.js's DX-spot flags for // CW/SSB) — this tab's own equivalent uses our decoded callers instead of // the DX Cluster, since that's what's actually available/meaningful on // FT8: a DX-cluster spot can't tell you someone is STILL calling CQ right // now, but a fresh decode can. Positioned as plain HTML (same approach as // _renderAxis above), clicking one runs the exact same selection logic as // clicking that station's row in Band Activity (WSJTX._selectRow) - sets // RX/TX markers, prefills the log form, country flag, etc. ───────────── function _renderCqFlags() { const el = document.getElementById('wj-scope-cq-flags'); if (!el || !canvas) return; const rect = canvas.getBoundingClientRect(); if (rect.width < 1) { el.innerHTML = ''; return; } const decodes = window.WSJTX?.getCqDecodes?.() || []; let html = ''; for (const d of decodes) { if (d.idx < 0) continue; // decode aged out of _decodes (MAX_DECODES cap) between read and render const pct = _freqToFrac(d.deltaFreq) * 100; if (pct < -5 || pct > 105) continue; // well outside the visible band - skip const snr = d.snr >= 0 ? '+' + d.snr : String(d.snr); html += `