[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,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);
|
||||
Reference in New Issue
Block a user