10026 lines
376 KiB
JavaScript
10026 lines
376 KiB
JavaScript
import {
|
||
AUTH_ADMIN_ROLES,
|
||
AUTH_ROLES,
|
||
AUTH_ROLE_LABELS,
|
||
changeOwnPassword,
|
||
createUser,
|
||
deleteUser,
|
||
fetchAuthSession,
|
||
hasAccountControls,
|
||
hasAuthRole,
|
||
listUsers,
|
||
login,
|
||
logout,
|
||
normalizeAuthRoles,
|
||
updateUser
|
||
} from "./chunk-FT2RH7BL.js";
|
||
|
||
// 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" },
|
||
// Working the bands: the log is the task and the radio is the instrument,
|
||
// so this one opens on the logbook with the controls a keystroke away.
|
||
ham: { label: "Ham radio", unavailable: "Ham radio needs a rig that can transmit", advanced: true, audio: true, scheduler: false, preferredTab: "logbook", capability: "ham" }
|
||
};
|
||
const layoutCapabilities = { broadcast: false, digital: false, ham: 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",
|
||
"#header-share-btn",
|
||
"#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");
|
||
const nav = bar?.querySelector(".tab-bar-nav");
|
||
if (!bar || !nav) return true;
|
||
const barRect = bar.getBoundingClientRect();
|
||
const actionsRect = actions.getBoundingClientRect();
|
||
if (actionsRect.right > barRect.right + 1) return false;
|
||
const tabs = Array.from(nav.querySelectorAll(".tab")).filter((tab) => tab.offsetParent !== null);
|
||
if (!tabs.length) return true;
|
||
const tabsRight = Math.max(...tabs.map((tab) => tab.getBoundingClientRect().right));
|
||
return tabsRight <= actionsRect.left - 1;
|
||
};
|
||
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);
|
||
if (typeof ResizeObserver !== "undefined") {
|
||
const bar = actions.closest(".tab-bar");
|
||
const observer = new ResizeObserver(() => {
|
||
reflowOverflow();
|
||
});
|
||
if (bar) observer.observe(bar);
|
||
observer.observe(actions);
|
||
}
|
||
document.fonts?.ready.then(() => {
|
||
reflowOverflow();
|
||
}).catch(() => {
|
||
});
|
||
}
|
||
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;
|
||
["satellites", "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", "sstv"]]
|
||
];
|
||
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;
|
||
},
|
||
rerenderAll() {
|
||
for (const plugin of decoders.values()) plugin.rerender?.();
|
||
},
|
||
syncMapAll() {
|
||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||
},
|
||
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/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",
|
||
"logbook",
|
||
"digital-modes",
|
||
"map",
|
||
"satellites",
|
||
"statistics",
|
||
"recorder",
|
||
"settings",
|
||
"about"
|
||
];
|
||
var TAB_PATHS = {
|
||
main: "/",
|
||
bookmarks: "/bookmarks",
|
||
logbook: "/logbook",
|
||
"digital-modes": "/digital-modes",
|
||
map: "/map",
|
||
satellites: "/satellites",
|
||
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);
|
||
}
|
||
var FREQ_MULTIPLIER = { k: 1e3, m: 1e6, g: 1e9 };
|
||
function parseFrequencyParam(raw) {
|
||
if (typeof raw !== "string") return null;
|
||
const text = raw.trim().toLowerCase().replace(/hz$/, "").trim();
|
||
const match = /^(\d+(?:\.\d+)?)\s*([kmg]?)$/.exec(text);
|
||
if (!match) return null;
|
||
const value = Number(match[1]) * (FREQ_MULTIPLIER[match[2] ?? ""] ?? 1);
|
||
if (!Number.isFinite(value) || value <= 0) return null;
|
||
return Math.round(value);
|
||
}
|
||
function parseModeParam(raw) {
|
||
const mode = typeof raw === "string" ? raw.trim().toUpperCase() : "";
|
||
return /^[A-Z]{2,4}$/.test(mode) ? mode : null;
|
||
}
|
||
function parseTuneLink(search) {
|
||
const params = new URLSearchParams(search);
|
||
const rig = (params.get("rig") || "").trim();
|
||
return {
|
||
rig: rig || null,
|
||
freqHz: parseFrequencyParam(params.get("f")),
|
||
mode: parseModeParam(params.get("mode")),
|
||
bandwidthHz: parseFrequencyParam(params.get("bw"))
|
||
};
|
||
}
|
||
function tuneLinkSearch(search, link) {
|
||
const params = new URLSearchParams(search);
|
||
const set = (key, value) => {
|
||
if (value == null || value === "") params.delete(key);
|
||
else params.set(key, value);
|
||
};
|
||
const hz = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 ? String(Math.round(value)) : null;
|
||
set("rig", link.rig);
|
||
set("f", hz(link.freqHz));
|
||
set("mode", parseModeParam(link.mode));
|
||
set("bw", hz(link.bandwidthHz));
|
||
const text = params.toString();
|
||
return text ? `?${text}` : "";
|
||
}
|
||
|
||
// 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 = {
|
||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||
// with it. They used to come only with the map group, which left their
|
||
// sub-tabs empty — decodes queueing in the runtime — until something opened
|
||
// the Map tab. Their map calls are optional, so map-core stays lazy.
|
||
"digital-modes": [
|
||
"/ft8.js",
|
||
"/ft4.js",
|
||
"/ft2.js",
|
||
"/wspr.js",
|
||
"/cw.js",
|
||
"/background-decode.js",
|
||
"/sat.js",
|
||
"/wefax.js",
|
||
"/sstv.js",
|
||
"/ais.js",
|
||
"/vdes.js",
|
||
"/aprs.js",
|
||
"/hf-aprs.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"],
|
||
satellites: ["/satellite-predictions.js"],
|
||
statistics: ["/map-core.js"],
|
||
bookmarks: ["/bookmarks.js"],
|
||
logbook: ["/logbook.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", "logbook", "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 authRoles = [];
|
||
var authUsername = null;
|
||
var authEnabled = true;
|
||
function setAuthRoles(roles) {
|
||
authRoles = normalizeAuthRoles(roles);
|
||
}
|
||
function hasAuthRole2(role) {
|
||
return hasAuthRole(authRoles, role);
|
||
}
|
||
function buildRoleChoices(selected) {
|
||
const element = document.createElement("span");
|
||
element.className = "auth-role-choices";
|
||
const inputs = AUTH_ROLES.map((value) => {
|
||
const label = document.createElement("label");
|
||
label.className = "auth-role-choice";
|
||
const input = document.createElement("input");
|
||
input.type = "checkbox";
|
||
input.value = value;
|
||
input.checked = selected.includes(value);
|
||
label.append(input, ` ${AUTH_ROLE_LABELS[value]}`);
|
||
element.append(label);
|
||
return { input, value };
|
||
});
|
||
inputs.forEach(({ input, value }) => {
|
||
input.addEventListener("change", () => {
|
||
if (!input.checked) return;
|
||
if (value === "guest") {
|
||
inputs.forEach((choice) => {
|
||
if (choice.value !== "guest") choice.input.checked = false;
|
||
});
|
||
} else {
|
||
const guest = inputs.find((choice) => choice.value === "guest");
|
||
if (guest) guest.input.checked = false;
|
||
}
|
||
});
|
||
});
|
||
return { element, inputs };
|
||
}
|
||
async function checkAuthStatus() {
|
||
return fetchAuthSession();
|
||
}
|
||
async function authLogin(username, password) {
|
||
return login(username, password);
|
||
}
|
||
async function authLogout() {
|
||
try {
|
||
await logout();
|
||
setAuthRoles([]);
|
||
authUsername = null;
|
||
disconnect();
|
||
setDecodeHistoryOverlayVisible(false);
|
||
requiredElement("content").style.display = "none";
|
||
requiredElement("loading").style.display = "none";
|
||
requiredElement("auth-password").value = "";
|
||
updateAuthUI();
|
||
showAuthGate();
|
||
} catch (e) {
|
||
console.error("Logout failed:", e);
|
||
showAuthError("Logout failed");
|
||
}
|
||
}
|
||
function showAuthGate() {
|
||
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";
|
||
});
|
||
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");
|
||
const accountTab = document.getElementById("settings-account-tab");
|
||
if (!authEnabled) {
|
||
if (badge) badge.style.display = "none";
|
||
if (headerAuthBtn2) headerAuthBtn2.style.display = "none";
|
||
if (accountTab) accountTab.style.display = "none";
|
||
syncTopBarAccess();
|
||
return;
|
||
}
|
||
if (authRoles.length > 0) {
|
||
const canManageAccount = hasAccountControls(authRoles);
|
||
if (accountTab) accountTab.style.display = canManageAccount ? "" : "none";
|
||
if (!canManageAccount && accountTab?.classList.contains("active")) {
|
||
const panel = document.getElementById("subtab-settings-account");
|
||
if (panel) panel.style.display = "none";
|
||
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
|
||
}
|
||
if (badge) badge.style.display = "block";
|
||
if (badgeRole) badgeRole.textContent = `${authUsername || "local"} — ${authRoles.map((role) => AUTH_ROLE_LABELS[role]).join(", ")}`;
|
||
if (headerAuthBtn2) {
|
||
headerAuthBtn2.textContent = "Logout";
|
||
headerAuthBtn2.style.display = "block";
|
||
}
|
||
} else {
|
||
if (accountTab) accountTab.style.display = "none";
|
||
if (badge) badge.style.display = "none";
|
||
if (headerAuthBtn2) {
|
||
headerAuthBtn2.textContent = "Login";
|
||
headerAuthBtn2.style.display = "block";
|
||
}
|
||
}
|
||
syncTopBarAccess();
|
||
}
|
||
function applyAuthRestrictions() {
|
||
if (authRoles.length === 0) return;
|
||
if (!hasAuthRole2("control")) {
|
||
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;
|
||
bandwidthControlSupported = !!caps.filter_controls;
|
||
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 decodeHistoryProgressBarEl = document.getElementById("decode-history-progress-bar");
|
||
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", "sstv"]) {
|
||
window.trxPluginRuntime.prune(decoder);
|
||
}
|
||
};
|
||
function syncTopBarAccess() {
|
||
const loggedOut = authEnabled && authRoles.length === 0;
|
||
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";
|
||
const lacksLogbookAccess = authEnabled && btn.dataset.tab === "logbook" && !hasAuthRole2("write");
|
||
btn.style.display = (!loggedOut || isMain) && !lacksLogbookAccess ? "" : "none";
|
||
btn.disabled = false;
|
||
});
|
||
if (rigSwitch) {
|
||
rigSwitch.style.display = loggedOut ? "none" : "";
|
||
}
|
||
if (headerRigSwitchSelect) {
|
||
headerRigSwitchSelect.disabled = loggedOut || !hasAuthRole2("control") || lastRigIds.length === 0;
|
||
}
|
||
}
|
||
var overviewDrawPending = false;
|
||
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "", fraction = null) {
|
||
if (!decodeHistoryOverlayEl) return;
|
||
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||
if (decodeHistoryProgressBarEl) {
|
||
if (fraction == null) {
|
||
decodeHistoryOverlayEl.dataset.phase = "fetching";
|
||
decodeHistoryProgressBarEl.style.width = "";
|
||
} else {
|
||
delete decodeHistoryOverlayEl.dataset.phase;
|
||
decodeHistoryProgressBarEl.style.width = `${Math.round(Math.max(0, Math.min(1, fraction)) * 100)}%`;
|
||
}
|
||
}
|
||
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() {
|
||
renderSdrSquelch();
|
||
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 bandwidthControlSupported = 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 || !hasAuthRole2("control");
|
||
populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch);
|
||
updateRigSubtitle(lastActiveRigId);
|
||
window.trxUi?.setActiveRig(lastActiveRigId);
|
||
window.trx.modules.scheduler?.setRig(lastActiveRigId);
|
||
window.trx.modules.backgroundDecode?.setRig(lastActiveRigId);
|
||
if (rigListChanged) {
|
||
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))),
|
||
// A logbook is for contacts made, so the layout built around it is offered
|
||
// where a rig can transmit.
|
||
ham: serverRigs.some((rig) => rig?.tx === true)
|
||
});
|
||
}
|
||
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 refreshDecodeBars() {
|
||
window.updateAisBar?.();
|
||
window.updateVdesBar?.();
|
||
window.updateAprsBar?.();
|
||
window.updateFt8Bar?.();
|
||
window.updateCwBar?.();
|
||
}
|
||
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 = "--";
|
||
});
|
||
refreshDecodeBars();
|
||
window.trxPluginRuntime.rerenderAll();
|
||
}
|
||
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)}`;
|
||
};
|
||
var incomingTuneLink = parseTuneLink(window.location.search);
|
||
var tuneLinkPhase = "pending";
|
||
var tuneLinkSyncTimer = null;
|
||
function currentTuneLink() {
|
||
const bandwidthHz = bandwidthControlSupported && isFiniteNumber(currentBandwidthHz) && currentBandwidthHz > 0 ? currentBandwidthHz : null;
|
||
return {
|
||
rig: lastActiveRigId || null,
|
||
freqHz: isFiniteNumber(lastFreqHz) ? lastFreqHz : null,
|
||
mode: lastModeName || null,
|
||
bandwidthHz
|
||
};
|
||
}
|
||
function tuneLinkUrl() {
|
||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||
return `${window.location.origin}${window.location.pathname}${search}${window.location.hash}`;
|
||
}
|
||
function scheduleTuneLinkSync() {
|
||
if (tuneLinkPhase !== "live") return;
|
||
if (window.trx?.modules.vchan?.isOnVirtual() === true) return;
|
||
if (tuneLinkSyncTimer != null) return;
|
||
tuneLinkSyncTimer = window.setTimeout(() => {
|
||
tuneLinkSyncTimer = null;
|
||
const search = tuneLinkSearch(window.location.search, currentTuneLink());
|
||
if (search === window.location.search) return;
|
||
window.history.replaceState({}, "", `${window.location.pathname}${search}${window.location.hash}`);
|
||
}, 500);
|
||
}
|
||
async function applyTuneLink(link) {
|
||
const wanted = link.rig || link.mode || link.freqHz != null || link.bandwidthHz != null;
|
||
if (!wanted) return;
|
||
if (!hasAuthRole2("control")) {
|
||
showHint("Read-only session — link not applied", 2500);
|
||
return;
|
||
}
|
||
if (link.rig && headerRigSwitchSelect && link.rig !== lastActiveRigId && lastRigIds.includes(link.rig)) {
|
||
headerRigSwitchSelect.value = link.rig;
|
||
await switchRigFromSelect(headerRigSwitchSelect);
|
||
}
|
||
if (link.mode && link.mode !== lastModeName && modeEl) {
|
||
const known = Array.from(modeEl.options).some((option) => option.value === link.mode);
|
||
if (known) {
|
||
modeEl.value = link.mode;
|
||
await applyModeFromPicker();
|
||
} else {
|
||
showHint(`Rig has no ${link.mode} mode`, 2500);
|
||
}
|
||
}
|
||
if (link.freqHz != null) {
|
||
try {
|
||
setRigFrequency(link.freqHz);
|
||
} catch {
|
||
}
|
||
}
|
||
if (link.bandwidthHz != null && bandwidthControlSupported && spectrumBwInput) {
|
||
spectrumBwInput.value = String(link.bandwidthHz / 1e3);
|
||
await applyBandwidthFromInput();
|
||
}
|
||
}
|
||
function followTuneLinkOnce() {
|
||
if (tuneLinkPhase !== "pending") return;
|
||
tuneLinkPhase = "applying";
|
||
void applyTuneLink(incomingTuneLink).finally(() => {
|
||
tuneLinkPhase = "live";
|
||
scheduleTuneLinkSync();
|
||
});
|
||
}
|
||
async function copyTuneLink() {
|
||
const url = tuneLinkUrl();
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
showHint("Link copied", 1500);
|
||
} catch {
|
||
window.trxUi?.notify("Clipboard unavailable — the address bar holds the link", { kind: "error" });
|
||
}
|
||
}
|
||
var shareLinkBtn = document.getElementById("header-share-btn");
|
||
if (shareLinkBtn) shareLinkBtn.addEventListener("click", () => {
|
||
void copyTuneLink();
|
||
});
|
||
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);
|
||
}
|
||
refreshDecodeBars();
|
||
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.sstv_decode_enabled === "boolean" && window.syncSstvToggle) {
|
||
window.syncSstvToggle(update.sstv_decode_enabled);
|
||
}
|
||
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;
|
||
recordSquelchMeterSample(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 --";
|
||
}
|
||
if (update.status) followTuneLinkOnce();
|
||
scheduleTuneLinkSync();
|
||
}
|
||
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) {
|
||
setAuthRoles([]);
|
||
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 (!hasAuthRole2("control")) {
|
||
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 pendingMapTarget = null;
|
||
function applyMapTarget(target) {
|
||
const map = window.trx.modules.map;
|
||
if (!map) return false;
|
||
if (target.kind === "position") {
|
||
map.focusMapPosition?.(target.lat, target.lon);
|
||
} else {
|
||
map.focusMapLocator?.(target.grid, target.preferredType);
|
||
}
|
||
return true;
|
||
}
|
||
function requestMapTarget(target) {
|
||
pendingMapTarget = target;
|
||
navigateToTab("map");
|
||
if (window.trx.modules.map) {
|
||
requestAnimationFrame(() => {
|
||
drainPendingMapTarget();
|
||
});
|
||
}
|
||
}
|
||
function drainPendingMapTarget() {
|
||
const target = pendingMapTarget;
|
||
if (!target) return;
|
||
if (applyMapTarget(target)) pendingMapTarget = null;
|
||
}
|
||
window.navigateToAprsMap = (lat, lon) => {
|
||
if (!isFiniteNumber(lat) || !isFiniteNumber(lon)) return;
|
||
requestMapTarget({ kind: "position", lat, lon });
|
||
};
|
||
window.navigateToMapLocator = (grid, preferredType = null) => {
|
||
if (!grid) return;
|
||
requestMapTarget({ kind: "locator", grid, preferredType });
|
||
};
|
||
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();
|
||
drainPendingMapTarget();
|
||
});
|
||
});
|
||
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 leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||
const { updateHistory = true, replaceHistory = false } = options;
|
||
if (authEnabled && authRoles.length === 0 && name !== "main") {
|
||
showAuthGate();
|
||
return;
|
||
}
|
||
if (authEnabled && name === "logbook" && !hasAuthRole2("write")) {
|
||
showHint("Write role required for logbook access", 2500);
|
||
navigateToTab("main", options);
|
||
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();
|
||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||
void loadPluginsForTab(name).then(() => {
|
||
if (name === "satellites") window.refreshSatPredictions?.();
|
||
flushPendingDecodeStats();
|
||
}).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();
|
||
const authStatus = await checkAuthStatus();
|
||
authEnabled = !authStatus.auth_disabled;
|
||
if (!authEnabled) {
|
||
setAuthRoles(AUTH_ADMIN_ROLES);
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
return;
|
||
}
|
||
if (authStatus.authenticated) {
|
||
setAuthRoles(authStatus.roles);
|
||
authUsername = authStatus.username ?? null;
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
applyAuthRestrictions();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
} else {
|
||
showAuthGate();
|
||
}
|
||
}
|
||
var settingsUiReady = false;
|
||
function initSettingsUI() {
|
||
settingsUiReady = true;
|
||
window.trx.modules.scheduler?.initialize(lastActiveRigId, authRoles);
|
||
window.trx.modules.scheduler?.wireEvents();
|
||
if (window.trx.modules.backgroundDecode) {
|
||
window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRoles);
|
||
window.trx.modules.backgroundDecode.wireEvents();
|
||
}
|
||
void refreshUserManagement();
|
||
}
|
||
async function refreshUserManagement() {
|
||
const section = document.getElementById("user-management");
|
||
const tab = document.getElementById("settings-users-tab");
|
||
if (!section || !tab) return;
|
||
const canManageUsers = authEnabled && hasAuthRole2("administrator");
|
||
tab.style.display = canManageUsers ? "" : "none";
|
||
if (!canManageUsers) {
|
||
const panel = document.getElementById("subtab-settings-users");
|
||
if (panel) panel.style.display = "none";
|
||
if (tab.classList.contains("active")) {
|
||
document.querySelector('[data-subtab="settings-scheduler"]')?.click();
|
||
}
|
||
return;
|
||
}
|
||
const list = requiredElement("user-list");
|
||
try {
|
||
const users = await listUsers();
|
||
const enabledAdminCount = users.filter((user) => user.enabled && hasAuthRole(user.roles, "administrator")).length;
|
||
list.replaceChildren(...users.map((user) => {
|
||
const row = document.createElement("div");
|
||
row.className = "sch-row";
|
||
row.style.cssText = "display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin:.4rem 0";
|
||
const name = document.createElement("strong");
|
||
name.textContent = user.username;
|
||
name.style.minWidth = "10rem";
|
||
if (!user.enabled) name.textContent += " (disabled)";
|
||
const { element: roles, inputs: roleInputs } = buildRoleChoices(user.roles);
|
||
const enabledLabel = document.createElement("label");
|
||
enabledLabel.className = "auth-role-choice";
|
||
const enabled = document.createElement("input");
|
||
enabled.type = "checkbox";
|
||
enabled.checked = user.enabled;
|
||
enabled.disabled = user.username === authUsername;
|
||
if (enabled.disabled) enabled.title = "You cannot disable your current account";
|
||
enabledLabel.append(enabled, " Enabled");
|
||
const isOnlyAdmin = user.enabled && hasAuthRole(user.roles, "administrator") && enabledAdminCount === 1;
|
||
const administratorInput = roleInputs.find((item) => item.value === "administrator")?.input;
|
||
const guestInput = roleInputs.find((item) => item.value === "guest")?.input;
|
||
if (isOnlyAdmin && administratorInput) {
|
||
administratorInput.disabled = true;
|
||
administratorInput.title = "The final administrator cannot be demoted";
|
||
}
|
||
if (isOnlyAdmin && guestInput) {
|
||
guestInput.disabled = true;
|
||
guestInput.title = "The final administrator cannot become a Guest";
|
||
}
|
||
if (isOnlyAdmin) {
|
||
enabled.disabled = true;
|
||
enabled.title = "The final enabled administrator cannot be disabled";
|
||
}
|
||
const password = document.createElement("input");
|
||
password.type = "password";
|
||
password.placeholder = "New password (8+ characters)";
|
||
password.autocomplete = "new-password";
|
||
password.className = "auth-input";
|
||
password.minLength = 8;
|
||
password.maxLength = 1024;
|
||
const save = document.createElement("button");
|
||
save.type = "button";
|
||
save.textContent = "Save";
|
||
save.addEventListener("click", async () => {
|
||
const changes = {
|
||
roles: roleInputs.filter(({ input }) => input.checked).map(({ value }) => value),
|
||
enabled: enabled.checked
|
||
};
|
||
if (password.value) changes.password = password.value;
|
||
await runUserOperation(() => updateUser(user.username, changes));
|
||
});
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.textContent = "Remove";
|
||
remove.className = "danger";
|
||
remove.disabled = user.username === authUsername || isOnlyAdmin;
|
||
if (isOnlyAdmin) remove.title = "The final administrator cannot be removed";
|
||
remove.addEventListener("click", async () => {
|
||
if (await window.trxUi.confirm({ title: "Remove user?", message: `Remove ${user.username} and revoke their sessions?`, confirmLabel: "Remove", danger: true })) {
|
||
await runUserOperation(() => deleteUser(user.username));
|
||
}
|
||
});
|
||
row.append(name, enabledLabel, roles, password, save, remove);
|
||
return row;
|
||
}));
|
||
} catch (error) {
|
||
showUserManagementError(error);
|
||
}
|
||
}
|
||
function showUserManagementError(error) {
|
||
const element = document.getElementById("user-management-error");
|
||
if (!element) return;
|
||
element.textContent = error instanceof Error ? error.message : String(error);
|
||
element.style.display = "block";
|
||
}
|
||
async function runUserOperation(operation) {
|
||
try {
|
||
await operation();
|
||
const error = document.getElementById("user-management-error");
|
||
if (error) error.style.display = "none";
|
||
await refreshUserManagement();
|
||
} catch (reason) {
|
||
showUserManagementError(reason);
|
||
}
|
||
}
|
||
var createRoleContainer = document.getElementById("user-create-roles");
|
||
if (createRoleContainer) {
|
||
const { element } = buildRoleChoices(["read"]);
|
||
element.id = createRoleContainer.id;
|
||
createRoleContainer.replaceWith(element);
|
||
}
|
||
document.getElementById("user-create-form")?.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const username = requiredElement("user-create-username");
|
||
const password = requiredElement("user-create-password");
|
||
const enabled = requiredElement("user-create-enabled");
|
||
const roles = Array.from(document.querySelectorAll("#user-create-roles input[type=checkbox]"));
|
||
void runUserOperation(async () => {
|
||
await createUser(username.value, password.value, roles.filter((input) => input.checked).map((input) => input.value), enabled.checked);
|
||
username.value = "";
|
||
password.value = "";
|
||
enabled.checked = true;
|
||
roles.forEach((input) => {
|
||
input.checked = input.value === "read";
|
||
});
|
||
});
|
||
});
|
||
document.getElementById("account-password-form")?.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const form = event.currentTarget;
|
||
const currentPassword = requiredElement("account-current-password");
|
||
const newPassword = requiredElement("account-new-password");
|
||
const confirmPassword = requiredElement("account-confirm-password");
|
||
const error = requiredElement("account-password-error");
|
||
const submit = form.querySelector('button[type="submit"]');
|
||
if (newPassword.value !== confirmPassword.value) {
|
||
error.textContent = "New passwords do not match";
|
||
error.style.display = "block";
|
||
return;
|
||
}
|
||
if (submit) submit.disabled = true;
|
||
void changeOwnPassword(currentPassword.value, newPassword.value).then(async () => {
|
||
form.reset();
|
||
error.style.display = "none";
|
||
await authLogout();
|
||
showHint("Password changed. Sign in again.", 3e3);
|
||
}).catch((reason) => {
|
||
error.textContent = reason instanceof Error ? reason.message : String(reason);
|
||
error.style.display = "block";
|
||
}).finally(() => {
|
||
if (submit) submit.disabled = false;
|
||
});
|
||
});
|
||
requiredElement("auth-form").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const usernameEl = requiredElement("auth-username");
|
||
const passwordEl = requiredElement("auth-password");
|
||
const btn = requiredElement("auth-form").querySelector("button[type=submit]");
|
||
if (!btn) return;
|
||
btn.disabled = true;
|
||
btn.textContent = "Logging in...";
|
||
try {
|
||
const result = await authLogin(usernameEl.value, passwordEl.value);
|
||
setAuthRoles(result.roles);
|
||
authUsername = result.username ?? usernameEl.value;
|
||
passwordEl.value = "";
|
||
hideAuthGate();
|
||
updateAuthUI();
|
||
applyAuthRestrictions();
|
||
connect();
|
||
connectDecode();
|
||
initSettingsUI();
|
||
resizeHeaderSignalCanvas();
|
||
startHeaderSignalSampling();
|
||
} catch (err) {
|
||
showAuthError("Invalid username or password");
|
||
console.error("Login error:", err);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = "Login";
|
||
}
|
||
});
|
||
var headerAuthBtn = document.getElementById("header-auth-btn");
|
||
if (headerAuthBtn) {
|
||
headerAuthBtn.addEventListener("click", async () => {
|
||
if (authRoles.length > 0) {
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
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;
|
||
} },
|
||
authRoles: { get() {
|
||
return authRoles;
|
||
} },
|
||
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().then(() => {
|
||
if (settingsUiReady) initSettingsUI();
|
||
}).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();
|
||
});
|
||
}
|
||
});
|
||
}
|
||
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 sdrSquelchDbEl = document.getElementById("sdr-squelch-db");
|
||
var sdrSquelchStateEl = document.getElementById("sdr-squelch-state");
|
||
var sdrSquelchToggleBtn = document.getElementById("sdr-squelch-toggle");
|
||
var squelchLineEl = document.getElementById("spectrum-squelch-line");
|
||
var squelchGripEl = document.getElementById("spectrum-squelch-grip");
|
||
var squelchLabelEl = document.getElementById("spectrum-squelch-label");
|
||
var SDR_SQUELCH_MIN_DB = -120;
|
||
var SDR_SQUELCH_MAX_DB = -30;
|
||
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";
|
||
}
|
||
var sdrSquelchEnabled = loadSetting("sdrSquelchEnabled", false);
|
||
var sdrSquelchThresholdDb = clampSdrSquelchDb(Number(loadSetting("sdrSquelchThresholdDb", -95)));
|
||
function clampSdrSquelchDb(value) {
|
||
if (!isFiniteNumber(value)) return SDR_SQUELCH_MIN_DB;
|
||
return Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, Math.round(value)));
|
||
}
|
||
function sdrSquelchIsPassing() {
|
||
if (!sdrSquelchEnabled) return true;
|
||
if (!isFiniteNumber(sigLastDbm)) return false;
|
||
return sigLastDbm >= sdrSquelchThresholdDb;
|
||
}
|
||
function renderSdrSquelch() {
|
||
if (sdrSquelchDbEl && document.activeElement !== sdrSquelchDbEl) {
|
||
sdrSquelchDbEl.value = String(sdrSquelchThresholdDb);
|
||
}
|
||
if (sdrSquelchDbEl) sdrSquelchDbEl.disabled = !sdrSquelchSupported;
|
||
if (sdrSquelchToggleBtn) {
|
||
sdrSquelchToggleBtn.setAttribute("aria-pressed", String(sdrSquelchEnabled));
|
||
sdrSquelchToggleBtn.title = sdrSquelchEnabled ? "Turn the squelch off" : "Turn the squelch on";
|
||
}
|
||
const state = !sdrSquelchEnabled ? "off" : sdrSquelchIsPassing() ? "open" : "closed";
|
||
if (sdrSquelchStateEl) {
|
||
sdrSquelchStateEl.dataset.state = state;
|
||
sdrSquelchStateEl.parentElement?.setAttribute(
|
||
"aria-label",
|
||
state === "off" ? "Squelch off" : state === "open" ? "Squelch on, open" : "Squelch on, closed"
|
||
);
|
||
}
|
||
if (squelchLabelEl) squelchLabelEl.textContent = String(sdrSquelchThresholdDb);
|
||
if (squelchGripEl) squelchGripEl.setAttribute("aria-valuenow", String(sdrSquelchThresholdDb));
|
||
if (squelchLineEl) squelchLineEl.dataset.state = state === "open" ? "open" : "closed";
|
||
positionSquelchLine();
|
||
}
|
||
function positionSquelchLine() {
|
||
if (!squelchLineEl) return;
|
||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||
const canvas = document.getElementById("spectrum-canvas");
|
||
const visible = sdrSquelchSupported && sdrSquelchEnabled && mode !== "WFM" && !!canvas && canvas.clientHeight > 0 && getComputedStyle(requiredElement("spectrum-panel")).display !== "none";
|
||
if (!visible) {
|
||
squelchLineEl.style.display = "none";
|
||
return;
|
||
}
|
||
const dbMin = spectrumFloor;
|
||
const dbMax = spectrumFloor + spectrumRange;
|
||
const frac = Math.max(0, Math.min(1, (sdrSquelchThresholdDb - dbMin) / Math.max(1, dbMax - dbMin)));
|
||
squelchLineEl.style.display = "";
|
||
squelchLineEl.style.top = `${canvas.offsetTop + canvas.clientHeight * (1 - frac)}px`;
|
||
}
|
||
function submitSdrSquelch() {
|
||
if (!sdrSquelchSupported) return;
|
||
sdrSquelchLocalAt = Date.now();
|
||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
||
postPath(
|
||
`/set_sdr_squelch?enabled=${sdrSquelchEnabled ? "true" : "false"}&threshold_db=${encodeURIComponent(sdrSquelchThresholdDb.toFixed(2))}`
|
||
).catch(() => {
|
||
});
|
||
}
|
||
function setSdrSquelch(thresholdDb, enabled, options = {}) {
|
||
sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
||
sdrSquelchEnabled = enabled;
|
||
renderSdrSquelch();
|
||
if (options.submit !== false) submitSdrSquelch();
|
||
}
|
||
var SDR_SQUELCH_HOLD_MS = 2e3;
|
||
var sdrSquelchLocalAt = 0;
|
||
var SQUELCH_NOISE_WINDOW_MS = 1e4;
|
||
var SQUELCH_NOISE_MARGIN_DB = 5;
|
||
var SQUELCH_MEASURE_MS = 1500;
|
||
var squelchMeterSamples = [];
|
||
function recordSquelchMeterSample(db) {
|
||
if (!isFiniteNumber(db)) return;
|
||
const now = Date.now();
|
||
squelchMeterSamples.push({ t: now, v: db });
|
||
while (squelchMeterSamples[0] && now - squelchMeterSamples[0].t > SQUELCH_NOISE_WINDOW_MS) {
|
||
squelchMeterSamples.shift();
|
||
}
|
||
}
|
||
function squelchNoiseFloorDb() {
|
||
const now = Date.now();
|
||
const values = squelchMeterSamples.filter((sample) => now - sample.t <= SQUELCH_NOISE_WINDOW_MS).map((sample) => sample.v).sort((a, b) => a - b);
|
||
if (values.length < 4) return null;
|
||
return values[Math.floor((values.length - 1) * 0.2)] ?? null;
|
||
}
|
||
function autoSquelchThresholdDb() {
|
||
const noiseDb = squelchNoiseFloorDb();
|
||
if (noiseDb == null) return null;
|
||
return clampSdrSquelchDb(noiseDb + SQUELCH_NOISE_MARGIN_DB);
|
||
}
|
||
function updateSdrSquelchControlVisibility() {
|
||
if (!sdrSquelchWrapEl) return;
|
||
const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase();
|
||
sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none";
|
||
renderSdrSquelch();
|
||
}
|
||
function syncSdrSquelchFromServer(enabled, thresholdDb) {
|
||
if (squelchDragPointerId !== null) return;
|
||
if (sdrSquelchDbEl && document.activeElement === sdrSquelchDbEl) return;
|
||
if (Date.now() - sdrSquelchLocalAt < SDR_SQUELCH_HOLD_MS) return;
|
||
sdrSquelchEnabled = enabled;
|
||
if (isFiniteNumber(thresholdDb)) sdrSquelchThresholdDb = clampSdrSquelchDb(thresholdDb);
|
||
saveSetting("sdrSquelchEnabled", sdrSquelchEnabled);
|
||
saveSetting("sdrSquelchThresholdDb", sdrSquelchThresholdDb);
|
||
renderSdrSquelch();
|
||
}
|
||
var squelchDragPointerId = null;
|
||
var squelchDragSubmitAt = 0;
|
||
if (sdrSquelchDbEl) {
|
||
sdrSquelchDbEl.addEventListener("change", () => {
|
||
setSdrSquelch(Number(sdrSquelchDbEl.value), sdrSquelchEnabled);
|
||
});
|
||
sdrSquelchDbEl.addEventListener("blur", () => {
|
||
renderSdrSquelch();
|
||
});
|
||
}
|
||
if (sdrSquelchToggleBtn) {
|
||
sdrSquelchToggleBtn.addEventListener("click", () => {
|
||
if (!sdrSquelchSupported) return;
|
||
setSdrSquelch(sdrSquelchThresholdDb, !sdrSquelchEnabled);
|
||
});
|
||
}
|
||
function applyAutoSquelch(threshold) {
|
||
setSdrSquelch(threshold, true);
|
||
showHint(`Squelch ${threshold} dB`, 1500);
|
||
}
|
||
var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto");
|
||
if (sdrSquelchAutoBtn) {
|
||
sdrSquelchAutoBtn.addEventListener("click", () => {
|
||
if (!sdrSquelchSupported) return;
|
||
const threshold = autoSquelchThresholdDb();
|
||
if (threshold != null) {
|
||
applyAutoSquelch(threshold);
|
||
return;
|
||
}
|
||
sdrSquelchAutoBtn.disabled = true;
|
||
showHint("Measuring the noise…");
|
||
setTimeout(() => {
|
||
sdrSquelchAutoBtn.disabled = false;
|
||
const measured = autoSquelchThresholdDb();
|
||
if (measured == null) {
|
||
showHint("No meter to measure the noise from", 1800);
|
||
return;
|
||
}
|
||
applyAutoSquelch(measured);
|
||
}, SQUELCH_MEASURE_MS);
|
||
});
|
||
}
|
||
if (squelchGripEl) {
|
||
const dbFromClientY = (clientY) => {
|
||
const canvas = document.getElementById("spectrum-canvas");
|
||
if (!canvas || !canvas.clientHeight) return sdrSquelchThresholdDb;
|
||
const rect = canvas.getBoundingClientRect();
|
||
const frac = 1 - (clientY - rect.top) / rect.height;
|
||
return clampSdrSquelchDb(spectrumFloor + frac * spectrumRange);
|
||
};
|
||
squelchGripEl.addEventListener("pointerdown", (event) => {
|
||
if (!sdrSquelchSupported) return;
|
||
squelchDragPointerId = event.pointerId;
|
||
squelchGripEl.setPointerCapture(event.pointerId);
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
});
|
||
squelchGripEl.addEventListener("pointermove", (event) => {
|
||
if (squelchDragPointerId !== event.pointerId) return;
|
||
sdrSquelchThresholdDb = dbFromClientY(event.clientY);
|
||
renderSdrSquelch();
|
||
const now = Date.now();
|
||
if (now - squelchDragSubmitAt > 200) {
|
||
squelchDragSubmitAt = now;
|
||
submitSdrSquelch();
|
||
}
|
||
});
|
||
const endDrag = (event) => {
|
||
if (squelchDragPointerId !== event.pointerId) return;
|
||
squelchDragPointerId = null;
|
||
submitSdrSquelch();
|
||
};
|
||
squelchGripEl.addEventListener("pointerup", endDrag);
|
||
squelchGripEl.addEventListener("pointercancel", endDrag);
|
||
squelchGripEl.addEventListener("keydown", (event) => {
|
||
const step = event.shiftKey ? 10 : 1;
|
||
if (event.key === "ArrowUp" || event.key === "ArrowRight") {
|
||
setSdrSquelch(sdrSquelchThresholdDb + step, sdrSquelchEnabled);
|
||
} else if (event.key === "ArrowDown" || event.key === "ArrowLeft") {
|
||
setSdrSquelch(sdrSquelchThresholdDb - step, sdrSquelchEnabled);
|
||
} else {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
});
|
||
}
|
||
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 (sdrSquelchDbEl) {
|
||
sdrSquelchDbEl.addEventListener("wheel", (event) => {
|
||
event.preventDefault();
|
||
setSdrSquelch(sdrSquelchThresholdDb + (event.deltaY < 0 ? 1 : -1), sdrSquelchEnabled);
|
||
}, { 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;
|
||
}
|
||
}
|
||
var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
||
"lrpt_image",
|
||
"lrpt_progress",
|
||
"wefax",
|
||
"wefax_progress",
|
||
"sstv",
|
||
"sstv_progress"
|
||
]);
|
||
var pendingDecodeStats = [];
|
||
var PENDING_DECODE_STATS_MAX = 5e4;
|
||
function recordDecodeStat(kind, rig, tsMs) {
|
||
const stats = window.trx.modules.map;
|
||
if (stats) {
|
||
stats.statsRecordDecode(kind, rig, tsMs);
|
||
return;
|
||
}
|
||
pendingDecodeStats.push({ kind, rig, tsMs });
|
||
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||
}
|
||
}
|
||
function flushPendingDecodeStats() {
|
||
const stats = window.trx.modules.map;
|
||
if (!stats || pendingDecodeStats.length === 0) return;
|
||
for (const entry of pendingDecodeStats.splice(0)) {
|
||
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||
}
|
||
stats.scheduleStatsRender();
|
||
}
|
||
function scheduleStatsRenderIfLoaded() {
|
||
window.trx.modules.map?.scheduleStatsRender();
|
||
}
|
||
function dispatchDecodeMessage(msg, skipStats = false) {
|
||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||
scheduleStatsRenderIfLoaded();
|
||
}
|
||
}
|
||
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 (!IMAGE_DECODE_KINDS.has(kind)) {
|
||
for (const msg of messages) {
|
||
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||
}
|
||
scheduleStatsRenderIfLoaded();
|
||
}
|
||
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 historyRetried = false;
|
||
let historyBatchDrainScheduled = false;
|
||
let historyTotal = 0;
|
||
let historyProcessed = 0;
|
||
const historyGroupQueue = [];
|
||
const liveBuffer = [];
|
||
function releaseLiveBuffer() {
|
||
if (historySettled) return;
|
||
historySettled = true;
|
||
for (const msg of liveBuffer) {
|
||
try {
|
||
dispatchDecodeMessage(msg);
|
||
} catch (_) {
|
||
}
|
||
}
|
||
liveBuffer.length = 0;
|
||
}
|
||
function finishHistoryReplay() {
|
||
clearTimeout(historyTimeout);
|
||
releaseLiveBuffer();
|
||
terminateDecodeHistoryWorker();
|
||
setDecodeHistoryReplayActive(false);
|
||
setDecodeHistoryOverlayVisible(false);
|
||
}
|
||
function updateHistoryReplayOverlay() {
|
||
setDecodeHistoryOverlayVisible(
|
||
true,
|
||
"Loading decode history…",
|
||
`Replaying ${historyProcessed} / ${historyTotal} decoded messages`,
|
||
historyTotal > 0 ? historyProcessed / historyTotal : null
|
||
);
|
||
}
|
||
function maybeFinishHistoryReplay() {
|
||
if (historyWorkerDone && historyGroupQueue.length === 0) finishHistoryReplay();
|
||
}
|
||
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) => {
|
||
const total = totalDecodeHistoryMessages(groups);
|
||
if (total > 0) {
|
||
enqueueDecodeHistoryGroups(groups);
|
||
} else {
|
||
finishHistoryReplay();
|
||
}
|
||
}, (err) => {
|
||
console.error("Decode history fallback failed", err);
|
||
if (historyRetried) {
|
||
showHint("Decode history unavailable", 3e3);
|
||
finishHistoryReplay();
|
||
return;
|
||
}
|
||
historyRetried = true;
|
||
historyFallbackStarted = false;
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Retrying");
|
||
setTimeout(() => {
|
||
startDecodeHistoryFallback();
|
||
}, 2e3);
|
||
});
|
||
}
|
||
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) return;
|
||
releaseLiveBuffer();
|
||
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Still loading — live decodes are showing");
|
||
}, 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) releaseLiveBuffer();
|
||
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;
|
||
recordSquelchMeterSample(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);
|
||
positionSquelchLine();
|
||
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) {
|
||
if (sdrSquelchEnabled) {
|
||
setSdrSquelch(sdrSquelchThresholdDb, false);
|
||
showHint("Squelch off", 1200);
|
||
} else {
|
||
const threshold = sdrSquelchThresholdDb > SDR_SQUELCH_MIN_DB ? sdrSquelchThresholdDb : autoSquelchThresholdDb() ?? SDR_SQUELCH_MIN_DB + 25;
|
||
setSdrSquelch(threshold, true);
|
||
showHint(`Squelch ${clampSdrSquelchDb(threshold)} dB`, 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" : "";
|
||
});
|
||
}
|