refactor: convert WebGL renderer to TypeScript

This commit is contained in:
sjg
2026-08-01 12:07:02 +02:00
parent a58553ba66
commit 9ac6d7f82c
4 changed files with 231 additions and 151 deletions
@@ -19,9 +19,9 @@
return cssColorProbe;
}
function parseRgbString(value) {
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
const m = /^rgba?\(([^)]+)\)$/.exec(value.trim());
if (!m) return null;
const parts = m[1].split(",").map((p) => p.trim());
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]);
@@ -36,7 +36,7 @@
];
}
function parseHexColor(value) {
const raw = String(value || "").trim();
const raw = 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) {
@@ -50,8 +50,9 @@
return [r, g, b, a];
}
function parseCssColor(value) {
const key = String(value ?? "");
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
const key = value;
const cached = cssColorCache.get(key);
if (cached) return [...cached];
let parsed = parseHexColor(key) || parseRgbString(key);
if (!parsed) {
const probe = ensureCssColorProbe();
@@ -60,13 +61,13 @@
const computed = getComputedStyle(probe).color;
parsed = parseRgbString(computed) || [0, 0, 0, 1];
}
cssColorCache.set(key, parsed.slice());
return parsed.slice();
cssColorCache.set(key, [...parsed]);
return [...parsed];
}
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 hue = ((h || 0) % 360 + 360) % 360 / 360;
const sat = Math.max(0, Math.min(1, (s || 0) / 100));
const lig = Math.max(0, Math.min(1, (l || 0) / 100));
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
const p = 2 * lig - q;
const hueToRgb = (t) => {
@@ -81,28 +82,26 @@
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)))];
return [r, g, b, Math.max(0, Math.min(1, a))];
}
function normalizeColor(input, alphaMul = 1) {
let rgba;
if (Array.isArray(input)) {
const arr = input.map((v) => Number(v));
const arr = input;
if (arr.length >= 4) {
rgba = [arr[0], arr[1], arr[2], arr[3]];
rgba = [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 1];
} 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];
rgba = [
input.r || 0,
input.g || 0,
input.b || 0,
input.a ?? 1
];
}
const out = [
Math.max(0, Math.min(1, rgba[0])),
@@ -114,6 +113,7 @@
}
function compileShader(gl, type, source) {
const shader = gl.createShader(type);
if (shader === null) throw new Error("Unable to create WebGL shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
@@ -161,17 +161,28 @@
pushColoredVertex(out, dx2, dy2, rgba);
}
class TrxWebGlRenderer {
canvas;
options;
gl;
ready;
textures = /* @__PURE__ */ new Map();
_colorScratch = new Float32Array(4096 * 6);
_colorGpuSize = 0;
_texScratch = new Float32Array(6 * 4);
colorProgram;
colorBuffer;
colorLoc;
textureProgram;
textureBuffer;
textureLoc;
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.gl = canvas.getContext("webgl", this.options) || canvas.getContext("experimental-webgl", this.options);
this.ready = !!this.gl;
this.textures = /* @__PURE__ */ new Map();
this._colorScratch = new Float32Array(4096 * 6);
this._colorGpuSize = 0;
this._texScratch = new Float32Array(6 * 4);
if (!this.ready) return;
const gl = this.gl;
if (!gl) return;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
@@ -203,7 +214,7 @@
};
}
ensureSize(cssWidth, cssHeight, dpr = window.devicePixelRatio || 1) {
if (!this.ready) return false;
if (!this.gl) 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;
@@ -215,20 +226,22 @@
return changed;
}
clear(color) {
if (!this.ready) return;
if (!this.gl) 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) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
}
drawTriangleStrip(vertices) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
}
_drawColorGeometry(vertices, mode) {
if (!this.ready || !vertices || vertices.length === 0) return;
if (!this.gl || vertices.length === 0) return;
const gl = this.gl;
const count = vertices.length;
if (count > this._colorScratch.length) {
@@ -263,7 +276,7 @@
pushColoredVertex(v, x, y, rgba);
pushColoredVertex(v, x + w, y + h, rgba);
pushColoredVertex(v, x, y + h, rgba);
this._drawColorGeometry(v, this.gl.TRIANGLES);
this.drawTriangles(v);
}
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
if (w <= 0 || h <= 0) return;
@@ -278,62 +291,62 @@
pushColoredVertex(v, x, y, tl);
pushColoredVertex(v, x + w, y + h, br);
pushColoredVertex(v, x, y + h, bl);
this._drawColorGeometry(v, this.gl.TRIANGLES);
this.drawTriangles(v);
}
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 halfW = Math.max(0.5, 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],
points[i] ?? 0,
points[i + 1] ?? 0,
points[i + 2] ?? 0,
points[i + 3] ?? 0,
halfW,
rgba
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
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 halfW = Math.max(0.5, 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],
segments[i] ?? 0,
segments[i + 1] ?? 0,
segments[i + 2] ?? 0,
segments[i + 3] ?? 0,
halfW,
rgba
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
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);
pushColoredVertex(verts, points[i] ?? 0, baselineY, rgba);
pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
this.drawTriangleStrip(verts);
}
drawPoints(points, size, color) {
if (!Array.isArray(points) || points.length < 2) return;
const radius = Math.max(1, Number(size) || 1);
const radius = Math.max(1, size || 1);
const rgba = normalizeColor(color);
const verts = [];
for (let i = 0; i < points.length; i += 2) {
const x = points[i] - radius;
const y = points[i + 1] - radius;
const x = (points[i] ?? 0) - radius;
const y = (points[i + 1] ?? 0) - radius;
const w = radius * 2;
const h = radius * 2;
pushColoredVertex(verts, x, y, rgba);
@@ -343,11 +356,11 @@
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y + h, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
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 dash = Math.max(1, dashLen || 1);
const gap = Math.max(1, gapLen || 1);
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const segments = [];
@@ -358,7 +371,7 @@
this.drawSegments(segments, color, width);
}
uploadRgbaTexture(name, width, height, data, filter = "linear") {
if (!this.ready || !name || !data) return null;
if (!this.gl || !name) return null;
const gl = this.gl;
let entry = this.textures.get(name);
if (!entry) {
@@ -403,7 +416,7 @@
return entry.texture;
}
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
if (!this.ready || !name || w <= 0 || h <= 0) return;
if (!this.gl || !name || w <= 0 || h <= 0) return;
const entry = this.textures.get(name);
if (!entry) return;
const gl = this.gl;
@@ -468,14 +481,14 @@
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.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, 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) {
function createRenderer(canvas, options = {}) {
return new TrxWebGlRenderer(canvas, options);
}
global.trxParseCssColor = parseCssColor;
@@ -20,7 +20,7 @@ await build({
"ui-core": path.join(sourceDir, "ui-core.ts"),
"map-core": path.join(sourceDir, "map-core.js"),
screenshot: path.join(sourceDir, "screenshot.js"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.js"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"),
ais: path.join(sourceDir, "plugins", "ais.js"),
aprs: path.join(sourceDir, "plugins", "aprs.js"),
@@ -2,17 +2,39 @@
//
// SPDX-License-Identifier: GPL-2.0-or-later
(function initTrxWebGl(global) {
type Rgba = [number, number, number, number];
type ColorInput = string | number[] | { r: number; g: number; b: number; a?: number };
interface TextureEntry { texture: WebGLTexture | null; width: number; height: number }
interface ColorLocations {
pos: number;
color: number;
resolution: WebGLUniformLocation | null;
}
interface TextureLocations {
pos: number;
uv: number;
resolution: WebGLUniformLocation | null;
alpha: WebGLUniformLocation | null;
tex: WebGLUniformLocation | null;
}
interface TrxWebGlGlobals {
trxParseCssColor?: (value: string) => Rgba;
trxHslToRgba?: (h: number, s: number, l: number, a?: number) => Rgba;
createTrxWebGlRenderer?: (canvas: HTMLCanvasElement, options?: WebGLContextAttributes) => object;
trxClearCssColorCache?: () => void;
}
(function initTrxWebGl(global: TrxWebGlGlobals) {
"use strict";
const cssColorCache = new Map();
let cssColorProbe = null;
const cssColorCache = new Map<string, Rgba>();
let cssColorProbe: HTMLSpanElement | null = null;
function clearCssColorCache() {
cssColorCache.clear();
}
function ensureCssColorProbe() {
function ensureCssColorProbe(): HTMLSpanElement {
if (cssColorProbe) return cssColorProbe;
const el = document.createElement("span");
el.style.position = "absolute";
@@ -25,10 +47,10 @@
return cssColorProbe;
}
function parseRgbString(value) {
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
function parseRgbString(value: string): Rgba | null {
const m = /^rgba?\(([^)]+)\)$/.exec(value.trim());
if (!m) return null;
const parts = m[1].split(",").map((p) => p.trim());
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]);
@@ -43,8 +65,8 @@
];
}
function parseHexColor(value) {
const raw = String(value || "").trim();
function parseHexColor(value: string): Rgba | null {
const raw = 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) {
@@ -58,9 +80,10 @@
return [r, g, b, a];
}
function parseCssColor(value) {
const key = String(value ?? "");
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
function parseCssColor(value: string): Rgba {
const key = value;
const cached = cssColorCache.get(key);
if (cached) return [...cached];
let parsed = parseHexColor(key) || parseRgbString(key);
if (!parsed) {
@@ -70,18 +93,18 @@
const computed = getComputedStyle(probe).color;
parsed = parseRgbString(computed) || [0, 0, 0, 1];
}
cssColorCache.set(key, parsed.slice());
return parsed.slice();
cssColorCache.set(key, [...parsed]);
return [...parsed];
}
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));
function hslToRgba(h: number, s: number, l: number, a = 1): Rgba {
const hue = ((((h || 0) % 360) + 360) % 360) / 360;
const sat = Math.max(0, Math.min(1, (s || 0) / 100));
const lig = Math.max(0, Math.min(1, (l || 0) / 100));
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
const p = 2 * lig - q;
const hueToRgb = (t) => {
const hueToRgb = (t: number): number => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
@@ -94,31 +117,29 @@
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)))];
return [r, g, b, Math.max(0, Math.min(1, a))];
}
function normalizeColor(input, alphaMul = 1) {
let rgba;
function normalizeColor(input: ColorInput, alphaMul = 1): Rgba {
let rgba: Rgba;
if (Array.isArray(input)) {
const arr = input.map((v) => Number(v));
const arr = input;
if (arr.length >= 4) {
rgba = [arr[0], arr[1], arr[2], arr[3]];
rgba = [arr[0] ?? 0, arr[1] ?? 0, arr[2] ?? 0, arr[3] ?? 1];
} 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];
rgba = [
input.r || 0,
input.g || 0,
input.b || 0,
input.a ?? 1,
];
}
const out = [
const out: Rgba = [
Math.max(0, Math.min(1, rgba[0])),
Math.max(0, Math.min(1, rgba[1])),
Math.max(0, Math.min(1, rgba[2])),
@@ -127,8 +148,9 @@
return out;
}
function compileShader(gl, type, source) {
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type);
if (shader === null) throw new Error("Unable to create WebGL shader");
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
@@ -139,7 +161,7 @@
return shader;
}
function createProgram(gl, vertexSrc, fragmentSrc) {
function createProgram(gl: WebGLRenderingContext, vertexSrc: string, fragmentSrc: string): WebGLProgram {
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
const program = gl.createProgram();
@@ -156,11 +178,11 @@
return program;
}
function pushColoredVertex(target, x, y, rgba) {
function pushColoredVertex(target: number[], x: number, y: number, rgba: Rgba) {
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
}
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
function segmentToQuadVertices(out: number[], x0: number, y0: number, x1: number, y1: number, halfW: number, rgba: Rgba) {
const dx = x1 - x0;
const dy = y1 - y0;
const len = Math.hypot(dx, dy);
@@ -183,23 +205,33 @@
}
class TrxWebGlRenderer {
constructor(canvas, options = {}) {
readonly canvas: HTMLCanvasElement;
readonly options: WebGLContextAttributes;
readonly gl: WebGLRenderingContext | null;
readonly ready: boolean;
readonly textures = new Map<string, TextureEntry>();
private _colorScratch = new Float32Array(4096 * 6);
private _colorGpuSize = 0;
private readonly _texScratch = new Float32Array(6 * 4);
private colorProgram!: WebGLProgram;
private colorBuffer!: WebGLBuffer | null;
private colorLoc!: ColorLocations;
private textureProgram!: WebGLProgram;
private textureBuffer!: WebGLBuffer | null;
private textureLoc!: TextureLocations;
constructor(canvas: HTMLCanvasElement, options: WebGLContextAttributes = {}) {
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.gl = (canvas.getContext("webgl", this.options) ||
canvas.getContext("experimental-webgl", this.options)) as WebGLRenderingContext | null;
this.ready = !!this.gl;
this.textures = new Map();
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
this._colorGpuSize = 0; // current GPU buffer size (floats)
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
if (!this.ready) return;
const gl = this.gl;
if (!gl) return;
gl.disable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE);
gl.enable(gl.BLEND);
@@ -268,8 +300,8 @@
};
}
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
if (!this.ready) return false;
ensureSize(cssWidth: number, cssHeight: number, dpr = (window.devicePixelRatio || 1)): boolean {
if (!this.gl) 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;
@@ -281,24 +313,26 @@
return changed;
}
clear(color) {
if (!this.ready) return;
clear(color: ColorInput) {
if (!this.gl) 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) {
drawTriangles(vertices: number[]) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
}
drawTriangleStrip(vertices) {
drawTriangleStrip(vertices: number[]) {
if (!this.gl) return;
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
}
_drawColorGeometry(vertices, mode) {
if (!this.ready || !vertices || vertices.length === 0) return;
private _drawColorGeometry(vertices: number[], mode: number) {
if (!this.gl || vertices.length === 0) return;
const gl = this.gl;
const count = vertices.length;
@@ -333,88 +367,88 @@
gl.drawArrays(mode, 0, count / 6);
}
fillRect(x, y, w, h, color) {
fillRect(x: number, y: number, w: number, h: number, color: ColorInput) {
if (w <= 0 || h <= 0) return;
const rgba = normalizeColor(color);
const v = [];
const v: number[] = [];
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);
this.drawTriangles(v);
}
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
fillGradientRect(x: number, y: number, w: number, h: number, colorTL: ColorInput, colorTR: ColorInput, colorBR: ColorInput, colorBL: ColorInput) {
if (w <= 0 || h <= 0) return;
const tl = normalizeColor(colorTL);
const tr = normalizeColor(colorTR);
const br = normalizeColor(colorBR);
const bl = normalizeColor(colorBL);
const v = [];
const v: number[] = [];
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);
this.drawTriangles(v);
}
drawPolyline(points, color, width = 1) {
drawPolyline(points: number[], color: ColorInput, 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 = [];
const halfW = Math.max(0.5, width || 1) / 2;
const verts: number[] = [];
for (let i = 0; i < points.length - 2; i += 2) {
segmentToQuadVertices(
verts,
points[i], points[i + 1],
points[i + 2], points[i + 3],
points[i] ?? 0, points[i + 1] ?? 0,
points[i + 2] ?? 0, points[i + 3] ?? 0,
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
drawSegments(segments, color, width = 1) {
drawSegments(segments: number[], color: ColorInput, 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 = [];
const halfW = Math.max(0.5, width || 1) / 2;
const verts: number[] = [];
for (let i = 0; i < segments.length - 3; i += 4) {
segmentToQuadVertices(
verts,
segments[i], segments[i + 1],
segments[i + 2], segments[i + 3],
segments[i] ?? 0, segments[i + 1] ?? 0,
segments[i + 2] ?? 0, segments[i + 3] ?? 0,
halfW,
rgba,
);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
drawFilledArea(points, baselineY, color) {
drawFilledArea(points: number[], baselineY: number, color: ColorInput) {
if (!Array.isArray(points) || points.length < 4) return;
const rgba = normalizeColor(color);
const verts = [];
const verts: number[] = [];
for (let i = 0; i < points.length; i += 2) {
pushColoredVertex(verts, points[i], baselineY, rgba);
pushColoredVertex(verts, points[i], points[i + 1], rgba);
pushColoredVertex(verts, points[i] ?? 0, baselineY, rgba);
pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
this.drawTriangleStrip(verts);
}
drawPoints(points, size, color) {
drawPoints(points: number[], size: number, color: ColorInput) {
if (!Array.isArray(points) || points.length < 2) return;
const radius = Math.max(1, Number(size) || 1);
const radius = Math.max(1, size || 1);
const rgba = normalizeColor(color);
const verts = [];
const verts: number[] = [];
for (let i = 0; i < points.length; i += 2) {
const x = points[i] - radius;
const y = points[i + 1] - radius;
const x = (points[i] ?? 0) - radius;
const y = (points[i + 1] ?? 0) - radius;
const w = radius * 2;
const h = radius * 2;
pushColoredVertex(verts, x, y, rgba);
@@ -424,15 +458,15 @@
pushColoredVertex(verts, x + w, y + h, rgba);
pushColoredVertex(verts, x, y + h, rgba);
}
this._drawColorGeometry(verts, this.gl.TRIANGLES);
this.drawTriangles(verts);
}
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);
drawDashedVerticalLine(x: number, y0: number, y1: number, dashLen: number, gapLen: number, color: ColorInput, width = 1) {
const dash = Math.max(1, dashLen || 1);
const gap = Math.max(1, gapLen || 1);
const top = Math.min(y0, y1);
const bottom = Math.max(y0, y1);
const segments = [];
const segments: number[] = [];
for (let y = top; y < bottom; y += dash + gap) {
const segEnd = Math.min(bottom, y + dash);
segments.push(x, y, x, segEnd);
@@ -440,8 +474,8 @@
this.drawSegments(segments, color, width);
}
uploadRgbaTexture(name, width, height, data, filter = "linear") {
if (!this.ready || !name || !data) return null;
uploadRgbaTexture(name: string, width: number, height: number, data: ArrayBufferView, filter: "linear" | "nearest" = "linear"): WebGLTexture | null {
if (!this.gl || !name) return null;
const gl = this.gl;
let entry = this.textures.get(name);
if (!entry) {
@@ -486,8 +520,8 @@
return entry.texture;
}
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
if (!this.ready || !name || w <= 0 || h <= 0) return;
drawTexture(name: string, x: number, y: number, w: number, h: number, alpha = 1, flipY = true) {
if (!this.gl || !name || w <= 0 || h <= 0) return;
const entry = this.textures.get(name);
if (!entry) return;
const gl = this.gl;
@@ -516,7 +550,7 @@
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.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, alpha || 0)));
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
gl.uniform1i(this.textureLoc.tex, 0);
@@ -524,7 +558,7 @@
}
}
function createRenderer(canvas, options) {
function createRenderer(canvas: HTMLCanvasElement, options: WebGLContextAttributes = {}) {
return new TrxWebGlRenderer(canvas, options);
}
@@ -532,4 +566,4 @@
global.trxHslToRgba = hslToRgba;
global.createTrxWebGlRenderer = createRenderer;
global.trxClearCssColorCache = clearCssColorCache;
})(window);
})(window as TrxWebGlGlobals);
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
test("WebGL color helpers normalize CSS and HSL colors", async () => {
const window = {};
const context = vm.createContext({
window,
document: {
body: { appendChild() {} },
createElement: () => ({ style: {} }),
},
getComputedStyle: () => ({ color: "rgb(0, 0, 0)" }),
Map,
Math,
Number,
String,
parseInt,
});
const source = await readFile(
new URL("../../assets/web/generated/webgl-renderer.js", import.meta.url),
"utf8",
);
new vm.Script(source).runInContext(context);
assert.deepEqual(Array.from(window.trxParseCssColor("#ff800080")), [1, 128 / 255, 0, 128 / 255]);
assert.deepEqual(Array.from(window.trxHslToRgba(0, 100, 50)), [1, 0, 0, 1]);
});