[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:
@@ -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 loadingSub = document.getElementById("loading-sub");
|
||||||
const overviewCanvas = document.getElementById("overview-canvas");
|
const overviewCanvas = document.getElementById("overview-canvas");
|
||||||
const signalOverlayCanvas = document.getElementById("signal-overlay-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 signalVisualBlockEl = document.querySelector(".signal-visual-block");
|
||||||
const signalSplitControlEl = document.getElementById("signal-split-control");
|
const signalSplitControlEl = document.getElementById("signal-split-control");
|
||||||
const signalSplitSliderEl = document.getElementById("signal-split-slider");
|
const signalSplitSliderEl = document.getElementById("signal-split-slider");
|
||||||
@@ -759,14 +765,19 @@ let overviewWaterfallRows = [];
|
|||||||
let overviewWaterfallPushCount = 0; // monotonically increments on every push
|
let overviewWaterfallPushCount = 0; // monotonically increments on every push
|
||||||
const HEADER_SIG_WINDOW_MS = 10_000;
|
const HEADER_SIG_WINDOW_MS = 10_000;
|
||||||
|
|
||||||
// Offscreen waterfall cache — reused across frames to avoid full redraws
|
function cssColorToRgba(color, alphaMul = 1) {
|
||||||
let _wfOC = null; // OffscreenCanvas
|
const parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
|
||||||
let _wfOCPalKey = ""; // palette signature when offscreen was last built
|
const parsed = parser ? parser(color) : [0, 0, 0, 1];
|
||||||
let _wfOCPushCount = 0; // overviewWaterfallPushCount when offscreen was last updated
|
return [
|
||||||
|
parsed[0],
|
||||||
|
parsed[1],
|
||||||
|
parsed[2],
|
||||||
|
Math.max(0, Math.min(1, parsed[3] * alphaMul)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function _wfResetOffscreen() { _wfOC = null; _wfOCPushCount = 0; _wfOCPalKey = ""; }
|
function rgbaWithAlpha(color, alphaMul = 1) {
|
||||||
function _wfPalKey(pal) {
|
return cssColorToRgba(color, alphaMul);
|
||||||
return `${pal.waterfallHue}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeHeaderSignalCanvas() {
|
function resizeHeaderSignalCanvas() {
|
||||||
@@ -776,17 +787,13 @@ function resizeHeaderSignalCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureOverviewCanvasBackingStore() {
|
function ensureOverviewCanvasBackingStore() {
|
||||||
if (!overviewCanvas) return false;
|
if (!overviewCanvas || !overviewGl || !overviewGl.ready) return false;
|
||||||
const cssW = Math.floor(overviewCanvas.clientWidth);
|
const cssW = Math.floor(overviewCanvas.clientWidth);
|
||||||
const cssH = Math.floor(overviewCanvas.clientHeight);
|
const cssH = Math.floor(overviewCanvas.clientHeight);
|
||||||
if (cssW <= 0 || cssH <= 0) return false;
|
if (cssW <= 0 || cssH <= 0) return false;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const nextW = Math.floor(cssW * dpr);
|
const resized = overviewGl.ensureSize(cssW, cssH, dpr);
|
||||||
const nextH = Math.floor(cssH * dpr);
|
if (resized) {
|
||||||
if (overviewCanvas.width !== nextW || overviewCanvas.height !== nextH) {
|
|
||||||
overviewCanvas.width = nextW;
|
|
||||||
overviewCanvas.height = nextH;
|
|
||||||
_wfResetOffscreen();
|
|
||||||
trimOverviewWaterfallRows();
|
trimOverviewWaterfallRows();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -809,7 +816,7 @@ function signalOverlayHeight() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawSignalOverlay() {
|
function drawSignalOverlay() {
|
||||||
if (!signalOverlayCanvas || !signalVisualBlockEl) return;
|
if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return;
|
||||||
if (!lastSpectrumData) {
|
if (!lastSpectrumData) {
|
||||||
signalOverlayCanvas.style.height = "0";
|
signalOverlayCanvas.style.height = "0";
|
||||||
signalOverlayCanvas.width = 0;
|
signalOverlayCanvas.width = 0;
|
||||||
@@ -826,22 +833,19 @@ function drawSignalOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const nextW = Math.floor(cssW * dpr);
|
signalOverlayGl.ensureSize(cssW, cssH, dpr);
|
||||||
const nextH = Math.floor(cssH * dpr);
|
const W = signalOverlayCanvas.width;
|
||||||
if (signalOverlayCanvas.width !== nextW || signalOverlayCanvas.height !== nextH) {
|
const H = signalOverlayCanvas.height;
|
||||||
signalOverlayCanvas.width = nextW;
|
if (W <= 0 || H <= 0) return;
|
||||||
signalOverlayCanvas.height = nextH;
|
signalOverlayGl.clear([0, 0, 0, 0]);
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = signalOverlayCanvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
ctx.clearRect(0, 0, cssW, cssH);
|
|
||||||
|
|
||||||
const range = spectrumVisibleRange(lastSpectrumData);
|
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) {
|
if (lastFreqHz != null && currentBandwidthHz > 0) {
|
||||||
for (const spec of visibleBandwidthSpecs(lastFreqHz)) {
|
for (const spec of visibleBandwidthSpecs(lastFreqHz)) {
|
||||||
@@ -850,71 +854,51 @@ function drawSignalOverlay() {
|
|||||||
const xR = hzToX(span.hiHz);
|
const xR = hzToX(span.hiHz);
|
||||||
const stripW = xR - xL;
|
const stripW = xR - xL;
|
||||||
if (stripW <= 1) continue;
|
if (stripW <= 1) continue;
|
||||||
const grd = ctx.createLinearGradient(xL, 0, xR, 0);
|
|
||||||
if (span.side < 0) {
|
if (span.side < 0) {
|
||||||
grd.addColorStop(0, "rgba(240,173,78,0.05)");
|
signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwSoft, bwMid, bwMid, bwSoft);
|
||||||
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)");
|
|
||||||
} else if (span.side > 0) {
|
} else if (span.side > 0) {
|
||||||
grd.addColorStop(0, "rgba(240,173,78,0.19)");
|
signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwMid, bwSoft, bwSoft, bwMid);
|
||||||
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)");
|
|
||||||
} else {
|
} else {
|
||||||
grd.addColorStop(0, "rgba(240,173,78,0.05)");
|
const half = stripW / 2;
|
||||||
grd.addColorStop(0.2, "rgba(240,173,78,0.14)");
|
signalOverlayGl.fillGradientRect(xL, 0, half, H, bwSoft, bwMid, bwMid, bwSoft);
|
||||||
grd.addColorStop(0.5, "rgba(240,173,78,0.19)");
|
signalOverlayGl.fillGradientRect(xL + half, 0, half, H, bwMid, bwSoft, bwSoft, bwMid);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = "rgba(240,173,78,0.70)";
|
const edgeW = Math.max(1, Math.round(5 * dpr));
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
if (span.side <= 0) {
|
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) {
|
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) {
|
if (span.side !== 0) {
|
||||||
ctx.strokeStyle = "rgba(240,173,78,0.38)";
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
const hardX = span.side < 0 ? xR : xL;
|
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) {
|
if (lastFreqHz != null) {
|
||||||
const xf = hzToX(lastFreqHz);
|
const xf = hzToX(lastFreqHz);
|
||||||
if (xf >= 0 && xf <= cssW) {
|
if (xf >= 0 && xf <= W) {
|
||||||
ctx.save();
|
signalOverlayGl.drawDashedVerticalLine(
|
||||||
ctx.setLineDash([4, 4]);
|
xf,
|
||||||
ctx.strokeStyle = "#ff1744";
|
0,
|
||||||
ctx.lineWidth = 1;
|
H,
|
||||||
ctx.beginPath();
|
Math.max(2, Math.round(4 * dpr)),
|
||||||
ctx.moveTo(xf, 0);
|
Math.max(2, Math.round(4 * dpr)),
|
||||||
ctx.lineTo(xf, cssH);
|
cssColorToRgba("#ff1744"),
|
||||||
ctx.stroke();
|
Math.max(1, dpr),
|
||||||
ctx.restore();
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleOverviewDraw() {
|
function scheduleOverviewDraw() {
|
||||||
@@ -977,138 +961,93 @@ function startHeaderSignalSampling() {
|
|||||||
|
|
||||||
function drawHeaderSignalGraph() {
|
function drawHeaderSignalGraph() {
|
||||||
if (!ensureOverviewCanvasBackingStore()) return;
|
if (!ensureOverviewCanvasBackingStore()) return;
|
||||||
const ctx = overviewCanvas.getContext("2d");
|
if (!overviewGl || !overviewGl.ready) return;
|
||||||
if (!ctx) return;
|
|
||||||
const pal = canvasPalette();
|
const pal = canvasPalette();
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const W = overviewCanvas.width;
|
||||||
const w = overviewCanvas.width / dpr;
|
const H = overviewCanvas.height;
|
||||||
const h = overviewCanvas.height / dpr;
|
if (W <= 0 || H <= 0) return;
|
||||||
if (w <= 0 || h <= 0) return;
|
|
||||||
|
|
||||||
ctx.save();
|
overviewGl.clear(cssColorToRgba(pal.bg));
|
||||||
ctx.scale(dpr, dpr);
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
|
||||||
if (lastSpectrumData && overviewWaterfallRows.length > 0) {
|
if (lastSpectrumData && overviewWaterfallRows.length > 0) {
|
||||||
drawOverviewWaterfall(ctx, w, h, pal);
|
drawOverviewWaterfall(W, H, pal);
|
||||||
} else {
|
} else {
|
||||||
drawOverviewSignalHistory(ctx, w, h, pal);
|
drawOverviewSignalHistory(W, H, pal);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
|
||||||
positionRdsPsOverlay();
|
positionRdsPsOverlay();
|
||||||
drawSignalOverlay();
|
drawSignalOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _wfDrawRows(oct, rows, startRowIdx, endRowIdx, iW, iH, pal) {
|
function drawOverviewWaterfall(W, H, pal) {
|
||||||
// Draw rows[startRowIdx..endRowIdx) into oct, positioned at the canvas bottom.
|
if (!overviewGl || !overviewGl.ready) return;
|
||||||
// rowH is computed relative to the total row count (all of `rows`).
|
const maxVisible = Math.max(1, Math.floor(H));
|
||||||
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));
|
|
||||||
const rows = overviewWaterfallRows.slice(-maxVisible);
|
const rows = overviewWaterfallRows.slice(-maxVisible);
|
||||||
if (rows.length === 0) return;
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
const iW = Math.ceil(w);
|
const iW = Math.max(1, Math.ceil(W));
|
||||||
const iH = Math.ceil(h);
|
const iH = Math.max(1, Math.ceil(H));
|
||||||
const palKey = _wfPalKey(pal);
|
const rgba = new Uint8Array(iW * iH * 4);
|
||||||
const steadyState = rows.length >= maxVisible;
|
const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115;
|
||||||
// How many rows were pushed since the offscreen was last updated
|
const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90);
|
||||||
const newPushes = overviewWaterfallPushCount - _wfOCPushCount;
|
|
||||||
|
|
||||||
// Detect conditions that require a full redraw
|
for (let y = 0; y < iH; y++) {
|
||||||
const sizeChanged = !_wfOC || _wfOC.width !== iW || _wfOC.height !== iH;
|
const rowFrac = y / Math.max(1, iH - 1);
|
||||||
const palChanged = _wfOCPalKey !== palKey;
|
const rowIdx = Math.max(0, Math.min(rows.length - 1, Math.floor(rowFrac * rows.length)));
|
||||||
const needsFull = sizeChanged || palChanged || _wfOCPushCount === 0;
|
const bins = rows[rowIdx];
|
||||||
|
if (!Array.isArray(bins) || bins.length === 0) continue;
|
||||||
if (sizeChanged || !_wfOC) {
|
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, bins.length);
|
||||||
_wfOC = new OffscreenCanvas(iW, iH);
|
const spanBins = Math.max(1, endIdx - startIdx);
|
||||||
_wfOCPushCount = 0;
|
for (let x = 0; x < iW; x++) {
|
||||||
}
|
const frac = x / Math.max(1, iW - 1);
|
||||||
const oct = _wfOC.getContext("2d");
|
const binIdx = Math.min(endIdx, startIdx + Math.floor(frac * spanBins));
|
||||||
|
const c = waterfallColorRgba(bins[binIdx], pal, minDb, maxDb);
|
||||||
if (needsFull) {
|
const p = (y * iW + x) * 4;
|
||||||
oct.clearRect(0, 0, iW, iH);
|
rgba[p + 0] = Math.round(c[0] * 255);
|
||||||
_wfDrawRows(oct, rows, 0, rows.length, iW, iH, pal);
|
rgba[p + 1] = Math.round(c[1] * 255);
|
||||||
_wfOCPushCount = overviewWaterfallPushCount;
|
rgba[p + 2] = Math.round(c[2] * 255);
|
||||||
_wfOCPalKey = palKey;
|
rgba[p + 3] = Math.round(c[3] * 255);
|
||||||
} 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 now = Date.now();
|
||||||
const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS);
|
const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS);
|
||||||
if (samples.length === 0) return;
|
if (samples.length === 0) return;
|
||||||
|
|
||||||
const maxVal = 20;
|
const maxVal = 20;
|
||||||
const windowStart = now - HEADER_SIG_WINDOW_MS;
|
const windowStart = now - HEADER_SIG_WINDOW_MS;
|
||||||
const toX = (t) => ((t - windowStart) / HEADER_SIG_WINDOW_MS) * w;
|
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 toY = (v) => H - (Math.max(0, Math.min(maxVal, v)) / maxVal) * (H - 3) - 1.5;
|
||||||
|
|
||||||
const gridMarkers = [
|
const gridMarkers = [
|
||||||
{ val: 0, label: "S0" },
|
{ val: 0 },
|
||||||
{ val: 9, label: "S9" },
|
{ val: 9 },
|
||||||
{ val: 18, label: "S9+" },
|
{ val: 18 },
|
||||||
];
|
];
|
||||||
ctx.strokeStyle = pal.waveformGrid;
|
const gridSegments = [];
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.font = "11px sans-serif";
|
|
||||||
ctx.fillStyle = pal.waveformLabel;
|
|
||||||
ctx.textAlign = "right";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
for (const marker of gridMarkers) {
|
for (const marker of gridMarkers) {
|
||||||
const y = toY(marker.val);
|
const y = toY(marker.val);
|
||||||
ctx.beginPath();
|
gridSegments.push(0, y, W, y);
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1);
|
||||||
|
|
||||||
ctx.beginPath();
|
const linePoints = [];
|
||||||
samples.forEach((sample, idx) => {
|
samples.forEach((sample, idx) => {
|
||||||
const x = toX(sample.t);
|
const x = toX(sample.t);
|
||||||
const y = toY(sample.v);
|
const y = toY(sample.v);
|
||||||
if (idx === 0) ctx.moveTo(x, y);
|
if (idx === 0 || x >= linePoints[linePoints.length - 2]) {
|
||||||
else ctx.lineTo(x, y);
|
linePoints.push(x, y);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ctx.strokeStyle = pal.waveformLine;
|
overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6);
|
||||||
ctx.lineWidth = 1.6;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||||||
if (holdMs > 0) {
|
if (holdMs > 0) {
|
||||||
ctx.beginPath();
|
const holdPoints = [];
|
||||||
for (let i = 0; i < samples.length; i++) {
|
for (let i = 0; i < samples.length; i++) {
|
||||||
let peak = samples[i].v;
|
let peak = samples[i].v;
|
||||||
for (let j = i; j >= 0; j--) {
|
for (let j = i; j >= 0; j--) {
|
||||||
@@ -1117,26 +1056,28 @@ function drawOverviewSignalHistory(ctx, w, h, pal) {
|
|||||||
}
|
}
|
||||||
const x = toX(samples[i].t);
|
const x = toX(samples[i].t);
|
||||||
const y = toY(peak);
|
const y = toY(peak);
|
||||||
if (i === 0) ctx.moveTo(x, y);
|
if (i === 0 || x >= holdPoints[holdPoints.length - 2]) {
|
||||||
else ctx.lineTo(x, y);
|
holdPoints.push(x, y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx.strokeStyle = pal.waveformPeak;
|
overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1);
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function waterfallColor(db, pal) {
|
function waterfallColorRgba(db, pal, minDb, maxDb) {
|
||||||
const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115;
|
const lo = Number.isFinite(minDb) ? minDb : (Number.isFinite(spectrumFloor) ? spectrumFloor : -115);
|
||||||
const maxDb = minDb + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90);
|
const hi = Number.isFinite(maxDb) ? maxDb : (lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90));
|
||||||
const safeDb = Number.isFinite(db) ? db : minDb;
|
const safeDb = Number.isFinite(db) ? db : lo;
|
||||||
const clamped = Math.max(minDb, Math.min(maxDb, safeDb));
|
const clamped = Math.max(lo, Math.min(hi, safeDb));
|
||||||
const span = Math.max(1, maxDb - minDb);
|
const span = Math.max(1, hi - lo);
|
||||||
const t = (clamped - minDb) / span;
|
const t = (clamped - lo) / span;
|
||||||
const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]);
|
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 light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]);
|
||||||
const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[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) {
|
function formatFreq(hz) {
|
||||||
@@ -5873,6 +5814,10 @@ window.addEventListener("beforeunload", () => {
|
|||||||
|
|
||||||
// ── Spectrum display ─────────────────────────────────────────────────────────
|
// ── Spectrum display ─────────────────────────────────────────────────────────
|
||||||
const spectrumCanvas = document.getElementById("spectrum-canvas");
|
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 spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
const spectrumTooltip = document.getElementById("spectrum-tooltip");
|
||||||
const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn");
|
const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn");
|
||||||
@@ -5881,6 +5826,7 @@ let spectrumSource = null;
|
|||||||
let spectrumReconnectTimer = null;
|
let spectrumReconnectTimer = null;
|
||||||
let spectrumDrawPending = false;
|
let spectrumDrawPending = false;
|
||||||
let spectrumAxisKey = "";
|
let spectrumAxisKey = "";
|
||||||
|
let spectrumDbAxisKey = "";
|
||||||
let lastSpectrumRenderData = null;
|
let lastSpectrumRenderData = null;
|
||||||
let spectrumPeakHoldFrames = [];
|
let spectrumPeakHoldFrames = [];
|
||||||
let pendingSpectrumFrameWaiters = [];
|
let pendingSpectrumFrameWaiters = [];
|
||||||
@@ -6198,7 +6144,6 @@ function startSpectrumStreaming() {
|
|||||||
clearSpectrumPeakHoldFrames();
|
clearSpectrumPeakHoldFrames();
|
||||||
overviewWaterfallRows = [];
|
overviewWaterfallRows = [];
|
||||||
overviewWaterfallPushCount = 0;
|
overviewWaterfallPushCount = 0;
|
||||||
_wfResetOffscreen();
|
|
||||||
scheduleOverviewDraw();
|
scheduleOverviewDraw();
|
||||||
clearSpectrumCanvas();
|
clearSpectrumCanvas();
|
||||||
updateRdsPsOverlay(null);
|
updateRdsPsOverlay(null);
|
||||||
@@ -6247,7 +6192,6 @@ function stopSpectrumStreaming() {
|
|||||||
clearSpectrumPeakHoldFrames();
|
clearSpectrumPeakHoldFrames();
|
||||||
overviewWaterfallRows = [];
|
overviewWaterfallRows = [];
|
||||||
overviewWaterfallPushCount = 0;
|
overviewWaterfallPushCount = 0;
|
||||||
_wfResetOffscreen();
|
|
||||||
scheduleOverviewDraw();
|
scheduleOverviewDraw();
|
||||||
updateRdsPsOverlay(null);
|
updateRdsPsOverlay(null);
|
||||||
clearSpectrumCanvas();
|
clearSpectrumCanvas();
|
||||||
@@ -6255,10 +6199,15 @@ function stopSpectrumStreaming() {
|
|||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────────────────────────
|
// ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
function clearSpectrumCanvas() {
|
function clearSpectrumCanvas() {
|
||||||
if (!spectrumCanvas) return;
|
if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
|
||||||
const ctx = spectrumCanvas.getContext("2d");
|
const cssW = spectrumCanvas.clientWidth || 1;
|
||||||
ctx.fillStyle = spectrumBgColor();
|
const cssH = spectrumCanvas.clientHeight || 1;
|
||||||
ctx.fillRect(0, 0, spectrumCanvas.width, spectrumCanvas.height);
|
spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1);
|
||||||
|
spectrumGl.clear(cssColorToRgba(spectrumBgColor()));
|
||||||
|
if (spectrumDbAxis) {
|
||||||
|
spectrumDbAxis.innerHTML = "";
|
||||||
|
spectrumDbAxisKey = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOverlayPs(ps) {
|
function formatOverlayPs(ps) {
|
||||||
@@ -6550,167 +6499,73 @@ function scheduleSpectrumDraw() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawSpectrum(data) {
|
function drawSpectrum(data) {
|
||||||
if (!spectrumCanvas) return;
|
if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
|
||||||
|
|
||||||
// HiDPI sizing
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const cssW = spectrumCanvas.clientWidth || 640;
|
||||||
const cssW = spectrumCanvas.clientWidth || 640;
|
|
||||||
const cssH = spectrumCanvas.clientHeight || 160;
|
const cssH = spectrumCanvas.clientHeight || 160;
|
||||||
const W = Math.round(cssW * dpr);
|
spectrumGl.ensureSize(cssW, cssH, dpr);
|
||||||
const H = Math.round(cssH * dpr);
|
const W = spectrumCanvas.width;
|
||||||
if (spectrumCanvas.width !== W || spectrumCanvas.height !== H) {
|
const H = spectrumCanvas.height;
|
||||||
spectrumCanvas.width = W;
|
|
||||||
spectrumCanvas.height = H;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = spectrumCanvas.getContext("2d");
|
const pal = canvasPalette();
|
||||||
const pal = canvasPalette();
|
|
||||||
const range = spectrumVisibleRange(data);
|
const range = spectrumVisibleRange(data);
|
||||||
const bins = data.bins;
|
const bins = data.bins;
|
||||||
const peakHoldBins = buildSpectrumPeakHoldBins(bins);
|
const peakHoldBins = buildSpectrumPeakHoldBins(bins);
|
||||||
const n = bins.length;
|
const n = bins.length;
|
||||||
|
|
||||||
// Background
|
|
||||||
ctx.fillStyle = pal.bg;
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
|
||||||
|
|
||||||
|
spectrumGl.clear(cssColorToRgba(pal.bg));
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
|
|
||||||
const DB_MIN = spectrumFloor;
|
const DB_MIN = spectrumFloor;
|
||||||
const DB_MAX = spectrumFloor + spectrumRange;
|
const DB_MAX = spectrumFloor + spectrumRange;
|
||||||
const dbRange = DB_MAX - DB_MIN;
|
const dbRange = DB_MAX - DB_MIN;
|
||||||
const fullSpanHz = data.sample_rate;
|
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 gridStep = spectrumRange > 100 ? 20 : 10;
|
||||||
|
const gridSegments = [];
|
||||||
for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
|
for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
|
||||||
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
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) {
|
function hzToX(hz) {
|
||||||
return ((hz - range.visLoHz) / range.visSpanHz) * W;
|
return ((hz - range.visLoHz) / range.visSpanHz) * W;
|
||||||
}
|
}
|
||||||
function binX(i) {
|
function binX(i) {
|
||||||
return hzToX(loHz + (i / (n - 1)) * fullSpanHz);
|
return hzToX(loHz + (i / (n - 1)) * fullSpanHz);
|
||||||
}
|
}
|
||||||
function binY(i) {
|
function binYFromBins(srcBins, i) {
|
||||||
const db = Math.max(DB_MIN, Math.min(DB_MAX, bins[i]));
|
const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i]));
|
||||||
return H * (1 - (db - DB_MIN) / dbRange);
|
return H * (1 - (db - DB_MIN) / dbRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BW strip (drawn before spectrum so traces appear on top) ──────────────
|
const fillPoints = [];
|
||||||
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);
|
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const x = binX(i), y = binY(i);
|
fillPoints.push(binX(i), binYFromBins(bins, i));
|
||||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
spectrumGl.drawFilledArea(fillPoints, H, cssColorToRgba(pal.spectrumFill));
|
||||||
ctx.restore();
|
|
||||||
|
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);
|
const markerPeaks = visibleSpectrumPeakIndices(data);
|
||||||
if (markerPeaks.length > 0) {
|
if (markerPeaks.length > 0) {
|
||||||
ctx.save();
|
const markerPoints = [];
|
||||||
ctx.fillStyle = pal.waveformPeak;
|
|
||||||
ctx.strokeStyle = pal.bg;
|
|
||||||
ctx.lineWidth = Math.max(1, dpr * 0.75);
|
|
||||||
const radius = Math.max(2, dpr * 1.6);
|
|
||||||
for (const idx of markerPeaks) {
|
for (const idx of markerPeaks) {
|
||||||
const x = binX(idx);
|
markerPoints.push(binX(idx), binYFromBins(bins, idx));
|
||||||
const y = binY(idx);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(x, y - radius * 0.35, radius, 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
ctx.restore();
|
spectrumGl.drawPoints(markerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpectrumFreqAxis(range);
|
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 ──────────────────────────────────────────────────────────────
|
// ── Zoom helpers ──────────────────────────────────────────────────────────────
|
||||||
function spectrumZoomAt(cssX, cssW, data, factor) {
|
function spectrumZoomAt(cssX, cssW, data, factor) {
|
||||||
const range = spectrumVisibleRange(data);
|
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>
|
<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">‹</button>
|
<button id="spectrum-center-left-btn" class="spectrum-edge-shift spectrum-edge-shift-left" type="button" aria-label="Shift spectrum center left">‹</button>
|
||||||
<canvas id="spectrum-canvas"></canvas>
|
<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">›</button>
|
<button id="spectrum-center-right-btn" class="spectrum-edge-shift spectrum-edge-shift-right" type="button" aria-label="Shift spectrum center right">›</button>
|
||||||
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
<div id="spectrum-bookmark-side-right" class="spectrum-bookmark-side spectrum-bookmark-side-right" aria-hidden="true"></div>
|
||||||
<div id="spectrum-tooltip"></div>
|
<div id="spectrum-tooltip"></div>
|
||||||
@@ -632,6 +633,7 @@
|
|||||||
<div class="hint" id="power-hint">Connecting…</div>
|
<div class="hint" id="power-hint">Connecting…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/webgl-renderer.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/ais.js"></script>
|
<script src="/ais.js"></script>
|
||||||
<script src="/vdes.js"></script>
|
<script src="/vdes.js"></script>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ const cwWpmInput = document.getElementById("cw-wpm");
|
|||||||
const cwToneInput = document.getElementById("cw-tone");
|
const cwToneInput = document.getElementById("cw-tone");
|
||||||
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
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 cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
const CW_MAX_LINES = 200;
|
const CW_MAX_LINES = 200;
|
||||||
@@ -88,7 +91,7 @@ function toneClampForRange(tone, range) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureCwToneCanvasResolution() {
|
function ensureCwToneCanvasResolution() {
|
||||||
if (!cwToneCanvas) return false;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
const rect = cwToneCanvas.getBoundingClientRect();
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
const cssWidth = Math.round(rect.width);
|
const cssWidth = Math.round(rect.width);
|
||||||
const cssHeight = Math.round(rect.height);
|
const cssHeight = Math.round(rect.height);
|
||||||
@@ -96,26 +99,16 @@ function ensureCwToneCanvasResolution() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const nextWidth = Math.round(cssWidth * dpr);
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
const nextHeight = Math.round(cssHeight * dpr);
|
|
||||||
if (cwToneCanvas.width !== nextWidth || cwToneCanvas.height !== nextHeight) {
|
|
||||||
cwToneCanvas.width = nextWidth;
|
|
||||||
cwToneCanvas.height = nextHeight;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawCwTonePicker() {
|
function drawCwTonePicker() {
|
||||||
if (!cwToneCanvas) return;
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
const ctx = cwToneCanvas.getContext("2d");
|
|
||||||
if (!ctx) return;
|
|
||||||
|
|
||||||
const width = cwToneCanvas.width;
|
const width = cwToneCanvas.width;
|
||||||
const height = cwToneCanvas.height;
|
const height = cwToneCanvas.height;
|
||||||
ctx.clearRect(0, 0, width, height);
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
const range = currentCwToneRange();
|
const range = currentCwToneRange();
|
||||||
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||||
@@ -127,8 +120,7 @@ function drawCwTonePicker() {
|
|||||||
cwToneRangeEl.textContent = "Waiting for spectrum";
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.fillStyle = "rgba(130, 150, 165, 0.22)";
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,77 +170,48 @@ function drawCwTonePicker() {
|
|||||||
|
|
||||||
const rootStyle = getComputedStyle(document.documentElement);
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
const axisColor = "rgba(230, 235, 245, 0.15)";
|
const parseColor = typeof window.trxParseCssColor === "function"
|
||||||
const textColor = "rgba(230, 235, 245, 0.58)";
|
? 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)";
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
const hGridCount = 4;
|
const hGridCount = 4;
|
||||||
ctx.strokeStyle = axisColor;
|
const gridSegments = [];
|
||||||
ctx.lineWidth = 1;
|
|
||||||
for (let i = 1; i <= hGridCount; i += 1) {
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
const y = Math.round((i / (hGridCount + 1)) * (height - 1)) + 0.5;
|
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||||
ctx.beginPath();
|
gridSegments.push(0, y, width, y);
|
||||||
ctx.moveTo(0, y);
|
|
||||||
ctx.lineTo(width, y);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
|
||||||
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||||
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
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`;
|
const tickSegments = [];
|
||||||
ctx.fillStyle = textColor;
|
|
||||||
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))) + 0.5;
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
ctx.beginPath();
|
tickSegments.push(x, 0, x, height);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
ctx.beginPath();
|
const linePoints = [];
|
||||||
ctx.moveTo(0, height - 0.5);
|
|
||||||
for (let x = 0; x < width; x += 1) {
|
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);
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
ctx.closePath();
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
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();
|
|
||||||
|
|
||||||
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
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))]);
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||||
ctx.fillStyle = "rgba(255, 255, 255, 0.9)";
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
ctx.fillRect(markerX, 0, 1.5, height);
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(markerX, markerY, Math.max(2, Math.round(height * 0.055)), 0, Math.PI * 2);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
if (cwAutoInput?.checked) {
|
if (cwAutoInput?.checked) {
|
||||||
ctx.fillStyle = "rgba(0, 0, 0, 0.22)";
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2182,6 +2182,28 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
touch-action: none;
|
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 {
|
.spectrum-edge-shift {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc((var(--spectrum-plot-height) - var(--overview-plot-height)) / 2);
|
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(logo)
|
||||||
.service(style_css)
|
.service(style_css)
|
||||||
.service(app_js)
|
.service(app_js)
|
||||||
|
.service(webgl_renderer_js)
|
||||||
.service(leaflet_ais_tracksymbol_js)
|
.service(leaflet_ais_tracksymbol_js)
|
||||||
.service(ais_js)
|
.service(ais_js)
|
||||||
.service(vdes_js)
|
.service(vdes_js)
|
||||||
@@ -1067,6 +1068,16 @@ async fn app_js() -> impl Responder {
|
|||||||
.body(status::APP_JS)
|
.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")]
|
#[get("/leaflet-ais-tracksymbol.js")]
|
||||||
async fn leaflet_ais_tracksymbol_js() -> impl Responder {
|
async fn leaflet_ais_tracksymbol_js() -> impl Responder {
|
||||||
HttpResponse::Ok()
|
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");
|
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
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 =
|
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
||||||
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
||||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
||||||
|
|||||||
Reference in New Issue
Block a user