diff --git a/aidocs/WEBGL.md b/aidocs/WEBGL.md new file mode 100644 index 0000000..760d738 --- /dev/null +++ b/aidocs/WEBGL.md @@ -0,0 +1,79 @@ +# Canvas2D to WebGL Transition Plan + +## Goal +- Replace all runtime Canvas2D rendering in the frontend with WebGL. +- Remove Canvas2D code paths after feature parity is reached. +- Keep existing interaction behavior (zoom/pan/tune/BW drag/tooltips/overlays) intact. + +## Scope +- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js` + - `overview-canvas` + - `spectrum-canvas` + - `signal-overlay-canvas` +- `src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js` + - `cw-tone-waterfall` +- New shared WebGL utility module: + - `assets/web/webgl-renderer.js` + +## Non-Goals +- No Canvas2D fallback path. +- No feature redesign outside rendering internals. + +## Constraints +- Must preserve existing data flow and event wiring. +- Must keep map/decoder/bookmark integrations unchanged. +- Must remain dependency-free (no external rendering libraries). + +## 2-Phase Migration +1. Phase 1 (Rendering engine insertion) +- Add shared WebGL renderer utility (primitives + textures + color parsing). +- Keep existing business logic and interaction handlers untouched. +- Swap draw targets from 2D contexts to WebGL primitives. + +2. Phase 2 (Canvas2D removal and parity closure) +- Remove `getContext("2d")` usage from app and plugins. +- Remove obsolete 2D-specific cache paths. +- Validate behavior on resize/theme/style/stream reconnect/decoder mode changes. + +## Parallel Workstreams ("Agents") +1. Agent A: Shared WebGL core +- Build `webgl-renderer.js` with: + - HiDPI resize handling + - Solid/gradient rects + - Polyline/segment/fill primitives + - RGBA texture upload + blit + - CSS color parser helpers + +2. Agent B: Main spectrum/overview migration +- Port `drawSpectrum`, `drawHeaderSignalGraph`, `drawSignalOverlay`, and clear paths. +- Replace 2D offscreen waterfall cache with WebGL texture updates. +- Keep frequency axis/bookmark axis DOM behavior unchanged. + +3. Agent C: CW tone picker migration +- Port `drawCwTonePicker` primitives to WebGL. +- Preserve auto/manual tone interactions and mode gating. + +## Acceptance Criteria +- No frontend `getContext("2d")` usage remains. +- All four canvases render using WebGL and respond to resize/DPR changes. +- Spectrum interactions still work: + - wheel zoom + - drag pan + - BW edge drag + - click tune +- Overview strip continues showing waterfall/history. +- CW tone picker remains interactive and reflects current spectrum/tone. + +## Verification Checklist +- Static: + - `rg -n 'getContext\\("2d"\\)' src/trx-client/trx-frontend/trx-frontend-http/assets/web` +- Runtime smoke: + - Open main tab: verify overview + spectrum + overlay. + - Toggle theme/style. + - Resize window and spectrum grip. + - Enable CW decoder and validate tone picker updates/click-to-set. + - Confirm no rendering exceptions in browser console. + +## Rollout Notes +- Initial rollout is WebGL-only. +- If a browser lacks WebGL, canvases remain blank by design until a dedicated fallback policy is defined. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index 1b0ce36..f24d1b8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -329,6 +329,12 @@ const loadingTitle = document.getElementById("loading-title"); const loadingSub = document.getElementById("loading-sub"); const overviewCanvas = document.getElementById("overview-canvas"); const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); +const overviewGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(overviewCanvas, { alpha: true }) + : null; +const signalOverlayGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(signalOverlayCanvas, { alpha: true }) + : null; const signalVisualBlockEl = document.querySelector(".signal-visual-block"); const signalSplitControlEl = document.getElementById("signal-split-control"); const signalSplitSliderEl = document.getElementById("signal-split-slider"); @@ -759,14 +765,19 @@ let overviewWaterfallRows = []; let overviewWaterfallPushCount = 0; // monotonically increments on every push const HEADER_SIG_WINDOW_MS = 10_000; -// Offscreen waterfall cache — reused across frames to avoid full redraws -let _wfOC = null; // OffscreenCanvas -let _wfOCPalKey = ""; // palette signature when offscreen was last built -let _wfOCPushCount = 0; // overviewWaterfallPushCount when offscreen was last updated +function cssColorToRgba(color, alphaMul = 1) { + const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; + const parsed = parser ? parser(color) : [0, 0, 0, 1]; + return [ + parsed[0], + parsed[1], + parsed[2], + Math.max(0, Math.min(1, parsed[3] * alphaMul)), + ]; +} -function _wfResetOffscreen() { _wfOC = null; _wfOCPushCount = 0; _wfOCPalKey = ""; } -function _wfPalKey(pal) { - return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}`; +function rgbaWithAlpha(color, alphaMul = 1) { + return cssColorToRgba(color, alphaMul); } function resizeHeaderSignalCanvas() { @@ -776,17 +787,13 @@ function resizeHeaderSignalCanvas() { } function ensureOverviewCanvasBackingStore() { - if (!overviewCanvas) return false; + if (!overviewCanvas || !overviewGl || !overviewGl.ready) return false; const cssW = Math.floor(overviewCanvas.clientWidth); const cssH = Math.floor(overviewCanvas.clientHeight); if (cssW <= 0 || cssH <= 0) return false; const dpr = window.devicePixelRatio || 1; - const nextW = Math.floor(cssW * dpr); - const nextH = Math.floor(cssH * dpr); - if (overviewCanvas.width !== nextW || overviewCanvas.height !== nextH) { - overviewCanvas.width = nextW; - overviewCanvas.height = nextH; - _wfResetOffscreen(); + const resized = overviewGl.ensureSize(cssW, cssH, dpr); + if (resized) { trimOverviewWaterfallRows(); } return true; @@ -809,7 +816,7 @@ function signalOverlayHeight() { } function drawSignalOverlay() { - if (!signalOverlayCanvas || !signalVisualBlockEl) return; + if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return; if (!lastSpectrumData) { signalOverlayCanvas.style.height = "0"; signalOverlayCanvas.width = 0; @@ -826,22 +833,19 @@ function drawSignalOverlay() { } const dpr = window.devicePixelRatio || 1; - const nextW = Math.floor(cssW * dpr); - const nextH = Math.floor(cssH * dpr); - if (signalOverlayCanvas.width !== nextW || signalOverlayCanvas.height !== nextH) { - signalOverlayCanvas.width = nextW; - signalOverlayCanvas.height = nextH; - } - - const ctx = signalOverlayCanvas.getContext("2d"); - if (!ctx) return; - - ctx.save(); - ctx.scale(dpr, dpr); - ctx.clearRect(0, 0, cssW, cssH); + signalOverlayGl.ensureSize(cssW, cssH, dpr); + const W = signalOverlayCanvas.width; + const H = signalOverlayCanvas.height; + if (W <= 0 || H <= 0) return; + signalOverlayGl.clear([0, 0, 0, 0]); const range = spectrumVisibleRange(lastSpectrumData); - const hzToX = (hz) => ((hz - range.visLoHz) / range.visSpanHz) * cssW; + const hzToX = (hz) => ((hz - range.visLoHz) / range.visSpanHz) * W; + const bwSoft = cssColorToRgba("rgba(240,173,78,0.05)"); + const bwMid = cssColorToRgba("rgba(240,173,78,0.19)"); + const bwEdge = cssColorToRgba("rgba(240,173,78,0.30)"); + const bwStroke = cssColorToRgba("rgba(240,173,78,0.70)"); + const bwHard = cssColorToRgba("rgba(240,173,78,0.38)"); if (lastFreqHz != null && currentBandwidthHz > 0) { for (const spec of visibleBandwidthSpecs(lastFreqHz)) { @@ -850,71 +854,51 @@ function drawSignalOverlay() { const xR = hzToX(span.hiHz); const stripW = xR - xL; if (stripW <= 1) continue; - const grd = ctx.createLinearGradient(xL, 0, xR, 0); if (span.side < 0) { - grd.addColorStop(0, "rgba(240,173,78,0.05)"); - grd.addColorStop(0.2, "rgba(240,173,78,0.14)"); - grd.addColorStop(0.7, "rgba(240,173,78,0.19)"); - grd.addColorStop(1, "rgba(240,173,78,0.19)"); + signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwSoft, bwMid, bwMid, bwSoft); } else if (span.side > 0) { - grd.addColorStop(0, "rgba(240,173,78,0.19)"); - grd.addColorStop(0.3, "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)"); + signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwMid, bwSoft, bwSoft, bwMid); } else { - 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, cssH); - - const edgeW = 5; - const edgeFill = "rgba(240,173,78,0.30)"; - if (span.side <= 0) { - ctx.fillStyle = edgeFill; - ctx.fillRect(xL, 0, edgeW, cssH); - } - if (span.side >= 0) { - ctx.fillStyle = edgeFill; - ctx.fillRect(xR - edgeW, 0, edgeW, cssH); + const half = stripW / 2; + signalOverlayGl.fillGradientRect(xL, 0, half, H, bwSoft, bwMid, bwMid, bwSoft); + signalOverlayGl.fillGradientRect(xL + half, 0, half, H, bwMid, bwSoft, bwSoft, bwMid); } - ctx.strokeStyle = "rgba(240,173,78,0.70)"; - ctx.lineWidth = 1.5; + const edgeW = Math.max(1, Math.round(5 * dpr)); if (span.side <= 0) { - ctx.beginPath(); ctx.moveTo(xL, 0); ctx.lineTo(xL, cssH); ctx.stroke(); + signalOverlayGl.fillRect(xL, 0, edgeW, H, bwEdge); } if (span.side >= 0) { - ctx.beginPath(); ctx.moveTo(xR, 0); ctx.lineTo(xR, cssH); ctx.stroke(); + signalOverlayGl.fillRect(xR - edgeW, 0, edgeW, H, bwEdge); + } + + if (span.side <= 0) { + signalOverlayGl.drawSegments([xL, 0, xL, H], bwStroke, Math.max(1, dpr * 1.5)); + } + if (span.side >= 0) { + signalOverlayGl.drawSegments([xR, 0, xR, H], bwStroke, Math.max(1, dpr * 1.5)); } if (span.side !== 0) { - ctx.strokeStyle = "rgba(240,173,78,0.38)"; - ctx.lineWidth = 1; const hardX = span.side < 0 ? xR : xL; - ctx.beginPath(); ctx.moveTo(hardX, 0); ctx.lineTo(hardX, cssH); ctx.stroke(); + signalOverlayGl.drawSegments([hardX, 0, hardX, H], bwHard, Math.max(1, dpr)); } } } if (lastFreqHz != null) { const xf = hzToX(lastFreqHz); - if (xf >= 0 && xf <= cssW) { - ctx.save(); - ctx.setLineDash([4, 4]); - ctx.strokeStyle = "#ff1744"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(xf, 0); - ctx.lineTo(xf, cssH); - ctx.stroke(); - ctx.restore(); + if (xf >= 0 && xf <= W) { + signalOverlayGl.drawDashedVerticalLine( + xf, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(2, Math.round(4 * dpr)), + cssColorToRgba("#ff1744"), + Math.max(1, dpr), + ); } } - - ctx.restore(); } function scheduleOverviewDraw() { @@ -977,138 +961,93 @@ function startHeaderSignalSampling() { function drawHeaderSignalGraph() { if (!ensureOverviewCanvasBackingStore()) return; - const ctx = overviewCanvas.getContext("2d"); - if (!ctx) return; + if (!overviewGl || !overviewGl.ready) return; const pal = canvasPalette(); - const dpr = window.devicePixelRatio || 1; - const w = overviewCanvas.width / dpr; - const h = overviewCanvas.height / dpr; - if (w <= 0 || h <= 0) return; + const W = overviewCanvas.width; + const H = overviewCanvas.height; + if (W <= 0 || H <= 0) return; - ctx.save(); - ctx.scale(dpr, dpr); - ctx.clearRect(0, 0, w, h); + overviewGl.clear(cssColorToRgba(pal.bg)); if (lastSpectrumData && overviewWaterfallRows.length > 0) { - drawOverviewWaterfall(ctx, w, h, pal); + drawOverviewWaterfall(W, H, pal); } else { - drawOverviewSignalHistory(ctx, w, h, pal); + drawOverviewSignalHistory(W, H, pal); } - ctx.restore(); positionRdsPsOverlay(); drawSignalOverlay(); } -function _wfDrawRows(oct, rows, startRowIdx, endRowIdx, iW, iH, pal) { - // Draw rows[startRowIdx..endRowIdx) into oct, positioned at the canvas bottom. - // rowH is computed relative to the total row count (all of `rows`). - const total = rows.length; - const rowH = iH / total; - const columnStep = Math.max(1, Math.ceil(iW / 320)); - for (let ri = startRowIdx; ri < endRowIdx; ri++) { - const bins = rows[ri]; - if (!Array.isArray(bins) || bins.length === 0) continue; - const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length); - const spanBins = Math.max(1, endIdx - startIdx); - const y = iH - (total - ri) * rowH; - for (let x = 0; x < iW; x += columnStep) { - const frac = x / Math.max(1, iW - 1); - const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); - oct.fillStyle = waterfallColor(bins[binIdx], pal); - oct.fillRect(x, y, columnStep + 0.75, rowH + 1); - } - } -} - -function drawOverviewWaterfall(ctx, w, h, pal) { - const maxVisible = Math.max(1, Math.floor(h)); +function drawOverviewWaterfall(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; + const maxVisible = Math.max(1, Math.floor(H)); const rows = overviewWaterfallRows.slice(-maxVisible); if (rows.length === 0) return; - const iW = Math.ceil(w); - const iH = Math.ceil(h); - const palKey = _wfPalKey(pal); - const steadyState = rows.length >= maxVisible; - // How many rows were pushed since the offscreen was last updated - const newPushes = overviewWaterfallPushCount - _wfOCPushCount; + const iW = Math.max(1, Math.ceil(W)); + const iH = Math.max(1, Math.ceil(H)); + const rgba = new Uint8Array(iW * iH * 4); + const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90); - // Detect conditions that require a full redraw - const sizeChanged = !_wfOC || _wfOC.width !== iW || _wfOC.height !== iH; - const palChanged = _wfOCPalKey !== palKey; - const needsFull = sizeChanged || palChanged || _wfOCPushCount === 0; - - if (sizeChanged || !_wfOC) { - _wfOC = new OffscreenCanvas(iW, iH); - _wfOCPushCount = 0; - } - const oct = _wfOC.getContext("2d"); - - if (needsFull) { - oct.clearRect(0, 0, iW, iH); - _wfDrawRows(oct, rows, 0, rows.length, iW, iH, pal); - _wfOCPushCount = overviewWaterfallPushCount; - _wfOCPalKey = palKey; - } else if (steadyState && newPushes > 0) { - // Steady state: scroll up and paint only the new rows at the bottom. - // newPushes new rows are at the tail of `rows`; each replaces one old row. - const newCount = Math.min(newPushes, rows.length); - const rowH = iH / rows.length; - const scrollPx = Math.round(newCount * rowH); - if (scrollPx > 0 && scrollPx < iH) { - const img = oct.getImageData(0, scrollPx, iW, iH - scrollPx); - oct.putImageData(img, 0, 0); - oct.clearRect(0, iH - scrollPx, iW, scrollPx); + for (let y = 0; y < iH; y++) { + const rowFrac = y / Math.max(1, iH - 1); + const rowIdx = Math.max(0, Math.min(rows.length - 1, Math.floor(rowFrac * rows.length))); + const bins = rows[rowIdx]; + if (!Array.isArray(bins) || bins.length === 0) continue; + const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length); + const spanBins = Math.max(1, endIdx - startIdx); + for (let x = 0; x < iW; x++) { + const frac = x / Math.max(1, iW - 1); + const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins)); + const c = waterfallColorRgba(bins[binIdx], pal, minDb, maxDb); + const p = (y * iW + x) * 4; + rgba[p + 0] = Math.round(c[0] * 255); + rgba[p + 1] = Math.round(c[1] * 255); + rgba[p + 2] = Math.round(c[2] * 255); + rgba[p + 3] = Math.round(c[3] * 255); } - _wfDrawRows(oct, rows, rows.length - newCount, rows.length, iW, iH, pal); - _wfOCPushCount = overviewWaterfallPushCount; } - ctx.drawImage(_wfOC, 0, 0, w, h); + overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, rgba, "linear"); + overviewGl.drawTexture("overview-waterfall", 0, 0, W, H, 1, true); } -function drawOverviewSignalHistory(ctx, w, h, pal) { +function drawOverviewSignalHistory(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; const now = Date.now(); const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS); if (samples.length === 0) return; const maxVal = 20; const windowStart = now - HEADER_SIG_WINDOW_MS; - const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * w; - const toY = (v) => h - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (h - 3) - 1.5; + const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * W; + const toY = (v) => H - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (H - 3) - 1.5; const gridMarkers = [ - { val: 0, label: "S0" }, - { val: 9, label: "S9" }, - { val: 18, label: "S9+" }, + { val: 0 }, + { val: 9 }, + { val: 18 }, ]; - ctx.strokeStyle = pal.waveformGrid; - ctx.lineWidth = 1; - ctx.font = "11px sans-serif"; - ctx.fillStyle = pal.waveformLabel; - ctx.textAlign = "right"; - ctx.textBaseline = "middle"; + const gridSegments = []; for (const marker of gridMarkers) { const y = toY(marker.val); - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(w, y); - ctx.stroke(); - ctx.fillText(marker.label, w - 6, Math.max(8, Math.min(h - 8, y + 6))); + gridSegments.push(0, y, W, y); } + overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1); - ctx.beginPath(); + const linePoints = []; samples.forEach((sample, idx) => { const x = toX(sample.t); const y = toY(sample.v); - if (idx === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); + if (idx === 0 || x >= linePoints[linePoints.length - 2]) { + linePoints.push(x, y); + } }); - ctx.strokeStyle = pal.waveformLine; - ctx.lineWidth = 1.6; - ctx.stroke(); + overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); if (holdMs > 0) { - ctx.beginPath(); + const holdPoints = []; for (let i = 0; i < samples.length; i++) { let peak = samples[i].v; for (let j = i; j >= 0; j--) { @@ -1117,26 +1056,28 @@ function drawOverviewSignalHistory(ctx, w, h, pal) { } const x = toX(samples[i].t); const y = toY(peak); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); + if (i === 0 || x >= holdPoints[holdPoints.length - 2]) { + holdPoints.push(x, y); + } } - ctx.strokeStyle = pal.waveformPeak; - ctx.lineWidth = 1; - ctx.stroke(); + overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1); } } -function waterfallColor(db, pal) { - const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; - const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90); - const safeDb = Number.isFinite(db) ? db : minDb; - const clamped = Math.max(minDb, Math.min(maxDb, safeDb)); - const span = Math.max(1, maxDb - minDb); - const t = (clamped - minDb) / span; +function waterfallColorRgba(db, pal, minDb, maxDb) { + const lo = Number.isFinite(minDb) ? minDb : (Number.isFinite(spectrumFloor) ? spectrumFloor : -115); + const hi = Number.isFinite(maxDb) ? maxDb : (lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90)); + const safeDb = Number.isFinite(db) ? db : lo; + const clamped = Math.max(lo, Math.min(hi, safeDb)); + const span = Math.max(1, hi - lo); + const t = (clamped - lo) / span; const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]); const light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]); const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[0]); - return `hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`; + if (typeof window.trxHslToRgba === "function") { + return window.trxHslToRgba(hue, pal.waterfallSat, light, alpha); + } + return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`); } function formatFreq(hz) { @@ -5873,6 +5814,10 @@ window.addEventListener("beforeunload", () => { // ── Spectrum display ───────────────────────────────────────────────────────── const spectrumCanvas = document.getElementById("spectrum-canvas"); +const spectrumGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(spectrumCanvas, { alpha: true }) + : null; +const spectrumDbAxis = document.getElementById("spectrum-db-axis"); const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); const spectrumTooltip = document.getElementById("spectrum-tooltip"); const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn"); @@ -5881,6 +5826,7 @@ let spectrumSource = null; let spectrumReconnectTimer = null; let spectrumDrawPending = false; let spectrumAxisKey = ""; +let spectrumDbAxisKey = ""; let lastSpectrumRenderData = null; let spectrumPeakHoldFrames = []; let pendingSpectrumFrameWaiters = []; @@ -6198,7 +6144,6 @@ function startSpectrumStreaming() { clearSpectrumPeakHoldFrames(); overviewWaterfallRows = []; overviewWaterfallPushCount = 0; - _wfResetOffscreen(); scheduleOverviewDraw(); clearSpectrumCanvas(); updateRdsPsOverlay(null); @@ -6247,7 +6192,6 @@ function stopSpectrumStreaming() { clearSpectrumPeakHoldFrames(); overviewWaterfallRows = []; overviewWaterfallPushCount = 0; - _wfResetOffscreen(); scheduleOverviewDraw(); updateRdsPsOverlay(null); clearSpectrumCanvas(); @@ -6255,10 +6199,15 @@ function stopSpectrumStreaming() { // ── Rendering ──────────────────────────────────────────────────────────────── function clearSpectrumCanvas() { - if (!spectrumCanvas) return; - const ctx = spectrumCanvas.getContext("2d"); - ctx.fillStyle = spectrumBgColor(); - ctx.fillRect(0, 0, spectrumCanvas.width, spectrumCanvas.height); + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; + const cssW = spectrumCanvas.clientWidth || 1; + const cssH = spectrumCanvas.clientHeight || 1; + spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1); + spectrumGl.clear(cssColorToRgba(spectrumBgColor())); + if (spectrumDbAxis) { + spectrumDbAxis.innerHTML = ""; + spectrumDbAxisKey = ""; + } } function formatOverlayPs(ps) { @@ -6550,167 +6499,73 @@ function scheduleSpectrumDraw() { } function drawSpectrum(data) { - if (!spectrumCanvas) return; + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; - // HiDPI sizing - const dpr = window.devicePixelRatio || 1; - const cssW = spectrumCanvas.clientWidth || 640; + 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.height = H; - } + spectrumGl.ensureSize(cssW, cssH, dpr); + const W = spectrumCanvas.width; + const H = spectrumCanvas.height; - const ctx = spectrumCanvas.getContext("2d"); - const pal = canvasPalette(); + const pal = canvasPalette(); const range = spectrumVisibleRange(data); - const bins = data.bins; + const bins = data.bins; const peakHoldBins = buildSpectrumPeakHoldBins(bins); - const n = bins.length; - - // Background - ctx.fillStyle = pal.bg; - ctx.fillRect(0, 0, W, H); + const n = bins.length; + spectrumGl.clear(cssColorToRgba(pal.bg)); if (!n) return; - const DB_MIN = spectrumFloor; - const DB_MAX = spectrumFloor + spectrumRange; + const DB_MIN = spectrumFloor; + const DB_MAX = spectrumFloor + spectrumRange; const dbRange = DB_MAX - DB_MIN; const fullSpanHz = data.sample_rate; - const loHz = data.center_hz - fullSpanHz / 2; + const loHz = data.center_hz - fullSpanHz / 2; - // Horizontal dB grid lines - ctx.strokeStyle = pal.spectrumGrid; - ctx.lineWidth = 1; const gridStep = spectrumRange > 100 ? 20 : 10; + const gridSegments = []; for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) { const y = Math.round(H * (1 - (db - DB_MIN) / dbRange)); - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + gridSegments.push(0, y, W, y); } + spectrumGl.drawSegments(gridSegments, cssColorToRgba(pal.spectrumGrid), 1); + updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr); - // Y-axis dB labels (left side) - ctx.save(); - ctx.font = `${Math.round(9 * dpr)}px monospace`; - ctx.fillStyle = pal.spectrumLabel; - 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; } function binX(i) { return hzToX(loHz + (i / (n - 1)) * fullSpanHz); } - function binY(i) { - const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i])); + function binYFromBins(srcBins, i) { + const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i])); return H * (1 - (db - DB_MIN) / dbRange); } - // ── BW strip (drawn before spectrum so traces appear on top) ────────────── - if (lastFreqHz != null && currentBandwidthHz > 0) { - if (_bwDragEdge) { - // Bottom bookmark tab centered on each visible channel, shown while resizing BW - const bwText = formatBwLabel(currentBandwidthHz); - for (const spec of visibleBandwidthSpecs(lastFreqHz)) { - const xMid = hzToX(spec.centerHz); - 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 TAB_OFFSET = 4 * dpr; - const tabX = Math.max(0, Math.min(W - tw - PAD * 2, xMid - (tw + PAD * 2) / 2)); - const tabBottom = H - TAB_OFFSET; - const tabY = tabBottom - TAB_H; - const r = 3 * dpr; - // Rounded-bottom tab shape (flat top) - ctx.fillStyle = "rgba(240,173,78,0.85)"; - ctx.beginPath(); - ctx.moveTo(tabX, tabY); - ctx.lineTo(tabX + tw + PAD * 2, tabY); - ctx.lineTo(tabX + tw + PAD * 2, tabBottom - r); - ctx.arcTo(tabX + tw + PAD * 2, tabBottom, tabX + tw + PAD * 2 - r, tabBottom, r); - ctx.lineTo(tabX + r, tabBottom); - ctx.arcTo(tabX, tabBottom, tabX, tabBottom - r, r); - ctx.lineTo(tabX, tabY); - ctx.closePath(); - ctx.fill(); - // Tab text - ctx.fillStyle = spectrumBgColor(); - ctx.textAlign = "left"; - ctx.fillText(bwText, tabX + PAD, tabBottom - 4 * dpr); - ctx.restore(); - } - } - } - - // ── 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 = pal.spectrumFill; - ctx.fill(); - ctx.restore(); - - // ── Peak-hold shadow ─────────────────────────────────────────────────────── - if (Array.isArray(peakHoldBins) && peakHoldBins.length === n) { - ctx.save(); - ctx.beginPath(); - ctx.strokeStyle = pal.waveformPeak; - ctx.globalAlpha = 0.7; - ctx.lineWidth = Math.max(1, dpr * 0.9); - for (let i = 0; i < n; i++) { - const x = binX(i); - const db = Math.max(DB_MIN, Math.min(DB_MAX, peakHoldBins[i])); - const y = H * (1 - (db - DB_MIN) / dbRange); - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); - } - ctx.stroke(); - ctx.restore(); - } - - // ── Spectrum line ───────────────────────────────────────────────────────── - ctx.save(); - ctx.beginPath(); - ctx.strokeStyle = pal.spectrumLine; - ctx.lineWidth = Math.max(1, dpr); + const fillPoints = []; for (let i = 0; i < n; i++) { - const x = binX(i), y = binY(i); - i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + fillPoints.push(binX(i), binYFromBins(bins, i)); } - ctx.stroke(); - ctx.restore(); + spectrumGl.drawFilledArea(fillPoints, H, cssColorToRgba(pal.spectrumFill)); + + if (Array.isArray(peakHoldBins) && peakHoldBins.length === n) { + const peakPoints = []; + for (let i = 0; i < n; i++) { + peakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); + } + spectrumGl.drawPolyline(peakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9)); + } + + spectrumGl.drawPolyline(fillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr)); - // ── Peak markers for easier snap-tune targeting ────────────────────────── const markerPeaks = visibleSpectrumPeakIndices(data); if (markerPeaks.length > 0) { - ctx.save(); - ctx.fillStyle = pal.waveformPeak; - ctx.strokeStyle = pal.bg; - ctx.lineWidth = Math.max(1, dpr * 0.75); - const radius = Math.max(2, dpr * 1.6); + const markerPoints = []; for (const idx of markerPeaks) { - const x = binX(idx); - const y = binY(idx); - ctx.beginPath(); - ctx.arc(x, y - radius * 0.35, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); + markerPoints.push(binX(idx), binYFromBins(bins, idx)); } - ctx.restore(); + spectrumGl.drawPoints(markerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak)); } updateSpectrumFreqAxis(range); @@ -6960,6 +6815,34 @@ function updateSpectrumFreqAxis(range) { } } +function updateSpectrumDbAxis(dbMin, dbMax, gridStep, heightPx, dpr) { + if (!spectrumDbAxis) return; + const key = [ + Math.round(dbMin), + Math.round(dbMax), + Math.round(gridStep), + Math.round(heightPx), + Math.round((dpr || 1) * 100), + currentTheme(), + currentStyle(), + ].join(":"); + if (key === spectrumDbAxisKey) return; + spectrumDbAxisKey = key; + spectrumDbAxis.innerHTML = ""; + + const spanDb = Math.max(1, dbMax - dbMin); + const cssHeight = heightPx / Math.max(1, dpr || 1); + for (let db = Math.ceil(dbMin / gridStep) * gridStep; db <= dbMax; db += gridStep) { + const yPx = Math.round(heightPx * (1 - (db - dbMin) / spanDb)); + const yCss = yPx / Math.max(1, dpr || 1); + if (yCss <= 7 || yCss >= cssHeight - 4) continue; + const span = document.createElement("span"); + span.textContent = `${db}`; + span.style.top = `${yCss}px`; + spectrumDbAxis.appendChild(span); + } +} + // ── Zoom helpers ────────────────────────────────────────────────────────────── function spectrumZoomAt(cssX, cssW, data, factor) { const range = spectrumVisibleRange(data); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 0867e94..102bead 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -92,6 +92,7 @@ +
@@ -632,6 +633,7 @@
Connecting…
+ diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js index ee44476..7dd55bc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/cw.js @@ -7,6 +7,9 @@ const cwWpmInput = document.getElementById("cw-wpm"); const cwToneInput = document.getElementById("cw-tone"); const cwSignalIndicator = document.getElementById("cw-signal-indicator"); const cwToneCanvas = document.getElementById("cw-tone-waterfall"); +const cwToneGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) + : null; const cwTonePickerEl = document.querySelector(".cw-tone-picker"); const cwToneRangeEl = document.getElementById("cw-tone-range"); const CW_MAX_LINES = 200; @@ -88,7 +91,7 @@ function toneClampForRange(tone, range) { } function ensureCwToneCanvasResolution() { - if (!cwToneCanvas) return false; + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; const rect = cwToneCanvas.getBoundingClientRect(); const cssWidth = Math.round(rect.width); const cssHeight = Math.round(rect.height); @@ -96,26 +99,16 @@ function ensureCwToneCanvasResolution() { return false; } const dpr = window.devicePixelRatio || 1; - const nextWidth = Math.round(cssWidth * dpr); - const nextHeight = Math.round(cssHeight * dpr); - if (cwToneCanvas.width !== nextWidth || cwToneCanvas.height !== nextHeight) { - cwToneCanvas.width = nextWidth; - cwToneCanvas.height = nextHeight; - return true; - } - return false; + return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); } function drawCwTonePicker() { - if (!cwToneCanvas) return; + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; ensureCwToneCanvasResolution(); if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; - const ctx = cwToneCanvas.getContext("2d"); - if (!ctx) return; - const width = cwToneCanvas.width; const height = cwToneCanvas.height; - ctx.clearRect(0, 0, width, height); + cwToneGl.clear([0, 0, 0, 0]); const range = currentCwToneRange(); if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) { @@ -127,8 +120,7 @@ function drawCwTonePicker() { cwToneRangeEl.textContent = "Waiting for spectrum"; } } - ctx.fillStyle = "rgba(130, 150, 165, 0.22)"; - ctx.fillRect(0, 0, width, height); + cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]); return; } @@ -178,77 +170,48 @@ function drawCwTonePicker() { const rootStyle = getComputedStyle(document.documentElement); const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; - const axisColor = "rgba(230, 235, 245, 0.15)"; - const textColor = "rgba(230, 235, 245, 0.58)"; + const parseColor = typeof window.trxParseCssColor === "function" + ? window.trxParseCssColor + : null; + const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; + const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; - ctx.fillStyle = "rgba(7, 12, 18, 0.94)"; - ctx.fillRect(0, 0, width, height); + cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); const hGridCount = 4; - ctx.strokeStyle = axisColor; - ctx.lineWidth = 1; + const gridSegments = []; for (let i = 1; i <= hGridCount; i += 1) { - const y = Math.round((i / (hGridCount + 1)) * (height - 1)) + 0.5; - ctx.beginPath(); - ctx.moveTo(0, y); - ctx.lineTo(width, y); - ctx.stroke(); + const y = Math.round((i / (hGridCount + 1)) * (height - 1)); + gridSegments.push(0, y, width, y); } + cwToneGl.drawSegments(gridSegments, axisColor, 1); const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200; const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; - ctx.font = `${Math.max(10, Math.round(height * 0.18))}px ui-monospace, SFMono-Regular, Menlo, monospace`; - ctx.fillStyle = textColor; + const tickSegments = []; for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { const frac = (tone - range.toneMinHz) / range.toneSpanHz; - const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))) + 0.5; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, height); - ctx.stroke(); - if (tone % (toneStep * 2) === 0) { - const label = `${Math.round(tone)}`; - const textWidth = ctx.measureText(label).width; - ctx.fillText(label, Math.max(1, Math.min(width - textWidth - 1, x + 2)), height - 3); - } + const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); + tickSegments.push(x, 0, x, height); } + cwToneGl.drawSegments(tickSegments, axisColor, 1); - ctx.beginPath(); - ctx.moveTo(0, height - 0.5); + const linePoints = []; for (let x = 0; x < width; x += 1) { - ctx.lineTo(x + 0.5, yForDb(smoothed[x]) + 0.5); + linePoints.push(x, yForDb(smoothed[x])); } - ctx.lineTo(width - 0.5, height - 0.5); - ctx.closePath(); - ctx.save(); - ctx.globalAlpha = 0.24; - ctx.fillStyle = accent; - ctx.fill(); - ctx.restore(); - - ctx.beginPath(); - for (let x = 0; x < width; x += 1) { - const y = yForDb(smoothed[x]) + 0.5; - if (x === 0) ctx.moveTo(0.5, y); - else ctx.lineTo(x + 0.5, y); - } - ctx.lineWidth = 1.8; - ctx.strokeStyle = accent; - ctx.stroke(); + cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); + cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]); - ctx.fillStyle = "rgba(255, 255, 255, 0.9)"; - ctx.fillRect(markerX, 0, 1.5, height); - ctx.beginPath(); - ctx.arc(markerX, markerY, Math.max(2, Math.round(height * 0.055)), 0, Math.PI * 2); - ctx.fill(); + cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); + cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); if (cwAutoInput?.checked) { - ctx.fillStyle = "rgba(0, 0, 0, 0.22)"; - ctx.fillRect(0, 0, width, height); + cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]); } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index f526fb8..07d0402 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -2182,6 +2182,28 @@ button:focus-visible, input:focus-visible, select:focus-visible { cursor: crosshair; touch-action: none; } +#spectrum-db-axis { + position: absolute; + top: 0; + left: 0; + width: 3rem; + height: var(--spectrum-plot-height); + pointer-events: none; + z-index: 7; +} +#spectrum-db-axis span { + position: absolute; + left: 0.22rem; + transform: translateY(-50%); + font-size: 0.62rem; + line-height: 1; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + color: var(--text-muted); + font-weight: 700; + letter-spacing: 0.01em; + text-shadow: 0 1px 2px color-mix(in srgb, var(--bg) 65%, transparent); + white-space: nowrap; +} .spectrum-edge-shift { position: absolute; top: calc((var(--spectrum-plot-height) - var(--overview-plot-height)) / 2); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/webgl-renderer.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/webgl-renderer.js new file mode 100644 index 0000000..76a7fca --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/webgl-renderer.js @@ -0,0 +1,483 @@ +(function initTrxWebGl(global) { + "use strict"; + + const cssColorCache = new Map(); + let cssColorProbe = null; + + function ensureCssColorProbe() { + if (cssColorProbe) return cssColorProbe; + const el = document.createElement("span"); + el.style.position = "absolute"; + el.style.left = "-9999px"; + el.style.top = "-9999px"; + el.style.pointerEvents = "none"; + el.style.opacity = "0"; + document.body.appendChild(el); + cssColorProbe = el; + return cssColorProbe; + } + + function parseRgbString(value) { + const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim()); + if (!m) return null; + const parts = m[1].split(",").map((p) => p.trim()); + if (parts.length < 3) return null; + const r = Number(parts[0]); + const g = Number(parts[1]); + const b = Number(parts[2]); + const a = parts.length > 3 ? Number(parts[3]) : 1; + if (![r, g, b, a].every(Number.isFinite)) return null; + return [ + Math.max(0, Math.min(1, r / 255)), + Math.max(0, Math.min(1, g / 255)), + Math.max(0, Math.min(1, b / 255)), + Math.max(0, Math.min(1, a)), + ]; + } + + function parseHexColor(value) { + const raw = String(value || "").trim(); + if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null; + let hex = raw.slice(1); + if (hex.length === 3 || hex.length === 4) { + hex = hex.split("").map((ch) => ch + ch).join(""); + } + if (!(hex.length === 6 || hex.length === 8)) return null; + const r = parseInt(hex.slice(0, 2), 16) / 255; + const g = parseInt(hex.slice(2, 4), 16) / 255; + const b = parseInt(hex.slice(4, 6), 16) / 255; + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; + return [r, g, b, a]; + } + + function parseCssColor(value) { + const key = String(value ?? ""); + if (cssColorCache.has(key)) return cssColorCache.get(key).slice(); + + let parsed = parseHexColor(key) || parseRgbString(key); + if (!parsed) { + const probe = ensureCssColorProbe(); + probe.style.color = ""; + probe.style.color = key; + const computed = getComputedStyle(probe).color; + parsed = parseRgbString(computed) || [0, 0, 0, 1]; + } + cssColorCache.set(key, parsed.slice()); + return parsed.slice(); + } + + function hslToRgba(h, s, l, a = 1) { + const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360; + const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100)); + const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100)); + + const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat; + const p = 2 * lig - q; + const hueToRgb = (t) => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + + const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3); + const g = sat === 0 ? lig : hueToRgb(hue); + const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3); + return [r, g, b, Math.max(0, Math.min(1, Number(a)))]; + } + + function normalizeColor(input, alphaMul = 1) { + let rgba; + if (Array.isArray(input)) { + const arr = input.map((v) => Number(v)); + if (arr.length >= 4) { + rgba = [arr[0], arr[1], arr[2], arr[3]]; + } else { + rgba = [0, 0, 0, 1]; + } + } else if (typeof input === "string") { + rgba = parseCssColor(input); + } else if (input && typeof input === "object") { + rgba = [ + Number(input.r) || 0, + Number(input.g) || 0, + Number(input.b) || 0, + Number(input.a ?? 1), + ]; + } else { + rgba = [0, 0, 0, 1]; + } + const out = [ + Math.max(0, Math.min(1, rgba[0])), + Math.max(0, Math.min(1, rgba[1])), + Math.max(0, Math.min(1, rgba[2])), + Math.max(0, Math.min(1, rgba[3] * alphaMul)), + ]; + return out; + } + + function compileShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader) || "shader compile error"; + gl.deleteShader(shader); + throw new Error(log); + } + return shader; + } + + function createProgram(gl, vertexSrc, fragmentSrc) { + const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc); + const program = gl.createProgram(); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program) || "program link error"; + gl.deleteProgram(program); + throw new Error(log); + } + return program; + } + + function pushColoredVertex(target, x, y, rgba) { + target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]); + } + + function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) { + const dx = x1 - x0; + const dy = y1 - y0; + const len = Math.hypot(dx, dy); + if (!(len > 0.0001)) return; + const nx = (-dy / len) * halfW; + const ny = (dx / len) * halfW; + + const ax = x0 - nx, ay = y0 - ny; + const bx = x0 + nx, by = y0 + ny; + const cx = x1 + nx, cy = y1 + ny; + const dx2 = x1 - nx, dy2 = y1 - ny; + + pushColoredVertex(out, ax, ay, rgba); + pushColoredVertex(out, bx, by, rgba); + pushColoredVertex(out, cx, cy, rgba); + + pushColoredVertex(out, ax, ay, rgba); + pushColoredVertex(out, cx, cy, rgba); + pushColoredVertex(out, dx2, dy2, rgba); + } + + class TrxWebGlRenderer { + constructor(canvas, options = {}) { + this.canvas = canvas; + this.options = { alpha: true, premultipliedAlpha: false, ...options }; + this.gl = + canvas?.getContext("webgl", this.options) || + canvas?.getContext("experimental-webgl", this.options) || + null; + this.ready = !!this.gl; + this.textures = new Map(); + if (!this.ready) return; + + const gl = this.gl; + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + const colorVertexSrc = + "attribute vec2 a_pos;\n" + + "attribute vec4 a_color;\n" + + "uniform vec2 u_resolution;\n" + + "varying vec4 v_color;\n" + + "void main() {\n" + + " vec2 zeroToOne = a_pos / u_resolution;\n" + + " vec2 clip = zeroToOne * 2.0 - 1.0;\n" + + " gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" + + " v_color = a_color;\n" + + "}\n"; + const colorFragmentSrc = + "precision mediump float;\n" + + "varying vec4 v_color;\n" + + "void main() {\n" + + " gl_FragColor = v_color;\n" + + "}\n"; + + const textureVertexSrc = + "attribute vec2 a_pos;\n" + + "attribute vec2 a_uv;\n" + + "uniform vec2 u_resolution;\n" + + "varying vec2 v_uv;\n" + + "void main() {\n" + + " vec2 zeroToOne = a_pos / u_resolution;\n" + + " vec2 clip = zeroToOne * 2.0 - 1.0;\n" + + " gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" + + " v_uv = a_uv;\n" + + "}\n"; + const textureFragmentSrc = + "precision mediump float;\n" + + "varying vec2 v_uv;\n" + + "uniform sampler2D u_tex;\n" + + "uniform float u_alpha;\n" + + "void main() {\n" + + " vec4 c = texture2D(u_tex, v_uv);\n" + + " gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" + + "}\n"; + + this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc); + this.colorBuffer = gl.createBuffer(); + this.colorLoc = { + pos: gl.getAttribLocation(this.colorProgram, "a_pos"), + color: gl.getAttribLocation(this.colorProgram, "a_color"), + resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"), + }; + + this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc); + this.textureBuffer = gl.createBuffer(); + this.textureLoc = { + pos: gl.getAttribLocation(this.textureProgram, "a_pos"), + uv: gl.getAttribLocation(this.textureProgram, "a_uv"), + resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"), + alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"), + tex: gl.getUniformLocation(this.textureProgram, "u_tex"), + }; + } + + ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) { + if (!this.ready) return false; + const nextW = Math.max(1, Math.round(cssWidth * dpr)); + const nextH = Math.max(1, Math.round(cssHeight * dpr)); + const changed = this.canvas.width !== nextW || this.canvas.height !== nextH; + if (changed) { + this.canvas.width = nextW; + this.canvas.height = nextH; + } + this.gl.viewport(0, 0, this.canvas.width, this.canvas.height); + return changed; + } + + clear(color) { + if (!this.ready) return; + const gl = this.gl; + const rgba = normalizeColor(color); + gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]); + gl.clear(gl.COLOR_BUFFER_BIT); + } + + drawTriangles(vertices) { + this._drawColorGeometry(vertices, this.gl.TRIANGLES); + } + + drawTriangleStrip(vertices) { + this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP); + } + + _drawColorGeometry(vertices, mode) { + if (!this.ready || !vertices || vertices.length === 0) return; + const gl = this.gl; + const program = this.colorProgram; + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); + const arr = vertices instanceof Float32Array ? vertices : new Float32Array(vertices); + gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STREAM_DRAW); + gl.enableVertexAttribArray(this.colorLoc.pos); + gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0); + gl.enableVertexAttribArray(this.colorLoc.color); + gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8); + gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height); + gl.drawArrays(mode, 0, arr.length / 6); + } + + fillRect(x, y, w, h, color) { + if (w <= 0 || h <= 0) return; + const rgba = normalizeColor(color); + const v = []; + pushColoredVertex(v, x, y, rgba); + pushColoredVertex(v, x + w, y, rgba); + pushColoredVertex(v, x + w, y + h, rgba); + pushColoredVertex(v, x, y, rgba); + pushColoredVertex(v, x + w, y + h, rgba); + pushColoredVertex(v, x, y + h, rgba); + this._drawColorGeometry(v, this.gl.TRIANGLES); + } + + fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) { + if (w <= 0 || h <= 0) return; + const tl = normalizeColor(colorTL); + const tr = normalizeColor(colorTR); + const br = normalizeColor(colorBR); + const bl = normalizeColor(colorBL); + const v = []; + pushColoredVertex(v, x, y, tl); + pushColoredVertex(v, x + w, y, tr); + pushColoredVertex(v, x + w, y + h, br); + pushColoredVertex(v, x, y, tl); + pushColoredVertex(v, x + w, y + h, br); + pushColoredVertex(v, x, y + h, bl); + this._drawColorGeometry(v, this.gl.TRIANGLES); + } + + drawPolyline(points, color, width = 1) { + if (!Array.isArray(points) || points.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, Number(width) || 1) / 2; + const verts = []; + for (let i = 0; i < points.length - 2; i += 2) { + segmentToQuadVertices( + verts, + points[i], points[i + 1], + points[i + 2], points[i + 3], + halfW, + rgba, + ); + } + this._drawColorGeometry(verts, this.gl.TRIANGLES); + } + + drawSegments(segments, color, width = 1) { + if (!Array.isArray(segments) || segments.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, Number(width) || 1) / 2; + const verts = []; + for (let i = 0; i < segments.length - 3; i += 4) { + segmentToQuadVertices( + verts, + segments[i], segments[i + 1], + segments[i + 2], segments[i + 3], + halfW, + rgba, + ); + } + this._drawColorGeometry(verts, this.gl.TRIANGLES); + } + + drawFilledArea(points, baselineY, color) { + if (!Array.isArray(points) || points.length < 4) return; + const rgba = normalizeColor(color); + const verts = []; + for (let i = 0; i < points.length; i += 2) { + pushColoredVertex(verts, points[i], baselineY, rgba); + pushColoredVertex(verts, points[i], points[i + 1], rgba); + } + this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP); + } + + drawPoints(points, size, color) { + if (!Array.isArray(points) || points.length < 2) return; + const radius = Math.max(1, Number(size) || 1); + const rgba = normalizeColor(color); + for (let i = 0; i < points.length; i += 2) { + this.fillRect(points[i] - radius, points[i + 1] - radius, radius * 2, radius * 2, rgba); + } + } + + drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) { + const dash = Math.max(1, Number(dashLen) || 1); + const gap = Math.max(1, Number(gapLen) || 1); + const top = Math.min(y0, y1); + const bottom = Math.max(y0, y1); + for (let y = top; y < bottom; y += dash + gap) { + const segEnd = Math.min(bottom, y + dash); + this.drawSegments([x, y, x, segEnd], color, width); + } + } + + uploadRgbaTexture(name, width, height, data, filter = "linear") { + if (!this.ready || !name || !data) return null; + const gl = this.gl; + let entry = this.textures.get(name); + if (!entry) { + const texture = gl.createTexture(); + entry = { texture, width: 0, height: 0 }; + this.textures.set(name, entry); + } + gl.bindTexture(gl.TEXTURE_2D, entry.texture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode); + 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); + if (entry.width !== width || entry.height !== height) { + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + width, + height, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + entry.width = width; + entry.height = height; + } else { + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + width, + height, + gl.RGBA, + gl.UNSIGNED_BYTE, + data, + ); + } + return entry.texture; + } + + drawTexture(name, x, y, w, h, alpha = 1, flipY = true) { + if (!this.ready || !name || w <= 0 || h <= 0) return; + const entry = this.textures.get(name); + if (!entry) return; + const gl = this.gl; + const v = flipY + ? [ + x, y, 0, 1, + x + w, y, 1, 1, + x + w, y + h, 1, 0, + x, y, 0, 1, + x + w, y + h, 1, 0, + x, y + h, 0, 0, + ] + : [ + x, y, 0, 0, + x + w, y, 1, 0, + x + w, y + h, 1, 1, + x, y, 0, 0, + x + w, y + h, 1, 1, + x, y + h, 0, 1, + ]; + gl.useProgram(this.textureProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(v), gl.STREAM_DRAW); + gl.enableVertexAttribArray(this.textureLoc.pos); + gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0); + gl.enableVertexAttribArray(this.textureLoc.uv); + gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8); + gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height); + gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0))); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, entry.texture); + gl.uniform1i(this.textureLoc.tex, 0); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + } + + function createRenderer(canvas, options) { + return new TrxWebGlRenderer(canvas, options); + } + + global.trxParseCssColor = parseCssColor; + global.trxHslToRgba = hslToRgba; + global.createTrxWebGlRenderer = createRenderer; +})(window); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index 13f1917..0a17017 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -1008,6 +1008,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(logo) .service(style_css) .service(app_js) + .service(webgl_renderer_js) .service(leaflet_ais_tracksymbol_js) .service(ais_js) .service(vdes_js) @@ -1067,6 +1068,16 @@ async fn app_js() -> impl Responder { .body(status::APP_JS) } +#[get("/webgl-renderer.js")] +async fn webgl_renderer_js() -> impl Responder { + HttpResponse::Ok() + .insert_header(( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )) + .body(status::WEBGL_RENDERER_JS) +} + #[get("/leaflet-ais-tracksymbol.js")] async fn leaflet_ais_tracksymbol_js() -> impl Responder { HttpResponse::Ok() diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index af5b6b5..9c0e4bc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -9,6 +9,7 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE"); const INDEX_HTML: &str = include_str!("../assets/web/index.html"); pub const STYLE_CSS: &str = include_str!("../assets/web/style.css"); pub const APP_JS: &str = include_str!("../assets/web/app.js"); +pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js"); pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str = include_str!("../assets/web/leaflet-ais-tracksymbol.js"); pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");