/bandplan.json needed the control role. Route access is decided by suffix for static assets — .js, .css, .png and so on — and ".json" is not among them, so the band plan matched nothing and fell through to the catch-all. It is compiled into the binary and identical for every user, so it is public now, like the rest of them. Two things followed from that. Read-only sessions never saw a band plan at all. And since the page asks for it during startup, the request can land before the session is established: that 401 was swallowed by an empty catch and never retried, which is why the allocations sometimes only appeared after a manual reload. So the client no longer hides the failure, retries once the auth gate clears — which is exactly when a startup 401 becomes fixable — and schedules a draw when the data lands, since the strip is painted from the spectrum draw and a rig sitting between frames would stay blank. The fixture can now refuse the first request the way the server did, and the spectrum layout test holds the client to recovering from it. Signed-off-by: Stan Grams <sjg@haxx.space>
9509 lines
356 KiB
JavaScript
9509 lines
356 KiB
JavaScript
// src/webgl-renderer.ts
|
||
(function initTrxWebGl(global) {
|
||
"use strict";
|
||
const cssColorCache = /* @__PURE__ */ new Map();
|
||
let cssColorProbe = null;
|
||
function clearCssColorCache() {
|
||
cssColorCache.clear();
|
||
}
|
||
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(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 = 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 = value;
|
||
const cached = cssColorCache.get(key);
|
||
if (cached) return [...cached];
|
||
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]);
|
||
return [...parsed];
|
||
}
|
||
function hslToRgba(h, s, l, a = 1) {
|
||
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) => {
|
||
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, a))];
|
||
}
|
||
function normalizeColor(input, alphaMul = 1) {
|
||
let rgba;
|
||
if (Array.isArray(input)) {
|
||
const arr = input;
|
||
if (arr.length >= 4) {
|
||
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 {
|
||
rgba = [
|
||
input.r || 0,
|
||
input.g || 0,
|
||
input.b || 0,
|
||
input.a ?? 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);
|
||
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)) {
|
||
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 > 1e-4)) 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 {
|
||
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);
|
||
this.ready = !!this.gl;
|
||
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);
|
||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||
const colorVertexSrc = "attribute vec2 a_pos;\nattribute vec4 a_color;\nuniform vec2 u_resolution;\nvarying vec4 v_color;\nvoid 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;\nvarying vec4 v_color;\nvoid main() {\n gl_FragColor = v_color;\n}\n";
|
||
const textureVertexSrc = "attribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec2 u_resolution;\nvarying vec2 v_uv;\nvoid 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;\nvarying vec2 v_uv;\nuniform sampler2D u_tex;\nuniform float u_alpha;\nvoid 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();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||
this._colorGpuSize = this._colorScratch.length;
|
||
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();
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
|
||
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.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;
|
||
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.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.gl || vertices.length === 0) return;
|
||
const gl = this.gl;
|
||
const count = vertices.length;
|
||
if (count > this._colorScratch.length) {
|
||
let newLen = this._colorScratch.length;
|
||
while (newLen < count) newLen *= 2;
|
||
this._colorScratch = new Float32Array(newLen);
|
||
}
|
||
this._colorScratch.set(vertices);
|
||
const view = this._colorScratch.subarray(0, count);
|
||
gl.useProgram(this.colorProgram);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||
if (count > this._colorGpuSize) {
|
||
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||
this._colorGpuSize = this._colorScratch.length;
|
||
} else {
|
||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
|
||
}
|
||
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, count / 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.drawTriangles(v);
|
||
}
|
||
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.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, width || 1) / 2;
|
||
const verts = [];
|
||
for (let i = 0; i < points.length - 2; i += 2) {
|
||
segmentToQuadVertices(
|
||
verts,
|
||
points[i] ?? 0,
|
||
points[i + 1] ?? 0,
|
||
points[i + 2] ?? 0,
|
||
points[i + 3] ?? 0,
|
||
halfW,
|
||
rgba
|
||
);
|
||
}
|
||
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, width || 1) / 2;
|
||
const verts = [];
|
||
for (let i = 0; i < segments.length - 3; i += 4) {
|
||
segmentToQuadVertices(
|
||
verts,
|
||
segments[i] ?? 0,
|
||
segments[i + 1] ?? 0,
|
||
segments[i + 2] ?? 0,
|
||
segments[i + 3] ?? 0,
|
||
halfW,
|
||
rgba
|
||
);
|
||
}
|
||
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] ?? 0, baselineY, rgba);
|
||
pushColoredVertex(verts, points[i] ?? 0, points[i + 1] ?? 0, rgba);
|
||
}
|
||
this.drawTriangleStrip(verts);
|
||
}
|
||
drawPoints(points, size, color) {
|
||
if (!Array.isArray(points) || points.length < 2) return;
|
||
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] ?? 0) - radius;
|
||
const y = (points[i + 1] ?? 0) - radius;
|
||
const w = radius * 2;
|
||
const h = radius * 2;
|
||
pushColoredVertex(verts, x, y, rgba);
|
||
pushColoredVertex(verts, x + w, y, rgba);
|
||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||
pushColoredVertex(verts, x, y, rgba);
|
||
pushColoredVertex(verts, x + w, y + h, rgba);
|
||
pushColoredVertex(verts, x, y + h, rgba);
|
||
}
|
||
this.drawTriangles(verts);
|
||
}
|
||
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, 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 = [];
|
||
for (let y = top; y < bottom; y += dash + gap) {
|
||
const segEnd = Math.min(bottom, y + dash);
|
||
segments.push(x, y, x, segEnd);
|
||
}
|
||
this.drawSegments(segments, color, width);
|
||
}
|
||
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||
if (!this.gl || !name) 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.gl || !name || w <= 0 || h <= 0) return;
|
||
const entry = this.textures.get(name);
|
||
if (!entry) return;
|
||
const gl = this.gl;
|
||
const s = this._texScratch;
|
||
const x2 = x + w, y2 = y + h;
|
||
if (flipY) {
|
||
s[0] = x;
|
||
s[1] = y;
|
||
s[2] = 0;
|
||
s[3] = 1;
|
||
s[4] = x2;
|
||
s[5] = y;
|
||
s[6] = 1;
|
||
s[7] = 1;
|
||
s[8] = x2;
|
||
s[9] = y2;
|
||
s[10] = 1;
|
||
s[11] = 0;
|
||
s[12] = x;
|
||
s[13] = y;
|
||
s[14] = 0;
|
||
s[15] = 1;
|
||
s[16] = x2;
|
||
s[17] = y2;
|
||
s[18] = 1;
|
||
s[19] = 0;
|
||
s[20] = x;
|
||
s[21] = y2;
|
||
s[22] = 0;
|
||
s[23] = 0;
|
||
} else {
|
||
s[0] = x;
|
||
s[1] = y;
|
||
s[2] = 0;
|
||
s[3] = 0;
|
||
s[4] = x2;
|
||
s[5] = y;
|
||
s[6] = 1;
|
||
s[7] = 0;
|
||
s[8] = x2;
|
||
s[9] = y2;
|
||
s[10] = 1;
|
||
s[11] = 1;
|
||
s[12] = x;
|
||
s[13] = y;
|
||
s[14] = 0;
|
||
s[15] = 0;
|
||
s[16] = x2;
|
||
s[17] = y2;
|
||
s[18] = 1;
|
||
s[19] = 1;
|
||
s[20] = x;
|
||
s[21] = y2;
|
||
s[22] = 0;
|
||
s[23] = 1;
|
||
}
|
||
gl.useProgram(this.textureProgram);
|
||
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
|
||
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, 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;
|
||
global.trxClearCssColorCache = clearCssColorCache;
|
||
})(window);
|
||
|
||
// src/ui-core.ts
|
||
var browserWindow = window;
|
||
var preparedTabLists = /* @__PURE__ */ new WeakSet();
|
||
function elementById(id) {
|
||
const element = document.getElementById(id);
|
||
if (!element) throw new Error(`Missing required UI element #${id}`);
|
||
return element;
|
||
}
|
||
(function initUiCore() {
|
||
const api = browserWindow.trxUi ?? {};
|
||
browserWindow.trxUi = api;
|
||
function ensureLiveRegions() {
|
||
if (!document.getElementById("toast-region")) {
|
||
const region = document.createElement("div");
|
||
region.id = "toast-region";
|
||
region.className = "toast-region";
|
||
region.setAttribute("aria-live", "polite");
|
||
region.setAttribute("aria-atomic", "false");
|
||
document.body.appendChild(region);
|
||
}
|
||
if (!document.getElementById("ui-confirm-dialog")) {
|
||
const dialog = document.createElement("dialog");
|
||
dialog.id = "ui-confirm-dialog";
|
||
dialog.className = "ui-dialog";
|
||
dialog.innerHTML = `
|
||
<form method="dialog" class="ui-dialog-card">
|
||
<h2 id="ui-confirm-title">Confirm action</h2>
|
||
<p id="ui-confirm-message"></p>
|
||
<div class="ui-dialog-actions">
|
||
<button value="cancel" type="submit">Cancel</button>
|
||
<button value="confirm" type="submit" class="danger">Confirm</button>
|
||
</div>
|
||
</form>`;
|
||
document.body.appendChild(dialog);
|
||
}
|
||
}
|
||
api.notify = function notify(message, options = {}) {
|
||
ensureLiveRegions();
|
||
const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options;
|
||
const toast = document.createElement("div");
|
||
toast.className = `toast toast-${kind}`;
|
||
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||
const text = document.createElement("span");
|
||
text.textContent = message;
|
||
toast.appendChild(text);
|
||
if (action && typeof action.run === "function") {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = action.label || "Retry";
|
||
button.addEventListener("click", () => {
|
||
action.run();
|
||
toast.remove();
|
||
});
|
||
toast.appendChild(button);
|
||
}
|
||
elementById("toast-region").appendChild(toast);
|
||
requestAnimationFrame(() => {
|
||
toast.classList.add("toast-visible");
|
||
});
|
||
if (duration > 0) setTimeout(() => {
|
||
toast.remove();
|
||
}, duration);
|
||
return toast;
|
||
};
|
||
api.confirm = function confirmAction(options = {}) {
|
||
ensureLiveRegions();
|
||
const dialog = elementById("ui-confirm-dialog");
|
||
elementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||
elementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||
const confirmButton = dialog.querySelector('[value="confirm"]');
|
||
if (!confirmButton) throw new Error("Confirmation dialog has no confirm button");
|
||
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||
confirmButton.classList.toggle("danger", options.danger !== false);
|
||
return new Promise((resolve) => {
|
||
const finish = () => {
|
||
resolve(dialog.returnValue === "confirm");
|
||
};
|
||
dialog.addEventListener("close", finish, { once: true });
|
||
dialog.showModal();
|
||
});
|
||
};
|
||
api.setButtonState = function setButtonState(button, options = {}) {
|
||
if (!button) return;
|
||
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
||
button.classList.toggle("is-active", active);
|
||
button.classList.toggle("is-busy", busy);
|
||
button.setAttribute("aria-pressed", String(active));
|
||
button.setAttribute("aria-busy", String(busy));
|
||
button.disabled = disabled || busy;
|
||
const label = active ? activeLabel : inactiveLabel;
|
||
if (label) button.textContent = label;
|
||
};
|
||
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
||
if (!bar) return;
|
||
if (preparedTabLists.has(bar)) return;
|
||
preparedTabLists.add(bar);
|
||
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
||
const buttons = Array.from(bar.querySelectorAll(selector));
|
||
bar.setAttribute("role", "tablist");
|
||
buttons.forEach((button, index) => {
|
||
button.setAttribute("role", "tab");
|
||
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
||
button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1;
|
||
const key = button.dataset.tab || button.dataset.subtab;
|
||
if (!key) return;
|
||
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||
if (panel) {
|
||
if (!button.id) button.id = `${kind}-tab-${key}`;
|
||
panel.setAttribute("role", "tabpanel");
|
||
panel.setAttribute("aria-labelledby", button.id);
|
||
}
|
||
});
|
||
bar.addEventListener("keydown", (event) => {
|
||
if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return;
|
||
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||
if (!direction) return;
|
||
event.preventDefault();
|
||
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||
if (!next) return;
|
||
next.focus();
|
||
next.click();
|
||
});
|
||
};
|
||
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
|
||
if (!bar) return;
|
||
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||
const active = tab === selected;
|
||
tab.setAttribute("aria-selected", String(active));
|
||
tab.tabIndex = active ? 0 : -1;
|
||
});
|
||
};
|
||
const layouts = {
|
||
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
||
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
|
||
};
|
||
const layoutCapabilities = { broadcast: false, digital: false };
|
||
let activeRigId = null;
|
||
const layoutSections = [
|
||
{ id: "advanced-radio-controls", key: "advanced" },
|
||
{ id: "audio-controls", key: "audio" },
|
||
{ id: "scheduler-controls", key: "scheduler" }
|
||
];
|
||
const seededSections = /* @__PURE__ */ new Set();
|
||
let appliedLayoutName = null;
|
||
function seedLayoutSections(layout, layoutChanged) {
|
||
layoutSections.forEach(({ id, key }) => {
|
||
const section = document.getElementById(id);
|
||
if (!section) return;
|
||
if (!layoutChanged && seededSections.has(id)) return;
|
||
seededSections.add(id);
|
||
section.open = layout[key];
|
||
});
|
||
}
|
||
function layoutStorageKey() {
|
||
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
||
}
|
||
function savedLayoutName() {
|
||
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||
}
|
||
function layoutAvailable(layout) {
|
||
return !layout.capability || layoutCapabilities[layout.capability];
|
||
}
|
||
function unavailableLayoutMessage() {
|
||
const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable);
|
||
return unavailable.length ? `Unavailable: ${unavailable.map((layout) => layout.unavailable).join("; ")}.` : "";
|
||
}
|
||
function refreshLayoutOptions() {
|
||
const select = document.getElementById("operator-layout-select");
|
||
if (!select) return;
|
||
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
||
select.replaceChildren();
|
||
Object.entries(layouts).forEach(([value, layout]) => {
|
||
if (!layoutAvailable(layout)) return;
|
||
select.add(new Option(layout.label, value));
|
||
});
|
||
const available = Array.from(select.options).some((option) => option.value === previous);
|
||
select.value = available ? previous : "compact";
|
||
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
|
||
select.title = unavailableLayoutMessage();
|
||
}
|
||
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
|
||
Object.keys(layoutCapabilities).forEach((name) => {
|
||
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
||
});
|
||
refreshLayoutOptions();
|
||
const select = document.getElementById("operator-layout-select");
|
||
const saved = savedLayoutName();
|
||
if (select && Array.from(select.options).some((option) => option.value === saved)) {
|
||
select.value = saved;
|
||
api.applyLayout(saved, { persist: false });
|
||
}
|
||
};
|
||
api.setActiveRig = function setActiveRig(rigId) {
|
||
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
|
||
const saved = savedLayoutName();
|
||
const select = document.getElementById("operator-layout-select");
|
||
if (select) select.value = Array.from(select.options).some((option) => option.value === saved) ? saved : "compact";
|
||
api.applyLayout(select?.value || saved, { persist: false });
|
||
};
|
||
api.applyLayout = function applyLayout(name, options = {}) {
|
||
const requestedName = name in layouts ? name : "compact";
|
||
const requestedLayout = layouts[requestedName];
|
||
const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact";
|
||
const layout = layouts[permittedName];
|
||
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
||
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName);
|
||
const layoutChanged = appliedLayoutName !== permittedName;
|
||
appliedLayoutName = permittedName;
|
||
seedLayoutSections(layout, layoutChanged);
|
||
if (options.navigate && typeof browserWindow.navigateToTab === "function") {
|
||
browserWindow.navigateToTab(layout.preferredTab);
|
||
}
|
||
};
|
||
function installLayoutControls() {
|
||
const actions = document.querySelector(".top-bar-actions");
|
||
if (actions && !document.getElementById("operator-layout-select")) {
|
||
const label = document.createElement("label");
|
||
label.className = "operator-layout-picker";
|
||
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
||
const select = label.querySelector("select");
|
||
if (!select) throw new Error("Operator layout picker has no select element");
|
||
const savedLayout = savedLayoutName();
|
||
actions.insertBefore(label, actions.firstChild);
|
||
select.value = savedLayout;
|
||
refreshLayoutOptions();
|
||
if (savedLayout !== "broadcast" && savedLayout in layouts) select.value = savedLayout;
|
||
select.addEventListener("change", () => {
|
||
api.applyLayout(select.value, { navigate: true });
|
||
});
|
||
api.applyLayout(select.value);
|
||
}
|
||
const tray = document.querySelector(".controls-tray");
|
||
if (tray && !document.getElementById("advanced-radio-controls")) {
|
||
const details = document.createElement("details");
|
||
details.id = "advanced-radio-controls";
|
||
details.className = "advanced-radio-controls";
|
||
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||
const body = details.querySelector(".advanced-radio-body");
|
||
if (!body) throw new Error("Advanced controls have no body");
|
||
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||
const element = document.getElementById(id);
|
||
if (element) body.appendChild(element);
|
||
});
|
||
tray.insertBefore(details, document.getElementById("audio-controls"));
|
||
api.applyLayout(savedLayoutName(), { persist: false });
|
||
}
|
||
}
|
||
const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"];
|
||
function anchorMenu(button, menu) {
|
||
const rect = button.getBoundingClientRect();
|
||
if (menu.parentElement !== document.body) document.body.appendChild(menu);
|
||
menu.style.position = "fixed";
|
||
menu.style.zIndex = "9700";
|
||
menu.style.top = "0px";
|
||
menu.style.bottom = "auto";
|
||
menu.style.right = "auto";
|
||
const width = menu.offsetWidth || 200;
|
||
const height = menu.offsetHeight || 0;
|
||
menu.style.left = `${String(Math.round(Math.min(Math.max(8, rect.right - width), window.innerWidth - width - 8)))}px`;
|
||
const below = rect.bottom + 8;
|
||
if (below + height <= window.innerHeight - 8) {
|
||
menu.style.top = `${String(Math.round(below))}px`;
|
||
} else {
|
||
menu.style.top = "auto";
|
||
menu.style.bottom = `${String(Math.round(window.innerHeight - rect.top + 8))}px`;
|
||
}
|
||
}
|
||
function installTopBarOverflow() {
|
||
const actions = document.querySelector(".top-bar-actions");
|
||
if (!actions || document.getElementById("top-bar-more")) return;
|
||
const wrap = document.createElement("div");
|
||
wrap.id = "top-bar-more";
|
||
wrap.className = "top-bar-more";
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.id = "top-bar-more-btn";
|
||
button.className = "header-bar-btn top-bar-more-btn";
|
||
button.textContent = "⋯";
|
||
button.setAttribute("aria-haspopup", "menu");
|
||
button.setAttribute("aria-expanded", "false");
|
||
button.setAttribute("aria-label", "More controls");
|
||
button.title = "More controls";
|
||
const menu = document.createElement("div");
|
||
menu.id = "top-bar-more-menu";
|
||
menu.className = "top-bar-more-menu";
|
||
menu.setAttribute("role", "menu");
|
||
button.setAttribute("aria-controls", menu.id);
|
||
wrap.append(button, menu);
|
||
actions.appendChild(wrap);
|
||
const closeMenu = () => {
|
||
menu.classList.remove("is-open");
|
||
button.setAttribute("aria-expanded", "false");
|
||
};
|
||
button.addEventListener("click", () => {
|
||
const open = menu.classList.toggle("is-open");
|
||
button.setAttribute("aria-expanded", String(open));
|
||
if (open) anchorMenu(button, menu);
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!(event.target instanceof Node)) {
|
||
closeMenu();
|
||
return;
|
||
}
|
||
if (!wrap.contains(event.target) && !menu.contains(event.target)) closeMenu();
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") closeMenu();
|
||
});
|
||
const barFits = () => {
|
||
const bar = actions.closest(".tab-bar");
|
||
if (!bar) return true;
|
||
const identity = bar.querySelector(".header-main");
|
||
const nav = bar.querySelector(".tab-bar-nav");
|
||
const gutters = 48;
|
||
const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters;
|
||
return needed <= bar.clientWidth;
|
||
};
|
||
const reflowOverflow = () => {
|
||
const nav = document.querySelector(".tab-bar-nav");
|
||
const bar = actions.closest(".tab-bar");
|
||
nav?.classList.remove("nav-icons-only");
|
||
bar?.classList.remove("bar-tight");
|
||
overflowOrder.forEach((selector) => {
|
||
const element = menu.querySelector(selector);
|
||
if (element) actions.insertBefore(element, wrap);
|
||
});
|
||
wrap.hidden = true;
|
||
for (const selector of overflowOrder) {
|
||
if (barFits()) break;
|
||
const element = actions.querySelector(selector);
|
||
if (!element) continue;
|
||
wrap.hidden = false;
|
||
menu.appendChild(element);
|
||
}
|
||
if (nav && !barFits()) nav.classList.add("nav-icons-only");
|
||
if (bar && !barFits()) bar.classList.add("bar-tight");
|
||
wrap.hidden = menu.children.length === 0;
|
||
if (wrap.hidden) closeMenu();
|
||
};
|
||
reflowOverflow();
|
||
window.addEventListener("resize", reflowOverflow);
|
||
}
|
||
function installMobileMore() {
|
||
const nav = document.querySelector(".tab-bar-nav");
|
||
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||
const more = document.createElement("button");
|
||
more.id = "mobile-more-btn";
|
||
more.className = "tab mobile-more-btn";
|
||
more.type = "button";
|
||
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">Tools</span>';
|
||
more.setAttribute("aria-label", "Tools and settings");
|
||
more.setAttribute("aria-haspopup", "menu");
|
||
more.setAttribute("aria-expanded", "false");
|
||
const menu = document.createElement("div");
|
||
menu.id = "mobile-more-menu";
|
||
menu.className = "mobile-more-menu";
|
||
menu.setAttribute("role", "menu");
|
||
more.setAttribute("aria-controls", menu.id);
|
||
const closeMore = (restoreFocus = false) => {
|
||
if (!menu.classList.contains("is-open")) return;
|
||
menu.classList.remove("is-open");
|
||
more.setAttribute("aria-expanded", "false");
|
||
if (restoreFocus) more.focus();
|
||
};
|
||
api.closeMobileOverlays = closeMore;
|
||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||
if (!source) return;
|
||
const item = document.createElement("button");
|
||
item.type = "button";
|
||
item.setAttribute("role", "menuitem");
|
||
item.dataset.navigateTab = tabName;
|
||
item.textContent = source.textContent.trim();
|
||
item.addEventListener("click", () => {
|
||
if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName);
|
||
closeMore();
|
||
});
|
||
menu.appendChild(item);
|
||
});
|
||
more.addEventListener("click", () => {
|
||
const open = menu.classList.toggle("is-open");
|
||
more.setAttribute("aria-expanded", String(open));
|
||
if (open) {
|
||
anchorMenu(more, menu);
|
||
menu.querySelector('[role="menuitem"]')?.focus();
|
||
}
|
||
});
|
||
document.addEventListener("click", (event) => {
|
||
if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore();
|
||
});
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") closeMore(true);
|
||
});
|
||
window.addEventListener("resize", () => {
|
||
closeMore();
|
||
});
|
||
window.addEventListener("popstate", () => {
|
||
closeMore();
|
||
});
|
||
nav.append(more, menu);
|
||
}
|
||
function installDecoderPicker() {
|
||
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||
if (!bar || document.getElementById("decoder-tab-select")) return;
|
||
const select = document.createElement("select");
|
||
select.id = "decoder-tab-select";
|
||
select.className = "decoder-tab-select";
|
||
select.setAttribute("aria-label", "Decoder view");
|
||
const groups = [
|
||
["Overview", ["overview"]],
|
||
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
||
["Broadcast & images", ["rds", "sat", "wefax"]]
|
||
];
|
||
groups.forEach(([label, ids]) => {
|
||
const group = document.createElement("optgroup");
|
||
group.label = label;
|
||
ids.forEach((id) => {
|
||
const button = bar.querySelector(`[data-subtab="${id}"]`);
|
||
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
||
});
|
||
select.appendChild(group);
|
||
});
|
||
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
|
||
bar.insertAdjacentElement("afterend", select);
|
||
}
|
||
function installDecoderBadges() {
|
||
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||
if (!bar) return;
|
||
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
||
const id = button.dataset.subtab;
|
||
if (!id) return;
|
||
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||
const dot = document.createElement("span");
|
||
dot.className = "decoder-state-dot";
|
||
dot.setAttribute("aria-hidden", "true");
|
||
button.appendChild(dot);
|
||
const status = document.getElementById(`${id}-status`);
|
||
if (!status) return;
|
||
const sync = () => {
|
||
const value = status.textContent.toLowerCase();
|
||
const state = /receiv|decod|connected|listening/.test(value) ? "active" : /error|fail|disconnected/.test(value) ? "error" : "idle";
|
||
dot.dataset.state = state;
|
||
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
|
||
};
|
||
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
|
||
sync();
|
||
});
|
||
}
|
||
api.init = function init() {
|
||
ensureLiveRegions();
|
||
installLayoutControls();
|
||
installTopBarOverflow();
|
||
installMobileMore();
|
||
installDecoderPicker();
|
||
installDecoderBadges();
|
||
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
||
document.querySelectorAll(".sub-tab-bar").forEach((bar) => {
|
||
api.prepareTabList(bar, "secondary");
|
||
});
|
||
window.addEventListener("unhandledrejection", (event) => {
|
||
const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly";
|
||
api.notify(message, { kind: "error" });
|
||
});
|
||
};
|
||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => {
|
||
api.init();
|
||
}, { once: true });
|
||
else api.init();
|
||
})();
|
||
|
||
// src/plugin-runtime.ts
|
||
var decoders = /* @__PURE__ */ new Map();
|
||
var queued = /* @__PURE__ */ new Map();
|
||
var MAX_QUEUED_ACTIONS_PER_DECODER = 512;
|
||
function enqueue(id, action) {
|
||
const actions = queued.get(id) ?? [];
|
||
actions.push(action);
|
||
if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER);
|
||
queued.set(id, actions);
|
||
}
|
||
function deliver(plugin, action) {
|
||
if (action.kind === "message" && plugin.onMessage) {
|
||
plugin.onMessage(action.payload);
|
||
return true;
|
||
}
|
||
if (action.kind === "batch" && plugin.onBatch) {
|
||
plugin.onBatch(action.payload);
|
||
return true;
|
||
}
|
||
if (action.kind === "restore" && plugin.restore) {
|
||
plugin.restore(action.payload);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
function dispatchOrQueue(id, action) {
|
||
const plugin = decoders.get(id);
|
||
if (!plugin) {
|
||
enqueue(id, action);
|
||
return false;
|
||
}
|
||
if (!deliver(plugin, action)) {
|
||
if (action.kind === "batch" && plugin.onMessage) {
|
||
for (const message of action.payload) plugin.onMessage(message);
|
||
return true;
|
||
}
|
||
if (action.kind === "restore" && plugin.onBatch) {
|
||
plugin.onBatch(action.payload);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
var runtime = {
|
||
registerDecoder(plugin) {
|
||
if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`);
|
||
const erased = plugin;
|
||
decoders.set(plugin.id, erased);
|
||
const pending = queued.get(plugin.id) ?? [];
|
||
queued.delete(plugin.id);
|
||
for (const action of pending) deliver(erased, action);
|
||
return () => {
|
||
if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id);
|
||
};
|
||
},
|
||
dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }),
|
||
dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }),
|
||
restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }),
|
||
reset(id) {
|
||
const plugin = decoders.get(id);
|
||
if (!plugin?.reset) return false;
|
||
plugin.reset();
|
||
return true;
|
||
},
|
||
resetAll() {
|
||
for (const plugin of decoders.values()) plugin.reset?.();
|
||
},
|
||
prune(id) {
|
||
const plugin = decoders.get(id);
|
||
if (!plugin?.prune) return false;
|
||
plugin.prune();
|
||
return true;
|
||
},
|
||
clearQueued() {
|
||
queued.clear();
|
||
},
|
||
hasDecoder: (id) => decoders.has(id)
|
||
};
|
||
window.trxPluginRuntime = runtime;
|
||
|
||
// src/leaflet-ais-tracksymbol.ts
|
||
(function() {
|
||
const leaflet = globalThis.L;
|
||
if (!leaflet) return;
|
||
function clamp(value, min, max) {
|
||
return Math.max(min, Math.min(max, value));
|
||
}
|
||
function finiteAngle(value) {
|
||
if (value === null || !Number.isFinite(value)) return null;
|
||
const normalized = (value % 360 + 360) % 360;
|
||
return normalized;
|
||
}
|
||
function svgColor(value, fallback) {
|
||
const text = value || fallback || "";
|
||
return text.replace(/"/g, """);
|
||
}
|
||
function buildSymbolHtml(options, zoom) {
|
||
const heading = finiteAngle(options.heading);
|
||
const course = finiteAngle(options.course);
|
||
const angle = heading != null ? heading : course;
|
||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
|
||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||
const color = svgColor(options.color, "#ff7559");
|
||
const outline = svgColor(options.outline, "#6b2118");
|
||
const body = angle != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})"><path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" /></g>` : `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||
const courseLine = course != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})"><line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" /></g>` : "";
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` + courseLine + body + `</svg>`;
|
||
}
|
||
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
|
||
options: {
|
||
heading: null,
|
||
course: null,
|
||
speed: null,
|
||
color: "#ff7559",
|
||
outline: "#6b2118",
|
||
size: 22,
|
||
interactive: true,
|
||
keyboard: true,
|
||
riseOnHover: true
|
||
},
|
||
initialize: function(latlng, options) {
|
||
const merged = leaflet.Util.extend({}, this.options, options || {});
|
||
merged.icon = leaflet.divIcon({
|
||
className: "trx-ais-track-symbol-icon",
|
||
html: "",
|
||
iconSize: [merged.size, merged.size],
|
||
iconAnchor: [merged.size / 2, merged.size / 2]
|
||
});
|
||
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
|
||
},
|
||
onAdd: function(map) {
|
||
leaflet.Marker.prototype.onAdd.call(this, map);
|
||
this._refreshIcon();
|
||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||
map.on("zoomend", this._boundZoomRefresh);
|
||
},
|
||
onRemove: function(map) {
|
||
if (this._boundZoomRefresh) {
|
||
map.off("zoomend", this._boundZoomRefresh);
|
||
this._boundZoomRefresh = null;
|
||
}
|
||
leaflet.Marker.prototype.onRemove.call(this, map);
|
||
},
|
||
setAisState: function(next) {
|
||
if ("heading" in next) this.options.heading = next.heading;
|
||
if ("course" in next) this.options.course = next.course;
|
||
if ("speed" in next) this.options.speed = next.speed;
|
||
if ("color" in next) this.options.color = next.color;
|
||
if ("outline" in next) this.options.outline = next.outline;
|
||
this._refreshIcon();
|
||
return this;
|
||
},
|
||
_refreshIcon: function() {
|
||
if (!this._icon) return;
|
||
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
||
const html = buildSymbolHtml(this.options, zoom);
|
||
this._icon.innerHTML = html;
|
||
const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22;
|
||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||
this._icon.style.width = `${size}px`;
|
||
this._icon.style.height = `${size}px`;
|
||
this._icon.style.marginLeft = `${-size / 2}px`;
|
||
this._icon.style.marginTop = `${-size / 2}px`;
|
||
}
|
||
});
|
||
leaflet.trxAisTrackSymbol = function(latlng, options) {
|
||
const Constructor = leaflet.TrxAisTrackSymbol;
|
||
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
||
return new Constructor(latlng, options);
|
||
};
|
||
})();
|
||
|
||
// src/core/geo.ts
|
||
function haversineKm(lat1, lon1, lat2, lon2) {
|
||
const radiusKm = 6371;
|
||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||
const dLon = (lon2 - lon1) * Math.PI / 180;
|
||
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
|
||
return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
}
|
||
function locatorToLatLon(locator) {
|
||
const raw = typeof locator === "string" ? locator.trim().toUpperCase() : "";
|
||
if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(raw)) return null;
|
||
let lon = -180 + (raw.charCodeAt(0) - 65) * 20 + Number(raw.slice(2, 3)) * 2;
|
||
let lat = -90 + (raw.charCodeAt(1) - 65) * 10 + Number(raw.slice(3, 4));
|
||
if (raw.length >= 6) {
|
||
lon += (raw.charCodeAt(4) - 65) * (5 / 60) + 2.5 / 60;
|
||
lat += (raw.charCodeAt(5) - 65) * (2.5 / 60) + 1.25 / 60;
|
||
} else {
|
||
lon += 1;
|
||
lat += 0.5;
|
||
}
|
||
return { lat, lon };
|
||
}
|
||
function formatDistanceKm(distanceKm) {
|
||
if (!Number.isFinite(distanceKm)) return null;
|
||
return distanceKm < 1 ? `${Math.round(distanceKm * 1e3)} m` : `${distanceKm.toFixed(1)} km`;
|
||
}
|
||
function formatTimeAgo(timestampMs) {
|
||
if (!timestampMs) return null;
|
||
const seconds = Math.round((Date.now() - timestampMs) / 1e3);
|
||
if (seconds < 60) return `${seconds}s ago`;
|
||
const minutes = Math.round(seconds / 60);
|
||
if (minutes < 60) return `${minutes} min ago`;
|
||
const hours = Math.floor(minutes / 60);
|
||
const remainingMinutes = minutes % 60;
|
||
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}min ago` : `${hours}h ago`;
|
||
}
|
||
function latLonToMaidenhead(lat, lon) {
|
||
const adjustedLon = lon + 180;
|
||
const adjustedLat = lat + 90;
|
||
const upperA = "A".charCodeAt(0);
|
||
const lowerA = "a".charCodeAt(0);
|
||
const field1 = String.fromCharCode(upperA + Math.floor(adjustedLon / 20));
|
||
const field2 = String.fromCharCode(upperA + Math.floor(adjustedLat / 10));
|
||
const square1 = Math.floor(adjustedLon % 20 / 2);
|
||
const square2 = Math.floor(adjustedLat % 10);
|
||
const sub1 = String.fromCharCode(lowerA + Math.floor(adjustedLon % 2 * 12));
|
||
const sub2 = String.fromCharCode(lowerA + Math.floor(adjustedLat % 1 * 24));
|
||
return `${field1}${field2}${square1}${square2}${sub1}${sub2}`;
|
||
}
|
||
|
||
// src/core/dom.ts
|
||
function escapeHtml(input) {
|
||
return String(input).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||
}
|
||
|
||
// src/core/settings.ts
|
||
var STORAGE_PREFIX = "trx_";
|
||
function saveSetting(key, value) {
|
||
try {
|
||
localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
|
||
} catch {
|
||
}
|
||
}
|
||
function loadSetting(key, fallback) {
|
||
try {
|
||
const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
||
return value === null ? fallback : JSON.parse(value);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
// src/core/decoder-registry.ts
|
||
var bridge = window;
|
||
var readyCallbacks = [];
|
||
var decoderRegistry = [];
|
||
function onDecoderRegistryReady(callback) {
|
||
if (decoderRegistry.length > 0) callback();
|
||
else readyCallbacks.push(callback);
|
||
}
|
||
function applyDecoderRegistryVisibility() {
|
||
const knownIds = new Set(decoderRegistry.map(({ id }) => id));
|
||
const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]);
|
||
document.querySelectorAll(
|
||
"#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]"
|
||
).forEach((button) => {
|
||
const id = button.dataset.subtab;
|
||
if (!id || alwaysShow.has(id) || knownIds.has(id)) return;
|
||
button.style.display = "none";
|
||
const panel = document.getElementById(`subtab-${id}`);
|
||
if (panel) panel.style.display = "none";
|
||
});
|
||
document.querySelectorAll('[id^="about-dec-"]').forEach((element) => {
|
||
const id = element.id.replace("about-dec-", "");
|
||
if (alwaysShow.has(id) || knownIds.has(id)) return;
|
||
const row = element.closest("tr");
|
||
if (row) row.style.display = "none";
|
||
});
|
||
document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach((element) => {
|
||
const match = /^settings-clear-(.+)-history$/.exec(element.id);
|
||
const id = match?.[1];
|
||
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
|
||
});
|
||
document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach((element) => {
|
||
const id = element.dataset.decoder;
|
||
if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none";
|
||
});
|
||
}
|
||
function isDecoderDescriptor(value) {
|
||
if (typeof value !== "object" || value === null) return false;
|
||
const item = value;
|
||
return typeof item.id === "string" && typeof item.label === "string" && typeof item.activation === "string" && Array.isArray(item.active_modes) && item.active_modes.every((mode) => typeof mode === "string") && typeof item.background_decode === "boolean" && typeof item.bookmark_selectable === "boolean";
|
||
}
|
||
function decodeRegistry(value) {
|
||
if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) {
|
||
throw new TypeError("The decoder registry response is malformed");
|
||
}
|
||
return value;
|
||
}
|
||
async function loadDecoderRegistry(onLoaded) {
|
||
try {
|
||
const response = await fetch("/decoders");
|
||
if (!response.ok) return;
|
||
decoderRegistry = decodeRegistry(await response.json());
|
||
bridge.decoderRegistry = decoderRegistry;
|
||
readyCallbacks.splice(0).forEach((callback) => {
|
||
callback();
|
||
});
|
||
applyDecoderRegistryVisibility();
|
||
onLoaded();
|
||
} catch (error) {
|
||
console.error("Failed to fetch decoder registry:", error);
|
||
}
|
||
}
|
||
bridge.decoderRegistry = decoderRegistry;
|
||
bridge.onDecoderRegistryReady = onDecoderRegistryReady;
|
||
|
||
// src/api/auth.ts
|
||
function decodeAuthSession(value) {
|
||
if (typeof value !== "object" || value === null) {
|
||
throw new TypeError("The authentication response is malformed");
|
||
}
|
||
const session = value;
|
||
if (typeof session.authenticated !== "boolean") {
|
||
throw new TypeError("The authentication response has no authenticated flag");
|
||
}
|
||
if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") {
|
||
throw new TypeError("The authentication response has an invalid role");
|
||
}
|
||
if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") {
|
||
throw new TypeError("The authentication response has an invalid auth_disabled flag");
|
||
}
|
||
const decoded = { authenticated: session.authenticated };
|
||
if (session.role !== void 0) decoded.role = session.role;
|
||
if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled;
|
||
return decoded;
|
||
}
|
||
var authDisabledSession = {
|
||
authenticated: true,
|
||
role: "control",
|
||
auth_disabled: true
|
||
};
|
||
async function fetchAuthSession() {
|
||
try {
|
||
const response = await fetch("/auth/session");
|
||
if (response.status === 404) return authDisabledSession;
|
||
if (!response.ok) return { authenticated: false };
|
||
return decodeAuthSession(await response.json());
|
||
} catch (error) {
|
||
console.error("Auth check failed:", error);
|
||
return { authenticated: false };
|
||
}
|
||
}
|
||
async function login(passphrase) {
|
||
const response = await fetch("/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ passphrase })
|
||
});
|
||
if (response.status === 404) return authDisabledSession;
|
||
if (!response.ok) {
|
||
const message = await response.text();
|
||
throw new Error(message || "Login failed");
|
||
}
|
||
return decodeAuthSession(await response.json());
|
||
}
|
||
async function logout() {
|
||
const response = await fetch("/auth/logout", { method: "POST" });
|
||
if (response.status !== 404 && !response.ok) throw new Error("Logout failed");
|
||
}
|
||
|
||
// src/core/format.ts
|
||
function formatDuration(milliseconds) {
|
||
const seconds = Math.floor(milliseconds / 1e3);
|
||
const days = Math.floor(seconds / 86400);
|
||
const hours = Math.floor(seconds % 86400 / 3600);
|
||
const minutes = Math.floor(seconds % 3600 / 60);
|
||
const remainder = seconds % 60;
|
||
const parts = [];
|
||
if (days > 0) parts.push(`${days}d`);
|
||
if (hours > 0 || days > 0) parts.push(`${hours}h`);
|
||
parts.push(`${minutes}m`, `${remainder}s`);
|
||
return parts.join(" ");
|
||
}
|
||
function formatFrequency(frequencyHz) {
|
||
if (!Number.isFinite(frequencyHz)) return "--";
|
||
if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`;
|
||
if (frequencyHz >= 1e7) return `${(frequencyHz / 1e6).toFixed(3)} MHz`;
|
||
return `${(frequencyHz / 1e3).toFixed(1)} kHz`;
|
||
}
|
||
function formatFrequencyForStep(frequencyHz, stepHz) {
|
||
if (!Number.isFinite(frequencyHz)) return "--";
|
||
if (stepHz >= 1e6) return (frequencyHz / 1e6).toFixed(6);
|
||
if (stepHz >= 1e3) return (frequencyHz / 1e3).toFixed(3);
|
||
if (stepHz >= 1) return String(Math.round(frequencyHz));
|
||
return formatFrequency(frequencyHz);
|
||
}
|
||
function formatFrequencyForHumans(frequencyHz) {
|
||
if (!Number.isFinite(frequencyHz)) return "--";
|
||
if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`;
|
||
if (frequencyHz >= 1e6) return `${(frequencyHz / 1e6).toFixed(3)} MHz`;
|
||
if (frequencyHz >= 1e3) return `${(frequencyHz / 1e3).toFixed(3)} kHz`;
|
||
return `${Math.round(frequencyHz)} Hz`;
|
||
}
|
||
function formatWavelength(frequencyHz) {
|
||
if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--";
|
||
const meters = 299792458 / frequencyHz;
|
||
return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`;
|
||
}
|
||
function parseFrequencyInput(value, defaultStepHz, mode) {
|
||
if (!value) return null;
|
||
const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/.exec(value.trim().toLowerCase());
|
||
if (!match?.[1]) return null;
|
||
const rawNumber = match[1];
|
||
let frequency = Number.parseFloat(rawNumber.replace(",", "."));
|
||
const unit = match[2] ?? "";
|
||
if (Number.isNaN(frequency)) return null;
|
||
if (unit.startsWith("gh") || unit === "g") frequency *= 1e9;
|
||
else if (unit.startsWith("mh") || unit === "m") frequency *= 1e6;
|
||
else if (unit.startsWith("kh") || unit === "k") frequency *= 1e3;
|
||
else if (!unit) {
|
||
const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(",");
|
||
if (mode.toUpperCase() === "WFM") {
|
||
if (hasDecimalSeparator && frequency >= 50 && frequency < 200) {
|
||
return Math.round(frequency * 1e6);
|
||
}
|
||
if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) {
|
||
return Math.round(frequency / 10 * 1e6);
|
||
}
|
||
}
|
||
if (defaultStepHz >= 1e6) frequency *= 1e6;
|
||
else if (defaultStepHz >= 1e3) frequency *= 1e3;
|
||
else if (defaultStepHz < 1) {
|
||
if (frequency < 1e3) frequency *= 1e6;
|
||
else if (frequency < 1e6) frequency *= 1e3;
|
||
}
|
||
}
|
||
return Math.round(frequency);
|
||
}
|
||
function formatByteSize(bytes) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||
}
|
||
|
||
// src/core/cbor.ts
|
||
var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||
function decodeUint(view, bytes, state, additional) {
|
||
const offset = state.offset;
|
||
if (additional < 24) return additional;
|
||
const widths = { 24: 1, 25: 2, 26: 4, 27: 8 };
|
||
const width = widths[additional];
|
||
if (width === void 0) throw new Error("Unsupported CBOR additional info");
|
||
if (offset + width > bytes.length) throw new Error("CBOR payload truncated");
|
||
state.offset += width;
|
||
if (additional === 24) return bytes[offset] ?? 0;
|
||
if (additional === 25) return view.getUint16(offset);
|
||
if (additional === 26) return view.getUint32(offset);
|
||
const numeric = Number(view.getBigUint64(offset));
|
||
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||
return numeric;
|
||
}
|
||
function decodeFloat16(bits) {
|
||
const sign = bits & 32768 ? -1 : 1;
|
||
const exponent = bits >> 10 & 31;
|
||
const fraction = bits & 1023;
|
||
if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024);
|
||
if (exponent === 31) return fraction === 0 ? sign * Infinity : Number.NaN;
|
||
return sign * 2 ** (exponent - 15) * (1 + fraction / 1024);
|
||
}
|
||
function decodeItem(view, bytes, state) {
|
||
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||
const initial = bytes[state.offset++];
|
||
if (initial === void 0) throw new Error("CBOR payload truncated");
|
||
const major = initial >> 5;
|
||
const additional = initial & 31;
|
||
if (major === 0) return decodeUint(view, bytes, state, additional);
|
||
if (major === 1) return -1 - decodeUint(view, bytes, state, additional);
|
||
if (major === 2 || major === 3) {
|
||
const length = decodeUint(view, bytes, state, additional);
|
||
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||
state.offset += length;
|
||
if (major === 2) return Array.from(chunk);
|
||
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||
}
|
||
if (major === 4) {
|
||
const length = decodeUint(view, bytes, state, additional);
|
||
return Array.from({ length }, () => decodeItem(view, bytes, state));
|
||
}
|
||
if (major === 5) {
|
||
const length = decodeUint(view, bytes, state, additional);
|
||
const value = {};
|
||
for (let index = 0; index < length; index += 1) {
|
||
const key = decodeItem(view, bytes, state);
|
||
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean" && key !== null) {
|
||
throw new Error("Unsupported composite CBOR map key");
|
||
}
|
||
value[String(key)] = decodeItem(view, bytes, state);
|
||
}
|
||
return value;
|
||
}
|
||
if (major === 6) {
|
||
decodeUint(view, bytes, state, additional);
|
||
return decodeItem(view, bytes, state);
|
||
}
|
||
if (major === 7) {
|
||
if (additional === 20) return false;
|
||
if (additional === 21) return true;
|
||
if (additional === 22) return null;
|
||
if (additional === 23) return void 0;
|
||
const widths = { 25: 2, 26: 4, 27: 8 };
|
||
const width = widths[additional];
|
||
if (width === void 0) throw new Error("Unsupported CBOR major type");
|
||
if (state.offset + width > bytes.length) throw new Error("CBOR payload truncated");
|
||
const offset = state.offset;
|
||
state.offset += width;
|
||
if (additional === 25) return decodeFloat16(view.getUint16(offset));
|
||
if (additional === 26) return view.getFloat32(offset);
|
||
return view.getFloat64(offset);
|
||
}
|
||
throw new Error("Unsupported CBOR major type");
|
||
}
|
||
function decodeCbor(buffer) {
|
||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||
const state = { offset: 0 };
|
||
const value = decodeItem(view, bytes, state);
|
||
if (state.offset !== bytes.length) throw new Error("Unexpected trailing bytes in CBOR payload");
|
||
return value;
|
||
}
|
||
|
||
// src/features/navigation/routes.ts
|
||
var TAB_ORDER = [
|
||
"main",
|
||
"bookmarks",
|
||
"digital-modes",
|
||
"map",
|
||
"statistics",
|
||
"recorder",
|
||
"settings",
|
||
"about"
|
||
];
|
||
var TAB_PATHS = {
|
||
main: "/",
|
||
bookmarks: "/bookmarks",
|
||
"digital-modes": "/digital-modes",
|
||
map: "/map",
|
||
statistics: "/statistics",
|
||
recorder: "/recorder",
|
||
settings: "/settings",
|
||
about: "/about"
|
||
};
|
||
function normalizeTabPath(pathname) {
|
||
const raw = pathname.length > 0 ? pathname : "/";
|
||
return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/";
|
||
}
|
||
function tabFromPath(pathname) {
|
||
const normalized = normalizeTabPath(pathname);
|
||
const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized);
|
||
return match ? match[0] : "main";
|
||
}
|
||
function updateTabHistory(name, replace = false) {
|
||
const targetPath = TAB_PATHS[name];
|
||
if (normalizeTabPath(window.location.pathname) === targetPath) return;
|
||
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
|
||
if (replace) window.history.replaceState({}, "", nextUrl);
|
||
else window.history.pushState({}, "", nextUrl);
|
||
}
|
||
|
||
// src/features/radio/auto-bandwidth.ts
|
||
function clampPercent(value) {
|
||
const numeric = Number(value) || 0;
|
||
return Math.max(0, Math.min(100, numeric)) / 100;
|
||
}
|
||
function estimateOccupiedBandwidth(data, centerHz, mode, limits, interference = {}) {
|
||
if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins) || !Number.isFinite(centerHz)) return null;
|
||
const bins = Array.from(data.bins);
|
||
if (bins.length < 3) return null;
|
||
const maxIdx = bins.length - 1;
|
||
const hzPerBin = data.sample_rate / maxIdx;
|
||
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||
const centerIdx = Math.max(
|
||
1,
|
||
Math.min(maxIdx - 1, Math.round((centerHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const normalizedMode = mode.toUpperCase();
|
||
const [defaultBw, minBw, maxBw, stepBw] = limits;
|
||
const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode) ? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
|
||
const isWfm = normalizedMode === "WFM";
|
||
const smoothRadius = isWfm ? 3 : 1;
|
||
const smoothed = bins.map((_, index) => {
|
||
let sum = 0;
|
||
let count = 0;
|
||
for (let adjacent = Math.max(0, index - smoothRadius); adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
|
||
sum += bins[adjacent] ?? 0;
|
||
count += 1;
|
||
}
|
||
return sum / count;
|
||
});
|
||
const sorted = [...bins].sort((left, right) => left - right);
|
||
const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
|
||
const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
|
||
const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
|
||
const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
|
||
const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
|
||
let peak = -Infinity;
|
||
for (let index = searchLo; index <= searchHi; index += 1) {
|
||
peak = Math.max(peak, smoothed[index] ?? -Infinity);
|
||
}
|
||
const snr = peak - noise;
|
||
if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;
|
||
const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
|
||
const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12e3 : stepBw) / hzPerBin));
|
||
const occupiedExtent = (direction, limitBins) => {
|
||
let lastOccupied = centerIdx;
|
||
let gap = 0;
|
||
for (let offset = 0; offset <= limitBins; offset += 1) {
|
||
const index = centerIdx + direction * offset;
|
||
if (index <= 0 || index >= maxIdx) break;
|
||
if ((smoothed[index] ?? -Infinity) >= threshold) {
|
||
lastOccupied = index;
|
||
gap = 0;
|
||
} else if (++gap > allowedGap) break;
|
||
}
|
||
return Math.abs(lastOccupied - centerIdx) * hzPerBin;
|
||
};
|
||
let rawBw = oneSided !== 0 ? occupiedExtent(oneSided, maxSpanBins) : 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
|
||
rawBw *= isWfm ? 1.08 : 1.12;
|
||
if (isWfm) {
|
||
const aci = clampPercent(interference.aci);
|
||
const cci = clampPercent(interference.cci);
|
||
const aciCap = maxBw - (maxBw - minBw) * aci;
|
||
const cciFloor = minBw + (defaultBw - minBw) * 0.65;
|
||
const cciCap = maxBw - (maxBw - cciFloor) * cci;
|
||
rawBw = Math.min(rawBw, aciCap, cciCap);
|
||
}
|
||
const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
|
||
return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
|
||
}
|
||
|
||
// src/features/spectrum/math.ts
|
||
function isNumericBins(value) {
|
||
return Array.isArray(value) ? value.every((item) => typeof item === "number") : ArrayBuffer.isView(value) && !(value instanceof DataView);
|
||
}
|
||
var base64Lookup = new Uint8Array(128).fill(255);
|
||
var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
for (let index = 0; index < base64Alphabet.length; index += 1) {
|
||
base64Lookup[base64Alphabet.charCodeAt(index)] = index;
|
||
}
|
||
var spectrumBinBuffer = new Int8Array(0);
|
||
function decodeBase64Int8(value) {
|
||
let end = value.length;
|
||
while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1;
|
||
const outputLength = end * 3 >>> 2;
|
||
if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength);
|
||
let outputIndex = 0;
|
||
for (let index = 0; index < end; ) {
|
||
const sextets = [0, 0, 0, 0];
|
||
for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) {
|
||
const code = value.charCodeAt(index);
|
||
const decoded = code < base64Lookup.length ? base64Lookup[code] : void 0;
|
||
if (decoded === void 0 || decoded === 255) throw new TypeError("Invalid base64 spectrum frame");
|
||
sextets[offset] = decoded;
|
||
}
|
||
const packed = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0);
|
||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 255;
|
||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 255;
|
||
if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 255;
|
||
}
|
||
return spectrumBinBuffer;
|
||
}
|
||
var nthScratch = new Float64Array(0);
|
||
function nthElement(values, target) {
|
||
if (values.length === 0 || target < 0 || target >= values.length) return null;
|
||
if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length);
|
||
for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0;
|
||
let low = 0;
|
||
let high = values.length - 1;
|
||
while (low < high) {
|
||
const pivot = nthScratch[low + (high - low >> 1)] ?? 0;
|
||
let left = low;
|
||
let right = high;
|
||
while (left <= right) {
|
||
while ((nthScratch[left] ?? Infinity) < pivot) left += 1;
|
||
while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1;
|
||
if (left <= right) {
|
||
const temporary = nthScratch[left] ?? 0;
|
||
nthScratch[left] = nthScratch[right] ?? 0;
|
||
nthScratch[right] = temporary;
|
||
left += 1;
|
||
right -= 1;
|
||
}
|
||
}
|
||
if (right < target) low = left;
|
||
if (target < left) high = right;
|
||
}
|
||
return nthScratch[target] ?? null;
|
||
}
|
||
function estimateNoiseFloorDb(bins) {
|
||
if (!isNumericBins(bins) || bins.length === 0) return null;
|
||
return nthElement(bins, Math.floor(bins.length * 0.15));
|
||
}
|
||
|
||
// src/plugin-loader.ts
|
||
var pluginGroups = {
|
||
"digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"],
|
||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||
statistics: ["/map-core.js"],
|
||
bookmarks: ["/bookmarks.js"],
|
||
recorder: [],
|
||
settings: ["/vchan.js", "/scheduler.js"]
|
||
};
|
||
var loaded = /* @__PURE__ */ new Set();
|
||
var loading = /* @__PURE__ */ new Map();
|
||
async function loadPlugin(path) {
|
||
if (loaded.has(path)) return;
|
||
const pending = loading.get(path);
|
||
if (pending) return pending;
|
||
const request = import(path).then(() => {
|
||
loaded.add(path);
|
||
loading.delete(path);
|
||
}).catch((error) => {
|
||
loading.delete(path);
|
||
throw new Error(`Failed to load plugin module: ${path}`, { cause: error });
|
||
});
|
||
loading.set(path, request);
|
||
return request;
|
||
}
|
||
async function loadPlugins(group) {
|
||
if (!(group in pluginGroups)) return;
|
||
for (const path of pluginGroups[group]) await loadPlugin(path);
|
||
}
|
||
async function loadEagerPlugins() {
|
||
await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins));
|
||
}
|
||
async function loadPluginsForTab(tab) {
|
||
await loadPlugins(tab);
|
||
}
|
||
|
||
// src/api/client.ts
|
||
function isRecord(value) {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
function isRigSnapshot(value) {
|
||
if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) {
|
||
return false;
|
||
}
|
||
const { info, status } = value;
|
||
return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean";
|
||
}
|
||
function isRigListResponse(value) {
|
||
return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every(
|
||
(rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean"
|
||
);
|
||
}
|
||
|
||
// src/app.ts
|
||
function requiredElement(id) {
|
||
const element = document.getElementById(id);
|
||
if (!element) throw new Error(`Missing required application element #${id}`);
|
||
return element;
|
||
}
|
||
function isFiniteNumber(value) {
|
||
return typeof value === "number" && Number.isFinite(value);
|
||
}
|
||
function primitiveString(value) {
|
||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
||
}
|
||
function isRecord2(value) {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|
||
function parseJsonUnknown(source) {
|
||
return JSON.parse(source);
|
||
}
|
||
function messageEventData(event) {
|
||
const data = event.data;
|
||
if (typeof data !== "string") throw new TypeError("Expected a text event payload");
|
||
return data;
|
||
}
|
||
async function responseJsonUnknown(response) {
|
||
return await response.json();
|
||
}
|
||
function isAppUpdate(value) {
|
||
return isRecord2(value) && typeof value.clients === "number" && typeof value.audio_clients === "number" && typeof value.rigctl_clients === "number" && Array.isArray(value.remotes) && value.remotes.every((remote) => typeof remote === "string") && typeof value.show_sdr_gain_control === "boolean" && typeof value.initial_map_zoom === "number" && typeof value.spectrum_coverage_margin_hz === "number" && typeof value.spectrum_usable_span_ratio === "number" && typeof value.bandplan_enabled === "boolean" && typeof value.bandplan_region === "string" && typeof value.decode_history_retention_min === "number" && typeof value.server_connected === "boolean" && isRigSnapshot(value);
|
||
}
|
||
function isAudioStreamInfo(value) {
|
||
return isRecord2(value) && typeof value.sample_rate === "number" && typeof value.channels === "number";
|
||
}
|
||
function isRecorderActiveList(value) {
|
||
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.rig_id === "string" && typeof entry.path === "string" && typeof entry.started_at === "number");
|
||
}
|
||
function isRecorderFileList(value) {
|
||
return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.name === "string" && typeof entry.size === "number");
|
||
}
|
||
function isRdsData(value) {
|
||
return isRecord2(value);
|
||
}
|
||
function isVchanRdsEntry(value) {
|
||
return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number");
|
||
}
|
||
void loadDecoderRegistry(refreshOperatorLayoutCapabilities);
|
||
var authRole = null;
|
||
var authEnabled = true;
|
||
async function checkAuthStatus() {
|
||
return fetchAuthSession();
|
||
}
|
||
async function authLogin(passphrase) {
|
||
return login(passphrase);
|
||
}
|
||
async function authLogout() {
|
||
try {
|
||
await logout();
|
||
authRole = null;
|
||
disconnect();
|
||
setDecodeHistoryOverlayVisible(false);
|
||
requiredElement("content").style.display = "none";
|
||
requiredElement("loading").style.display = "none";
|
||
requiredElement("auth-passphrase").value = "";
|
||
updateAuthUI();
|
||
const authStatus = await checkAuthStatus();
|
||
const allowGuest = authStatus.role === "rx";
|
||
showAuthGate(allowGuest);
|
||
} catch (e) {
|
||
console.error("Logout failed:", e);
|
||
showAuthError("Logout failed");
|
||
}
|
||
}
|
||
function showAuthGate(allowGuest = false) {
|
||
if (!authEnabled) return;
|
||
setDecodeHistoryOverlayVisible(false);
|
||
requiredElement("loading").style.display = "none";
|
||
requiredElement("content").style.display = "none";
|
||
const authGate = requiredElement("auth-gate");
|
||
authGate.style.display = "flex";
|
||
authGate.style.flexDirection = "column";
|
||
authGate.style.justifyContent = "center";
|
||
authGate.style.alignItems = "stretch";
|
||
const signalVisualBlock = document.querySelector(".signal-visual-block");
|
||
if (signalVisualBlock) {
|
||
signalVisualBlock.style.display = "none";
|
||
}
|
||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||
panel.style.display = "none";
|
||
});
|
||
const guestBtn2 = document.getElementById("auth-guest-btn");
|
||
if (guestBtn2) {
|
||
guestBtn2.style.display = allowGuest ? "block" : "none";
|
||
}
|
||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.tab === "main");
|
||
});
|
||
syncTopBarAccess();
|
||
}
|
||
function hideAuthGate() {
|
||
const authGate = requiredElement("auth-gate");
|
||
authGate.style.display = "none";
|
||
requiredElement("loading").style.display = "block";
|
||
const signalVisualBlock = document.querySelector(".signal-visual-block");
|
||
if (signalVisualBlock) {
|
||
signalVisualBlock.style.display = "";
|
||
}
|
||
document.querySelectorAll(".tab-panel").forEach((panel) => {
|
||
panel.style.display = "none";
|
||
});
|
||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||
btn.classList.remove("active");
|
||
});
|
||
navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true });
|
||
syncTopBarAccess();
|
||
if (!bandplanData) void loadBandplanJson();
|
||
}
|
||
function showAuthError(msg) {
|
||
const el = requiredElement("auth-error");
|
||
el.textContent = msg;
|
||
el.style.display = "block";
|
||
setTimeout(() => {
|
||
el.style.display = "none";
|
||
}, 5e3);
|
||
}
|
||
function updateAuthUI() {
|
||
const badge = document.getElementById("auth-badge");
|
||
const badgeRole = document.getElementById("auth-role-badge");
|
||
const headerAuthBtn2 = document.getElementById("header-auth-btn");
|
||
if (!authEnabled) {
|
||
if (badge) badge.style.display = "none";
|
||
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
|
||
syncTopBarAccess();
|
||
return;
|
||
}
|
||
if (authRole) {
|
||
if (badge) badge.style.display = "block";
|
||
if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)";
|
||
if (headerAuthBtn2) {
|
||
headerAuthBtn2.textContent = "Logout";
|
||
headerAuthBtn2.style.display = "block";
|
||
}
|
||
} else {
|
||
if (badge) badge.style.display = "none";
|
||
if (headerAuthBtn2) {
|
||
headerAuthBtn2.textContent = "Login";
|
||
headerAuthBtn2.style.display = "block";
|
||
}
|
||
}
|
||
syncTopBarAccess();
|
||
}
|
||
function applyAuthRestrictions() {
|
||
if (!authRole) return;
|
||
if (authRole === "rx") {
|
||
const pttBtn2 = document.getElementById("ptt-btn");
|
||
const powerBtn2 = document.getElementById("power-btn");
|
||
const lockBtn2 = document.getElementById("lock-btn");
|
||
const freqInput = document.getElementById("freq");
|
||
const centerFreqInput = document.getElementById("center-freq");
|
||
const modeSelect = document.getElementById("mode");
|
||
const txLimitInput2 = document.getElementById("tx-limit");
|
||
const txLimitBtn2 = document.getElementById("tx-limit-btn");
|
||
const txAudioBtn2 = document.getElementById("tx-audio-btn");
|
||
const txLimitRow2 = document.getElementById("tx-limit-row");
|
||
const jogUp = document.getElementById("jog-up");
|
||
const jogDown = document.getElementById("jog-down");
|
||
const jogButtons = document.querySelectorAll(".jog-step button");
|
||
const vfoButtons = document.querySelectorAll("#vfo-picker button");
|
||
if (pttBtn2) pttBtn2.disabled = true;
|
||
if (powerBtn2) powerBtn2.disabled = true;
|
||
if (lockBtn2) lockBtn2.disabled = true;
|
||
if (txAudioBtn2) txAudioBtn2.disabled = true;
|
||
if (txLimitBtn2) txLimitBtn2.disabled = true;
|
||
if (freqInput) freqInput.disabled = true;
|
||
if (centerFreqInput) centerFreqInput.disabled = true;
|
||
if (modeSelect) modeSelect.disabled = true;
|
||
if (txLimitInput2) txLimitInput2.disabled = true;
|
||
vfoButtons.forEach((btn) => btn.disabled = true);
|
||
const jogWheel2 = document.getElementById("jog-wheel");
|
||
if (jogUp) jogUp.disabled = true;
|
||
if (jogDown) jogDown.disabled = true;
|
||
if (jogWheel2) jogWheel2.style.opacity = "0.5";
|
||
jogButtons.forEach((btn) => btn.disabled = true);
|
||
const pluginToggleBtns = [
|
||
"ft8-decode-toggle-btn",
|
||
"ft4-decode-toggle-btn",
|
||
"ft2-decode-toggle-btn",
|
||
"wspr-decode-toggle-btn",
|
||
"lrpt-decode-toggle-btn",
|
||
"hf-aprs-decode-toggle-btn",
|
||
"cw-auto",
|
||
"settings-clear-ais-history",
|
||
"settings-clear-vdes-history",
|
||
"settings-clear-aprs-history",
|
||
"settings-clear-hf-aprs-history",
|
||
"settings-clear-cw-history",
|
||
"settings-clear-ft8-history",
|
||
"settings-clear-ft4-history",
|
||
"settings-clear-ft2-history",
|
||
"settings-clear-wspr-history",
|
||
"settings-clear-sat-history",
|
||
"header-rec-btn",
|
||
"recorder-start-btn",
|
||
"recorder-stop-btn"
|
||
];
|
||
pluginToggleBtns.forEach((id) => {
|
||
const btn = document.getElementById(id);
|
||
if (btn instanceof HTMLButtonElement) {
|
||
btn.disabled = true;
|
||
} else if (btn instanceof HTMLInputElement && btn.type === "checkbox") {
|
||
btn.disabled = true;
|
||
}
|
||
});
|
||
if (txLimitRow2) txLimitRow2.style.opacity = "0.5";
|
||
}
|
||
}
|
||
function applyCapabilities(caps) {
|
||
if (!caps) return;
|
||
lastHasTx = !!caps.tx;
|
||
if (signalVisualBlockEl) signalVisualBlockEl.style.display = "";
|
||
const pttBtn2 = document.getElementById("ptt-btn");
|
||
const txPowerCol = document.getElementById("tx-power-col");
|
||
const txMetersRow = document.getElementById("tx-meters");
|
||
const txAudioBtn2 = document.getElementById("tx-audio-btn");
|
||
const txVolSlider2 = document.getElementById("tx-vol");
|
||
const txVolControl = txVolSlider2 ? txVolSlider2.closest(".vol-label") : null;
|
||
const hasPowerControl = !caps.filter_controls;
|
||
if (txPowerCol) {
|
||
txPowerCol.style.display = caps.tx || hasPowerControl || caps.lockable ? "" : "none";
|
||
const label = txPowerCol.querySelector(".label span");
|
||
if (label) {
|
||
label.textContent = caps.tx && hasPowerControl ? "Transmit / Power" : caps.tx ? "Transmit / Tuning" : hasPowerControl ? "Power / Tuning" : "Tuning";
|
||
}
|
||
}
|
||
if (pttBtn2) pttBtn2.style.display = caps.tx ? "" : "none";
|
||
if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none";
|
||
if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none";
|
||
if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none";
|
||
if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none";
|
||
if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none";
|
||
if (!caps.tx && typeof stopTxAudio === "function" && txActive) {
|
||
void stopTxAudio();
|
||
}
|
||
const txLimitRow2 = document.getElementById("tx-limit-row");
|
||
if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none";
|
||
const vfoRow = document.getElementById("vfo-row");
|
||
if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none";
|
||
document.querySelectorAll(".full-row.label-below-row").forEach((row) => {
|
||
const label = row.querySelector(".label span");
|
||
if (label && label.textContent === "Signal") {
|
||
row.style.display = caps.signal_meter && !caps.filter_controls ? "" : "none";
|
||
}
|
||
});
|
||
const spectrumPanel = document.getElementById("spectrum-panel");
|
||
const centerFreqField = document.getElementById("center-freq-field");
|
||
if (spectrumPanel) {
|
||
if (caps.filter_controls) {
|
||
spectrumPanel.style.display = "";
|
||
setSignalSplitControlVisible(true);
|
||
if (centerFreqField) centerFreqField.style.display = "";
|
||
startSpectrumStreaming();
|
||
} else {
|
||
spectrumPanel.style.display = "none";
|
||
setSignalSplitControlVisible(false);
|
||
if (centerFreqField) centerFreqField.style.display = "none";
|
||
stopSpectrumStreaming();
|
||
resizeHeaderSignalCanvas();
|
||
scheduleOverviewDraw();
|
||
}
|
||
scheduleSpectrumLayout();
|
||
}
|
||
if (!caps.filter_controls) {
|
||
sdrSquelchSupported = false;
|
||
}
|
||
updateSdrSquelchControlVisibility();
|
||
window.trx?.modules?.vchan?.applyCapabilities(caps);
|
||
}
|
||
var freqEl = requiredElement("freq");
|
||
var centerFreqEl = requiredElement("center-freq");
|
||
var wavelengthEl = requiredElement("wavelength");
|
||
var sigStrengthEl = requiredElement("sig-strength");
|
||
var modeEl = requiredElement("mode");
|
||
var bandLabel = document.getElementById("band-label");
|
||
var powerBtn = requiredElement("power-btn");
|
||
var powerHint = requiredElement("power-hint");
|
||
var vfoPicker = requiredElement("vfo-picker");
|
||
var signalBar = requiredElement("signal-bar");
|
||
var signalValue = requiredElement("signal-value");
|
||
var pttBtn = requiredElement("ptt-btn");
|
||
var txLimitInput = requiredElement("tx-limit");
|
||
var txLimitBtn = requiredElement("tx-limit-btn");
|
||
var txLimitRow = requiredElement("tx-limit-row");
|
||
var lockBtn = requiredElement("lock-btn");
|
||
var txMeters = requiredElement("tx-meters");
|
||
var pwrBar = requiredElement("pwr-bar");
|
||
var pwrValue = requiredElement("pwr-value");
|
||
var swrBar = requiredElement("swr-bar");
|
||
var swrValue = requiredElement("swr-value");
|
||
var loadingEl = requiredElement("loading");
|
||
var contentEl = requiredElement("content");
|
||
var serverSubtitle = requiredElement("server-subtitle");
|
||
var rigSubtitle = requiredElement("rig-subtitle");
|
||
var ownerSubtitle = document.getElementById("owner-subtitle");
|
||
var locationSubtitle = requiredElement("location-subtitle");
|
||
var loadingTitle = requiredElement("loading-title");
|
||
var loadingSub = requiredElement("loading-sub");
|
||
var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||
var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||
var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||
var connLostOverlayEl = document.getElementById("conn-lost-overlay");
|
||
var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title");
|
||
var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub");
|
||
var overviewCanvas = requiredElement("overview-canvas");
|
||
var signalOverlayCanvas = requiredElement("signal-overlay-canvas");
|
||
var spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true };
|
||
var overviewGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) : null;
|
||
var signalOverlayGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) : null;
|
||
var signalVisualBlockEl = document.querySelector(".signal-visual-block");
|
||
var signalSplitControlEl = document.getElementById("signal-split-control");
|
||
var signalSplitSliderEl = document.getElementById("signal-split-slider");
|
||
var signalSplitValueEl = document.getElementById("signal-split-value");
|
||
var overviewPeakHoldEl = document.getElementById("overview-peak-hold");
|
||
var themeToggleBtn = document.getElementById("theme-toggle");
|
||
var headerRigSwitchSelect = document.getElementById("header-rig-switch-select");
|
||
var headerStylePickSelect = document.getElementById("header-style-pick-select");
|
||
var rdsPsOverlay = document.getElementById("rds-ps-overlay");
|
||
var tabMainEl = document.getElementById("tab-main");
|
||
var aboutServerVerEl = null;
|
||
var aboutServerBuildDateEl = null;
|
||
var aboutServerAddrEl = null;
|
||
var aboutServerCallEl = null;
|
||
var aboutServerLocationEl = null;
|
||
var aboutRigInfoEl = null;
|
||
var aboutRigAccessEl = null;
|
||
var aboutModesEl = null;
|
||
var aboutVfosEl = null;
|
||
var aboutActiveRigEl = null;
|
||
var aboutAudioCodecEl = null;
|
||
var aboutAudioSamplerateEl = null;
|
||
var aboutAudioChannelsEl = null;
|
||
var aboutAudioBitrateEl = null;
|
||
var aboutAudioFrameEl = null;
|
||
var aboutAudioRxEl = null;
|
||
var aboutAudioStreamsEl = null;
|
||
var aboutPskreporterEl = null;
|
||
var aboutAprsIsEl = null;
|
||
var aboutRigctlClientsEl = null;
|
||
var aboutRigctlEndpointEl = null;
|
||
var aboutClientsEl = null;
|
||
var _aboutElsResolved = false;
|
||
function _resolveAboutEls() {
|
||
if (_aboutElsResolved) return;
|
||
aboutServerVerEl = document.getElementById("about-server-ver");
|
||
if (!aboutServerVerEl) return;
|
||
_aboutElsResolved = true;
|
||
aboutServerBuildDateEl = document.getElementById("about-server-build-date");
|
||
aboutServerAddrEl = document.getElementById("about-server-addr");
|
||
aboutServerCallEl = document.getElementById("about-server-call");
|
||
aboutServerLocationEl = document.getElementById("about-server-location");
|
||
aboutRigInfoEl = document.getElementById("about-rig-info");
|
||
aboutRigAccessEl = document.getElementById("about-rig-access");
|
||
aboutModesEl = document.getElementById("about-modes");
|
||
aboutVfosEl = document.getElementById("about-vfos");
|
||
aboutActiveRigEl = document.getElementById("about-active-rig");
|
||
aboutAudioCodecEl = document.getElementById("about-audio-codec");
|
||
aboutAudioSamplerateEl = document.getElementById("about-audio-samplerate");
|
||
aboutAudioChannelsEl = document.getElementById("about-audio-channels");
|
||
aboutAudioBitrateEl = document.getElementById("about-audio-bitrate");
|
||
aboutAudioFrameEl = document.getElementById("about-audio-frame");
|
||
aboutAudioRxEl = document.getElementById("about-audio-rx");
|
||
aboutAudioStreamsEl = document.getElementById("about-audio-streams");
|
||
aboutPskreporterEl = document.getElementById("about-pskreporter");
|
||
aboutAprsIsEl = document.getElementById("about-aprs-is");
|
||
aboutRigctlClientsEl = document.getElementById("about-rigctl-clients");
|
||
aboutRigctlEndpointEl = document.getElementById("about-rigctl-endpoint");
|
||
aboutClientsEl = document.getElementById("about-clients");
|
||
}
|
||
var cwAutoEl = document.getElementById("cw-auto");
|
||
var cwWpmEl = document.getElementById("cw-wpm");
|
||
var cwToneEl = document.getElementById("cw-tone");
|
||
var overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2e3));
|
||
var decodeHistoryRetentionMin = 24 * 60;
|
||
var _decoderToggles = {};
|
||
function _ensureDecoderToggles() {
|
||
if (decoderRegistry.length === 0) return;
|
||
for (const d of decoderRegistry) {
|
||
if (d.activation !== "toggle") continue;
|
||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||
if (_decoderToggles[key]) continue;
|
||
const el = document.getElementById(d.id + "-decode-toggle-btn");
|
||
if (el) _decoderToggles[key] = { el, last: null, label: d.label };
|
||
}
|
||
}
|
||
function syncDecoderToggle(entry, enabled, label) {
|
||
if (!entry.el || entry.last === enabled) return;
|
||
entry.last = enabled;
|
||
entry.el.dataset.enabled = enabled ? "true" : "false";
|
||
entry.el.textContent = enabled ? `Disable ${label}` : `Enable ${label}`;
|
||
entry.el.style.borderColor = enabled ? "#00d17f" : "";
|
||
entry.el.style.color = enabled ? "#00d17f" : "";
|
||
}
|
||
var _aboutDecIds = [
|
||
"about-dec-ft8",
|
||
"about-dec-ft4",
|
||
"about-dec-ft2",
|
||
"about-dec-wspr",
|
||
"about-dec-cw",
|
||
"about-dec-aprs",
|
||
"about-dec-lrpt"
|
||
];
|
||
var _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null }));
|
||
function _resolveAboutDecEls() {
|
||
if (_aboutDecEls[0]?.el) return;
|
||
for (let i = 0; i < _aboutDecIds.length; i++) {
|
||
const entry = _aboutDecEls[i];
|
||
const id = _aboutDecIds[i];
|
||
if (entry && id) entry.el = document.getElementById(id);
|
||
}
|
||
}
|
||
function syncAboutDecoder(idx, enabled) {
|
||
const entry = _aboutDecEls[idx];
|
||
if (!entry || !entry.el || entry.last === enabled) return;
|
||
entry.last = enabled;
|
||
entry.el.textContent = enabled ? "Active" : "Off";
|
||
entry.el.className = enabled ? "about-status-on" : "about-status-off";
|
||
}
|
||
var primaryRds = null;
|
||
var vchanRdsById = /* @__PURE__ */ new Map();
|
||
var vchanSignalDbById = /* @__PURE__ */ new Map();
|
||
var rdsOverlayEntries = [];
|
||
function currentDecodeHistoryRetentionMs() {
|
||
const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || 24 * 60));
|
||
return minutes * 60 * 1e3;
|
||
}
|
||
window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs;
|
||
window.applyDecodeHistoryRetention = function() {
|
||
for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) {
|
||
window.trxPluginRuntime.prune(decoder);
|
||
}
|
||
};
|
||
function syncTopBarAccess() {
|
||
const loggedOut = authEnabled && !authRole;
|
||
const tabBar = document.getElementById("tab-bar");
|
||
const rigSwitch = document.querySelector(".header-rig-switch");
|
||
if (tabBar) tabBar.style.display = "";
|
||
document.querySelectorAll(".tab-bar .tab").forEach((btn) => {
|
||
const isMain = btn.dataset.tab === "main";
|
||
btn.style.display = !loggedOut || isMain ? "" : "none";
|
||
btn.disabled = false;
|
||
});
|
||
if (rigSwitch) {
|
||
rigSwitch.style.display = loggedOut ? "none" : "";
|
||
}
|
||
if (headerRigSwitchSelect) {
|
||
headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0;
|
||
}
|
||
}
|
||
var overviewDrawPending = false;
|
||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
||
if (!decodeHistoryOverlayEl) return;
|
||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||
}
|
||
function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) {
|
||
if (!connLostOverlayEl) return;
|
||
if (connLostOverlayTitleEl) connLostOverlayTitleEl.textContent = title;
|
||
if (connLostOverlaySubEl) connLostOverlaySubEl.textContent = sub;
|
||
connLostOverlayEl.classList.toggle("conn-lost-fullscreen", fullscreen);
|
||
connLostOverlayEl.classList.toggle("is-hidden", !visible);
|
||
}
|
||
var decodeHistoryReplayActive = false;
|
||
var decodeMapSyncPending = false;
|
||
function markDecodeMapSyncPending() {
|
||
decodeMapSyncPending = true;
|
||
}
|
||
function flushDeferredDecodeMapSync() {
|
||
if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap) return;
|
||
decodeMapSyncPending = false;
|
||
scheduleUiFrameJob("decode-map-maintenance", () => {
|
||
window.trx.modules.map?.pruneMapHistory();
|
||
});
|
||
}
|
||
function setDecodeHistoryReplayActive(active) {
|
||
decodeHistoryReplayActive = !!active;
|
||
if (!decodeHistoryReplayActive) {
|
||
flushDeferredDecodeMapSync();
|
||
}
|
||
}
|
||
function decodeHistoryMapRenderingDeferred() {
|
||
return decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap;
|
||
}
|
||
var lastSpectrumData = null;
|
||
window.lastSpectrumData = null;
|
||
var lastTxEn = null;
|
||
var lastHasTx = true;
|
||
var lastRendered = null;
|
||
var prevRenderData = {};
|
||
var hintTimer = null;
|
||
var sigMeasuring = false;
|
||
var sigLastSUnits = null;
|
||
var sigLastDbm = null;
|
||
var SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"];
|
||
var sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0);
|
||
function sigUnit(u) {
|
||
return `<span class="sig-unit">${u}</span>`;
|
||
}
|
||
function formatSigStrength(dbm) {
|
||
if (dbm === null || !isFiniteNumber(dbm)) return "--";
|
||
const unit = SIG_STRENGTH_UNITS[sigStrengthUnitIdx] || "dBFS";
|
||
if (unit === "S") return formatSignal(dbmToSUnits(dbm));
|
||
if (unit === "dBm") return `${dbm.toFixed(1)} ${sigUnit("dBm")}`;
|
||
if (unit === "dBf") {
|
||
const dbf = dbm + 107;
|
||
return `${dbf.toFixed(1)} ${sigUnit("dBf")}`;
|
||
}
|
||
const dbfs = Math.max(-140, Math.min(0, dbm));
|
||
return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`;
|
||
}
|
||
function refreshSigStrengthDisplay() {
|
||
if (!sigStrengthEl) return;
|
||
sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm);
|
||
}
|
||
if (sigStrengthEl) {
|
||
sigStrengthEl.addEventListener("click", () => {
|
||
sigStrengthUnitIdx = (sigStrengthUnitIdx + 1) % SIG_STRENGTH_UNITS.length;
|
||
saveSetting("sigStrengthUnit", sigStrengthUnitIdx);
|
||
refreshSigStrengthDisplay();
|
||
});
|
||
}
|
||
var sigMeasureTimer = null;
|
||
var sigMeasureLastTickMs = 0;
|
||
var sigMeasureAccumMs = 0;
|
||
var sigMeasureWeighted = 0;
|
||
var sigMeasurePeak = null;
|
||
var lastFreqHz = null;
|
||
window.lastFreqHz = null;
|
||
var centerFreqDirty = false;
|
||
var jogUnit = loadSetting("jogUnit", 1e3);
|
||
var jogMult = loadSetting("jogMult", 1);
|
||
var jogStep = Math.max(Math.round(jogUnit / jogMult), 1);
|
||
var minFreqStepHz = 1;
|
||
var lastModeName = "";
|
||
var lastWfmCci = 0;
|
||
var lastWfmAci = 0;
|
||
var VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"];
|
||
function vfoColor(idx) {
|
||
if (idx < VFO_COLORS.length) return VFO_COLORS[idx] ?? VFO_COLORS[0] ?? "var(--accent-green)";
|
||
const hue = idx * 137 % 360;
|
||
return `hsl(${hue}, 70%, 55%)`;
|
||
}
|
||
var jogAngle = 0;
|
||
var lastClientCount = null;
|
||
var lastLocked = false;
|
||
var sdrSquelchSupported = false;
|
||
var previousTuneState = null;
|
||
function savePreviousTuneState() {
|
||
previousTuneState = {
|
||
freqHz: lastFreqHz,
|
||
bandwidthHz: currentBandwidthHz,
|
||
mode: modeEl ? modeEl.value : "",
|
||
centerHz: lastSpectrumData ? Number(lastSpectrumData.center_hz) : null
|
||
};
|
||
}
|
||
async function restorePreviousTuneState() {
|
||
if (!previousTuneState) {
|
||
showHint("No previous state", 1500);
|
||
return;
|
||
}
|
||
const saved = previousTuneState;
|
||
savePreviousTuneState();
|
||
if (saved.mode && modeEl && modeEl.value !== saved.mode) {
|
||
modeEl.value = saved.mode;
|
||
syncModePicker();
|
||
await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`);
|
||
updateWfmControls();
|
||
}
|
||
if (isFiniteNumber(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) {
|
||
currentBandwidthHz = saved.bandwidthHz;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(currentBandwidthHz);
|
||
await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`);
|
||
}
|
||
if (saved.freqHz !== null && isFiniteNumber(saved.freqHz)) {
|
||
setRigFrequency(saved.freqHz);
|
||
}
|
||
if (isFiniteNumber(saved.centerHz)) {
|
||
await postPath(`/set_center_freq?hz=${saved.centerHz}`);
|
||
}
|
||
showHint("Restored previous", 1500);
|
||
}
|
||
var lastRigIds = [];
|
||
var lastRigDisplayNames = {};
|
||
var lastActiveRigId = null;
|
||
var rigSwitchInProgress = false;
|
||
var lastCityLabel = "";
|
||
var sseSessionId = null;
|
||
var originalTitle = document.title;
|
||
var savedTheme = loadSetting("theme", null);
|
||
function currentTheme() {
|
||
return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
||
}
|
||
function updateDocumentTitle(rds = null) {
|
||
const freqHz = activeChannelFreqHz();
|
||
if (!isFiniteNumber(freqHz)) {
|
||
document.title = originalTitle;
|
||
return;
|
||
}
|
||
const parts = [formatFrequency(freqHz)];
|
||
const ps = rds?.program_service;
|
||
if (ps && ps.length > 0) {
|
||
parts.push(ps);
|
||
}
|
||
const rigName = lastActiveRigId && lastRigDisplayNames[lastActiveRigId] || lastActiveRigId || "";
|
||
if (rigName) parts.push(rigName);
|
||
if (lastCityLabel) parts.push(lastCityLabel);
|
||
parts.push(originalTitle);
|
||
document.title = parts.join(" - ");
|
||
}
|
||
function setTheme(theme) {
|
||
const next = theme === "light" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", next);
|
||
saveSetting("theme", next);
|
||
if (themeToggleBtn) {
|
||
themeToggleBtn.textContent = next === "dark" ? "☀️ Light" : "🌙 Dark";
|
||
themeToggleBtn.title = next === "dark" ? "Switch to light mode" : "Switch to dark mode";
|
||
}
|
||
if (typeof trxClearCssColorCache === "function") trxClearCssColorCache();
|
||
invalidateBookmarkColors();
|
||
}
|
||
function invalidateBookmarkColors() {
|
||
const bookmarks = window.trx?.modules.bookmarks;
|
||
if (!bookmarks) return;
|
||
bookmarks.invalidateColors();
|
||
void getComputedStyle(document.documentElement).getPropertyValue("--bg");
|
||
const colorMap = bmCategoryColorMap();
|
||
const ref = bookmarks.overlayList;
|
||
document.querySelectorAll(".spectrum-bookmark-chip").forEach((chip) => {
|
||
const bm = ref.find((b) => b.id === chip.dataset.bmId);
|
||
if (!bm) return;
|
||
const col = colorMap[bm.category || ""] || "#66d9ef";
|
||
chip.style.setProperty("--bm-cat-bg", col);
|
||
chip.style.setProperty("--bm-cat-fg", bmContrastFg(col));
|
||
});
|
||
for (const id of ["spectrum-bookmark-axis", "spectrum-bookmark-side-left", "spectrum-bookmark-side-right"]) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.dataset.bmKey = "";
|
||
}
|
||
try {
|
||
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||
} catch (_) {
|
||
}
|
||
}
|
||
var CANVAS_PALETTE = {
|
||
original: {
|
||
dark: {
|
||
bg: "#0a0f18",
|
||
spectrumLine: "#00e676",
|
||
spectrumFill: "rgba(0,230,118,0.10)",
|
||
spectrumGrid: "rgba(255,255,255,0.06)",
|
||
spectrumLabel: "rgba(180,200,220,0.45)",
|
||
waveformLine: "rgba(94,234,212,0.92)",
|
||
waveformPeak: "rgba(251,191,36,0.88)",
|
||
waveformGrid: "rgba(148,163,184,0.12)",
|
||
waveformLabel: "rgba(203,213,225,0.72)",
|
||
waterfallHue: [225, 30],
|
||
waterfallSat: 88,
|
||
waterfallLight: [16, 68],
|
||
waterfallAlpha: [0.28, 0.86]
|
||
},
|
||
light: {
|
||
bg: "#eef3fb",
|
||
spectrumLine: "#007a47",
|
||
spectrumFill: "rgba(0,110,70,0.12)",
|
||
spectrumGrid: "rgba(0,30,80,0.10)",
|
||
spectrumLabel: "rgba(30,50,90,0.55)",
|
||
waveformLine: "rgba(17,94,89,0.95)",
|
||
waveformPeak: "rgba(217,119,6,0.9)",
|
||
waveformGrid: "rgba(71,85,105,0.14)",
|
||
waveformLabel: "rgba(51,65,85,0.72)",
|
||
waterfallHue: [210, 35],
|
||
waterfallSat: 82,
|
||
waterfallLight: [92, 40],
|
||
waterfallAlpha: [0.42, 0.8]
|
||
}
|
||
},
|
||
arctic: {
|
||
dark: {
|
||
bg: "#1e2530",
|
||
spectrumLine: "#88c0d0",
|
||
spectrumFill: "rgba(136,192,208,0.12)",
|
||
spectrumGrid: "rgba(216,222,233,0.08)",
|
||
spectrumLabel: "rgba(216,222,233,0.55)",
|
||
waveformLine: "rgba(136,192,208,0.92)",
|
||
waveformPeak: "rgba(235,203,139,0.88)",
|
||
waveformGrid: "rgba(216,222,233,0.10)",
|
||
waveformLabel: "rgba(216,222,233,0.65)",
|
||
waterfallHue: [212, 188],
|
||
waterfallSat: 70,
|
||
waterfallLight: [14, 58],
|
||
waterfallAlpha: [0.28, 0.82]
|
||
},
|
||
light: {
|
||
bg: "#dde1e9",
|
||
spectrumLine: "#5e81ac",
|
||
spectrumFill: "rgba(94,129,172,0.14)",
|
||
spectrumGrid: "rgba(46,52,64,0.08)",
|
||
spectrumLabel: "rgba(46,52,64,0.55)",
|
||
waveformLine: "rgba(94,129,172,0.95)",
|
||
waveformPeak: "rgba(208,135,112,0.9)",
|
||
waveformGrid: "rgba(46,52,64,0.12)",
|
||
waveformLabel: "rgba(46,52,64,0.65)",
|
||
waterfallHue: [215, 195],
|
||
waterfallSat: 65,
|
||
waterfallLight: [88, 45],
|
||
waterfallAlpha: [0.35, 0.78]
|
||
}
|
||
},
|
||
lime: {
|
||
dark: {
|
||
bg: "#181815",
|
||
spectrumLine: "#a6e22e",
|
||
spectrumFill: "rgba(166,226,46,0.10)",
|
||
spectrumGrid: "rgba(248,248,242,0.05)",
|
||
spectrumLabel: "rgba(248,248,242,0.45)",
|
||
waveformLine: "rgba(166,226,46,0.92)",
|
||
waveformPeak: "rgba(230,219,116,0.88)",
|
||
waveformGrid: "rgba(248,248,242,0.08)",
|
||
waveformLabel: "rgba(248,248,242,0.65)",
|
||
waterfallHue: [70, 38],
|
||
waterfallSat: 80,
|
||
waterfallLight: [12, 62],
|
||
waterfallAlpha: [0.25, 0.88]
|
||
},
|
||
light: {
|
||
bg: "#ede8d8",
|
||
spectrumLine: "#5f8700",
|
||
spectrumFill: "rgba(95,135,0,0.12)",
|
||
spectrumGrid: "rgba(39,40,34,0.08)",
|
||
spectrumLabel: "rgba(39,40,34,0.50)",
|
||
waveformLine: "rgba(95,135,0,0.95)",
|
||
waveformPeak: "rgba(176,120,0,0.9)",
|
||
waveformGrid: "rgba(39,40,34,0.10)",
|
||
waveformLabel: "rgba(39,40,34,0.60)",
|
||
waterfallHue: [75, 42],
|
||
waterfallSat: 75,
|
||
waterfallLight: [90, 42],
|
||
waterfallAlpha: [0.35, 0.78]
|
||
}
|
||
},
|
||
contrast: {
|
||
dark: {
|
||
bg: "#000000",
|
||
spectrumLine: "#00ff88",
|
||
spectrumFill: "rgba(0,255,136,0.12)",
|
||
spectrumGrid: "rgba(255,255,255,0.12)",
|
||
spectrumLabel: "rgba(255,255,255,0.70)",
|
||
waveformLine: "rgba(0,255,136,0.95)",
|
||
waveformPeak: "rgba(255,204,0,0.92)",
|
||
waveformGrid: "rgba(255,255,255,0.15)",
|
||
waveformLabel: "rgba(255,255,255,0.80)",
|
||
waterfallHue: [150, 60],
|
||
waterfallSat: 100,
|
||
waterfallLight: [8, 55],
|
||
waterfallAlpha: [0.3, 0.95]
|
||
},
|
||
light: {
|
||
bg: "#f4f4f4",
|
||
spectrumLine: "#005cc5",
|
||
spectrumFill: "rgba(0,92,197,0.12)",
|
||
spectrumGrid: "rgba(0,0,0,0.12)",
|
||
spectrumLabel: "rgba(0,0,0,0.65)",
|
||
waveformLine: "rgba(0,92,197,0.95)",
|
||
waveformPeak: "rgba(180,60,0,0.9)",
|
||
waveformGrid: "rgba(0,0,0,0.14)",
|
||
waveformLabel: "rgba(0,0,0,0.70)",
|
||
waterfallHue: [220, 180],
|
||
waterfallSat: 100,
|
||
waterfallLight: [90, 42],
|
||
waterfallAlpha: [0.35, 0.82]
|
||
}
|
||
},
|
||
"neon-disco": {
|
||
dark: {
|
||
bg: "#090010",
|
||
spectrumLine: "#ff10e0",
|
||
spectrumFill: "rgba(255,16,224,0.12)",
|
||
spectrumGrid: "rgba(255,16,224,0.10)",
|
||
spectrumLabel: "rgba(240,200,255,0.55)",
|
||
waveformLine: "rgba(57,255,20,0.92)",
|
||
waveformPeak: "rgba(255,16,224,0.88)",
|
||
waveformGrid: "rgba(255,16,224,0.10)",
|
||
waveformLabel: "rgba(240,200,255,0.65)",
|
||
waterfallHue: [300, 120],
|
||
waterfallSat: 100,
|
||
waterfallLight: [8, 55],
|
||
waterfallAlpha: [0.3, 0.92]
|
||
},
|
||
light: {
|
||
bg: "#f0d8ff",
|
||
spectrumLine: "#cc00a8",
|
||
spectrumFill: "rgba(204,0,168,0.12)",
|
||
spectrumGrid: "rgba(100,0,150,0.10)",
|
||
spectrumLabel: "rgba(50,0,80,0.55)",
|
||
waveformLine: "rgba(31,136,0,0.95)",
|
||
waveformPeak: "rgba(180,0,120,0.9)",
|
||
waveformGrid: "rgba(50,0,80,0.10)",
|
||
waveformLabel: "rgba(50,0,80,0.65)",
|
||
waterfallHue: [300, 120],
|
||
waterfallSat: 90,
|
||
waterfallLight: [90, 45],
|
||
waterfallAlpha: [0.35, 0.8]
|
||
}
|
||
},
|
||
"golden-rain": {
|
||
dark: {
|
||
bg: "#120d07",
|
||
spectrumLine: "#e4b24d",
|
||
spectrumFill: "rgba(228,178,77,0.11)",
|
||
spectrumGrid: "rgba(255,229,172,0.07)",
|
||
spectrumLabel: "rgba(230,205,152,0.54)",
|
||
waveformLine: "rgba(236,199,108,0.92)",
|
||
waveformPeak: "rgba(214,134,44,0.90)",
|
||
waveformGrid: "rgba(255,210,120,0.09)",
|
||
waveformLabel: "rgba(232,214,174,0.66)",
|
||
waterfallHue: [40, 18],
|
||
waterfallSat: 88,
|
||
waterfallLight: [8, 58],
|
||
waterfallAlpha: [0.26, 0.84]
|
||
},
|
||
light: {
|
||
bg: "#f5ecd9",
|
||
spectrumLine: "#9e6700",
|
||
spectrumFill: "rgba(158,103,0,0.12)",
|
||
spectrumGrid: "rgba(82,55,14,0.09)",
|
||
spectrumLabel: "rgba(82,55,14,0.55)",
|
||
waveformLine: "rgba(140,92,0,0.94)",
|
||
waveformPeak: "rgba(191,86,0,0.90)",
|
||
waveformGrid: "rgba(82,55,14,0.11)",
|
||
waveformLabel: "rgba(82,55,14,0.66)",
|
||
waterfallHue: [45, 18],
|
||
waterfallSat: 86,
|
||
waterfallLight: [92, 42],
|
||
waterfallAlpha: [0.34, 0.82]
|
||
}
|
||
},
|
||
amber: {
|
||
dark: {
|
||
bg: "#130706",
|
||
spectrumLine: "#ff7a1f",
|
||
spectrumFill: "rgba(255,122,31,0.14)",
|
||
spectrumGrid: "rgba(255,110,40,0.09)",
|
||
spectrumLabel: "rgba(255,202,164,0.54)",
|
||
waveformLine: "rgba(255,134,54,0.94)",
|
||
waveformPeak: "rgba(255,220,96,0.92)",
|
||
waveformGrid: "rgba(255,120,36,0.11)",
|
||
waveformLabel: "rgba(255,214,176,0.66)",
|
||
waterfallHue: [8, 42],
|
||
waterfallSat: 96,
|
||
waterfallLight: [8, 58],
|
||
waterfallAlpha: [0.26, 0.88]
|
||
},
|
||
light: {
|
||
bg: "#fff2e7",
|
||
spectrumLine: "#c24500",
|
||
spectrumFill: "rgba(194,69,0,0.14)",
|
||
spectrumGrid: "rgba(125,52,0,0.09)",
|
||
spectrumLabel: "rgba(90,38,0,0.56)",
|
||
waveformLine: "rgba(176,62,0,0.95)",
|
||
waveformPeak: "rgba(224,132,0,0.90)",
|
||
waveformGrid: "rgba(125,52,0,0.10)",
|
||
waveformLabel: "rgba(90,38,0,0.68)",
|
||
waterfallHue: [18, 48],
|
||
waterfallSat: 90,
|
||
waterfallLight: [92, 42],
|
||
waterfallAlpha: [0.34, 0.84]
|
||
}
|
||
},
|
||
fire: {
|
||
dark: {
|
||
bg: "#140406",
|
||
spectrumLine: "#cf1b22",
|
||
spectrumFill: "rgba(207,27,34,0.14)",
|
||
spectrumGrid: "rgba(255,84,60,0.08)",
|
||
spectrumLabel: "rgba(255,214,202,0.54)",
|
||
waveformLine: "rgba(222,46,34,0.94)",
|
||
waveformPeak: "rgba(255,112,48,0.90)",
|
||
waveformGrid: "rgba(255,84,60,0.10)",
|
||
waveformLabel: "rgba(255,226,214,0.66)",
|
||
waterfallHue: [2, 18],
|
||
waterfallSat: 96,
|
||
waterfallLight: [8, 52],
|
||
waterfallAlpha: [0.26, 0.88]
|
||
},
|
||
light: {
|
||
bg: "#ffede5",
|
||
spectrumLine: "#a91511",
|
||
spectrumFill: "rgba(169,21,17,0.14)",
|
||
spectrumGrid: "rgba(125,36,12,0.09)",
|
||
spectrumLabel: "rgba(92,24,10,0.56)",
|
||
waveformLine: "rgba(164,28,16,0.95)",
|
||
waveformPeak: "rgba(214,88,20,0.90)",
|
||
waveformGrid: "rgba(125,36,12,0.10)",
|
||
waveformLabel: "rgba(92,24,10,0.68)",
|
||
waterfallHue: [4, 24],
|
||
waterfallSat: 82,
|
||
waterfallLight: [92, 40],
|
||
waterfallAlpha: [0.34, 0.84]
|
||
}
|
||
},
|
||
phosphor: {
|
||
dark: {
|
||
bg: "#010501",
|
||
spectrumLine: "#39ff14",
|
||
spectrumFill: "rgba(57,255,20,0.13)",
|
||
spectrumGrid: "rgba(57,255,20,0.07)",
|
||
spectrumLabel: "rgba(168,230,168,0.55)",
|
||
waveformLine: "rgba(57,255,20,0.92)",
|
||
waveformPeak: "rgba(184,240,96,0.88)",
|
||
waveformGrid: "rgba(57,255,20,0.08)",
|
||
waveformLabel: "rgba(168,230,168,0.65)",
|
||
waterfallHue: [115, 90],
|
||
waterfallSat: 100,
|
||
waterfallLight: [5, 52],
|
||
waterfallAlpha: [0.28, 0.92]
|
||
},
|
||
light: {
|
||
bg: "#e0f0e0",
|
||
spectrumLine: "#1a7a1a",
|
||
spectrumFill: "rgba(26,122,26,0.13)",
|
||
spectrumGrid: "rgba(10,42,10,0.08)",
|
||
spectrumLabel: "rgba(10,42,10,0.52)",
|
||
waveformLine: "rgba(20,110,20,0.95)",
|
||
waveformPeak: "rgba(74,138,0,0.90)",
|
||
waveformGrid: "rgba(10,42,10,0.10)",
|
||
waveformLabel: "rgba(10,42,10,0.65)",
|
||
waterfallHue: [115, 90],
|
||
waterfallSat: 90,
|
||
waterfallLight: [92, 40],
|
||
waterfallAlpha: [0.34, 0.82]
|
||
}
|
||
}
|
||
};
|
||
function currentStyle() {
|
||
const style = document.documentElement.getAttribute("data-style");
|
||
return style && style in CANVAS_PALETTE ? style : "original";
|
||
}
|
||
function canvasPalette() {
|
||
const s = currentStyle();
|
||
const t = currentTheme();
|
||
return CANVAS_PALETTE[s][t];
|
||
}
|
||
function setStyle(style) {
|
||
const remapped = style === "nord" ? "arctic" : style === "monokai" ? "lime" : style === "blood" ? "fire" : style;
|
||
const next = remapped in CANVAS_PALETTE ? remapped : "original";
|
||
if (next === "original") {
|
||
document.documentElement.removeAttribute("data-style");
|
||
} else {
|
||
document.documentElement.setAttribute("data-style", next);
|
||
}
|
||
saveSetting("style", next);
|
||
if (headerStylePickSelect) headerStylePickSelect.value = next;
|
||
if (typeof trxClearCssColorCache === "function") trxClearCssColorCache();
|
||
invalidateBookmarkColors();
|
||
scheduleOverviewDraw();
|
||
}
|
||
if (overviewPeakHoldEl) {
|
||
if (!isFiniteNumber(overviewPeakHoldMs) || overviewPeakHoldMs < 0) {
|
||
overviewPeakHoldMs = 2e3;
|
||
}
|
||
overviewPeakHoldEl.value = String(overviewPeakHoldMs);
|
||
overviewPeakHoldEl.addEventListener("change", () => {
|
||
overviewPeakHoldMs = Math.max(0, Number(overviewPeakHoldEl.value) || 0);
|
||
saveSetting("overviewPeakHoldMs", overviewPeakHoldMs);
|
||
pruneSpectrumPeakHoldFrames();
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
});
|
||
}
|
||
if (savedTheme === "light" || savedTheme === "dark") {
|
||
setTheme(savedTheme);
|
||
} else {
|
||
const prefersLight = window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
|
||
setTheme(prefersLight ? "light" : "dark");
|
||
}
|
||
var savedStyle = loadSetting("style", "original");
|
||
setStyle(savedStyle);
|
||
if (themeToggleBtn) {
|
||
themeToggleBtn.addEventListener("click", () => {
|
||
setTheme(currentTheme() === "dark" ? "light" : "dark");
|
||
window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme());
|
||
window.trx.modules.map?.syncLocatorMarkerStyles();
|
||
window.trx.modules.map?.refreshAisMarkerColors();
|
||
scheduleOverviewDraw();
|
||
if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
if (headerStylePickSelect) {
|
||
headerStylePickSelect.addEventListener("change", () => {
|
||
setStyle(headerStylePickSelect.value);
|
||
window.trx.modules.map?.updateMapBaseLayerForTheme(currentTheme());
|
||
window.trx.modules.map?.syncLocatorMarkerStyles();
|
||
window.trx.modules.map?.refreshAisMarkerColors();
|
||
});
|
||
}
|
||
function readyText() {
|
||
return lastClientCount !== null ? `Ready · ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
|
||
}
|
||
var HINT_ERROR_RE = /failed|missing|unavailable|unknown|lost|required/i;
|
||
var HINT_BUSY_RE = /initializ|connecting|retrying|scanning|shifting|waiting|sending|switching|toggling|setting|not fully/i;
|
||
function hintState(msg) {
|
||
if (HINT_ERROR_RE.test(msg)) return "error";
|
||
if (HINT_BUSY_RE.test(msg) || /[\u2026]$|\.\.\.$/.test(msg)) return "busy";
|
||
return "ok";
|
||
}
|
||
function setPowerHint(msg) {
|
||
powerHint.textContent = msg;
|
||
powerHint.dataset.state = hintState(msg);
|
||
}
|
||
function rigBadgeColor(rigId) {
|
||
const text = (rigId || "rx").toString();
|
||
let hash = 0;
|
||
for (let i = 0; i < text.length; i++) {
|
||
hash = hash * 33 + text.charCodeAt(i) >>> 0;
|
||
}
|
||
const hue = hash % 360;
|
||
return `hsl(${hue}, 62%, 52%)`;
|
||
}
|
||
window.getDecodeRigMeta = function() {
|
||
const rigId = lastActiveRigId || "local";
|
||
return {
|
||
rigId,
|
||
label: lastRigDisplayNames[rigId] || rigId,
|
||
color: rigBadgeColor(rigId)
|
||
};
|
||
};
|
||
function populateRigPicker(selectEl, rigIds, activeRigId, disabled) {
|
||
if (!selectEl) return;
|
||
const selectedBefore = selectEl.value;
|
||
selectEl.replaceChildren();
|
||
rigIds.forEach((id) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = id;
|
||
opt.textContent = lastRigDisplayNames[id] || id;
|
||
selectEl.appendChild(opt);
|
||
});
|
||
const preferred = typeof activeRigId === "string" && rigIds.includes(activeRigId) ? activeRigId : selectedBefore;
|
||
if (preferred && rigIds.includes(preferred)) {
|
||
selectEl.value = preferred;
|
||
}
|
||
selectEl.disabled = disabled;
|
||
}
|
||
function updateRigSubtitle(activeRigId) {
|
||
if (!rigSubtitle) return;
|
||
const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--";
|
||
rigSubtitle.textContent = `Rig: ${name}`;
|
||
updateDocumentTitle(activeChannelRds());
|
||
}
|
||
function applyRigList(activeRigId, rigIds, displayNames = {}) {
|
||
if (!Array.isArray(rigIds)) return;
|
||
const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0);
|
||
const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||
lastRigIds = nextIds;
|
||
if (displayNames && typeof displayNames === "object") {
|
||
lastRigDisplayNames = { ...displayNames };
|
||
}
|
||
const aboutList = document.getElementById("about-rig-list");
|
||
if (aboutList) {
|
||
aboutList.textContent = lastRigIds.length ? lastRigIds.join(", ") : "--";
|
||
}
|
||
if (typeof activeRigId === "string" && activeRigId.length > 0) {
|
||
if (!lastActiveRigId) {
|
||
lastActiveRigId = activeRigId;
|
||
}
|
||
const aboutActive = document.getElementById("about-active-rig");
|
||
if (aboutActive) aboutActive.textContent = lastActiveRigId;
|
||
}
|
||
const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || "");
|
||
const rigListChanged = prevKey !== nextKey;
|
||
const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx";
|
||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||
updateRigSubtitle(lastActiveRigId);
|
||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||
if (rigListChanged) {
|
||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||
window.trx.modules.bookmarks?.populateScopePicker();
|
||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||
}
|
||
window.trx.modules.map?.updateMapRigFilter();
|
||
}
|
||
async function refreshRigList() {
|
||
try {
|
||
const resp = await fetch("/rigs", { cache: "no-store" });
|
||
if (!resp.ok) return;
|
||
const data = await responseJsonUnknown(resp);
|
||
if (!isRigListResponse(data)) return;
|
||
const rigs = data.rigs;
|
||
const rigIds = rigs.map((r) => r.remote).filter(Boolean);
|
||
const displayNames = {};
|
||
rigs.forEach((r) => {
|
||
if (!r || !r.remote) return;
|
||
if (typeof r.display_name === "string" && r.display_name.length > 0) {
|
||
displayNames[r.remote] = r.display_name;
|
||
} else {
|
||
const mfg = (r.manufacturer || "").trim();
|
||
const mdl = (r.model || "").trim();
|
||
const hw = [mfg, mdl].filter(Boolean).join(" ");
|
||
displayNames[r.remote] = hw || r.remote;
|
||
}
|
||
});
|
||
serverRigs = rigs;
|
||
refreshOperatorLayoutCapabilities();
|
||
serverActiveRigId = data.active_remote;
|
||
applyRigList(data.active_remote, rigIds, displayNames);
|
||
window.trx.modules.map?.syncAprsReceiverMarker();
|
||
} catch (e) {
|
||
}
|
||
}
|
||
function refreshOperatorLayoutCapabilities() {
|
||
const rigModes = serverRigs.map(
|
||
(rig) => Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []
|
||
);
|
||
const decoderModes = new Set(decoderRegistry.flatMap(
|
||
(decoder) => Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : []
|
||
));
|
||
window.trxUi?.setLayoutCapabilities({
|
||
broadcast: rigModes.some((modes) => modes.includes("WFM")),
|
||
digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode)))
|
||
});
|
||
}
|
||
function showHint(msg, duration) {
|
||
setPowerHint(msg);
|
||
if (hintTimer) clearTimeout(hintTimer);
|
||
if (duration) hintTimer = setTimeout(() => {
|
||
setPowerHint(readyText());
|
||
}, duration);
|
||
if (HINT_ERROR_RE.test(msg)) {
|
||
window.trxUi?.notify(msg, { kind: "error" });
|
||
}
|
||
}
|
||
var supportedModes = [];
|
||
var supportedBands = [];
|
||
var lastUnsupportedFreqPopupAt = 0;
|
||
var freqDirty = false;
|
||
var initialized = false;
|
||
var lastEventAt = Date.now();
|
||
var aboutUptimeStart = null;
|
||
var es = null;
|
||
var esHeartbeat = null;
|
||
setInterval(() => {
|
||
if (!aboutUptimeStart) return;
|
||
const el = document.getElementById("about-uptime");
|
||
if (el) el.textContent = formatDuration(Date.now() - aboutUptimeStart);
|
||
}, 1e3);
|
||
var reconnectTimer = null;
|
||
var overviewSignalSamples = [];
|
||
var overviewSignalTimer = null;
|
||
var overviewWaterfallRows = [];
|
||
var overviewWaterfallPushCount = 0;
|
||
var HEADER_SIG_WINDOW_MS = 1e4;
|
||
var OVERVIEW_WF_TEX_MAX_W = 512;
|
||
var overviewWfTexData = null;
|
||
var overviewWfTexWidth = 0;
|
||
var overviewWfTexHeight = 0;
|
||
var overviewWfTexPushCount = 0;
|
||
var overviewWfTexPalKey = "";
|
||
var overviewWfTexReady = false;
|
||
function cssColorToRgba(color, alphaMul = 1) {
|
||
const parsed = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor(color) : [0, 0, 0, 1];
|
||
return [
|
||
parsed[0] ?? 0,
|
||
parsed[1] ?? 0,
|
||
parsed[2] ?? 0,
|
||
Math.max(0, Math.min(1, (parsed[3] ?? 1) * alphaMul))
|
||
];
|
||
}
|
||
function rgbaWithAlpha(color, alphaMul = 1) {
|
||
return cssColorToRgba(color, alphaMul);
|
||
}
|
||
var BW_OVERLAY_COLORS = {
|
||
soft: [240 / 255, 173 / 255, 78 / 255, 0.05],
|
||
mid: [240 / 255, 173 / 255, 78 / 255, 0.19],
|
||
edge: [240 / 255, 173 / 255, 78 / 255, 0.3],
|
||
stroke: [240 / 255, 173 / 255, 78 / 255, 0.7],
|
||
hard: [240 / 255, 173 / 255, 78 / 255, 0.38]
|
||
};
|
||
var BOOKMARK_MARKER_FALLBACK = "#66d9ef";
|
||
function overviewWfResetTextureCache() {
|
||
overviewWfTexData = null;
|
||
overviewWfTexWidth = 0;
|
||
overviewWfTexHeight = 0;
|
||
overviewWfTexPushCount = 0;
|
||
overviewWfTexPalKey = "";
|
||
overviewWfTexReady = false;
|
||
}
|
||
function overviewWfPaletteKey(pal, viewKey = "") {
|
||
return `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
|
||
}
|
||
function resizeHeaderSignalCanvas() {
|
||
if (!ensureOverviewCanvasBackingStore()) return;
|
||
positionRdsPsOverlay();
|
||
drawHeaderSignalGraph();
|
||
}
|
||
function ensureOverviewCanvasBackingStore() {
|
||
if (!overviewCanvas || !overviewGl || !overviewGl.ready) return false;
|
||
const cssW = Math.floor(overviewCanvas.clientWidth);
|
||
const cssH = Math.floor(overviewCanvas.clientHeight);
|
||
if (cssW <= 0 || cssH <= 0) return false;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const resized = overviewGl.ensureSize(cssW, cssH, dpr);
|
||
if (resized) {
|
||
overviewWfResetTextureCache();
|
||
trimOverviewWaterfallRows();
|
||
}
|
||
return true;
|
||
}
|
||
function signalOverlayHeight() {
|
||
if (!overviewCanvas) return 0;
|
||
let height = overviewCanvas.clientHeight || 0;
|
||
if (bandplanStripEl && bandplanStripEl.classList.contains("bp-visible")) {
|
||
height += bandplanStripEl.clientHeight || 0;
|
||
}
|
||
const spectrumCanvasEl = document.getElementById("spectrum-canvas");
|
||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||
const spectrumVisible = spectrumCanvasEl && spectrumCanvasEl.clientHeight > 0 && spectrumPanelEl && getComputedStyle(spectrumPanelEl).display !== "none";
|
||
if (spectrumVisible) {
|
||
height += spectrumCanvasEl.clientHeight || 0;
|
||
const wfCanvas = document.getElementById("spectrum-waterfall-canvas");
|
||
if (wfCanvas && wfCanvas.clientHeight > 0) {
|
||
height += wfCanvas.clientHeight;
|
||
}
|
||
}
|
||
return Math.floor(height);
|
||
}
|
||
function drawSignalOverlay() {
|
||
if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return;
|
||
if (!lastSpectrumData) {
|
||
signalOverlayCanvas.style.height = "0";
|
||
signalOverlayCanvas.width = 0;
|
||
signalOverlayCanvas.height = 0;
|
||
return;
|
||
}
|
||
const cssW = Math.floor(signalVisualBlockEl.clientWidth);
|
||
const cssH = signalOverlayHeight();
|
||
signalOverlayCanvas.style.height = cssH > 0 ? `${cssH}px` : "0";
|
||
if (cssW <= 0 || cssH <= 0) {
|
||
signalOverlayCanvas.width = 0;
|
||
signalOverlayCanvas.height = 0;
|
||
return;
|
||
}
|
||
const dpr = window.devicePixelRatio || 1;
|
||
signalOverlayGl.ensureSize(cssW, cssH, dpr);
|
||
const W = signalOverlayCanvas.width;
|
||
const H = signalOverlayCanvas.height;
|
||
if (W <= 0 || H <= 0) return;
|
||
signalOverlayGl.clear([0, 0, 0, 0]);
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
const hzToX = (hz) => (hz - range.visLoHz) / range.visSpanHz * W;
|
||
const bwSoft = BW_OVERLAY_COLORS.soft;
|
||
const bwMid = BW_OVERLAY_COLORS.mid;
|
||
const bwEdge = BW_OVERLAY_COLORS.edge;
|
||
const bwStroke = BW_OVERLAY_COLORS.stroke;
|
||
const bwHard = BW_OVERLAY_COLORS.hard;
|
||
const bmRef = window.trx.modules.bookmarks?.overlayList ?? null;
|
||
if (bmRef && bmRef.length > 0) {
|
||
const colorMap = bmCategoryColorMap();
|
||
const grouped = /* @__PURE__ */ new Map();
|
||
for (const bm of bmRef) {
|
||
const f = Number(bm?.freq_hz);
|
||
if (!isFiniteNumber(f) || f < range.visLoHz || f > range.visHiHz) continue;
|
||
if (lastFreqHz !== null && isFiniteNumber(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue;
|
||
const x = hzToX(f);
|
||
if (!isFiniteNumber(x) || x < 0 || x > W) continue;
|
||
const color = colorMap[bm?.category || ""] || BOOKMARK_MARKER_FALLBACK;
|
||
const segments = grouped.get(color) ?? [];
|
||
segments.push(x, 0, x, H);
|
||
grouped.set(color, segments);
|
||
}
|
||
for (const [color, segments] of grouped.entries()) {
|
||
if (!Array.isArray(segments) || segments.length === 0) continue;
|
||
signalOverlayGl.drawSegments(segments, rgbaWithAlpha(color, 0.72), Math.max(1, dpr * 0.9));
|
||
}
|
||
}
|
||
const _bwCenterHz = activeBandwidthCenterHz();
|
||
if (_bwCenterHz != null && currentBandwidthHz > 0) {
|
||
for (const spec of visibleBandwidthSpecs(_bwCenterHz)) {
|
||
const span = displaySpanForBandwidthSpec(spec);
|
||
const xL = hzToX(span.loHz);
|
||
const xR = hzToX(span.hiHz);
|
||
const stripW = xR - xL;
|
||
if (stripW <= 1) continue;
|
||
if (span.side < 0) {
|
||
signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwSoft, bwMid, bwMid, bwSoft);
|
||
} else if (span.side > 0) {
|
||
signalOverlayGl.fillGradientRect(xL, 0, stripW, H, bwMid, bwSoft, bwSoft, bwMid);
|
||
} else {
|
||
const half = stripW / 2;
|
||
signalOverlayGl.fillGradientRect(xL, 0, half, H, bwSoft, bwMid, bwMid, bwSoft);
|
||
signalOverlayGl.fillGradientRect(xL + half, 0, half, H, bwMid, bwSoft, bwSoft, bwMid);
|
||
}
|
||
const edgeW = Math.max(1, Math.round(5 * dpr));
|
||
if (span.side <= 0) {
|
||
signalOverlayGl.fillRect(xL, 0, edgeW, H, bwEdge);
|
||
}
|
||
if (span.side >= 0) {
|
||
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) {
|
||
const hardX = span.side < 0 ? xR : xL;
|
||
signalOverlayGl.drawSegments([hardX, 0, hardX, H], bwHard, Math.max(1, dpr));
|
||
}
|
||
}
|
||
}
|
||
const virtualChannels = window.trx.modules.vchan?.channels || [];
|
||
if (virtualChannels.length > 0) {
|
||
virtualChannels.forEach((ch) => {
|
||
if (!isFiniteNumber(ch.freq_hz) || ch.freq_hz <= 0) return;
|
||
const xc = hzToX(ch.freq_hz);
|
||
if (xc < 0 || xc > W) return;
|
||
const isActive = ch.id === window.trx.modules.vchan?.activeId;
|
||
const color = cssColorToRgba("#38bdf8");
|
||
if (isActive) {
|
||
signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5));
|
||
} else {
|
||
signalOverlayGl.drawDashedVerticalLine(
|
||
xc,
|
||
0,
|
||
H,
|
||
Math.max(2, Math.round(4 * dpr)),
|
||
Math.max(3, Math.round(6 * dpr)),
|
||
color,
|
||
Math.max(1, dpr)
|
||
);
|
||
}
|
||
});
|
||
}
|
||
if (lastFreqHz != null) {
|
||
const xf = hzToX(lastFreqHz);
|
||
if (xf >= 0 && xf <= W) {
|
||
signalOverlayGl.drawDashedVerticalLine(
|
||
xf,
|
||
0,
|
||
H,
|
||
Math.max(2, Math.round(4 * dpr)),
|
||
Math.max(2, Math.round(4 * dpr)),
|
||
cssColorToRgba("#ff1744"),
|
||
Math.max(1, dpr)
|
||
);
|
||
}
|
||
}
|
||
}
|
||
function scheduleOverviewDraw() {
|
||
if (!overviewCanvas || overviewDrawPending) return;
|
||
overviewDrawPending = true;
|
||
requestAnimationFrame(() => {
|
||
overviewDrawPending = false;
|
||
drawHeaderSignalGraph();
|
||
});
|
||
}
|
||
function pushHeaderSignalSample(sUnits) {
|
||
if (!overviewCanvas) return;
|
||
const now = Date.now();
|
||
const sample = isFiniteNumber(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0;
|
||
overviewSignalSamples.push({ t: now, v: sample });
|
||
while (overviewSignalSamples[0] && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) {
|
||
overviewSignalSamples.shift();
|
||
}
|
||
scheduleOverviewDraw();
|
||
}
|
||
function trimOverviewWaterfallRows() {
|
||
if (!overviewCanvas) return;
|
||
const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr));
|
||
if (overviewWaterfallRows.length > maxRows) {
|
||
overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows);
|
||
}
|
||
}
|
||
function overviewVisibleBinWindow(data, binCount) {
|
||
if (!data || !isFiniteNumber(data.sample_rate) || binCount <= 1) {
|
||
return { startIdx: 0, endIdx: Math.max(0, binCount - 1) };
|
||
}
|
||
const range = spectrumVisibleRange(data);
|
||
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||
const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate;
|
||
const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate;
|
||
const maxIdx = binCount - 1;
|
||
const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx)));
|
||
const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx)));
|
||
return { startIdx, endIdx };
|
||
}
|
||
function pushOverviewWaterfallFrame(data) {
|
||
if (!overviewCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return;
|
||
overviewWaterfallRows.push(data.bins.slice());
|
||
overviewWaterfallPushCount++;
|
||
trimOverviewWaterfallRows();
|
||
scheduleOverviewDraw();
|
||
}
|
||
function startHeaderSignalSampling() {
|
||
if (!overviewCanvas || overviewSignalTimer) return;
|
||
overviewSignalTimer = setInterval(() => {
|
||
pushHeaderSignalSample(isFiniteNumber(sigLastSUnits) ? sigLastSUnits : 0);
|
||
}, 120);
|
||
}
|
||
function drawHeaderSignalGraph() {
|
||
if (!ensureOverviewCanvasBackingStore()) return;
|
||
if (!overviewGl || !overviewGl.ready) return;
|
||
const pal = canvasPalette();
|
||
const W = overviewCanvas.width;
|
||
const H = overviewCanvas.height;
|
||
if (W <= 0 || H <= 0) return;
|
||
overviewGl.clear(cssColorToRgba(pal.bg));
|
||
if (lastSpectrumData && overviewWaterfallRows.length > 0) {
|
||
drawOverviewWaterfall(W, H, pal);
|
||
} else {
|
||
drawOverviewSignalHistory(W, H, pal);
|
||
}
|
||
positionRdsPsOverlay();
|
||
drawSignalOverlay();
|
||
updateBandplanStrip(bandplanComputeRange());
|
||
}
|
||
function drawOverviewWaterfall(W, H, pal) {
|
||
if (!overviewGl || !overviewGl.ready) return;
|
||
const maxVisible = Math.max(1, Math.floor(H));
|
||
const rows = overviewWaterfallRows.slice(-maxVisible);
|
||
if (rows.length === 0) return;
|
||
const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2)));
|
||
const iH = Math.max(1, rows.length);
|
||
const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115;
|
||
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
|
||
const view = lastSpectrumData ? spectrumVisibleRange(lastSpectrumData) : null;
|
||
const viewKey = view ? `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}` : "na";
|
||
const palKey = overviewWfPaletteKey(pal, viewKey);
|
||
const rowStride = iW * 4;
|
||
const expectedSize = iW * iH * 4;
|
||
const newPushes = overviewWaterfallPushCount - overviewWfTexPushCount;
|
||
const sizeChanged = overviewWfTexWidth !== iW || overviewWfTexHeight !== iH;
|
||
const palChanged = overviewWfTexPalKey !== palKey;
|
||
const needsFull = !overviewWfTexData || sizeChanged || palChanged || overviewWfTexPushCount === 0;
|
||
let texUpdated = false;
|
||
if (!overviewWfTexData || overviewWfTexData.length !== expectedSize) {
|
||
overviewWfTexData = new Uint8Array(expectedSize);
|
||
}
|
||
const textureData = overviewWfTexData;
|
||
overviewWfTexWidth = iW;
|
||
overviewWfTexHeight = iH;
|
||
ensureWaterfallLut(pal, minDb, maxDb);
|
||
function renderRow(dstY, srcBins) {
|
||
if (!isNumericBins(srcBins) || srcBins.length === 0) return;
|
||
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
|
||
const spanBins = Math.max(1, endIdx - startIdx);
|
||
const rowBase = dstY * rowStride;
|
||
const iwM1 = Math.max(1, iW - 1);
|
||
for (let x = 0; x < iW; x++) {
|
||
const binIdx = Math.min(endIdx, startIdx + (x * spanBins / iwM1 | 0));
|
||
const db = srcBins[binIdx];
|
||
if (db !== void 0) waterfallLutWrite(textureData, rowBase + x * 4, db);
|
||
}
|
||
}
|
||
if (needsFull) {
|
||
for (let y = 0; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
overviewWfTexPushCount = overviewWaterfallPushCount;
|
||
overviewWfTexPalKey = palKey;
|
||
texUpdated = true;
|
||
} else if (newPushes > 0) {
|
||
const newCount = Math.min(newPushes, iH);
|
||
if (newCount >= iH) {
|
||
for (let y = 0; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
} else {
|
||
const shiftBytes = newCount * rowStride;
|
||
textureData.copyWithin(0, shiftBytes);
|
||
const startRow = iH - newCount;
|
||
for (let y = startRow; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
}
|
||
overviewWfTexPushCount = overviewWaterfallPushCount;
|
||
overviewWfTexPalKey = palKey;
|
||
texUpdated = true;
|
||
}
|
||
if (texUpdated || !overviewWfTexReady) {
|
||
overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, textureData, "linear");
|
||
overviewWfTexReady = true;
|
||
}
|
||
overviewGl.drawTexture("overview-waterfall", 0, 0, W, H, 1, true);
|
||
}
|
||
function drawOverviewSignalHistory(W, H, pal) {
|
||
if (!overviewGl || !overviewGl.ready) return;
|
||
const now = Date.now();
|
||
const samples = overviewSignalSamples.filter((sample) => now - sample.t <= HEADER_SIG_WINDOW_MS);
|
||
if (samples.length === 0) return;
|
||
const maxVal = 20;
|
||
const windowStart = now - HEADER_SIG_WINDOW_MS;
|
||
const toX = (t) => (t - windowStart) / HEADER_SIG_WINDOW_MS * W;
|
||
const toY = (v) => H - Math.max(0, Math.min(maxVal, v)) / maxVal * (H - 3) - 1.5;
|
||
const gridMarkers = [
|
||
{ val: 0 },
|
||
{ val: 9 },
|
||
{ val: 18 }
|
||
];
|
||
const gridSegments = [];
|
||
for (const marker of gridMarkers) {
|
||
const y = toY(marker.val);
|
||
gridSegments.push(0, y, W, y);
|
||
}
|
||
overviewGl.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1);
|
||
const linePoints = [];
|
||
samples.forEach((sample, idx) => {
|
||
const x = toX(sample.t);
|
||
const y = toY(sample.v);
|
||
if (idx === 0 || x >= (linePoints[linePoints.length - 2] ?? -Infinity)) {
|
||
linePoints.push(x, y);
|
||
}
|
||
});
|
||
overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6);
|
||
const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||
if (holdMs > 0) {
|
||
const holdPoints = [];
|
||
for (let i = 0; i < samples.length; i++) {
|
||
const sample = samples[i];
|
||
if (!sample) continue;
|
||
let peak = sample.v;
|
||
for (let j = i; j >= 0; j--) {
|
||
const prior = samples[j];
|
||
if (!prior || sample.t - prior.t > holdMs) break;
|
||
if (prior.v > peak) peak = prior.v;
|
||
}
|
||
const x = toX(sample.t);
|
||
const y = toY(peak);
|
||
if (i === 0 || x >= (holdPoints[holdPoints.length - 2] ?? -Infinity)) {
|
||
holdPoints.push(x, y);
|
||
}
|
||
}
|
||
overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1);
|
||
}
|
||
}
|
||
function waterfallColorRgba(db, pal, minDb, maxDb) {
|
||
const lo = isFiniteNumber(minDb) ? minDb : isFiniteNumber(spectrumFloor) ? spectrumFloor : -115;
|
||
const hi = isFiniteNumber(maxDb) ? maxDb : lo + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
|
||
const safeDb = isFiniteNumber(db) ? db : lo;
|
||
const clamped = Math.max(lo, Math.min(hi, safeDb));
|
||
const span = Math.max(1, hi - lo);
|
||
const tLinear = (clamped - lo) / span;
|
||
const t = waterfallGamma === 1 ? tLinear : Math.pow(tLinear, waterfallGamma);
|
||
const hue = pal.waterfallHue[0] + t * (pal.waterfallHue[1] - pal.waterfallHue[0]);
|
||
const light = pal.waterfallLight[0] + t * (pal.waterfallLight[1] - pal.waterfallLight[0]);
|
||
const alpha = pal.waterfallAlpha[0] + t * (pal.waterfallAlpha[1] - pal.waterfallAlpha[0]);
|
||
if (typeof window.trxHslToRgba === "function") {
|
||
return window.trxHslToRgba(hue, pal.waterfallSat, light, alpha);
|
||
}
|
||
return cssColorToRgba(`hsla(${hue}, ${pal.waterfallSat}%, ${light}%, ${alpha})`);
|
||
}
|
||
var _wfLutKey = "";
|
||
var _wfLut = new Uint8Array(256 * 4);
|
||
function ensureWaterfallLut(pal, minDb, maxDb) {
|
||
const key = `${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${minDb}|${maxDb}|${waterfallGamma}`;
|
||
if (key === _wfLutKey) return;
|
||
_wfLutKey = key;
|
||
for (let i = 0; i < 256; i++) {
|
||
const db = i < 128 ? i : i - 256;
|
||
const c = waterfallColorRgba(db, pal, minDb, maxDb);
|
||
const p = i * 4;
|
||
_wfLut[p + 0] = c[0] * 255 + 0.5 | 0;
|
||
_wfLut[p + 1] = c[1] * 255 + 0.5 | 0;
|
||
_wfLut[p + 2] = c[2] * 255 + 0.5 | 0;
|
||
_wfLut[p + 3] = c[3] * 255 + 0.5 | 0;
|
||
}
|
||
}
|
||
function waterfallLutWrite(texData, offset, db) {
|
||
const idx = (db | 0) + 256 & 255;
|
||
const p = idx * 4;
|
||
texData[offset] = _wfLut[p] ?? 0;
|
||
texData[offset + 1] = _wfLut[p + 1] ?? 0;
|
||
texData[offset + 2] = _wfLut[p + 2] ?? 0;
|
||
texData[offset + 3] = _wfLut[p + 3] ?? 0;
|
||
}
|
||
function refreshWavelengthDisplay(hz) {
|
||
if (!wavelengthEl) return;
|
||
wavelengthEl.textContent = formatWavelength(hz);
|
||
}
|
||
function refreshFreqDisplay() {
|
||
if (window.trx?.modules.vchan?.interceptFreqDisplay()) return;
|
||
if (lastFreqHz == null || freqDirty) return;
|
||
freqEl.value = formatFrequencyForStep(lastFreqHz, jogUnit);
|
||
refreshWavelengthDisplay(lastFreqHz);
|
||
}
|
||
function activeRdsChannelId() {
|
||
const virtualChannelId = window.trx.modules.vchan?.activeId;
|
||
if (virtualChannelId) return virtualChannelId;
|
||
return null;
|
||
}
|
||
function activeChannelRds() {
|
||
if (!activeChannelIsWfm()) return null;
|
||
const activeId = activeRdsChannelId();
|
||
if (activeId) {
|
||
const rds = vchanRdsById.get(activeId);
|
||
if (rds) return rds;
|
||
const virtualChannels = window.trx.modules.vchan?.channels || [];
|
||
if (virtualChannels.length > 0) {
|
||
if (virtualChannels[0]?.id === activeId) return primaryRds;
|
||
}
|
||
}
|
||
return primaryRds;
|
||
}
|
||
function activeChannelIsWfm() {
|
||
const virtualChannels = window.trx.modules.vchan?.channels || [];
|
||
if (virtualChannels.length > 0) {
|
||
const activeId = activeRdsChannelId();
|
||
const active = virtualChannels.find((ch) => ch.id === activeId) || virtualChannels[0];
|
||
return String(active?.mode || "").toUpperCase() === "WFM";
|
||
}
|
||
return lastModeName === "WFM";
|
||
}
|
||
function activeChannelFreqHz() {
|
||
if (window.trx.modules.vchan) {
|
||
const ch = window.trx.modules.vchan.activeChannel();
|
||
if (ch && isFiniteNumber(ch.freq_hz)) return ch.freq_hz;
|
||
}
|
||
return lastFreqHz;
|
||
}
|
||
function activeBandwidthCenterHz() {
|
||
const freqHz = activeChannelFreqHz();
|
||
return isFiniteNumber(freqHz) ? freqHz : lastFreqHz;
|
||
}
|
||
function buildRdsOverlayHtml(rds) {
|
||
const ps = rds?.program_service;
|
||
const hasPs = !!(ps && ps.length > 0);
|
||
const hasPi = rds?.pi != null;
|
||
if (!hasPs && !hasPi) return "";
|
||
const mainText = hasPs ? formatOverlayPs(ps) : formatOverlayPi(rds?.pi);
|
||
const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback";
|
||
const metaText = hasPs ? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}` : rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : "");
|
||
const trafficFlags = `<span class="rds-ps-flags">${overlayTrafficFlagHtml("TP", rds?.traffic_program)}${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}</span>`;
|
||
return `<span class="${mainClass}">${hasPs ? formatPsHtml(ps) : escapeHtml(mainText)}</span><span class="rds-ps-meta"><span class="rds-ps-meta-text">${escapeHtml(metaText)}</span>${trafficFlags}</span>`;
|
||
}
|
||
function collectRdsOverlayEntries() {
|
||
const entries = [];
|
||
const virtualChannels = window.trx.modules.vchan?.channels || [];
|
||
if (virtualChannels.length > 0) {
|
||
for (const ch of virtualChannels) {
|
||
if (String(ch?.mode || "").toUpperCase() !== "WFM") continue;
|
||
if (!isFiniteNumber(ch.freq_hz)) continue;
|
||
const rds = vchanRdsById.get(ch.id) || (virtualChannels[0]?.id === ch.id ? primaryRds : null);
|
||
if (!rds) continue;
|
||
entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds });
|
||
}
|
||
} else if (lastModeName === "WFM" && primaryRds && lastFreqHz !== null && isFiniteNumber(lastFreqHz)) {
|
||
entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds });
|
||
}
|
||
return entries;
|
||
}
|
||
function renderRdsOverlays() {
|
||
if (!rdsPsOverlay) return;
|
||
if (!lastSpectrumData || !overviewCanvas) {
|
||
rdsOverlayEntries = [];
|
||
rdsPsOverlay.style.display = "none";
|
||
return;
|
||
}
|
||
const entries = collectRdsOverlayEntries();
|
||
rdsOverlayEntries = [];
|
||
rdsPsOverlay.replaceChildren();
|
||
if (entries.length === 0) {
|
||
rdsPsOverlay.style.display = "none";
|
||
return;
|
||
}
|
||
entries.forEach((entry) => {
|
||
const html = buildRdsOverlayHtml(entry.rds);
|
||
if (!html) return;
|
||
const el = document.createElement("div");
|
||
el.className = "rds-ps-overlay-item";
|
||
el.dataset.freqHz = String(entry.freq_hz);
|
||
el.innerHTML = html;
|
||
el.addEventListener("click", (evt) => {
|
||
evt.stopPropagation();
|
||
void copyRdsPsToClipboard(entry.rds, entry.freq_hz);
|
||
});
|
||
el.addEventListener("mouseenter", () => {
|
||
el.style.zIndex = String(entries.length + 10);
|
||
});
|
||
el.addEventListener("mouseleave", () => {
|
||
if (el.dataset.defaultZ) el.style.zIndex = el.dataset.defaultZ;
|
||
});
|
||
rdsPsOverlay.appendChild(el);
|
||
rdsOverlayEntries.push({ ...entry, el });
|
||
});
|
||
if (rdsOverlayEntries.length === 0) {
|
||
rdsPsOverlay.style.display = "none";
|
||
return;
|
||
}
|
||
rdsPsOverlay.style.display = "block";
|
||
positionRdsOverlays();
|
||
}
|
||
window.renderRdsOverlays = renderRdsOverlays;
|
||
function positionRdsOverlays() {
|
||
if (!rdsPsOverlay || !lastSpectrumData || !overviewCanvas || rdsOverlayEntries.length === 0) return;
|
||
const width = overviewCanvas.clientWidth || overviewCanvas.width || 0;
|
||
if (width <= 0) return;
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
if (!isFiniteNumber(range.visLoHz) || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) return;
|
||
const sortedByFreq = [...rdsOverlayEntries].sort((a, b) => a.freq_hz - b.freq_hz);
|
||
const freqZMap = new Map(sortedByFreq.map((e, i) => [e.id, i + 1]));
|
||
rdsOverlayEntries.forEach((entry, idx) => {
|
||
const el = entry.el;
|
||
if (!el) return;
|
||
if (!isFiniteNumber(entry.freq_hz)) {
|
||
el.style.display = "none";
|
||
return;
|
||
}
|
||
el.style.display = "";
|
||
const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz;
|
||
const clamped = Math.max(0.06, Math.min(0.94, rel));
|
||
el.style.left = `${clamped * width}px`;
|
||
el.style.top = "50%";
|
||
const z = String(freqZMap.get(entry.id) ?? idx + 1);
|
||
el.style.zIndex = z;
|
||
el.dataset.defaultZ = z;
|
||
});
|
||
}
|
||
function positionRdsPsOverlay() {
|
||
positionRdsOverlays();
|
||
}
|
||
function resetRdsDisplay() {
|
||
updateRdsPsOverlay(primaryRds);
|
||
}
|
||
function resetDecoderStateOnRigSwitch() {
|
||
primaryRds = null;
|
||
vchanRdsById = /* @__PURE__ */ new Map();
|
||
resetRdsDisplay();
|
||
resetWfmStereoIndicator();
|
||
resetIntfBars();
|
||
lastSpectrumData = null;
|
||
window.lastSpectrumData = null;
|
||
lastSpectrumRenderData = null;
|
||
const decoderIds = ["ais-status", "vdes-status", "aprs-status", "cw-status", "ft8-status", "wspr-status"];
|
||
decoderIds.forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = "--";
|
||
});
|
||
}
|
||
function resetWfmStereoIndicator() {
|
||
if (!wfmStFlagEl) return;
|
||
wfmStFlagEl.textContent = "MO";
|
||
wfmStFlagEl.classList.remove("wfm-st-flag-stereo");
|
||
wfmStFlagEl.classList.add("wfm-st-flag-mono");
|
||
}
|
||
function updateIntfBar(fillEl, valEl, level) {
|
||
if (!fillEl || !valEl) return;
|
||
const v = Math.round(Math.min(Math.max(level, 0), 100));
|
||
valEl.textContent = String(v);
|
||
fillEl.style.width = v + "%";
|
||
fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65);
|
||
fillEl.classList.toggle("wfm-intf-high", v >= 65);
|
||
if (v < 35) {
|
||
fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high");
|
||
}
|
||
}
|
||
function resetIntfBars() {
|
||
updateIntfBar(wfmCciFillEl, wfmCciValEl, 0);
|
||
updateIntfBar(wfmAciFillEl, wfmAciValEl, 0);
|
||
}
|
||
var _fastFreqMarker = document.getElementById("fast-freq-marker");
|
||
var _fastBwLeft = document.getElementById("fast-bw-left");
|
||
var _fastBwRight = document.getElementById("fast-bw-right");
|
||
function positionFastOverlay(freqHz, bwHz) {
|
||
if (!lastSpectrumData || !signalVisualBlockEl) {
|
||
if (_fastFreqMarker) _fastFreqMarker.style.display = "none";
|
||
if (_fastBwLeft) _fastBwLeft.style.display = "none";
|
||
if (_fastBwRight) _fastBwRight.style.display = "none";
|
||
return;
|
||
}
|
||
const cssW = signalVisualBlockEl.clientWidth;
|
||
if (cssW <= 0) return;
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
const hzToFrac = (hz) => (hz - range.visLoHz) / range.visSpanHz;
|
||
if (_fastFreqMarker && isFiniteNumber(freqHz)) {
|
||
const frac = hzToFrac(freqHz);
|
||
if (frac >= 0 && frac <= 1) {
|
||
_fastFreqMarker.style.display = "";
|
||
_fastFreqMarker.style.transform = `translateX(${frac * cssW}px)`;
|
||
} else {
|
||
_fastFreqMarker.style.display = "none";
|
||
}
|
||
}
|
||
if (_fastBwLeft && _fastBwRight && isFiniteNumber(freqHz) && isFiniteNumber(bwHz) && bwHz > 0) {
|
||
const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB");
|
||
let loHz, hiHz;
|
||
if (side < 0) {
|
||
loHz = freqHz - bwHz;
|
||
hiHz = freqHz;
|
||
} else if (side > 0) {
|
||
loHz = freqHz;
|
||
hiHz = freqHz + bwHz;
|
||
} else {
|
||
loHz = freqHz - bwHz / 2;
|
||
hiHz = freqHz + bwHz / 2;
|
||
}
|
||
const lFrac = hzToFrac(loHz);
|
||
const rFrac = hzToFrac(hiHz);
|
||
const cFrac = hzToFrac(freqHz);
|
||
if (lFrac < cFrac && cFrac >= 0 && lFrac <= 1) {
|
||
const x = Math.max(0, lFrac) * cssW;
|
||
const w = (Math.min(1, cFrac) - Math.max(0, lFrac)) * cssW;
|
||
_fastBwLeft.style.display = "";
|
||
_fastBwLeft.style.transform = `translateX(${x}px)`;
|
||
_fastBwLeft.style.width = `${w}px`;
|
||
} else {
|
||
_fastBwLeft.style.display = "none";
|
||
}
|
||
if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) {
|
||
const x = Math.max(0, cFrac) * cssW;
|
||
const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW;
|
||
_fastBwRight.style.display = "";
|
||
_fastBwRight.style.transform = `translateX(${x}px)`;
|
||
_fastBwRight.style.width = `${w}px`;
|
||
} else {
|
||
_fastBwRight.style.display = "none";
|
||
}
|
||
}
|
||
}
|
||
function applyLocalTunedFrequency(hz, forceDisplay = false) {
|
||
if (!isFiniteNumber(hz)) return;
|
||
const freqChanged = lastFreqHz !== hz;
|
||
if (!freqChanged && !forceDisplay) return;
|
||
if (freqChanged) {
|
||
if (lastFreqHz != null) savePreviousTuneState();
|
||
primaryRds = null;
|
||
resetRdsDisplay();
|
||
resetWfmStereoIndicator();
|
||
resetIntfBars();
|
||
}
|
||
lastFreqHz = hz;
|
||
window.lastFreqHz = lastFreqHz;
|
||
updateDocumentTitle(activeChannelRds());
|
||
refreshWavelengthDisplay(lastFreqHz);
|
||
if (forceDisplay) {
|
||
freqDirty = false;
|
||
}
|
||
if (forceDisplay || !freqDirty) {
|
||
refreshFreqDisplay();
|
||
}
|
||
window.ft8BaseHz = lastFreqHz;
|
||
if (window.updateFt8RfDisplay) {
|
||
window.updateFt8RfDisplay();
|
||
}
|
||
if (window.refreshCwTonePicker) {
|
||
window.refreshCwTonePicker();
|
||
}
|
||
positionFastOverlay(lastFreqHz, currentBandwidthHz);
|
||
if (freqChanged && lastSpectrumData) {
|
||
scheduleSpectrumDraw();
|
||
}
|
||
if (freqChanged && !lastSpectrumData) {
|
||
updateBandplanStrip(bandplanComputeRange());
|
||
}
|
||
positionRdsPsOverlay();
|
||
}
|
||
function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") {
|
||
const [, , maxBw] = mwDefaultsForMode(mode);
|
||
return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz);
|
||
}
|
||
function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") {
|
||
if (!isFiniteNumber(freqHz)) return [];
|
||
const modeUpper = String(mode || "").toUpperCase();
|
||
if (modeUpper === "AIS") {
|
||
return [
|
||
{ centerHz: freqHz, widthHz: currentBandwidthHz },
|
||
{ centerHz: freqHz + 5e4, widthHz: currentBandwidthHz }
|
||
];
|
||
}
|
||
return [{ centerHz: freqHz, widthHz: currentBandwidthHz }];
|
||
}
|
||
function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") {
|
||
const modeUpper = String(mode || "").toUpperCase();
|
||
if (modeUpper === "LSB" || modeUpper === "CWR") return -1;
|
||
if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1;
|
||
return 0;
|
||
}
|
||
function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") {
|
||
const centerHz = Number(spec?.centerHz);
|
||
const widthHz = Math.max(0, isFiniteNumber(spec?.widthHz) ? Number(spec.widthHz) : 0);
|
||
const side = sidebandDirectionForMode(mode);
|
||
if (side < 0) {
|
||
return { loHz: centerHz - widthHz, hiHz: centerHz, side };
|
||
}
|
||
if (side > 0) {
|
||
return { loHz: centerHz, hiHz: centerHz + widthHz, side };
|
||
}
|
||
const halfBw = widthHz / 2;
|
||
return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side };
|
||
}
|
||
function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") {
|
||
if (!isFiniteNumber(freqHz)) return null;
|
||
const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => {
|
||
const widthHz = Math.max(
|
||
0,
|
||
isFiniteNumber(spec.widthHz) ? spec.widthHz : Math.max(0, isFiniteNumber(bandwidthHz) ? bandwidthHz : 0)
|
||
);
|
||
return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode);
|
||
});
|
||
if (specs.length === 0) return null;
|
||
const first = specs[0];
|
||
if (!first) return null;
|
||
let loHz = first.loHz;
|
||
let hiHz = first.hiHz;
|
||
for (const spec of specs.slice(1)) {
|
||
loHz = Math.min(loHz, spec.loHz);
|
||
hiHz = Math.max(hiHz, spec.hiHz);
|
||
}
|
||
return { loHz, hiHz };
|
||
}
|
||
function effectiveSpectrumCoverageSpanHz(sampleRateHz) {
|
||
const sampleRate = Number(sampleRateHz);
|
||
if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0;
|
||
const ratio = isFiniteNumber(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92;
|
||
return sampleRate * Math.max(0.01, Math.min(1, ratio));
|
||
}
|
||
function sweetSpotMinimumOffsetHz(bandwidthHz) {
|
||
if (!isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) return 0;
|
||
return bandwidthHz / 2;
|
||
}
|
||
function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) {
|
||
if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz)) return false;
|
||
const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz);
|
||
if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) return true;
|
||
return Math.abs(centerHz - freqHz) >= minOffsetHz - 1;
|
||
}
|
||
function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) {
|
||
if (!isFiniteNumber(freqHz) || !isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||
return null;
|
||
}
|
||
const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz);
|
||
if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) {
|
||
const fallbackCenterHz = isFiniteNumber(preferredCenterHz) ? preferredCenterHz : freqHz;
|
||
return alignFreqToRigStep(Math.round(Math.max(minCenterHz, Math.min(maxCenterHz, fallbackCenterHz))));
|
||
}
|
||
const targetCentersHz = [];
|
||
const lowerTargetHz = alignFreqToRigStep(Math.round(freqHz - minOffsetHz));
|
||
const upperTargetHz = alignFreqToRigStep(Math.round(freqHz + minOffsetHz));
|
||
if (lowerTargetHz >= minCenterHz && lowerTargetHz <= maxCenterHz) targetCentersHz.push(lowerTargetHz);
|
||
if (upperTargetHz >= minCenterHz && upperTargetHz <= maxCenterHz && !targetCentersHz.some((value) => Math.abs(value - upperTargetHz) < 1)) {
|
||
targetCentersHz.push(upperTargetHz);
|
||
}
|
||
if (!targetCentersHz.length) return null;
|
||
if (isFiniteNumber(preferredCenterHz)) {
|
||
let bestCenterHz = targetCentersHz[0];
|
||
if (bestCenterHz === void 0) return null;
|
||
let bestDistance = Math.abs(bestCenterHz - preferredCenterHz);
|
||
for (const targetCenterHz of targetCentersHz.slice(1)) {
|
||
const distance = Math.abs(targetCenterHz - preferredCenterHz);
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
bestCenterHz = targetCenterHz;
|
||
}
|
||
}
|
||
return bestCenterHz;
|
||
}
|
||
return targetCentersHz[0];
|
||
}
|
||
function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||
if (!data || !isFiniteNumber(freqHz)) return null;
|
||
const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate);
|
||
const currentCenterHz = Number(data.center_hz);
|
||
if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) {
|
||
return null;
|
||
}
|
||
const halfSpanHz = sampleRate / 2;
|
||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||
if (!span) return null;
|
||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||
if (requiredHiHz - requiredLoHz >= sampleRate) {
|
||
return alignFreqToRigStep(Math.round(freqHz));
|
||
}
|
||
const currentLoHz = currentCenterHz - halfSpanHz;
|
||
const currentHiHz = currentCenterHz + halfSpanHz;
|
||
if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) {
|
||
return null;
|
||
}
|
||
let nextCenterHz = currentCenterHz;
|
||
if (requiredLoHz < currentLoHz) {
|
||
nextCenterHz = requiredLoHz + halfSpanHz;
|
||
}
|
||
if (requiredHiHz > currentHiHz) {
|
||
nextCenterHz = requiredHiHz - halfSpanHz;
|
||
}
|
||
return alignFreqToRigStep(Math.round(nextCenterHz));
|
||
}
|
||
function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||
return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz);
|
||
}
|
||
async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||
const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz);
|
||
if (!isFiniteNumber(nextCenterHz)) return;
|
||
if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return;
|
||
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
|
||
if (centerFreqEl && !centerFreqDirty) {
|
||
centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit);
|
||
}
|
||
}
|
||
var _freqOptimisticHz = null;
|
||
var _freqOptimisticSeq = 0;
|
||
function armOptimisticFrequency(freqHz) {
|
||
if (!isFiniteNumber(freqHz)) return;
|
||
_freqOptimisticSeq += 1;
|
||
_freqOptimisticHz = Math.round(freqHz);
|
||
}
|
||
function setRigFrequency(freqHz) {
|
||
const targetHz = Math.round(freqHz);
|
||
if (!freqAllowed(targetHz)) {
|
||
showUnsupportedFreqPopup(targetHz);
|
||
throw new Error(`Unsupported frequency: ${targetHz}`);
|
||
}
|
||
if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return;
|
||
void window.trx?.modules.vchan?.takeSchedulerControl();
|
||
const prevFreqHz = lastFreqHz;
|
||
const seq = ++_freqOptimisticSeq;
|
||
_freqOptimisticHz = targetHz;
|
||
applyLocalTunedFrequency(targetHz);
|
||
Promise.all([
|
||
postPath(`/set_freq?hz=${targetHz}`),
|
||
ensureTunedBandwidthCoverage(targetHz)
|
||
]).catch((err) => {
|
||
if (_freqOptimisticSeq === seq && prevFreqHz != null) {
|
||
_freqOptimisticHz = null;
|
||
applyLocalTunedFrequency(prevFreqHz, true);
|
||
}
|
||
console.warn("setRigFrequency failed:", err);
|
||
}).finally(() => {
|
||
if (_freqOptimisticSeq === seq) _freqOptimisticHz = null;
|
||
});
|
||
}
|
||
function spectrumBinIndexForHz(data, hz) {
|
||
if (!data || !isNumericBins(data.bins) || data.bins.length < 2 || !isFiniteNumber(hz)) {
|
||
return null;
|
||
}
|
||
const maxIdx = data.bins.length - 1;
|
||
const fullLoHz = Number(data.center_hz) - Number(data.sample_rate) / 2;
|
||
const idx = Math.round((hz - fullLoHz) / Number(data.sample_rate) * maxIdx);
|
||
return Math.max(0, Math.min(maxIdx, idx));
|
||
}
|
||
function spectrumPowerScore(db) {
|
||
const value = isFiniteNumber(db) ? db : -160;
|
||
const clamped = Math.max(-160, Math.min(40, value));
|
||
return 10 ** (clamped / 10);
|
||
}
|
||
function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) {
|
||
if (!data || !isNumericBins(data.bins) || data.bins.length < 16) {
|
||
return null;
|
||
}
|
||
if (!isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) {
|
||
return null;
|
||
}
|
||
const bins = data.bins;
|
||
const sampleRate = Number(data.sample_rate);
|
||
const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate);
|
||
const currentCenterHz = Number(data.center_hz);
|
||
if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(usableSpanHz) || usableSpanHz <= 0 || !isFiniteNumber(currentCenterHz)) {
|
||
return null;
|
||
}
|
||
const halfUsableSpanHz = usableSpanHz / 2;
|
||
const fullHalfSpanHz = sampleRate / 2;
|
||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||
if (!span) return null;
|
||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
|
||
const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange(
|
||
freqHz,
|
||
bandwidthHz,
|
||
currentCenterHz - halfUsableSpanHz,
|
||
currentCenterHz + halfUsableSpanHz,
|
||
requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz)
|
||
);
|
||
if (!isFiniteNumber(fallbackCenterHz)) return null;
|
||
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||
}
|
||
const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2);
|
||
const evalMinCenterHz = currentCenterHz - evalHalfSpanHz;
|
||
const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz;
|
||
const fitMinCenterHz = requiredHiHz - halfUsableSpanHz;
|
||
const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz;
|
||
const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz);
|
||
const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz);
|
||
if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||
const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange(
|
||
freqHz,
|
||
bandwidthHz,
|
||
evalMinCenterHz,
|
||
evalMaxCenterHz,
|
||
requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz)
|
||
);
|
||
if (!isFiniteNumber(fallbackCenterHz)) return null;
|
||
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||
}
|
||
const maxIdx = bins.length - 1;
|
||
const usableBins = Math.max(4, Math.min(maxIdx, Math.round(usableSpanHz / sampleRate * maxIdx)));
|
||
const fullLoHz = currentCenterHz - fullHalfSpanHz;
|
||
const startMinIdx = Math.max(
|
||
0,
|
||
Math.min(maxIdx - usableBins, Math.round((minCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx))
|
||
);
|
||
const startMaxIdx = Math.max(
|
||
startMinIdx,
|
||
Math.min(maxIdx - usableBins, Math.round((maxCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx))
|
||
);
|
||
let bestStartIdx = null;
|
||
let bestScore = Number.POSITIVE_INFINITY;
|
||
const signalLoHz = span.loHz;
|
||
const signalHiHz = span.hiHz;
|
||
for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) {
|
||
const endIdx = Math.min(maxIdx, startIdx + usableBins);
|
||
const windowLoHz = fullLoHz + startIdx / maxIdx * sampleRate;
|
||
const candidateCenterHz = windowLoHz + halfUsableSpanHz;
|
||
if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) {
|
||
continue;
|
||
}
|
||
const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz ?? freqHz) ?? startIdx));
|
||
const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz ?? freqHz) ?? endIdx));
|
||
let score = 0;
|
||
for (let i = startIdx; i <= endIdx; i++) {
|
||
if (i >= signalLoIdx && i <= signalHiIdx) continue;
|
||
score += spectrumPowerScore(bins[i] ?? -128);
|
||
}
|
||
const spanMidHz = (span.loHz + span.hiHz) / 2;
|
||
const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz);
|
||
score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08;
|
||
if (score < bestScore) {
|
||
bestScore = score;
|
||
bestStartIdx = startIdx;
|
||
}
|
||
}
|
||
if (!isFiniteNumber(bestScore) || bestStartIdx == null) {
|
||
const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange(
|
||
freqHz,
|
||
bandwidthHz,
|
||
minCenterHz,
|
||
maxCenterHz,
|
||
requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz)
|
||
);
|
||
if (!isFiniteNumber(fallbackCenterHz)) return null;
|
||
return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY };
|
||
}
|
||
const bestLoHz = fullLoHz + bestStartIdx / maxIdx * sampleRate;
|
||
const bestCenterHz = bestLoHz + halfUsableSpanHz;
|
||
return {
|
||
centerHz: alignFreqToRigStep(Math.round(bestCenterHz)),
|
||
score: bestScore
|
||
};
|
||
}
|
||
function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) {
|
||
if (!isFiniteNumber(freqHz)) return null;
|
||
const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz);
|
||
return candidate && isFiniteNumber(candidate.centerHz) ? candidate.centerHz : null;
|
||
}
|
||
function sweetSpotProbeCenters(data, freqHz, bandwidthHz) {
|
||
if (!data || !isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) {
|
||
return [];
|
||
}
|
||
const sampleRate = Number(data.sample_rate);
|
||
const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate);
|
||
if (!isFiniteNumber(usableSpanHz) || usableSpanHz <= 0) return [];
|
||
const halfUsableSpanHz = usableSpanHz / 2;
|
||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||
if (!span) return [];
|
||
const requiredLoHz = span.loHz - spectrumCoverageMarginHz;
|
||
const requiredHiHz = span.hiHz + spectrumCoverageMarginHz;
|
||
if (requiredHiHz - requiredLoHz >= usableSpanHz) {
|
||
const probeCenters = [];
|
||
const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz);
|
||
for (const centerHz of [freqHz - minOffsetHz, freqHz + minOffsetHz]) {
|
||
const alignedHz = alignFreqToRigStep(Math.round(centerHz));
|
||
if (sweetSpotCenterHasRequiredOffset(alignedHz, freqHz, bandwidthHz) && !probeCenters.some((value) => Math.abs(value - alignedHz) < 1)) {
|
||
probeCenters.push(alignedHz);
|
||
}
|
||
}
|
||
return probeCenters;
|
||
}
|
||
const minCenterHz = requiredHiHz - halfUsableSpanHz;
|
||
const maxCenterHz = requiredLoHz + halfUsableSpanHz;
|
||
if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) {
|
||
return [];
|
||
}
|
||
const points = 5;
|
||
const centers = [];
|
||
for (let i = 0; i < points; i++) {
|
||
const frac = i / (points - 1);
|
||
const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac));
|
||
if (sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - centerHz) < 1)) {
|
||
centers.push(centerHz);
|
||
}
|
||
}
|
||
const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz)));
|
||
if (isFiniteNumber(currentCenterHz) && sweetSpotCenterHasRequiredOffset(currentCenterHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) {
|
||
centers.push(currentCenterHz);
|
||
centers.sort((a, b) => a - b);
|
||
}
|
||
return centers;
|
||
}
|
||
async function applySweetSpotCenter() {
|
||
if (sweetSpotScanInFlight) {
|
||
showHint("Sweet-spot already scanning", 900);
|
||
return;
|
||
}
|
||
if (!isFiniteNumber(lastFreqHz) || !lastSpectrumData) return;
|
||
const originalCenterHz = Number(lastSpectrumData.center_hz);
|
||
const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz);
|
||
let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz);
|
||
if (!probeCentersHz.length && (!bestCandidate || !isFiniteNumber(bestCandidate.centerHz))) {
|
||
showHint("Sweet-spot unavailable", 1100);
|
||
return;
|
||
}
|
||
sweetSpotScanInFlight = true;
|
||
try {
|
||
showHint("Scanning sweet spot...", 1400);
|
||
for (const probeCenterHz of probeCentersHz) {
|
||
if (!isFiniteNumber(probeCenterHz)) continue;
|
||
let probeFrame = lastSpectrumData;
|
||
if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) {
|
||
await postPath(`/set_center_freq?hz=${probeCenterHz}`);
|
||
try {
|
||
probeFrame = await waitForSpectrumFrame(probeCenterHz, 1400);
|
||
} catch (_) {
|
||
continue;
|
||
}
|
||
}
|
||
const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz);
|
||
if (!candidate || !isFiniteNumber(candidate.centerHz)) continue;
|
||
if (!bestCandidate || candidate.score < bestCandidate.score) {
|
||
bestCandidate = candidate;
|
||
}
|
||
}
|
||
const targetCenterHz = bestCandidate && isFiniteNumber(bestCandidate.centerHz) ? bestCandidate.centerHz : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz);
|
||
if (!isFiniteNumber(targetCenterHz)) {
|
||
if (isFiniteNumber(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) {
|
||
await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`);
|
||
}
|
||
showHint("Sweet-spot unavailable", 1100);
|
||
return;
|
||
}
|
||
if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) {
|
||
await postPath(`/set_center_freq?hz=${targetCenterHz}`);
|
||
}
|
||
if (centerFreqEl && !centerFreqDirty) {
|
||
centerFreqEl.value = formatFrequencyForStep(targetCenterHz, jogUnit);
|
||
}
|
||
if (isFiniteNumber(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) {
|
||
showHint("Already at sweet spot", 900);
|
||
} else {
|
||
showHint("Sweet-spot set", 1200);
|
||
}
|
||
} finally {
|
||
sweetSpotScanInFlight = false;
|
||
}
|
||
}
|
||
function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) {
|
||
if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz) || !lastSpectrumData) return null;
|
||
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
||
if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return null;
|
||
const span = coverageSpanForMode(freqHz, bandwidthHz);
|
||
if (!span) return null;
|
||
const halfSpanHz = sampleRate / 2;
|
||
const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz);
|
||
const requiredHiOffset = span.hiHz + spectrumCoverageMarginHz - freqHz;
|
||
if (requiredLoOffset + requiredHiOffset >= sampleRate) {
|
||
return alignFreqToRigStep(Math.round(centerHz));
|
||
}
|
||
const minFreqHz = centerHz - halfSpanHz + requiredLoOffset;
|
||
const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset;
|
||
if (freqHz >= minFreqHz && freqHz <= maxFreqHz) {
|
||
return null;
|
||
}
|
||
const clampedHz = Math.max(minFreqHz, Math.min(maxFreqHz, freqHz));
|
||
return alignFreqToRigStep(Math.round(clampedHz));
|
||
}
|
||
var spectrumCenterPendingHz = null;
|
||
async function shiftSpectrumCenter(direction) {
|
||
if (!lastSpectrumData) return;
|
||
const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate);
|
||
const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz);
|
||
if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) return;
|
||
const stepHz = Math.max(5e4, Math.round(sampleRate * 0.35));
|
||
const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz));
|
||
spectrumCenterPendingHz = nextCenterHz;
|
||
showHint("Shifting spectrum…", 900);
|
||
await postPath(`/set_center_freq?hz=${nextCenterHz}`);
|
||
if (centerFreqEl && !centerFreqDirty) {
|
||
centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit);
|
||
}
|
||
const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz);
|
||
if (isFiniteNumber(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) {
|
||
await postPath(`/set_freq?hz=${nextFreqHz}`);
|
||
applyLocalTunedFrequency(nextFreqHz);
|
||
}
|
||
}
|
||
function refreshCenterFreqDisplay() {
|
||
if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return;
|
||
centerFreqEl.value = formatFrequencyForStep(lastSpectrumData.center_hz, jogUnit);
|
||
}
|
||
function normalizeMinFreqStep(cap) {
|
||
const val = Number(cap && cap.min_freq_step_hz);
|
||
if (!isFiniteNumber(val) || val < 1) return 1;
|
||
return Math.round(val);
|
||
}
|
||
function alignFreqToRigStep(hz) {
|
||
if (!isFiniteNumber(hz)) return hz;
|
||
const step = Math.max(1, minFreqStepHz);
|
||
return Math.round(hz / step) * step;
|
||
}
|
||
function updateJogStepSupport(cap) {
|
||
const nextMinStep = normalizeMinFreqStep(cap);
|
||
minFreqStepHz = nextMinStep;
|
||
const stepRoot = document.getElementById("jog-step");
|
||
if (!stepRoot) return;
|
||
const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]"));
|
||
if (buttons.length === 0) return;
|
||
buttons.forEach((btn) => {
|
||
const base = Number(btn.dataset.baseStep || btn.dataset.step);
|
||
if (isFiniteNumber(base) && base > 0) {
|
||
btn.dataset.baseStep = String(Math.round(base));
|
||
btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz));
|
||
}
|
||
});
|
||
const steps = buttons.map((btn) => Number(btn.dataset.step)).filter((s) => isFiniteNumber(s) && s > 0);
|
||
if (steps.length === 0) return;
|
||
const current = Number(jogUnit);
|
||
const firstStep = steps[0];
|
||
if (firstStep === void 0) return;
|
||
const desired = isFiniteNumber(current) && current >= minFreqStepHz ? current : Math.max(firstStep, minFreqStepHz);
|
||
jogUnit = steps.reduce((best, s) => Math.abs(s - desired) < Math.abs(best - desired) ? s : best, firstStep);
|
||
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
||
saveSetting("jogUnit", jogUnit);
|
||
saveSetting("jogStep", jogStep);
|
||
buttons.forEach((btn) => {
|
||
btn.classList.toggle("active", Number(btn.dataset.step) === jogUnit);
|
||
});
|
||
refreshFreqDisplay();
|
||
refreshCenterFreqDisplay();
|
||
}
|
||
function normalizeMode(modeVal) {
|
||
if (typeof modeVal === "string") return modeVal;
|
||
if (isRecord2(modeVal)) {
|
||
const entries = Object.entries(modeVal);
|
||
if (entries.length > 0) {
|
||
const firstEntry = entries[0];
|
||
if (!firstEntry) return "";
|
||
const [variant, value] = firstEntry;
|
||
if (variant === "Other" && typeof value === "string") return value;
|
||
return variant;
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
function updateSupportedBands(cap) {
|
||
if (cap && Array.isArray(cap.supported_bands)) {
|
||
supportedBands = cap.supported_bands.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number").map((b) => ({ low: b.low_hz, high: b.high_hz }));
|
||
} else {
|
||
supportedBands = [];
|
||
}
|
||
}
|
||
function freqAllowed(hz) {
|
||
if (!isFiniteNumber(hz)) return false;
|
||
if (supportedBands.length === 0) return true;
|
||
return supportedBands.some((b) => hz >= b.low && hz <= b.high);
|
||
}
|
||
function unsupportedBandSummary() {
|
||
if (supportedBands.length === 0) return "No supported frequency ranges were reported by the rig.";
|
||
const ranges = supportedBands.slice().sort((a, b) => a.low - b.low).map((b) => `${formatFrequencyForHumans(b.low)} to ${formatFrequencyForHumans(b.high)}`);
|
||
return `Supported ranges: ${ranges.join(", ")}`;
|
||
}
|
||
function showUnsupportedFreqPopup(hz) {
|
||
const message = `Unsupported frequency: ${formatFrequencyForHumans(hz)}.
|
||
|
||
${unsupportedBandSummary()}`;
|
||
showHint("Out of supported range", 1800);
|
||
const now = Date.now();
|
||
if (now - lastUnsupportedFreqPopupAt < 1200) return;
|
||
lastUnsupportedFreqPopupAt = now;
|
||
window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7e3 });
|
||
}
|
||
function dbmToSUnits(dbm) {
|
||
if (!isFiniteNumber(dbm)) return 0;
|
||
const clampedDbm = Math.max(-140, Math.min(20, dbm));
|
||
if (clampedDbm <= -121) return 0;
|
||
if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10;
|
||
return (clampedDbm + 121) / 6;
|
||
}
|
||
function formatSignal(sUnits) {
|
||
if (!isFiniteNumber(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`;
|
||
if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`;
|
||
const overDb = Math.min(60, Math.round((sUnits - 9) * 10 / 10) * 10);
|
||
return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`;
|
||
}
|
||
function setDisabled(disabled) {
|
||
[freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
|
||
if (el) el.disabled = disabled;
|
||
});
|
||
syncModePicker();
|
||
}
|
||
var serverVersion = null;
|
||
var serverBuildDate = null;
|
||
var serverCallsign = null;
|
||
var ownerCallsign = null;
|
||
var ownerWebsiteUrl = null;
|
||
var ownerWebsiteName = null;
|
||
var aisVesselUrlBase = null;
|
||
var serverRigs = [];
|
||
var serverActiveRigId = null;
|
||
var serverLat = null;
|
||
var serverLon = null;
|
||
var initialMapZoom = 10;
|
||
var spectrumCoverageMarginHz = 5e4;
|
||
var spectrumUsableSpanRatio = 0.92;
|
||
var DEFAULT_OVERVIEW_PLOT_HEIGHT_PX = 160;
|
||
var DEFAULT_SPECTRUM_PLOT_HEIGHT_PX = 160;
|
||
var MIN_OVERVIEW_PLOT_HEIGHT_PX = 90;
|
||
var MIN_SPECTRUM_PLOT_HEIGHT_PX = 130;
|
||
var DEFAULT_SIGNAL_SPLIT_PERCENT = 50;
|
||
var MIN_SIGNAL_SPLIT_PERCENT = 20;
|
||
var MAX_SIGNAL_SPLIT_PERCENT = 80;
|
||
var spectrumLayoutPending = false;
|
||
var spectrumManualTotalPlotHeightPx = null;
|
||
var spectrumResizeState = null;
|
||
var signalSplitPercent = clampSignalSplitPercent(
|
||
Number(loadSetting("signalSplitPercent", DEFAULT_SIGNAL_SPLIT_PERCENT))
|
||
);
|
||
function scheduleSpectrumLayout() {
|
||
if (spectrumLayoutPending) return;
|
||
spectrumLayoutPending = true;
|
||
requestAnimationFrame(() => {
|
||
spectrumLayoutPending = false;
|
||
updateSpectrumAutoHeight();
|
||
});
|
||
}
|
||
function clampSignalSplitPercent(value) {
|
||
const numeric = isFiniteNumber(value) ? value : DEFAULT_SIGNAL_SPLIT_PERCENT;
|
||
return Math.max(
|
||
MIN_SIGNAL_SPLIT_PERCENT,
|
||
Math.min(MAX_SIGNAL_SPLIT_PERCENT, Math.round(numeric))
|
||
);
|
||
}
|
||
function updateSignalSplitControlText() {
|
||
if (!signalSplitValueEl) return;
|
||
signalSplitValueEl.textContent = `${signalSplitPercent}/${100 - signalSplitPercent}`;
|
||
}
|
||
function setSignalSplitControlVisible(visible) {
|
||
if (!signalSplitControlEl) return;
|
||
signalSplitControlEl.style.display = visible ? "flex" : "none";
|
||
}
|
||
function currentOverviewHeightPx(overviewCanvasEl) {
|
||
return Math.max(
|
||
MIN_OVERVIEW_PLOT_HEIGHT_PX,
|
||
Math.round(overviewCanvasEl?.clientHeight || DEFAULT_OVERVIEW_PLOT_HEIGHT_PX)
|
||
);
|
||
}
|
||
function currentSpectrumHeightPx(spectrumCanvasEl) {
|
||
return Math.max(
|
||
MIN_SPECTRUM_PLOT_HEIGHT_PX,
|
||
Math.round(spectrumCanvasEl?.clientHeight || DEFAULT_SPECTRUM_PLOT_HEIGHT_PX)
|
||
);
|
||
}
|
||
function spectrumHeightBoundsPx(tabMain, content, overviewCanvasEl, spectrumCanvasEl) {
|
||
const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl);
|
||
const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl);
|
||
const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight;
|
||
const tabBottom = tabMain.getBoundingClientRect().bottom;
|
||
const contentBottom = content.getBoundingClientRect().bottom;
|
||
const slackPx = Math.floor(tabBottom - contentBottom);
|
||
const minTotalHeight = MIN_OVERVIEW_PLOT_HEIGHT_PX + MIN_SPECTRUM_PLOT_HEIGHT_PX;
|
||
const maxAutoTotalHeight = Math.max(
|
||
minTotalHeight,
|
||
currentTotalHeight + slackPx - 2
|
||
);
|
||
return {
|
||
minTotal: minTotalHeight,
|
||
autoMaxTotal: maxAutoTotalHeight
|
||
};
|
||
}
|
||
function updateSpectrumAutoHeight() {
|
||
const root = document.documentElement;
|
||
const overviewCanvasEl = document.getElementById("overview-canvas");
|
||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||
const spectrumCanvasEl = document.getElementById("spectrum-canvas");
|
||
if (!root || !tabMainEl || !contentEl || !overviewCanvasEl || !spectrumPanelEl || !spectrumCanvasEl) return;
|
||
const mainVisible = getComputedStyle(tabMainEl).display !== "none";
|
||
const contentVisible = getComputedStyle(contentEl).display !== "none";
|
||
const spectrumVisible = getComputedStyle(spectrumPanelEl).display !== "none";
|
||
const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl);
|
||
const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl);
|
||
if (!mainVisible || !contentVisible || !spectrumVisible) {
|
||
setSignalSplitControlVisible(false);
|
||
const dimensionsChanged = currentOverviewHeight !== DEFAULT_OVERVIEW_PLOT_HEIGHT_PX || currentSpectrumHeight !== DEFAULT_SPECTRUM_PLOT_HEIGHT_PX;
|
||
root.style.setProperty("--overview-plot-height", `${DEFAULT_OVERVIEW_PLOT_HEIGHT_PX}px`);
|
||
root.style.setProperty("--spectrum-plot-height", `${DEFAULT_SPECTRUM_PLOT_HEIGHT_PX}px`);
|
||
if (dimensionsChanged) {
|
||
resizeHeaderSignalCanvas();
|
||
scheduleOverviewDraw();
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}
|
||
return;
|
||
}
|
||
setSignalSplitControlVisible(true);
|
||
const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl);
|
||
const nextTotalHeight = spectrumManualTotalPlotHeightPx == null ? bounds.autoMaxTotal : Math.max(bounds.minTotal, Math.round(spectrumManualTotalPlotHeightPx));
|
||
if (spectrumManualTotalPlotHeightPx != null) {
|
||
spectrumManualTotalPlotHeightPx = nextTotalHeight;
|
||
}
|
||
const requestedOverviewHeight = Math.round(nextTotalHeight * signalSplitPercent / 100);
|
||
const nextOverviewHeight = Math.max(
|
||
MIN_OVERVIEW_PLOT_HEIGHT_PX,
|
||
Math.min(nextTotalHeight - MIN_SPECTRUM_PLOT_HEIGHT_PX, requestedOverviewHeight)
|
||
);
|
||
const nextSpectrumHeight = Math.max(
|
||
MIN_SPECTRUM_PLOT_HEIGHT_PX,
|
||
nextTotalHeight - nextOverviewHeight
|
||
);
|
||
if (Math.abs(nextOverviewHeight - currentOverviewHeight) < 2 && Math.abs(nextSpectrumHeight - currentSpectrumHeight) < 2) return;
|
||
root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`);
|
||
root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`);
|
||
if (typeof _updateCachedCanvasSizes === "function") _updateCachedCanvasSizes();
|
||
if (lastSpectrumData) {
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
scheduleSpectrumWaterfallDraw();
|
||
}
|
||
}
|
||
function beginSpectrumResize(clientY) {
|
||
const overviewCanvasEl = document.getElementById("overview-canvas");
|
||
const spectrumCanvasEl = document.getElementById("spectrum-canvas");
|
||
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||
if (!tabMainEl || !contentEl || !overviewCanvasEl || !spectrumCanvasEl || !spectrumPanelEl) return false;
|
||
if (getComputedStyle(spectrumPanelEl).display === "none") return false;
|
||
const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl);
|
||
const startTotalHeight = Math.max(
|
||
bounds.minTotal,
|
||
currentOverviewHeightPx(overviewCanvasEl) + currentSpectrumHeightPx(spectrumCanvasEl)
|
||
);
|
||
spectrumResizeState = {
|
||
startY: clientY,
|
||
startTotalHeight,
|
||
minTotalHeight: bounds.minTotal
|
||
};
|
||
document.body.classList.add("spectrum-resizing");
|
||
return true;
|
||
}
|
||
function updateSpectrumResize(clientY) {
|
||
if (!spectrumResizeState) return;
|
||
const deltaY = clientY - spectrumResizeState.startY;
|
||
spectrumManualTotalPlotHeightPx = Math.max(
|
||
spectrumResizeState.minTotalHeight,
|
||
Math.round(spectrumResizeState.startTotalHeight + deltaY)
|
||
);
|
||
updateSpectrumAutoHeight();
|
||
}
|
||
function endSpectrumResize() {
|
||
spectrumResizeState = null;
|
||
document.body.classList.remove("spectrum-resizing");
|
||
}
|
||
var spectrumSizeGrip = document.getElementById("spectrum-size-grip");
|
||
if (spectrumSizeGrip) {
|
||
spectrumSizeGrip.addEventListener("pointerdown", (event) => {
|
||
if (event.button !== 0) return;
|
||
if (!beginSpectrumResize(event.clientY)) return;
|
||
event.preventDefault();
|
||
if (typeof spectrumSizeGrip.setPointerCapture === "function") {
|
||
spectrumSizeGrip.setPointerCapture(event.pointerId);
|
||
}
|
||
});
|
||
spectrumSizeGrip.addEventListener("pointermove", (event) => {
|
||
if (!spectrumResizeState) return;
|
||
updateSpectrumResize(event.clientY);
|
||
});
|
||
const finishResize = (event) => {
|
||
if (!spectrumResizeState) return;
|
||
if (typeof spectrumSizeGrip.releasePointerCapture === "function" && spectrumSizeGrip.hasPointerCapture(event.pointerId)) {
|
||
spectrumSizeGrip.releasePointerCapture(event.pointerId);
|
||
}
|
||
endSpectrumResize();
|
||
};
|
||
spectrumSizeGrip.addEventListener("pointerup", finishResize);
|
||
spectrumSizeGrip.addEventListener("pointercancel", finishResize);
|
||
spectrumSizeGrip.addEventListener("dblclick", () => {
|
||
spectrumManualTotalPlotHeightPx = null;
|
||
scheduleSpectrumLayout();
|
||
});
|
||
}
|
||
if (signalSplitSliderEl) {
|
||
signalSplitSliderEl.value = String(signalSplitPercent);
|
||
signalSplitSliderEl.addEventListener("input", () => {
|
||
signalSplitPercent = clampSignalSplitPercent(Number(signalSplitSliderEl.value));
|
||
signalSplitSliderEl.value = String(signalSplitPercent);
|
||
updateSignalSplitControlText();
|
||
saveSetting("signalSplitPercent", signalSplitPercent);
|
||
scheduleSpectrumLayout();
|
||
});
|
||
signalSplitSliderEl.addEventListener("dblclick", (event) => {
|
||
event.preventDefault();
|
||
signalSplitPercent = DEFAULT_SIGNAL_SPLIT_PERCENT;
|
||
signalSplitSliderEl.value = String(signalSplitPercent);
|
||
updateSignalSplitControlText();
|
||
saveSetting("signalSplitPercent", signalSplitPercent);
|
||
scheduleSpectrumLayout();
|
||
});
|
||
}
|
||
updateSignalSplitControlText();
|
||
function updateTitle() {
|
||
const titleEl = document.getElementById("rig-title");
|
||
if (titleEl) {
|
||
if (ownerWebsiteUrl) {
|
||
const label = ownerWebsiteName || displayLabelFromUrl(ownerWebsiteUrl);
|
||
titleEl.innerHTML = `<a class="title-link" href="${escapeHtml(ownerWebsiteUrl)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`;
|
||
} else {
|
||
titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs";
|
||
}
|
||
}
|
||
updateDocumentTitle(activeChannelRds());
|
||
}
|
||
function displayLabelFromUrl(url) {
|
||
try {
|
||
const host = new URL(url).hostname.replace(/^www\./i, "");
|
||
return host || url;
|
||
} catch (_e) {
|
||
return url;
|
||
}
|
||
}
|
||
window.buildAisVesselUrl = function(mmsi) {
|
||
if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null;
|
||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||
};
|
||
function render(update) {
|
||
if (!update) return;
|
||
if (update.server_version) serverVersion = update.server_version;
|
||
if (update.server_build_date) serverBuildDate = update.server_build_date;
|
||
if (update.server_callsign) serverCallsign = update.server_callsign;
|
||
if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) {
|
||
ownerCallsign = update.owner_callsign;
|
||
}
|
||
if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) {
|
||
ownerWebsiteUrl = update.owner_website_url;
|
||
}
|
||
if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) {
|
||
ownerWebsiteName = update.owner_website_name;
|
||
}
|
||
if (typeof update.ais_vessel_url_base === "string" && update.ais_vessel_url_base.length > 0) {
|
||
aisVesselUrlBase = update.ais_vessel_url_base;
|
||
}
|
||
const prevLat = serverLat, prevLon = serverLon;
|
||
if (update.server_latitude != null) serverLat = update.server_latitude;
|
||
if (update.server_longitude != null) serverLon = update.server_longitude;
|
||
if (locationSubtitle && isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && (serverLat !== prevLat || serverLon !== prevLon || !locationSubtitle.textContent)) {
|
||
const grid = latLonToMaidenhead(serverLat, serverLon);
|
||
locationSubtitle.textContent = `Location: ${grid}`;
|
||
locationSubtitle.style.display = "";
|
||
window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid);
|
||
}
|
||
window.trx.modules.map?.syncAprsReceiverMarker();
|
||
if (typeof update.initial_map_zoom === "number" && isFiniteNumber(update.initial_map_zoom)) {
|
||
initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom));
|
||
}
|
||
if (typeof update.spectrum_coverage_margin_hz === "number" && isFiniteNumber(update.spectrum_coverage_margin_hz)) {
|
||
spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz));
|
||
}
|
||
if (typeof update.spectrum_usable_span_ratio === "number" && isFiniteNumber(update.spectrum_usable_span_ratio)) {
|
||
spectrumUsableSpanRatio = Math.max(0.01, Math.min(1, Number(update.spectrum_usable_span_ratio)));
|
||
}
|
||
if (typeof update.decode_history_retention_min === "number" && isFiniteNumber(update.decode_history_retention_min) && update.decode_history_retention_min > 0) {
|
||
const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min)));
|
||
if (nextRetentionMin !== decodeHistoryRetentionMin) {
|
||
decodeHistoryRetentionMin = nextRetentionMin;
|
||
if (typeof window.applyDecodeHistoryRetention === "function") {
|
||
window.applyDecodeHistoryRetention();
|
||
}
|
||
}
|
||
}
|
||
scheduleSpectrumLayout();
|
||
updateTitle();
|
||
initialized = !!update.initialized;
|
||
const hasUsableSnapshot = !!update.info && !!update.status && !!update.status.freq && typeof update.status.freq.hz === "number";
|
||
if (!initialized) {
|
||
const fallbackRigName = originalTitle || "Rig";
|
||
const manu = update.info && update.info.manufacturer || fallbackRigName;
|
||
const model = update.info && update.info.model || fallbackRigName;
|
||
const rev = update.info && update.info.revision || "";
|
||
const parts = [manu, model, rev].filter(Boolean).join(" ");
|
||
if (!hasUsableSnapshot) {
|
||
loadingTitle.textContent = `Initializing ${parts}…`;
|
||
loadingSub.textContent = "";
|
||
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
|
||
loadingEl.style.display = "";
|
||
if (contentEl) contentEl.style.display = "none";
|
||
setPowerHint("Initializing rig…");
|
||
setDisabled(true);
|
||
return;
|
||
}
|
||
loadingEl.style.display = "none";
|
||
if (contentEl) contentEl.style.display = "";
|
||
setPowerHint("Rig not fully initialized yet");
|
||
} else {
|
||
loadingEl.style.display = "none";
|
||
if (contentEl) contentEl.style.display = "";
|
||
}
|
||
if (serverSubtitle && update.server_callsign) {
|
||
const base = serverSubtitle.textContent.split(" hosted by")[0];
|
||
const safeCallsign = escapeHtml(update.server_callsign);
|
||
const encodedCallsign = encodeURIComponent(update.server_callsign);
|
||
serverSubtitle.innerHTML = `${escapeHtml(base)} hosted by <a href="https://qrzcq.com/call/${encodedCallsign}" target="_blank" rel="noopener">${safeCallsign}</a>`;
|
||
}
|
||
updateRigSubtitle(lastActiveRigId);
|
||
if (ownerSubtitle) {
|
||
if (ownerCallsign) {
|
||
const safeOwner = escapeHtml(ownerCallsign);
|
||
const encodedOwner = encodeURIComponent(ownerCallsign);
|
||
ownerSubtitle.innerHTML = `Owner: <a href="https://qrzcq.com/call/${encodedOwner}" target="_blank" rel="noopener">${safeOwner}</a>`;
|
||
} else {
|
||
ownerSubtitle.textContent = "Owner: --";
|
||
}
|
||
}
|
||
setDisabled(false);
|
||
if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) {
|
||
const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean);
|
||
if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) {
|
||
supportedModes = modes;
|
||
modeEl.replaceChildren();
|
||
supportedModes.forEach((m) => {
|
||
const opt = document.createElement("option");
|
||
opt.value = m;
|
||
opt.textContent = m;
|
||
modeEl.appendChild(opt);
|
||
});
|
||
renderModePicker();
|
||
}
|
||
}
|
||
if (update.info && update.info.capabilities) {
|
||
updateJogStepSupport(update.info.capabilities);
|
||
updateSupportedBands(update.info.capabilities);
|
||
applyCapabilities(update.info.capabilities);
|
||
}
|
||
if (update.filter && typeof update.filter.bandwidth_hz === "number") {
|
||
currentBandwidthHz = update.filter.bandwidth_hz;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(currentBandwidthHz);
|
||
positionFastOverlay(lastFreqHz, currentBandwidthHz);
|
||
if (window.refreshCwTonePicker) {
|
||
window.refreshCwTonePicker();
|
||
}
|
||
if (sdrGainEl && typeof update.filter.sdr_gain_db === "number" && document.activeElement !== sdrGainEl) {
|
||
sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db));
|
||
}
|
||
if (sdrLnaGainEl && typeof update.filter.sdr_lna_gain_db === "number" && document.activeElement !== sdrLnaGainEl) {
|
||
sdrLnaGainEl.value = String(Math.round(update.filter.sdr_lna_gain_db));
|
||
if (sdrLnaGainControlsEl) sdrLnaGainControlsEl.style.display = "";
|
||
}
|
||
if (wfmDeemphasisEl && typeof update.filter.wfm_deemphasis_us === "number") {
|
||
wfmDeemphasisEl.value = String(update.filter.wfm_deemphasis_us);
|
||
}
|
||
if (wfmAudioModeEl && typeof update.filter.wfm_stereo === "boolean") {
|
||
const nextMode = update.filter.wfm_stereo ? "stereo" : "mono";
|
||
if (wfmAudioModeEl.value !== nextMode) {
|
||
wfmAudioModeEl.value = nextMode;
|
||
saveSetting("wfmAudioMode", nextMode);
|
||
}
|
||
}
|
||
if (wfmDenoiseEl && (typeof update.filter.wfm_denoise === "string" || typeof update.filter.wfm_denoise === "boolean")) {
|
||
const nextDenoise = typeof update.filter.wfm_denoise === "string" ? normalizeWfmDenoiseLevel(update.filter.wfm_denoise) : update.filter.wfm_denoise ? "auto" : "off";
|
||
if (wfmDenoiseEl.value !== nextDenoise) {
|
||
wfmDenoiseEl.value = nextDenoise;
|
||
saveSetting("wfmDenoise", nextDenoise);
|
||
}
|
||
}
|
||
if (wfmStFlagEl && typeof update.filter.wfm_stereo_detected === "boolean") {
|
||
const detected = update.filter.wfm_stereo_detected;
|
||
wfmStFlagEl.textContent = detected ? "ST" : "MO";
|
||
wfmStFlagEl.classList.toggle("wfm-st-flag-stereo", detected);
|
||
wfmStFlagEl.classList.toggle("wfm-st-flag-mono", !detected);
|
||
}
|
||
if (typeof update.filter.wfm_cci === "number") {
|
||
lastWfmCci = Math.max(0, Math.min(100, update.filter.wfm_cci));
|
||
updateIntfBar(wfmCciFillEl, wfmCciValEl, lastWfmCci);
|
||
}
|
||
if (typeof update.filter.wfm_aci === "number") {
|
||
lastWfmAci = Math.max(0, Math.min(100, update.filter.wfm_aci));
|
||
updateIntfBar(wfmAciFillEl, wfmAciValEl, lastWfmAci);
|
||
}
|
||
if (samStereoWidthEl && typeof update.filter.sam_stereo_width === "number") {
|
||
samStereoWidthEl.value = String(Math.round(update.filter.sam_stereo_width * 100));
|
||
}
|
||
if (samCarrierSyncEl && typeof update.filter.sam_carrier_sync === "boolean") {
|
||
const nextVal = update.filter.sam_carrier_sync ? "on" : "off";
|
||
if (samCarrierSyncEl.value !== nextVal) samCarrierSyncEl.value = nextVal;
|
||
}
|
||
const hasSdrSquelchEnabled = typeof update.filter.sdr_squelch_enabled === "boolean";
|
||
const hasSdrSquelchThreshold = typeof update.filter.sdr_squelch_threshold_db === "number";
|
||
if (hasSdrSquelchEnabled || hasSdrSquelchThreshold) {
|
||
sdrSquelchSupported = true;
|
||
syncSdrSquelchFromServer(
|
||
hasSdrSquelchEnabled ? update.filter.sdr_squelch_enabled === true : true,
|
||
hasSdrSquelchThreshold && typeof update.filter.sdr_squelch_threshold_db === "number" ? update.filter.sdr_squelch_threshold_db : -120
|
||
);
|
||
}
|
||
updateSdrSquelchControlVisibility();
|
||
const hasSdrNbEnabled = typeof update.filter.sdr_nb_enabled === "boolean";
|
||
const hasSdrNbThreshold = typeof update.filter.sdr_nb_threshold === "number";
|
||
if (hasSdrNbEnabled || hasSdrNbThreshold) {
|
||
sdrNbSupported = true;
|
||
if (sdrNbWrapEl) sdrNbWrapEl.style.display = "";
|
||
if (sdrNbThresholdControlsEl) sdrNbThresholdControlsEl.style.display = "";
|
||
if (hasSdrNbEnabled && sdrNbEnabledEl) {
|
||
sdrNbEnabledEl.checked = update.filter.sdr_nb_enabled === true;
|
||
}
|
||
if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) {
|
||
if (typeof update.filter.sdr_nb_threshold === "number") {
|
||
sdrNbThresholdEl.value = String(Math.round(update.filter.sdr_nb_threshold));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (typeof update.show_sdr_gain_control === "boolean") {
|
||
if (sdrSettingsRowEl) sdrSettingsRowEl.style.display = update.show_sdr_gain_control ? "" : "none";
|
||
}
|
||
if (!_bandplanServerDefaultApplied && typeof update.bandplan_enabled === "boolean" && typeof update.bandplan_region === "string") {
|
||
_bandplanServerDefaultApplied = true;
|
||
const hasUserOverride = localStorage.getItem("trx_bandplanRegion") !== null;
|
||
if (!hasUserOverride) {
|
||
const region = update.bandplan_enabled ? update.bandplan_region : "off";
|
||
bandplanRegion = region;
|
||
saveSetting("bandplanRegion", region);
|
||
if (bandplanRegionSelect) bandplanRegionSelect.value = region;
|
||
bandplanSegmentsCache = null;
|
||
bandplanCacheKey = "";
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}
|
||
}
|
||
if (update.filter && sdrAgcEl && typeof update.filter.sdr_agc_enabled === "boolean") {
|
||
sdrAgcEl.checked = update.filter.sdr_agc_enabled;
|
||
updateSdrGainInputState();
|
||
}
|
||
if (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
|
||
if (update.status.freq.hz !== prevRenderData.freqHz) {
|
||
prevRenderData.freqHz = update.status.freq.hz;
|
||
const sseHz = update.status.freq.hz;
|
||
if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) > 1) {
|
||
} else {
|
||
if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) <= 1) {
|
||
_freqOptimisticHz = null;
|
||
}
|
||
applyLocalTunedFrequency(sseHz);
|
||
}
|
||
}
|
||
}
|
||
if (update.status && update.status.mode && update.status.mode !== prevRenderData.mode) {
|
||
prevRenderData.mode = update.status.mode;
|
||
const mode = normalizeMode(update.status.mode);
|
||
const modeUpper2 = mode ? mode.toUpperCase() : "";
|
||
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
|
||
if (!onVirtual) {
|
||
modeEl.value = modeUpper2;
|
||
syncModePicker();
|
||
if (modeUpper2 === "WFM" && lastModeName !== "WFM") {
|
||
setJogDivisor(10);
|
||
resetRdsDisplay();
|
||
} else if (modeUpper2 !== "WFM" && lastModeName === "WFM") {
|
||
resetRdsDisplay();
|
||
}
|
||
lastModeName = modeUpper2;
|
||
if (lastSpectrumData && !update.filter) {
|
||
void applyBwDefaultForMode(mode, false);
|
||
}
|
||
}
|
||
updateWfmControls();
|
||
updateSdrSquelchControlVisibility();
|
||
}
|
||
const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : "";
|
||
for (const d of decoderRegistry) {
|
||
if (d.activation !== "mode_bound") continue;
|
||
const el = document.getElementById(d.id + "-status");
|
||
if (!el) continue;
|
||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||
}
|
||
if (window.updateAisBar) window.updateAisBar();
|
||
if (window.updateVdesBar) window.updateVdesBar();
|
||
if (window.updateAprsBar) window.updateAprsBar();
|
||
if (window.updateFt8Bar) window.updateFt8Bar();
|
||
for (const d of decoderRegistry) {
|
||
if (d.activation !== "toggle") continue;
|
||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||
const enabled = !!update[key];
|
||
const modeMatch = d.active_modes.includes(modeUpper);
|
||
const el = document.getElementById(d.id + "-status");
|
||
if (el && (!enabled || !modeMatch) && el.textContent === "Receiving") {
|
||
el.textContent = "Connected, listening for packets";
|
||
}
|
||
}
|
||
if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) {
|
||
prevRenderData.txEn = update.status.tx_en;
|
||
lastTxEn = update.status.tx_en;
|
||
window.trxUi?.setButtonState(pttBtn, {
|
||
active: update.status.tx_en,
|
||
activeLabel: "Stop TX",
|
||
inactiveLabel: "Start TX"
|
||
});
|
||
if (update.status.tx_en) {
|
||
pttBtn.style.background = "var(--accent-red)";
|
||
pttBtn.style.borderColor = "var(--accent-red)";
|
||
pttBtn.style.color = "white";
|
||
} else {
|
||
pttBtn.style.background = "";
|
||
pttBtn.style.borderColor = "";
|
||
pttBtn.style.color = "";
|
||
}
|
||
}
|
||
_ensureDecoderToggles();
|
||
for (const [key, entry] of Object.entries(_decoderToggles)) {
|
||
syncDecoderToggle(entry, !!update[key], entry.label);
|
||
}
|
||
if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) {
|
||
window.syncWefaxToggle(update.wefax_decode_enabled);
|
||
}
|
||
if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) {
|
||
window._syncRecorderState(update.recorder_enabled);
|
||
}
|
||
if (window.updateSatLiveState) window.updateSatLiveState(update);
|
||
if (cwWpmEl && typeof update.cw_wpm === "number") {
|
||
cwWpmEl.value = String(update.cw_wpm);
|
||
}
|
||
if (cwToneEl && typeof update.cw_tone_hz === "number") {
|
||
cwToneEl.value = String(update.cw_tone_hz);
|
||
}
|
||
if (typeof update.cw_auto === "boolean") {
|
||
if (typeof window.applyCwAutoUiFromServer === "function") {
|
||
window.applyCwAutoUiFromServer(update.cw_auto);
|
||
} else if (typeof window.applyCwAutoUi === "function") {
|
||
window.applyCwAutoUi(update.cw_auto);
|
||
} else {
|
||
if (cwAutoEl) cwAutoEl.checked = update.cw_auto;
|
||
if (cwWpmEl) {
|
||
cwWpmEl.disabled = update.cw_auto;
|
||
cwWpmEl.readOnly = update.cw_auto;
|
||
}
|
||
if (cwToneEl) {
|
||
cwToneEl.disabled = update.cw_auto;
|
||
cwToneEl.readOnly = update.cw_auto;
|
||
}
|
||
}
|
||
}
|
||
let activeFreqColor = "var(--accent-green)";
|
||
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
|
||
const entries = update.status.vfo.entries;
|
||
const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null;
|
||
vfoPicker.replaceChildren();
|
||
entries.forEach((entry, idx) => {
|
||
const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null;
|
||
if (hz === null) return;
|
||
const mode = entry.mode ? normalizeMode(entry.mode) : "";
|
||
const modeText = mode ? ` [${mode}]` : "";
|
||
const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFrequency(hz)}${modeText}`;
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.textContent = label;
|
||
const color = vfoColor(idx);
|
||
if (activeIdx === idx) {
|
||
btn.classList.add("active");
|
||
btn.style.color = color;
|
||
activeFreqColor = color;
|
||
} else btn.addEventListener("click", async () => {
|
||
btn.disabled = true;
|
||
showHint("Toggling VFO…");
|
||
try {
|
||
await postPath("/toggle_vfo");
|
||
showHint("VFO toggled", 1200);
|
||
} catch (err) {
|
||
showHint("VFO toggle failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
vfoPicker.appendChild(btn);
|
||
});
|
||
} else {
|
||
vfoPicker.innerHTML = '<button type="button" class="active">--</button>';
|
||
}
|
||
if (freqEl) {
|
||
freqEl.style.color = activeFreqColor;
|
||
}
|
||
if (update.status && update.status.rx && typeof update.status.rx.sig === "number") {
|
||
if (update.status.rx.sig !== prevRenderData.sigDbm) {
|
||
prevRenderData.sigDbm = update.status.rx.sig;
|
||
const sUnits = dbmToSUnits(update.status.rx.sig);
|
||
sigLastSUnits = sUnits;
|
||
sigLastDbm = update.status.rx.sig;
|
||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
||
signalBar.style.width = `${pct}%`;
|
||
signalValue.innerHTML = formatSignal(sUnits);
|
||
refreshSigStrengthDisplay();
|
||
}
|
||
} else if (prevRenderData.sigDbm !== null) {
|
||
prevRenderData.sigDbm = null;
|
||
sigLastSUnits = null;
|
||
sigLastDbm = null;
|
||
signalBar.style.width = "0%";
|
||
signalValue.textContent = "--";
|
||
refreshSigStrengthDisplay();
|
||
}
|
||
if (bandLabel) {
|
||
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
|
||
}
|
||
if (typeof update.enabled === "boolean") {
|
||
window.trxUi?.setButtonState(powerBtn, {
|
||
active: update.enabled,
|
||
activeLabel: "Power Off",
|
||
inactiveLabel: "Power On"
|
||
});
|
||
} else {
|
||
powerBtn.disabled = true;
|
||
powerBtn.textContent = "Power unavailable";
|
||
powerBtn.setAttribute("aria-pressed", "false");
|
||
setPowerHint("State unknown");
|
||
}
|
||
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
|
||
txLimitInput.value = String(update.status.tx.limit);
|
||
txLimitRow.style.display = "";
|
||
} else {
|
||
txLimitInput.value = "";
|
||
txLimitRow.style.display = "none";
|
||
}
|
||
if (typeof update.clients === "number") lastClientCount = update.clients;
|
||
if (_activeTab === "about") {
|
||
_resolveAboutEls();
|
||
_resolveAboutDecEls();
|
||
if (update.server_version && aboutServerVerEl) {
|
||
aboutServerVerEl.textContent = `trx-server v${update.server_version}`;
|
||
}
|
||
if (update.server_build_date && aboutServerBuildDateEl) {
|
||
aboutServerBuildDateEl.textContent = update.server_build_date;
|
||
}
|
||
if (aboutServerAddrEl) aboutServerAddrEl.textContent = location.host;
|
||
if (update.server_callsign && aboutServerCallEl) {
|
||
aboutServerCallEl.textContent = update.server_callsign;
|
||
}
|
||
if (isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && aboutServerLocationEl) {
|
||
const grid = latLonToMaidenhead(serverLat, serverLon);
|
||
aboutServerLocationEl.textContent = `${grid} (${serverLat.toFixed(4)}, ${serverLon.toFixed(4)})`;
|
||
}
|
||
if (update.info) {
|
||
const parts = [update.info.manufacturer, update.info.model, update.info.revision].filter(Boolean).join(" ");
|
||
if (parts && aboutRigInfoEl) aboutRigInfoEl.textContent = parts;
|
||
const access = update.info.access;
|
||
if (access) {
|
||
if ("Serial" in access) {
|
||
const serialPath = access.Serial.path || "?";
|
||
if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`;
|
||
} else if ("Tcp" in access) {
|
||
if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.addr || "?"})`;
|
||
} else {
|
||
const key = Object.keys(access)[0];
|
||
if (key && aboutRigAccessEl) aboutRigAccessEl.textContent = key;
|
||
}
|
||
}
|
||
if (update.info.capabilities) {
|
||
const cap = update.info.capabilities;
|
||
if (Array.isArray(cap.supported_modes) && cap.supported_modes.length && aboutModesEl) {
|
||
aboutModesEl.textContent = cap.supported_modes.map(normalizeMode).filter(Boolean).join(", ");
|
||
}
|
||
if (typeof cap.num_vfos === "number" && aboutVfosEl) {
|
||
aboutVfosEl.textContent = String(cap.num_vfos);
|
||
}
|
||
}
|
||
}
|
||
if (lastActiveRigId && aboutActiveRigEl) {
|
||
aboutActiveRigEl.textContent = lastActiveRigId;
|
||
}
|
||
if (streamInfo) {
|
||
if (aboutAudioCodecEl) aboutAudioCodecEl.textContent = "Opus";
|
||
if (aboutAudioSamplerateEl) aboutAudioSamplerateEl.textContent = `${(streamInfo.sample_rate || 48e3).toLocaleString()} Hz`;
|
||
if (aboutAudioChannelsEl) aboutAudioChannelsEl.textContent = (streamInfo.channels || 1) === 1 ? "Mono" : "Stereo";
|
||
if (streamInfo.bitrate_bps && aboutAudioBitrateEl) {
|
||
const kbps = (streamInfo.bitrate_bps / 1e3).toFixed(0);
|
||
aboutAudioBitrateEl.textContent = `${kbps} kbps`;
|
||
}
|
||
if (streamInfo.frame_duration_ms && aboutAudioFrameEl) {
|
||
aboutAudioFrameEl.textContent = `${streamInfo.frame_duration_ms} ms`;
|
||
}
|
||
}
|
||
if (aboutAudioRxEl) aboutAudioRxEl.textContent = rxActive ? "Active" : "Off";
|
||
if (typeof update.audio_clients === "number" && aboutAudioStreamsEl) {
|
||
aboutAudioStreamsEl.textContent = String(update.audio_clients);
|
||
}
|
||
syncAboutDecoder(0, !!update.ft8_decode_enabled);
|
||
syncAboutDecoder(1, !!update.ft4_decode_enabled);
|
||
syncAboutDecoder(2, !!update.ft2_decode_enabled);
|
||
syncAboutDecoder(3, !!update.wspr_decode_enabled);
|
||
syncAboutDecoder(4, !!update.cw_decode_enabled);
|
||
syncAboutDecoder(5, !!(update.aprs_decode_enabled || update.hf_aprs_decode_enabled));
|
||
syncAboutDecoder(6, !!update.lrpt_decode_enabled);
|
||
if (update.pskreporter_status && aboutPskreporterEl) {
|
||
aboutPskreporterEl.textContent = update.pskreporter_status;
|
||
}
|
||
if (update.aprs_is_status && aboutAprsIsEl) {
|
||
aboutAprsIsEl.textContent = update.aprs_is_status;
|
||
}
|
||
if (typeof update.rigctl_clients === "number" && aboutRigctlClientsEl) {
|
||
aboutRigctlClientsEl.textContent = String(update.rigctl_clients);
|
||
}
|
||
if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0 && aboutRigctlEndpointEl) {
|
||
aboutRigctlEndpointEl.textContent = update.rigctl_addr;
|
||
}
|
||
if (typeof update.clients === "number" && aboutClientsEl) {
|
||
aboutClientsEl.textContent = String(update.clients);
|
||
}
|
||
}
|
||
if (Array.isArray(update.remotes)) {
|
||
applyRigList(typeof update.active_remote === "string" ? update.active_remote : null, update.remotes);
|
||
}
|
||
setPowerHint(readyText());
|
||
lastLocked = update.status?.lock === true;
|
||
window.trxUi?.setButtonState(lockBtn, {
|
||
active: lastLocked,
|
||
activeLabel: "Unlock Tuning",
|
||
inactiveLabel: "Lock Tuning"
|
||
});
|
||
const tx = update.status && update.status.tx ? update.status.tx : null;
|
||
txMeters.style.display = lastHasTx ? "" : "none";
|
||
if (tx && typeof tx.power === "number") {
|
||
const pct = Math.max(0, Math.min(100, tx.power));
|
||
pwrBar.style.width = `${pct}%`;
|
||
pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`;
|
||
} else {
|
||
pwrBar.style.width = "0%";
|
||
pwrValue.textContent = "PWR --";
|
||
}
|
||
if (tx && typeof tx.swr === "number") {
|
||
const swr = Math.max(1, tx.swr);
|
||
const pct = Math.max(0, Math.min(100, (swr - 1) / 2 * 100));
|
||
swrBar.style.width = `${pct}%`;
|
||
swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`;
|
||
} else {
|
||
swrBar.style.width = "0%";
|
||
swrValue.textContent = "SWR --";
|
||
}
|
||
}
|
||
function scheduleReconnect(delayMs = 1e3) {
|
||
if (reconnectTimer) return;
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, delayMs);
|
||
}
|
||
async function pollFreshSnapshot() {
|
||
try {
|
||
const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status";
|
||
const resp = await fetch(statusUrl, { cache: "no-store" });
|
||
if (!resp.ok) return;
|
||
const data = await responseJsonUnknown(resp);
|
||
if (!isAppUpdate(data)) return;
|
||
render(data);
|
||
void refreshRigList();
|
||
lastEventAt = Date.now();
|
||
} catch (e) {
|
||
}
|
||
}
|
||
function connect() {
|
||
if (es) {
|
||
es.close();
|
||
sseSessionId = null;
|
||
}
|
||
if (esHeartbeat) {
|
||
clearInterval(esHeartbeat);
|
||
}
|
||
stopMeterStreaming();
|
||
startMeterStreaming();
|
||
void pollFreshSnapshot();
|
||
const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events";
|
||
es = new EventSource(eventsUrl);
|
||
const source = es;
|
||
lastEventAt = Date.now();
|
||
es.onopen = () => {
|
||
setConnLostOverlay(false);
|
||
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
|
||
if (!aboutUptimeStart) aboutUptimeStart = Date.now();
|
||
void pollFreshSnapshot();
|
||
void refreshRigList();
|
||
};
|
||
source.onmessage = (evt) => {
|
||
try {
|
||
if (evt.data === lastRendered) return;
|
||
const data = parseJsonUnknown(evt.data);
|
||
if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape");
|
||
lastRendered = evt.data;
|
||
render(data);
|
||
lastEventAt = Date.now();
|
||
if (data.server_connected === false) {
|
||
setPowerHint("trx-server connection lost");
|
||
if (tabMainEl) tabMainEl.classList.add("server-disconnected");
|
||
} else {
|
||
if (tabMainEl) tabMainEl.classList.remove("server-disconnected");
|
||
if (data.initialized) setPowerHint(readyText());
|
||
}
|
||
} catch (e) {
|
||
console.error("Bad event data", e);
|
||
}
|
||
};
|
||
source.addEventListener("ping", () => {
|
||
lastEventAt = Date.now();
|
||
});
|
||
source.addEventListener("session", (evt) => {
|
||
try {
|
||
const eventData = messageEventData(evt);
|
||
const d = parseJsonUnknown(eventData);
|
||
sseSessionId = isRecord2(d) && typeof d.session_id === "string" ? d.session_id : null;
|
||
} catch (_) {
|
||
}
|
||
window.trx.modules.vchan?.handleSession(messageEventData(evt));
|
||
});
|
||
source.addEventListener("channels", (evt) => {
|
||
window.trx.modules.vchan?.handleChannels(messageEventData(evt));
|
||
});
|
||
source.onerror = () => {
|
||
if (source.readyState === EventSource.CLOSED) {
|
||
setPowerHint("trx-client connection lost, retrying…");
|
||
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
|
||
source.close();
|
||
void pollFreshSnapshot();
|
||
scheduleReconnect(1e3);
|
||
}
|
||
};
|
||
esHeartbeat = setInterval(() => {
|
||
const now = Date.now();
|
||
if (now - lastEventAt > 15e3) {
|
||
setPowerHint("trx-client connection lost, retrying…");
|
||
setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true);
|
||
source.close();
|
||
void pollFreshSnapshot();
|
||
scheduleReconnect(250);
|
||
}
|
||
}, 5e3);
|
||
}
|
||
function disconnect() {
|
||
if (es) {
|
||
es.close();
|
||
es = null;
|
||
}
|
||
if (decodeSource) {
|
||
decodeSource.close();
|
||
decodeSource = null;
|
||
}
|
||
stopSpectrumStreaming();
|
||
stopMeterStreaming();
|
||
if (esHeartbeat) {
|
||
clearInterval(esHeartbeat);
|
||
esHeartbeat = null;
|
||
}
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
setDecodeHistoryOverlayVisible(false);
|
||
setConnLostOverlay(false);
|
||
}
|
||
var uiFrameJobs = /* @__PURE__ */ new Map();
|
||
var uiFrameJobsHandle = null;
|
||
function flushUiFrameJobs() {
|
||
uiFrameJobsHandle = null;
|
||
const jobs = Array.from(uiFrameJobs.values());
|
||
uiFrameJobs.clear();
|
||
for (const job of jobs) {
|
||
try {
|
||
job();
|
||
} catch (err) {
|
||
console.error("Deferred UI job failed:", err);
|
||
}
|
||
}
|
||
}
|
||
function scheduleUiFrameJob(key, job) {
|
||
if (typeof job !== "function") return;
|
||
uiFrameJobs.set(key, job);
|
||
if (uiFrameJobsHandle !== null) return;
|
||
if (typeof requestAnimationFrame === "function") {
|
||
uiFrameJobsHandle = requestAnimationFrame(flushUiFrameJobs);
|
||
} else {
|
||
uiFrameJobsHandle = setTimeout(flushUiFrameJobs, 16);
|
||
}
|
||
}
|
||
window.trxScheduleUiFrameJob = scheduleUiFrameJob;
|
||
async function postPath(path, options = {}) {
|
||
if (rigSwitchInProgress && !options.allowDuringRigSwitch) {
|
||
throw new Error("Wait for the rig switch to finish");
|
||
}
|
||
const targetRigId = options.remote === void 0 ? lastActiveRigId : options.remote;
|
||
if (targetRigId && !path.includes("remote=")) {
|
||
const sep = path.includes("?") ? "&" : "?";
|
||
path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`;
|
||
}
|
||
const resp = await fetch(path, { method: "POST" });
|
||
if (authEnabled && resp.status === 401) {
|
||
authRole = null;
|
||
if (es) es.close();
|
||
showAuthGate();
|
||
throw new Error("Authentication required");
|
||
}
|
||
if (resp.status === 403) {
|
||
throw new Error("Insufficient permissions");
|
||
}
|
||
if (!resp.ok) {
|
||
const text = await resp.text();
|
||
throw new Error(text || resp.statusText);
|
||
}
|
||
return resp;
|
||
}
|
||
async function takeSchedulerControlForDecoderDisable(buttonEl) {
|
||
const enabled = buttonEl?.dataset?.enabled === "true" || /^\s*Disable\b/i.test(buttonEl?.textContent || "");
|
||
if (!enabled) return;
|
||
await window.trx.modules.vchan?.takeSchedulerControl();
|
||
}
|
||
window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable;
|
||
async function switchRigFromSelect(selectEl) {
|
||
if (!selectEl || !selectEl.value) {
|
||
showHint("No rig selected", 1500);
|
||
return;
|
||
}
|
||
if (authRole === "rx") {
|
||
showHint("Control role required", 1500);
|
||
return;
|
||
}
|
||
if (!lastRigIds.includes(selectEl.value)) {
|
||
showHint("Unknown rig", 1500);
|
||
return;
|
||
}
|
||
const prevRig = lastActiveRigId;
|
||
const nextRig = selectEl.value;
|
||
if (nextRig === prevRig || rigSwitchInProgress) return;
|
||
rigSwitchInProgress = true;
|
||
setControlPending(selectEl, true);
|
||
selectEl.closest(".header-rig-switch")?.classList.add("is-switching");
|
||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||
showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`);
|
||
try {
|
||
const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : "";
|
||
await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null });
|
||
lastActiveRigId = nextRig;
|
||
resetDecoderStateOnRigSwitch();
|
||
updateRigSubtitle(lastActiveRigId);
|
||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||
void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || "");
|
||
window.trx.modules.map?.syncAprsReceiverMarker();
|
||
connect();
|
||
stopSpectrumStreaming();
|
||
startSpectrumStreaming();
|
||
stopMeterStreaming();
|
||
startMeterStreaming();
|
||
if (rxActive) {
|
||
stopRxAudio();
|
||
startRxAudio();
|
||
}
|
||
showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500);
|
||
} catch (err) {
|
||
console.error("select_rig failed:", err);
|
||
selectEl.value = prevRig || "";
|
||
window.trxUi?.notify("Rig could not be switched", { kind: "error" });
|
||
} finally {
|
||
rigSwitchInProgress = false;
|
||
setControlPending(selectEl, false);
|
||
selectEl.closest(".header-rig-switch")?.classList.remove("is-switching");
|
||
}
|
||
}
|
||
if (headerRigSwitchSelect) {
|
||
headerRigSwitchSelect.addEventListener("change", () => {
|
||
void switchRigFromSelect(headerRigSwitchSelect);
|
||
});
|
||
}
|
||
function setControlPending(control, pending) {
|
||
if (!control) return;
|
||
control.disabled = pending;
|
||
control.classList.toggle("is-busy", pending);
|
||
control.setAttribute("aria-busy", String(pending));
|
||
}
|
||
powerBtn.addEventListener("click", async () => {
|
||
setControlPending(powerBtn, true);
|
||
showHint("Sending...");
|
||
try {
|
||
await postPath("/toggle_power");
|
||
showHint("Toggled, waiting for update…");
|
||
} catch (err) {
|
||
showHint("Toggle failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(powerBtn, false);
|
||
}
|
||
});
|
||
pttBtn.addEventListener("click", async () => {
|
||
setControlPending(pttBtn, true);
|
||
showHint("Toggling PTT…");
|
||
try {
|
||
const desired = lastTxEn ? "false" : "true";
|
||
await postPath(`/set_ptt?ptt=${desired}`);
|
||
showHint("PTT command sent", 1500);
|
||
} catch (err) {
|
||
showHint("PTT toggle failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(pttBtn, false);
|
||
}
|
||
});
|
||
function applyFreqFromInput() {
|
||
const parsedRaw = parseFrequencyInput(freqEl.value, jogUnit, modeEl?.value || "");
|
||
if (parsedRaw === null) {
|
||
showHint("Freq missing", 1500);
|
||
return;
|
||
}
|
||
const parsed = alignFreqToRigStep(parsedRaw);
|
||
if (!freqAllowed(parsed)) {
|
||
showUnsupportedFreqPopup(parsed);
|
||
return;
|
||
}
|
||
freqDirty = false;
|
||
setRigFrequency(parsed);
|
||
}
|
||
async function applyCenterFreqFromInput() {
|
||
if (!centerFreqEl) return;
|
||
const parsedRaw = parseFrequencyInput(centerFreqEl.value, jogUnit, modeEl?.value || "");
|
||
if (parsedRaw === null) {
|
||
showHint("Central freq missing", 1500);
|
||
return;
|
||
}
|
||
const parsed = alignFreqToRigStep(parsedRaw);
|
||
if (!freqAllowed(parsed)) {
|
||
showUnsupportedFreqPopup(parsed);
|
||
return;
|
||
}
|
||
centerFreqDirty = false;
|
||
setControlPending(centerFreqEl, true);
|
||
showHint("Setting central frequency…");
|
||
try {
|
||
await postPath(`/set_center_freq?hz=${parsed}`);
|
||
showHint("Central freq set", 1500);
|
||
} catch (err) {
|
||
showHint("Set central freq failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(centerFreqEl, false);
|
||
}
|
||
}
|
||
freqEl.addEventListener("keydown", (e) => {
|
||
freqDirty = true;
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
applyFreqFromInput();
|
||
} else if (e.key === "Escape") {
|
||
freqDirty = false;
|
||
refreshFreqDisplay();
|
||
freqEl.blur();
|
||
}
|
||
});
|
||
freqEl.addEventListener("blur", () => {
|
||
if (freqDirty) {
|
||
freqDirty = false;
|
||
refreshFreqDisplay();
|
||
}
|
||
});
|
||
if (centerFreqEl) {
|
||
centerFreqEl.addEventListener("keydown", (e) => {
|
||
centerFreqDirty = true;
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void applyCenterFreqFromInput();
|
||
} else if (e.key === "Escape") {
|
||
centerFreqDirty = false;
|
||
refreshCenterFreqDisplay();
|
||
centerFreqEl.blur();
|
||
}
|
||
});
|
||
centerFreqEl.addEventListener("blur", () => {
|
||
if (centerFreqDirty) {
|
||
centerFreqDirty = false;
|
||
refreshCenterFreqDisplay();
|
||
}
|
||
});
|
||
centerFreqEl.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const direction = e.deltaY < 0 ? 1 : -1;
|
||
jogFreq(direction);
|
||
}, { passive: false });
|
||
}
|
||
freqEl.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const direction = e.deltaY < 0 ? 1 : -1;
|
||
jogFreq(direction);
|
||
}, { passive: false });
|
||
var jogWheel = requiredElement("jog-wheel");
|
||
var jogIndicator = requiredElement("jog-indicator");
|
||
var jogDownBtn = requiredElement("jog-down");
|
||
var jogUpBtn = requiredElement("jog-up");
|
||
var jogStepEl = requiredElement("jog-step");
|
||
var jogMultEl = requiredElement("jog-mult");
|
||
var VALID_JOG_DIVISORS = /* @__PURE__ */ new Set([1, 10]);
|
||
function applyJogStep() {
|
||
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
||
saveSetting("jogUnit", jogUnit);
|
||
saveSetting("jogMult", jogMult);
|
||
saveSetting("jogStep", jogStep);
|
||
refreshFreqDisplay();
|
||
refreshCenterFreqDisplay();
|
||
}
|
||
function setJogDivisor(divisor) {
|
||
const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1;
|
||
jogMult = next;
|
||
if (jogMultEl) {
|
||
jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => {
|
||
b.classList.toggle("active", parseInt(b.dataset.mult ?? "", 10) === jogMult);
|
||
});
|
||
}
|
||
applyJogStep();
|
||
}
|
||
function jogFreq(direction) {
|
||
if (lastLocked) {
|
||
showHint("Locked", 1500);
|
||
return;
|
||
}
|
||
if (lastFreqHz === null) return;
|
||
const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep);
|
||
if (!freqAllowed(newHz)) {
|
||
showUnsupportedFreqPopup(newHz);
|
||
return;
|
||
}
|
||
jogAngle = (jogAngle + direction * 15) % 360;
|
||
jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`;
|
||
setRigFrequency(newHz);
|
||
}
|
||
jogDownBtn.addEventListener("click", () => jogFreq(-1));
|
||
jogUpBtn.addEventListener("click", () => jogFreq(1));
|
||
jogWheel.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const direction = e.deltaY < 0 ? 1 : -1;
|
||
jogFreq(direction);
|
||
}, { passive: false });
|
||
var jogTouchY = null;
|
||
jogWheel.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
jogTouchY = e.touches[0]?.clientY ?? null;
|
||
}, { passive: false });
|
||
jogWheel.addEventListener("touchmove", (e) => {
|
||
e.preventDefault();
|
||
if (jogTouchY === null) return;
|
||
const touch = e.touches[0];
|
||
if (!touch) return;
|
||
const dy = jogTouchY - touch.clientY;
|
||
if (Math.abs(dy) > 12) {
|
||
jogFreq(dy > 0 ? 1 : -1);
|
||
jogTouchY = touch.clientY;
|
||
}
|
||
}, { passive: false });
|
||
jogWheel.addEventListener("touchend", () => {
|
||
jogTouchY = null;
|
||
});
|
||
var jogMouseY = null;
|
||
jogWheel.addEventListener("mousedown", (e) => {
|
||
e.preventDefault();
|
||
jogMouseY = e.clientY;
|
||
jogWheel.style.cursor = "grabbing";
|
||
});
|
||
window.addEventListener("mousemove", (e) => {
|
||
if (jogMouseY === null) return;
|
||
const dy = jogMouseY - e.clientY;
|
||
if (Math.abs(dy) > 10) {
|
||
jogFreq(dy > 0 ? 1 : -1);
|
||
jogMouseY = e.clientY;
|
||
}
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
jogMouseY = null;
|
||
if (jogWheel) jogWheel.style.cursor = "grab";
|
||
});
|
||
jogStepEl.addEventListener("click", (e) => {
|
||
const btn = e.target instanceof Element ? e.target.closest("button[data-step]") : null;
|
||
if (!btn) return;
|
||
jogUnit = parseInt(btn.dataset.step ?? "", 10);
|
||
jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
applyJogStep();
|
||
});
|
||
if (jogMultEl) {
|
||
jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => {
|
||
const divisor = parseInt(btn.dataset.mult ?? "", 10);
|
||
if (!VALID_JOG_DIVISORS.has(divisor)) {
|
||
btn.remove();
|
||
}
|
||
});
|
||
jogMultEl.addEventListener("click", (e) => {
|
||
const btn = e.target instanceof Element ? e.target.closest("button[data-mult]") : null;
|
||
if (!btn) return;
|
||
setJogDivisor(parseInt(btn.dataset.mult ?? "", 10));
|
||
});
|
||
}
|
||
{
|
||
const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]"));
|
||
const activeUnit = unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === jogUnit) || unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === 1e3) || unitBtns[0];
|
||
if (activeUnit) {
|
||
jogUnit = parseInt(activeUnit.dataset.step ?? "", 10);
|
||
unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit));
|
||
}
|
||
if (jogMultEl) {
|
||
const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]"));
|
||
const activeMult = multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === 1) || multBtns[0];
|
||
if (activeMult) {
|
||
jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult ?? "", 10)) ? parseInt(activeMult.dataset.mult ?? "", 10) : 1;
|
||
multBtns.forEach((b) => b.classList.toggle("active", b === activeMult));
|
||
} else {
|
||
jogMult = 1;
|
||
}
|
||
}
|
||
jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz);
|
||
}
|
||
var modePickerEl = requiredElement("mode-picker");
|
||
function renderModePicker() {
|
||
modePickerEl.replaceChildren();
|
||
for (const option of Array.from(modeEl.options)) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.dataset.mode = option.value;
|
||
btn.textContent = option.textContent;
|
||
btn.addEventListener("click", () => {
|
||
if (btn.disabled || modeEl.value === option.value) return;
|
||
modeEl.value = option.value;
|
||
syncModePicker();
|
||
void applyModeFromPicker();
|
||
});
|
||
modePickerEl.appendChild(btn);
|
||
}
|
||
syncModePicker();
|
||
}
|
||
function syncModePicker() {
|
||
const active = (modeEl.value || "").toUpperCase();
|
||
modePickerEl.querySelectorAll("button").forEach((btn) => {
|
||
const selected = (btn.dataset.mode || "").toUpperCase() === active;
|
||
btn.classList.toggle("active", selected);
|
||
btn.disabled = modeEl.disabled;
|
||
btn.setAttribute("aria-pressed", String(selected));
|
||
});
|
||
}
|
||
async function applyModeFromPicker() {
|
||
const mode = modeEl.value || "";
|
||
if (!mode) {
|
||
showHint("Mode missing", 1500);
|
||
return;
|
||
}
|
||
updateWfmControls();
|
||
setControlPending(modeEl, true);
|
||
syncModePicker();
|
||
showHint("Setting mode…");
|
||
try {
|
||
if (await window.trx.modules.vchan?.interceptMode(mode)) {
|
||
showHint("Channel mode set", 1500);
|
||
return;
|
||
}
|
||
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
|
||
showHint("Mode set", 1500);
|
||
if (mode.toUpperCase() === "WFM") {
|
||
setJogDivisor(10);
|
||
}
|
||
await applyBwDefaultForMode(mode, true);
|
||
} catch (err) {
|
||
showHint("Set mode failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(modeEl, false);
|
||
syncModePicker();
|
||
}
|
||
}
|
||
modeEl.addEventListener("change", applyModeFromPicker);
|
||
txLimitInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
txLimitBtn.click();
|
||
}
|
||
});
|
||
txLimitBtn.addEventListener("click", async () => {
|
||
const limit = txLimitInput.value;
|
||
if (limit === "" || limit === "--") {
|
||
showHint("Limit missing", 1500);
|
||
return;
|
||
}
|
||
setControlPending(txLimitBtn, true);
|
||
showHint("Setting TX limit…");
|
||
try {
|
||
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
|
||
showHint("TX limit set", 1500);
|
||
} catch (err) {
|
||
showHint("TX limit failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(txLimitBtn, false);
|
||
}
|
||
});
|
||
lockBtn.addEventListener("click", async () => {
|
||
setControlPending(lockBtn, true);
|
||
showHint("Toggling lock…");
|
||
try {
|
||
const nextLock = !lastLocked;
|
||
await postPath(nextLock ? "/lock" : "/unlock");
|
||
showHint("Lock toggled", 1500);
|
||
} catch (err) {
|
||
showHint("Lock toggle failed", 2e3);
|
||
console.error(err);
|
||
} finally {
|
||
setControlPending(lockBtn, false);
|
||
}
|
||
});
|
||
var MODE_BW_DEFAULTS = {
|
||
CW: [500, 100, 9e3, 50],
|
||
CWR: [500, 100, 9e3, 50],
|
||
LSB: [2700, 300, 6e3, 100],
|
||
USB: [2700, 300, 6e3, 100],
|
||
AM: [9e3, 500, 2e4, 500],
|
||
SAM: [9e3, 500, 2e4, 500],
|
||
FM: [12500, 2500, 25e3, 500],
|
||
AIS: [25e3, 12500, 5e4, 500],
|
||
VDES: [1e5, 25e3, 2e5, 1e3],
|
||
WFM: [18e4, 6e4, 3e5, 5e3],
|
||
DIG: [3e3, 300, 6e3, 100],
|
||
PKT: [25e3, 300, 5e4, 500]
|
||
};
|
||
var MODE_BW_FALLBACK = [3e3, 300, 5e5, 100];
|
||
function mwDefaultsForMode(mode) {
|
||
return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK;
|
||
}
|
||
function formatBwLabel(hz) {
|
||
if (hz >= 1e3) return (hz / 1e3).toFixed(hz % 1e3 === 0 ? 0 : 1) + " kHz";
|
||
return hz + " Hz";
|
||
}
|
||
var currentBandwidthHz = 3e3;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
var spectrumBwInput = requiredElement("spectrum-bw-input");
|
||
var spectrumBwSetBtn = requiredElement("spectrum-bw-set-btn");
|
||
var spectrumBwAutoBtn = requiredElement("spectrum-bw-auto-btn");
|
||
var spectrumBwSweetBtn = requiredElement("spectrum-bw-sweet-btn");
|
||
function formatBandwidthInputKhz(hz) {
|
||
const khz = hz / 1e3;
|
||
if (Math.abs(Math.round(khz) - khz) < 1e-4) return String(Math.round(khz));
|
||
if (Math.abs(Math.round(khz * 10) - khz * 10) < 1e-4) return khz.toFixed(1);
|
||
return khz.toFixed(2);
|
||
}
|
||
function syncBandwidthInput(hz) {
|
||
if (!spectrumBwInput || !isFiniteNumber(hz) || hz <= 0) return;
|
||
const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||
spectrumBwInput.min = String(minBw / 1e3);
|
||
spectrumBwInput.max = String(maxBw / 1e3);
|
||
spectrumBwInput.step = String(stepBw / 1e3);
|
||
spectrumBwInput.value = formatBandwidthInputKhz(hz);
|
||
}
|
||
async function applyBwDefaultForMode(mode, sendToServer) {
|
||
const [def] = mwDefaultsForMode(mode);
|
||
currentBandwidthHz = def;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(def);
|
||
positionFastOverlay(lastFreqHz, def);
|
||
if (lastSpectrumData) {
|
||
scheduleSpectrumDraw();
|
||
}
|
||
if (sendToServer) {
|
||
try {
|
||
await postPath(`/set_bandwidth?hz=${def}`);
|
||
} catch (error) {
|
||
window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } });
|
||
}
|
||
}
|
||
}
|
||
async function applyBandwidthFromInput() {
|
||
if (!spectrumBwInput) return;
|
||
const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||
const nextKhz = Number(spectrumBwInput.value);
|
||
const next = Math.round(nextKhz * 1e3);
|
||
if (!isFiniteNumber(next) || next <= 0) {
|
||
syncBandwidthInput(currentBandwidthHz);
|
||
return;
|
||
}
|
||
const clamped = Math.max(minBw, Math.min(maxBw, next));
|
||
currentBandwidthHz = clamped;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(clamped);
|
||
positionFastOverlay(lastFreqHz, clamped);
|
||
if (lastSpectrumData) {
|
||
scheduleSpectrumDraw();
|
||
}
|
||
try {
|
||
if (await window.trx.modules.vchan?.interceptBandwidth(clamped)) return;
|
||
await postPath(`/set_bandwidth?hz=${clamped}`);
|
||
if (isFiniteNumber(lastFreqHz)) {
|
||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||
}
|
||
} catch (error) {
|
||
window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } });
|
||
}
|
||
}
|
||
async function applyAutoBandwidth() {
|
||
if (!lastSpectrumData || lastFreqHz == null) return;
|
||
const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true;
|
||
const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci };
|
||
const mode = (modeEl?.value || "").toUpperCase();
|
||
const estimated = estimateOccupiedBandwidth(
|
||
lastSpectrumData,
|
||
lastFreqHz,
|
||
mode,
|
||
mwDefaultsForMode(mode),
|
||
interference
|
||
);
|
||
if (!isFiniteNumber(estimated) || estimated <= 0) {
|
||
syncBandwidthInput(currentBandwidthHz);
|
||
return;
|
||
}
|
||
currentBandwidthHz = estimated;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(estimated);
|
||
positionFastOverlay(lastFreqHz, estimated);
|
||
if (lastSpectrumData) {
|
||
scheduleSpectrumDraw();
|
||
}
|
||
let reason = "measured occupied spectrum";
|
||
if (mode === "WFM") {
|
||
if (estimated === 6e4 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`;
|
||
else if (estimated === 6e4) reason = "weak-signal noise rejection";
|
||
else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`;
|
||
else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`;
|
||
}
|
||
window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5e3 });
|
||
try {
|
||
if (await window.trx.modules.vchan?.interceptBandwidth(estimated)) return;
|
||
await postPath(`/set_bandwidth?hz=${estimated}`);
|
||
if (isFiniteNumber(lastFreqHz)) {
|
||
await ensureTunedBandwidthCoverage(lastFreqHz);
|
||
}
|
||
} catch (error) {
|
||
window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } });
|
||
}
|
||
}
|
||
if (spectrumBwInput) {
|
||
spectrumBwInput.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
void applyBandwidthFromInput();
|
||
}
|
||
});
|
||
}
|
||
if (spectrumBwSetBtn) {
|
||
spectrumBwSetBtn.addEventListener("click", () => {
|
||
void applyBandwidthFromInput();
|
||
});
|
||
}
|
||
if (spectrumBwAutoBtn) {
|
||
spectrumBwAutoBtn.addEventListener("click", () => {
|
||
void applyAutoBandwidth();
|
||
});
|
||
}
|
||
if (spectrumBwSweetBtn) {
|
||
spectrumBwSweetBtn.addEventListener("click", () => {
|
||
applySweetSpotCenter().catch(() => {
|
||
});
|
||
});
|
||
}
|
||
var _activeTab = "main";
|
||
function tabFromPath2(pathname = window.location.pathname) {
|
||
return tabFromPath(pathname);
|
||
}
|
||
var _mapInitTimer = null;
|
||
function _initMapWhenReady() {
|
||
const loadingEl2 = document.getElementById("map-loading");
|
||
const map = window.trx.modules.map;
|
||
if (map && "L" in window) {
|
||
if (_mapInitTimer) {
|
||
clearInterval(_mapInitTimer);
|
||
_mapInitTimer = null;
|
||
}
|
||
if (loadingEl2) loadingEl2.classList.add("is-hidden");
|
||
map.initAprsMap();
|
||
map.sizeAprsMapToViewport();
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
map.sizeAprsMapToViewport();
|
||
map.aprsMap?.invalidateSize();
|
||
});
|
||
});
|
||
return;
|
||
}
|
||
if (loadingEl2) loadingEl2.classList.remove("is-hidden");
|
||
if (!_mapInitTimer) {
|
||
_mapInitTimer = setInterval(() => {
|
||
if (_activeTab !== "map") {
|
||
if (_mapInitTimer) clearInterval(_mapInitTimer);
|
||
_mapInitTimer = null;
|
||
return;
|
||
}
|
||
_initMapWhenReady();
|
||
}, 100);
|
||
}
|
||
}
|
||
function navigateToTab(name, options = {}) {
|
||
window.trxUi?.closeMobileOverlays?.();
|
||
const { updateHistory = true, replaceHistory = false } = options;
|
||
if (authEnabled && !authRole && name !== "main") {
|
||
showAuthGate(false);
|
||
return;
|
||
}
|
||
const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`);
|
||
if (!btn) return;
|
||
_activeTab = name;
|
||
document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
const toolsBtn = document.getElementById("mobile-more-btn");
|
||
if (toolsBtn) {
|
||
const inToolsMenu = !!document.querySelector(`#mobile-more-menu [data-navigate-tab="${name}"]`);
|
||
toolsBtn.classList.toggle("active", inToolsMenu);
|
||
}
|
||
window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn);
|
||
document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none");
|
||
const panel = document.getElementById(`tab-${name}`);
|
||
if (!panel) return;
|
||
panel.style.display = "";
|
||
materializeTabPanel(panel);
|
||
if (name === "map" || name === "statistics") {
|
||
const peer = document.getElementById(name === "map" ? "tab-statistics" : "tab-map");
|
||
if (peer) materializeTabPanel(peer);
|
||
}
|
||
if (updateHistory) {
|
||
updateTabHistory(name, replaceHistory);
|
||
}
|
||
scheduleSpectrumLayout();
|
||
void loadPluginsForTab(name).catch((error) => {
|
||
console.error(error);
|
||
});
|
||
if (name === "map") {
|
||
_initMapWhenReady();
|
||
}
|
||
if (name === "statistics") {
|
||
window.trx.modules.map?.scheduleStatsRender();
|
||
}
|
||
if (name === "recorder") {
|
||
void refreshRecorderStatus();
|
||
}
|
||
}
|
||
function materializeTabPanel(panel) {
|
||
const tmpl = panel.querySelector("template");
|
||
if (tmpl) {
|
||
panel.appendChild(tmpl.content.cloneNode(true));
|
||
tmpl.remove();
|
||
panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||
if (decoderRegistry.length) applyDecoderRegistryVisibility();
|
||
}
|
||
}
|
||
window.navigateToTab = navigateToTab;
|
||
requiredElement("tab-bar").addEventListener("click", (e) => {
|
||
const btn = e.target instanceof Element ? e.target.closest(".tab[data-tab]") : null;
|
||
if (!btn) return;
|
||
const tab = btn.dataset.tab;
|
||
if (tab && TAB_ORDER.includes(tab)) navigateToTab(tab);
|
||
});
|
||
window.addEventListener("popstate", () => {
|
||
navigateToTab(tabFromPath2(), { updateHistory: false });
|
||
});
|
||
(function() {
|
||
let tx = 0, ty = 0;
|
||
const THRESHOLD = 60;
|
||
const ANGLE_LIMIT = 1.6;
|
||
const NO_SWIPE_SELECTORS = [
|
||
"#jog-wheel",
|
||
"#spectrum-canvas",
|
||
"#overview-canvas",
|
||
"#aprs-map",
|
||
".controls-tray-scroll",
|
||
".sub-tab-bar",
|
||
"input[type=range]",
|
||
"select",
|
||
"input[type=text]",
|
||
"input[type=number]",
|
||
"input[type=search]"
|
||
];
|
||
function isExcluded(el) {
|
||
return NO_SWIPE_SELECTORS.some((sel) => el.closest(sel));
|
||
}
|
||
document.addEventListener("touchstart", (e) => {
|
||
if (e.touches.length !== 1) return;
|
||
if (!(e.target instanceof Element) || isExcluded(e.target)) return;
|
||
const touch = e.touches[0];
|
||
if (!touch) return;
|
||
tx = touch.clientX;
|
||
ty = touch.clientY;
|
||
}, { passive: true });
|
||
document.addEventListener("touchend", (e) => {
|
||
if (e.changedTouches.length !== 1 || tx === 0) return;
|
||
const touch = e.changedTouches[0];
|
||
if (!touch) return;
|
||
const dx = touch.clientX - tx;
|
||
const dy = touch.clientY - ty;
|
||
tx = 0;
|
||
if (Math.abs(dx) < THRESHOLD) return;
|
||
if (Math.abs(dy) > 0 && Math.abs(dx) / Math.abs(dy) < ANGLE_LIMIT) return;
|
||
const activeBtn = document.querySelector(".tab-bar .tab.active");
|
||
if (!activeBtn) return;
|
||
const cur = TAB_ORDER.indexOf(activeBtn.dataset.tab);
|
||
if (cur === -1) return;
|
||
const next = dx < 0 ? cur + 1 : cur - 1;
|
||
const nextTab = TAB_ORDER[next];
|
||
if (next >= 0 && next < TAB_ORDER.length && nextTab) navigateToTab(nextTab);
|
||
}, { passive: true });
|
||
})();
|
||
window.addEventListener("resize", () => {
|
||
scheduleSpectrumLayout();
|
||
});
|
||
async function initializeApp() {
|
||
showAuthGate(false);
|
||
const authStatus = await checkAuthStatus();
|
||
authEnabled = !authStatus.auth_disabled;
|
||
if (!authEnabled) {
|
||
authRole = "control";
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
return;
|
||
}
|
||
if (authStatus.authenticated) {
|
||
authRole = authStatus.role ?? null;
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
applyAuthRestrictions();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
} else {
|
||
const allowGuest = authStatus.role === "rx";
|
||
showAuthGate(allowGuest);
|
||
}
|
||
}
|
||
function initSettingsUI() {
|
||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole);
|
||
window.trx.modules.scheduler?.wireEvents();
|
||
if (window.trx.modules.backgroundDecode) {
|
||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole);
|
||
window.trx.modules.backgroundDecode.wireEvents();
|
||
}
|
||
}
|
||
requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const passphraseEl = requiredElement("auth-passphrase");
|
||
const passphrase = passphraseEl.value;
|
||
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.textContent = "Logging in...";
|
||
try {
|
||
const result = await authLogin(passphrase);
|
||
authRole = result.role ?? null;
|
||
passphraseEl.value = "";
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
applyAuthRestrictions();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
} catch (err) {
|
||
showAuthError("Invalid passphrase");
|
||
console.error("Login error:", err);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Login";
|
||
}
|
||
});
|
||
var guestBtn = document.getElementById("auth-guest-btn");
|
||
if (guestBtn) {
|
||
guestBtn.addEventListener("click", () => {
|
||
authRole = "rx";
|
||
requiredElement("auth-passphrase").value = "";
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
applyAuthRestrictions();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
});
|
||
}
|
||
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||
if (headerAuthBtn) {
|
||
headerAuthBtn.addEventListener("click", async () => {
|
||
if (authRole) {
|
||
if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) {
|
||
await authLogout();
|
||
}
|
||
} else {
|
||
showAuthGate(false);
|
||
}
|
||
});
|
||
}
|
||
var trxState = /* @__PURE__ */ Object.create(null);
|
||
var trxModules = {};
|
||
Object.defineProperties(trxState, {
|
||
serverLat: { get() {
|
||
return serverLat;
|
||
}, set(v) {
|
||
serverLat = v;
|
||
} },
|
||
serverLon: { get() {
|
||
return serverLon;
|
||
}, set(v) {
|
||
serverLon = v;
|
||
} },
|
||
lastFreqHz: { get() {
|
||
return lastFreqHz;
|
||
} },
|
||
lastActiveRigId: { get() {
|
||
return lastActiveRigId;
|
||
} },
|
||
lastRigIds: { get() {
|
||
return lastRigIds;
|
||
} },
|
||
lastRigDisplayNames: { get() {
|
||
return lastRigDisplayNames;
|
||
} },
|
||
initialMapZoom: { get() {
|
||
return initialMapZoom;
|
||
} },
|
||
decodeHistoryRetentionMin: { get() {
|
||
return decodeHistoryRetentionMin;
|
||
} },
|
||
authEnabled: { get() {
|
||
return authEnabled;
|
||
} },
|
||
authRole: { get() {
|
||
return authRole;
|
||
} },
|
||
decoderRegistry: { get() {
|
||
return decoderRegistry;
|
||
} },
|
||
sseSessionId: { get() {
|
||
return sseSessionId;
|
||
} },
|
||
primaryRds: { get() {
|
||
return primaryRds;
|
||
} },
|
||
vchanRdsById: { get() {
|
||
return vchanRdsById;
|
||
} },
|
||
vchanSignalDbById: { get() {
|
||
return vchanSignalDbById;
|
||
} },
|
||
lastCityLabel: { get() {
|
||
return lastCityLabel;
|
||
}, set(v) {
|
||
lastCityLabel = v;
|
||
} },
|
||
serverVersion: { get() {
|
||
return serverVersion;
|
||
} },
|
||
serverBuildDate: { get() {
|
||
return serverBuildDate;
|
||
} },
|
||
serverCallsign: { get() {
|
||
return serverCallsign;
|
||
} },
|
||
ownerCallsign: { get() {
|
||
return ownerCallsign;
|
||
} },
|
||
ownerWebsiteUrl: { get() {
|
||
return ownerWebsiteUrl;
|
||
} },
|
||
ownerWebsiteName: { get() {
|
||
return ownerWebsiteName;
|
||
} },
|
||
aisVesselUrlBase: { get() {
|
||
return aisVesselUrlBase;
|
||
} },
|
||
serverRigs: { get() {
|
||
return serverRigs;
|
||
} },
|
||
serverActiveRigId: { get() {
|
||
return serverActiveRigId;
|
||
} },
|
||
lastModeName: { get() {
|
||
return lastModeName;
|
||
}, set(v) {
|
||
lastModeName = v;
|
||
} },
|
||
jogUnit: { get() {
|
||
return jogUnit;
|
||
} },
|
||
rxActive: { get() {
|
||
return rxActive;
|
||
} },
|
||
audioChannelOverride: { get() {
|
||
return _audioChannelOverride;
|
||
}, set(v) {
|
||
_audioChannelOverride = v;
|
||
} },
|
||
lastSpectrumData: { get() {
|
||
return lastSpectrumData;
|
||
} },
|
||
lastSpectrumRenderData: { get() {
|
||
return lastSpectrumRenderData;
|
||
} },
|
||
currentBandwidthHz: { get() {
|
||
return currentBandwidthHz;
|
||
}, set(v) {
|
||
currentBandwidthHz = v;
|
||
window.currentBandwidthHz = v;
|
||
} },
|
||
spectrumFloor: { get() {
|
||
return spectrumFloor;
|
||
} },
|
||
spectrumRange: { get() {
|
||
return spectrumRange;
|
||
} },
|
||
spectrumCanvas: { get() {
|
||
return spectrumCanvas;
|
||
} },
|
||
overviewCanvas: { get() {
|
||
return overviewCanvas;
|
||
} },
|
||
overviewGl: { get() {
|
||
return overviewGl;
|
||
} },
|
||
spectrumGl: { get() {
|
||
return spectrumGl;
|
||
} },
|
||
signalOverlayGl: { get() {
|
||
return signalOverlayGl;
|
||
} }
|
||
});
|
||
var trxCore = Object.freeze({
|
||
saveSetting,
|
||
loadSetting,
|
||
showHint,
|
||
escapeMapHtml: escapeHtml,
|
||
formatFreq: formatFrequency,
|
||
formatFreqForHumans: formatFrequencyForHumans,
|
||
formatWavelength,
|
||
formatBwLabel,
|
||
formatUptime: formatDuration,
|
||
formatSigStrength,
|
||
formatSignal,
|
||
postPath,
|
||
scheduleUiFrameJob,
|
||
navigateToTab,
|
||
rigBadgeColor,
|
||
setRigFrequency,
|
||
applyLocalTunedFrequency,
|
||
armOptimisticFrequency,
|
||
syncBandwidthInput,
|
||
scheduleSpectrumDraw,
|
||
onDecoderRegistryReady,
|
||
syncModePicker,
|
||
formatFreqForStep: formatFrequencyForStep,
|
||
refreshFreqDisplay,
|
||
setJogDivisor,
|
||
mwDefaultsForMode,
|
||
resetRdsDisplay,
|
||
positionRdsPsOverlay,
|
||
updateWfmControls,
|
||
updateSdrSquelchControlVisibility,
|
||
startRxAudio,
|
||
stopRxAudio,
|
||
latLonToMaidenhead,
|
||
locatorToLatLon,
|
||
haversineKm,
|
||
formatDistanceKm,
|
||
formatTimeAgo,
|
||
bookmarkDistanceText,
|
||
buildBookmarkTooltipText,
|
||
nearestBookmarkForHz,
|
||
currentDecodeHistoryRetentionMs,
|
||
currentTheme,
|
||
canvasPalette,
|
||
currentStyle,
|
||
cssColorToRgba,
|
||
rgbaWithAlpha,
|
||
isBinsArray: isNumericBins,
|
||
estimateNoiseFloorDb,
|
||
spectrumVisibleRange,
|
||
drawSpectrum,
|
||
bandForHz: function(hz) {
|
||
return trxModules.map?.bandForHz?.(hz);
|
||
},
|
||
markDecodeMapSyncPending,
|
||
decodeHistoryMapRenderingDeferred,
|
||
updateDocumentTitle,
|
||
activeChannelRds
|
||
});
|
||
Object.defineProperties(trxState, {
|
||
decodeHistoryReplayActive: { get() {
|
||
return decodeHistoryReplayActive;
|
||
} },
|
||
decodeMapSyncPending: { get() {
|
||
return decodeMapSyncPending;
|
||
} },
|
||
_activeTab: { get() {
|
||
return _activeTab;
|
||
} },
|
||
locationSubtitle: { get() {
|
||
return locationSubtitle;
|
||
} }
|
||
});
|
||
window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules });
|
||
void loadEagerPlugins().catch((error) => {
|
||
console.error(error);
|
||
});
|
||
void initializeApp();
|
||
window.addEventListener("resize", resizeHeaderSignalCanvas);
|
||
function bookmarkDistanceText(bm) {
|
||
if (!bm || serverLat == null || serverLon == null) return null;
|
||
const latLon = locatorToLatLon(bm.locator);
|
||
if (!latLon) return null;
|
||
return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon));
|
||
}
|
||
function buildBookmarkTooltipText(bm) {
|
||
if (!bm) return null;
|
||
const parts = [];
|
||
if (bm.name) parts.push(String(bm.name));
|
||
if (window.trx.modules.bookmarks) parts.push(window.trx.modules.bookmarks.formatFrequency(bm.freq_hz));
|
||
if (bm.mode) parts.push(String(bm.mode));
|
||
if (bm.locator) parts.push(String(bm.locator));
|
||
const distance = bookmarkDistanceText(bm);
|
||
if (distance) parts.push(distance);
|
||
let text = parts.join(" · ");
|
||
if (bm.comment) {
|
||
text += (text ? "\n" : "") + String(bm.comment);
|
||
}
|
||
return text;
|
||
}
|
||
function nearestBookmarkForHz(hz, widthPx, range) {
|
||
const ref = window.trx.modules.bookmarks?.overlayList ?? null;
|
||
if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) {
|
||
return null;
|
||
}
|
||
const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10);
|
||
let best = null;
|
||
let bestDelta = Number.POSITIVE_INFINITY;
|
||
for (const bm of ref) {
|
||
const delta = Math.abs(Number(bm.freq_hz) - hz);
|
||
if (delta <= maxDeltaHz && delta < bestDelta) {
|
||
best = bm;
|
||
bestDelta = delta;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
function _wireSubTabBar(bar) {
|
||
if (bar._subtabWired) return;
|
||
bar._subtabWired = true;
|
||
window.trxUi?.prepareTabList(bar, "secondary");
|
||
bar.addEventListener("click", (e) => {
|
||
const btn = e.target instanceof Element ? e.target.closest(".sub-tab[data-subtab]") : null;
|
||
if (!btn) return;
|
||
bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active"));
|
||
btn.classList.add("active");
|
||
window.trxUi?.syncSelectedTab(bar, btn);
|
||
const decoderPicker = document.getElementById("decoder-tab-select");
|
||
if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab ?? "";
|
||
const parent = bar.parentElement;
|
||
if (!parent) return;
|
||
parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none");
|
||
const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`);
|
||
if (nextPanel) nextPanel.style.display = "";
|
||
if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) {
|
||
requestAnimationFrame(() => {
|
||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||
});
|
||
}
|
||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||
window.clearSatPredictionDom();
|
||
}
|
||
});
|
||
}
|
||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||
window.addEventListener("resize", () => {
|
||
const mapTab = document.getElementById("tab-map");
|
||
if (!mapTab || mapTab.style.display === "none") return;
|
||
window.trx.modules.map?.sizeAprsMapToViewport();
|
||
});
|
||
var sigMeasureBtn = requiredElement("sig-measure-btn");
|
||
var sigClearBtn = requiredElement("sig-clear-btn");
|
||
var sigResult = requiredElement("sig-result");
|
||
function resetSignalMeasurementState() {
|
||
sigMeasureLastTickMs = 0;
|
||
sigMeasureAccumMs = 0;
|
||
sigMeasureWeighted = 0;
|
||
sigMeasurePeak = null;
|
||
}
|
||
function updateSignalMeasurement(nowMs) {
|
||
if (!sigMeasuring) return;
|
||
if (sigMeasureLastTickMs === 0) {
|
||
sigMeasureLastTickMs = nowMs;
|
||
return;
|
||
}
|
||
const dt = Math.max(0, nowMs - sigMeasureLastTickMs);
|
||
sigMeasureLastTickMs = nowMs;
|
||
if (!isFiniteNumber(sigLastSUnits)) return;
|
||
sigMeasureAccumMs += dt;
|
||
sigMeasureWeighted += sigLastSUnits * dt;
|
||
if (sigMeasurePeak === null || sigLastSUnits > sigMeasurePeak) {
|
||
sigMeasurePeak = sigLastSUnits;
|
||
}
|
||
}
|
||
function stopSignalMeasurement() {
|
||
if (sigMeasureTimer) {
|
||
clearInterval(sigMeasureTimer);
|
||
sigMeasureTimer = null;
|
||
}
|
||
sigMeasuring = false;
|
||
sigMeasureBtn.textContent = "Measure";
|
||
sigMeasureBtn.style.borderColor = "";
|
||
sigMeasureBtn.style.color = "";
|
||
}
|
||
sigMeasureBtn.addEventListener("click", () => {
|
||
if (!sigMeasuring) {
|
||
resetSignalMeasurementState();
|
||
sigMeasuring = true;
|
||
sigMeasureBtn.textContent = "Stop (0.0s)";
|
||
sigMeasureBtn.style.borderColor = "#00d17f";
|
||
sigMeasureBtn.style.color = "#00d17f";
|
||
sigMeasureTimer = setInterval(() => {
|
||
const now = Date.now();
|
||
updateSignalMeasurement(now);
|
||
sigMeasureBtn.textContent = `Stop (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`;
|
||
}, 200);
|
||
} else {
|
||
updateSignalMeasurement(Date.now());
|
||
stopSignalMeasurement();
|
||
if (sigMeasureAccumMs > 0) {
|
||
const avg = sigMeasureWeighted / sigMeasureAccumMs;
|
||
const peak = sigMeasurePeak ?? avg;
|
||
sigResult.innerHTML = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`;
|
||
}
|
||
}
|
||
});
|
||
sigClearBtn.addEventListener("click", () => {
|
||
stopSignalMeasurement();
|
||
resetSignalMeasurementState();
|
||
sigResult.textContent = "";
|
||
});
|
||
var rxAudioBtn = requiredElement("rx-audio-btn");
|
||
var txAudioBtn = requiredElement("tx-audio-btn");
|
||
var RX_AUDIO_LABEL = "Play Audio";
|
||
var TX_AUDIO_LABEL = "Transmit Audio";
|
||
var audioStatus = requiredElement("audio-status");
|
||
var audioLevelFill = document.getElementById("audio-level-fill");
|
||
var audioRow = requiredElement("audio-row");
|
||
var wfmControlsCol = document.getElementById("wfm-controls-col");
|
||
var wfmDeemphasisEl = document.getElementById("wfm-deemphasis");
|
||
var wfmAudioModeEl = document.getElementById("wfm-audio-mode");
|
||
var wfmDenoiseEl = document.getElementById("wfm-denoise");
|
||
var sdrSettingsRowEl = document.getElementById("sdr-settings-row");
|
||
var sdrGainEl = document.getElementById("sdr-gain-db");
|
||
var sdrGainSetBtn = document.getElementById("sdr-gain-set");
|
||
var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls");
|
||
var sdrLnaGainEl = document.getElementById("sdr-lna-gain-db");
|
||
var sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set");
|
||
var sdrAgcEl = document.getElementById("sdr-agc-enabled");
|
||
var wfmStFlagEl = document.getElementById("wfm-st-flag");
|
||
var wfmCciFillEl = document.getElementById("wfm-cci-fill");
|
||
var wfmCciValEl = document.getElementById("wfm-cci-val");
|
||
var wfmAciFillEl = document.getElementById("wfm-aci-fill");
|
||
var wfmAciValEl = document.getElementById("wfm-aci-val");
|
||
var samControlsCol = document.getElementById("sam-controls-col");
|
||
var modeControlsRow = document.getElementById("mode-controls-row");
|
||
var samStereoWidthEl = document.getElementById("sam-stereo-width");
|
||
var samCarrierSyncEl = document.getElementById("sam-carrier-sync");
|
||
var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap");
|
||
var sdrSquelchEl = document.getElementById("sdr-squelch");
|
||
var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct");
|
||
var SDR_SQUELCH_MIN_DB = -120;
|
||
var SDR_SQUELCH_MAX_DB = -30;
|
||
var syncFromServerSdrSquelch = false;
|
||
var sdrNbWrapEl = document.getElementById("sdr-nb-wrap");
|
||
var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled");
|
||
var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls");
|
||
var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold");
|
||
var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set");
|
||
var sdrNbSupported = false;
|
||
fetch("/audio", { method: "GET" }).then((r) => {
|
||
if (r.status === 404) audioRow.style.display = "none";
|
||
}).catch(() => {
|
||
});
|
||
var audioWs = null;
|
||
var audioCtx = null;
|
||
var rxActive = false;
|
||
var txActive = false;
|
||
var txStream = null;
|
||
var txProcessor = null;
|
||
var streamInfo = null;
|
||
var opusDecoder = null;
|
||
var wasmOpusDecoder = null;
|
||
var txEncoder = null;
|
||
var nextPlayTime = 0;
|
||
var lastLevelUpdate = 0;
|
||
var rxGainNode = null;
|
||
var txGainNode = null;
|
||
var rxVolSlider = requiredElement("rx-vol");
|
||
var txVolSlider = requiredElement("tx-vol");
|
||
var TX_TIMEOUT_SECS = 120;
|
||
var txTimeoutTimer = null;
|
||
var txTimeoutRemaining = 0;
|
||
var txTimeoutInterval = null;
|
||
var hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined";
|
||
var opusDecoderGlobal = window["opus-decoder"];
|
||
var hasWasmOpus = typeof opusDecoderGlobal?.OpusDecoder === "function";
|
||
var MAX_RX_BUFFER_SECS = 0.25;
|
||
var TARGET_RX_BUFFER_SECS = 0.04;
|
||
var MIN_RX_JITTER_SAMPLES = 512;
|
||
if (rxAudioBtn) {
|
||
rxAudioBtn.textContent = RX_AUDIO_LABEL;
|
||
rxAudioBtn.setAttribute("aria-label", RX_AUDIO_LABEL);
|
||
}
|
||
if (txAudioBtn) {
|
||
txAudioBtn.textContent = TX_AUDIO_LABEL;
|
||
txAudioBtn.setAttribute("aria-label", TX_AUDIO_LABEL);
|
||
}
|
||
function setAudioLevel(levelPct) {
|
||
if (!audioLevelFill) return;
|
||
const clamped = Math.max(0, Math.min(100, isFiniteNumber(levelPct) ? levelPct : 0));
|
||
audioLevelFill.style.width = `${clamped}%`;
|
||
}
|
||
function ensureRxAudioContext(preferredSampleRate) {
|
||
if (!audioCtx) {
|
||
try {
|
||
audioCtx = isFiniteNumber(preferredSampleRate) && preferredSampleRate > 0 ? new AudioContext({ sampleRate: preferredSampleRate }) : new AudioContext();
|
||
} catch (e) {
|
||
audioCtx = new AudioContext();
|
||
}
|
||
}
|
||
audioCtx.resume().catch(() => {
|
||
});
|
||
if (!rxGainNode) {
|
||
rxGainNode = audioCtx.createGain();
|
||
rxGainNode.connect(audioCtx.destination);
|
||
}
|
||
}
|
||
function levelFromChannels(channels, frameCount) {
|
||
if (!Array.isArray(channels) || channels.length === 0 || !isFiniteNumber(frameCount) || frameCount <= 0) {
|
||
return 0;
|
||
}
|
||
let sumSquares = 0;
|
||
let samples = 0;
|
||
for (const channel of channels) {
|
||
if (!channel) continue;
|
||
const limit = Math.min(frameCount, channel.length);
|
||
for (let i = 0; i < limit; i++) {
|
||
const sample = channel[i] ?? 0;
|
||
sumSquares += sample * sample;
|
||
}
|
||
samples += limit;
|
||
}
|
||
if (samples <= 0) return 0;
|
||
const rms = Math.sqrt(sumSquares / samples);
|
||
return Math.min(100, rms * 220);
|
||
}
|
||
function normalizeWfmDenoiseLevel(value) {
|
||
const next = primitiveString(value).toLowerCase();
|
||
if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next;
|
||
return "auto";
|
||
}
|
||
function clampSdrSquelchPercent(value) {
|
||
if (!isFiniteNumber(value)) return 0;
|
||
return Math.max(0, Math.min(100, Math.round(value)));
|
||
}
|
||
function sdrSquelchPercentToServer(percent) {
|
||
const pct = clampSdrSquelchPercent(percent);
|
||
if (pct <= 0) {
|
||
return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB };
|
||
}
|
||
const ratio = pct / 100;
|
||
const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
|
||
return { enabled: true, thresholdDb };
|
||
}
|
||
function sdrSquelchServerToPercent(enabled, thresholdDb) {
|
||
if (!enabled) return 0;
|
||
if (!isFiniteNumber(thresholdDb)) return 0;
|
||
const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB);
|
||
return clampSdrSquelchPercent(ratio * 100);
|
||
}
|
||
function updateSdrSquelchPctLabel() {
|
||
if (!sdrSquelchEl || !sdrSquelchPctEl) return;
|
||
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
|
||
sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`;
|
||
}
|
||
function updateSdrSquelchControlVisibility() {
|
||
if (!sdrSquelchWrapEl) return;
|
||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
|
||
}
|
||
function syncSdrSquelchFromServer(enabled, thresholdDb) {
|
||
if (!sdrSquelchEl) return;
|
||
if (document.activeElement === sdrSquelchEl) return;
|
||
const pct = sdrSquelchServerToPercent(enabled, thresholdDb);
|
||
syncFromServerSdrSquelch = true;
|
||
sdrSquelchEl.value = String(pct);
|
||
updateSdrSquelchPctLabel();
|
||
syncFromServerSdrSquelch = false;
|
||
saveSetting("sdrSquelchPct", pct);
|
||
}
|
||
function submitSdrSquelchPercent(percent) {
|
||
if (!sdrSquelchSupported) return;
|
||
const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent);
|
||
postPath(
|
||
`/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}`
|
||
).catch(() => {
|
||
});
|
||
}
|
||
if (sdrSquelchEl) {
|
||
const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0)));
|
||
sdrSquelchEl.value = String(savedPct);
|
||
updateSdrSquelchPctLabel();
|
||
sdrSquelchEl.addEventListener("input", () => {
|
||
const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
|
||
sdrSquelchEl.value = String(pct);
|
||
updateSdrSquelchPctLabel();
|
||
saveSetting("sdrSquelchPct", pct);
|
||
if (!syncFromServerSdrSquelch) {
|
||
submitSdrSquelchPercent(pct);
|
||
}
|
||
});
|
||
}
|
||
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
|
||
if (sdrSquelchAutoBtn) {
|
||
sdrSquelchAutoBtn.addEventListener("click", () => {
|
||
if (!sdrSquelchSupported) return;
|
||
let pct = 0;
|
||
const data = lastSpectrumData || window.lastSpectrumData;
|
||
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
|
||
const noiseDb = estimateNoiseFloorDb(data.bins);
|
||
if (noiseDb != null && isFiniteNumber(noiseDb)) {
|
||
const thresholdDb = noiseDb + 6;
|
||
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
|
||
pct = clampSdrSquelchPercent(
|
||
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
|
||
);
|
||
}
|
||
}
|
||
if (sdrSquelchEl) {
|
||
sdrSquelchEl.value = String(pct);
|
||
updateSdrSquelchPctLabel();
|
||
saveSetting("sdrSquelchPct", pct);
|
||
}
|
||
submitSdrSquelchPercent(pct);
|
||
});
|
||
}
|
||
if (wfmAudioModeEl) {
|
||
wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo");
|
||
wfmAudioModeEl.addEventListener("change", () => {
|
||
saveSetting("wfmAudioMode", wfmAudioModeEl.value);
|
||
const enabled = wfmAudioModeEl.value !== "mono";
|
||
postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
if (wfmDenoiseEl) {
|
||
wfmDenoiseEl.value = normalizeWfmDenoiseLevel(loadSetting("wfmDenoise", "auto"));
|
||
wfmDenoiseEl.addEventListener("change", () => {
|
||
const level = normalizeWfmDenoiseLevel(wfmDenoiseEl.value);
|
||
wfmDenoiseEl.value = level;
|
||
saveSetting("wfmDenoise", level);
|
||
postPath(`/set_wfm_denoise?level=${encodeURIComponent(level)}`).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
if (wfmDeemphasisEl) {
|
||
wfmDeemphasisEl.addEventListener("change", () => {
|
||
postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
if (samStereoWidthEl) {
|
||
samStereoWidthEl.addEventListener("input", () => {
|
||
const width = Number(samStereoWidthEl.value) / 100;
|
||
postPath(`/set_sam_stereo_width?width=${width}`).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
if (samCarrierSyncEl) {
|
||
samCarrierSyncEl.addEventListener("change", () => {
|
||
const enabled = samCarrierSyncEl.value === "on";
|
||
postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
function submitSdrGain() {
|
||
if (!sdrGainEl) return;
|
||
const parsed = Number.parseFloat(sdrGainEl.value);
|
||
if (!isFiniteNumber(parsed) || parsed < 0) return;
|
||
postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => {
|
||
});
|
||
}
|
||
function updateSdrGainInputState() {
|
||
if (!sdrAgcEl) return;
|
||
const agcOn = sdrAgcEl.checked;
|
||
if (sdrGainEl) sdrGainEl.disabled = agcOn;
|
||
if (sdrGainSetBtn) sdrGainSetBtn.disabled = agcOn;
|
||
if (sdrLnaGainEl) sdrLnaGainEl.disabled = agcOn;
|
||
if (sdrLnaGainSetBtn) sdrLnaGainSetBtn.disabled = agcOn;
|
||
}
|
||
if (sdrAgcEl) {
|
||
sdrAgcEl.addEventListener("change", () => {
|
||
postPath(`/set_sdr_agc?enabled=${sdrAgcEl.checked ? "true" : "false"}`).catch(() => {
|
||
});
|
||
updateSdrGainInputState();
|
||
});
|
||
}
|
||
if (sdrGainSetBtn) {
|
||
sdrGainSetBtn.addEventListener("click", submitSdrGain);
|
||
}
|
||
if (sdrGainEl) {
|
||
sdrGainEl.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") {
|
||
ev.preventDefault();
|
||
submitSdrGain();
|
||
}
|
||
});
|
||
}
|
||
function submitSdrLnaGain() {
|
||
if (!sdrLnaGainEl) return;
|
||
const parsed = Number.parseFloat(sdrLnaGainEl.value);
|
||
if (!isFiniteNumber(parsed) || parsed < 0) return;
|
||
postPath(`/set_sdr_lna_gain?db=${encodeURIComponent(parsed)}`).catch(() => {
|
||
});
|
||
}
|
||
if (sdrLnaGainSetBtn) {
|
||
sdrLnaGainSetBtn.addEventListener("click", submitSdrLnaGain);
|
||
}
|
||
if (sdrLnaGainEl) {
|
||
sdrLnaGainEl.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") {
|
||
ev.preventDefault();
|
||
submitSdrLnaGain();
|
||
}
|
||
});
|
||
}
|
||
function submitSdrNbState() {
|
||
if (!sdrNbSupported) return;
|
||
const enabled = sdrNbEnabledEl ? sdrNbEnabledEl.checked : false;
|
||
const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10;
|
||
if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return;
|
||
postPath(
|
||
`/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}`
|
||
).catch(() => {
|
||
});
|
||
}
|
||
if (sdrNbEnabledEl) {
|
||
sdrNbEnabledEl.addEventListener("change", () => {
|
||
submitSdrNbState();
|
||
});
|
||
}
|
||
function submitSdrNbThreshold() {
|
||
if (!sdrNbThresholdEl) return;
|
||
const parsed = Number.parseFloat(sdrNbThresholdEl.value);
|
||
if (!isFiniteNumber(parsed) || parsed < 1 || parsed > 100) return;
|
||
submitSdrNbState();
|
||
}
|
||
if (sdrNbThresholdSetBtn) {
|
||
sdrNbThresholdSetBtn.addEventListener("click", submitSdrNbThreshold);
|
||
}
|
||
if (sdrNbThresholdEl) {
|
||
sdrNbThresholdEl.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") {
|
||
ev.preventDefault();
|
||
submitSdrNbThreshold();
|
||
}
|
||
});
|
||
}
|
||
function updateWfmControls() {
|
||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||
if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none";
|
||
if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none";
|
||
if (modeControlsRow) modeControlsRow.style.display = mode === "WFM" || mode === "SAM" ? "" : "none";
|
||
}
|
||
if (!hasWebCodecs) {
|
||
rxAudioBtn.disabled = true;
|
||
txAudioBtn.disabled = true;
|
||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||
}
|
||
function resetTxTimeout() {
|
||
txTimeoutRemaining = TX_TIMEOUT_SECS;
|
||
if (txTimeoutTimer) clearTimeout(txTimeoutTimer);
|
||
txTimeoutTimer = setTimeout(() => {
|
||
console.warn("PTT safety timeout — stopping TX");
|
||
void stopTxAudio();
|
||
}, TX_TIMEOUT_SECS * 1e3);
|
||
}
|
||
function startTxTimeoutCountdown() {
|
||
txTimeoutRemaining = TX_TIMEOUT_SECS;
|
||
if (txTimeoutInterval) clearInterval(txTimeoutInterval);
|
||
txTimeoutInterval = setInterval(() => {
|
||
txTimeoutRemaining--;
|
||
if (txTimeoutRemaining <= 10 && txTimeoutRemaining > 0 && txActive) {
|
||
audioStatus.textContent = `TX timeout ${txTimeoutRemaining}s`;
|
||
}
|
||
}, 1e3);
|
||
}
|
||
function clearTxTimeout() {
|
||
if (txTimeoutTimer) {
|
||
clearTimeout(txTimeoutTimer);
|
||
txTimeoutTimer = null;
|
||
}
|
||
if (txTimeoutInterval) {
|
||
clearInterval(txTimeoutInterval);
|
||
txTimeoutInterval = null;
|
||
}
|
||
txTimeoutRemaining = 0;
|
||
}
|
||
function resetRxDecoder() {
|
||
if (opusDecoder) {
|
||
try {
|
||
opusDecoder.close();
|
||
} catch (e) {
|
||
}
|
||
opusDecoder = null;
|
||
}
|
||
if (wasmOpusDecoder) {
|
||
try {
|
||
wasmOpusDecoder.free();
|
||
} catch (e) {
|
||
}
|
||
wasmOpusDecoder = null;
|
||
}
|
||
nextPlayTime = 0;
|
||
}
|
||
function configureRxStream(nextInfo) {
|
||
const nextSampleRate = nextInfo && nextInfo.sample_rate || 48e3;
|
||
streamInfo = nextInfo;
|
||
updateWfmControls();
|
||
resetRxDecoder();
|
||
ensureRxAudioContext(nextSampleRate);
|
||
if (rxGainNode) rxGainNode.gain.value = Number(rxVolSlider.value) / 100;
|
||
rxActive = true;
|
||
window.trxUi?.setButtonState(rxAudioBtn, { active: true, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||
setAudioLevel(0);
|
||
rxAudioBtn.style.borderColor = "#00d17f";
|
||
rxAudioBtn.style.color = "#00d17f";
|
||
audioStatus.textContent = "RX";
|
||
syncHeaderAudioBtn();
|
||
}
|
||
function extractAudioFrameChannels(frame) {
|
||
const channels = Math.max(1, frame.numberOfChannels || 1);
|
||
const frames = Math.max(0, frame.numberOfFrames || 0);
|
||
const format = String(frame.format || "").toLowerCase();
|
||
const isPlanar = format.includes("planar");
|
||
if (!isPlanar) {
|
||
const interleaved = new Float32Array(frames * channels);
|
||
frame.copyTo(interleaved, { planeIndex: 0 });
|
||
const out2 = Array.from({ length: channels }, () => new Float32Array(frames));
|
||
for (let i = 0; i < frames; i++) {
|
||
for (let ch = 0; ch < channels; ch++) {
|
||
const channel = out2[ch];
|
||
if (channel) channel[i] = interleaved[i * channels + ch] ?? 0;
|
||
}
|
||
}
|
||
return out2;
|
||
}
|
||
const out = [];
|
||
for (let ch = 0; ch < channels; ch++) {
|
||
let len = frames;
|
||
try {
|
||
len = Math.max(frames, Math.floor(frame.allocationSize({ planeIndex: ch }) / 4));
|
||
} catch (e) {
|
||
}
|
||
const plane = new Float32Array(len);
|
||
frame.copyTo(plane, { planeIndex: ch });
|
||
out.push(plane.length === frames ? plane : plane.subarray(0, frames));
|
||
}
|
||
return out;
|
||
}
|
||
var _audioChannelOverride = null;
|
||
function scheduleDecodedAudio(channelData, frameCount, sampleRate) {
|
||
if (!audioCtx || !rxGainNode) return;
|
||
const levelNow = Date.now();
|
||
if (levelNow - lastLevelUpdate >= 50) {
|
||
setAudioLevel(levelFromChannels(channelData, frameCount));
|
||
lastLevelUpdate = levelNow;
|
||
}
|
||
const forceMono = channelData.length >= 2 && wfmAudioModeEl && wfmAudioModeEl.value === "mono" && modeEl && (modeEl.value || "").toUpperCase() === "WFM";
|
||
const outChannels = forceMono ? 1 : channelData.length;
|
||
const ab = audioCtx.createBuffer(outChannels, frameCount, sampleRate);
|
||
if (forceMono) {
|
||
const monoData = new Float32Array(frameCount);
|
||
for (let ch = 0; ch < channelData.length; ch++) {
|
||
const plane = channelData[ch];
|
||
if (!plane) continue;
|
||
for (let i = 0; i < frameCount; i++) monoData[i] = (monoData[i] ?? 0) + (plane[i] ?? 0);
|
||
}
|
||
const inv = 1 / Math.max(1, channelData.length);
|
||
for (let i = 0; i < frameCount; i++) monoData[i] = (monoData[i] ?? 0) * inv;
|
||
ab.copyToChannel(monoData, 0);
|
||
} else {
|
||
for (let ch = 0; ch < channelData.length; ch++) {
|
||
const channel = channelData[ch];
|
||
if (channel) ab.copyToChannel(new Float32Array(channel), ch);
|
||
}
|
||
}
|
||
const src = audioCtx.createBufferSource();
|
||
src.buffer = ab;
|
||
src.connect(rxGainNode);
|
||
const now = audioCtx.currentTime;
|
||
const sr = streamInfo && streamInfo.sample_rate || sampleRate || 48e3;
|
||
const minLeadSecs = Math.max(0, MIN_RX_JITTER_SAMPLES / Math.max(1, sr));
|
||
const targetLeadSecs = Math.max(TARGET_RX_BUFFER_SECS, minLeadSecs);
|
||
if (nextPlayTime && nextPlayTime - now > MAX_RX_BUFFER_SECS) {
|
||
nextPlayTime = now + targetLeadSecs;
|
||
}
|
||
if (!nextPlayTime || nextPlayTime < now + minLeadSecs) {
|
||
nextPlayTime = now + targetLeadSecs;
|
||
}
|
||
const schedTime = nextPlayTime || now + targetLeadSecs;
|
||
src.start(schedTime);
|
||
nextPlayTime = schedTime + ab.duration;
|
||
}
|
||
function startRxAudio() {
|
||
if (rxActive) {
|
||
stopRxAudio();
|
||
return;
|
||
}
|
||
if (!hasWebCodecs && !hasWasmOpus) {
|
||
audioStatus.textContent = "Audio not supported in this browser";
|
||
return;
|
||
}
|
||
ensureRxAudioContext(streamInfo && streamInfo.sample_rate || 48e3);
|
||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||
let audioPath;
|
||
if (_audioChannelOverride) {
|
||
const remoteParam = lastActiveRigId ? `&remote=${encodeURIComponent(lastActiveRigId)}` : "";
|
||
audioPath = `/audio?channel_id=${encodeURIComponent(_audioChannelOverride)}${remoteParam}`;
|
||
} else if (lastActiveRigId) {
|
||
audioPath = `/audio?remote=${encodeURIComponent(lastActiveRigId)}`;
|
||
} else {
|
||
audioPath = "/audio";
|
||
}
|
||
audioWs = new WebSocket(`${proto}//${location.host}${audioPath}`);
|
||
audioWs.binaryType = "arraybuffer";
|
||
audioStatus.textContent = "Connecting…";
|
||
audioWs.onopen = () => {
|
||
audioStatus.textContent = "Connected";
|
||
};
|
||
audioWs.onmessage = (evt) => {
|
||
if (typeof evt.data === "string") {
|
||
try {
|
||
const info = parseJsonUnknown(evt.data);
|
||
if (!isAudioStreamInfo(info)) throw new TypeError("Unexpected audio stream metadata");
|
||
configureRxStream(info);
|
||
} catch (e) {
|
||
console.error("Audio stream info parse error", e);
|
||
}
|
||
return;
|
||
}
|
||
if (!audioCtx) return;
|
||
const data = new Uint8Array(evt.data);
|
||
if (!opusDecoder && !wasmOpusDecoder) {
|
||
const channels = streamInfo && streamInfo.channels || 1;
|
||
const sampleRate = streamInfo && streamInfo.sample_rate || 48e3;
|
||
if (hasWebCodecs) {
|
||
try {
|
||
opusDecoder = new AudioDecoder({
|
||
output: (frame) => {
|
||
const ch = extractAudioFrameChannels(frame);
|
||
scheduleDecodedAudio(ch, frame.numberOfFrames, frame.sampleRate);
|
||
frame.close();
|
||
},
|
||
error: (e) => {
|
||
console.error("AudioDecoder error", e);
|
||
}
|
||
});
|
||
opusDecoder.configure({ codec: "opus", sampleRate, numberOfChannels: channels });
|
||
} catch (e) {
|
||
console.warn("WebCodecs Opus not supported, trying WASM fallback", e);
|
||
opusDecoder = null;
|
||
}
|
||
}
|
||
if (!opusDecoder && hasWasmOpus) {
|
||
try {
|
||
const coupledStreamCount = channels >= 2 ? 1 : 0;
|
||
const mapping = channels >= 2 ? [0, 1] : [0];
|
||
const Decoder = opusDecoderGlobal?.OpusDecoder;
|
||
if (!Decoder) throw new Error("Opus decoder unavailable");
|
||
wasmOpusDecoder = new Decoder({
|
||
sampleRate,
|
||
channels,
|
||
streamCount: 1,
|
||
coupledStreamCount,
|
||
channelMappingTable: mapping,
|
||
preSkip: 0
|
||
});
|
||
const decoder = wasmOpusDecoder;
|
||
decoder.ready.then(() => {
|
||
audioStatus.textContent = "RX";
|
||
}).catch((e) => {
|
||
console.error("WASM Opus init failed", e);
|
||
wasmOpusDecoder = null;
|
||
});
|
||
} catch (e) {
|
||
console.warn("WASM Opus decoder init failed", e);
|
||
wasmOpusDecoder = null;
|
||
}
|
||
}
|
||
}
|
||
if (opusDecoder) {
|
||
try {
|
||
opusDecoder.decode(new EncodedAudioChunk({
|
||
type: "key",
|
||
timestamp: performance.now() * 1e3,
|
||
data
|
||
}));
|
||
} catch (e) {
|
||
}
|
||
} else if (wasmOpusDecoder) {
|
||
try {
|
||
const result = wasmOpusDecoder.decodeFrame(data);
|
||
if (result && result.samplesDecoded > 0) {
|
||
scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48e3);
|
||
}
|
||
} catch (e) {
|
||
}
|
||
}
|
||
};
|
||
audioWs.onclose = () => {
|
||
if (txActive) {
|
||
void stopTxAudio();
|
||
}
|
||
rxActive = false;
|
||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||
streamInfo = null;
|
||
updateWfmControls();
|
||
rxAudioBtn.style.borderColor = "";
|
||
rxAudioBtn.style.color = "";
|
||
audioStatus.textContent = "Off";
|
||
setAudioLevel(0);
|
||
rxGainNode = null;
|
||
if (opusDecoder) {
|
||
try {
|
||
opusDecoder.close();
|
||
} catch (e) {
|
||
}
|
||
opusDecoder = null;
|
||
}
|
||
if (wasmOpusDecoder) {
|
||
try {
|
||
wasmOpusDecoder.free();
|
||
} catch (e) {
|
||
}
|
||
wasmOpusDecoder = null;
|
||
}
|
||
nextPlayTime = 0;
|
||
syncHeaderAudioBtn();
|
||
};
|
||
audioWs.onerror = () => {
|
||
audioStatus.textContent = "Error";
|
||
};
|
||
}
|
||
function stopRxAudio() {
|
||
rxActive = false;
|
||
window.trxUi?.setButtonState(rxAudioBtn, { active: false, activeLabel: "Stop Audio", inactiveLabel: "Play Audio" });
|
||
streamInfo = null;
|
||
if (audioWs) {
|
||
audioWs.close();
|
||
audioWs = null;
|
||
}
|
||
if (audioCtx) {
|
||
void audioCtx.close();
|
||
audioCtx = null;
|
||
}
|
||
updateWfmControls();
|
||
rxGainNode = null;
|
||
if (opusDecoder) {
|
||
try {
|
||
opusDecoder.close();
|
||
} catch (e) {
|
||
}
|
||
opusDecoder = null;
|
||
}
|
||
if (wasmOpusDecoder) {
|
||
try {
|
||
wasmOpusDecoder.free();
|
||
} catch (e) {
|
||
}
|
||
wasmOpusDecoder = null;
|
||
}
|
||
nextPlayTime = 0;
|
||
rxAudioBtn.style.borderColor = "";
|
||
rxAudioBtn.style.color = "";
|
||
audioStatus.textContent = "Off";
|
||
setAudioLevel(0);
|
||
syncHeaderAudioBtn();
|
||
}
|
||
function startTxAudio() {
|
||
if (txActive) {
|
||
void stopTxAudio();
|
||
return;
|
||
}
|
||
if (!hasWebCodecs) {
|
||
audioStatus.textContent = "Audio requires Chrome/Edge";
|
||
return;
|
||
}
|
||
if (!audioWs || audioWs.readyState !== WebSocket.OPEN) {
|
||
audioStatus.textContent = "RX first";
|
||
return;
|
||
}
|
||
if (!streamInfo) return;
|
||
const activeStreamInfo = streamInfo;
|
||
navigator.mediaDevices.getUserMedia({
|
||
audio: { sampleRate: activeStreamInfo.sample_rate || 48e3, channelCount: activeStreamInfo.channels || 1 }
|
||
}).then(async (stream) => {
|
||
txStream = stream;
|
||
txActive = true;
|
||
window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||
txAudioBtn.style.borderColor = "#e55353";
|
||
txAudioBtn.style.color = "#e55353";
|
||
audioStatus.textContent = "RX+TX";
|
||
resetTxTimeout();
|
||
startTxTimeoutCountdown();
|
||
try {
|
||
await postPath("/set_ptt?ptt=true");
|
||
} catch (e) {
|
||
console.error("PTT on failed", e);
|
||
}
|
||
const sampleRate = activeStreamInfo.sample_rate || 48e3;
|
||
const channels = activeStreamInfo.channels || 1;
|
||
const encoder = new AudioEncoder({
|
||
output: (chunk) => {
|
||
const buf = new ArrayBuffer(chunk.byteLength);
|
||
chunk.copyTo(buf);
|
||
if (audioWs && audioWs.readyState === WebSocket.OPEN) {
|
||
audioWs.send(buf);
|
||
}
|
||
},
|
||
error: (e) => {
|
||
console.error("AudioEncoder error", e);
|
||
}
|
||
});
|
||
encoder.configure({
|
||
codec: "opus",
|
||
sampleRate,
|
||
numberOfChannels: channels,
|
||
bitrate: activeStreamInfo.bitrate_bps || 24e3
|
||
});
|
||
txEncoder = encoder;
|
||
if (!audioCtx) audioCtx = new AudioContext({ sampleRate });
|
||
const source = audioCtx.createMediaStreamSource(stream);
|
||
const frameDuration = (activeStreamInfo.frame_duration_ms || 20) / 1e3;
|
||
const frameSize = Math.floor(sampleRate * frameDuration);
|
||
const processor = audioCtx.createScriptProcessor(frameSize, channels, channels);
|
||
let tsCounter = 0;
|
||
processor.onaudioprocess = (e) => {
|
||
if (!txActive || !txEncoder) return;
|
||
const input = e.inputBuffer;
|
||
resetTxTimeout();
|
||
const monoData = input.getChannelData(0);
|
||
try {
|
||
const frame = new AudioData({
|
||
format: "f32-planar",
|
||
sampleRate: input.sampleRate,
|
||
numberOfFrames: input.length,
|
||
numberOfChannels: 1,
|
||
timestamp: tsCounter,
|
||
data: monoData
|
||
});
|
||
tsCounter += input.length / input.sampleRate * 1e6;
|
||
txEncoder.encode(frame);
|
||
frame.close();
|
||
} catch (e2) {
|
||
}
|
||
};
|
||
txGainNode = audioCtx.createGain();
|
||
txGainNode.gain.value = Number(txVolSlider.value) / 100;
|
||
source.connect(txGainNode);
|
||
txGainNode.connect(processor);
|
||
processor.connect(audioCtx.destination);
|
||
txProcessor = { source, processor };
|
||
}).catch((err) => {
|
||
console.error("getUserMedia failed:", err);
|
||
audioStatus.textContent = "Mic denied";
|
||
});
|
||
}
|
||
async function stopTxAudio() {
|
||
if (!txActive) return;
|
||
txActive = false;
|
||
window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" });
|
||
clearTxTimeout();
|
||
try {
|
||
await postPath("/set_ptt?ptt=false");
|
||
} catch (e) {
|
||
console.error("PTT off failed", e);
|
||
}
|
||
if (txStream) {
|
||
txStream.getTracks().forEach((t) => t.stop());
|
||
txStream = null;
|
||
}
|
||
if (txProcessor) {
|
||
txProcessor.source.disconnect();
|
||
txProcessor.processor.disconnect();
|
||
txProcessor = null;
|
||
}
|
||
if (txEncoder) {
|
||
try {
|
||
txEncoder.close();
|
||
} catch (e) {
|
||
}
|
||
txEncoder = null;
|
||
}
|
||
txGainNode = null;
|
||
txAudioBtn.style.borderColor = "";
|
||
txAudioBtn.style.color = "";
|
||
audioStatus.textContent = rxActive ? "RX" : "Off";
|
||
}
|
||
rxAudioBtn.addEventListener("click", startRxAudio);
|
||
txAudioBtn.addEventListener("click", startTxAudio);
|
||
var headerAudioToggle = document.getElementById("header-audio-toggle");
|
||
var _audioIconPlay = '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M5 3v10l8-5z"/></svg>';
|
||
var _audioIconPause = '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="3" y="3" width="3.5" height="10" rx="0.8"/><rect x="9.5" y="3" width="3.5" height="10" rx="0.8"/></svg>';
|
||
function syncHeaderAudioBtn() {
|
||
if (!headerAudioToggle) return;
|
||
headerAudioToggle.classList.toggle("audio-active", rxActive);
|
||
headerAudioToggle.title = rxActive ? "Stop audio" : "Play audio";
|
||
headerAudioToggle.innerHTML = rxActive ? _audioIconPause : _audioIconPlay;
|
||
}
|
||
if (headerAudioToggle) {
|
||
headerAudioToggle.addEventListener("click", startRxAudio);
|
||
}
|
||
var recorderActive = false;
|
||
var recorderStartBtn = document.getElementById("recorder-start-btn");
|
||
var recorderStopBtn = document.getElementById("recorder-stop-btn");
|
||
var recorderStatusInd = document.getElementById("recorder-status-indicator");
|
||
var headerRecBtn = document.getElementById("header-rec-btn");
|
||
function syncRecorderUi() {
|
||
if (recorderStartBtn) recorderStartBtn.disabled = recorderActive;
|
||
if (recorderStopBtn) recorderStopBtn.disabled = !recorderActive;
|
||
if (recorderStatusInd) {
|
||
recorderStatusInd.textContent = recorderActive ? "Recording" : "";
|
||
recorderStatusInd.classList.toggle("rec-active", recorderActive);
|
||
}
|
||
if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive);
|
||
const tabBtn = document.querySelector('.tab[data-tab="recorder"]');
|
||
if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive);
|
||
}
|
||
if (recorderStartBtn) {
|
||
recorderStartBtn.addEventListener("click", async () => {
|
||
try {
|
||
await postPath("/api/recorder/start");
|
||
} catch (e) {
|
||
console.error("Recorder start failed", e);
|
||
}
|
||
});
|
||
}
|
||
if (recorderStopBtn) {
|
||
recorderStopBtn.addEventListener("click", async () => {
|
||
try {
|
||
await postPath("/api/recorder/stop");
|
||
} catch (e) {
|
||
console.error("Recorder stop failed", e);
|
||
}
|
||
});
|
||
}
|
||
if (headerRecBtn) {
|
||
headerRecBtn.addEventListener("click", async () => {
|
||
try {
|
||
if (recorderActive) {
|
||
await postPath("/api/recorder/stop");
|
||
} else {
|
||
await postPath("/api/recorder/start");
|
||
}
|
||
} catch (e) {
|
||
console.error("Recorder toggle failed", e);
|
||
}
|
||
});
|
||
}
|
||
window._syncRecorderState = function(enabled) {
|
||
recorderActive = enabled;
|
||
syncRecorderUi();
|
||
};
|
||
var _recorderFiles = [];
|
||
var _recFilesPage = 0;
|
||
var REC_PAGE_SIZE = 15;
|
||
async function refreshRecorderStatus() {
|
||
try {
|
||
const [statusResp, filesResp] = await Promise.all([
|
||
fetch("/api/recorder/status"),
|
||
fetch("/api/recorder/files")
|
||
]);
|
||
if (statusResp.ok) {
|
||
const active = await responseJsonUnknown(statusResp);
|
||
if (isRecorderActiveList(active)) renderRecorderActive(active);
|
||
}
|
||
if (filesResp.ok) {
|
||
const files = await responseJsonUnknown(filesResp);
|
||
if (isRecorderFileList(files)) {
|
||
_recorderFiles = files;
|
||
renderRecorderFiles();
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error("Recorder status fetch failed", e);
|
||
}
|
||
}
|
||
function renderRecorderActive(list) {
|
||
const el = document.getElementById("recorder-active-list");
|
||
if (!el) return;
|
||
if (!list.length) {
|
||
el.innerHTML = '<p class="recorder-empty">No active recordings.</p>';
|
||
return;
|
||
}
|
||
let html = '<table class="recorder-table"><thead><tr><th>Rig</th><th>VChan</th><th>File</th><th>Started</th></tr></thead><tbody>';
|
||
for (const r of list) {
|
||
const started = new Date(r.started_at * 1e3).toLocaleTimeString();
|
||
const fname = r.path.split("/").pop();
|
||
html += `<tr><td>${escapeHtml(r.rig_id)}</td><td>${r.vchan_id ? escapeHtml(r.vchan_id) : "-"}</td><td>${escapeHtml(fname)}</td><td>${started}</td></tr>`;
|
||
}
|
||
html += "</tbody></table>";
|
||
el.innerHTML = html;
|
||
}
|
||
function recFilterAndSort() {
|
||
const filterEl = document.getElementById("recorder-filter");
|
||
const sortEl = document.getElementById("recorder-sort");
|
||
const filter = (filterEl ? filterEl.value : "").toLowerCase();
|
||
const sortMode = sortEl ? sortEl.value : "name-desc";
|
||
let filtered = _recorderFiles;
|
||
if (filter) {
|
||
filtered = filtered.filter(function(f) {
|
||
return f.name.toLowerCase().includes(filter);
|
||
});
|
||
}
|
||
const sorted = filtered.slice();
|
||
switch (sortMode) {
|
||
case "name-asc":
|
||
sorted.sort(function(a, b) {
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
break;
|
||
case "name-desc":
|
||
sorted.sort(function(a, b) {
|
||
return b.name.localeCompare(a.name);
|
||
});
|
||
break;
|
||
case "size-asc":
|
||
sorted.sort(function(a, b) {
|
||
return a.size - b.size;
|
||
});
|
||
break;
|
||
case "size-desc":
|
||
sorted.sort(function(a, b) {
|
||
return b.size - a.size;
|
||
});
|
||
break;
|
||
}
|
||
return sorted;
|
||
}
|
||
function renderRecorderFiles() {
|
||
const el = document.getElementById("recorder-files-list");
|
||
if (!el) return;
|
||
const sorted = recFilterAndSort();
|
||
const total = sorted.length;
|
||
const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE));
|
||
if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1;
|
||
if (_recFilesPage < 0) _recFilesPage = 0;
|
||
const start = _recFilesPage * REC_PAGE_SIZE;
|
||
const page = sorted.slice(start, start + REC_PAGE_SIZE);
|
||
const summaryEl = document.getElementById("rec-page-summary");
|
||
const indicatorEl = document.getElementById("rec-page-indicator");
|
||
const prevBtn = document.getElementById("rec-page-prev");
|
||
const nextBtn = document.getElementById("rec-page-next");
|
||
if (summaryEl) {
|
||
summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0";
|
||
}
|
||
if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages;
|
||
if (prevBtn) prevBtn.disabled = _recFilesPage <= 0;
|
||
if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1;
|
||
const filterEl = document.getElementById("recorder-filter");
|
||
const filter = filterEl ? filterEl.value : "";
|
||
if (!page.length) {
|
||
el.innerHTML = '<p class="recorder-empty">' + (filter ? "No files match filter." : "No recorded files.") + "</p>";
|
||
return;
|
||
}
|
||
let html = '<table class="recorder-table"><thead><tr><th>File</th><th>Size</th><th>Actions</th></tr></thead><tbody>';
|
||
for (const f of page) {
|
||
const safeName = escapeHtml(f.name);
|
||
const encodedName = encodeURIComponent(f.name);
|
||
html += '<tr data-name="' + safeName + '"><td>' + safeName + "</td><td>" + formatByteSize(f.size) + '</td><td><div class="rec-file-actions"><button class="rec-file-btn rec-play-btn" data-name="' + safeName + '" data-url="/api/recorder/download/' + encodedName + '" type="button" aria-expanded="false">Play</button><a class="rec-file-btn" href="/api/recorder/download/' + encodedName + '" download="' + safeName + '">Download</a><button class="rec-file-btn rec-delete-btn" data-name="' + safeName + '" type="button">Remove</button></div></td></tr>';
|
||
}
|
||
html += "</tbody></table>";
|
||
el.innerHTML = html;
|
||
el.querySelectorAll(".rec-play-btn").forEach(function(btn) {
|
||
btn.addEventListener("click", function() {
|
||
const row = btn.closest("tr");
|
||
if (!row) return;
|
||
const next = row.nextElementSibling;
|
||
if (next && next.classList.contains("rec-player-row")) {
|
||
const audio2 = next.querySelector("audio");
|
||
if (audio2) {
|
||
try {
|
||
audio2.pause();
|
||
} catch (_) {
|
||
}
|
||
}
|
||
next.remove();
|
||
btn.setAttribute("aria-expanded", "false");
|
||
btn.textContent = "Play";
|
||
return;
|
||
}
|
||
el.querySelectorAll(".rec-player-row").forEach(function(r) {
|
||
const a = r.querySelector("audio");
|
||
if (a) {
|
||
try {
|
||
a.pause();
|
||
} catch (_) {
|
||
}
|
||
}
|
||
r.remove();
|
||
});
|
||
el.querySelectorAll(".rec-play-btn").forEach(function(b) {
|
||
b.setAttribute("aria-expanded", "false");
|
||
b.textContent = "Play";
|
||
});
|
||
const playerRow = document.createElement("tr");
|
||
playerRow.className = "rec-player-row";
|
||
const cell = document.createElement("td");
|
||
cell.colSpan = 3;
|
||
const audio = document.createElement("audio");
|
||
audio.controls = true;
|
||
audio.preload = "metadata";
|
||
audio.src = btn.dataset.url ?? "";
|
||
audio.className = "rec-player-audio";
|
||
cell.appendChild(audio);
|
||
playerRow.appendChild(cell);
|
||
row.parentNode?.insertBefore(playerRow, row.nextSibling);
|
||
btn.setAttribute("aria-expanded", "true");
|
||
btn.textContent = "Hide";
|
||
void audio.play().catch(() => {
|
||
});
|
||
});
|
||
});
|
||
el.querySelectorAll(".rec-delete-btn").forEach(function(btn) {
|
||
btn.addEventListener("click", async function() {
|
||
const name = btn.dataset.name ?? "";
|
||
if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return;
|
||
try {
|
||
const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" });
|
||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||
_recorderFiles = _recorderFiles.filter(function(f) {
|
||
return f.name !== name;
|
||
});
|
||
renderRecorderFiles();
|
||
} catch (e) {
|
||
console.error("Delete failed", e);
|
||
window.trxUi?.notify("Recording could not be deleted", { kind: "error" });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
(function() {
|
||
const filterEl = document.getElementById("recorder-filter");
|
||
const sortEl = document.getElementById("recorder-sort");
|
||
if (filterEl) filterEl.addEventListener("input", function() {
|
||
_recFilesPage = 0;
|
||
renderRecorderFiles();
|
||
});
|
||
if (sortEl) sortEl.addEventListener("change", function() {
|
||
_recFilesPage = 0;
|
||
renderRecorderFiles();
|
||
});
|
||
const prevBtn = document.getElementById("rec-page-prev");
|
||
const nextBtn = document.getElementById("rec-page-next");
|
||
if (prevBtn) prevBtn.addEventListener("click", function() {
|
||
_recFilesPage--;
|
||
renderRecorderFiles();
|
||
});
|
||
if (nextBtn) nextBtn.addEventListener("click", function() {
|
||
_recFilesPage++;
|
||
renderRecorderFiles();
|
||
});
|
||
})();
|
||
var rxVolPct = requiredElement("rx-vol-pct");
|
||
var txVolPct = requiredElement("tx-vol-pct");
|
||
rxVolSlider.value = String(loadSetting("rxVol", 80));
|
||
txVolSlider.value = String(loadSetting("txVol", 80));
|
||
rxVolPct.textContent = `${rxVolSlider.value}%`;
|
||
txVolPct.textContent = `${txVolSlider.value}%`;
|
||
function updateVolSlider(slider, pctEl, gainNode) {
|
||
pctEl.textContent = `${slider.value}%`;
|
||
if (gainNode) gainNode.gain.value = Number(slider.value) / 100;
|
||
}
|
||
rxVolSlider.addEventListener("input", () => {
|
||
updateVolSlider(rxVolSlider, rxVolPct, rxGainNode);
|
||
saveSetting("rxVol", Number(rxVolSlider.value));
|
||
});
|
||
txVolSlider.addEventListener("input", () => {
|
||
updateVolSlider(txVolSlider, txVolPct, txGainNode);
|
||
saveSetting("txVol", Number(txVolSlider.value));
|
||
});
|
||
function volWheel(slider, pctEl, getGain, storageKey) {
|
||
slider.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const step = e.deltaY < 0 ? 2 : -2;
|
||
slider.value = String(Math.max(0, Math.min(100, Number(slider.value) + step)));
|
||
updateVolSlider(slider, pctEl, getGain());
|
||
saveSetting(storageKey, Number(slider.value));
|
||
}, { passive: false });
|
||
}
|
||
volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol");
|
||
volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol");
|
||
if (sdrSquelchEl) {
|
||
sdrSquelchEl.addEventListener("wheel", (e) => {
|
||
e.preventDefault();
|
||
const step = e.deltaY < 0 ? 2 : -2;
|
||
const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step);
|
||
sdrSquelchEl.value = String(next);
|
||
updateSdrSquelchPctLabel();
|
||
saveSetting("sdrSquelchPct", next);
|
||
submitSdrSquelchPercent(next);
|
||
}, { passive: false });
|
||
}
|
||
requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear());
|
||
var decodeSource = null;
|
||
var decodeHistoryWorker = null;
|
||
function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
|
||
if (!el) return;
|
||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||
const isActiveMode = activeModes.includes(modeUpper);
|
||
if (el.textContent === "Receiving" && isActiveMode) return;
|
||
el.textContent = isActiveMode ? connectedText : inactiveText;
|
||
}
|
||
var _decodeConnectedText = {
|
||
vdes: "Connected, listening for bursts",
|
||
cw: "Connected, listening for CW"
|
||
};
|
||
function updateDecodeStatus(text) {
|
||
for (const d of decoderRegistry) {
|
||
if (d.activation !== "mode_bound") continue;
|
||
const el = document.getElementById(d.id + "-status");
|
||
if (!el) continue;
|
||
const connText = _decodeConnectedText[d.id] || text;
|
||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||
}
|
||
for (const d of decoderRegistry) {
|
||
if (d.activation !== "toggle") continue;
|
||
const el = document.getElementById(d.id + "-status");
|
||
if (el && el.textContent !== "Receiving") el.textContent = text;
|
||
}
|
||
}
|
||
function dispatchDecodeMessage(msg, skipStats = false) {
|
||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||
if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") {
|
||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
||
window.trx.modules.map?.scheduleStatsRender();
|
||
}
|
||
}
|
||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||
var DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8;
|
||
function terminateDecodeHistoryWorker() {
|
||
if (!decodeHistoryWorker) return;
|
||
try {
|
||
decodeHistoryWorker.terminate();
|
||
} catch (_) {
|
||
}
|
||
decodeHistoryWorker = null;
|
||
}
|
||
function scheduleDecodeHistoryDrainStep(callback) {
|
||
if (typeof callback !== "function") return;
|
||
if (typeof requestAnimationFrame === "function") {
|
||
requestAnimationFrame(() => callback());
|
||
} else {
|
||
setTimeout(callback, 16);
|
||
}
|
||
}
|
||
function decodeHistoryUrl() {
|
||
return "/decode/history";
|
||
}
|
||
function loadDecodeHistoryOnMainThread(onReady, onError) {
|
||
fetch(decodeHistoryUrl()).then(async (resp) => {
|
||
if (!resp.ok) return null;
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Receiving compressed history payload");
|
||
const payload = await resp.arrayBuffer();
|
||
if (!payload || payload.byteLength === 0) return {};
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history payload");
|
||
return decodeCbor(payload);
|
||
}).then((groups) => {
|
||
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
|
||
onReady(groups);
|
||
} else {
|
||
onReady({});
|
||
}
|
||
}).catch((err) => {
|
||
if (typeof onError === "function") onError(err);
|
||
});
|
||
}
|
||
function restoreDecodeHistoryGroup(kind, messages) {
|
||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||
if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") {
|
||
for (const msg of messages) {
|
||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||
}
|
||
window.trx.modules.map?.scheduleStatsRender();
|
||
}
|
||
window.trxPluginRuntime.restore(kind, messages);
|
||
}
|
||
function connectDecode() {
|
||
if (decodeSource) {
|
||
decodeSource.close();
|
||
}
|
||
terminateDecodeHistoryWorker();
|
||
decodeHistoryReplayActive = false;
|
||
decodeMapSyncPending = false;
|
||
window.trxPluginRuntime.clearQueued();
|
||
window.trxPluginRuntime.resetAll();
|
||
let historySettled = false;
|
||
let historyWorkerDone = false;
|
||
let historyFallbackStarted = false;
|
||
let historyBatchDrainScheduled = false;
|
||
let historyTotal = 0;
|
||
let historyProcessed = 0;
|
||
const historyGroupQueue = [];
|
||
const liveBuffer = [];
|
||
function flushLiveBuffer() {
|
||
historySettled = true;
|
||
terminateDecodeHistoryWorker();
|
||
setDecodeHistoryReplayActive(false);
|
||
setDecodeHistoryOverlayVisible(false);
|
||
for (const msg of liveBuffer) {
|
||
try {
|
||
dispatchDecodeMessage(msg);
|
||
} catch (_) {
|
||
}
|
||
}
|
||
liveBuffer.length = 0;
|
||
}
|
||
function updateHistoryReplayOverlay() {
|
||
setDecodeHistoryOverlayVisible(
|
||
true,
|
||
"Loading decode history…",
|
||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`
|
||
);
|
||
}
|
||
function maybeFinishHistoryReplay() {
|
||
if (historySettled) return;
|
||
if (historyWorkerDone && historyGroupQueue.length === 0) {
|
||
clearTimeout(historyTimeout);
|
||
flushLiveBuffer();
|
||
}
|
||
}
|
||
function pumpDecodeHistoryGroupQueue() {
|
||
historyBatchDrainScheduled = false;
|
||
const startedAt = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0;
|
||
while (historyGroupQueue.length > 0) {
|
||
const next = historyGroupQueue.shift();
|
||
if (!next) continue;
|
||
restoreDecodeHistoryGroup(next.kind, next.messages);
|
||
historyProcessed += Array.isArray(next.messages) ? next.messages.length : 0;
|
||
updateHistoryReplayOverlay();
|
||
if (startedAt > 0 && performance.now() - startedAt >= DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS) {
|
||
break;
|
||
}
|
||
}
|
||
if (historyGroupQueue.length > 0) {
|
||
scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue);
|
||
historyBatchDrainScheduled = true;
|
||
return;
|
||
}
|
||
maybeFinishHistoryReplay();
|
||
}
|
||
function enqueueDecodeHistoryGroup(kind, messages) {
|
||
if (!Array.isArray(messages) || messages.length === 0) return;
|
||
historyGroupQueue.push({ kind, messages });
|
||
if (historyBatchDrainScheduled) return;
|
||
historyBatchDrainScheduled = true;
|
||
scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue);
|
||
}
|
||
function totalDecodeHistoryMessages(groups) {
|
||
if (!groups || typeof groups !== "object") return 0;
|
||
return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"].reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0);
|
||
}
|
||
function enqueueDecodeHistoryGroups(groups) {
|
||
historyTotal = totalDecodeHistoryMessages(groups);
|
||
historyProcessed = 0;
|
||
if (historyTotal > 0) {
|
||
setDecodeHistoryReplayActive(true);
|
||
updateHistoryReplayOverlay();
|
||
}
|
||
for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]) {
|
||
const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : [];
|
||
if (messages.length === 0) continue;
|
||
for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) {
|
||
enqueueDecodeHistoryGroup(kind, messages.slice(index, index + DECODE_HISTORY_WORKER_GROUP_LIMIT));
|
||
}
|
||
}
|
||
historyWorkerDone = true;
|
||
maybeFinishHistoryReplay();
|
||
}
|
||
function startDecodeHistoryFallback() {
|
||
if (historyFallbackStarted || historySettled) return;
|
||
historyFallbackStarted = true;
|
||
loadDecodeHistoryOnMainThread((groups) => {
|
||
clearTimeout(historyTimeout);
|
||
const total = totalDecodeHistoryMessages(groups);
|
||
if (total > 0) {
|
||
enqueueDecodeHistoryGroups(groups);
|
||
} else {
|
||
flushLiveBuffer();
|
||
}
|
||
}, (err) => {
|
||
console.error("Decode history fallback failed", err);
|
||
clearTimeout(historyTimeout);
|
||
flushLiveBuffer();
|
||
});
|
||
}
|
||
function startDecodeHistoryWorkerReplay() {
|
||
if (typeof Worker !== "function") return false;
|
||
let worker;
|
||
try {
|
||
worker = new Worker("/decode-history-worker.js");
|
||
} catch (err) {
|
||
console.error("Decode history worker startup failed", err);
|
||
return false;
|
||
}
|
||
decodeHistoryWorker = worker;
|
||
worker.onmessage = (evt) => {
|
||
if (historySettled || worker !== decodeHistoryWorker) return;
|
||
const data = evt.data;
|
||
if (!isRecord2(data) || typeof data.type !== "string") return;
|
||
if (data.type === "status") {
|
||
const phase = primitiveString(data.phase);
|
||
if (phase === "fetching") {
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||
} else if (phase === "decoding") {
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history in background");
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === "start") {
|
||
historyTotal = Math.max(0, Number(data.total) || 0);
|
||
historyProcessed = 0;
|
||
if (historyTotal > 0) {
|
||
setDecodeHistoryReplayActive(true);
|
||
updateHistoryReplayOverlay();
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === "group") {
|
||
const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message)) : [];
|
||
enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages);
|
||
return;
|
||
}
|
||
if (data.type === "done") {
|
||
historyWorkerDone = true;
|
||
clearTimeout(historyTimeout);
|
||
terminateDecodeHistoryWorker();
|
||
maybeFinishHistoryReplay();
|
||
return;
|
||
}
|
||
if (data.type === "error") {
|
||
console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure");
|
||
terminateDecodeHistoryWorker();
|
||
startDecodeHistoryFallback();
|
||
}
|
||
};
|
||
worker.postMessage({
|
||
type: "fetch-history",
|
||
url: decodeHistoryUrl(),
|
||
batchLimit: DECODE_HISTORY_WORKER_GROUP_LIMIT
|
||
});
|
||
return true;
|
||
}
|
||
const historyTimeout = setTimeout(() => {
|
||
if (!historySettled) {
|
||
terminateDecodeHistoryWorker();
|
||
flushLiveBuffer();
|
||
}
|
||
}, 2e4);
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||
decodeSource = new EventSource("/decode");
|
||
const source = decodeSource;
|
||
source.onopen = () => {
|
||
updateDecodeStatus("Connected, listening for packets");
|
||
};
|
||
source.onmessage = (evt) => {
|
||
try {
|
||
const msg = parseJsonUnknown(evt.data);
|
||
if (!isRecord2(msg) || typeof msg.type !== "string") return;
|
||
const decoded = { ...msg, type: msg.type };
|
||
if (historySettled) dispatchDecodeMessage(decoded);
|
||
else liveBuffer.push(decoded);
|
||
} catch (e) {
|
||
}
|
||
};
|
||
source.onerror = () => {
|
||
const wasClosed = source.readyState === 2;
|
||
source.close();
|
||
terminateDecodeHistoryWorker();
|
||
if (!historySettled) flushLiveBuffer();
|
||
if (wasClosed) {
|
||
updateDecodeStatus("Decode not available (check client audio config)");
|
||
setTimeout(connectDecode, 1e4);
|
||
} else {
|
||
updateDecodeStatus("Decode disconnected, retrying…");
|
||
setTimeout(connectDecode, 5e3);
|
||
}
|
||
};
|
||
if (!startDecodeHistoryWorkerReplay()) {
|
||
startDecodeHistoryFallback();
|
||
}
|
||
}
|
||
window.addEventListener("beforeunload", () => {
|
||
if (txActive) {
|
||
navigator.sendBeacon("/set_ptt?ptt=false", "");
|
||
}
|
||
});
|
||
var spectrumCanvas = document.getElementById("spectrum-canvas");
|
||
var spectrumGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions) : null;
|
||
var spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||
var spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||
var spectrumTooltip = document.getElementById("spectrum-tooltip");
|
||
var spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn");
|
||
var spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn");
|
||
var spectrumSource = null;
|
||
var spectrumReconnectTimer = null;
|
||
var meterSource = null;
|
||
var meterReconnectTimer = null;
|
||
var spectrumDrawPending = false;
|
||
var spectrumAxisKey = "";
|
||
var spectrumDbAxisKey = "";
|
||
var lastSpectrumRenderData = null;
|
||
var spectrumPeakHoldFrames = [];
|
||
var pendingSpectrumFrameWaiters = [];
|
||
var sweetSpotScanInFlight = false;
|
||
var spectrumTmpGridSegments = [];
|
||
var spectrumTmpFillPoints = [];
|
||
var spectrumTmpPeakPoints = [];
|
||
var spectrumTmpMarkerPoints = [];
|
||
var spectrumZoom = 1;
|
||
var spectrumPanFrac = 0.5;
|
||
var spectrumFloor = -115;
|
||
var spectrumRange = 90;
|
||
var waterfallGamma = 1;
|
||
var SPECTRUM_HEADROOM_DB = 20;
|
||
var SPECTRUM_SMOOTH_ALPHA = 0.42;
|
||
var spectrumCrosshairX = null;
|
||
var spectrumCrosshairY = null;
|
||
var _bwDragEdge = null;
|
||
var _bwDragStartX = 0;
|
||
var _bwDragStartBwHz = 0;
|
||
var _bwDragCanvas = null;
|
||
function spectrumBgColor() {
|
||
return canvasPalette().bg;
|
||
}
|
||
function clearSpectrumPeakHoldFrames() {
|
||
spectrumPeakHoldFrames = [];
|
||
}
|
||
function settlePendingSpectrumFrameWaiters(frame) {
|
||
if (!pendingSpectrumFrameWaiters.length) return;
|
||
const remaining = [];
|
||
for (const waiter of pendingSpectrumFrameWaiters) {
|
||
if (!waiter) continue;
|
||
const targetCenterHz = Number(waiter.targetCenterHz);
|
||
if (isFiniteNumber(targetCenterHz) && (!frame || Math.abs(Number(frame.center_hz) - targetCenterHz) >= 2)) {
|
||
remaining.push(waiter);
|
||
continue;
|
||
}
|
||
if (waiter.timer) {
|
||
clearTimeout(waiter.timer);
|
||
waiter.timer = null;
|
||
}
|
||
if (typeof waiter.resolve === "function") {
|
||
waiter.resolve(frame);
|
||
}
|
||
}
|
||
pendingSpectrumFrameWaiters = remaining;
|
||
}
|
||
function rejectPendingSpectrumFrameWaiters(error) {
|
||
if (!pendingSpectrumFrameWaiters.length) return;
|
||
for (const waiter of pendingSpectrumFrameWaiters) {
|
||
if (!waiter) continue;
|
||
if (waiter.timer) {
|
||
clearTimeout(waiter.timer);
|
||
waiter.timer = null;
|
||
}
|
||
if (typeof waiter.reject === "function") {
|
||
waiter.reject(error || new Error("Spectrum unavailable"));
|
||
}
|
||
}
|
||
pendingSpectrumFrameWaiters = [];
|
||
}
|
||
function waitForSpectrumFrame(expectedCenterHz = null, timeoutMs = 1200) {
|
||
const targetCenterHz = expectedCenterHz;
|
||
if (lastSpectrumData && (!isFiniteNumber(targetCenterHz) || Math.abs(Number(lastSpectrumData.center_hz) - targetCenterHz) < 2)) {
|
||
return Promise.resolve(lastSpectrumData);
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const waiter = {
|
||
targetCenterHz,
|
||
resolve,
|
||
reject,
|
||
timer: null
|
||
};
|
||
waiter.timer = setTimeout(() => {
|
||
pendingSpectrumFrameWaiters = pendingSpectrumFrameWaiters.filter((entry) => entry !== waiter);
|
||
reject(new Error("Timed out waiting for spectrum frame"));
|
||
}, Math.max(200, timeoutMs));
|
||
pendingSpectrumFrameWaiters.push(waiter);
|
||
});
|
||
}
|
||
function pruneSpectrumPeakHoldFrames(now = Date.now()) {
|
||
const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||
if (holdMs <= 0) {
|
||
clearSpectrumPeakHoldFrames();
|
||
return;
|
||
}
|
||
let removeCount = 0;
|
||
for (let i = 0; i < spectrumPeakHoldFrames.length; i++) {
|
||
const f = spectrumPeakHoldFrames[i];
|
||
if (f && isNumericBins(f.bins) && now - f.t <= holdMs) break;
|
||
removeCount++;
|
||
}
|
||
if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount);
|
||
}
|
||
function pushSpectrumPeakHoldFrame(frame) {
|
||
if (!frame || !isNumericBins(frame.bins) || frame.bins.length === 0) {
|
||
clearSpectrumPeakHoldFrames();
|
||
return;
|
||
}
|
||
const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||
if (holdMs <= 0) {
|
||
clearSpectrumPeakHoldFrames();
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
pruneSpectrumPeakHoldFrames(now);
|
||
const lastFrame = spectrumPeakHoldFrames[spectrumPeakHoldFrames.length - 1];
|
||
if (lastFrame && lastFrame.bins.length !== frame.bins.length) {
|
||
clearSpectrumPeakHoldFrames();
|
||
}
|
||
spectrumPeakHoldFrames.push({ t: now, bins: frame.bins.slice() });
|
||
}
|
||
function buildSpectrumPeakHoldBins(currentBins) {
|
||
const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0);
|
||
if (holdMs <= 0 || !isNumericBins(currentBins) || currentBins.length === 0) {
|
||
return null;
|
||
}
|
||
pruneSpectrumPeakHoldFrames();
|
||
if (spectrumPeakHoldFrames.length === 0) return null;
|
||
const peakBins = currentBins.slice();
|
||
for (const frame of spectrumPeakHoldFrames) {
|
||
if (!frame || !isNumericBins(frame.bins) || frame.bins.length !== peakBins.length) continue;
|
||
for (let i = 0; i < peakBins.length; i++) {
|
||
const frameValue = frame.bins[i];
|
||
const peakValue = peakBins[i];
|
||
if (frameValue !== void 0 && peakValue !== void 0 && frameValue > peakValue) peakBins[i] = frameValue;
|
||
}
|
||
}
|
||
return peakBins;
|
||
}
|
||
var _smoothBins = [];
|
||
function buildSpectrumRenderData(frame) {
|
||
if (!frame || !isNumericBins(frame.bins)) return frame;
|
||
const n = frame.bins.length;
|
||
const prev = lastSpectrumRenderData;
|
||
const canBlend = prev && isNumericBins(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz;
|
||
if (_smoothBins.length !== n) _smoothBins = new Array(n);
|
||
const src = frame.bins;
|
||
if (canBlend) {
|
||
const prevBins = prev.bins;
|
||
const alpha = SPECTRUM_SMOOTH_ALPHA;
|
||
for (let i = 0; i < n; i++) {
|
||
const previous = prevBins[i] ?? 0;
|
||
_smoothBins[i] = previous + ((src[i] ?? previous) - previous) * alpha;
|
||
}
|
||
} else {
|
||
for (let i = 0; i < n; i++) _smoothBins[i] = src[i] ?? 0;
|
||
}
|
||
return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds ?? null };
|
||
}
|
||
function spectrumVisibleRange(data) {
|
||
const fullSpanHz = data.sample_rate;
|
||
const loHz = data.center_hz - fullSpanHz / 2;
|
||
const halfVis = 0.5 / spectrumZoom;
|
||
spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
|
||
const visCenterHz = loHz + spectrumPanFrac * fullSpanHz;
|
||
const visSpanHz = fullSpanHz / spectrumZoom;
|
||
return {
|
||
loHz,
|
||
hiHz: loHz + fullSpanHz,
|
||
visLoHz: visCenterHz - visSpanHz / 2,
|
||
visHiHz: visCenterHz + visSpanHz / 2,
|
||
fullSpanHz,
|
||
visSpanHz
|
||
};
|
||
}
|
||
function canvasXToHz(cssX, cssW, range) {
|
||
return range.visLoHz + cssX / cssW * range.visSpanHz;
|
||
}
|
||
function nearestSpectrumPeak(cssX, cssW, data) {
|
||
if (!data || !isNumericBins(data.bins) || data.bins.length === 0 || cssW <= 0) {
|
||
return null;
|
||
}
|
||
const bins = data.bins;
|
||
const maxIdx = bins.length - 1;
|
||
const range = spectrumVisibleRange(data);
|
||
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||
const targetHz = canvasXToHz(cssX, cssW, range);
|
||
const targetIdx = Math.max(
|
||
0,
|
||
Math.min(maxIdx, Math.round((targetHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const visStartIdx = Math.max(
|
||
0,
|
||
Math.min(maxIdx, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const visEndIdx = Math.max(
|
||
visStartIdx,
|
||
Math.min(maxIdx, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const visSpanBins = Math.max(1, visEndIdx - visStartIdx);
|
||
const searchRadius = Math.max(3, Math.min(80, Math.round(24 / cssW * visSpanBins)));
|
||
const searchLo = Math.max(1, targetIdx - searchRadius);
|
||
const searchHi = Math.min(maxIdx - 1, targetIdx + searchRadius);
|
||
let windowMax = -Infinity;
|
||
const localPeaks = [];
|
||
for (let i = searchLo; i <= searchHi; i++) {
|
||
const val = bins[i] ?? -Infinity;
|
||
if (val > windowMax) windowMax = val;
|
||
if (val >= (bins[i - 1] ?? -Infinity) && val >= (bins[i + 1] ?? -Infinity)) {
|
||
localPeaks.push(i);
|
||
}
|
||
}
|
||
const candidates = localPeaks.filter((i) => (bins[i] ?? -Infinity) >= windowMax - 6);
|
||
const ranked = (candidates.length ? candidates : localPeaks).sort((a, b) => {
|
||
const dist = Math.abs(a - targetIdx) - Math.abs(b - targetIdx);
|
||
if (dist !== 0) return dist;
|
||
return (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity);
|
||
});
|
||
let snappedIdx = ranked[0];
|
||
if (snappedIdx == null) {
|
||
snappedIdx = targetIdx;
|
||
for (let i = searchLo; i <= searchHi; i++) {
|
||
if ((bins[i] ?? -Infinity) > (bins[snappedIdx] ?? -Infinity)) snappedIdx = i;
|
||
}
|
||
}
|
||
return {
|
||
index: snappedIdx,
|
||
hz: Math.round(fullLoHz + snappedIdx / maxIdx * data.sample_rate),
|
||
db: bins[snappedIdx] ?? -Infinity
|
||
};
|
||
}
|
||
function nearestSpectrumPeakHz(cssX, cssW, data) {
|
||
return nearestSpectrumPeak(cssX, cssW, data)?.hz ?? null;
|
||
}
|
||
function spectrumTargetHzAt(cssX, cssW, data) {
|
||
if (!data) return null;
|
||
const range = spectrumVisibleRange(data);
|
||
return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range));
|
||
}
|
||
function visibleSpectrumPeakIndices(data, limit = 24) {
|
||
if (!data || !isNumericBins(data.bins) || data.bins.length < 3) {
|
||
return [];
|
||
}
|
||
const bins = data.bins;
|
||
const maxIdx = bins.length - 1;
|
||
const range = spectrumVisibleRange(data);
|
||
const fullLoHz = data.center_hz - data.sample_rate / 2;
|
||
const visStartIdx = Math.max(
|
||
1,
|
||
Math.min(maxIdx - 1, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const visEndIdx = Math.max(
|
||
visStartIdx,
|
||
Math.min(maxIdx - 1, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx))
|
||
);
|
||
const peaks = [];
|
||
for (let i = visStartIdx; i <= visEndIdx; i++) {
|
||
const v = bins[i] ?? -Infinity;
|
||
if (v >= (bins[i - 1] ?? -Infinity) && v >= (bins[i + 1] ?? -Infinity)) {
|
||
peaks.push(i);
|
||
}
|
||
}
|
||
if (peaks.length === 0) {
|
||
return [];
|
||
}
|
||
const peakValues = peaks.map((i) => bins[i] ?? -Infinity).sort((a, b) => a - b);
|
||
const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))] ?? -Infinity;
|
||
return peaks.filter((i) => (bins[i] ?? -Infinity) >= cutoff).sort((a, b) => (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity)).slice(0, limit).sort((a, b) => a - b);
|
||
}
|
||
function formatSpectrumFreq(hz) {
|
||
if (jogUnit >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||
if (jogUnit >= 1e3) return (hz / 1e3).toFixed(3) + " kHz";
|
||
return hz.toFixed(0) + " Hz";
|
||
}
|
||
function scheduleSpectrumReconnect() {
|
||
if (spectrumReconnectTimer !== null) return;
|
||
spectrumReconnectTimer = setTimeout(() => {
|
||
spectrumReconnectTimer = null;
|
||
startSpectrumStreaming();
|
||
}, 1e3);
|
||
}
|
||
function startSpectrumStreaming() {
|
||
if (spectrumSource !== null) return;
|
||
const spectrumUrl = lastActiveRigId ? `/spectrum?remote=${encodeURIComponent(lastActiveRigId)}` : "/spectrum";
|
||
spectrumSource = new EventSource(spectrumUrl);
|
||
const source = spectrumSource;
|
||
source.onmessage = (evt) => {
|
||
if (evt.data === "null") {
|
||
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset"));
|
||
lastSpectrumData = null;
|
||
lastSpectrumRenderData = null;
|
||
clearSpectrumPeakHoldFrames();
|
||
overviewWaterfallRows = [];
|
||
overviewWaterfallPushCount = 0;
|
||
overviewWfResetTextureCache();
|
||
spectrumWfRows = [];
|
||
spectrumWfPushCount = 0;
|
||
spectrumWfTexReady = false;
|
||
scheduleOverviewDraw();
|
||
clearSpectrumCanvas();
|
||
updateRdsPsOverlay(null);
|
||
}
|
||
};
|
||
source.addEventListener("b", (evt) => {
|
||
try {
|
||
const eventData = messageEventData(evt);
|
||
const commaA = eventData.indexOf(",");
|
||
const commaB = eventData.indexOf(",", commaA + 1);
|
||
const centerHz = Number(eventData.slice(0, commaA));
|
||
const sampleRate = Number(eventData.slice(commaA + 1, commaB));
|
||
const b64 = eventData.slice(commaB + 1);
|
||
const hadSpectrum = !!lastSpectrumData;
|
||
const bins = decodeBase64Int8(b64);
|
||
const rds = lastSpectrumData?.rds;
|
||
const frame = { bins, center_hz: centerHz, sample_rate: sampleRate, rds: rds ?? null };
|
||
lastSpectrumData = frame;
|
||
window.lastSpectrumData = lastSpectrumData;
|
||
const spectrumSummary = document.getElementById("spectrum-text-summary");
|
||
if (spectrumSummary && bins.length) {
|
||
let peakIndex = 0;
|
||
for (let i = 1; i < bins.length; i += 1) if ((bins[i] ?? -Infinity) > (bins[peakIndex] ?? -Infinity)) peakIndex = i;
|
||
const peakHz = centerHz - sampleRate / 2 + peakIndex / Math.max(1, bins.length - 1) * sampleRate;
|
||
spectrumSummary.textContent = `Spectrum centered at ${formatFrequencyForHumans(centerHz)}, spanning ${formatFrequencyForHumans(sampleRate)}. Strongest visible bin near ${formatFrequencyForHumans(peakHz)} at ${bins[peakIndex]} dB.`;
|
||
}
|
||
if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1e3) {
|
||
spectrumCenterPendingHz = null;
|
||
}
|
||
const renderFrame = buildSpectrumRenderData(frame);
|
||
lastSpectrumRenderData = renderFrame;
|
||
settlePendingSpectrumFrameWaiters(frame);
|
||
pushSpectrumPeakHoldFrame(renderFrame);
|
||
pushOverviewWaterfallFrame(frame);
|
||
pushSpectrumWaterfallFrame(frame);
|
||
refreshCenterFreqDisplay();
|
||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||
scheduleSpectrumDraw();
|
||
if (!hadSpectrum) {
|
||
updateRdsPsOverlay(frame.rds);
|
||
} else {
|
||
positionRdsPsOverlay();
|
||
}
|
||
} catch (_) {
|
||
}
|
||
});
|
||
source.addEventListener("rds", (evt) => {
|
||
try {
|
||
const eventData = messageEventData(evt);
|
||
const value = eventData === "null" ? void 0 : parseJsonUnknown(eventData);
|
||
const rds = value !== void 0 && isRdsData(value) ? value : null;
|
||
if (lastSpectrumData) lastSpectrumData.rds = rds;
|
||
updateRdsPsOverlay(rds ?? null);
|
||
} catch (_) {
|
||
}
|
||
});
|
||
source.addEventListener("rds_vchan", (evt) => {
|
||
try {
|
||
const eventData = messageEventData(evt);
|
||
const value = eventData === "null" ? [] : parseJsonUnknown(eventData);
|
||
const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : [];
|
||
const next = /* @__PURE__ */ new Map();
|
||
const nextSig = /* @__PURE__ */ new Map();
|
||
payload.forEach((entry) => {
|
||
next.set(entry.id, entry.rds ?? null);
|
||
if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db);
|
||
});
|
||
vchanRdsById = next;
|
||
vchanSignalDbById = nextSig;
|
||
const virtualChannelId = window.trx.modules.vchan?.activeId;
|
||
if (virtualChannelId && nextSig.has(virtualChannelId)) {
|
||
sigLastDbm = nextSig.get(virtualChannelId) ?? null;
|
||
refreshSigStrengthDisplay();
|
||
}
|
||
updateRdsPsOverlay(primaryRds);
|
||
} catch (_) {
|
||
}
|
||
});
|
||
source.onerror = () => {
|
||
rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected"));
|
||
if (spectrumSource) {
|
||
spectrumSource.close();
|
||
spectrumSource = null;
|
||
}
|
||
scheduleSpectrumReconnect();
|
||
};
|
||
}
|
||
function stopSpectrumStreaming() {
|
||
if (spectrumSource !== null) {
|
||
spectrumSource.close();
|
||
spectrumSource = null;
|
||
}
|
||
if (spectrumReconnectTimer !== null) {
|
||
clearTimeout(spectrumReconnectTimer);
|
||
spectrumReconnectTimer = null;
|
||
}
|
||
spectrumDrawPending = false;
|
||
lastSpectrumData = null;
|
||
lastSpectrumRenderData = null;
|
||
rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped"));
|
||
clearSpectrumPeakHoldFrames();
|
||
overviewWaterfallRows = [];
|
||
overviewWaterfallPushCount = 0;
|
||
overviewWfResetTextureCache();
|
||
spectrumWfRows = [];
|
||
spectrumWfPushCount = 0;
|
||
spectrumWfTexReady = false;
|
||
scheduleOverviewDraw();
|
||
updateRdsPsOverlay(null);
|
||
clearSpectrumCanvas();
|
||
}
|
||
var METER_ATTACK_ALPHA = 0.08;
|
||
var METER_DECAY_ALPHA = 0.03;
|
||
var meterSmoothedDbm = null;
|
||
var meterRafPending = false;
|
||
function scheduleMeterReconnect() {
|
||
if (meterReconnectTimer !== null) return;
|
||
meterReconnectTimer = setTimeout(() => {
|
||
meterReconnectTimer = null;
|
||
startMeterStreaming();
|
||
}, 1e3);
|
||
}
|
||
function applyMeterSample(dbm) {
|
||
if (typeof dbm !== "number" || !isFiniteNumber(dbm)) return;
|
||
if (meterSmoothedDbm === null) {
|
||
meterSmoothedDbm = dbm;
|
||
} else {
|
||
const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA;
|
||
meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm);
|
||
}
|
||
if (!meterRafPending) {
|
||
meterRafPending = true;
|
||
requestAnimationFrame(flushMeterDom);
|
||
}
|
||
}
|
||
function flushMeterDom() {
|
||
meterRafPending = false;
|
||
const dbm = meterSmoothedDbm;
|
||
if (dbm === null) return;
|
||
prevRenderData.sigDbm = dbm;
|
||
const sUnits = dbmToSUnits(dbm);
|
||
sigLastSUnits = sUnits;
|
||
sigLastDbm = dbm;
|
||
const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100;
|
||
if (signalBar) signalBar.style.width = `${pct}%`;
|
||
if (signalValue) signalValue.innerHTML = formatSignal(sUnits);
|
||
refreshSigStrengthDisplay();
|
||
}
|
||
function startMeterStreaming() {
|
||
if (meterSource !== null) return;
|
||
const url = lastActiveRigId ? `/meter?remote=${encodeURIComponent(lastActiveRigId)}` : "/meter";
|
||
meterSource = new EventSource(url);
|
||
meterSource.onmessage = (evt) => {
|
||
try {
|
||
const value = parseJsonUnknown(evt.data);
|
||
if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig);
|
||
} catch (_) {
|
||
}
|
||
};
|
||
meterSource.onerror = () => {
|
||
if (meterSource) {
|
||
meterSource.close();
|
||
meterSource = null;
|
||
}
|
||
scheduleMeterReconnect();
|
||
};
|
||
}
|
||
function stopMeterStreaming() {
|
||
if (meterSource !== null) {
|
||
meterSource.close();
|
||
meterSource = null;
|
||
}
|
||
if (meterReconnectTimer !== null) {
|
||
clearTimeout(meterReconnectTimer);
|
||
meterReconnectTimer = null;
|
||
}
|
||
meterSmoothedDbm = null;
|
||
}
|
||
function clearSpectrumCanvas() {
|
||
if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
|
||
const cssW = spectrumCanvas.clientWidth || 1;
|
||
const cssH = spectrumCanvas.clientHeight || 1;
|
||
spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1);
|
||
spectrumGl.clear(cssColorToRgba(spectrumBgColor()));
|
||
if (spectrumDbAxis) {
|
||
spectrumDbAxis.replaceChildren();
|
||
spectrumDbAxisKey = "";
|
||
}
|
||
}
|
||
function formatOverlayPs(ps) {
|
||
return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_");
|
||
}
|
||
function formatPsHtml(ps) {
|
||
const clipped = primitiveString(ps).slice(0, 8);
|
||
let html = "";
|
||
for (let i = 0; i < 8; i += 1) {
|
||
const ch = clipped[i];
|
||
if (ch == null || ch === " ") {
|
||
html += `<span class="rds-ps-gap">_</span>`;
|
||
} else {
|
||
html += escapeHtml(ch);
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
function formatOverlayPi(pi) {
|
||
return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --";
|
||
}
|
||
function formatOverlayPty(pty, ptyName) {
|
||
const name = primitiveString(ptyName);
|
||
if (name) return name;
|
||
const code = primitiveString(pty);
|
||
return code || "--";
|
||
}
|
||
function overlayTrafficFlagHtml(label, active) {
|
||
const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive";
|
||
return `<span class="rds-flag ${stateClass}">${label}</span>`;
|
||
}
|
||
function formatRdsFlag(value, yes = "Yes", no = "No") {
|
||
if (value == null) return "--";
|
||
return value ? yes : no;
|
||
}
|
||
function formatRdsAudio(value) {
|
||
if (value == null) return "--";
|
||
return value ? "Music" : "Speech";
|
||
}
|
||
function formatMinuteTimestamp(date = /* @__PURE__ */ new Date()) {
|
||
const yyyy = date.getFullYear();
|
||
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
||
const dd = String(date.getDate()).padStart(2, "0");
|
||
const hh = String(date.getHours()).padStart(2, "0");
|
||
const min = String(date.getMinutes()).padStart(2, "0");
|
||
return `${yyyy}-${mm}-${dd} ${hh}:${min}`;
|
||
}
|
||
function buildRdsRawPayload(rds) {
|
||
const freqHz = activeChannelFreqHz();
|
||
return {
|
||
time: formatMinuteTimestamp(),
|
||
freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null,
|
||
...rds
|
||
};
|
||
}
|
||
function formatRdsAfMHz(hz) {
|
||
return `${(hz / 1e6).toFixed(1)} MHz`;
|
||
}
|
||
function tuneRdsAlternativeFrequency(hz) {
|
||
if (!isFiniteNumber(hz) || hz <= 0) return;
|
||
const targetHz = Math.round(hz);
|
||
setRigFrequency(targetHz);
|
||
showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200);
|
||
}
|
||
function renderRdsAlternativeFrequencies(list) {
|
||
const afEl = document.getElementById("rds-af-list");
|
||
if (!afEl) return;
|
||
const afs = Array.isArray(list) ? list.filter((hz) => isFiniteNumber(hz) && hz > 0).map((hz) => Math.round(hz)) : [];
|
||
const afKey = afs.join(",");
|
||
if (!afs.length) {
|
||
if (afEl.dataset.afKey === "") return;
|
||
afEl.dataset.afKey = "";
|
||
afEl.textContent = "--";
|
||
return;
|
||
}
|
||
if (afEl.dataset.afKey === afKey) return;
|
||
afEl.dataset.afKey = afKey;
|
||
afEl.replaceChildren();
|
||
for (const hz of afs) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "rds-af-btn";
|
||
btn.dataset.hz = String(hz);
|
||
btn.textContent = formatRdsAfMHz(hz);
|
||
afEl.appendChild(btn);
|
||
}
|
||
if (!afEl.childElementCount) afEl.textContent = "--";
|
||
}
|
||
async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) {
|
||
const rds = rdsOverride || activeChannelRds();
|
||
const ps = rds?.program_service;
|
||
if (!rds || !ps || ps.length === 0) {
|
||
showHint("No RDS PS", 1200);
|
||
return;
|
||
}
|
||
const freqHz = isFiniteNumber(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz();
|
||
const freqMhz = isFiniteNumber(freqHz) ? (Math.round(freqHz / 1e5) / 10).toFixed(1) : "--.-";
|
||
const piHex = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--";
|
||
const clipPs = formatOverlayPs(ps);
|
||
const clipText = `${formatMinuteTimestamp()} - ${freqMhz} MHz - ${piHex} - ${clipPs}`;
|
||
try {
|
||
await navigator.clipboard.writeText(clipText);
|
||
showHint("RDS copied", 1200);
|
||
} catch (_) {
|
||
showHint("Clipboard failed", 1500);
|
||
}
|
||
}
|
||
async function copyRdsRawToClipboard() {
|
||
const rawEl = document.getElementById("rds-raw");
|
||
const rawText = rawEl?.textContent ?? "";
|
||
if (!rawText || rawText === "--") {
|
||
showHint("No RDS JSON", 1200);
|
||
return;
|
||
}
|
||
try {
|
||
await navigator.clipboard.writeText(rawText);
|
||
showHint("RDS JSON copied", 1200);
|
||
} catch (_) {
|
||
showHint("Clipboard failed", 1500);
|
||
}
|
||
}
|
||
var rdsPsValueEl = document.getElementById("rds-ps");
|
||
if (rdsPsValueEl) {
|
||
rdsPsValueEl.addEventListener("click", () => {
|
||
void copyRdsPsToClipboard();
|
||
});
|
||
}
|
||
var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn");
|
||
if (rdsRawCopyBtn) {
|
||
rdsRawCopyBtn.addEventListener("click", () => {
|
||
void copyRdsRawToClipboard();
|
||
});
|
||
}
|
||
var rdsAfListEl = document.getElementById("rds-af-list");
|
||
if (rdsAfListEl) {
|
||
rdsAfListEl.addEventListener("click", (event) => {
|
||
const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null;
|
||
const hz = Number(btn?.dataset?.hz);
|
||
if (btn && isFiniteNumber(hz)) {
|
||
tuneRdsAlternativeFrequency(hz);
|
||
}
|
||
});
|
||
}
|
||
function updateRdsPsOverlay(rds) {
|
||
primaryRds = rds || null;
|
||
const activeRds = activeChannelRds();
|
||
updateDocumentTitle(activeRds);
|
||
renderRdsOverlays();
|
||
const statusEl = document.getElementById("rds-status");
|
||
const modeEl2 = document.getElementById("rds-mode");
|
||
const piEl = document.getElementById("rds-pi");
|
||
const psEl = document.getElementById("rds-ps");
|
||
const ptyEl = document.getElementById("rds-pty");
|
||
const ptyNameEl = document.getElementById("rds-pty-name");
|
||
const ptynEl = document.getElementById("rds-ptyn");
|
||
const tpEl = document.getElementById("rds-tp");
|
||
const taEl = document.getElementById("rds-ta");
|
||
const musicEl = document.getElementById("rds-music");
|
||
const stereoEl = document.getElementById("rds-stereo");
|
||
const compEl = document.getElementById("rds-compressed");
|
||
const headEl = document.getElementById("rds-artificial-head");
|
||
const dynPtyEl = document.getElementById("rds-dynamic-pty");
|
||
const afEl = document.getElementById("rds-af-list");
|
||
const rtEl = document.getElementById("rds-radio-text");
|
||
const rawEl = document.getElementById("rds-raw");
|
||
if (!statusEl || !piEl || !psEl || !ptyEl || !ptyNameEl || !rawEl) return;
|
||
if (modeEl2) modeEl2.textContent = document.getElementById("mode")?.value || "--";
|
||
if (!activeRds) {
|
||
statusEl.textContent = "No signal";
|
||
statusEl.className = "rds-value rds-no-signal";
|
||
piEl.textContent = "--";
|
||
psEl.textContent = "--";
|
||
ptyEl.textContent = "--";
|
||
ptyNameEl.textContent = "--";
|
||
if (ptynEl) ptynEl.textContent = "--";
|
||
if (tpEl) tpEl.textContent = "--";
|
||
if (taEl) taEl.textContent = "--";
|
||
if (musicEl) musicEl.textContent = "--";
|
||
if (stereoEl) stereoEl.textContent = "--";
|
||
if (compEl) compEl.textContent = "--";
|
||
if (headEl) headEl.textContent = "--";
|
||
if (dynPtyEl) dynPtyEl.textContent = "--";
|
||
if (afEl) afEl.textContent = "--";
|
||
if (rtEl) rtEl.textContent = "--";
|
||
if (rawEl && lastSpectrumData) {
|
||
const { bins: _b, ...rest } = lastSpectrumData;
|
||
const freqHz = activeChannelFreqHz();
|
||
rawEl.textContent = JSON.stringify({
|
||
time: formatMinuteTimestamp(),
|
||
freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null,
|
||
...rest
|
||
}, null, 2);
|
||
}
|
||
return;
|
||
}
|
||
statusEl.textContent = "Decoding";
|
||
statusEl.className = "rds-value rds-decoding";
|
||
piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--";
|
||
if (psEl) {
|
||
if (activeRds.program_service) {
|
||
psEl.innerHTML = formatPsHtml(activeRds.program_service);
|
||
} else {
|
||
psEl.textContent = "--";
|
||
}
|
||
}
|
||
ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--");
|
||
ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--";
|
||
if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--";
|
||
if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program);
|
||
if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement);
|
||
if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music);
|
||
if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo);
|
||
if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed);
|
||
if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head);
|
||
if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty);
|
||
renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz);
|
||
if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--";
|
||
rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2);
|
||
}
|
||
window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds);
|
||
function scheduleSpectrumDraw() {
|
||
if (spectrumDrawPending) return;
|
||
spectrumDrawPending = true;
|
||
requestAnimationFrame(() => {
|
||
spectrumDrawPending = false;
|
||
if (lastSpectrumRenderData) {
|
||
drawSpectrum(lastSpectrumRenderData);
|
||
if (overviewWaterfallRows.length > 0) scheduleOverviewDraw();
|
||
if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw();
|
||
}
|
||
});
|
||
}
|
||
function drawSpectrum(data) {
|
||
if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return;
|
||
const dpr = _cachedDpr;
|
||
const cssW = _cachedSpectrumCssW;
|
||
const cssH = _cachedSpectrumCssH;
|
||
spectrumGl.ensureSize(cssW, cssH, dpr);
|
||
const W = spectrumCanvas.width;
|
||
const H = spectrumCanvas.height;
|
||
const pal = canvasPalette();
|
||
const range = spectrumVisibleRange(data);
|
||
const bins = data.bins;
|
||
const peakHoldBins = buildSpectrumPeakHoldBins(bins);
|
||
const n = bins.length;
|
||
spectrumGl.clear(cssColorToRgba(pal.bg));
|
||
if (!n) return;
|
||
const DB_MIN = spectrumFloor;
|
||
const DB_MAX = spectrumFloor + spectrumRange;
|
||
const dbRange = DB_MAX - DB_MIN;
|
||
const fullSpanHz = data.sample_rate;
|
||
const loHz = data.center_hz - fullSpanHz / 2;
|
||
const gridStep = spectrumRange > 100 ? 20 : 10;
|
||
spectrumTmpGridSegments.length = 0;
|
||
for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) {
|
||
const y = Math.round(H * (1 - (db - DB_MIN) / dbRange));
|
||
spectrumTmpGridSegments.push(0, y, W, y);
|
||
}
|
||
spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1);
|
||
updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr);
|
||
function hzToX(hz) {
|
||
return (hz - range.visLoHz) / range.visSpanHz * W;
|
||
}
|
||
function binX(i) {
|
||
return hzToX(loHz + i / (n - 1) * fullSpanHz);
|
||
}
|
||
function binYFromBins(srcBins, i) {
|
||
const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i] ?? DB_MIN));
|
||
return H * (1 - (db - DB_MIN) / dbRange);
|
||
}
|
||
spectrumTmpFillPoints.length = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i));
|
||
}
|
||
spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill));
|
||
if (isNumericBins(peakHoldBins) && peakHoldBins.length === n) {
|
||
spectrumTmpPeakPoints.length = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i));
|
||
}
|
||
spectrumGl.drawPolyline(spectrumTmpPeakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9));
|
||
}
|
||
spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr));
|
||
const noiseDb = estimateNoiseFloorDb(bins);
|
||
if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) {
|
||
const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange));
|
||
const nfSegments = [];
|
||
const dashLen = Math.max(4, Math.round(6 * dpr));
|
||
const gapLen = Math.max(3, Math.round(5 * dpr));
|
||
for (let x = 0; x < W; x += dashLen + gapLen) {
|
||
nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY);
|
||
}
|
||
spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8));
|
||
}
|
||
const markerPeaks = visibleSpectrumPeakIndices(data);
|
||
if (markerPeaks.length > 0) {
|
||
spectrumTmpMarkerPoints.length = 0;
|
||
for (const idx of markerPeaks) {
|
||
spectrumTmpMarkerPoints.push(binX(idx), binYFromBins(bins, idx));
|
||
}
|
||
spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak));
|
||
}
|
||
if (spectrumCrosshairX != null && spectrumCrosshairY != null) {
|
||
const cx = spectrumCrosshairX * dpr;
|
||
const cy = spectrumCrosshairY * dpr;
|
||
const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5);
|
||
spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6));
|
||
spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6));
|
||
}
|
||
if (_spectrumZoomEl) {
|
||
if (spectrumZoom > 1.01) {
|
||
_spectrumZoomEl.textContent = spectrumZoom.toFixed(1) + "x";
|
||
_spectrumZoomEl.style.display = "block";
|
||
} else {
|
||
_spectrumZoomEl.style.display = "none";
|
||
}
|
||
}
|
||
if (_spectrumMinimapEl) {
|
||
if (spectrumZoom > 1.01) {
|
||
_spectrumMinimapEl.style.display = "block";
|
||
const viewFrac = 1 / spectrumZoom;
|
||
const halfVis = viewFrac / 2;
|
||
const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis);
|
||
const viewL = panClamped - halfVis;
|
||
const viewR = panClamped + halfVis;
|
||
if (_spectrumMinimapInner) {
|
||
_spectrumMinimapInner.style.left = viewL * 100 + "%";
|
||
_spectrumMinimapInner.style.width = (viewR - viewL) * 100 + "%";
|
||
}
|
||
} else {
|
||
_spectrumMinimapEl.style.display = "none";
|
||
}
|
||
}
|
||
updateSpectrumFreqAxis(range);
|
||
updateBookmarkAxis(range);
|
||
updateBandplanStrip(range);
|
||
drawSignalOverlay();
|
||
}
|
||
var spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas");
|
||
var spectrumWaterfallGl = typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas ? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions) : null;
|
||
var spectrumWfRows = [];
|
||
var spectrumWfPushCount = 0;
|
||
var spectrumWfTexData = null;
|
||
var spectrumWfTexWidth = 0;
|
||
var spectrumWfTexHeight = 0;
|
||
var spectrumWfTexPushCount = 0;
|
||
var spectrumWfTexPalKey = "";
|
||
var spectrumWfTexReady = false;
|
||
var spectrumWfDrawPending = false;
|
||
var SPECTRUM_WF_TEX_MAX_W = 1024;
|
||
var _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator");
|
||
var _spectrumMinimapEl = document.getElementById("spectrum-minimap");
|
||
var _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null;
|
||
var _cachedSpectrumCssW = 640;
|
||
var _cachedSpectrumCssH = 160;
|
||
var _cachedSpecWfCssW = 640;
|
||
var _cachedSpecWfCssH = 120;
|
||
var _cachedDpr = window.devicePixelRatio || 1;
|
||
function _updateCachedCanvasSizes() {
|
||
_cachedDpr = window.devicePixelRatio || 1;
|
||
if (spectrumCanvas) {
|
||
_cachedSpectrumCssW = spectrumCanvas.clientWidth || 640;
|
||
_cachedSpectrumCssH = spectrumCanvas.clientHeight || 160;
|
||
}
|
||
if (spectrumWaterfallCanvas) {
|
||
_cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640;
|
||
_cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120;
|
||
}
|
||
}
|
||
window.addEventListener("resize", _updateCachedCanvasSizes);
|
||
_updateCachedCanvasSizes();
|
||
function pushSpectrumWaterfallFrame(data) {
|
||
if (!spectrumWaterfallCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return;
|
||
spectrumWfRows.push(data.bins.slice());
|
||
spectrumWfPushCount++;
|
||
trimSpectrumWaterfallRows();
|
||
scheduleSpectrumWaterfallDraw();
|
||
}
|
||
function trimSpectrumWaterfallRows() {
|
||
if (!spectrumWaterfallCanvas) return;
|
||
const maxRows = Math.max(1, Math.floor(_cachedSpecWfCssH * _cachedDpr));
|
||
if (spectrumWfRows.length > maxRows) {
|
||
spectrumWfRows.splice(0, spectrumWfRows.length - maxRows);
|
||
}
|
||
}
|
||
function scheduleSpectrumWaterfallDraw() {
|
||
if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return;
|
||
spectrumWfDrawPending = true;
|
||
requestAnimationFrame(() => {
|
||
spectrumWfDrawPending = false;
|
||
drawSpectrumWaterfall();
|
||
});
|
||
}
|
||
function drawSpectrumWaterfall() {
|
||
if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return;
|
||
if (!lastSpectrumData || spectrumWfRows.length === 0) return;
|
||
const dpr = _cachedDpr;
|
||
const cssW = _cachedSpecWfCssW;
|
||
const cssH = _cachedSpecWfCssH;
|
||
spectrumWaterfallGl.ensureSize(cssW, cssH, dpr);
|
||
const W = spectrumWaterfallCanvas.width;
|
||
const H = spectrumWaterfallCanvas.height;
|
||
if (W <= 0 || H <= 0) return;
|
||
const pal = canvasPalette();
|
||
const maxVisible = Math.max(1, Math.floor(H));
|
||
const rows = spectrumWfRows.slice(-maxVisible);
|
||
if (rows.length === 0) return;
|
||
const iW = Math.max(96, Math.min(SPECTRUM_WF_TEX_MAX_W, Math.ceil(W / 2)));
|
||
const iH = Math.max(1, rows.length);
|
||
const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115;
|
||
const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90);
|
||
const view = spectrumVisibleRange(lastSpectrumData);
|
||
const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`;
|
||
const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`;
|
||
const rowStride = iW * 4;
|
||
const expectedSize = iW * iH * 4;
|
||
const newPushes = spectrumWfPushCount - spectrumWfTexPushCount;
|
||
const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH;
|
||
const palChanged = spectrumWfTexPalKey !== palKey;
|
||
const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0;
|
||
let texUpdated = false;
|
||
if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) {
|
||
spectrumWfTexData = new Uint8Array(expectedSize);
|
||
}
|
||
spectrumWfTexWidth = iW;
|
||
spectrumWfTexHeight = iH;
|
||
ensureWaterfallLut(pal, minDb, maxDb);
|
||
function renderRow(dstY, srcBins) {
|
||
if (!isNumericBins(srcBins) || srcBins.length === 0) return;
|
||
const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length);
|
||
const spanBins = Math.max(1, endIdx - startIdx);
|
||
const rowBase = dstY * rowStride;
|
||
const iwM1 = Math.max(1, iW - 1);
|
||
for (let x = 0; x < iW; x++) {
|
||
const binIdx = Math.min(endIdx, startIdx + (x * spanBins / iwM1 | 0));
|
||
const db = srcBins[binIdx];
|
||
if (db !== void 0) waterfallLutWrite(textureData, rowBase + x * 4, db);
|
||
}
|
||
}
|
||
const textureData = spectrumWfTexData;
|
||
if (needsFull) {
|
||
for (let y = 0; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
spectrumWfTexPushCount = spectrumWfPushCount;
|
||
spectrumWfTexPalKey = palKey;
|
||
texUpdated = true;
|
||
} else if (newPushes > 0) {
|
||
const newCount = Math.min(newPushes, iH);
|
||
if (newCount >= iH) {
|
||
for (let y = 0; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
} else {
|
||
const shiftBytes = newCount * rowStride;
|
||
textureData.copyWithin(0, shiftBytes);
|
||
const startRow = iH - newCount;
|
||
for (let y = startRow; y < iH; y++) {
|
||
const row = rows[y];
|
||
if (row) renderRow(y, row);
|
||
}
|
||
}
|
||
spectrumWfTexPushCount = spectrumWfPushCount;
|
||
spectrumWfTexPalKey = palKey;
|
||
texUpdated = true;
|
||
}
|
||
if (texUpdated || !spectrumWfTexReady) {
|
||
spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, textureData, "linear");
|
||
spectrumWfTexReady = true;
|
||
}
|
||
spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true);
|
||
}
|
||
function bmLuminance(hex) {
|
||
const lin = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||
const r = lin(parseInt(hex.slice(1, 3), 16) / 255);
|
||
const g = lin(parseInt(hex.slice(3, 5), 16) / 255);
|
||
const b = lin(parseInt(hex.slice(5, 7), 16) / 255);
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
}
|
||
function bmContrastFg(bgHex) {
|
||
return bmLuminance(bgHex) >= 0.4 ? "#1a202c" : "#ffffff";
|
||
}
|
||
function bmResolveThemeColor(name, fallbackHex) {
|
||
const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||
if (/^#[0-9a-f]{6}$/i.test(val)) return val;
|
||
if (/^#[0-9a-f]{3}$/i.test(val))
|
||
return "#" + [...val.slice(1)].map((c) => c + c).join("");
|
||
const m = val.match(/\d+/g);
|
||
if (m && m.length >= 3)
|
||
return "#" + m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, "0")).join("");
|
||
return fallbackHex;
|
||
}
|
||
function bmBlendHex(aHex, bHex, ratio = 0.5) {
|
||
const mix = Math.max(0, Math.min(1, isFiniteNumber(ratio) ? ratio : 0.5));
|
||
const aR = parseInt(aHex.slice(1, 3), 16);
|
||
const aG = parseInt(aHex.slice(3, 5), 16);
|
||
const aB = parseInt(aHex.slice(5, 7), 16);
|
||
const bR = parseInt(bHex.slice(1, 3), 16);
|
||
const bG = parseInt(bHex.slice(3, 5), 16);
|
||
const bB = parseInt(bHex.slice(5, 7), 16);
|
||
const toHex = (value) => Math.round(value).toString(16).padStart(2, "0");
|
||
return "#" + [
|
||
aR + (bR - aR) * mix,
|
||
aG + (bG - aG) * mix,
|
||
aB + (bB - aB) * mix
|
||
].map(toHex).join("");
|
||
}
|
||
function bmThemePalette() {
|
||
const yellow = bmResolveThemeColor("--accent-yellow", "#f0ad4e");
|
||
const green = bmResolveThemeColor("--accent-green", "#c24b1a");
|
||
const red = bmResolveThemeColor("--accent-red", "#e55353");
|
||
const heading = bmResolveThemeColor("--text-heading", "#c6d5ea");
|
||
const border = bmResolveThemeColor("--border-light", "#304766");
|
||
return [
|
||
yellow,
|
||
bmBlendHex(yellow, heading, 0.28),
|
||
bmBlendHex(yellow, green, 0.45),
|
||
bmBlendHex(green, heading, 0.22),
|
||
bmBlendHex(yellow, red, 0.42),
|
||
bmBlendHex(red, heading, 0.18),
|
||
bmBlendHex(border, yellow, 0.58),
|
||
bmBlendHex(border, heading, 0.5)
|
||
];
|
||
}
|
||
function bmCategoryColorMap() {
|
||
const ref = window.trx.modules.bookmarks?.overlayList ?? [];
|
||
const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort();
|
||
const palette = bmThemePalette();
|
||
const fallback = palette[0] ?? BOOKMARK_MARKER_FALLBACK;
|
||
const map = { "": fallback };
|
||
cats.forEach((cat, i) => {
|
||
if (cat) map[cat] = palette[(i + 1) % palette.length] ?? fallback;
|
||
});
|
||
return map;
|
||
}
|
||
function createBookmarkChip(bm, colorMap, options = {}) {
|
||
const span = document.createElement("span");
|
||
const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + " Hz";
|
||
span.className = "spectrum-bookmark-chip";
|
||
if (options.sideStack) {
|
||
span.classList.add("spectrum-bookmark-chip-side");
|
||
} else {
|
||
span.style.top = "2px";
|
||
}
|
||
span.title = buildBookmarkTooltipText(bm) || bm.name + " — " + freqStr + (bm.comment ? "\n" + bm.comment : "");
|
||
span.dataset.bmId = bm.id;
|
||
const labelHtml = options.sideStack ? `<span class="spectrum-bookmark-side-head"><svg class='bm-icon-svg' viewBox='0 0 8 12' width='8' height='12' aria-hidden='true'><path d='M0,0 h8 v10 l-4,2 l-4,-2 Z'/></svg><span class="spectrum-bookmark-freq">${escapeHtml(freqStr)}</span></span><span class="spectrum-bookmark-name">${escapeHtml(bm.name)}</span>` : "<svg class='bm-icon-svg' viewBox='0 0 8 12' width='8' height='12' aria-hidden='true'><path d='M0,0 h8 v10 l-4,2 l-4,-2 Z'/></svg> <span class='spectrum-bookmark-name'>" + escapeHtml(bm.name) + "</span>";
|
||
span.innerHTML = labelHtml;
|
||
const col = colorMap[bm.category || ""] ?? BOOKMARK_MARKER_FALLBACK;
|
||
span.style.setProperty("--bm-cat-bg", col);
|
||
span.style.setProperty("--bm-cat-fg", bmContrastFg(col));
|
||
span.addEventListener("click", () => {
|
||
void window.trx.modules.bookmarks?.apply(bm);
|
||
});
|
||
return span;
|
||
}
|
||
function updateSideBookmarkStack(container, bookmarks, colorMap) {
|
||
if (!container) return;
|
||
const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
|
||
const nextKey = Array.isArray(bookmarks) ? `${rev}:${bookmarks.map((bm) => bm.id).join(",")}` : "";
|
||
if (!Array.isArray(bookmarks) || bookmarks.length === 0) {
|
||
if (container.dataset.bmKey) {
|
||
container.replaceChildren();
|
||
container.dataset.bmKey = "";
|
||
}
|
||
container.classList.remove("bm-side-visible");
|
||
return;
|
||
}
|
||
if (container.dataset.bmKey !== nextKey) {
|
||
container.dataset.bmKey = nextKey;
|
||
container.replaceChildren();
|
||
for (const bm of bookmarks) {
|
||
container.appendChild(createBookmarkChip(bm, colorMap, { sideStack: true }));
|
||
}
|
||
}
|
||
container.classList.add("bm-side-visible");
|
||
}
|
||
function updateBookmarkAxis(range) {
|
||
const axisEl = document.getElementById("spectrum-bookmark-axis");
|
||
const leftSideEl = document.getElementById("spectrum-bookmark-side-left");
|
||
const rightSideEl = document.getElementById("spectrum-bookmark-side-right");
|
||
if (!axisEl) return;
|
||
const allBookmarks = window.trx.modules.bookmarks?.overlayList ?? [];
|
||
const visBookmarks = allBookmarks.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz);
|
||
const leftBookmarks = allBookmarks.filter((bm) => bm.freq_hz < range.visLoHz).sort((a, b) => b.freq_hz - a.freq_hz).slice(0, 3);
|
||
const rightBookmarks = allBookmarks.filter((bm) => bm.freq_hz > range.visHiHz).sort((a, b) => a.freq_hz - b.freq_hz).slice(0, 3);
|
||
const colorMap = bmCategoryColorMap();
|
||
updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap);
|
||
updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap);
|
||
const hasVisible = visBookmarks.length > 0;
|
||
axisEl.classList.add("bm-axis-visible");
|
||
axisEl.classList.toggle("bm-axis-empty", !hasVisible);
|
||
if (!hasVisible) {
|
||
if (axisEl.dataset.bmKey) {
|
||
axisEl.replaceChildren();
|
||
axisEl.dataset.bmKey = "";
|
||
}
|
||
return;
|
||
}
|
||
const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0;
|
||
const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`;
|
||
if (axisEl.dataset.bmKey !== newKey) {
|
||
axisEl.dataset.bmKey = newKey;
|
||
axisEl.replaceChildren();
|
||
for (const bm of visBookmarks) {
|
||
axisEl.appendChild(createBookmarkChip(bm, colorMap));
|
||
}
|
||
}
|
||
const axisWidth = axisEl.clientWidth || 0;
|
||
const edgePad = 8;
|
||
const spans = axisEl.querySelectorAll(":scope > span");
|
||
const widths = [];
|
||
for (const span of spans) widths.push(span.offsetWidth || 0);
|
||
visBookmarks.forEach((bm, i) => {
|
||
const span = spans[i];
|
||
if (!span) return;
|
||
const frac = (bm.freq_hz - range.visLoHz) / range.visSpanHz;
|
||
if (axisWidth > 0) {
|
||
const lw = widths[i] ?? 0;
|
||
const clamped = Math.max(edgePad + lw / 2, Math.min(axisWidth - edgePad - lw / 2, frac * axisWidth));
|
||
span.style.transform = `translateX(${clamped - lw / 2}px)`;
|
||
} else {
|
||
span.style.left = (frac * 100).toFixed(2) + "%";
|
||
}
|
||
});
|
||
}
|
||
function updateSpectrumFreqAxis(range) {
|
||
if (!spectrumFreqAxis) return;
|
||
const spanHz = range.visSpanHz;
|
||
const targets = [
|
||
100,
|
||
200,
|
||
500,
|
||
1e3,
|
||
2e3,
|
||
5e3,
|
||
1e4,
|
||
2e4,
|
||
5e4,
|
||
1e5,
|
||
2e5,
|
||
5e5,
|
||
1e6,
|
||
2e6,
|
||
5e6,
|
||
1e7
|
||
];
|
||
const ideal = spanHz / 5;
|
||
const firstTarget = targets[0] ?? 100;
|
||
const stepHz = targets.reduce((best, s) => Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, firstTarget);
|
||
const axisKey = [
|
||
Math.round(range.visLoHz),
|
||
Math.round(range.visHiHz),
|
||
Math.round(stepHz),
|
||
spectrumFreqAxis.clientWidth || 0
|
||
].join(":");
|
||
if (axisKey === spectrumAxisKey) return;
|
||
spectrumAxisKey = axisKey;
|
||
const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz;
|
||
const leftShiftBtn = document.getElementById("spectrum-center-left-btn");
|
||
const rightShiftBtn = document.getElementById("spectrum-center-right-btn");
|
||
spectrumFreqAxis.replaceChildren();
|
||
if (leftShiftBtn) spectrumFreqAxis.appendChild(leftShiftBtn);
|
||
if (rightShiftBtn) spectrumFreqAxis.appendChild(rightShiftBtn);
|
||
const axisWidth = spectrumFreqAxis.clientWidth || 0;
|
||
const buttonReserve = Math.max(
|
||
leftShiftBtn?.offsetWidth || 0,
|
||
rightShiftBtn?.offsetWidth || 0,
|
||
0
|
||
);
|
||
const edgePad = Math.max(6, buttonReserve + 10);
|
||
for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) {
|
||
const frac = (hz - range.visLoHz) / range.visSpanHz;
|
||
if (frac < 0 || frac > 1) continue;
|
||
const label = hz >= 1e6 ? (hz / 1e6).toFixed(stepHz < 1e6 ? stepHz < 1e5 ? 3 : 1 : 0) + " M" : hz >= 1e3 ? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k" : hz.toFixed(0);
|
||
const span = document.createElement("span");
|
||
span.textContent = label;
|
||
spectrumFreqAxis.appendChild(span);
|
||
const labelWidth = span.offsetWidth || 0;
|
||
if (axisWidth > 0 && labelWidth > 0) {
|
||
const minCenter = edgePad + labelWidth / 2;
|
||
const maxCenter = axisWidth - edgePad - labelWidth / 2;
|
||
const desiredCenter = frac * axisWidth;
|
||
const clampedCenter = Math.max(minCenter, Math.min(maxCenter, desiredCenter));
|
||
span.style.left = `${clampedCenter}px`;
|
||
} else {
|
||
span.style.left = (frac * 100).toFixed(2) + "%";
|
||
}
|
||
}
|
||
}
|
||
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.replaceChildren();
|
||
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);
|
||
}
|
||
}
|
||
function shouldIgnoreGlobalShortcut(target) {
|
||
if (!(target instanceof HTMLElement)) return false;
|
||
const tag = target.tagName;
|
||
if (target.isContentEditable) return true;
|
||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
||
return !!target.closest("[contenteditable='true']");
|
||
}
|
||
function toggleShortcutOverlay() {
|
||
const el = document.getElementById("shortcut-overlay");
|
||
if (!el) return;
|
||
el.classList.toggle("is-hidden");
|
||
}
|
||
function hideShortcutOverlay() {
|
||
const el = document.getElementById("shortcut-overlay");
|
||
if (el) el.classList.add("is-hidden");
|
||
}
|
||
function isShortcutOverlayVisible() {
|
||
const el = document.getElementById("shortcut-overlay");
|
||
return el && !el.classList.contains("is-hidden");
|
||
}
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
const overlay = document.getElementById("shortcut-overlay");
|
||
if (overlay) overlay.addEventListener("click", (e) => {
|
||
if (e.target === overlay) hideShortcutOverlay();
|
||
});
|
||
});
|
||
window.addEventListener("keydown", (event) => {
|
||
if (event.defaultPrevented || event.repeat || event.isComposing) return;
|
||
const key = (event.key || "").toLowerCase();
|
||
if (event.key === "F1") {
|
||
event.preventDefault();
|
||
toggleShortcutOverlay();
|
||
return;
|
||
}
|
||
if (event.key === "Escape" && isShortcutOverlayVisible()) {
|
||
event.preventDefault();
|
||
hideShortcutOverlay();
|
||
return;
|
||
}
|
||
if (key === "f" && !event.ctrlKey && !event.metaKey && !event.altKey && !shouldIgnoreGlobalShortcut(event.target)) {
|
||
event.preventDefault();
|
||
const fi = document.getElementById("freq");
|
||
if (fi) {
|
||
fi.focus();
|
||
fi.select();
|
||
}
|
||
return;
|
||
}
|
||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||
if (shouldIgnoreGlobalShortcut(event.target)) return;
|
||
if (key === "s") {
|
||
event.preventDefault();
|
||
if (window.trx.modules.screenshot) {
|
||
void window.trx.modules.screenshot.captureSpectrumScreenshot();
|
||
} else void import("./screenshot.js").then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot()).catch((error) => {
|
||
console.error("Screenshot module failed to load", error);
|
||
});
|
||
return;
|
||
}
|
||
if (key === "r") {
|
||
event.preventDefault();
|
||
if (lastLocked) {
|
||
showHint("Locked", 1500);
|
||
return;
|
||
}
|
||
if (lastFreqHz != null) {
|
||
const step = Math.max(1, jogStep);
|
||
const rounded = Math.round(lastFreqHz / step) * step;
|
||
if (rounded !== lastFreqHz) {
|
||
if (!freqAllowed(rounded)) {
|
||
showUnsupportedFreqPopup(rounded);
|
||
return;
|
||
}
|
||
setRigFrequency(rounded);
|
||
showHint(`Rounded → ${formatFrequency(rounded)}`, 1200);
|
||
} else {
|
||
showHint("Already on step", 1200);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (key === "b") {
|
||
event.preventDefault();
|
||
void restorePreviousTuneState();
|
||
return;
|
||
}
|
||
if (key === "[") {
|
||
event.preventDefault();
|
||
const [, minBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||
const next = Math.max(minBw, currentBandwidthHz - 1e4);
|
||
if (next !== currentBandwidthHz) {
|
||
currentBandwidthHz = next;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(next);
|
||
positionFastOverlay(lastFreqHz, next);
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
postPath(`/set_bandwidth?hz=${next}`).catch(() => {
|
||
});
|
||
showHint(`BW ${formatBwLabel(next)}`, 1200);
|
||
}
|
||
return;
|
||
}
|
||
if (key === "]") {
|
||
event.preventDefault();
|
||
const [, , maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||
const next = Math.min(maxBw, currentBandwidthHz + 1e4);
|
||
if (next !== currentBandwidthHz) {
|
||
currentBandwidthHz = next;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(next);
|
||
positionFastOverlay(lastFreqHz, next);
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
postPath(`/set_bandwidth?hz=${next}`).catch(() => {
|
||
});
|
||
showHint(`BW ${formatBwLabel(next)}`, 1200);
|
||
}
|
||
return;
|
||
}
|
||
if (key === "arrowleft" || key === "arrowright") {
|
||
event.preventDefault();
|
||
jogFreq(key === "arrowright" ? 1 : -1);
|
||
return;
|
||
}
|
||
if (key === "arrowup" || key === "arrowdown") {
|
||
event.preventDefault();
|
||
void shiftSpectrumCenter(key === "arrowup" ? 1 : -1);
|
||
return;
|
||
}
|
||
if (key === "m") {
|
||
event.preventDefault();
|
||
if (modeEl && !modeEl.disabled) {
|
||
modeEl.focus();
|
||
modeEl.click();
|
||
if (typeof modeEl.showPicker === "function") {
|
||
try {
|
||
modeEl.showPicker();
|
||
} catch (_) {
|
||
}
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if (key === "z") {
|
||
event.preventDefault();
|
||
if (wfmAudioModeEl) {
|
||
const next = wfmAudioModeEl.value === "mono" ? "stereo" : "mono";
|
||
wfmAudioModeEl.value = next;
|
||
saveSetting("wfmAudioMode", next);
|
||
const enabled = next !== "mono";
|
||
postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => {
|
||
});
|
||
showHint(next === "stereo" ? "Stereo" : "Mono", 1200);
|
||
} else {
|
||
showHint("Stereo N/A", 1200);
|
||
}
|
||
return;
|
||
}
|
||
if (key === "n") {
|
||
event.preventDefault();
|
||
if (sdrNbSupported && sdrNbEnabledEl) {
|
||
sdrNbEnabledEl.checked = !sdrNbEnabledEl.checked;
|
||
submitSdrNbState();
|
||
showHint(sdrNbEnabledEl.checked ? "NB On" : "NB Off", 1200);
|
||
} else {
|
||
showHint("NB N/A", 1200);
|
||
}
|
||
return;
|
||
}
|
||
if (key === "q") {
|
||
event.preventDefault();
|
||
if (sdrSquelchSupported && sdrSquelchEl) {
|
||
const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value));
|
||
let nextPct;
|
||
if (current > 0) {
|
||
nextPct = 0;
|
||
} else {
|
||
let auto = 30;
|
||
const data = lastSpectrumData || window.lastSpectrumData;
|
||
if (data && isNumericBins(data.bins) && data.bins.length > 0) {
|
||
const noiseDb = estimateNoiseFloorDb(data.bins);
|
||
if (noiseDb != null && isFiniteNumber(noiseDb)) {
|
||
const thresholdDb = noiseDb + 6;
|
||
const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb));
|
||
auto = clampSdrSquelchPercent(
|
||
(clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100
|
||
);
|
||
}
|
||
}
|
||
nextPct = auto;
|
||
}
|
||
sdrSquelchEl.value = String(nextPct);
|
||
updateSdrSquelchPctLabel();
|
||
saveSetting("sdrSquelchPct", nextPct);
|
||
submitSdrSquelchPercent(nextPct);
|
||
showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200);
|
||
} else {
|
||
showHint("Squelch N/A", 1200);
|
||
}
|
||
return;
|
||
}
|
||
if (lastSpectrumData && spectrumCanvas) {
|
||
if (key === "+" || key === "=") {
|
||
event.preventDefault();
|
||
const cssW = spectrumCanvas.clientWidth || 640;
|
||
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25);
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
return;
|
||
}
|
||
if (key === "-") {
|
||
event.preventDefault();
|
||
const cssW = spectrumCanvas.clientWidth || 640;
|
||
spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25);
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
return;
|
||
}
|
||
if (key === "0") {
|
||
event.preventDefault();
|
||
spectrumZoom = 1;
|
||
spectrumPanFrac = 0.5;
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
return;
|
||
}
|
||
}
|
||
}, { capture: true });
|
||
function spectrumZoomAt(cssX, cssW, data, factor) {
|
||
const range = spectrumVisibleRange(data);
|
||
const hzAtCursor = canvasXToHz(cssX, cssW, range);
|
||
const frac = cssX / cssW;
|
||
spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor));
|
||
const newVisSpan = data.sample_rate / spectrumZoom;
|
||
const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan;
|
||
const loHz = data.center_hz - data.sample_rate / 2;
|
||
spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate;
|
||
}
|
||
function handleSpectrumWheel(e, canvasEl) {
|
||
e.preventDefault();
|
||
if (!lastSpectrumData || !canvasEl) return;
|
||
if (e.ctrlKey) {
|
||
const direction = e.deltaY < 0 ? 1 : -1;
|
||
jogFreq(direction);
|
||
return;
|
||
}
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
const cssX = e.clientX - rect.left;
|
||
const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25;
|
||
spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor);
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
}
|
||
function handleSpectrumClick(e, canvasEl) {
|
||
if (_sDragMoved) {
|
||
_sDragMoved = false;
|
||
return;
|
||
}
|
||
if (!lastSpectrumData || !canvasEl) return;
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
const cssX = e.clientX - rect.left;
|
||
const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData);
|
||
if (!isFiniteNumber(targetHz)) return;
|
||
setRigFrequency(targetHz);
|
||
}
|
||
if (spectrumCanvas) {
|
||
spectrumCanvas.addEventListener("wheel", (e) => {
|
||
handleSpectrumWheel(e, spectrumCanvas);
|
||
}, { passive: false });
|
||
}
|
||
if (overviewCanvas) {
|
||
overviewCanvas.addEventListener("wheel", (e) => {
|
||
handleSpectrumWheel(e, overviewCanvas);
|
||
}, { passive: false });
|
||
overviewCanvas.addEventListener("click", (e) => {
|
||
handleSpectrumClick(e, overviewCanvas);
|
||
});
|
||
}
|
||
if (spectrumWaterfallCanvas) {
|
||
spectrumWaterfallCanvas.addEventListener("wheel", (e) => {
|
||
handleSpectrumWheel(e, spectrumWaterfallCanvas);
|
||
}, { passive: false });
|
||
spectrumWaterfallCanvas.addEventListener("click", (e) => {
|
||
handleSpectrumClick(e, spectrumWaterfallCanvas);
|
||
});
|
||
spectrumWaterfallCanvas.addEventListener("mousedown", (e) => {
|
||
onSpectrumMouseDown(e, spectrumWaterfallCanvas);
|
||
});
|
||
}
|
||
function getBwEdgeHit(cssX, cssW, range) {
|
||
const bwCenterHz = activeBandwidthCenterHz();
|
||
if (!isFiniteNumber(bwCenterHz) || !currentBandwidthHz || !lastSpectrumData) return null;
|
||
const HIT = 8;
|
||
let bestEdge = null;
|
||
let bestDist = Number.POSITIVE_INFINITY;
|
||
for (const spec of visibleBandwidthSpecs(bwCenterHz)) {
|
||
const span = displaySpanForBandwidthSpec(spec);
|
||
const xL = (span.loHz - range.visLoHz) / range.visSpanHz * cssW;
|
||
const xR = (span.hiHz - range.visLoHz) / range.visSpanHz * cssW;
|
||
if (span.side < 0) {
|
||
const distL2 = Math.abs(cssX - xL);
|
||
if (distL2 < HIT && distL2 < bestDist) {
|
||
bestEdge = "left";
|
||
bestDist = distL2;
|
||
}
|
||
continue;
|
||
}
|
||
if (span.side > 0) {
|
||
const distR2 = Math.abs(cssX - xR);
|
||
if (distR2 < HIT && distR2 < bestDist) {
|
||
bestEdge = "right";
|
||
bestDist = distR2;
|
||
}
|
||
continue;
|
||
}
|
||
const distL = Math.abs(cssX - xL);
|
||
const distR = Math.abs(cssX - xR);
|
||
if (distL < HIT && distL < bestDist) {
|
||
bestEdge = "left";
|
||
bestDist = distL;
|
||
}
|
||
if (distR < HIT && distR < bestDist) {
|
||
bestEdge = "right";
|
||
bestDist = distR;
|
||
}
|
||
}
|
||
if (bestEdge) return bestEdge;
|
||
return null;
|
||
}
|
||
var _sDragStart = null;
|
||
var _sDragMoved = false;
|
||
var _sDragCanvas = null;
|
||
function onSpectrumMouseDown(e, canvasEl) {
|
||
if (!canvasEl || e.button !== 0) return;
|
||
if (lastSpectrumData) {
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
const cssX = e.clientX - rect.left;
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
const edge = getBwEdgeHit(cssX, rect.width, range);
|
||
if (edge) {
|
||
_bwDragEdge = edge;
|
||
_bwDragStartX = cssX;
|
||
_bwDragStartBwHz = currentBandwidthHz;
|
||
_bwDragCanvas = canvasEl;
|
||
_sDragStart = null;
|
||
_sDragCanvas = null;
|
||
_sDragMoved = true;
|
||
return;
|
||
}
|
||
}
|
||
_sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac };
|
||
_sDragCanvas = canvasEl;
|
||
_sDragMoved = false;
|
||
}
|
||
if (spectrumCanvas) {
|
||
spectrumCanvas.addEventListener("mousedown", (e) => {
|
||
onSpectrumMouseDown(e, spectrumCanvas);
|
||
});
|
||
}
|
||
if (overviewCanvas) {
|
||
overviewCanvas.addEventListener("mousedown", (e) => {
|
||
onSpectrumMouseDown(e, overviewCanvas);
|
||
});
|
||
}
|
||
if (spectrumCanvas || overviewCanvas) {
|
||
window.addEventListener("mousemove", (e) => {
|
||
if (_bwDragEdge && lastSpectrumData) {
|
||
const dragCanvas2 = _bwDragCanvas || spectrumCanvas;
|
||
if (!dragCanvas2) return;
|
||
const rect2 = dragCanvas2.getBoundingClientRect();
|
||
const cssX = e.clientX - rect2.left;
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
const dxHz = (cssX - _bwDragStartX) / rect2.width * range.visSpanHz;
|
||
const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB");
|
||
let newBw;
|
||
if (side === 0) {
|
||
newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz * 2 : _bwDragStartBwHz - dxHz * 2;
|
||
} else {
|
||
newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz : _bwDragStartBwHz - dxHz;
|
||
}
|
||
const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB");
|
||
newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw)));
|
||
currentBandwidthHz = newBw;
|
||
window.currentBandwidthHz = currentBandwidthHz;
|
||
syncBandwidthInput(newBw);
|
||
positionFastOverlay(lastFreqHz, newBw);
|
||
scheduleSpectrumDraw();
|
||
scheduleOverviewDraw();
|
||
return;
|
||
}
|
||
if (!_sDragStart || !lastSpectrumData) return;
|
||
const dragCanvas = _sDragCanvas || spectrumCanvas || overviewCanvas;
|
||
if (!dragCanvas) return;
|
||
const rect = dragCanvas.getBoundingClientRect();
|
||
const dx = e.clientX - _sDragStart.clientX;
|
||
if (Math.abs(dx) > 3) _sDragMoved = true;
|
||
spectrumPanFrac = _sDragStart.panFrac - dx / rect.width / spectrumZoom;
|
||
scheduleSpectrumDraw();
|
||
});
|
||
window.addEventListener("mouseup", async () => {
|
||
if (_bwDragEdge) {
|
||
try {
|
||
const bwHz = Math.round(currentBandwidthHz);
|
||
if (!await window.trx.modules.vchan?.interceptBandwidth(bwHz)) {
|
||
await postPath(`/set_bandwidth?hz=${bwHz}`);
|
||
if (isFiniteNumber(lastFreqHz)) {
|
||
await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
window.trxUi?.notify("Bandwidth could not be changed", {
|
||
kind: "error",
|
||
action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) }
|
||
});
|
||
}
|
||
_bwDragEdge = null;
|
||
_bwDragCanvas = null;
|
||
return;
|
||
}
|
||
_sDragStart = null;
|
||
_sDragCanvas = null;
|
||
});
|
||
}
|
||
var _sTouch = null;
|
||
if (spectrumCanvas) {
|
||
spectrumCanvas.addEventListener("touchstart", (e) => {
|
||
e.preventDefault();
|
||
if (e.touches.length === 2) {
|
||
const t0 = e.touches[0], t1 = e.touches[1];
|
||
if (!t0 || !t1) return;
|
||
_sTouch = {
|
||
type: "pinch",
|
||
dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY),
|
||
midX: (t0.clientX + t1.clientX) / 2,
|
||
zoom: spectrumZoom,
|
||
panFrac: spectrumPanFrac
|
||
};
|
||
} else if (e.touches.length === 1) {
|
||
const touch = e.touches[0];
|
||
if (touch) _sTouch = { type: "pan", clientX: touch.clientX, panFrac: spectrumPanFrac };
|
||
}
|
||
}, { passive: false });
|
||
spectrumCanvas.addEventListener("touchmove", (e) => {
|
||
e.preventDefault();
|
||
if (!_sTouch || !lastSpectrumData) return;
|
||
const rect = spectrumCanvas.getBoundingClientRect();
|
||
if (_sTouch.type === "pinch" && e.touches.length === 2) {
|
||
const t0 = e.touches[0], t1 = e.touches[1];
|
||
if (!t0 || !t1) return;
|
||
const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY);
|
||
const newMidX = (t0.clientX + t1.clientX) / 2;
|
||
const scale = newDist / _sTouch.dist;
|
||
const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale));
|
||
const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2;
|
||
const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom;
|
||
const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2;
|
||
const midFrac = (_sTouch.midX - rect.left) / rect.width;
|
||
const midHz = oldVisLo + midFrac * oldVisSpan;
|
||
const newVisSpan = lastSpectrumData.sample_rate / newZoom;
|
||
const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan;
|
||
spectrumZoom = newZoom;
|
||
spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate;
|
||
const dxMid = newMidX - _sTouch.midX;
|
||
spectrumPanFrac -= dxMid / rect.width / spectrumZoom;
|
||
scheduleSpectrumDraw();
|
||
} else if (_sTouch.type === "pan" && e.touches.length === 1) {
|
||
const touch = e.touches[0];
|
||
if (!touch) return;
|
||
const dx = touch.clientX - _sTouch.clientX;
|
||
spectrumPanFrac = _sTouch.panFrac - dx / rect.width / spectrumZoom;
|
||
scheduleSpectrumDraw();
|
||
}
|
||
}, { passive: false });
|
||
spectrumCanvas.addEventListener("touchend", () => {
|
||
_sTouch = null;
|
||
});
|
||
}
|
||
if (spectrumCanvas) {
|
||
spectrumCanvas.addEventListener("mousemove", (e) => {
|
||
if (!lastSpectrumData || !spectrumTooltip) return;
|
||
const rect = spectrumCanvas.getBoundingClientRect();
|
||
const cssX = e.clientX - rect.left;
|
||
const range = spectrumVisibleRange(lastSpectrumData);
|
||
const edge = getBwEdgeHit(cssX, rect.width, range);
|
||
spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair";
|
||
const hz = canvasXToHz(cssX, rect.width, range);
|
||
const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range);
|
||
const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData);
|
||
const peakHz = peak?.hz ?? null;
|
||
const peakDb = peak && isFiniteNumber(peak.db) ? `${peak.db.toFixed(1)} dB` : null;
|
||
if (bookmark) {
|
||
spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark);
|
||
} else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) {
|
||
spectrumTooltip.textContent = peakDb ? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}` : `Peak ${formatSpectrumFreq(peakHz)}`;
|
||
} else {
|
||
const baseText = formatSpectrumFreq(peakHz ?? hz);
|
||
spectrumTooltip.textContent = peakDb ? `${baseText} · ${peakDb}` : baseText;
|
||
}
|
||
spectrumTooltip.style.display = "block";
|
||
const tw = spectrumTooltip.offsetWidth;
|
||
let tx = cssX + 10;
|
||
if (tx + tw > rect.width) tx = cssX - tw - 10;
|
||
spectrumTooltip.style.left = tx + "px";
|
||
spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px";
|
||
spectrumCrosshairX = cssX;
|
||
spectrumCrosshairY = e.clientY - rect.top;
|
||
scheduleSpectrumDraw();
|
||
});
|
||
spectrumCanvas.addEventListener("mouseleave", () => {
|
||
if (spectrumTooltip) spectrumTooltip.style.display = "none";
|
||
spectrumCanvas.style.cursor = "crosshair";
|
||
spectrumCrosshairX = null;
|
||
spectrumCrosshairY = null;
|
||
scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
if (spectrumCanvas) {
|
||
spectrumCanvas.addEventListener("click", (e) => {
|
||
handleSpectrumClick(e, spectrumCanvas);
|
||
});
|
||
}
|
||
if (spectrumCenterLeftBtn) {
|
||
spectrumCenterLeftBtn.addEventListener("click", () => {
|
||
shiftSpectrumCenter(-1).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
if (spectrumCenterRightBtn) {
|
||
spectrumCenterRightBtn.addEventListener("click", () => {
|
||
shiftSpectrumCenter(1).catch(() => {
|
||
});
|
||
});
|
||
}
|
||
(function() {
|
||
const floorInput = document.getElementById("spectrum-floor-input");
|
||
const autoBtn = document.getElementById("spectrum-auto-btn");
|
||
if (floorInput) {
|
||
floorInput.addEventListener("change", () => {
|
||
const v = Number(floorInput.value);
|
||
if (!isNaN(v)) {
|
||
spectrumFloor = v;
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}
|
||
});
|
||
}
|
||
const rangeInput = document.getElementById("spectrum-range-input");
|
||
if (rangeInput) {
|
||
rangeInput.value = String(spectrumRange);
|
||
rangeInput.addEventListener("change", () => {
|
||
const v = Number(rangeInput.value);
|
||
if (!isNaN(v) && v >= 10) {
|
||
spectrumRange = v;
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}
|
||
});
|
||
}
|
||
if (autoBtn) {
|
||
autoBtn.addEventListener("click", () => {
|
||
if (!lastSpectrumData) return;
|
||
const sorted = [...lastSpectrumData.bins].sort((a, b) => a - b);
|
||
const noise = sorted[Math.floor(sorted.length * 0.15)];
|
||
const peak = sorted[sorted.length - 1];
|
||
if (noise === void 0 || peak === void 0) return;
|
||
spectrumFloor = Math.floor(noise / 10) * 10 - 10;
|
||
spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB);
|
||
if (floorInput) floorInput.value = String(spectrumFloor);
|
||
if (rangeInput) rangeInput.value = String(spectrumRange);
|
||
scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
const gammaInput = document.getElementById("spectrum-gamma-input");
|
||
const gammaValue = document.getElementById("spectrum-gamma-value");
|
||
if (gammaInput) {
|
||
gammaInput.addEventListener("input", () => {
|
||
const v = Number(gammaInput.value);
|
||
if (isFiniteNumber(v) && v > 0) {
|
||
waterfallGamma = v;
|
||
if (gammaValue) gammaValue.textContent = v.toFixed(1);
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}
|
||
});
|
||
gammaInput.addEventListener("dblclick", () => {
|
||
waterfallGamma = 1;
|
||
gammaInput.value = "1.0";
|
||
if (gammaValue) gammaValue.textContent = "1.0";
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
})();
|
||
var bandplanData = null;
|
||
var bandplanRegion = loadSetting("bandplanRegion", "off");
|
||
var bandplanShowLabels = loadSetting("bandplanLabels", true);
|
||
var _bandplanServerDefaultApplied = false;
|
||
var bandplanSegmentsCache = null;
|
||
var bandplanCacheKey = "";
|
||
var bandplanStripEl = document.getElementById("spectrum-bandplan-strip");
|
||
var bandplanRegionSelect = document.getElementById("bandplan-region-select");
|
||
var bandplanLabelsCheck = document.getElementById("bandplan-labels-check");
|
||
function loadBandplanJson() {
|
||
return fetch("/bandplan.json").then(async (response) => {
|
||
if (!response.ok) throw new Error(String(response.status));
|
||
return await responseJsonUnknown(response);
|
||
}).then((data) => {
|
||
if (!isRecord2(data)) return;
|
||
bandplanData = data;
|
||
bandplanSegmentsCache = null;
|
||
bandplanCacheKey = "";
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
}).catch((err) => {
|
||
console.warn("Band plan unavailable", err);
|
||
});
|
||
}
|
||
void loadBandplanJson();
|
||
if (bandplanRegionSelect) {
|
||
bandplanRegionSelect.value = bandplanRegion;
|
||
bandplanRegionSelect.addEventListener("change", () => {
|
||
bandplanRegion = bandplanRegionSelect.value;
|
||
saveSetting("bandplanRegion", bandplanRegion);
|
||
bandplanSegmentsCache = null;
|
||
bandplanCacheKey = "";
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
if (bandplanLabelsCheck) {
|
||
bandplanLabelsCheck.checked = bandplanShowLabels;
|
||
bandplanLabelsCheck.addEventListener("change", () => {
|
||
bandplanShowLabels = bandplanLabelsCheck.checked;
|
||
saveSetting("bandplanLabels", bandplanShowLabels);
|
||
bandplanSegmentsCache = null;
|
||
bandplanCacheKey = "";
|
||
if (lastSpectrumData) scheduleSpectrumDraw();
|
||
});
|
||
}
|
||
function bandplanComputeRange() {
|
||
if (lastSpectrumData) {
|
||
return spectrumVisibleRange(lastSpectrumData);
|
||
}
|
||
const freq = lastFreqHz;
|
||
if (!freq || !isFiniteNumber(freq)) return null;
|
||
if (bandplanData && bandplanData[bandplanRegion]) {
|
||
const bands = bandplanData[bandplanRegion]?.bands ?? [];
|
||
for (const band of bands) {
|
||
if (freq >= band.low_hz && freq <= band.high_hz) {
|
||
const margin = (band.high_hz - band.low_hz) * 0.05;
|
||
return {
|
||
visLoHz: band.low_hz - margin,
|
||
visHiHz: band.high_hz + margin,
|
||
visSpanHz: band.high_hz - band.low_hz + 2 * margin
|
||
};
|
||
}
|
||
}
|
||
}
|
||
const span = 5e5;
|
||
return { visLoHz: freq - span / 2, visHiHz: freq + span / 2, visSpanHz: span };
|
||
}
|
||
function bandplanVisibleSegments(region, loHz, hiHz) {
|
||
if (!bandplanData || !bandplanData[region]) return [];
|
||
const bands = bandplanData[region].bands;
|
||
const result = [];
|
||
for (const band of bands) {
|
||
if (band.high_hz < loHz || band.low_hz > hiHz) continue;
|
||
for (const seg of band.segments) {
|
||
if (seg.high_hz <= loHz || seg.low_hz >= hiHz) continue;
|
||
result.push({
|
||
low_hz: seg.low_hz,
|
||
high_hz: seg.high_hz,
|
||
mode: seg.mode,
|
||
label: seg.label,
|
||
band: band.name
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
function _clearBandplanStrip(reserveSpace) {
|
||
if (!bandplanStripEl) return;
|
||
if (bandplanCacheKey) {
|
||
bandplanStripEl.replaceChildren();
|
||
bandplanCacheKey = "";
|
||
}
|
||
bandplanStripEl.classList.toggle("bp-visible", reserveSpace);
|
||
bandplanStripEl.classList.toggle("bp-empty", reserveSpace);
|
||
}
|
||
function updateBandplanStrip(range) {
|
||
if (!bandplanStripEl) return;
|
||
if (!range || bandplanRegion === "off" || !bandplanData) {
|
||
_clearBandplanStrip(false);
|
||
return;
|
||
}
|
||
const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz);
|
||
if (segments.length === 0) {
|
||
_clearBandplanStrip(true);
|
||
return;
|
||
}
|
||
bandplanStripEl.classList.add("bp-visible");
|
||
bandplanStripEl.classList.remove("bp-empty");
|
||
const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(",");
|
||
const stripW = bandplanStripEl.clientWidth || 1;
|
||
if (bandplanCacheKey !== newKey) {
|
||
bandplanCacheKey = newKey;
|
||
bandplanStripEl.replaceChildren();
|
||
const seenBands = /* @__PURE__ */ new Set();
|
||
for (const seg of segments) {
|
||
const el = document.createElement("div");
|
||
el.className = "bp-segment";
|
||
el.dataset.mode = seg.mode;
|
||
el.title = seg.band + " – " + seg.label + " (" + seg.mode + ")";
|
||
if (bandplanShowLabels) {
|
||
const lbl = document.createElement("span");
|
||
lbl.className = "bp-segment-label";
|
||
lbl.textContent = seg.label;
|
||
el.appendChild(lbl);
|
||
}
|
||
bandplanStripEl.appendChild(el);
|
||
if (!seenBands.has(seg.band)) {
|
||
seenBands.add(seg.band);
|
||
const bandLbl = document.createElement("div");
|
||
bandLbl.className = "bp-band-label";
|
||
bandLbl.textContent = seg.band;
|
||
bandLbl.dataset.bandLow = String(seg.low_hz);
|
||
bandplanStripEl.appendChild(bandLbl);
|
||
}
|
||
}
|
||
bandplanSegmentsCache = segments;
|
||
}
|
||
const children = bandplanStripEl.querySelectorAll(".bp-segment");
|
||
const bandLabels = bandplanStripEl.querySelectorAll(".bp-band-label");
|
||
const segs = bandplanSegmentsCache || segments;
|
||
segs.forEach((seg, i) => {
|
||
const el = children[i];
|
||
if (!el) return;
|
||
const l = Math.max(0, (seg.low_hz - range.visLoHz) / range.visSpanHz);
|
||
const r = Math.min(1, (seg.high_hz - range.visLoHz) / range.visSpanHz);
|
||
const leftPx = l * stripW;
|
||
const widthPx = Math.max(1, (r - l) * stripW);
|
||
el.style.left = leftPx + "px";
|
||
el.style.width = widthPx + "px";
|
||
const lbl = el.querySelector(".bp-segment-label");
|
||
if (lbl) {
|
||
lbl.style.display = widthPx < 20 ? "none" : "";
|
||
}
|
||
});
|
||
bandLabels.forEach((lbl) => {
|
||
const bandLow = Number(lbl.dataset.bandLow);
|
||
const frac = (bandLow - range.visLoHz) / range.visSpanHz;
|
||
const px = Math.max(2, frac * stripW);
|
||
lbl.style.left = px + "px";
|
||
lbl.style.display = frac < -0.1 || frac > 1.05 ? "none" : "";
|
||
});
|
||
}
|