[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
@@ -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);