[feat](trx-frontend-http): migrate frontend canvas rendering to WebGL

Replace Canvas2D rendering in spectrum, overview, signal overlay, and CW tone picker with a shared WebGL renderer and wire the new asset into frontend HTTP routes.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-05 21:37:50 +01:00
parent 633ad92dd6
commit 27b90a62c5
8 changed files with 827 additions and 383 deletions
+79
View File
@@ -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.
@@ -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;
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);
}
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);
}
_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,30 +6499,22 @@ 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 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 range = spectrumVisibleRange(data);
const bins = data.bins;
const peakHoldBins = buildSpectrumPeakHoldBins(bins);
const n = bins.length;
// Background
ctx.fillStyle = pal.bg;
ctx.fillRect(0, 0, W, H);
spectrumGl.clear(cssColorToRgba(pal.bg));
if (!n) return;
const DB_MIN = spectrumFloor;
@@ -6582,135 +6523,49 @@ function drawSpectrum(data) {
const fullSpanHz = data.sample_rate;
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();
}
}
const fillPoints = [];
for (let i = 0; i < n; i++) {
fillPoints.push(binX(i), binYFromBins(bins, i));
}
spectrumGl.drawFilledArea(fillPoints, H, cssColorToRgba(pal.spectrumFill));
// ── 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);
const peakPoints = [];
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);
peakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
}
ctx.stroke();
ctx.restore();
spectrumGl.drawPolyline(peakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9));
}
// ── Spectrum line ─────────────────────────────────────────────────────────
ctx.save();
ctx.beginPath();
ctx.strokeStyle = pal.spectrumLine;
ctx.lineWidth = Math.max(1, dpr);
for (let i = 0; i < n; i++) {
const x = binX(i), y = binY(i);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.restore();
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);
@@ -92,6 +92,7 @@
<div id="spectrum-bookmark-side-left" class="spectrum-bookmark-side spectrum-bookmark-side-left" aria-hidden="true"></div>
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">&lsaquo;</button>
<canvas id="spectrum-canvas"></canvas>
<div id="spectrum-db-axis" aria-hidden="true"></div>
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">&rsaquo;</button>
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
<div id="spectrum-tooltip"></div>
@@ -632,6 +633,7 @@
<div class="hint" id="power-hint">Connecting…</div>
</div>
</div>
<script src="/webgl-renderer.js"></script>
<script src="/app.js"></script>
<script src="/ais.js"></script>
<script src="/vdes.js"></script>
@@ -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]);
}
}
@@ -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);
@@ -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);
@@ -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()
@@ -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");