// 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 = `

Confirm action

`; document.body.appendChild(dialog); } } api.notify = function notify(message, options = {}) { ensureLiveRegions(); const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options; const toast = document.createElement("div"); toast.className = `toast toast-${kind}`; toast.setAttribute("role", kind === "error" ? "alert" : "status"); const text = document.createElement("span"); text.textContent = message; toast.appendChild(text); if (action && typeof action.run === "function") { const button = document.createElement("button"); button.type = "button"; button.textContent = action.label || "Retry"; button.addEventListener("click", () => { action.run(); toast.remove(); }); toast.appendChild(button); } elementById("toast-region").appendChild(toast); requestAnimationFrame(() => { toast.classList.add("toast-visible"); }); if (duration > 0) setTimeout(() => { toast.remove(); }, duration); return toast; }; api.confirm = function confirmAction(options = {}) { ensureLiveRegions(); const dialog = elementById("ui-confirm-dialog"); elementById("ui-confirm-title").textContent = options.title || "Confirm action"; elementById("ui-confirm-message").textContent = options.message || "Continue?"; const confirmButton = dialog.querySelector('[value="confirm"]'); if (!confirmButton) throw new Error("Confirmation dialog has no confirm button"); confirmButton.textContent = options.confirmLabel || "Confirm"; confirmButton.classList.toggle("danger", options.danger !== false); return new Promise((resolve) => { const finish = () => { resolve(dialog.returnValue === "confirm"); }; dialog.addEventListener("close", finish, { once: true }); dialog.showModal(); }); }; api.setButtonState = function setButtonState(button, options = {}) { if (!button) return; const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options; button.classList.toggle("is-active", active); button.classList.toggle("is-busy", busy); button.setAttribute("aria-pressed", String(active)); button.setAttribute("aria-busy", String(busy)); button.disabled = disabled || busy; const label = active ? activeLabel : inactiveLabel; if (label) button.textContent = label; }; api.prepareTabList = function prepareTabList(bar, kind = "primary") { if (!bar) return; if (preparedTabLists.has(bar)) return; preparedTabLists.add(bar); const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]"; const buttons = Array.from(bar.querySelectorAll(selector)); bar.setAttribute("role", "tablist"); buttons.forEach((button, index) => { button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(button.classList.contains("active"))); button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1; const key = button.dataset.tab || button.dataset.subtab; if (!key) return; button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`); const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`); if (panel) { if (!button.id) button.id = `${kind}-tab-${key}`; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", button.id); } }); bar.addEventListener("keydown", (event) => { if (!(event.target instanceof HTMLElement) || !buttons.includes(event.target)) return; const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!direction) return; event.preventDefault(); const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length]; if (!next) return; next.focus(); next.click(); }); }; api.syncSelectedTab = function syncSelectedTab(bar, selected) { if (!bar) return; bar.querySelectorAll('[role="tab"]').forEach((tab) => { const active = tab === selected; tab.setAttribute("aria-selected", String(active)); tab.tabIndex = active ? 0 : -1; }); }; const layouts = { compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" }, broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" }, digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" }, full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" } }; const layoutCapabilities = { broadcast: false, digital: false }; let activeRigId = null; const layoutSections = [ { id: "advanced-radio-controls", key: "advanced" }, { id: "audio-controls", key: "audio" }, { id: "scheduler-controls", key: "scheduler" } ]; const seededSections = /* @__PURE__ */ new Set(); let appliedLayoutName = null; function seedLayoutSections(layout, layoutChanged) { layoutSections.forEach(({ id, key }) => { const section = document.getElementById(id); if (!section) return; if (!layoutChanged && seededSections.has(id)) return; seededSections.add(id); section.open = layout[key]; }); } function layoutStorageKey() { return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout"; } function savedLayoutName() { return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact"; } function layoutAvailable(layout) { return !layout.capability || layoutCapabilities[layout.capability]; } function unavailableLayoutMessage() { const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable); return unavailable.length ? `Unavailable: ${unavailable.map((layout) => layout.unavailable).join("; ")}.` : ""; } function refreshLayoutOptions() { const select = document.getElementById("operator-layout-select"); if (!select) return; const previous = select.value || document.body.dataset.operatorLayout || "compact"; select.replaceChildren(); Object.entries(layouts).forEach(([value, layout]) => { if (!layoutAvailable(layout)) return; select.add(new Option(layout.label, value)); }); const available = Array.from(select.options).some((option) => option.value === previous); select.value = available ? previous : "compact"; if (!available && previous !== "compact") api.applyLayout("compact", { persist: false }); select.title = unavailableLayoutMessage(); } api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) { Object.keys(layoutCapabilities).forEach((name) => { if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]); }); refreshLayoutOptions(); const select = document.getElementById("operator-layout-select"); const saved = savedLayoutName(); if (select && Array.from(select.options).some((option) => option.value === saved)) { select.value = saved; api.applyLayout(saved, { persist: false }); } }; api.setActiveRig = function setActiveRig(rigId) { activeRigId = typeof rigId === "string" && rigId ? rigId : null; const saved = savedLayoutName(); const select = document.getElementById("operator-layout-select"); if (select) select.value = Array.from(select.options).some((option) => option.value === saved) ? saved : "compact"; api.applyLayout(select?.value || saved, { persist: false }); }; api.applyLayout = function applyLayout(name, options = {}) { const requestedName = name in layouts ? name : "compact"; const requestedLayout = layouts[requestedName]; const permittedName = layoutAvailable(requestedLayout) ? requestedName : "compact"; const layout = layouts[permittedName]; document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact"; if (options.persist !== false) localStorage.setItem(layoutStorageKey(), permittedName); const layoutChanged = appliedLayoutName !== permittedName; appliedLayoutName = permittedName; seedLayoutSections(layout, layoutChanged); if (options.navigate && typeof browserWindow.navigateToTab === "function") { browserWindow.navigateToTab(layout.preferredTab); } }; function installLayoutControls() { const actions = document.querySelector(".top-bar-actions"); if (actions && !document.getElementById("operator-layout-select")) { const label = document.createElement("label"); label.className = "operator-layout-picker"; label.innerHTML = 'Operator layout'; 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 = 'Advanced radio controls
'; 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.appendChild(details); api.applyLayout(savedLayoutName(), { persist: false }); } } const overflowOrder = [".operator-layout-picker", ".header-style-pick", "#theme-toggle"]; 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)); }); document.addEventListener("click", (event) => { if (!(event.target instanceof Node) || !wrap.contains(event.target)) closeMenu(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMenu(); }); const barFits = () => { const bar = actions.closest(".tab-bar"); if (!bar) return true; const identity = bar.querySelector(".header-main"); const nav = bar.querySelector(".tab-bar-nav"); const gutters = 48; const needed = (identity?.offsetWidth ?? 0) + (nav?.scrollWidth ?? 0) + actions.scrollWidth + gutters; return needed <= bar.clientWidth; }; const reflowOverflow = () => { 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); } wrap.hidden = menu.children.length === 0; if (wrap.hidden) closeMenu(); }; reflowOverflow(); window.addEventListener("resize", reflowOverflow); } function installMobileMore() { const nav = document.querySelector(".tab-bar-nav"); if (!nav || document.getElementById("mobile-more-btn")) return; const more = document.createElement("button"); more.id = "mobile-more-btn"; more.className = "tab mobile-more-btn"; more.type = "button"; more.innerHTML = 'More'; more.setAttribute("aria-haspopup", "menu"); more.setAttribute("aria-expanded", "false"); const menu = document.createElement("div"); menu.id = "mobile-more-menu"; menu.className = "mobile-more-menu"; menu.setAttribute("role", "menu"); more.setAttribute("aria-controls", menu.id); const closeMore = (restoreFocus = false) => { if (!menu.classList.contains("is-open")) return; menu.classList.remove("is-open"); more.setAttribute("aria-expanded", "false"); if (restoreFocus) more.focus(); }; api.closeMobileOverlays = closeMore; ["statistics", "recorder", "settings", "about"].forEach((tabName) => { const source = nav.querySelector(`[data-tab="${tabName}"]`); if (!source) return; const item = document.createElement("button"); item.type = "button"; item.setAttribute("role", "menuitem"); item.dataset.navigateTab = tabName; item.textContent = source.textContent.trim(); item.addEventListener("click", () => { if (typeof browserWindow.navigateToTab === "function") browserWindow.navigateToTab(tabName); closeMore(); }); menu.appendChild(item); }); more.addEventListener("click", () => { const open = menu.classList.toggle("is-open"); more.setAttribute("aria-expanded", String(open)); if (open) menu.querySelector('[role="menuitem"]')?.focus(); }); document.addEventListener("click", (event) => { if (!(event.target instanceof Node) || !menu.contains(event.target) && !more.contains(event.target)) closeMore(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") closeMore(true); }); window.addEventListener("resize", () => { closeMore(); }); window.addEventListener("popstate", () => { closeMore(); }); nav.append(more, menu); } function installDecoderPicker() { const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar"); if (!bar || document.getElementById("decoder-tab-select")) return; const select = document.createElement("select"); select.id = "decoder-tab-select"; select.className = "decoder-tab-select"; select.setAttribute("aria-label", "Decoder view"); const groups = [ ["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]], ["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]] ]; groups.forEach(([label, ids]) => { const group = document.createElement("optgroup"); group.label = label; ids.forEach((id) => { const button = bar.querySelector(`[data-subtab="${id}"]`); if (button) group.appendChild(new Option(button.textContent.trim(), id)); }); select.appendChild(group); }); select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click()); bar.insertAdjacentElement("afterend", select); } function installDecoderBadges() { const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar"); if (!bar) return; bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => { const id = button.dataset.subtab; if (!id) return; if (id === "overview" || button.querySelector(".decoder-state-dot")) return; const dot = document.createElement("span"); dot.className = "decoder-state-dot"; dot.setAttribute("aria-hidden", "true"); button.appendChild(dot); const status = document.getElementById(`${id}-status`); if (!status) return; const sync = () => { const value = status.textContent.toLowerCase(); const state = /receiv|decod|connected|listening/.test(value) ? "active" : /error|fail|disconnected/.test(value) ? "error" : "idle"; dot.dataset.state = state; button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`; }; new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true }); sync(); }); } api.init = function init() { ensureLiveRegions(); installLayoutControls(); installTopBarOverflow(); installMobileMore(); installDecoderPicker(); installDecoderBadges(); api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary"); document.querySelectorAll(".sub-tab-bar").forEach((bar) => { api.prepareTabList(bar, "secondary"); }); window.addEventListener("unhandledrejection", (event) => { const message = event.reason instanceof Error ? event.reason.message : "An operation failed unexpectedly"; api.notify(message, { kind: "error" }); }); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", () => { api.init(); }, { once: true }); else api.init(); })(); // src/plugin-runtime.ts var decoders = /* @__PURE__ */ new Map(); var queued = /* @__PURE__ */ new Map(); var MAX_QUEUED_ACTIONS_PER_DECODER = 512; function enqueue(id, action) { const actions = queued.get(id) ?? []; actions.push(action); if (actions.length > MAX_QUEUED_ACTIONS_PER_DECODER) actions.splice(0, actions.length - MAX_QUEUED_ACTIONS_PER_DECODER); queued.set(id, actions); } function deliver(plugin, action) { if (action.kind === "message" && plugin.onMessage) { plugin.onMessage(action.payload); return true; } if (action.kind === "batch" && plugin.onBatch) { plugin.onBatch(action.payload); return true; } if (action.kind === "restore" && plugin.restore) { plugin.restore(action.payload); return true; } return false; } function dispatchOrQueue(id, action) { const plugin = decoders.get(id); if (!plugin) { enqueue(id, action); return false; } if (!deliver(plugin, action)) { if (action.kind === "batch" && plugin.onMessage) { for (const message of action.payload) plugin.onMessage(message); return true; } if (action.kind === "restore" && plugin.onBatch) { plugin.onBatch(action.payload); return true; } return false; } return true; } var runtime = { registerDecoder(plugin) { if (decoders.has(plugin.id)) throw new Error(`Decoder plugin already registered: ${plugin.id}`); const erased = plugin; decoders.set(plugin.id, erased); const pending = queued.get(plugin.id) ?? []; queued.delete(plugin.id); for (const action of pending) deliver(erased, action); return () => { if (decoders.get(plugin.id) === erased) decoders.delete(plugin.id); }; }, dispatch: (id, message) => dispatchOrQueue(id, { kind: "message", payload: message }), dispatchBatch: (id, messages) => dispatchOrQueue(id, { kind: "batch", payload: messages }), restore: (id, messages) => dispatchOrQueue(id, { kind: "restore", payload: messages }), reset(id) { const plugin = decoders.get(id); if (!plugin?.reset) return false; plugin.reset(); return true; }, resetAll() { for (const plugin of decoders.values()) plugin.reset?.(); }, prune(id) { const plugin = decoders.get(id); if (!plugin?.prune) return false; plugin.prune(); return true; }, clearQueued() { queued.clear(); }, hasDecoder: (id) => decoders.has(id) }; window.trxPluginRuntime = runtime; // src/leaflet-ais-tracksymbol.ts (function() { const leaflet = globalThis.L; if (!leaflet) return; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function finiteAngle(value) { if (value === null || !Number.isFinite(value)) return null; const normalized = (value % 360 + 360) % 360; return normalized; } function svgColor(value, fallback) { const text = value || fallback || ""; return text.replace(/"/g, """); } function buildSymbolHtml(options, zoom) { const heading = finiteAngle(options.heading); const course = finiteAngle(options.course); const angle = heading != null ? heading : course; const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0; const sizeBase = Number.isFinite(options.size) ? options.size : 22; const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; const size = clamp(sizeBase + zoomBoost, 16, 32); const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0; const color = svgColor(options.color, "#ff7559"); const outline = svgColor(options.outline, "#6b2118"); const body = angle != null ? `` : ``; const courseLine = course != null ? `` : ""; return ``; } leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({ options: { heading: null, course: null, speed: null, color: "#ff7559", outline: "#6b2118", size: 22, interactive: true, keyboard: true, riseOnHover: true }, initialize: function(latlng, options) { const merged = leaflet.Util.extend({}, this.options, options || {}); merged.icon = leaflet.divIcon({ className: "trx-ais-track-symbol-icon", html: "", iconSize: [merged.size, merged.size], iconAnchor: [merged.size / 2, merged.size / 2] }); leaflet.Marker.prototype.initialize.call(this, latlng, merged); }, onAdd: function(map) { leaflet.Marker.prototype.onAdd.call(this, map); this._refreshIcon(); this._boundZoomRefresh = this._refreshIcon.bind(this); map.on("zoomend", this._boundZoomRefresh); }, onRemove: function(map) { if (this._boundZoomRefresh) { map.off("zoomend", this._boundZoomRefresh); this._boundZoomRefresh = null; } leaflet.Marker.prototype.onRemove.call(this, map); }, setAisState: function(next) { if ("heading" in next) this.options.heading = next.heading; if ("course" in next) this.options.course = next.course; if ("speed" in next) this.options.speed = next.speed; if ("color" in next) this.options.color = next.color; if ("outline" in next) this.options.outline = next.outline; this._refreshIcon(); return this; }, _refreshIcon: function() { if (!this._icon) return; const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0; const html = buildSymbolHtml(this.options, zoom); this._icon.innerHTML = html; const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22; const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; const size = clamp(sizeBase + zoomBoost, 16, 32); this._icon.style.width = `${size}px`; this._icon.style.height = `${size}px`; this._icon.style.marginLeft = `${-size / 2}px`; this._icon.style.marginTop = `${-size / 2}px`; } }); leaflet.trxAisTrackSymbol = function(latlng, options) { const Constructor = leaflet.TrxAisTrackSymbol; if (!Constructor) throw new Error("AIS track symbol constructor is unavailable"); return new Constructor(latlng, options); }; })(); // src/core/geo.ts function haversineKm(lat1, lon1, lat2, lon2) { const radiusKm = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return radiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function locatorToLatLon(locator) { const raw = typeof locator === "string" ? locator.trim().toUpperCase() : ""; if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(raw)) return null; let lon = -180 + (raw.charCodeAt(0) - 65) * 20 + Number(raw.slice(2, 3)) * 2; let lat = -90 + (raw.charCodeAt(1) - 65) * 10 + Number(raw.slice(3, 4)); if (raw.length >= 6) { lon += (raw.charCodeAt(4) - 65) * (5 / 60) + 2.5 / 60; lat += (raw.charCodeAt(5) - 65) * (2.5 / 60) + 1.25 / 60; } else { lon += 1; lat += 0.5; } return { lat, lon }; } function formatDistanceKm(distanceKm) { if (!Number.isFinite(distanceKm)) return null; return distanceKm < 1 ? `${Math.round(distanceKm * 1e3)} m` : `${distanceKm.toFixed(1)} km`; } function formatTimeAgo(timestampMs) { if (!timestampMs) return null; const seconds = Math.round((Date.now() - timestampMs) / 1e3); if (seconds < 60) return `${seconds}s ago`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} min ago`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}min ago` : `${hours}h ago`; } function latLonToMaidenhead(lat, lon) { const adjustedLon = lon + 180; const adjustedLat = lat + 90; const upperA = "A".charCodeAt(0); const lowerA = "a".charCodeAt(0); const field1 = String.fromCharCode(upperA + Math.floor(adjustedLon / 20)); const field2 = String.fromCharCode(upperA + Math.floor(adjustedLat / 10)); const square1 = Math.floor(adjustedLon % 20 / 2); const square2 = Math.floor(adjustedLat % 10); const sub1 = String.fromCharCode(lowerA + Math.floor(adjustedLon % 2 * 12)); const sub2 = String.fromCharCode(lowerA + Math.floor(adjustedLat % 1 * 24)); return `${field1}${field2}${square1}${square2}${sub1}${sub2}`; } // src/core/dom.ts function escapeHtml(input) { return String(input).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); } // src/core/settings.ts var STORAGE_PREFIX = "trx_"; function saveSetting(key, value) { try { localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value)); } catch { } } function loadSetting(key, fallback) { try { const value = localStorage.getItem(`${STORAGE_PREFIX}${key}`); return value === null ? fallback : JSON.parse(value); } catch { return fallback; } } // src/core/decoder-registry.ts var bridge = window; var readyCallbacks = []; var decoderRegistry = []; function onDecoderRegistryReady(callback) { if (decoderRegistry.length > 0) callback(); else readyCallbacks.push(callback); } function applyDecoderRegistryVisibility() { const knownIds = new Set(decoderRegistry.map(({ id }) => id)); const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]); document.querySelectorAll( "#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]" ).forEach((button) => { const id = button.dataset.subtab; if (!id || alwaysShow.has(id) || knownIds.has(id)) return; button.style.display = "none"; const panel = document.getElementById(`subtab-${id}`); if (panel) panel.style.display = "none"; }); document.querySelectorAll('[id^="about-dec-"]').forEach((element) => { const id = element.id.replace("about-dec-", ""); if (alwaysShow.has(id) || knownIds.has(id)) return; const row = element.closest("tr"); if (row) row.style.display = "none"; }); document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach((element) => { const match = /^settings-clear-(.+)-history$/.exec(element.id); const id = match?.[1]; if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none"; }); document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach((element) => { const id = element.dataset.decoder; if (id && !alwaysShow.has(id) && !knownIds.has(id)) element.style.display = "none"; }); } function isDecoderDescriptor(value) { if (typeof value !== "object" || value === null) return false; const item = value; return typeof item.id === "string" && typeof item.label === "string" && typeof item.activation === "string" && Array.isArray(item.active_modes) && item.active_modes.every((mode) => typeof mode === "string") && typeof item.background_decode === "boolean" && typeof item.bookmark_selectable === "boolean"; } function decodeRegistry(value) { if (!Array.isArray(value) || !value.every(isDecoderDescriptor)) { throw new TypeError("The decoder registry response is malformed"); } return value; } async function loadDecoderRegistry(onLoaded) { try { const response = await fetch("/decoders"); if (!response.ok) return; decoderRegistry = decodeRegistry(await response.json()); bridge.decoderRegistry = decoderRegistry; readyCallbacks.splice(0).forEach((callback) => { callback(); }); applyDecoderRegistryVisibility(); onLoaded(); } catch (error) { console.error("Failed to fetch decoder registry:", error); } } bridge.decoderRegistry = decoderRegistry; bridge.onDecoderRegistryReady = onDecoderRegistryReady; // src/api/auth.ts function decodeAuthSession(value) { if (typeof value !== "object" || value === null) { throw new TypeError("The authentication response is malformed"); } const session = value; if (typeof session.authenticated !== "boolean") { throw new TypeError("The authentication response has no authenticated flag"); } if (session.role !== void 0 && session.role !== "rx" && session.role !== "control") { throw new TypeError("The authentication response has an invalid role"); } if (session.auth_disabled !== void 0 && typeof session.auth_disabled !== "boolean") { throw new TypeError("The authentication response has an invalid auth_disabled flag"); } const decoded = { authenticated: session.authenticated }; if (session.role !== void 0) decoded.role = session.role; if (session.auth_disabled !== void 0) decoded.auth_disabled = session.auth_disabled; return decoded; } var authDisabledSession = { authenticated: true, role: "control", auth_disabled: true }; async function fetchAuthSession() { try { const response = await fetch("/auth/session"); if (response.status === 404) return authDisabledSession; if (!response.ok) return { authenticated: false }; return decodeAuthSession(await response.json()); } catch (error) { console.error("Auth check failed:", error); return { authenticated: false }; } } async function login(passphrase) { const response = await fetch("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase }) }); if (response.status === 404) return authDisabledSession; if (!response.ok) { const message = await response.text(); throw new Error(message || "Login failed"); } return decodeAuthSession(await response.json()); } async function logout() { const response = await fetch("/auth/logout", { method: "POST" }); if (response.status !== 404 && !response.ok) throw new Error("Logout failed"); } // src/core/format.ts function formatDuration(milliseconds) { const seconds = Math.floor(milliseconds / 1e3); const days = Math.floor(seconds / 86400); const hours = Math.floor(seconds % 86400 / 3600); const minutes = Math.floor(seconds % 3600 / 60); const remainder = seconds % 60; const parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0 || days > 0) parts.push(`${hours}h`); parts.push(`${minutes}m`, `${remainder}s`); return parts.join(" "); } function formatFrequency(frequencyHz) { if (!Number.isFinite(frequencyHz)) return "--"; if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`; if (frequencyHz >= 1e7) return `${(frequencyHz / 1e6).toFixed(3)} MHz`; return `${(frequencyHz / 1e3).toFixed(1)} kHz`; } function formatFrequencyForStep(frequencyHz, stepHz) { if (!Number.isFinite(frequencyHz)) return "--"; if (stepHz >= 1e6) return (frequencyHz / 1e6).toFixed(6); if (stepHz >= 1e3) return (frequencyHz / 1e3).toFixed(3); if (stepHz >= 1) return String(Math.round(frequencyHz)); return formatFrequency(frequencyHz); } function formatFrequencyForHumans(frequencyHz) { if (!Number.isFinite(frequencyHz)) return "--"; if (frequencyHz >= 1e9) return `${(frequencyHz / 1e9).toFixed(3)} GHz`; if (frequencyHz >= 1e6) return `${(frequencyHz / 1e6).toFixed(3)} MHz`; if (frequencyHz >= 1e3) return `${(frequencyHz / 1e3).toFixed(3)} kHz`; return `${Math.round(frequencyHz)} Hz`; } function formatWavelength(frequencyHz) { if (!Number.isFinite(frequencyHz) || frequencyHz <= 0) return "--"; const meters = 299792458 / frequencyHz; return meters >= 1 ? `${Math.round(meters)} m` : `${Math.round(meters * 100)} cm`; } function parseFrequencyInput(value, defaultStepHz, mode) { if (!value) return null; const match = /^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/.exec(value.trim().toLowerCase()); if (!match?.[1]) return null; const rawNumber = match[1]; let frequency = Number.parseFloat(rawNumber.replace(",", ".")); const unit = match[2] ?? ""; if (Number.isNaN(frequency)) return null; if (unit.startsWith("gh") || unit === "g") frequency *= 1e9; else if (unit.startsWith("mh") || unit === "m") frequency *= 1e6; else if (unit.startsWith("kh") || unit === "k") frequency *= 1e3; else if (!unit) { const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); if (mode.toUpperCase() === "WFM") { if (hasDecimalSeparator && frequency >= 50 && frequency < 200) { return Math.round(frequency * 1e6); } if (!hasDecimalSeparator && frequency >= 875 && frequency <= 1080) { return Math.round(frequency / 10 * 1e6); } } if (defaultStepHz >= 1e6) frequency *= 1e6; else if (defaultStepHz >= 1e3) frequency *= 1e3; else if (defaultStepHz < 1) { if (frequency < 1e3) frequency *= 1e6; else if (frequency < 1e6) frequency *= 1e3; } } return Math.round(frequency); } function formatByteSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(1)} MB`; } // src/core/cbor.ts var textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; function decodeUint(view, bytes, state, additional) { const offset = state.offset; if (additional < 24) return additional; const widths = { 24: 1, 25: 2, 26: 4, 27: 8 }; const width = widths[additional]; if (width === void 0) throw new Error("Unsupported CBOR additional info"); if (offset + width > bytes.length) throw new Error("CBOR payload truncated"); state.offset += width; if (additional === 24) return bytes[offset] ?? 0; if (additional === 25) return view.getUint16(offset); if (additional === 26) return view.getUint32(offset); const numeric = Number(view.getBigUint64(offset)); if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); return numeric; } function decodeFloat16(bits) { const sign = bits & 32768 ? -1 : 1; const exponent = bits >> 10 & 31; const fraction = bits & 1023; if (exponent === 0) return fraction === 0 ? sign * 0 : sign * 2 ** -14 * (fraction / 1024); if (exponent === 31) return fraction === 0 ? sign * Infinity : Number.NaN; return sign * 2 ** (exponent - 15) * (1 + fraction / 1024); } function decodeItem(view, bytes, state) { if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); const initial = bytes[state.offset++]; if (initial === void 0) throw new Error("CBOR payload truncated"); const major = initial >> 5; const additional = initial & 31; if (major === 0) return decodeUint(view, bytes, state, additional); if (major === 1) return -1 - decodeUint(view, bytes, state, additional); if (major === 2 || major === 3) { const length = decodeUint(view, bytes, state, additional); if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); const chunk = bytes.subarray(state.offset, state.offset + length); state.offset += length; if (major === 2) return Array.from(chunk); return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); } if (major === 4) { const length = decodeUint(view, bytes, state, additional); return Array.from({ length }, () => decodeItem(view, bytes, state)); } if (major === 5) { const length = decodeUint(view, bytes, state, additional); const value = {}; for (let index = 0; index < length; index += 1) { const key = decodeItem(view, bytes, state); if (typeof key !== "string" && typeof key !== "number" && typeof key !== "boolean" && key !== null) { throw new Error("Unsupported composite CBOR map key"); } value[String(key)] = decodeItem(view, bytes, state); } return value; } if (major === 6) { decodeUint(view, bytes, state, additional); return decodeItem(view, bytes, state); } if (major === 7) { if (additional === 20) return false; if (additional === 21) return true; if (additional === 22) return null; if (additional === 23) return void 0; const widths = { 25: 2, 26: 4, 27: 8 }; const width = widths[additional]; if (width === void 0) throw new Error("Unsupported CBOR major type"); if (state.offset + width > bytes.length) throw new Error("CBOR payload truncated"); const offset = state.offset; state.offset += width; if (additional === 25) return decodeFloat16(view.getUint16(offset)); if (additional === 26) return view.getFloat32(offset); return view.getFloat64(offset); } throw new Error("Unsupported CBOR major type"); } function decodeCbor(buffer) { const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const state = { offset: 0 }; const value = decodeItem(view, bytes, state); if (state.offset !== bytes.length) throw new Error("Unexpected trailing bytes in CBOR payload"); return value; } // src/features/navigation/routes.ts var TAB_ORDER = [ "main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about" ]; var TAB_PATHS = { main: "/", bookmarks: "/bookmarks", "digital-modes": "/digital-modes", map: "/map", statistics: "/statistics", recorder: "/recorder", settings: "/settings", about: "/about" }; function normalizeTabPath(pathname) { const raw = pathname.length > 0 ? pathname : "/"; return raw === "/" ? "/" : raw.replace(/\/+$/, "") || "/"; } function tabFromPath(pathname) { const normalized = normalizeTabPath(pathname); const match = Object.entries(TAB_PATHS).find(([, path]) => path === normalized); return match ? match[0] : "main"; } function updateTabHistory(name, replace = false) { const targetPath = TAB_PATHS[name]; if (normalizeTabPath(window.location.pathname) === targetPath) return; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; if (replace) window.history.replaceState({}, "", nextUrl); else window.history.pushState({}, "", nextUrl); } // src/features/radio/auto-bandwidth.ts function clampPercent(value) { const numeric = Number(value) || 0; return Math.max(0, Math.min(100, numeric)) / 100; } function estimateOccupiedBandwidth(data, centerHz, mode, limits, interference = {}) { if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins) || !Number.isFinite(centerHz)) return null; const bins = Array.from(data.bins); if (bins.length < 3) return null; const maxIdx = bins.length - 1; const hzPerBin = data.sample_rate / maxIdx; const fullLoHz = data.center_hz - data.sample_rate / 2; const centerIdx = Math.max( 1, Math.min(maxIdx - 1, Math.round((centerHz - fullLoHz) / data.sample_rate * maxIdx)) ); const normalizedMode = mode.toUpperCase(); const [defaultBw, minBw, maxBw, stepBw] = limits; const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode) ? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0; const isWfm = normalizedMode === "WFM"; const smoothRadius = isWfm ? 3 : 1; const smoothed = bins.map((_, index) => { let sum = 0; let count = 0; for (let adjacent = Math.max(0, index - smoothRadius); adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) { sum += bins[adjacent] ?? 0; count += 1; } return sum / count; }); const sorted = [...bins].sort((left, right) => left - right); const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity; const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin)); const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins; const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins)); const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins)); let peak = -Infinity; for (let index = searchLo; index <= searchHi; index += 1) { peak = Math.max(peak, smoothed[index] ?? -Infinity); } const snr = peak - noise; if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw; const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28))); const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12e3 : stepBw) / hzPerBin)); const occupiedExtent = (direction, limitBins) => { let lastOccupied = centerIdx; let gap = 0; for (let offset = 0; offset <= limitBins; offset += 1) { const index = centerIdx + direction * offset; if (index <= 0 || index >= maxIdx) break; if ((smoothed[index] ?? -Infinity) >= threshold) { lastOccupied = index; gap = 0; } else if (++gap > allowedGap) break; } return Math.abs(lastOccupied - centerIdx) * hzPerBin; }; let rawBw = oneSided !== 0 ? occupiedExtent(oneSided, maxSpanBins) : 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins)); rawBw *= isWfm ? 1.08 : 1.12; if (isWfm) { const aci = clampPercent(interference.aci); const cci = clampPercent(interference.cci); const aciCap = maxBw - (maxBw - minBw) * aci; const cciFloor = minBw + (defaultBw - minBw) * 0.65; const cciCap = maxBw - (maxBw - cciFloor) * cci; rawBw = Math.min(rawBw, aciCap, cciCap); } const clamped = Math.max(minBw, Math.min(maxBw, rawBw)); return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw); } // src/features/spectrum/math.ts function isNumericBins(value) { return Array.isArray(value) ? value.every((item) => typeof item === "number") : ArrayBuffer.isView(value) && !(value instanceof DataView); } var base64Lookup = new Uint8Array(128).fill(255); var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; for (let index = 0; index < base64Alphabet.length; index += 1) { base64Lookup[base64Alphabet.charCodeAt(index)] = index; } var spectrumBinBuffer = new Int8Array(0); function decodeBase64Int8(value) { let end = value.length; while (end > 0 && value.charCodeAt(end - 1) === 61) end -= 1; const outputLength = end * 3 >>> 2; if (spectrumBinBuffer.length !== outputLength) spectrumBinBuffer = new Int8Array(outputLength); let outputIndex = 0; for (let index = 0; index < end; ) { const sextets = [0, 0, 0, 0]; for (let offset = 0; offset < 4 && index < end; offset += 1, index += 1) { const code = value.charCodeAt(index); const decoded = code < base64Lookup.length ? base64Lookup[code] : void 0; if (decoded === void 0 || decoded === 255) throw new TypeError("Invalid base64 spectrum frame"); sextets[offset] = decoded; } const packed = (sextets[0] ?? 0) << 18 | (sextets[1] ?? 0) << 12 | (sextets[2] ?? 0) << 6 | (sextets[3] ?? 0); if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 16 & 255; if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed >> 8 & 255; if (outputIndex < outputLength) spectrumBinBuffer[outputIndex++] = packed & 255; } return spectrumBinBuffer; } var nthScratch = new Float64Array(0); function nthElement(values, target) { if (values.length === 0 || target < 0 || target >= values.length) return null; if (nthScratch.length < values.length) nthScratch = new Float64Array(values.length); for (let index = 0; index < values.length; index += 1) nthScratch[index] = values[index] ?? 0; let low = 0; let high = values.length - 1; while (low < high) { const pivot = nthScratch[low + (high - low >> 1)] ?? 0; let left = low; let right = high; while (left <= right) { while ((nthScratch[left] ?? Infinity) < pivot) left += 1; while ((nthScratch[right] ?? -Infinity) > pivot) right -= 1; if (left <= right) { const temporary = nthScratch[left] ?? 0; nthScratch[left] = nthScratch[right] ?? 0; nthScratch[right] = temporary; left += 1; right -= 1; } } if (right < target) low = left; if (target < left) high = right; } return nthScratch[target] ?? null; } function estimateNoiseFloorDb(bins) { if (!isNumericBins(bins) || bins.length === 0) return null; return nthElement(bins, Math.floor(bins.length * 0.15)); } // src/plugin-loader.ts var pluginGroups = { "digital-modes": ["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/background-decode.js", "/sat.js", "/wefax.js"], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], statistics: ["/map-core.js"], bookmarks: ["/bookmarks.js"], recorder: [], settings: ["/vchan.js", "/scheduler.js"] }; var loaded = /* @__PURE__ */ new Set(); var loading = /* @__PURE__ */ new Map(); async function loadPlugin(path) { if (loaded.has(path)) return; const pending = loading.get(path); if (pending) return pending; const request = import(path).then(() => { loaded.add(path); loading.delete(path); }).catch((error) => { loading.delete(path); throw new Error(`Failed to load plugin module: ${path}`, { cause: error }); }); loading.set(path, request); return request; } async function loadPlugins(group) { if (!(group in pluginGroups)) return; for (const path of pluginGroups[group]) await loadPlugin(path); } async function loadEagerPlugins() { await Promise.all(["digital-modes", "bookmarks", "settings"].map(loadPlugins)); } async function loadPluginsForTab(tab) { await loadPlugins(tab); } // src/api/client.ts function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function isRigSnapshot(value) { if (!isRecord(value) || !isRecord(value.info) || !isRecord(value.status)) { return false; } const { info, status } = value; return typeof value.initialized === "boolean" && typeof info.manufacturer === "string" && typeof info.model === "string" && isRecord(status.freq) && typeof status.freq.hz === "number" && (typeof status.mode === "string" || isRecord(status.mode)) && typeof status.tx_en === "boolean"; } function isRigListResponse(value) { return isRecord(value) && (value.active_remote === null || typeof value.active_remote === "string") && Array.isArray(value.rigs) && value.rigs.every( (rig) => isRecord(rig) && typeof rig.remote === "string" && typeof rig.manufacturer === "string" && typeof rig.model === "string" && Array.isArray(rig.supported_modes) && typeof rig.tx === "boolean" && typeof rig.filter_controls === "boolean" && typeof rig.initialized === "boolean" ); } // src/app.ts function requiredElement(id) { const element = document.getElementById(id); if (!element) throw new Error(`Missing required application element #${id}`); return element; } function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } function primitiveString(value) { return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : ""; } function isRecord2(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseJsonUnknown(source) { return JSON.parse(source); } function messageEventData(event) { const data = event.data; if (typeof data !== "string") throw new TypeError("Expected a text event payload"); return data; } async function responseJsonUnknown(response) { return await response.json(); } function isAppUpdate(value) { return isRecord2(value) && typeof value.clients === "number" && typeof value.audio_clients === "number" && typeof value.rigctl_clients === "number" && Array.isArray(value.remotes) && value.remotes.every((remote) => typeof remote === "string") && typeof value.show_sdr_gain_control === "boolean" && typeof value.initial_map_zoom === "number" && typeof value.spectrum_coverage_margin_hz === "number" && typeof value.spectrum_usable_span_ratio === "number" && typeof value.bandplan_enabled === "boolean" && typeof value.bandplan_region === "string" && typeof value.decode_history_retention_min === "number" && typeof value.server_connected === "boolean" && isRigSnapshot(value); } function isAudioStreamInfo(value) { return isRecord2(value) && typeof value.sample_rate === "number" && typeof value.channels === "number"; } function isRecorderActiveList(value) { return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.rig_id === "string" && typeof entry.path === "string" && typeof entry.started_at === "number"); } function isRecorderFileList(value) { return Array.isArray(value) && value.every((entry) => isRecord2(entry) && typeof entry.name === "string" && typeof entry.size === "number"); } function isRdsData(value) { return isRecord2(value); } function isVchanRdsEntry(value) { return isRecord2(value) && typeof value.id === "string" && (value.rds === void 0 || value.rds === null || isRdsData(value.rds)) && (value.signal_db === void 0 || value.signal_db === null || typeof value.signal_db === "number"); } void loadDecoderRegistry(refreshOperatorLayoutCapabilities); var authRole = null; var authEnabled = true; async function checkAuthStatus() { return fetchAuthSession(); } async function authLogin(passphrase) { return login(passphrase); } async function authLogout() { try { await logout(); authRole = null; disconnect(); setDecodeHistoryOverlayVisible(false); requiredElement("content").style.display = "none"; requiredElement("loading").style.display = "none"; requiredElement("auth-passphrase").value = ""; updateAuthUI(); const authStatus = await checkAuthStatus(); const allowGuest = authStatus.role === "rx"; showAuthGate(allowGuest); } catch (e) { console.error("Logout failed:", e); showAuthError("Logout failed"); } } function showAuthGate(allowGuest = false) { if (!authEnabled) return; setDecodeHistoryOverlayVisible(false); requiredElement("loading").style.display = "none"; requiredElement("content").style.display = "none"; const authGate = requiredElement("auth-gate"); authGate.style.display = "flex"; authGate.style.flexDirection = "column"; authGate.style.justifyContent = "center"; authGate.style.alignItems = "stretch"; const signalVisualBlock = document.querySelector(".signal-visual-block"); if (signalVisualBlock) { signalVisualBlock.style.display = "none"; } document.querySelectorAll(".tab-panel").forEach((panel) => { panel.style.display = "none"; }); const guestBtn2 = document.getElementById("auth-guest-btn"); if (guestBtn2) { guestBtn2.style.display = allowGuest ? "block" : "none"; } document.querySelectorAll(".tab-bar .tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tab === "main"); }); syncTopBarAccess(); } function hideAuthGate() { const authGate = requiredElement("auth-gate"); authGate.style.display = "none"; requiredElement("loading").style.display = "block"; const signalVisualBlock = document.querySelector(".signal-visual-block"); if (signalVisualBlock) { signalVisualBlock.style.display = ""; } document.querySelectorAll(".tab-panel").forEach((panel) => { panel.style.display = "none"; }); document.querySelectorAll(".tab-bar .tab").forEach((btn) => { btn.classList.remove("active"); }); navigateToTab(tabFromPath2(), { updateHistory: false, replaceHistory: true }); syncTopBarAccess(); } function showAuthError(msg) { const el = requiredElement("auth-error"); el.textContent = msg; el.style.display = "block"; setTimeout(() => { el.style.display = "none"; }, 5e3); } function updateAuthUI() { const badge = document.getElementById("auth-badge"); const badgeRole = document.getElementById("auth-role-badge"); const headerAuthBtn2 = document.getElementById("header-auth-btn"); if (!authEnabled) { if (badge) badge.style.display = "none"; if (headerAuthBtn2) headerAuthBtn2.style.display = "none"; syncTopBarAccess(); return; } if (authRole) { if (badge) badge.style.display = "block"; if (badgeRole) badgeRole.textContent = authRole === "control" ? "Control (full access)" : "RX (read-only)"; if (headerAuthBtn2) { headerAuthBtn2.textContent = "Logout"; headerAuthBtn2.style.display = "block"; } } else { if (badge) badge.style.display = "none"; if (headerAuthBtn2) { headerAuthBtn2.textContent = "Login"; headerAuthBtn2.style.display = "block"; } } syncTopBarAccess(); } function applyAuthRestrictions() { if (!authRole) return; if (authRole === "rx") { const pttBtn2 = document.getElementById("ptt-btn"); const powerBtn2 = document.getElementById("power-btn"); const lockBtn2 = document.getElementById("lock-btn"); const freqInput = document.getElementById("freq"); const centerFreqInput = document.getElementById("center-freq"); const modeSelect = document.getElementById("mode"); const txLimitInput2 = document.getElementById("tx-limit"); const txLimitBtn2 = document.getElementById("tx-limit-btn"); const txAudioBtn2 = document.getElementById("tx-audio-btn"); const txLimitRow2 = document.getElementById("tx-limit-row"); const jogUp = document.getElementById("jog-up"); const jogDown = document.getElementById("jog-down"); const jogButtons = document.querySelectorAll(".jog-step button"); const vfoButtons = document.querySelectorAll("#vfo-picker button"); if (pttBtn2) pttBtn2.disabled = true; if (powerBtn2) powerBtn2.disabled = true; if (lockBtn2) lockBtn2.disabled = true; if (txAudioBtn2) txAudioBtn2.disabled = true; if (txLimitBtn2) txLimitBtn2.disabled = true; if (freqInput) freqInput.disabled = true; if (centerFreqInput) centerFreqInput.disabled = true; if (modeSelect) modeSelect.disabled = true; if (txLimitInput2) txLimitInput2.disabled = true; vfoButtons.forEach((btn) => btn.disabled = true); const jogWheel2 = document.getElementById("jog-wheel"); if (jogUp) jogUp.disabled = true; if (jogDown) jogDown.disabled = true; if (jogWheel2) jogWheel2.style.opacity = "0.5"; jogButtons.forEach((btn) => btn.disabled = true); const pluginToggleBtns = [ "ft8-decode-toggle-btn", "ft4-decode-toggle-btn", "ft2-decode-toggle-btn", "wspr-decode-toggle-btn", "lrpt-decode-toggle-btn", "hf-aprs-decode-toggle-btn", "cw-auto", "settings-clear-ais-history", "settings-clear-vdes-history", "settings-clear-aprs-history", "settings-clear-hf-aprs-history", "settings-clear-cw-history", "settings-clear-ft8-history", "settings-clear-ft4-history", "settings-clear-ft2-history", "settings-clear-wspr-history", "settings-clear-sat-history", "header-rec-btn", "recorder-start-btn", "recorder-stop-btn" ]; pluginToggleBtns.forEach((id) => { const btn = document.getElementById(id); if (btn instanceof HTMLButtonElement) { btn.disabled = true; } else if (btn instanceof HTMLInputElement && btn.type === "checkbox") { btn.disabled = true; } }); if (txLimitRow2) txLimitRow2.style.opacity = "0.5"; } } function applyCapabilities(caps) { if (!caps) return; lastHasTx = !!caps.tx; if (signalVisualBlockEl) signalVisualBlockEl.style.display = ""; const pttBtn2 = document.getElementById("ptt-btn"); const txPowerCol = document.getElementById("tx-power-col"); const txMetersRow = document.getElementById("tx-meters"); const txAudioBtn2 = document.getElementById("tx-audio-btn"); const txVolSlider2 = document.getElementById("tx-vol"); const txVolControl = txVolSlider2 ? txVolSlider2.closest(".vol-label") : null; const hasPowerControl = !caps.filter_controls; if (txPowerCol) { txPowerCol.style.display = caps.tx || hasPowerControl || caps.lockable ? "" : "none"; const label = txPowerCol.querySelector(".label span"); if (label) { label.textContent = caps.tx && hasPowerControl ? "Transmit / Power" : caps.tx ? "Transmit / Tuning" : hasPowerControl ? "Power / Tuning" : "Tuning"; } } if (pttBtn2) pttBtn2.style.display = caps.tx ? "" : "none"; if (powerBtn) powerBtn.style.display = hasPowerControl ? "" : "none"; if (lockBtn) lockBtn.style.display = caps.lockable ? "" : "none"; if (txMetersRow) txMetersRow.style.display = caps.tx ? "" : "none"; if (txAudioBtn2) txAudioBtn2.style.display = caps.tx ? "" : "none"; if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; if (!caps.tx && typeof stopTxAudio === "function" && txActive) { void stopTxAudio(); } const txLimitRow2 = document.getElementById("tx-limit-row"); if (txLimitRow2) txLimitRow2.style.display = caps.tx_limit ? "" : "none"; const vfoRow = document.getElementById("vfo-row"); if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none"; document.querySelectorAll(".full-row.label-below-row").forEach((row) => { const label = row.querySelector(".label span"); if (label && label.textContent === "Signal") { row.style.display = caps.signal_meter && !caps.filter_controls ? "" : "none"; } }); const spectrumPanel = document.getElementById("spectrum-panel"); const centerFreqField = document.getElementById("center-freq-field"); if (spectrumPanel) { if (caps.filter_controls) { spectrumPanel.style.display = ""; setSignalSplitControlVisible(true); if (centerFreqField) centerFreqField.style.display = ""; startSpectrumStreaming(); } else { spectrumPanel.style.display = "none"; setSignalSplitControlVisible(false); if (centerFreqField) centerFreqField.style.display = "none"; stopSpectrumStreaming(); resizeHeaderSignalCanvas(); scheduleOverviewDraw(); } scheduleSpectrumLayout(); } if (!caps.filter_controls) { sdrSquelchSupported = false; } updateSdrSquelchControlVisibility(); window.trx?.modules?.vchan?.applyCapabilities(caps); } var freqEl = requiredElement("freq"); var centerFreqEl = requiredElement("center-freq"); var wavelengthEl = requiredElement("wavelength"); var sigStrengthEl = requiredElement("sig-strength"); var modeEl = requiredElement("mode"); var bandLabel = document.getElementById("band-label"); var powerBtn = requiredElement("power-btn"); var powerHint = requiredElement("power-hint"); var vfoPicker = requiredElement("vfo-picker"); var signalBar = requiredElement("signal-bar"); var signalValue = requiredElement("signal-value"); var pttBtn = requiredElement("ptt-btn"); var txLimitInput = requiredElement("tx-limit"); var txLimitBtn = requiredElement("tx-limit-btn"); var txLimitRow = requiredElement("tx-limit-row"); var lockBtn = requiredElement("lock-btn"); var txMeters = requiredElement("tx-meters"); var pwrBar = requiredElement("pwr-bar"); var pwrValue = requiredElement("pwr-value"); var swrBar = requiredElement("swr-bar"); var swrValue = requiredElement("swr-value"); var loadingEl = requiredElement("loading"); var contentEl = requiredElement("content"); var serverSubtitle = requiredElement("server-subtitle"); var rigSubtitle = requiredElement("rig-subtitle"); var ownerSubtitle = document.getElementById("owner-subtitle"); var locationSubtitle = requiredElement("location-subtitle"); var loadingTitle = requiredElement("loading-title"); var loadingSub = requiredElement("loading-sub"); var decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); var decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); var decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); var connLostOverlayEl = document.getElementById("conn-lost-overlay"); var connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); var connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); var overviewCanvas = requiredElement("overview-canvas"); var signalOverlayCanvas = requiredElement("signal-overlay-canvas"); var spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; var overviewGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) : null; var signalOverlayGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) : null; var signalVisualBlockEl = document.querySelector(".signal-visual-block"); var signalSplitControlEl = document.getElementById("signal-split-control"); var signalSplitSliderEl = document.getElementById("signal-split-slider"); var signalSplitValueEl = document.getElementById("signal-split-value"); var overviewPeakHoldEl = document.getElementById("overview-peak-hold"); var themeToggleBtn = document.getElementById("theme-toggle"); var headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); var headerStylePickSelect = document.getElementById("header-style-pick-select"); var rdsPsOverlay = document.getElementById("rds-ps-overlay"); var tabMainEl = document.getElementById("tab-main"); var aboutServerVerEl = null; var aboutServerBuildDateEl = null; var aboutServerAddrEl = null; var aboutServerCallEl = null; var aboutServerLocationEl = null; var aboutRigInfoEl = null; var aboutRigAccessEl = null; var aboutModesEl = null; var aboutVfosEl = null; var aboutActiveRigEl = null; var aboutAudioCodecEl = null; var aboutAudioSamplerateEl = null; var aboutAudioChannelsEl = null; var aboutAudioBitrateEl = null; var aboutAudioFrameEl = null; var aboutAudioRxEl = null; var aboutAudioStreamsEl = null; var aboutPskreporterEl = null; var aboutAprsIsEl = null; var aboutRigctlClientsEl = null; var aboutRigctlEndpointEl = null; var aboutClientsEl = null; var _aboutElsResolved = false; function _resolveAboutEls() { if (_aboutElsResolved) return; aboutServerVerEl = document.getElementById("about-server-ver"); if (!aboutServerVerEl) return; _aboutElsResolved = true; aboutServerBuildDateEl = document.getElementById("about-server-build-date"); aboutServerAddrEl = document.getElementById("about-server-addr"); aboutServerCallEl = document.getElementById("about-server-call"); aboutServerLocationEl = document.getElementById("about-server-location"); aboutRigInfoEl = document.getElementById("about-rig-info"); aboutRigAccessEl = document.getElementById("about-rig-access"); aboutModesEl = document.getElementById("about-modes"); aboutVfosEl = document.getElementById("about-vfos"); aboutActiveRigEl = document.getElementById("about-active-rig"); aboutAudioCodecEl = document.getElementById("about-audio-codec"); aboutAudioSamplerateEl = document.getElementById("about-audio-samplerate"); aboutAudioChannelsEl = document.getElementById("about-audio-channels"); aboutAudioBitrateEl = document.getElementById("about-audio-bitrate"); aboutAudioFrameEl = document.getElementById("about-audio-frame"); aboutAudioRxEl = document.getElementById("about-audio-rx"); aboutAudioStreamsEl = document.getElementById("about-audio-streams"); aboutPskreporterEl = document.getElementById("about-pskreporter"); aboutAprsIsEl = document.getElementById("about-aprs-is"); aboutRigctlClientsEl = document.getElementById("about-rigctl-clients"); aboutRigctlEndpointEl = document.getElementById("about-rigctl-endpoint"); aboutClientsEl = document.getElementById("about-clients"); } var cwAutoEl = document.getElementById("cw-auto"); var cwWpmEl = document.getElementById("cw-wpm"); var cwToneEl = document.getElementById("cw-tone"); var overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2e3)); var decodeHistoryRetentionMin = 24 * 60; var _decoderToggles = {}; function _ensureDecoderToggles() { if (decoderRegistry.length === 0) return; for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; const key = d.id.replace(/-/g, "_") + "_decode_enabled"; if (_decoderToggles[key]) continue; const el = document.getElementById(d.id + "-decode-toggle-btn"); if (el) _decoderToggles[key] = { el, last: null, label: d.label }; } } function syncDecoderToggle(entry, enabled, label) { if (!entry.el || entry.last === enabled) return; entry.last = enabled; entry.el.dataset.enabled = enabled ? "true" : "false"; entry.el.textContent = enabled ? `Disable ${label}` : `Enable ${label}`; entry.el.style.borderColor = enabled ? "#00d17f" : ""; entry.el.style.color = enabled ? "#00d17f" : ""; } var _aboutDecIds = [ "about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr", "about-dec-cw", "about-dec-aprs", "about-dec-lrpt" ]; var _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); function _resolveAboutDecEls() { if (_aboutDecEls[0]?.el) return; for (let i = 0; i < _aboutDecIds.length; i++) { const entry = _aboutDecEls[i]; const id = _aboutDecIds[i]; if (entry && id) entry.el = document.getElementById(id); } } function syncAboutDecoder(idx, enabled) { const entry = _aboutDecEls[idx]; if (!entry || !entry.el || entry.last === enabled) return; entry.last = enabled; entry.el.textContent = enabled ? "Active" : "Off"; entry.el.className = enabled ? "about-status-on" : "about-status-off"; } var primaryRds = null; var vchanRdsById = /* @__PURE__ */ new Map(); var vchanSignalDbById = /* @__PURE__ */ new Map(); var rdsOverlayEntries = []; function currentDecodeHistoryRetentionMs() { const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || 24 * 60)); return minutes * 60 * 1e3; } window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; window.applyDecodeHistoryRetention = function() { for (const decoder of ["aprs", "hf_aprs", "ais", "vdes", "ft8", "ft4", "ft2", "wspr", "wefax"]) { window.trxPluginRuntime.prune(decoder); } }; function syncTopBarAccess() { const loggedOut = authEnabled && !authRole; const tabBar = document.getElementById("tab-bar"); const rigSwitch = document.querySelector(".header-rig-switch"); if (tabBar) tabBar.style.display = ""; document.querySelectorAll(".tab-bar .tab").forEach((btn) => { const isMain = btn.dataset.tab === "main"; btn.style.display = !loggedOut || isMain ? "" : "none"; btn.disabled = false; }); if (rigSwitch) { rigSwitch.style.display = loggedOut ? "none" : ""; } if (headerRigSwitchSelect) { headerRigSwitchSelect.disabled = loggedOut || authRole === "rx" || lastRigIds.length === 0; } } var overviewDrawPending = false; function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") { if (!decodeHistoryOverlayEl) return; if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title; if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || ""; decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible); } function setConnLostOverlay(visible, title = "Connection lost", sub = "Retrying…", fullscreen = false) { if (!connLostOverlayEl) return; if (connLostOverlayTitleEl) connLostOverlayTitleEl.textContent = title; if (connLostOverlaySubEl) connLostOverlaySubEl.textContent = sub; connLostOverlayEl.classList.toggle("conn-lost-fullscreen", fullscreen); connLostOverlayEl.classList.toggle("is-hidden", !visible); } var decodeHistoryReplayActive = false; var decodeMapSyncPending = false; function markDecodeMapSyncPending() { decodeMapSyncPending = true; } function flushDeferredDecodeMapSync() { if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap) return; decodeMapSyncPending = false; scheduleUiFrameJob("decode-map-maintenance", () => { window.trx.modules.map?.pruneMapHistory(); }); } function setDecodeHistoryReplayActive(active) { decodeHistoryReplayActive = !!active; if (!decodeHistoryReplayActive) { flushDeferredDecodeMapSync(); } } function decodeHistoryMapRenderingDeferred() { return decodeHistoryReplayActive || !window.trx?.modules.map?.aprsMap; } var lastSpectrumData = null; window.lastSpectrumData = null; var lastTxEn = null; var lastHasTx = true; var lastRendered = null; var prevRenderData = {}; var hintTimer = null; var sigMeasuring = false; var sigLastSUnits = null; var sigLastDbm = null; var SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; var sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); function sigUnit(u) { return `${u}`; } function formatSigStrength(dbm) { if (dbm === null || !isFiniteNumber(dbm)) return "--"; const unit = SIG_STRENGTH_UNITS[sigStrengthUnitIdx] || "dBFS"; if (unit === "S") return formatSignal(dbmToSUnits(dbm)); if (unit === "dBm") return `${dbm.toFixed(1)} ${sigUnit("dBm")}`; if (unit === "dBf") { const dbf = dbm + 107; return `${dbf.toFixed(1)} ${sigUnit("dBf")}`; } const dbfs = Math.max(-140, Math.min(0, dbm)); return `${dbfs.toFixed(1)} ${sigUnit("dBFS")}`; } function refreshSigStrengthDisplay() { if (!sigStrengthEl) return; sigStrengthEl.innerHTML = formatSigStrength(sigLastDbm); } if (sigStrengthEl) { sigStrengthEl.addEventListener("click", () => { sigStrengthUnitIdx = (sigStrengthUnitIdx + 1) % SIG_STRENGTH_UNITS.length; saveSetting("sigStrengthUnit", sigStrengthUnitIdx); refreshSigStrengthDisplay(); }); } var sigMeasureTimer = null; var sigMeasureLastTickMs = 0; var sigMeasureAccumMs = 0; var sigMeasureWeighted = 0; var sigMeasurePeak = null; var lastFreqHz = null; window.lastFreqHz = null; var centerFreqDirty = false; var jogUnit = loadSetting("jogUnit", 1e3); var jogMult = loadSetting("jogMult", 1); var jogStep = Math.max(Math.round(jogUnit / jogMult), 1); var minFreqStepHz = 1; var lastModeName = ""; var lastWfmCci = 0; var lastWfmAci = 0; var VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; function vfoColor(idx) { if (idx < VFO_COLORS.length) return VFO_COLORS[idx] ?? VFO_COLORS[0] ?? "var(--accent-green)"; const hue = idx * 137 % 360; return `hsl(${hue}, 70%, 55%)`; } var jogAngle = 0; var lastClientCount = null; var lastLocked = false; var sdrSquelchSupported = false; var previousTuneState = null; function savePreviousTuneState() { previousTuneState = { freqHz: lastFreqHz, bandwidthHz: currentBandwidthHz, mode: modeEl ? modeEl.value : "", centerHz: lastSpectrumData ? Number(lastSpectrumData.center_hz) : null }; } async function restorePreviousTuneState() { if (!previousTuneState) { showHint("No previous state", 1500); return; } const saved = previousTuneState; savePreviousTuneState(); if (saved.mode && modeEl && modeEl.value !== saved.mode) { modeEl.value = saved.mode; 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"; } function rigBadgeColor(rigId) { const text = (rigId || "rx").toString(); let hash = 0; for (let i = 0; i < text.length; i++) { hash = hash * 33 + text.charCodeAt(i) >>> 0; } const hue = hash % 360; return `hsl(${hue}, 62%, 52%)`; } window.getDecodeRigMeta = function() { const rigId = lastActiveRigId || "local"; return { rigId, label: lastRigDisplayNames[rigId] || rigId, color: rigBadgeColor(rigId) }; }; function populateRigPicker(selectEl, rigIds, activeRigId, disabled) { if (!selectEl) return; const selectedBefore = selectEl.value; selectEl.replaceChildren(); rigIds.forEach((id) => { const opt = document.createElement("option"); opt.value = id; opt.textContent = lastRigDisplayNames[id] || id; selectEl.appendChild(opt); }); const preferred = typeof activeRigId === "string" && rigIds.includes(activeRigId) ? activeRigId : selectedBefore; if (preferred && rigIds.includes(preferred)) { selectEl.value = preferred; } selectEl.disabled = disabled; } function updateRigSubtitle(activeRigId) { if (!rigSubtitle) return; const name = activeRigId && lastRigDisplayNames[activeRigId] || activeRigId || "--"; rigSubtitle.textContent = `Rig: ${name}`; updateDocumentTitle(activeChannelRds()); } function applyRigList(activeRigId, rigIds, displayNames = {}) { if (!Array.isArray(rigIds)) return; const nextIds = rigIds.filter((id) => typeof id === "string" && id.length > 0); const prevKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); lastRigIds = nextIds; if (displayNames && typeof displayNames === "object") { lastRigDisplayNames = { ...displayNames }; } const aboutList = document.getElementById("about-rig-list"); if (aboutList) { aboutList.textContent = lastRigIds.length ? lastRigIds.join(", ") : "--"; } if (typeof activeRigId === "string" && activeRigId.length > 0) { if (!lastActiveRigId) { lastActiveRigId = activeRigId; } const aboutActive = document.getElementById("about-active-rig"); if (aboutActive) aboutActive.textContent = lastActiveRigId; } const nextKey = lastRigIds.join("\0") + "|" + (lastActiveRigId || ""); const rigListChanged = prevKey !== nextKey; const disableSwitch = lastRigIds.length === 0 || !authRole || authRole === "rx"; populateRigPicker(headerRigSwitchSelect, lastRigIds, lastActiveRigId, disableSwitch); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); if (rigListChanged) { window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); window.trx.modules.bookmarks?.populateScopePicker(); void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); } window.trx.modules.map?.updateMapRigFilter(); } async function refreshRigList() { try { const resp = await fetch("/rigs", { cache: "no-store" }); if (!resp.ok) return; const data = await responseJsonUnknown(resp); if (!isRigListResponse(data)) return; const rigs = data.rigs; const rigIds = rigs.map((r) => r.remote).filter(Boolean); const displayNames = {}; rigs.forEach((r) => { if (!r || !r.remote) return; if (typeof r.display_name === "string" && r.display_name.length > 0) { displayNames[r.remote] = r.display_name; } else { const mfg = (r.manufacturer || "").trim(); const mdl = (r.model || "").trim(); const hw = [mfg, mdl].filter(Boolean).join(" "); displayNames[r.remote] = hw || r.remote; } }); serverRigs = rigs; refreshOperatorLayoutCapabilities(); serverActiveRigId = data.active_remote; applyRigList(data.active_remote, rigIds, displayNames); window.trx.modules.map?.syncAprsReceiverMarker(); } catch (e) { } } function refreshOperatorLayoutCapabilities() { const rigModes = serverRigs.map( (rig) => Array.isArray(rig?.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : [] ); const decoderModes = new Set(decoderRegistry.flatMap( (decoder) => Array.isArray(decoder?.active_modes) ? decoder.active_modes.map(normalizeMode).filter(Boolean) : [] )); window.trxUi?.setLayoutCapabilities({ broadcast: rigModes.some((modes) => modes.includes("WFM")), digital: decoderModes.size > 0 && rigModes.some((modes) => modes.some((mode) => decoderModes.has(mode))) }); } function showHint(msg, duration) { powerHint.textContent = msg; if (hintTimer) clearTimeout(hintTimer); if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration); if (/failed|missing|unavailable|unknown|lost|required/i.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 = `${overlayTrafficFlagHtml("TP", rds?.traffic_program)}${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}`; return `${hasPs ? formatPsHtml(ps) : escapeHtml(mainText)}${escapeHtml(metaText)}${trafficFlags}`; } function collectRdsOverlayEntries() { const entries = []; const virtualChannels = window.trx.modules.vchan?.channels || []; if (virtualChannels.length > 0) { for (const ch of virtualChannels) { if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; if (!isFiniteNumber(ch.freq_hz)) continue; const rds = vchanRdsById.get(ch.id) || (virtualChannels[0]?.id === ch.id ? primaryRds : null); if (!rds) continue; entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds }); } } else if (lastModeName === "WFM" && primaryRds && lastFreqHz !== null && isFiniteNumber(lastFreqHz)) { entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); } return entries; } function renderRdsOverlays() { if (!rdsPsOverlay) return; if (!lastSpectrumData || !overviewCanvas) { rdsOverlayEntries = []; rdsPsOverlay.style.display = "none"; return; } const entries = collectRdsOverlayEntries(); rdsOverlayEntries = []; rdsPsOverlay.replaceChildren(); if (entries.length === 0) { rdsPsOverlay.style.display = "none"; return; } entries.forEach((entry) => { const html = buildRdsOverlayHtml(entry.rds); if (!html) return; const el = document.createElement("div"); el.className = "rds-ps-overlay-item"; el.dataset.freqHz = String(entry.freq_hz); el.innerHTML = html; el.addEventListener("click", (evt) => { evt.stopPropagation(); void copyRdsPsToClipboard(entry.rds, entry.freq_hz); }); el.addEventListener("mouseenter", () => { el.style.zIndex = String(entries.length + 10); }); el.addEventListener("mouseleave", () => { if (el.dataset.defaultZ) el.style.zIndex = el.dataset.defaultZ; }); rdsPsOverlay.appendChild(el); rdsOverlayEntries.push({ ...entry, el }); }); if (rdsOverlayEntries.length === 0) { rdsPsOverlay.style.display = "none"; return; } rdsPsOverlay.style.display = "block"; positionRdsOverlays(); } window.renderRdsOverlays = renderRdsOverlays; function positionRdsOverlays() { if (!rdsPsOverlay || !lastSpectrumData || !overviewCanvas || rdsOverlayEntries.length === 0) return; const width = overviewCanvas.clientWidth || overviewCanvas.width || 0; if (width <= 0) return; const range = spectrumVisibleRange(lastSpectrumData); if (!isFiniteNumber(range.visLoHz) || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) return; const sortedByFreq = [...rdsOverlayEntries].sort((a, b) => a.freq_hz - b.freq_hz); const freqZMap = new Map(sortedByFreq.map((e, i) => [e.id, i + 1])); rdsOverlayEntries.forEach((entry, idx) => { const el = entry.el; if (!el) return; if (!isFiniteNumber(entry.freq_hz)) { el.style.display = "none"; return; } el.style.display = ""; const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; const clamped = Math.max(0.06, Math.min(0.94, rel)); el.style.left = `${clamped * width}px`; el.style.top = "50%"; const z = String(freqZMap.get(entry.id) ?? idx + 1); el.style.zIndex = z; el.dataset.defaultZ = z; }); } function positionRdsPsOverlay() { positionRdsOverlays(); } function resetRdsDisplay() { updateRdsPsOverlay(primaryRds); } function resetDecoderStateOnRigSwitch() { primaryRds = null; vchanRdsById = /* @__PURE__ */ new Map(); resetRdsDisplay(); resetWfmStereoIndicator(); resetIntfBars(); lastSpectrumData = null; window.lastSpectrumData = null; lastSpectrumRenderData = null; const decoderIds = ["ais-status", "vdes-status", "aprs-status", "cw-status", "ft8-status", "wspr-status"]; decoderIds.forEach((id) => { const el = document.getElementById(id); if (el) el.textContent = "--"; }); } function resetWfmStereoIndicator() { if (!wfmStFlagEl) return; wfmStFlagEl.textContent = "MO"; wfmStFlagEl.classList.remove("wfm-st-flag-stereo"); wfmStFlagEl.classList.add("wfm-st-flag-mono"); } function updateIntfBar(fillEl, valEl, level) { if (!fillEl || !valEl) return; const v = Math.round(Math.min(Math.max(level, 0), 100)); valEl.textContent = String(v); fillEl.style.width = v + "%"; fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65); fillEl.classList.toggle("wfm-intf-high", v >= 65); if (v < 35) { fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high"); } } function resetIntfBars() { updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); } var _fastFreqMarker = document.getElementById("fast-freq-marker"); var _fastBwLeft = document.getElementById("fast-bw-left"); var _fastBwRight = document.getElementById("fast-bw-right"); function positionFastOverlay(freqHz, bwHz) { if (!lastSpectrumData || !signalVisualBlockEl) { if (_fastFreqMarker) _fastFreqMarker.style.display = "none"; if (_fastBwLeft) _fastBwLeft.style.display = "none"; if (_fastBwRight) _fastBwRight.style.display = "none"; return; } const cssW = signalVisualBlockEl.clientWidth; if (cssW <= 0) return; const range = spectrumVisibleRange(lastSpectrumData); const hzToFrac = (hz) => (hz - range.visLoHz) / range.visSpanHz; if (_fastFreqMarker && isFiniteNumber(freqHz)) { const frac = hzToFrac(freqHz); if (frac >= 0 && frac <= 1) { _fastFreqMarker.style.display = ""; _fastFreqMarker.style.transform = `translateX(${frac * cssW}px)`; } else { _fastFreqMarker.style.display = "none"; } } if (_fastBwLeft && _fastBwRight && isFiniteNumber(freqHz) && isFiniteNumber(bwHz) && bwHz > 0) { const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); let loHz, hiHz; if (side < 0) { loHz = freqHz - bwHz; hiHz = freqHz; } else if (side > 0) { loHz = freqHz; hiHz = freqHz + bwHz; } else { loHz = freqHz - bwHz / 2; hiHz = freqHz + bwHz / 2; } const lFrac = hzToFrac(loHz); const rFrac = hzToFrac(hiHz); const cFrac = hzToFrac(freqHz); if (lFrac < cFrac && cFrac >= 0 && lFrac <= 1) { const x = Math.max(0, lFrac) * cssW; const w = (Math.min(1, cFrac) - Math.max(0, lFrac)) * cssW; _fastBwLeft.style.display = ""; _fastBwLeft.style.transform = `translateX(${x}px)`; _fastBwLeft.style.width = `${w}px`; } else { _fastBwLeft.style.display = "none"; } if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) { const x = Math.max(0, cFrac) * cssW; const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW; _fastBwRight.style.display = ""; _fastBwRight.style.transform = `translateX(${x}px)`; _fastBwRight.style.width = `${w}px`; } else { _fastBwRight.style.display = "none"; } } } function applyLocalTunedFrequency(hz, forceDisplay = false) { if (!isFiniteNumber(hz)) return; const freqChanged = lastFreqHz !== hz; if (!freqChanged && !forceDisplay) return; if (freqChanged) { if (lastFreqHz != null) savePreviousTuneState(); primaryRds = null; resetRdsDisplay(); resetWfmStereoIndicator(); resetIntfBars(); } lastFreqHz = hz; window.lastFreqHz = lastFreqHz; updateDocumentTitle(activeChannelRds()); refreshWavelengthDisplay(lastFreqHz); if (forceDisplay) { freqDirty = false; } if (forceDisplay || !freqDirty) { refreshFreqDisplay(); } window.ft8BaseHz = lastFreqHz; if (window.updateFt8RfDisplay) { window.updateFt8RfDisplay(); } if (window.refreshCwTonePicker) { window.refreshCwTonePicker(); } positionFastOverlay(lastFreqHz, currentBandwidthHz); if (freqChanged && lastSpectrumData) { scheduleSpectrumDraw(); } if (freqChanged && !lastSpectrumData) { updateBandplanStrip(bandplanComputeRange()); } positionRdsPsOverlay(); } function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { const [, , maxBw] = mwDefaultsForMode(mode); return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz); } function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { if (!isFiniteNumber(freqHz)) return []; const modeUpper = String(mode || "").toUpperCase(); if (modeUpper === "AIS") { return [ { centerHz: freqHz, widthHz: currentBandwidthHz }, { centerHz: freqHz + 5e4, widthHz: currentBandwidthHz } ]; } return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; } function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { const modeUpper = String(mode || "").toUpperCase(); if (modeUpper === "LSB" || modeUpper === "CWR") return -1; if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; return 0; } function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { const centerHz = Number(spec?.centerHz); const widthHz = Math.max(0, isFiniteNumber(spec?.widthHz) ? Number(spec.widthHz) : 0); const side = sidebandDirectionForMode(mode); if (side < 0) { return { loHz: centerHz - widthHz, hiHz: centerHz, side }; } if (side > 0) { return { loHz: centerHz, hiHz: centerHz + widthHz, side }; } const halfBw = widthHz / 2; return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; } function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { if (!isFiniteNumber(freqHz)) return null; const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { const widthHz = Math.max( 0, isFiniteNumber(spec.widthHz) ? spec.widthHz : Math.max(0, isFiniteNumber(bandwidthHz) ? bandwidthHz : 0) ); return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); }); if (specs.length === 0) return null; const first = specs[0]; if (!first) return null; let loHz = first.loHz; let hiHz = first.hiHz; for (const spec of specs.slice(1)) { loHz = Math.min(loHz, spec.loHz); hiHz = Math.max(hiHz, spec.hiHz); } return { loHz, hiHz }; } function effectiveSpectrumCoverageSpanHz(sampleRateHz) { const sampleRate = Number(sampleRateHz); if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return 0; const ratio = isFiniteNumber(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; return sampleRate * Math.max(0.01, Math.min(1, ratio)); } function sweetSpotMinimumOffsetHz(bandwidthHz) { if (!isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) return 0; return bandwidthHz / 2; } function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz)) return false; const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) return true; return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; } function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { if (!isFiniteNumber(freqHz) || !isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { return null; } const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); if (!isFiniteNumber(minOffsetHz) || minOffsetHz <= 0) { const fallbackCenterHz = isFiniteNumber(preferredCenterHz) ? preferredCenterHz : freqHz; return alignFreqToRigStep(Math.round(Math.max(minCenterHz, Math.min(maxCenterHz, fallbackCenterHz)))); } const targetCentersHz = []; const lowerTargetHz = alignFreqToRigStep(Math.round(freqHz - minOffsetHz)); const upperTargetHz = alignFreqToRigStep(Math.round(freqHz + minOffsetHz)); if (lowerTargetHz >= minCenterHz && lowerTargetHz <= maxCenterHz) targetCentersHz.push(lowerTargetHz); if (upperTargetHz >= minCenterHz && upperTargetHz <= maxCenterHz && !targetCentersHz.some((value) => Math.abs(value - upperTargetHz) < 1)) { targetCentersHz.push(upperTargetHz); } if (!targetCentersHz.length) return null; if (isFiniteNumber(preferredCenterHz)) { let bestCenterHz = targetCentersHz[0]; if (bestCenterHz === void 0) return null; let bestDistance = Math.abs(bestCenterHz - preferredCenterHz); for (const targetCenterHz of targetCentersHz.slice(1)) { const distance = Math.abs(targetCenterHz - preferredCenterHz); if (distance < bestDistance) { bestDistance = distance; bestCenterHz = targetCenterHz; } } return bestCenterHz; } return targetCentersHz[0]; } function requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz = coverageGuardBandwidthHz()) { if (!data || !isFiniteNumber(freqHz)) return null; const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); const currentCenterHz = Number(data.center_hz); if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) { return null; } const halfSpanHz = sampleRate / 2; const span = coverageSpanForMode(freqHz, bandwidthHz); if (!span) return null; const requiredLoHz = span.loHz - spectrumCoverageMarginHz; const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; if (requiredHiHz - requiredLoHz >= sampleRate) { return alignFreqToRigStep(Math.round(freqHz)); } const currentLoHz = currentCenterHz - halfSpanHz; const currentHiHz = currentCenterHz + halfSpanHz; if (requiredLoHz >= currentLoHz && requiredHiHz <= currentHiHz) { return null; } let nextCenterHz = currentCenterHz; if (requiredLoHz < currentLoHz) { nextCenterHz = requiredLoHz + halfSpanHz; } if (requiredHiHz > currentHiHz) { nextCenterHz = requiredHiHz - halfSpanHz; } return alignFreqToRigStep(Math.round(nextCenterHz)); } function requiredCenterFreqForCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { return requiredCenterFreqForCoverageInFrame(lastSpectrumData, freqHz, bandwidthHz); } async function ensureTunedBandwidthCoverage(freqHz, bandwidthHz = coverageGuardBandwidthHz()) { const nextCenterHz = requiredCenterFreqForCoverage(freqHz, bandwidthHz); if (!isFiniteNumber(nextCenterHz)) return; if (lastSpectrumData && Math.abs(nextCenterHz - Number(lastSpectrumData.center_hz)) < 1) return; await postPath(`/set_center_freq?hz=${nextCenterHz}`); if (centerFreqEl && !centerFreqDirty) { centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit); } } var _freqOptimisticHz = null; var _freqOptimisticSeq = 0; function armOptimisticFrequency(freqHz) { if (!isFiniteNumber(freqHz)) return; _freqOptimisticSeq += 1; _freqOptimisticHz = Math.round(freqHz); } function setRigFrequency(freqHz) { const targetHz = Math.round(freqHz); if (!freqAllowed(targetHz)) { showUnsupportedFreqPopup(targetHz); throw new Error(`Unsupported frequency: ${targetHz}`); } if (window.trx?.modules.vchan?.interceptFrequency(targetHz)) return; void window.trx?.modules.vchan?.takeSchedulerControl(); const prevFreqHz = lastFreqHz; const seq = ++_freqOptimisticSeq; _freqOptimisticHz = targetHz; applyLocalTunedFrequency(targetHz); Promise.all([ postPath(`/set_freq?hz=${targetHz}`), ensureTunedBandwidthCoverage(targetHz) ]).catch((err) => { if (_freqOptimisticSeq === seq && prevFreqHz != null) { _freqOptimisticHz = null; applyLocalTunedFrequency(prevFreqHz, true); } console.warn("setRigFrequency failed:", err); }).finally(() => { if (_freqOptimisticSeq === seq) _freqOptimisticHz = null; }); } function spectrumBinIndexForHz(data, hz) { if (!data || !isNumericBins(data.bins) || data.bins.length < 2 || !isFiniteNumber(hz)) { return null; } const maxIdx = data.bins.length - 1; const fullLoHz = Number(data.center_hz) - Number(data.sample_rate) / 2; const idx = Math.round((hz - fullLoHz) / Number(data.sample_rate) * maxIdx); return Math.max(0, Math.min(maxIdx, idx)); } function spectrumPowerScore(db) { const value = isFiniteNumber(db) ? db : -160; const clamped = Math.max(-160, Math.min(40, value)); return 10 ** (clamped / 10); } function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { if (!data || !isNumericBins(data.bins) || data.bins.length < 16) { return null; } if (!isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { return null; } const bins = data.bins; const sampleRate = Number(data.sample_rate); const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); const currentCenterHz = Number(data.center_hz); if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(usableSpanHz) || usableSpanHz <= 0 || !isFiniteNumber(currentCenterHz)) { return null; } const halfUsableSpanHz = usableSpanHz / 2; const fullHalfSpanHz = sampleRate / 2; const span = coverageSpanForMode(freqHz, bandwidthHz); if (!span) return null; const requiredLoHz = span.loHz - spectrumCoverageMarginHz; const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; if (requiredHiHz - requiredLoHz >= usableSpanHz) { const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( freqHz, bandwidthHz, currentCenterHz - halfUsableSpanHz, currentCenterHz + halfUsableSpanHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) ); if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } const evalHalfSpanHz = Math.max(0, (sampleRate - usableSpanHz) / 2); const evalMinCenterHz = currentCenterHz - evalHalfSpanHz; const evalMaxCenterHz = currentCenterHz + evalHalfSpanHz; const fitMinCenterHz = requiredHiHz - halfUsableSpanHz; const fitMaxCenterHz = requiredLoHz + halfUsableSpanHz; const minCenterHz = Math.max(evalMinCenterHz, fitMinCenterHz); const maxCenterHz = Math.min(evalMaxCenterHz, fitMaxCenterHz); if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( freqHz, bandwidthHz, evalMinCenterHz, evalMaxCenterHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) ); if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } const maxIdx = bins.length - 1; const usableBins = Math.max(4, Math.min(maxIdx, Math.round(usableSpanHz / sampleRate * maxIdx))); const fullLoHz = currentCenterHz - fullHalfSpanHz; const startMinIdx = Math.max( 0, Math.min(maxIdx - usableBins, Math.round((minCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) ); const startMaxIdx = Math.max( startMinIdx, Math.min(maxIdx - usableBins, Math.round((maxCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) ); let bestStartIdx = null; let bestScore = Number.POSITIVE_INFINITY; const signalLoHz = span.loHz; const signalHiHz = span.hiHz; for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) { const endIdx = Math.min(maxIdx, startIdx + usableBins); const windowLoHz = fullLoHz + startIdx / maxIdx * sampleRate; const candidateCenterHz = windowLoHz + halfUsableSpanHz; if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { continue; } const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz ?? freqHz) ?? startIdx)); const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz ?? freqHz) ?? endIdx)); let score = 0; for (let i = startIdx; i <= endIdx; i++) { if (i >= signalLoIdx && i <= signalHiIdx) continue; score += spectrumPowerScore(bins[i] ?? -128); } const spanMidHz = (span.loHz + span.hiHz) / 2; const centeredOffsetHz = Math.abs(candidateCenterHz - spanMidHz); score *= 1 + centeredOffsetHz / Math.max(usableSpanHz, 1) * 0.08; if (score < bestScore) { bestScore = score; bestStartIdx = startIdx; } } if (!isFiniteNumber(bestScore) || bestStartIdx == null) { const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( freqHz, bandwidthHz, minCenterHz, maxCenterHz, requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) ); if (!isFiniteNumber(fallbackCenterHz)) return null; return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } const bestLoHz = fullLoHz + bestStartIdx / maxIdx * sampleRate; const bestCenterHz = bestLoHz + halfUsableSpanHz; return { centerHz: alignFreqToRigStep(Math.round(bestCenterHz)), score: bestScore }; } function sweetSpotCenterFreq(freqHz = lastFreqHz, bandwidthHz = currentBandwidthHz) { if (!isFiniteNumber(freqHz)) return null; const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); return candidate && isFiniteNumber(candidate.centerHz) ? candidate.centerHz : null; } function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { if (!data || !isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { return []; } const sampleRate = Number(data.sample_rate); const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); if (!isFiniteNumber(usableSpanHz) || usableSpanHz <= 0) return []; const halfUsableSpanHz = usableSpanHz / 2; const span = coverageSpanForMode(freqHz, bandwidthHz); if (!span) return []; const requiredLoHz = span.loHz - spectrumCoverageMarginHz; const requiredHiHz = span.hiHz + spectrumCoverageMarginHz; if (requiredHiHz - requiredLoHz >= usableSpanHz) { const probeCenters = []; const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); for (const centerHz of [freqHz - minOffsetHz, freqHz + minOffsetHz]) { const alignedHz = alignFreqToRigStep(Math.round(centerHz)); if (sweetSpotCenterHasRequiredOffset(alignedHz, freqHz, bandwidthHz) && !probeCenters.some((value) => Math.abs(value - alignedHz) < 1)) { probeCenters.push(alignedHz); } } return probeCenters; } const minCenterHz = requiredHiHz - halfUsableSpanHz; const maxCenterHz = requiredLoHz + halfUsableSpanHz; if (!isFiniteNumber(minCenterHz) || !isFiniteNumber(maxCenterHz) || minCenterHz > maxCenterHz) { return []; } const points = 5; const centers = []; for (let i = 0; i < points; i++) { const frac = i / (points - 1); const centerHz = alignFreqToRigStep(Math.round(minCenterHz + (maxCenterHz - minCenterHz) * frac)); if (sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - centerHz) < 1)) { centers.push(centerHz); } } const currentCenterHz = alignFreqToRigStep(Math.round(Number(data.center_hz))); if (isFiniteNumber(currentCenterHz) && sweetSpotCenterHasRequiredOffset(currentCenterHz, freqHz, bandwidthHz) && !centers.some((value) => Math.abs(value - currentCenterHz) < 1)) { centers.push(currentCenterHz); centers.sort((a, b) => a - b); } return centers; } async function applySweetSpotCenter() { if (sweetSpotScanInFlight) { showHint("Sweet-spot already scanning", 900); return; } if (!isFiniteNumber(lastFreqHz) || !lastSpectrumData) return; const originalCenterHz = Number(lastSpectrumData.center_hz); const probeCentersHz = sweetSpotProbeCenters(lastSpectrumData, lastFreqHz, currentBandwidthHz); let bestCandidate = sweetSpotCandidateForFrame(lastSpectrumData, lastFreqHz, currentBandwidthHz); if (!probeCentersHz.length && (!bestCandidate || !isFiniteNumber(bestCandidate.centerHz))) { showHint("Sweet-spot unavailable", 1100); return; } sweetSpotScanInFlight = true; try { showHint("Scanning sweet spot...", 1400); for (const probeCenterHz of probeCentersHz) { if (!isFiniteNumber(probeCenterHz)) continue; let probeFrame = lastSpectrumData; if (!probeFrame || Math.abs(Number(probeFrame.center_hz) - probeCenterHz) >= 1) { await postPath(`/set_center_freq?hz=${probeCenterHz}`); try { probeFrame = await waitForSpectrumFrame(probeCenterHz, 1400); } catch (_) { continue; } } const candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz); if (!candidate || !isFiniteNumber(candidate.centerHz)) continue; if (!bestCandidate || candidate.score < bestCandidate.score) { bestCandidate = candidate; } } const targetCenterHz = bestCandidate && isFiniteNumber(bestCandidate.centerHz) ? bestCandidate.centerHz : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); if (!isFiniteNumber(targetCenterHz)) { if (isFiniteNumber(originalCenterHz) && (!lastSpectrumData || Math.abs(Number(lastSpectrumData.center_hz) - originalCenterHz) >= 1)) { await postPath(`/set_center_freq?hz=${alignFreqToRigStep(Math.round(originalCenterHz))}`); } showHint("Sweet-spot unavailable", 1100); return; } if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) { await postPath(`/set_center_freq?hz=${targetCenterHz}`); } if (centerFreqEl && !centerFreqDirty) { centerFreqEl.value = formatFrequencyForStep(targetCenterHz, jogUnit); } if (isFiniteNumber(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { showHint("Already at sweet spot", 900); } else { showHint("Sweet-spot set", 1200); } } finally { sweetSpotScanInFlight = false; } } function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { if (!isFiniteNumber(centerHz) || !isFiniteNumber(freqHz) || !lastSpectrumData) return null; const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); if (!isFiniteNumber(sampleRate) || sampleRate <= 0) return null; const span = coverageSpanForMode(freqHz, bandwidthHz); if (!span) return null; const halfSpanHz = sampleRate / 2; const requiredLoOffset = freqHz - (span.loHz - spectrumCoverageMarginHz); const requiredHiOffset = span.hiHz + spectrumCoverageMarginHz - freqHz; if (requiredLoOffset + requiredHiOffset >= sampleRate) { return alignFreqToRigStep(Math.round(centerHz)); } const minFreqHz = centerHz - halfSpanHz + requiredLoOffset; const maxFreqHz = centerHz + halfSpanHz - requiredHiOffset; if (freqHz >= minFreqHz && freqHz <= maxFreqHz) { return null; } const clampedHz = Math.max(minFreqHz, Math.min(maxFreqHz, freqHz)); return alignFreqToRigStep(Math.round(clampedHz)); } var spectrumCenterPendingHz = null; async function shiftSpectrumCenter(direction) { if (!lastSpectrumData) return; const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); if (!isFiniteNumber(sampleRate) || sampleRate <= 0 || !isFiniteNumber(currentCenterHz)) return; const stepHz = Math.max(5e4, Math.round(sampleRate * 0.35)); const nextCenterHz = alignFreqToRigStep(Math.round(currentCenterHz + direction * stepHz)); spectrumCenterPendingHz = nextCenterHz; showHint("Shifting spectrum…", 900); await postPath(`/set_center_freq?hz=${nextCenterHz}`); if (centerFreqEl && !centerFreqDirty) { centerFreqEl.value = formatFrequencyForStep(nextCenterHz, jogUnit); } const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); if (isFiniteNumber(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { await postPath(`/set_freq?hz=${nextFreqHz}`); applyLocalTunedFrequency(nextFreqHz); } } function refreshCenterFreqDisplay() { if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; centerFreqEl.value = formatFrequencyForStep(lastSpectrumData.center_hz, jogUnit); } function normalizeMinFreqStep(cap) { const val = Number(cap && cap.min_freq_step_hz); if (!isFiniteNumber(val) || val < 1) return 1; return Math.round(val); } function alignFreqToRigStep(hz) { if (!isFiniteNumber(hz)) return hz; const step = Math.max(1, minFreqStepHz); return Math.round(hz / step) * step; } function updateJogStepSupport(cap) { const nextMinStep = normalizeMinFreqStep(cap); minFreqStepHz = nextMinStep; const stepRoot = document.getElementById("jog-step"); if (!stepRoot) return; const buttons = Array.from(stepRoot.querySelectorAll("button[data-step]")); if (buttons.length === 0) return; buttons.forEach((btn) => { const base = Number(btn.dataset.baseStep || btn.dataset.step); if (isFiniteNumber(base) && base > 0) { btn.dataset.baseStep = String(Math.round(base)); btn.dataset.step = String(Math.max(Math.round(base), minFreqStepHz)); } }); const steps = buttons.map((btn) => Number(btn.dataset.step)).filter((s) => isFiniteNumber(s) && s > 0); if (steps.length === 0) return; const current = Number(jogUnit); const firstStep = steps[0]; if (firstStep === void 0) return; const desired = isFiniteNumber(current) && current >= minFreqStepHz ? current : Math.max(firstStep, minFreqStepHz); jogUnit = steps.reduce((best, s) => Math.abs(s - desired) < Math.abs(best - desired) ? s : best, firstStep); jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); saveSetting("jogUnit", jogUnit); saveSetting("jogStep", jogStep); buttons.forEach((btn) => { btn.classList.toggle("active", Number(btn.dataset.step) === jogUnit); }); refreshFreqDisplay(); refreshCenterFreqDisplay(); } function normalizeMode(modeVal) { if (typeof modeVal === "string") return modeVal; if (isRecord2(modeVal)) { const entries = Object.entries(modeVal); if (entries.length > 0) { const firstEntry = entries[0]; if (!firstEntry) return ""; const [variant, value] = firstEntry; if (variant === "Other" && typeof value === "string") return value; return variant; } } return ""; } function updateSupportedBands(cap) { if (cap && Array.isArray(cap.supported_bands)) { supportedBands = cap.supported_bands.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number").map((b) => ({ low: b.low_hz, high: b.high_hz })); } else { supportedBands = []; } } function freqAllowed(hz) { if (!isFiniteNumber(hz)) return false; if (supportedBands.length === 0) return true; return supportedBands.some((b) => hz >= b.low && hz <= b.high); } function unsupportedBandSummary() { if (supportedBands.length === 0) return "No supported frequency ranges were reported by the rig."; const ranges = supportedBands.slice().sort((a, b) => a.low - b.low).map((b) => `${formatFrequencyForHumans(b.low)} to ${formatFrequencyForHumans(b.high)}`); return `Supported ranges: ${ranges.join(", ")}`; } function showUnsupportedFreqPopup(hz) { const message = `Unsupported frequency: ${formatFrequencyForHumans(hz)}. ${unsupportedBandSummary()}`; showHint("Out of supported range", 1800); const now = Date.now(); if (now - lastUnsupportedFreqPopupAt < 1200) return; lastUnsupportedFreqPopupAt = now; window.trxUi?.notify(message.replaceAll("\n", " "), { kind: "error", duration: 7e3 }); } function dbmToSUnits(dbm) { if (!isFiniteNumber(dbm)) return 0; const clampedDbm = Math.max(-140, Math.min(20, dbm)); if (clampedDbm <= -121) return 0; if (clampedDbm >= -73) return 9 + (clampedDbm + 73) / 10; return (clampedDbm + 121) / 6; } function formatSignal(sUnits) { if (!isFiniteNumber(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`; const overDb = Math.min(60, Math.round((sUnits - 9) * 10 / 10) * 10); return overDb === 0 ? `${sigUnit("S")}9` : `${sigUnit("S")}9+${overDb}${sigUnit("dB")}`; } function setDisabled(disabled) { [freqEl, centerFreqEl, modeEl, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => { if (el) el.disabled = disabled; }); } 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 = `${escapeHtml(label)}`; } else { titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; } } updateDocumentTitle(activeChannelRds()); } function displayLabelFromUrl(url) { try { const host = new URL(url).hostname.replace(/^www\./i, ""); return host || url; } catch (_e) { return url; } } window.buildAisVesselUrl = function(mmsi) { if (!aisVesselUrlBase || !isFiniteNumber(Number(mmsi))) return null; return `${aisVesselUrlBase}${String(mmsi)}`; }; function render(update) { if (!update) return; if (update.server_version) serverVersion = update.server_version; if (update.server_build_date) serverBuildDate = update.server_build_date; if (update.server_callsign) serverCallsign = update.server_callsign; if (typeof update.owner_callsign === "string" && update.owner_callsign.length > 0) { ownerCallsign = update.owner_callsign; } if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) { ownerWebsiteUrl = update.owner_website_url; } if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) { ownerWebsiteName = update.owner_website_name; } if (typeof update.ais_vessel_url_base === "string" && update.ais_vessel_url_base.length > 0) { aisVesselUrlBase = update.ais_vessel_url_base; } const prevLat = serverLat, prevLon = serverLon; if (update.server_latitude != null) serverLat = update.server_latitude; if (update.server_longitude != null) serverLon = update.server_longitude; if (locationSubtitle && isFiniteNumber(serverLat) && isFiniteNumber(serverLon) && (serverLat !== prevLat || serverLon !== prevLon || !locationSubtitle.textContent)) { const grid = latLonToMaidenhead(serverLat, serverLon); locationSubtitle.textContent = `Location: ${grid}`; locationSubtitle.style.display = ""; window.trx.modules.map?.reverseGeocodeLocation(serverLat, serverLon, grid); } window.trx.modules.map?.syncAprsReceiverMarker(); if (typeof update.initial_map_zoom === "number" && isFiniteNumber(update.initial_map_zoom)) { initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); } if (typeof update.spectrum_coverage_margin_hz === "number" && isFiniteNumber(update.spectrum_coverage_margin_hz)) { spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); } if (typeof update.spectrum_usable_span_ratio === "number" && isFiniteNumber(update.spectrum_usable_span_ratio)) { spectrumUsableSpanRatio = Math.max(0.01, Math.min(1, Number(update.spectrum_usable_span_ratio))); } if (typeof update.decode_history_retention_min === "number" && isFiniteNumber(update.decode_history_retention_min) && update.decode_history_retention_min > 0) { const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); if (nextRetentionMin !== decodeHistoryRetentionMin) { decodeHistoryRetentionMin = nextRetentionMin; if (typeof window.applyDecodeHistoryRetention === "function") { window.applyDecodeHistoryRetention(); } } } scheduleSpectrumLayout(); updateTitle(); initialized = !!update.initialized; const hasUsableSnapshot = !!update.info && !!update.status && !!update.status.freq && typeof update.status.freq.hz === "number"; if (!initialized) { const fallbackRigName = originalTitle || "Rig"; const manu = update.info && update.info.manufacturer || fallbackRigName; const model = update.info && update.info.model || fallbackRigName; const rev = update.info && update.info.revision || ""; const parts = [manu, model, rev].filter(Boolean).join(" "); if (!hasUsableSnapshot) { loadingTitle.textContent = `Initializing ${parts}…`; loadingSub.textContent = ""; console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); loadingEl.style.display = ""; if (contentEl) contentEl.style.display = "none"; powerHint.textContent = "Initializing rig…"; setDisabled(true); return; } loadingEl.style.display = "none"; if (contentEl) contentEl.style.display = ""; powerHint.textContent = "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 ${safeCallsign}`; } updateRigSubtitle(lastActiveRigId); if (ownerSubtitle) { if (ownerCallsign) { const safeOwner = escapeHtml(ownerCallsign); const encodedOwner = encodeURIComponent(ownerCallsign); ownerSubtitle.innerHTML = `Owner: ${safeOwner}`; } 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); }); } } 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; if (modeUpper2 === "WFM" && lastModeName !== "WFM") { setJogDivisor(10); resetRdsDisplay(); } else if (modeUpper2 !== "WFM" && lastModeName === "WFM") { resetRdsDisplay(); } lastModeName = modeUpper2; if (lastSpectrumData && !update.filter) { void applyBwDefaultForMode(mode, false); } } updateWfmControls(); updateSdrSquelchControlVisibility(); } const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; for (const d of decoderRegistry) { if (d.activation !== "mode_bound") continue; const el = document.getElementById(d.id + "-status"); if (!el) continue; const connText = _decodeConnectedText[d.id] || "Connected, listening for packets"; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } if (window.updateAisBar) window.updateAisBar(); if (window.updateVdesBar) window.updateVdesBar(); if (window.updateAprsBar) window.updateAprsBar(); if (window.updateFt8Bar) window.updateFt8Bar(); for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; const key = d.id.replace(/-/g, "_") + "_decode_enabled"; const enabled = !!update[key]; const modeMatch = d.active_modes.includes(modeUpper); const el = document.getElementById(d.id + "-status"); if (el && (!enabled || !modeMatch) && el.textContent === "Receiving") { el.textContent = "Connected, listening for packets"; } } if (update.status && typeof update.status.tx_en === "boolean" && update.status.tx_en !== prevRenderData.txEn) { prevRenderData.txEn = update.status.tx_en; lastTxEn = update.status.tx_en; window.trxUi?.setButtonState(pttBtn, { active: update.status.tx_en, activeLabel: "Stop TX", inactiveLabel: "Start TX" }); if (update.status.tx_en) { pttBtn.style.background = "var(--accent-red)"; pttBtn.style.borderColor = "var(--accent-red)"; pttBtn.style.color = "white"; } else { pttBtn.style.background = ""; pttBtn.style.borderColor = ""; pttBtn.style.color = ""; } } _ensureDecoderToggles(); for (const [key, entry] of Object.entries(_decoderToggles)) { syncDecoderToggle(entry, !!update[key], entry.label); } if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) { window.syncWefaxToggle(update.wefax_decode_enabled); } if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) { window._syncRecorderState(update.recorder_enabled); } if (window.updateSatLiveState) window.updateSatLiveState(update); if (cwWpmEl && typeof update.cw_wpm === "number") { cwWpmEl.value = String(update.cw_wpm); } if (cwToneEl && typeof update.cw_tone_hz === "number") { cwToneEl.value = String(update.cw_tone_hz); } if (typeof update.cw_auto === "boolean") { if (typeof window.applyCwAutoUiFromServer === "function") { window.applyCwAutoUiFromServer(update.cw_auto); } else if (typeof window.applyCwAutoUi === "function") { window.applyCwAutoUi(update.cw_auto); } else { if (cwAutoEl) cwAutoEl.checked = update.cw_auto; if (cwWpmEl) { cwWpmEl.disabled = update.cw_auto; cwWpmEl.readOnly = update.cw_auto; } if (cwToneEl) { cwToneEl.disabled = update.cw_auto; cwToneEl.readOnly = update.cw_auto; } } } let activeFreqColor = "var(--accent-green)"; if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) { const entries = update.status.vfo.entries; const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null; vfoPicker.replaceChildren(); entries.forEach((entry, idx) => { const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null; if (hz === null) return; const mode = entry.mode ? normalizeMode(entry.mode) : ""; const modeText = mode ? ` [${mode}]` : ""; const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFrequency(hz)}${modeText}`; const btn = document.createElement("button"); btn.type = "button"; btn.textContent = label; const color = vfoColor(idx); if (activeIdx === idx) { btn.classList.add("active"); btn.style.color = color; activeFreqColor = color; } else btn.addEventListener("click", async () => { btn.disabled = true; showHint("Toggling VFO…"); try { await postPath("/toggle_vfo"); showHint("VFO toggled", 1200); } catch (err) { showHint("VFO toggle failed", 2e3); console.error(err); } finally { btn.disabled = false; } }); vfoPicker.appendChild(btn); }); } else { vfoPicker.innerHTML = ''; } if (freqEl) { freqEl.style.color = activeFreqColor; } if (update.status && update.status.rx && typeof update.status.rx.sig === "number") { if (update.status.rx.sig !== prevRenderData.sigDbm) { prevRenderData.sigDbm = update.status.rx.sig; const sUnits = dbmToSUnits(update.status.rx.sig); sigLastSUnits = sUnits; sigLastDbm = update.status.rx.sig; const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; signalBar.style.width = `${pct}%`; signalValue.innerHTML = formatSignal(sUnits); refreshSigStrengthDisplay(); } } else if (prevRenderData.sigDbm !== null) { prevRenderData.sigDbm = null; sigLastSUnits = null; sigLastDbm = null; signalBar.style.width = "0%"; signalValue.textContent = "--"; refreshSigStrengthDisplay(); } if (bandLabel) { bandLabel.textContent = typeof update.band === "string" ? update.band : "--"; } if (typeof update.enabled === "boolean") { window.trxUi?.setButtonState(powerBtn, { active: update.enabled, activeLabel: "Power Off", inactiveLabel: "Power On" }); } else { powerBtn.disabled = true; powerBtn.textContent = "Power unavailable"; powerBtn.setAttribute("aria-pressed", "false"); powerHint.textContent = "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); } powerHint.textContent = readyText(); lastLocked = update.status?.lock === true; window.trxUi?.setButtonState(lockBtn, { active: lastLocked, activeLabel: "Unlock Tuning", inactiveLabel: "Lock Tuning" }); const tx = update.status && update.status.tx ? update.status.tx : null; txMeters.style.display = lastHasTx ? "" : "none"; if (tx && typeof tx.power === "number") { const pct = Math.max(0, Math.min(100, tx.power)); pwrBar.style.width = `${pct}%`; pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`; } else { pwrBar.style.width = "0%"; pwrValue.textContent = "PWR --"; } if (tx && typeof tx.swr === "number") { const swr = Math.max(1, tx.swr); const pct = Math.max(0, Math.min(100, (swr - 1) / 2 * 100)); swrBar.style.width = `${pct}%`; swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`; } else { swrBar.style.width = "0%"; swrValue.textContent = "SWR --"; } } function scheduleReconnect(delayMs = 1e3) { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delayMs); } async function pollFreshSnapshot() { try { const statusUrl = lastActiveRigId ? `/status?remote=${encodeURIComponent(lastActiveRigId)}` : "/status"; const resp = await fetch(statusUrl, { cache: "no-store" }); if (!resp.ok) return; const data = await responseJsonUnknown(resp); if (!isAppUpdate(data)) return; render(data); void refreshRigList(); lastEventAt = Date.now(); } catch (e) { } } function connect() { if (es) { es.close(); sseSessionId = null; } if (esHeartbeat) { clearInterval(esHeartbeat); } stopMeterStreaming(); startMeterStreaming(); void pollFreshSnapshot(); const eventsUrl = lastActiveRigId ? `/events?remote=${encodeURIComponent(lastActiveRigId)}` : "/events"; es = new EventSource(eventsUrl); const source = es; lastEventAt = Date.now(); es.onopen = () => { setConnLostOverlay(false); if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); if (!aboutUptimeStart) aboutUptimeStart = Date.now(); void pollFreshSnapshot(); void refreshRigList(); }; source.onmessage = (evt) => { try { if (evt.data === lastRendered) return; const data = parseJsonUnknown(evt.data); if (!isAppUpdate(data)) throw new TypeError("Unexpected status event shape"); lastRendered = evt.data; render(data); lastEventAt = Date.now(); if (data.server_connected === false) { powerHint.textContent = "trx-server connection lost"; if (tabMainEl) tabMainEl.classList.add("server-disconnected"); } else { if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); if (data.initialized) powerHint.textContent = 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) { powerHint.textContent = "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) { powerHint.textContent = "trx-client connection lost, retrying…"; setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); source.close(); void pollFreshSnapshot(); scheduleReconnect(250); } }, 5e3); } function disconnect() { if (es) { es.close(); es = null; } if (decodeSource) { decodeSource.close(); decodeSource = null; } stopSpectrumStreaming(); stopMeterStreaming(); if (esHeartbeat) { clearInterval(esHeartbeat); esHeartbeat = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } setDecodeHistoryOverlayVisible(false); setConnLostOverlay(false); } var uiFrameJobs = /* @__PURE__ */ new Map(); var uiFrameJobsHandle = null; function flushUiFrameJobs() { uiFrameJobsHandle = null; const jobs = Array.from(uiFrameJobs.values()); uiFrameJobs.clear(); for (const job of jobs) { try { job(); } catch (err) { console.error("Deferred UI job failed:", err); } } } function scheduleUiFrameJob(key, job) { if (typeof job !== "function") return; uiFrameJobs.set(key, job); if (uiFrameJobsHandle !== null) return; if (typeof requestAnimationFrame === "function") { uiFrameJobsHandle = requestAnimationFrame(flushUiFrameJobs); } else { uiFrameJobsHandle = setTimeout(flushUiFrameJobs, 16); } } window.trxScheduleUiFrameJob = scheduleUiFrameJob; async function postPath(path, options = {}) { if (rigSwitchInProgress && !options.allowDuringRigSwitch) { throw new Error("Wait for the rig switch to finish"); } const targetRigId = options.remote === void 0 ? lastActiveRigId : options.remote; if (targetRigId && !path.includes("remote=")) { const sep = path.includes("?") ? "&" : "?"; path = `${path}${sep}remote=${encodeURIComponent(targetRigId)}`; } const resp = await fetch(path, { method: "POST" }); if (authEnabled && resp.status === 401) { authRole = null; if (es) es.close(); showAuthGate(); throw new Error("Authentication required"); } if (resp.status === 403) { throw new Error("Insufficient permissions"); } if (!resp.ok) { const text = await resp.text(); throw new Error(text || resp.statusText); } return resp; } async function takeSchedulerControlForDecoderDisable(buttonEl) { const enabled = buttonEl?.dataset?.enabled === "true" || /^\s*Disable\b/i.test(buttonEl?.textContent || ""); if (!enabled) return; await window.trx.modules.vchan?.takeSchedulerControl(); } window.takeSchedulerControlForDecoderDisable = takeSchedulerControlForDecoderDisable; async function switchRigFromSelect(selectEl) { if (!selectEl || !selectEl.value) { showHint("No rig selected", 1500); return; } if (authRole === "rx") { showHint("Control role required", 1500); return; } if (!lastRigIds.includes(selectEl.value)) { showHint("Unknown rig", 1500); return; } const prevRig = lastActiveRigId; const nextRig = selectEl.value; if (nextRig === prevRig || rigSwitchInProgress) return; rigSwitchInProgress = true; setControlPending(selectEl, true); selectEl.closest(".header-rig-switch")?.classList.add("is-switching"); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`); showHint(`Switching to ${lastRigDisplayNames[nextRig] || nextRig}…`); try { const sidParam = sseSessionId ? `&session_id=${encodeURIComponent(sseSessionId)}` : ""; await postPath(`/select_rig?remote=${encodeURIComponent(nextRig)}${sidParam}`, { allowDuringRigSwitch: true, remote: null }); lastActiveRigId = nextRig; resetDecoderStateOnRigSwitch(); updateRigSubtitle(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); window.trx.modules.scheduler?.setRig(lastActiveRigId); window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); void window.trx.modules.bookmarks?.fetch(document.getElementById("bm-category-filter")?.value || ""); window.trx.modules.map?.syncAprsReceiverMarker(); connect(); stopSpectrumStreaming(); startSpectrumStreaming(); stopMeterStreaming(); startMeterStreaming(); if (rxActive) { stopRxAudio(); startRxAudio(); } showHint(`Rig: ${lastRigDisplayNames[lastActiveRigId] || lastActiveRigId}`, 1500); } catch (err) { console.error("select_rig failed:", err); selectEl.value = prevRig || ""; window.trxUi?.notify("Rig could not be switched", { kind: "error" }); } finally { rigSwitchInProgress = false; setControlPending(selectEl, false); selectEl.closest(".header-rig-switch")?.classList.remove("is-switching"); } } if (headerRigSwitchSelect) { headerRigSwitchSelect.addEventListener("change", () => { void switchRigFromSelect(headerRigSwitchSelect); }); } function setControlPending(control, pending) { if (!control) return; control.disabled = pending; control.classList.toggle("is-busy", pending); control.setAttribute("aria-busy", String(pending)); } powerBtn.addEventListener("click", async () => { setControlPending(powerBtn, true); showHint("Sending..."); try { await postPath("/toggle_power"); showHint("Toggled, waiting for update…"); } catch (err) { showHint("Toggle failed", 2e3); console.error(err); } finally { setControlPending(powerBtn, false); } }); pttBtn.addEventListener("click", async () => { setControlPending(pttBtn, true); showHint("Toggling PTT…"); try { const desired = lastTxEn ? "false" : "true"; await postPath(`/set_ptt?ptt=${desired}`); showHint("PTT command sent", 1500); } catch (err) { showHint("PTT toggle failed", 2e3); console.error(err); } finally { setControlPending(pttBtn, false); } }); function applyFreqFromInput() { const parsedRaw = parseFrequencyInput(freqEl.value, jogUnit, modeEl?.value || ""); if (parsedRaw === null) { showHint("Freq missing", 1500); return; } const parsed = alignFreqToRigStep(parsedRaw); if (!freqAllowed(parsed)) { showUnsupportedFreqPopup(parsed); return; } freqDirty = false; setRigFrequency(parsed); } async function applyCenterFreqFromInput() { if (!centerFreqEl) return; const parsedRaw = parseFrequencyInput(centerFreqEl.value, jogUnit, modeEl?.value || ""); if (parsedRaw === null) { showHint("Central freq missing", 1500); return; } const parsed = alignFreqToRigStep(parsedRaw); if (!freqAllowed(parsed)) { showUnsupportedFreqPopup(parsed); return; } centerFreqDirty = false; setControlPending(centerFreqEl, true); showHint("Setting central frequency…"); try { await postPath(`/set_center_freq?hz=${parsed}`); showHint("Central freq set", 1500); } catch (err) { showHint("Set central freq failed", 2e3); console.error(err); } finally { setControlPending(centerFreqEl, false); } } freqEl.addEventListener("keydown", (e) => { freqDirty = true; if (e.key === "Enter") { e.preventDefault(); applyFreqFromInput(); } else if (e.key === "Escape") { freqDirty = false; refreshFreqDisplay(); freqEl.blur(); } }); freqEl.addEventListener("blur", () => { if (freqDirty) { freqDirty = false; refreshFreqDisplay(); } }); if (centerFreqEl) { centerFreqEl.addEventListener("keydown", (e) => { centerFreqDirty = true; if (e.key === "Enter") { e.preventDefault(); void applyCenterFreqFromInput(); } else if (e.key === "Escape") { centerFreqDirty = false; refreshCenterFreqDisplay(); centerFreqEl.blur(); } }); centerFreqEl.addEventListener("blur", () => { if (centerFreqDirty) { centerFreqDirty = false; refreshCenterFreqDisplay(); } }); centerFreqEl.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); } freqEl.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); var jogWheel = requiredElement("jog-wheel"); var jogIndicator = requiredElement("jog-indicator"); var jogDownBtn = requiredElement("jog-down"); var jogUpBtn = requiredElement("jog-up"); var jogStepEl = requiredElement("jog-step"); var jogMultEl = requiredElement("jog-mult"); var VALID_JOG_DIVISORS = /* @__PURE__ */ new Set([1, 10]); function applyJogStep() { jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); saveSetting("jogUnit", jogUnit); saveSetting("jogMult", jogMult); saveSetting("jogStep", jogStep); refreshFreqDisplay(); refreshCenterFreqDisplay(); } function setJogDivisor(divisor) { const next = VALID_JOG_DIVISORS.has(divisor) ? divisor : 1; jogMult = next; if (jogMultEl) { jogMultEl.querySelectorAll("button[data-mult]").forEach((b) => { b.classList.toggle("active", parseInt(b.dataset.mult ?? "", 10) === jogMult); }); } applyJogStep(); } function jogFreq(direction) { if (lastLocked) { showHint("Locked", 1500); return; } if (lastFreqHz === null) return; const newHz = alignFreqToRigStep(lastFreqHz + direction * jogStep); if (!freqAllowed(newHz)) { showUnsupportedFreqPopup(newHz); return; } jogAngle = (jogAngle + direction * 15) % 360; jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`; setRigFrequency(newHz); } jogDownBtn.addEventListener("click", () => jogFreq(-1)); jogUpBtn.addEventListener("click", () => jogFreq(1)); jogWheel.addEventListener("wheel", (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); }, { passive: false }); var jogTouchY = null; jogWheel.addEventListener("touchstart", (e) => { e.preventDefault(); jogTouchY = e.touches[0]?.clientY ?? null; }, { passive: false }); jogWheel.addEventListener("touchmove", (e) => { e.preventDefault(); if (jogTouchY === null) return; const touch = e.touches[0]; if (!touch) return; const dy = jogTouchY - touch.clientY; if (Math.abs(dy) > 12) { jogFreq(dy > 0 ? 1 : -1); jogTouchY = touch.clientY; } }, { passive: false }); jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); var jogMouseY = null; jogWheel.addEventListener("mousedown", (e) => { e.preventDefault(); jogMouseY = e.clientY; jogWheel.style.cursor = "grabbing"; }); window.addEventListener("mousemove", (e) => { if (jogMouseY === null) return; const dy = jogMouseY - e.clientY; if (Math.abs(dy) > 10) { jogFreq(dy > 0 ? 1 : -1); jogMouseY = e.clientY; } }); window.addEventListener("mouseup", () => { jogMouseY = null; if (jogWheel) jogWheel.style.cursor = "grab"; }); jogStepEl.addEventListener("click", (e) => { const btn = e.target instanceof Element ? e.target.closest("button[data-step]") : null; if (!btn) return; jogUnit = parseInt(btn.dataset.step ?? "", 10); jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); applyJogStep(); }); if (jogMultEl) { jogMultEl.querySelectorAll("button[data-mult]").forEach((btn) => { const divisor = parseInt(btn.dataset.mult ?? "", 10); if (!VALID_JOG_DIVISORS.has(divisor)) { btn.remove(); } }); jogMultEl.addEventListener("click", (e) => { const btn = e.target instanceof Element ? e.target.closest("button[data-mult]") : null; if (!btn) return; setJogDivisor(parseInt(btn.dataset.mult ?? "", 10)); }); } { const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); const activeUnit = unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === jogUnit) || unitBtns.find((b) => parseInt(b.dataset.step ?? "", 10) === 1e3) || unitBtns[0]; if (activeUnit) { jogUnit = parseInt(activeUnit.dataset.step ?? "", 10); unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); } if (jogMultEl) { const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); const activeMult = multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || multBtns.find((b) => parseInt(b.dataset.mult ?? "", 10) === 1) || multBtns[0]; if (activeMult) { jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult ?? "", 10)) ? parseInt(activeMult.dataset.mult ?? "", 10) : 1; multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); } else { jogMult = 1; } } jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); } async function applyModeFromPicker() { const mode = modeEl.value || ""; if (!mode) { showHint("Mode missing", 1500); return; } updateWfmControls(); setControlPending(modeEl, true); 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); } } modeEl.addEventListener("change", applyModeFromPicker); txLimitInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); txLimitBtn.click(); } }); txLimitBtn.addEventListener("click", async () => { const limit = txLimitInput.value; if (limit === "" || limit === "--") { showHint("Limit missing", 1500); return; } setControlPending(txLimitBtn, true); showHint("Setting TX limit…"); try { await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); showHint("TX limit set", 1500); } catch (err) { showHint("TX limit failed", 2e3); console.error(err); } finally { setControlPending(txLimitBtn, false); } }); lockBtn.addEventListener("click", async () => { setControlPending(lockBtn, true); showHint("Toggling lock…"); try { const nextLock = !lastLocked; await postPath(nextLock ? "/lock" : "/unlock"); showHint("Lock toggled", 1500); } catch (err) { showHint("Lock toggle failed", 2e3); console.error(err); } finally { setControlPending(lockBtn, false); } }); var MODE_BW_DEFAULTS = { CW: [500, 100, 9e3, 50], CWR: [500, 100, 9e3, 50], LSB: [2700, 300, 6e3, 100], USB: [2700, 300, 6e3, 100], AM: [9e3, 500, 2e4, 500], SAM: [9e3, 500, 2e4, 500], FM: [12500, 2500, 25e3, 500], AIS: [25e3, 12500, 5e4, 500], VDES: [1e5, 25e3, 2e5, 1e3], WFM: [18e4, 6e4, 3e5, 5e3], DIG: [3e3, 300, 6e3, 100], PKT: [25e3, 300, 5e4, 500] }; var MODE_BW_FALLBACK = [3e3, 300, 5e5, 100]; function mwDefaultsForMode(mode) { return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK; } function formatBwLabel(hz) { if (hz >= 1e3) return (hz / 1e3).toFixed(hz % 1e3 === 0 ? 0 : 1) + " kHz"; return hz + " Hz"; } var currentBandwidthHz = 3e3; window.currentBandwidthHz = currentBandwidthHz; var spectrumBwInput = requiredElement("spectrum-bw-input"); var spectrumBwSetBtn = requiredElement("spectrum-bw-set-btn"); var spectrumBwAutoBtn = requiredElement("spectrum-bw-auto-btn"); var spectrumBwSweetBtn = requiredElement("spectrum-bw-sweet-btn"); function formatBandwidthInputKhz(hz) { const khz = hz / 1e3; if (Math.abs(Math.round(khz) - khz) < 1e-4) return String(Math.round(khz)); if (Math.abs(Math.round(khz * 10) - khz * 10) < 1e-4) return khz.toFixed(1); return khz.toFixed(2); } function syncBandwidthInput(hz) { if (!spectrumBwInput || !isFiniteNumber(hz) || hz <= 0) return; const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); spectrumBwInput.min = String(minBw / 1e3); spectrumBwInput.max = String(maxBw / 1e3); spectrumBwInput.step = String(stepBw / 1e3); spectrumBwInput.value = formatBandwidthInputKhz(hz); } async function applyBwDefaultForMode(mode, sendToServer) { const [def] = mwDefaultsForMode(mode); currentBandwidthHz = def; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(def); positionFastOverlay(lastFreqHz, def); if (lastSpectrumData) { scheduleSpectrumDraw(); } if (sendToServer) { try { await postPath(`/set_bandwidth?hz=${def}`); } catch (error) { window.trxUi?.notify("Default bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: () => applyBwDefaultForMode(mode, true) } }); } } } async function applyBandwidthFromInput() { if (!spectrumBwInput) return; const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); const nextKhz = Number(spectrumBwInput.value); const next = Math.round(nextKhz * 1e3); if (!isFiniteNumber(next) || next <= 0) { syncBandwidthInput(currentBandwidthHz); return; } const clamped = Math.max(minBw, Math.min(maxBw, next)); currentBandwidthHz = clamped; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(clamped); positionFastOverlay(lastFreqHz, clamped); if (lastSpectrumData) { scheduleSpectrumDraw(); } try { if (await window.trx.modules.vchan?.interceptBandwidth(clamped)) return; await postPath(`/set_bandwidth?hz=${clamped}`); if (isFiniteNumber(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz); } } catch (error) { window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); } } async function applyAutoBandwidth() { if (!lastSpectrumData || lastFreqHz == null) return; const onVirtual = window.trx.modules.vchan?.isOnVirtual() === true; const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; const mode = (modeEl?.value || "").toUpperCase(); const estimated = estimateOccupiedBandwidth( lastSpectrumData, lastFreqHz, mode, mwDefaultsForMode(mode), interference ); if (!isFiniteNumber(estimated) || estimated <= 0) { syncBandwidthInput(currentBandwidthHz); return; } currentBandwidthHz = estimated; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(estimated); positionFastOverlay(lastFreqHz, estimated); if (lastSpectrumData) { scheduleSpectrumDraw(); } let reason = "measured occupied spectrum"; if (mode === "WFM") { if (estimated === 6e4 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; else if (estimated === 6e4) reason = "weak-signal noise rejection"; else if (lastWfmAci >= lastWfmCci && lastWfmAci >= 10) reason = `${Math.round(lastWfmAci)}% ACI cap`; else if (lastWfmCci >= 10) reason = `${Math.round(lastWfmCci)}% CCI confidence cap`; } window.trxUi?.notify(`Auto BW: ${formatBwLabel(estimated)} — ${reason}`, { kind: "success", duration: 5e3 }); try { if (await window.trx.modules.vchan?.interceptBandwidth(estimated)) return; await postPath(`/set_bandwidth?hz=${estimated}`); if (isFiniteNumber(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz); } } catch (error) { window.trxUi?.notify("Automatic bandwidth could not be applied", { kind: "error", action: { label: "Retry", run: applyAutoBandwidth } }); } } if (spectrumBwInput) { spectrumBwInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); void applyBandwidthFromInput(); } }); } if (spectrumBwSetBtn) { spectrumBwSetBtn.addEventListener("click", () => { void applyBandwidthFromInput(); }); } if (spectrumBwAutoBtn) { spectrumBwAutoBtn.addEventListener("click", () => { void applyAutoBandwidth(); }); } if (spectrumBwSweetBtn) { spectrumBwSweetBtn.addEventListener("click", () => { applySweetSpotCenter().catch(() => { }); }); } var _activeTab = "main"; function tabFromPath2(pathname = window.location.pathname) { return tabFromPath(pathname); } var _mapInitTimer = null; function _initMapWhenReady() { const loadingEl2 = document.getElementById("map-loading"); const map = window.trx.modules.map; if (map && "L" in window) { if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; } if (loadingEl2) loadingEl2.classList.add("is-hidden"); map.initAprsMap(); map.sizeAprsMapToViewport(); requestAnimationFrame(() => { requestAnimationFrame(() => { map.sizeAprsMapToViewport(); map.aprsMap?.invalidateSize(); }); }); return; } if (loadingEl2) loadingEl2.classList.remove("is-hidden"); if (!_mapInitTimer) { _mapInitTimer = setInterval(() => { if (_activeTab !== "map") { if (_mapInitTimer) clearInterval(_mapInitTimer); _mapInitTimer = null; return; } _initMapWhenReady(); }, 100); } } function navigateToTab(name, options = {}) { window.trxUi?.closeMobileOverlays?.(); const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { showAuthGate(false); return; } const btn = document.querySelector(`.tab-bar .tab[data-tab="${name}"]`); if (!btn) return; _activeTab = name; document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); btn.classList.add("active"); window.trxUi?.syncSelectedTab(document.querySelector(".tab-bar-nav"), btn); document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); const panel = document.getElementById(`tab-${name}`); if (!panel) return; panel.style.display = ""; materializeTabPanel(panel); if (name === "map" || name === "statistics") { const peer = document.getElementById(name === "map" ? "tab-statistics" : "tab-map"); if (peer) materializeTabPanel(peer); } if (updateHistory) { updateTabHistory(name, replaceHistory); } scheduleSpectrumLayout(); void loadPluginsForTab(name).catch((error) => { console.error(error); }); if (name === "map") { _initMapWhenReady(); } if (name === "statistics") { window.trx.modules.map?.scheduleStatsRender(); } if (name === "recorder") { void refreshRecorderStatus(); } } function materializeTabPanel(panel) { const tmpl = panel.querySelector("template"); if (tmpl) { panel.appendChild(tmpl.content.cloneNode(true)); tmpl.remove(); panel.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); if (decoderRegistry.length) applyDecoderRegistryVisibility(); } } window.navigateToTab = navigateToTab; requiredElement("tab-bar").addEventListener("click", (e) => { const btn = e.target instanceof Element ? e.target.closest(".tab[data-tab]") : null; if (!btn) return; const tab = btn.dataset.tab; if (tab && TAB_ORDER.includes(tab)) navigateToTab(tab); }); window.addEventListener("popstate", () => { navigateToTab(tabFromPath2(), { updateHistory: false }); }); (function() { let tx = 0, ty = 0; const THRESHOLD = 60; const ANGLE_LIMIT = 1.6; const NO_SWIPE_SELECTORS = [ "#jog-wheel", "#spectrum-canvas", "#overview-canvas", "#aprs-map", ".controls-tray-scroll", ".sub-tab-bar", "input[type=range]", "select", "input[type=text]", "input[type=number]", "input[type=search]" ]; function isExcluded(el) { return NO_SWIPE_SELECTORS.some((sel) => el.closest(sel)); } document.addEventListener("touchstart", (e) => { if (e.touches.length !== 1) return; if (!(e.target instanceof Element) || isExcluded(e.target)) return; const touch = e.touches[0]; if (!touch) return; tx = touch.clientX; ty = touch.clientY; }, { passive: true }); document.addEventListener("touchend", (e) => { if (e.changedTouches.length !== 1 || tx === 0) return; const touch = e.changedTouches[0]; if (!touch) return; const dx = touch.clientX - tx; const dy = touch.clientY - ty; tx = 0; if (Math.abs(dx) < THRESHOLD) return; if (Math.abs(dy) > 0 && Math.abs(dx) / Math.abs(dy) < ANGLE_LIMIT) return; const activeBtn = document.querySelector(".tab-bar .tab.active"); if (!activeBtn) return; const cur = TAB_ORDER.indexOf(activeBtn.dataset.tab); if (cur === -1) return; const next = dx < 0 ? cur + 1 : cur - 1; const nextTab = TAB_ORDER[next]; if (next >= 0 && next < TAB_ORDER.length && nextTab) navigateToTab(nextTab); }, { passive: true }); })(); window.addEventListener("resize", () => { scheduleSpectrumLayout(); }); async function initializeApp() { showAuthGate(false); const authStatus = await checkAuthStatus(); authEnabled = !authStatus.auth_disabled; if (!authEnabled) { authRole = "control"; hideAuthGate(); updateAuthUI(); connect(); connectDecode(); initSettingsUI(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); return; } if (authStatus.authenticated) { authRole = authStatus.role ?? null; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); connect(); connectDecode(); initSettingsUI(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } else { const allowGuest = authStatus.role === "rx"; showAuthGate(allowGuest); } } function initSettingsUI() { window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); window.trx.modules.scheduler?.wireEvents(); if (window.trx.modules.backgroundDecode) { window.trx.modules.backgroundDecode.initialize(lastActiveRigId, authRole); window.trx.modules.backgroundDecode.wireEvents(); } } requiredElement("auth-form").addEventListener("submit", async (e) => { e.preventDefault(); const passphraseEl = requiredElement("auth-passphrase"); const passphrase = passphraseEl.value; const btn = requiredElement("auth-form").querySelector("button[type=submit]"); if (!btn) return; btn.disabled = true; btn.textContent = "Logging in..."; try { const result = await authLogin(passphrase); authRole = result.role ?? null; passphraseEl.value = ""; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); connect(); connectDecode(); initSettingsUI(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); } catch (err) { showAuthError("Invalid passphrase"); console.error("Login error:", err); } finally { btn.disabled = false; btn.textContent = "Login"; } }); var guestBtn = document.getElementById("auth-guest-btn"); if (guestBtn) { guestBtn.addEventListener("click", () => { authRole = "rx"; requiredElement("auth-passphrase").value = ""; hideAuthGate(); updateAuthUI(); applyAuthRestrictions(); connect(); connectDecode(); initSettingsUI(); resizeHeaderSignalCanvas(); startHeaderSignalSampling(); }); } var headerAuthBtn = document.getElementById("header-auth-btn"); if (headerAuthBtn) { headerAuthBtn.addEventListener("click", async () => { if (authRole) { if (await window.trxUi.confirm({ title: "Log out?", message: "Audio and control access for this browser session will end.", confirmLabel: "Log out", danger: false })) { await authLogout(); } } else { showAuthGate(false); } }); } var trxState = /* @__PURE__ */ Object.create(null); var trxModules = {}; Object.defineProperties(trxState, { serverLat: { get() { return serverLat; }, set(v) { serverLat = v; } }, serverLon: { get() { return serverLon; }, set(v) { serverLon = v; } }, lastFreqHz: { get() { return lastFreqHz; } }, lastActiveRigId: { get() { return lastActiveRigId; } }, lastRigIds: { get() { return lastRigIds; } }, lastRigDisplayNames: { get() { return lastRigDisplayNames; } }, initialMapZoom: { get() { return initialMapZoom; } }, decodeHistoryRetentionMin: { get() { return decodeHistoryRetentionMin; } }, authEnabled: { get() { return authEnabled; } }, authRole: { get() { return authRole; } }, decoderRegistry: { get() { return decoderRegistry; } }, sseSessionId: { get() { return sseSessionId; } }, primaryRds: { get() { return primaryRds; } }, vchanRdsById: { get() { return vchanRdsById; } }, vchanSignalDbById: { get() { return vchanSignalDbById; } }, lastCityLabel: { get() { return lastCityLabel; }, set(v) { lastCityLabel = v; } }, serverVersion: { get() { return serverVersion; } }, serverBuildDate: { get() { return serverBuildDate; } }, serverCallsign: { get() { return serverCallsign; } }, ownerCallsign: { get() { return ownerCallsign; } }, ownerWebsiteUrl: { get() { return ownerWebsiteUrl; } }, ownerWebsiteName: { get() { return ownerWebsiteName; } }, aisVesselUrlBase: { get() { return aisVesselUrlBase; } }, serverRigs: { get() { return serverRigs; } }, serverActiveRigId: { get() { return serverActiveRigId; } }, lastModeName: { get() { return lastModeName; }, set(v) { lastModeName = v; } }, jogUnit: { get() { return jogUnit; } }, rxActive: { get() { return rxActive; } }, audioChannelOverride: { get() { return _audioChannelOverride; }, set(v) { _audioChannelOverride = v; } }, lastSpectrumData: { get() { return lastSpectrumData; } }, lastSpectrumRenderData: { get() { return lastSpectrumRenderData; } }, currentBandwidthHz: { get() { return currentBandwidthHz; }, set(v) { currentBandwidthHz = v; window.currentBandwidthHz = v; } }, spectrumFloor: { get() { return spectrumFloor; } }, spectrumRange: { get() { return spectrumRange; } }, spectrumCanvas: { get() { return spectrumCanvas; } }, overviewCanvas: { get() { return overviewCanvas; } }, overviewGl: { get() { return overviewGl; } }, spectrumGl: { get() { return spectrumGl; } }, signalOverlayGl: { get() { return signalOverlayGl; } } }); var trxCore = Object.freeze({ saveSetting, loadSetting, showHint, escapeMapHtml: escapeHtml, formatFreq: formatFrequency, formatFreqForHumans: formatFrequencyForHumans, formatWavelength, formatBwLabel, formatUptime: formatDuration, formatSigStrength, formatSignal, postPath, scheduleUiFrameJob, navigateToTab, rigBadgeColor, setRigFrequency, applyLocalTunedFrequency, armOptimisticFrequency, syncBandwidthInput, scheduleSpectrumDraw, onDecoderRegistryReady, formatFreqForStep: formatFrequencyForStep, refreshFreqDisplay, setJogDivisor, mwDefaultsForMode, resetRdsDisplay, positionRdsPsOverlay, updateWfmControls, updateSdrSquelchControlVisibility, startRxAudio, stopRxAudio, latLonToMaidenhead, locatorToLatLon, haversineKm, formatDistanceKm, formatTimeAgo, bookmarkDistanceText, buildBookmarkTooltipText, nearestBookmarkForHz, currentDecodeHistoryRetentionMs, currentTheme, canvasPalette, currentStyle, cssColorToRgba, rgbaWithAlpha, isBinsArray: isNumericBins, estimateNoiseFloorDb, spectrumVisibleRange, drawSpectrum, bandForHz: function(hz) { return trxModules.map?.bandForHz?.(hz); }, markDecodeMapSyncPending, decodeHistoryMapRenderingDeferred, updateDocumentTitle, activeChannelRds }); Object.defineProperties(trxState, { decodeHistoryReplayActive: { get() { return decodeHistoryReplayActive; } }, decodeMapSyncPending: { get() { return decodeMapSyncPending; } }, _activeTab: { get() { return _activeTab; } }, locationSubtitle: { get() { return locationSubtitle; } } }); window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules }); void loadEagerPlugins().catch((error) => { console.error(error); }); void initializeApp(); window.addEventListener("resize", resizeHeaderSignalCanvas); function bookmarkDistanceText(bm) { if (!bm || serverLat == null || serverLon == null) return null; const latLon = locatorToLatLon(bm.locator); if (!latLon) return null; return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon)); } function buildBookmarkTooltipText(bm) { if (!bm) return null; const parts = []; if (bm.name) parts.push(String(bm.name)); if (window.trx.modules.bookmarks) parts.push(window.trx.modules.bookmarks.formatFrequency(bm.freq_hz)); if (bm.mode) parts.push(String(bm.mode)); if (bm.locator) parts.push(String(bm.locator)); const distance = bookmarkDistanceText(bm); if (distance) parts.push(distance); let text = parts.join(" · "); if (bm.comment) { text += (text ? "\n" : "") + String(bm.comment); } return text; } function nearestBookmarkForHz(hz, widthPx, range) { const ref = window.trx.modules.bookmarks?.overlayList ?? null; if (!ref || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) { return null; } const maxDeltaHz = Math.max(range.visSpanHz / widthPx * 6, 10); let best = null; let bestDelta = Number.POSITIVE_INFINITY; for (const bm of ref) { const delta = Math.abs(Number(bm.freq_hz) - hz); if (delta <= maxDeltaHz && delta < bestDelta) { best = bm; bestDelta = delta; } } return best; } function _wireSubTabBar(bar) { if (bar._subtabWired) return; bar._subtabWired = true; window.trxUi?.prepareTabList(bar, "secondary"); bar.addEventListener("click", (e) => { const btn = e.target instanceof Element ? e.target.closest(".sub-tab[data-subtab]") : null; if (!btn) return; bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); btn.classList.add("active"); window.trxUi?.syncSelectedTab(bar, btn); const decoderPicker = document.getElementById("decoder-tab-select"); if (decoderPicker && btn.closest("#tab-digital-modes")) decoderPicker.value = btn.dataset.subtab ?? ""; const parent = bar.parentElement; if (!parent) return; parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); if (nextPanel) nextPanel.style.display = ""; if (btn.dataset.subtab === "cw" && window.refreshCwTonePicker) { requestAnimationFrame(() => { if (window.refreshCwTonePicker) window.refreshCwTonePicker(); }); } if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { window.clearSatPredictionDom(); } }); } document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); window.addEventListener("resize", () => { const mapTab = document.getElementById("tab-map"); if (!mapTab || mapTab.style.display === "none") return; window.trx.modules.map?.sizeAprsMapToViewport(); }); var sigMeasureBtn = requiredElement("sig-measure-btn"); var sigClearBtn = requiredElement("sig-clear-btn"); var sigResult = requiredElement("sig-result"); function resetSignalMeasurementState() { sigMeasureLastTickMs = 0; sigMeasureAccumMs = 0; sigMeasureWeighted = 0; sigMeasurePeak = null; } function updateSignalMeasurement(nowMs) { if (!sigMeasuring) return; if (sigMeasureLastTickMs === 0) { sigMeasureLastTickMs = nowMs; return; } const dt = Math.max(0, nowMs - sigMeasureLastTickMs); sigMeasureLastTickMs = nowMs; if (!isFiniteNumber(sigLastSUnits)) return; sigMeasureAccumMs += dt; sigMeasureWeighted += sigLastSUnits * dt; if (sigMeasurePeak === null || sigLastSUnits > sigMeasurePeak) { sigMeasurePeak = sigLastSUnits; } } function stopSignalMeasurement() { if (sigMeasureTimer) { clearInterval(sigMeasureTimer); sigMeasureTimer = null; } sigMeasuring = false; sigMeasureBtn.textContent = "Measure"; sigMeasureBtn.style.borderColor = ""; sigMeasureBtn.style.color = ""; } sigMeasureBtn.addEventListener("click", () => { if (!sigMeasuring) { resetSignalMeasurementState(); sigMeasuring = true; sigMeasureBtn.textContent = "Stop (0.0s)"; sigMeasureBtn.style.borderColor = "#00d17f"; sigMeasureBtn.style.color = "#00d17f"; sigMeasureTimer = setInterval(() => { const now = Date.now(); updateSignalMeasurement(now); sigMeasureBtn.textContent = `Stop (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`; }, 200); } else { updateSignalMeasurement(Date.now()); stopSignalMeasurement(); if (sigMeasureAccumMs > 0) { const avg = sigMeasureWeighted / sigMeasureAccumMs; const peak = sigMeasurePeak ?? avg; sigResult.innerHTML = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`; } } }); sigClearBtn.addEventListener("click", () => { stopSignalMeasurement(); resetSignalMeasurementState(); sigResult.textContent = ""; }); var rxAudioBtn = requiredElement("rx-audio-btn"); var txAudioBtn = requiredElement("tx-audio-btn"); var RX_AUDIO_LABEL = "Play Audio"; var TX_AUDIO_LABEL = "Transmit Audio"; var audioStatus = requiredElement("audio-status"); var audioLevelFill = document.getElementById("audio-level-fill"); var audioRow = requiredElement("audio-row"); var wfmControlsCol = document.getElementById("wfm-controls-col"); var wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); var wfmAudioModeEl = document.getElementById("wfm-audio-mode"); var wfmDenoiseEl = document.getElementById("wfm-denoise"); var sdrSettingsRowEl = document.getElementById("sdr-settings-row"); var sdrGainEl = document.getElementById("sdr-gain-db"); var sdrGainSetBtn = document.getElementById("sdr-gain-set"); var sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls"); var sdrLnaGainEl = document.getElementById("sdr-lna-gain-db"); var sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set"); var sdrAgcEl = document.getElementById("sdr-agc-enabled"); var wfmStFlagEl = document.getElementById("wfm-st-flag"); var wfmCciFillEl = document.getElementById("wfm-cci-fill"); var wfmCciValEl = document.getElementById("wfm-cci-val"); var wfmAciFillEl = document.getElementById("wfm-aci-fill"); var wfmAciValEl = document.getElementById("wfm-aci-val"); var samControlsCol = document.getElementById("sam-controls-col"); var samStereoWidthEl = document.getElementById("sam-stereo-width"); var samCarrierSyncEl = document.getElementById("sam-carrier-sync"); var sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap"); var sdrSquelchEl = document.getElementById("sdr-squelch"); var sdrSquelchPctEl = document.getElementById("sdr-squelch-pct"); var SDR_SQUELCH_MIN_DB = -120; var SDR_SQUELCH_MAX_DB = -30; var syncFromServerSdrSquelch = false; var sdrNbWrapEl = document.getElementById("sdr-nb-wrap"); var sdrNbEnabledEl = document.getElementById("sdr-nb-enabled"); var sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls"); var sdrNbThresholdEl = document.getElementById("sdr-nb-threshold"); var sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set"); var sdrNbSupported = false; fetch("/audio", { method: "GET" }).then((r) => { if (r.status === 404) audioRow.style.display = "none"; }).catch(() => { }); var audioWs = null; var audioCtx = null; var rxActive = false; var txActive = false; var txStream = null; var txProcessor = null; var streamInfo = null; var opusDecoder = null; var wasmOpusDecoder = null; var txEncoder = null; var nextPlayTime = 0; var lastLevelUpdate = 0; var rxGainNode = null; var txGainNode = null; var rxVolSlider = requiredElement("rx-vol"); var txVolSlider = requiredElement("tx-vol"); var TX_TIMEOUT_SECS = 120; var txTimeoutTimer = null; var txTimeoutRemaining = 0; var txTimeoutInterval = null; var hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined"; var opusDecoderGlobal = window["opus-decoder"]; var hasWasmOpus = typeof opusDecoderGlobal?.OpusDecoder === "function"; var MAX_RX_BUFFER_SECS = 0.25; var TARGET_RX_BUFFER_SECS = 0.04; var MIN_RX_JITTER_SAMPLES = 512; if (rxAudioBtn) { rxAudioBtn.textContent = RX_AUDIO_LABEL; rxAudioBtn.setAttribute("aria-label", RX_AUDIO_LABEL); } if (txAudioBtn) { txAudioBtn.textContent = TX_AUDIO_LABEL; txAudioBtn.setAttribute("aria-label", TX_AUDIO_LABEL); } function setAudioLevel(levelPct) { if (!audioLevelFill) return; const clamped = Math.max(0, Math.min(100, isFiniteNumber(levelPct) ? levelPct : 0)); audioLevelFill.style.width = `${clamped}%`; } function ensureRxAudioContext(preferredSampleRate) { if (!audioCtx) { try { audioCtx = isFiniteNumber(preferredSampleRate) && preferredSampleRate > 0 ? new AudioContext({ sampleRate: preferredSampleRate }) : new AudioContext(); } catch (e) { audioCtx = new AudioContext(); } } audioCtx.resume().catch(() => { }); if (!rxGainNode) { rxGainNode = audioCtx.createGain(); rxGainNode.connect(audioCtx.destination); } } function levelFromChannels(channels, frameCount) { if (!Array.isArray(channels) || channels.length === 0 || !isFiniteNumber(frameCount) || frameCount <= 0) { return 0; } let sumSquares = 0; let samples = 0; for (const channel of channels) { if (!channel) continue; const limit = Math.min(frameCount, channel.length); for (let i = 0; i < limit; i++) { const sample = channel[i] ?? 0; sumSquares += sample * sample; } samples += limit; } if (samples <= 0) return 0; const rms = Math.sqrt(sumSquares / samples); return Math.min(100, rms * 220); } function normalizeWfmDenoiseLevel(value) { const next = primitiveString(value).toLowerCase(); if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next; return "auto"; } function clampSdrSquelchPercent(value) { if (!isFiniteNumber(value)) return 0; return Math.max(0, Math.min(100, Math.round(value))); } function sdrSquelchPercentToServer(percent) { const pct = clampSdrSquelchPercent(percent); if (pct <= 0) { return { enabled: false, thresholdDb: SDR_SQUELCH_MIN_DB }; } const ratio = pct / 100; const thresholdDb = SDR_SQUELCH_MIN_DB + ratio * (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB); return { enabled: true, thresholdDb }; } function sdrSquelchServerToPercent(enabled, thresholdDb) { if (!enabled) return 0; if (!isFiniteNumber(thresholdDb)) return 0; const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB); return clampSdrSquelchPercent(ratio * 100); } function updateSdrSquelchPctLabel() { if (!sdrSquelchEl || !sdrSquelchPctEl) return; const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`; } function updateSdrSquelchControlVisibility() { if (!sdrSquelchWrapEl) return; const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none"; } function syncSdrSquelchFromServer(enabled, thresholdDb) { if (!sdrSquelchEl) return; if (document.activeElement === sdrSquelchEl) return; const pct = sdrSquelchServerToPercent(enabled, thresholdDb); syncFromServerSdrSquelch = true; sdrSquelchEl.value = String(pct); updateSdrSquelchPctLabel(); syncFromServerSdrSquelch = false; saveSetting("sdrSquelchPct", pct); } function submitSdrSquelchPercent(percent) { if (!sdrSquelchSupported) return; const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent); postPath( `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}` ).catch(() => { }); } if (sdrSquelchEl) { const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0))); sdrSquelchEl.value = String(savedPct); updateSdrSquelchPctLabel(); sdrSquelchEl.addEventListener("input", () => { const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); sdrSquelchEl.value = String(pct); updateSdrSquelchPctLabel(); saveSetting("sdrSquelchPct", pct); if (!syncFromServerSdrSquelch) { submitSdrSquelchPercent(pct); } }); } var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto"); if (sdrSquelchAutoBtn) { sdrSquelchAutoBtn.addEventListener("click", () => { if (!sdrSquelchSupported) return; let pct = 0; const data = lastSpectrumData || window.lastSpectrumData; if (data && isNumericBins(data.bins) && data.bins.length > 0) { const noiseDb = estimateNoiseFloorDb(data.bins); if (noiseDb != null && isFiniteNumber(noiseDb)) { const thresholdDb = noiseDb + 6; const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb)); pct = clampSdrSquelchPercent( (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100 ); } } if (sdrSquelchEl) { sdrSquelchEl.value = String(pct); updateSdrSquelchPctLabel(); saveSetting("sdrSquelchPct", pct); } submitSdrSquelchPercent(pct); }); } if (wfmAudioModeEl) { wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo"); wfmAudioModeEl.addEventListener("change", () => { saveSetting("wfmAudioMode", wfmAudioModeEl.value); const enabled = wfmAudioModeEl.value !== "mono"; postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => { }); }); } if (wfmDenoiseEl) { wfmDenoiseEl.value = normalizeWfmDenoiseLevel(loadSetting("wfmDenoise", "auto")); wfmDenoiseEl.addEventListener("change", () => { const level = normalizeWfmDenoiseLevel(wfmDenoiseEl.value); wfmDenoiseEl.value = level; saveSetting("wfmDenoise", level); postPath(`/set_wfm_denoise?level=${encodeURIComponent(level)}`).catch(() => { }); }); } if (wfmDeemphasisEl) { wfmDeemphasisEl.addEventListener("change", () => { postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => { }); }); } if (samStereoWidthEl) { samStereoWidthEl.addEventListener("input", () => { const width = Number(samStereoWidthEl.value) / 100; postPath(`/set_sam_stereo_width?width=${width}`).catch(() => { }); }); } if (samCarrierSyncEl) { samCarrierSyncEl.addEventListener("change", () => { const enabled = samCarrierSyncEl.value === "on"; postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => { }); }); } function submitSdrGain() { if (!sdrGainEl) return; const parsed = Number.parseFloat(sdrGainEl.value); if (!isFiniteNumber(parsed) || parsed < 0) return; postPath(`/set_sdr_gain?db=${encodeURIComponent(parsed)}`).catch(() => { }); } function updateSdrGainInputState() { if (!sdrAgcEl) return; const agcOn = sdrAgcEl.checked; if (sdrGainEl) sdrGainEl.disabled = agcOn; if (sdrGainSetBtn) sdrGainSetBtn.disabled = agcOn; if (sdrLnaGainEl) sdrLnaGainEl.disabled = agcOn; if (sdrLnaGainSetBtn) sdrLnaGainSetBtn.disabled = agcOn; } if (sdrAgcEl) { sdrAgcEl.addEventListener("change", () => { postPath(`/set_sdr_agc?enabled=${sdrAgcEl.checked ? "true" : "false"}`).catch(() => { }); updateSdrGainInputState(); }); } if (sdrGainSetBtn) { sdrGainSetBtn.addEventListener("click", submitSdrGain); } if (sdrGainEl) { sdrGainEl.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); submitSdrGain(); } }); } function submitSdrLnaGain() { if (!sdrLnaGainEl) return; const parsed = Number.parseFloat(sdrLnaGainEl.value); if (!isFiniteNumber(parsed) || parsed < 0) return; postPath(`/set_sdr_lna_gain?db=${encodeURIComponent(parsed)}`).catch(() => { }); } if (sdrLnaGainSetBtn) { sdrLnaGainSetBtn.addEventListener("click", submitSdrLnaGain); } if (sdrLnaGainEl) { sdrLnaGainEl.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); submitSdrLnaGain(); } }); } function submitSdrNbState() { if (!sdrNbSupported) return; const enabled = sdrNbEnabledEl ? sdrNbEnabledEl.checked : false; const threshold = sdrNbThresholdEl ? Number.parseFloat(sdrNbThresholdEl.value) : 10; if (!isFiniteNumber(threshold) || threshold < 1 || threshold > 100) return; postPath( `/set_sdr_noise_blanker?enabled=${enabled ? "true" : "false"}&threshold=${encodeURIComponent(threshold)}` ).catch(() => { }); } if (sdrNbEnabledEl) { sdrNbEnabledEl.addEventListener("change", () => { submitSdrNbState(); }); } function submitSdrNbThreshold() { if (!sdrNbThresholdEl) return; const parsed = Number.parseFloat(sdrNbThresholdEl.value); if (!isFiniteNumber(parsed) || parsed < 1 || parsed > 100) return; submitSdrNbState(); } if (sdrNbThresholdSetBtn) { sdrNbThresholdSetBtn.addEventListener("click", submitSdrNbThreshold); } if (sdrNbThresholdEl) { sdrNbThresholdEl.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { ev.preventDefault(); submitSdrNbThreshold(); } }); } function updateWfmControls() { const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); if (wfmControlsCol) wfmControlsCol.style.display = mode === "WFM" ? "" : "none"; if (samControlsCol) samControlsCol.style.display = mode === "SAM" ? "" : "none"; } if (!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 = ''; var _audioIconPause = ''; 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 = '

No active recordings.

'; return; } let html = ''; for (const r of list) { const started = new Date(r.started_at * 1e3).toLocaleTimeString(); const fname = r.path.split("/").pop(); html += ``; } html += "
RigVChanFileStarted
${escapeHtml(r.rig_id)}${r.vchan_id ? escapeHtml(r.vchan_id) : "-"}${escapeHtml(fname)}${started}
"; 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 = '

' + (filter ? "No files match filter." : "No recorded files.") + "

"; return; } let html = ''; for (const f of page) { const safeName = escapeHtml(f.name); const encodedName = encodeURIComponent(f.name); html += ''; } html += "
FileSizeActions
' + safeName + "" + formatByteSize(f.size) + '
Download
"; el.innerHTML = html; el.querySelectorAll(".rec-play-btn").forEach(function(btn) { btn.addEventListener("click", function() { const row = btn.closest("tr"); if (!row) return; const next = row.nextElementSibling; if (next && next.classList.contains("rec-player-row")) { const audio2 = next.querySelector("audio"); if (audio2) { try { audio2.pause(); } catch (_) { } } next.remove(); btn.setAttribute("aria-expanded", "false"); btn.textContent = "Play"; return; } el.querySelectorAll(".rec-player-row").forEach(function(r) { const a = r.querySelector("audio"); if (a) { try { a.pause(); } catch (_) { } } r.remove(); }); el.querySelectorAll(".rec-play-btn").forEach(function(b) { b.setAttribute("aria-expanded", "false"); b.textContent = "Play"; }); const playerRow = document.createElement("tr"); playerRow.className = "rec-player-row"; const cell = document.createElement("td"); cell.colSpan = 3; const audio = document.createElement("audio"); audio.controls = true; audio.preload = "metadata"; audio.src = btn.dataset.url ?? ""; audio.className = "rec-player-audio"; cell.appendChild(audio); playerRow.appendChild(cell); row.parentNode?.insertBefore(playerRow, row.nextSibling); btn.setAttribute("aria-expanded", "true"); btn.textContent = "Hide"; void audio.play().catch(() => { }); }); }); el.querySelectorAll(".rec-delete-btn").forEach(function(btn) { btn.addEventListener("click", async function() { const name = btn.dataset.name ?? ""; if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return; try { const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" }); if (!resp.ok) throw new Error("HTTP " + resp.status); _recorderFiles = _recorderFiles.filter(function(f) { return f.name !== name; }); renderRecorderFiles(); } catch (e) { console.error("Delete failed", e); window.trxUi?.notify("Recording could not be deleted", { kind: "error" }); } }); }); } (function() { const filterEl = document.getElementById("recorder-filter"); const sortEl = document.getElementById("recorder-sort"); if (filterEl) filterEl.addEventListener("input", function() { _recFilesPage = 0; renderRecorderFiles(); }); if (sortEl) sortEl.addEventListener("change", function() { _recFilesPage = 0; renderRecorderFiles(); }); const prevBtn = document.getElementById("rec-page-prev"); const nextBtn = document.getElementById("rec-page-next"); if (prevBtn) prevBtn.addEventListener("click", function() { _recFilesPage--; renderRecorderFiles(); }); if (nextBtn) nextBtn.addEventListener("click", function() { _recFilesPage++; renderRecorderFiles(); }); })(); var rxVolPct = requiredElement("rx-vol-pct"); var txVolPct = requiredElement("tx-vol-pct"); rxVolSlider.value = String(loadSetting("rxVol", 80)); txVolSlider.value = String(loadSetting("txVol", 80)); rxVolPct.textContent = `${rxVolSlider.value}%`; txVolPct.textContent = `${txVolSlider.value}%`; function updateVolSlider(slider, pctEl, gainNode) { pctEl.textContent = `${slider.value}%`; if (gainNode) gainNode.gain.value = Number(slider.value) / 100; } rxVolSlider.addEventListener("input", () => { updateVolSlider(rxVolSlider, rxVolPct, rxGainNode); saveSetting("rxVol", Number(rxVolSlider.value)); }); txVolSlider.addEventListener("input", () => { updateVolSlider(txVolSlider, txVolPct, txGainNode); saveSetting("txVol", Number(txVolSlider.value)); }); function volWheel(slider, pctEl, getGain, storageKey) { slider.addEventListener("wheel", (e) => { e.preventDefault(); const step = e.deltaY < 0 ? 2 : -2; slider.value = String(Math.max(0, Math.min(100, Number(slider.value) + step))); updateVolSlider(slider, pctEl, getGain()); saveSetting(storageKey, Number(slider.value)); }, { passive: false }); } volWheel(rxVolSlider, rxVolPct, () => rxGainNode, "rxVol"); volWheel(txVolSlider, txVolPct, () => txGainNode, "txVol"); if (sdrSquelchEl) { sdrSquelchEl.addEventListener("wheel", (e) => { e.preventDefault(); const step = e.deltaY < 0 ? 2 : -2; const next = clampSdrSquelchPercent(Number(sdrSquelchEl.value) + step); sdrSquelchEl.value = String(next); updateSdrSquelchPctLabel(); saveSetting("sdrSquelchPct", next); submitSdrSquelchPercent(next); }, { passive: false }); } requiredElement("copyright-year").textContent = String((/* @__PURE__ */ new Date()).getFullYear()); var decodeSource = null; var decodeHistoryWorker = null; function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) { if (!el) return; const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); const isActiveMode = activeModes.includes(modeUpper); if (el.textContent === "Receiving" && isActiveMode) return; el.textContent = isActiveMode ? connectedText : inactiveText; } var _decodeConnectedText = { vdes: "Connected, listening for bursts", cw: "Connected, listening for CW" }; function updateDecodeStatus(text) { for (const d of decoderRegistry) { if (d.activation !== "mode_bound") continue; const el = document.getElementById(d.id + "-status"); if (!el) continue; const connText = _decodeConnectedText[d.id] || text; setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText); } for (const d of decoderRegistry) { if (d.activation !== "toggle") continue; const el = document.getElementById(d.id + "-status"); if (el && el.textContent !== "Receiving") el.textContent = text; } } function dispatchDecodeMessage(msg, skipStats = false) { if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg); if (!skipStats && msg.type && msg.type !== "lrpt_image" && msg.type !== "lrpt_progress" && msg.type !== "wefax" && msg.type !== "wefax_progress") { window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null); window.trx.modules.map?.scheduleStatsRender(); } } var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; var DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS = 8; function terminateDecodeHistoryWorker() { if (!decodeHistoryWorker) return; try { decodeHistoryWorker.terminate(); } catch (_) { } decodeHistoryWorker = null; } function scheduleDecodeHistoryDrainStep(callback) { if (typeof callback !== "function") return; if (typeof requestAnimationFrame === "function") { requestAnimationFrame(() => callback()); } else { setTimeout(callback, 16); } } function decodeHistoryUrl() { return "/decode/history"; } function loadDecodeHistoryOnMainThread(onReady, onError) { fetch(decodeHistoryUrl()).then(async (resp) => { if (!resp.ok) return null; setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Receiving compressed history payload"); const payload = await resp.arrayBuffer(); if (!payload || payload.byteLength === 0) return {}; setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history payload"); return decodeCbor(payload); }).then((groups) => { if (groups && typeof groups === "object" && !Array.isArray(groups)) { onReady(groups); } else { onReady({}); } }).catch((err) => { if (typeof onError === "function") onError(err); }); } function restoreDecodeHistoryGroup(kind, messages) { if (!Array.isArray(messages) || messages.length === 0) return; if (kind !== "lrpt_image" && kind !== "lrpt_progress" && kind !== "wefax" && kind !== "wefax_progress") { for (const msg of messages) { window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0); } window.trx.modules.map?.scheduleStatsRender(); } window.trxPluginRuntime.restore(kind, messages); } function connectDecode() { if (decodeSource) { decodeSource.close(); } terminateDecodeHistoryWorker(); decodeHistoryReplayActive = false; decodeMapSyncPending = false; window.trxPluginRuntime.clearQueued(); window.trxPluginRuntime.resetAll(); let historySettled = false; let historyWorkerDone = false; let historyFallbackStarted = false; let historyBatchDrainScheduled = false; let historyTotal = 0; let historyProcessed = 0; const historyGroupQueue = []; const liveBuffer = []; function flushLiveBuffer() { historySettled = true; terminateDecodeHistoryWorker(); setDecodeHistoryReplayActive(false); setDecodeHistoryOverlayVisible(false); for (const msg of liveBuffer) { try { dispatchDecodeMessage(msg); } catch (_) { } } liveBuffer.length = 0; } function updateHistoryReplayOverlay() { setDecodeHistoryOverlayVisible( true, "Loading decode history…", `Replaying ${historyProcessed} / ${historyTotal} decoded messages` ); } function maybeFinishHistoryReplay() { if (historySettled) return; if (historyWorkerDone && historyGroupQueue.length === 0) { clearTimeout(historyTimeout); flushLiveBuffer(); } } function pumpDecodeHistoryGroupQueue() { historyBatchDrainScheduled = false; const startedAt = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0; while (historyGroupQueue.length > 0) { const next = historyGroupQueue.shift(); if (!next) continue; restoreDecodeHistoryGroup(next.kind, next.messages); historyProcessed += Array.isArray(next.messages) ? next.messages.length : 0; updateHistoryReplayOverlay(); if (startedAt > 0 && performance.now() - startedAt >= DECODE_HISTORY_BATCH_DRAIN_BUDGET_MS) { break; } } if (historyGroupQueue.length > 0) { scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue); historyBatchDrainScheduled = true; return; } maybeFinishHistoryReplay(); } function enqueueDecodeHistoryGroup(kind, messages) { if (!Array.isArray(messages) || messages.length === 0) return; historyGroupQueue.push({ kind, messages }); if (historyBatchDrainScheduled) return; historyBatchDrainScheduled = true; scheduleDecodeHistoryDrainStep(pumpDecodeHistoryGroupQueue); } function totalDecodeHistoryMessages(groups) { if (!groups || typeof groups !== "object") return 0; return ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"].reduce((sum, key) => sum + (Array.isArray(groups[key]) ? groups[key].length : 0), 0); } function enqueueDecodeHistoryGroups(groups) { historyTotal = totalDecodeHistoryMessages(groups); historyProcessed = 0; if (historyTotal > 0) { setDecodeHistoryReplayActive(true); updateHistoryReplayOverlay(); } for (const kind of ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr"]) { const messages = groups && Array.isArray(groups[kind]) ? groups[kind] : []; if (messages.length === 0) continue; for (let index = 0; index < messages.length; index += DECODE_HISTORY_WORKER_GROUP_LIMIT) { enqueueDecodeHistoryGroup(kind, messages.slice(index, index + DECODE_HISTORY_WORKER_GROUP_LIMIT)); } } historyWorkerDone = true; maybeFinishHistoryReplay(); } function startDecodeHistoryFallback() { if (historyFallbackStarted || historySettled) return; historyFallbackStarted = true; loadDecodeHistoryOnMainThread((groups) => { clearTimeout(historyTimeout); const total = totalDecodeHistoryMessages(groups); if (total > 0) { enqueueDecodeHistoryGroups(groups); } else { flushLiveBuffer(); } }, (err) => { console.error("Decode history fallback failed", err); clearTimeout(historyTimeout); flushLiveBuffer(); }); } function startDecodeHistoryWorkerReplay() { if (typeof Worker !== "function") return false; let worker; try { worker = new Worker("/decode-history-worker.js"); } catch (err) { console.error("Decode history worker startup failed", err); return false; } decodeHistoryWorker = worker; worker.onmessage = (evt) => { if (historySettled || worker !== decodeHistoryWorker) return; const data = evt.data; if (!isRecord2(data) || typeof data.type !== "string") return; if (data.type === "status") { const phase = primitiveString(data.phase); if (phase === "fetching") { setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); } else if (phase === "decoding") { setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Decoding compressed history in background"); } return; } if (data.type === "start") { historyTotal = Math.max(0, Number(data.total) || 0); historyProcessed = 0; if (historyTotal > 0) { setDecodeHistoryReplayActive(true); updateHistoryReplayOverlay(); } return; } if (data.type === "group") { const messages = Array.isArray(data.messages) ? data.messages.filter((message) => isRecord2(message) && typeof message.type === "string") : []; enqueueDecodeHistoryGroup(typeof data.kind === "string" ? data.kind : "", messages); return; } if (data.type === "done") { historyWorkerDone = true; clearTimeout(historyTimeout); terminateDecodeHistoryWorker(); maybeFinishHistoryReplay(); return; } if (data.type === "error") { console.error("Decode history worker failed", typeof data.message === "string" ? data.message : "unknown worker failure"); terminateDecodeHistoryWorker(); startDecodeHistoryFallback(); } }; worker.postMessage({ type: "fetch-history", url: decodeHistoryUrl(), batchLimit: DECODE_HISTORY_WORKER_GROUP_LIMIT }); return true; } const historyTimeout = setTimeout(() => { if (!historySettled) { terminateDecodeHistoryWorker(); flushLiveBuffer(); } }, 2e4); setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer"); decodeSource = new EventSource("/decode"); const source = decodeSource; source.onopen = () => { updateDecodeStatus("Connected, listening for packets"); }; source.onmessage = (evt) => { try { const msg = parseJsonUnknown(evt.data); if (!isRecord2(msg) || typeof msg.type !== "string") return; const decoded = { ...msg, type: msg.type }; if (historySettled) dispatchDecodeMessage(decoded); else liveBuffer.push(decoded); } catch (e) { } }; source.onerror = () => { const wasClosed = source.readyState === 2; source.close(); terminateDecodeHistoryWorker(); if (!historySettled) flushLiveBuffer(); if (wasClosed) { updateDecodeStatus("Decode not available (check client audio config)"); setTimeout(connectDecode, 1e4); } else { updateDecodeStatus("Decode disconnected, retrying…"); setTimeout(connectDecode, 5e3); } }; if (!startDecodeHistoryWorkerReplay()) { startDecodeHistoryFallback(); } } window.addEventListener("beforeunload", () => { if (txActive) { navigator.sendBeacon("/set_ptt?ptt=false", ""); } }); var spectrumCanvas = document.getElementById("spectrum-canvas"); var spectrumGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions) : null; var spectrumDbAxis = document.getElementById("spectrum-db-axis"); var spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); var spectrumTooltip = document.getElementById("spectrum-tooltip"); var spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn"); var spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn"); var spectrumSource = null; var spectrumReconnectTimer = null; var meterSource = null; var meterReconnectTimer = null; var spectrumDrawPending = false; var spectrumAxisKey = ""; var spectrumDbAxisKey = ""; var lastSpectrumRenderData = null; var spectrumPeakHoldFrames = []; var pendingSpectrumFrameWaiters = []; var sweetSpotScanInFlight = false; var spectrumTmpGridSegments = []; var spectrumTmpFillPoints = []; var spectrumTmpPeakPoints = []; var spectrumTmpMarkerPoints = []; var spectrumZoom = 1; var spectrumPanFrac = 0.5; var spectrumFloor = -115; var spectrumRange = 90; var waterfallGamma = 1; var SPECTRUM_HEADROOM_DB = 20; var SPECTRUM_SMOOTH_ALPHA = 0.42; var spectrumCrosshairX = null; var spectrumCrosshairY = null; var _bwDragEdge = null; var _bwDragStartX = 0; var _bwDragStartBwHz = 0; var _bwDragCanvas = null; function spectrumBgColor() { return canvasPalette().bg; } function clearSpectrumPeakHoldFrames() { spectrumPeakHoldFrames = []; } function settlePendingSpectrumFrameWaiters(frame) { if (!pendingSpectrumFrameWaiters.length) return; const remaining = []; for (const waiter of pendingSpectrumFrameWaiters) { if (!waiter) continue; const targetCenterHz = Number(waiter.targetCenterHz); if (isFiniteNumber(targetCenterHz) && (!frame || Math.abs(Number(frame.center_hz) - targetCenterHz) >= 2)) { remaining.push(waiter); continue; } if (waiter.timer) { clearTimeout(waiter.timer); waiter.timer = null; } if (typeof waiter.resolve === "function") { waiter.resolve(frame); } } pendingSpectrumFrameWaiters = remaining; } function rejectPendingSpectrumFrameWaiters(error) { if (!pendingSpectrumFrameWaiters.length) return; for (const waiter of pendingSpectrumFrameWaiters) { if (!waiter) continue; if (waiter.timer) { clearTimeout(waiter.timer); waiter.timer = null; } if (typeof waiter.reject === "function") { waiter.reject(error || new Error("Spectrum unavailable")); } } pendingSpectrumFrameWaiters = []; } function waitForSpectrumFrame(expectedCenterHz = null, timeoutMs = 1200) { const targetCenterHz = expectedCenterHz; if (lastSpectrumData && (!isFiniteNumber(targetCenterHz) || Math.abs(Number(lastSpectrumData.center_hz) - targetCenterHz) < 2)) { return Promise.resolve(lastSpectrumData); } return new Promise((resolve, reject) => { const waiter = { targetCenterHz, resolve, reject, timer: null }; waiter.timer = setTimeout(() => { pendingSpectrumFrameWaiters = pendingSpectrumFrameWaiters.filter((entry) => entry !== waiter); reject(new Error("Timed out waiting for spectrum frame")); }, Math.max(200, timeoutMs)); pendingSpectrumFrameWaiters.push(waiter); }); } function pruneSpectrumPeakHoldFrames(now = Date.now()) { const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); if (holdMs <= 0) { clearSpectrumPeakHoldFrames(); return; } let removeCount = 0; for (let i = 0; i < spectrumPeakHoldFrames.length; i++) { const f = spectrumPeakHoldFrames[i]; if (f && isNumericBins(f.bins) && now - f.t <= holdMs) break; removeCount++; } if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); } function pushSpectrumPeakHoldFrame(frame) { if (!frame || !isNumericBins(frame.bins) || frame.bins.length === 0) { clearSpectrumPeakHoldFrames(); return; } const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); if (holdMs <= 0) { clearSpectrumPeakHoldFrames(); return; } const now = Date.now(); pruneSpectrumPeakHoldFrames(now); const lastFrame = spectrumPeakHoldFrames[spectrumPeakHoldFrames.length - 1]; if (lastFrame && lastFrame.bins.length !== frame.bins.length) { clearSpectrumPeakHoldFrames(); } spectrumPeakHoldFrames.push({ t: now, bins: frame.bins.slice() }); } function buildSpectrumPeakHoldBins(currentBins) { const holdMs = Math.max(0, isFiniteNumber(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); if (holdMs <= 0 || !isNumericBins(currentBins) || currentBins.length === 0) { return null; } pruneSpectrumPeakHoldFrames(); if (spectrumPeakHoldFrames.length === 0) return null; const peakBins = currentBins.slice(); for (const frame of spectrumPeakHoldFrames) { if (!frame || !isNumericBins(frame.bins) || frame.bins.length !== peakBins.length) continue; for (let i = 0; i < peakBins.length; i++) { const frameValue = frame.bins[i]; const peakValue = peakBins[i]; if (frameValue !== void 0 && peakValue !== void 0 && frameValue > peakValue) peakBins[i] = frameValue; } } return peakBins; } var _smoothBins = []; function buildSpectrumRenderData(frame) { if (!frame || !isNumericBins(frame.bins)) return frame; const n = frame.bins.length; const prev = lastSpectrumRenderData; const canBlend = prev && isNumericBins(prev.bins) && prev.bins.length === n && prev.sample_rate === frame.sample_rate && prev.center_hz === frame.center_hz; if (_smoothBins.length !== n) _smoothBins = new Array(n); const src = frame.bins; if (canBlend) { const prevBins = prev.bins; const alpha = SPECTRUM_SMOOTH_ALPHA; for (let i = 0; i < n; i++) { const previous = prevBins[i] ?? 0; _smoothBins[i] = previous + ((src[i] ?? previous) - previous) * alpha; } } else { for (let i = 0; i < n; i++) _smoothBins[i] = src[i] ?? 0; } return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds ?? null }; } function spectrumVisibleRange(data) { const fullSpanHz = data.sample_rate; const loHz = data.center_hz - fullSpanHz / 2; const halfVis = 0.5 / spectrumZoom; spectrumPanFrac = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis); const visCenterHz = loHz + spectrumPanFrac * fullSpanHz; const visSpanHz = fullSpanHz / spectrumZoom; return { loHz, hiHz: loHz + fullSpanHz, visLoHz: visCenterHz - visSpanHz / 2, visHiHz: visCenterHz + visSpanHz / 2, fullSpanHz, visSpanHz }; } function canvasXToHz(cssX, cssW, range) { return range.visLoHz + cssX / cssW * range.visSpanHz; } function nearestSpectrumPeak(cssX, cssW, data) { if (!data || !isNumericBins(data.bins) || data.bins.length === 0 || cssW <= 0) { return null; } const bins = data.bins; const maxIdx = bins.length - 1; const range = spectrumVisibleRange(data); const fullLoHz = data.center_hz - data.sample_rate / 2; const targetHz = canvasXToHz(cssX, cssW, range); const targetIdx = Math.max( 0, Math.min(maxIdx, Math.round((targetHz - fullLoHz) / data.sample_rate * maxIdx)) ); const visStartIdx = Math.max( 0, Math.min(maxIdx, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx)) ); const visEndIdx = Math.max( visStartIdx, Math.min(maxIdx, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx)) ); const visSpanBins = Math.max(1, visEndIdx - visStartIdx); const searchRadius = Math.max(3, Math.min(80, Math.round(24 / cssW * visSpanBins))); const searchLo = Math.max(1, targetIdx - searchRadius); const searchHi = Math.min(maxIdx - 1, targetIdx + searchRadius); let windowMax = -Infinity; const localPeaks = []; for (let i = searchLo; i <= searchHi; i++) { const val = bins[i] ?? -Infinity; if (val > windowMax) windowMax = val; if (val >= (bins[i - 1] ?? -Infinity) && val >= (bins[i + 1] ?? -Infinity)) { localPeaks.push(i); } } const candidates = localPeaks.filter((i) => (bins[i] ?? -Infinity) >= windowMax - 6); const ranked = (candidates.length ? candidates : localPeaks).sort((a, b) => { const dist = Math.abs(a - targetIdx) - Math.abs(b - targetIdx); if (dist !== 0) return dist; return (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity); }); let snappedIdx = ranked[0]; if (snappedIdx == null) { snappedIdx = targetIdx; for (let i = searchLo; i <= searchHi; i++) { if ((bins[i] ?? -Infinity) > (bins[snappedIdx] ?? -Infinity)) snappedIdx = i; } } return { index: snappedIdx, hz: Math.round(fullLoHz + snappedIdx / maxIdx * data.sample_rate), db: bins[snappedIdx] ?? -Infinity }; } function nearestSpectrumPeakHz(cssX, cssW, data) { return nearestSpectrumPeak(cssX, cssW, data)?.hz ?? null; } function spectrumTargetHzAt(cssX, cssW, data) { if (!data) return null; const range = spectrumVisibleRange(data); return nearestSpectrumPeakHz(cssX, cssW, data) ?? Math.round(canvasXToHz(cssX, cssW, range)); } function visibleSpectrumPeakIndices(data, limit = 24) { if (!data || !isNumericBins(data.bins) || data.bins.length < 3) { return []; } const bins = data.bins; const maxIdx = bins.length - 1; const range = spectrumVisibleRange(data); const fullLoHz = data.center_hz - data.sample_rate / 2; const visStartIdx = Math.max( 1, Math.min(maxIdx - 1, Math.floor((range.visLoHz - fullLoHz) / data.sample_rate * maxIdx)) ); const visEndIdx = Math.max( visStartIdx, Math.min(maxIdx - 1, Math.ceil((range.visHiHz - fullLoHz) / data.sample_rate * maxIdx)) ); const peaks = []; for (let i = visStartIdx; i <= visEndIdx; i++) { const v = bins[i] ?? -Infinity; if (v >= (bins[i - 1] ?? -Infinity) && v >= (bins[i + 1] ?? -Infinity)) { peaks.push(i); } } if (peaks.length === 0) { return []; } const peakValues = peaks.map((i) => bins[i] ?? -Infinity).sort((a, b) => a - b); const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))] ?? -Infinity; return peaks.filter((i) => (bins[i] ?? -Infinity) >= cutoff).sort((a, b) => (bins[b] ?? -Infinity) - (bins[a] ?? -Infinity)).slice(0, limit).sort((a, b) => a - b); } function formatSpectrumFreq(hz) { if (jogUnit >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; if (jogUnit >= 1e3) return (hz / 1e3).toFixed(3) + " kHz"; return hz.toFixed(0) + " Hz"; } function scheduleSpectrumReconnect() { if (spectrumReconnectTimer !== null) return; spectrumReconnectTimer = setTimeout(() => { spectrumReconnectTimer = null; startSpectrumStreaming(); }, 1e3); } function startSpectrumStreaming() { if (spectrumSource !== null) return; const spectrumUrl = lastActiveRigId ? `/spectrum?remote=${encodeURIComponent(lastActiveRigId)}` : "/spectrum"; spectrumSource = new EventSource(spectrumUrl); const source = spectrumSource; source.onmessage = (evt) => { if (evt.data === "null") { rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream reset")); lastSpectrumData = null; lastSpectrumRenderData = null; clearSpectrumPeakHoldFrames(); overviewWaterfallRows = []; overviewWaterfallPushCount = 0; overviewWfResetTextureCache(); spectrumWfRows = []; spectrumWfPushCount = 0; spectrumWfTexReady = false; scheduleOverviewDraw(); clearSpectrumCanvas(); updateRdsPsOverlay(null); } }; source.addEventListener("b", (evt) => { try { const eventData = messageEventData(evt); const commaA = eventData.indexOf(","); const commaB = eventData.indexOf(",", commaA + 1); const centerHz = Number(eventData.slice(0, commaA)); const sampleRate = Number(eventData.slice(commaA + 1, commaB)); const b64 = eventData.slice(commaB + 1); const hadSpectrum = !!lastSpectrumData; const bins = decodeBase64Int8(b64); const rds = lastSpectrumData?.rds; const frame = { bins, center_hz: centerHz, sample_rate: sampleRate, rds: rds ?? null }; lastSpectrumData = frame; window.lastSpectrumData = lastSpectrumData; const spectrumSummary = document.getElementById("spectrum-text-summary"); if (spectrumSummary && bins.length) { let peakIndex = 0; for (let i = 1; i < bins.length; i += 1) if ((bins[i] ?? -Infinity) > (bins[peakIndex] ?? -Infinity)) peakIndex = i; const peakHz = centerHz - sampleRate / 2 + peakIndex / Math.max(1, bins.length - 1) * sampleRate; spectrumSummary.textContent = `Spectrum centered at ${formatFrequencyForHumans(centerHz)}, spanning ${formatFrequencyForHumans(sampleRate)}. Strongest visible bin near ${formatFrequencyForHumans(peakHz)} at ${bins[peakIndex]} dB.`; } if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1e3) { spectrumCenterPendingHz = null; } const renderFrame = buildSpectrumRenderData(frame); lastSpectrumRenderData = renderFrame; settlePendingSpectrumFrameWaiters(frame); pushSpectrumPeakHoldFrame(renderFrame); pushOverviewWaterfallFrame(frame); pushSpectrumWaterfallFrame(frame); refreshCenterFreqDisplay(); if (window.refreshCwTonePicker) window.refreshCwTonePicker(); scheduleSpectrumDraw(); if (!hadSpectrum) { updateRdsPsOverlay(frame.rds); } else { positionRdsPsOverlay(); } } catch (_) { } }); source.addEventListener("rds", (evt) => { try { const eventData = messageEventData(evt); const value = eventData === "null" ? void 0 : parseJsonUnknown(eventData); const rds = value !== void 0 && isRdsData(value) ? value : null; if (lastSpectrumData) lastSpectrumData.rds = rds; updateRdsPsOverlay(rds ?? null); } catch (_) { } }); source.addEventListener("rds_vchan", (evt) => { try { const eventData = messageEventData(evt); const value = eventData === "null" ? [] : parseJsonUnknown(eventData); const payload = Array.isArray(value) ? value.filter(isVchanRdsEntry) : []; const next = /* @__PURE__ */ new Map(); const nextSig = /* @__PURE__ */ new Map(); payload.forEach((entry) => { next.set(entry.id, entry.rds ?? null); if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db); }); vchanRdsById = next; vchanSignalDbById = nextSig; const virtualChannelId = window.trx.modules.vchan?.activeId; if (virtualChannelId && nextSig.has(virtualChannelId)) { sigLastDbm = nextSig.get(virtualChannelId) ?? null; refreshSigStrengthDisplay(); } updateRdsPsOverlay(primaryRds); } catch (_) { } }); source.onerror = () => { rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected")); if (spectrumSource) { spectrumSource.close(); spectrumSource = null; } scheduleSpectrumReconnect(); }; } function stopSpectrumStreaming() { if (spectrumSource !== null) { spectrumSource.close(); spectrumSource = null; } if (spectrumReconnectTimer !== null) { clearTimeout(spectrumReconnectTimer); spectrumReconnectTimer = null; } spectrumDrawPending = false; lastSpectrumData = null; lastSpectrumRenderData = null; rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped")); clearSpectrumPeakHoldFrames(); overviewWaterfallRows = []; overviewWaterfallPushCount = 0; overviewWfResetTextureCache(); spectrumWfRows = []; spectrumWfPushCount = 0; spectrumWfTexReady = false; scheduleOverviewDraw(); updateRdsPsOverlay(null); clearSpectrumCanvas(); } var METER_ATTACK_ALPHA = 0.08; var METER_DECAY_ALPHA = 0.03; var meterSmoothedDbm = null; var meterRafPending = false; function scheduleMeterReconnect() { if (meterReconnectTimer !== null) return; meterReconnectTimer = setTimeout(() => { meterReconnectTimer = null; startMeterStreaming(); }, 1e3); } function applyMeterSample(dbm) { if (typeof dbm !== "number" || !isFiniteNumber(dbm)) return; if (meterSmoothedDbm === null) { meterSmoothedDbm = dbm; } else { const alpha = dbm > meterSmoothedDbm ? METER_ATTACK_ALPHA : METER_DECAY_ALPHA; meterSmoothedDbm += alpha * (dbm - meterSmoothedDbm); } if (!meterRafPending) { meterRafPending = true; requestAnimationFrame(flushMeterDom); } } function flushMeterDom() { meterRafPending = false; const dbm = meterSmoothedDbm; if (dbm === null) return; prevRenderData.sigDbm = dbm; const sUnits = dbmToSUnits(dbm); sigLastSUnits = sUnits; sigLastDbm = dbm; const pct = sUnits <= 9 ? Math.max(0, Math.min(100, sUnits / 9 * 100)) : 100; if (signalBar) signalBar.style.width = `${pct}%`; if (signalValue) signalValue.innerHTML = formatSignal(sUnits); refreshSigStrengthDisplay(); } function startMeterStreaming() { if (meterSource !== null) return; const url = lastActiveRigId ? `/meter?remote=${encodeURIComponent(lastActiveRigId)}` : "/meter"; meterSource = new EventSource(url); meterSource.onmessage = (evt) => { try { const value = parseJsonUnknown(evt.data); if (isRecord2(value) && typeof value.sig === "number") applyMeterSample(value.sig); } catch (_) { } }; meterSource.onerror = () => { if (meterSource) { meterSource.close(); meterSource = null; } scheduleMeterReconnect(); }; } function stopMeterStreaming() { if (meterSource !== null) { meterSource.close(); meterSource = null; } if (meterReconnectTimer !== null) { clearTimeout(meterReconnectTimer); meterReconnectTimer = null; } meterSmoothedDbm = null; } function clearSpectrumCanvas() { if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; const cssW = spectrumCanvas.clientWidth || 1; const cssH = spectrumCanvas.clientHeight || 1; spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1); spectrumGl.clear(cssColorToRgba(spectrumBgColor())); if (spectrumDbAxis) { spectrumDbAxis.replaceChildren(); spectrumDbAxisKey = ""; } } function formatOverlayPs(ps) { return primitiveString(ps).slice(0, 8).padEnd(8, "_").replaceAll(" ", "_"); } function formatPsHtml(ps) { const clipped = primitiveString(ps).slice(0, 8); let html = ""; for (let i = 0; i < 8; i += 1) { const ch = clipped[i]; if (ch == null || ch === " ") { html += `_`; } 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 `${label}`; } function formatRdsFlag(value, yes = "Yes", no = "No") { if (value == null) return "--"; return value ? yes : no; } function formatRdsAudio(value) { if (value == null) return "--"; return value ? "Music" : "Speech"; } function formatMinuteTimestamp(date = /* @__PURE__ */ new Date()) { const yyyy = date.getFullYear(); const mm = String(date.getMonth() + 1).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0"); const hh = String(date.getHours()).padStart(2, "0"); const min = String(date.getMinutes()).padStart(2, "0"); return `${yyyy}-${mm}-${dd} ${hh}:${min}`; } function buildRdsRawPayload(rds) { const freqHz = activeChannelFreqHz(); return { time: formatMinuteTimestamp(), freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null, ...rds }; } function formatRdsAfMHz(hz) { return `${(hz / 1e6).toFixed(1)} MHz`; } function tuneRdsAlternativeFrequency(hz) { if (!isFiniteNumber(hz) || hz <= 0) return; const targetHz = Math.round(hz); setRigFrequency(targetHz); showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200); } function renderRdsAlternativeFrequencies(list) { const afEl = document.getElementById("rds-af-list"); if (!afEl) return; const afs = Array.isArray(list) ? list.filter((hz) => isFiniteNumber(hz) && hz > 0).map((hz) => Math.round(hz)) : []; const afKey = afs.join(","); if (!afs.length) { if (afEl.dataset.afKey === "") return; afEl.dataset.afKey = ""; afEl.textContent = "--"; return; } if (afEl.dataset.afKey === afKey) return; afEl.dataset.afKey = afKey; afEl.replaceChildren(); for (const hz of afs) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "rds-af-btn"; btn.dataset.hz = String(hz); btn.textContent = formatRdsAfMHz(hz); afEl.appendChild(btn); } if (!afEl.childElementCount) afEl.textContent = "--"; } async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) { const rds = rdsOverride || activeChannelRds(); const ps = rds?.program_service; if (!rds || !ps || ps.length === 0) { showHint("No RDS PS", 1200); return; } const freqHz = isFiniteNumber(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz(); const freqMhz = isFiniteNumber(freqHz) ? (Math.round(freqHz / 1e5) / 10).toFixed(1) : "--.-"; const piHex = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; const clipPs = formatOverlayPs(ps); const clipText = `${formatMinuteTimestamp()} - ${freqMhz} MHz - ${piHex} - ${clipPs}`; try { await navigator.clipboard.writeText(clipText); showHint("RDS copied", 1200); } catch (_) { showHint("Clipboard failed", 1500); } } async function copyRdsRawToClipboard() { const rawEl = document.getElementById("rds-raw"); const rawText = rawEl?.textContent ?? ""; if (!rawText || rawText === "--") { showHint("No RDS JSON", 1200); return; } try { await navigator.clipboard.writeText(rawText); showHint("RDS JSON copied", 1200); } catch (_) { showHint("Clipboard failed", 1500); } } var rdsPsValueEl = document.getElementById("rds-ps"); if (rdsPsValueEl) { rdsPsValueEl.addEventListener("click", () => { void copyRdsPsToClipboard(); }); } var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); if (rdsRawCopyBtn) { rdsRawCopyBtn.addEventListener("click", () => { void copyRdsRawToClipboard(); }); } var rdsAfListEl = document.getElementById("rds-af-list"); if (rdsAfListEl) { rdsAfListEl.addEventListener("click", (event) => { const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null; const hz = Number(btn?.dataset?.hz); if (btn && isFiniteNumber(hz)) { tuneRdsAlternativeFrequency(hz); } }); } function updateRdsPsOverlay(rds) { primaryRds = rds || null; const activeRds = activeChannelRds(); updateDocumentTitle(activeRds); renderRdsOverlays(); const statusEl = document.getElementById("rds-status"); const modeEl2 = document.getElementById("rds-mode"); const piEl = document.getElementById("rds-pi"); const psEl = document.getElementById("rds-ps"); const ptyEl = document.getElementById("rds-pty"); const ptyNameEl = document.getElementById("rds-pty-name"); const ptynEl = document.getElementById("rds-ptyn"); const tpEl = document.getElementById("rds-tp"); const taEl = document.getElementById("rds-ta"); const musicEl = document.getElementById("rds-music"); const stereoEl = document.getElementById("rds-stereo"); const compEl = document.getElementById("rds-compressed"); const headEl = document.getElementById("rds-artificial-head"); const dynPtyEl = document.getElementById("rds-dynamic-pty"); const afEl = document.getElementById("rds-af-list"); const rtEl = document.getElementById("rds-radio-text"); const rawEl = document.getElementById("rds-raw"); if (!statusEl || !piEl || !psEl || !ptyEl || !ptyNameEl || !rawEl) return; if (modeEl2) modeEl2.textContent = document.getElementById("mode")?.value || "--"; if (!activeRds) { statusEl.textContent = "No signal"; statusEl.className = "rds-value rds-no-signal"; piEl.textContent = "--"; psEl.textContent = "--"; ptyEl.textContent = "--"; ptyNameEl.textContent = "--"; if (ptynEl) ptynEl.textContent = "--"; if (tpEl) tpEl.textContent = "--"; if (taEl) taEl.textContent = "--"; if (musicEl) musicEl.textContent = "--"; if (stereoEl) stereoEl.textContent = "--"; if (compEl) compEl.textContent = "--"; if (headEl) headEl.textContent = "--"; if (dynPtyEl) dynPtyEl.textContent = "--"; if (afEl) afEl.textContent = "--"; if (rtEl) rtEl.textContent = "--"; if (rawEl && lastSpectrumData) { const { bins: _b, ...rest } = lastSpectrumData; const freqHz = activeChannelFreqHz(); rawEl.textContent = JSON.stringify({ time: formatMinuteTimestamp(), freq_hz: isFiniteNumber(freqHz) ? Math.round(freqHz) : null, ...rest }, null, 2); } return; } statusEl.textContent = "Decoding"; statusEl.className = "rds-value rds-decoding"; piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; if (psEl) { if (activeRds.program_service) { psEl.innerHTML = formatPsHtml(activeRds.program_service); } else { psEl.textContent = "--"; } } ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--"); ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--"; if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--"; if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program); if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement); if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music); if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo); if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed); if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head); if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty); renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz); if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--"; rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2); } window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds); function scheduleSpectrumDraw() { if (spectrumDrawPending) return; spectrumDrawPending = true; requestAnimationFrame(() => { spectrumDrawPending = false; if (lastSpectrumRenderData) { drawSpectrum(lastSpectrumRenderData); if (overviewWaterfallRows.length > 0) scheduleOverviewDraw(); if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw(); } }); } function drawSpectrum(data) { if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; const dpr = _cachedDpr; const cssW = _cachedSpectrumCssW; const cssH = _cachedSpectrumCssH; spectrumGl.ensureSize(cssW, cssH, dpr); const W = spectrumCanvas.width; const H = spectrumCanvas.height; const pal = canvasPalette(); const range = spectrumVisibleRange(data); const bins = data.bins; const peakHoldBins = buildSpectrumPeakHoldBins(bins); const n = bins.length; spectrumGl.clear(cssColorToRgba(pal.bg)); if (!n) return; const DB_MIN = spectrumFloor; const DB_MAX = spectrumFloor + spectrumRange; const dbRange = DB_MAX - DB_MIN; const fullSpanHz = data.sample_rate; const loHz = data.center_hz - fullSpanHz / 2; const gridStep = spectrumRange > 100 ? 20 : 10; spectrumTmpGridSegments.length = 0; for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) { const y = Math.round(H * (1 - (db - DB_MIN) / dbRange)); spectrumTmpGridSegments.push(0, y, W, y); } spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1); updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr); function hzToX(hz) { return (hz - range.visLoHz) / range.visSpanHz * W; } function binX(i) { return hzToX(loHz + i / (n - 1) * fullSpanHz); } function binYFromBins(srcBins, i) { const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i] ?? DB_MIN)); return H * (1 - (db - DB_MIN) / dbRange); } spectrumTmpFillPoints.length = 0; for (let i = 0; i < n; i++) { spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i)); } spectrumGl.drawFilledArea(spectrumTmpFillPoints, H, cssColorToRgba(pal.spectrumFill)); if (isNumericBins(peakHoldBins) && peakHoldBins.length === n) { spectrumTmpPeakPoints.length = 0; for (let i = 0; i < n; i++) { spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, i)); } spectrumGl.drawPolyline(spectrumTmpPeakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9)); } spectrumGl.drawPolyline(spectrumTmpFillPoints, cssColorToRgba(pal.spectrumLine), Math.max(1, dpr)); const noiseDb = estimateNoiseFloorDb(bins); if (noiseDb != null && noiseDb >= DB_MIN && noiseDb <= DB_MAX) { const noiseY = Math.round(H * (1 - (noiseDb - DB_MIN) / dbRange)); const nfSegments = []; const dashLen = Math.max(4, Math.round(6 * dpr)); const gapLen = Math.max(3, Math.round(5 * dpr)); for (let x = 0; x < W; x += dashLen + gapLen) { nfSegments.push(x, noiseY, Math.min(W, x + dashLen), noiseY); } spectrumGl.drawSegments(nfSegments, rgbaWithAlpha(pal.waveformPeak, 0.35), Math.max(1, dpr * 0.8)); } const markerPeaks = visibleSpectrumPeakIndices(data); if (markerPeaks.length > 0) { spectrumTmpMarkerPoints.length = 0; for (const idx of markerPeaks) { spectrumTmpMarkerPoints.push(binX(idx), binYFromBins(bins, idx)); } spectrumGl.drawPoints(spectrumTmpMarkerPoints, Math.max(2, dpr * 1.6), cssColorToRgba(pal.waveformPeak)); } if (spectrumCrosshairX != null && spectrumCrosshairY != null) { const cx = spectrumCrosshairX * dpr; const cy = spectrumCrosshairY * dpr; const chColor = rgbaWithAlpha(pal.spectrumLabel, 0.5); spectrumGl.drawSegments([cx, 0, cx, H], chColor, Math.max(1, dpr * 0.6)); spectrumGl.drawSegments([0, cy, W, cy], chColor, Math.max(1, dpr * 0.6)); } if (_spectrumZoomEl) { if (spectrumZoom > 1.01) { _spectrumZoomEl.textContent = spectrumZoom.toFixed(1) + "x"; _spectrumZoomEl.style.display = "block"; } else { _spectrumZoomEl.style.display = "none"; } } if (_spectrumMinimapEl) { if (spectrumZoom > 1.01) { _spectrumMinimapEl.style.display = "block"; const viewFrac = 1 / spectrumZoom; const halfVis = viewFrac / 2; const panClamped = Math.min(Math.max(spectrumPanFrac, halfVis), 1 - halfVis); const viewL = panClamped - halfVis; const viewR = panClamped + halfVis; if (_spectrumMinimapInner) { _spectrumMinimapInner.style.left = viewL * 100 + "%"; _spectrumMinimapInner.style.width = (viewR - viewL) * 100 + "%"; } } else { _spectrumMinimapEl.style.display = "none"; } } updateSpectrumFreqAxis(range); updateBookmarkAxis(range); updateBandplanStrip(range); drawSignalOverlay(); } var spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas"); var spectrumWaterfallGl = typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas ? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions) : null; var spectrumWfRows = []; var spectrumWfPushCount = 0; var spectrumWfTexData = null; var spectrumWfTexWidth = 0; var spectrumWfTexHeight = 0; var spectrumWfTexPushCount = 0; var spectrumWfTexPalKey = ""; var spectrumWfTexReady = false; var spectrumWfDrawPending = false; var SPECTRUM_WF_TEX_MAX_W = 1024; var _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator"); var _spectrumMinimapEl = document.getElementById("spectrum-minimap"); var _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null; var _cachedSpectrumCssW = 640; var _cachedSpectrumCssH = 160; var _cachedSpecWfCssW = 640; var _cachedSpecWfCssH = 120; var _cachedDpr = window.devicePixelRatio || 1; function _updateCachedCanvasSizes() { _cachedDpr = window.devicePixelRatio || 1; if (spectrumCanvas) { _cachedSpectrumCssW = spectrumCanvas.clientWidth || 640; _cachedSpectrumCssH = spectrumCanvas.clientHeight || 160; } if (spectrumWaterfallCanvas) { _cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640; _cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120; } } window.addEventListener("resize", _updateCachedCanvasSizes); _updateCachedCanvasSizes(); function pushSpectrumWaterfallFrame(data) { if (!spectrumWaterfallCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return; spectrumWfRows.push(data.bins.slice()); spectrumWfPushCount++; trimSpectrumWaterfallRows(); scheduleSpectrumWaterfallDraw(); } function trimSpectrumWaterfallRows() { if (!spectrumWaterfallCanvas) return; const maxRows = Math.max(1, Math.floor(_cachedSpecWfCssH * _cachedDpr)); if (spectrumWfRows.length > maxRows) { spectrumWfRows.splice(0, spectrumWfRows.length - maxRows); } } function scheduleSpectrumWaterfallDraw() { if (!spectrumWaterfallCanvas || spectrumWfDrawPending) return; spectrumWfDrawPending = true; requestAnimationFrame(() => { spectrumWfDrawPending = false; drawSpectrumWaterfall(); }); } function drawSpectrumWaterfall() { if (!spectrumWaterfallCanvas || !spectrumWaterfallGl || !spectrumWaterfallGl.ready) return; if (!lastSpectrumData || spectrumWfRows.length === 0) return; const dpr = _cachedDpr; const cssW = _cachedSpecWfCssW; const cssH = _cachedSpecWfCssH; spectrumWaterfallGl.ensureSize(cssW, cssH, dpr); const W = spectrumWaterfallCanvas.width; const H = spectrumWaterfallCanvas.height; if (W <= 0 || H <= 0) return; const pal = canvasPalette(); const maxVisible = Math.max(1, Math.floor(H)); const rows = spectrumWfRows.slice(-maxVisible); if (rows.length === 0) return; const iW = Math.max(96, Math.min(SPECTRUM_WF_TEX_MAX_W, Math.ceil(W / 2))); const iH = Math.max(1, rows.length); const minDb = isFiniteNumber(spectrumFloor) ? spectrumFloor : -115; const maxDb = minDb + Math.max(20, isFiniteNumber(spectrumRange) ? spectrumRange : 90); const view = spectrumVisibleRange(lastSpectrumData); const viewKey = `${Math.round(view.visLoHz)}:${Math.round(view.visHiHz)}`; const palKey = `swf|${pal.waterfallHue.join(",")}|${pal.waterfallSat}|${pal.waterfallLight.join(",")}|${pal.waterfallAlpha.join(",")}|${spectrumFloor}|${spectrumRange}|${waterfallGamma}|${viewKey}`; const rowStride = iW * 4; const expectedSize = iW * iH * 4; const newPushes = spectrumWfPushCount - spectrumWfTexPushCount; const sizeChanged = spectrumWfTexWidth !== iW || spectrumWfTexHeight !== iH; const palChanged = spectrumWfTexPalKey !== palKey; const needsFull = !spectrumWfTexData || sizeChanged || palChanged || spectrumWfTexPushCount === 0; let texUpdated = false; if (!spectrumWfTexData || spectrumWfTexData.length !== expectedSize) { spectrumWfTexData = new Uint8Array(expectedSize); } spectrumWfTexWidth = iW; spectrumWfTexHeight = iH; ensureWaterfallLut(pal, minDb, maxDb); function renderRow(dstY, srcBins) { if (!isNumericBins(srcBins) || srcBins.length === 0) return; const { startIdx, endIdx } = overviewVisibleBinWindow(lastSpectrumData, srcBins.length); const spanBins = Math.max(1, endIdx - startIdx); const rowBase = dstY * rowStride; const iwM1 = Math.max(1, iW - 1); for (let x = 0; x < iW; x++) { const binIdx = Math.min(endIdx, startIdx + (x * spanBins / iwM1 | 0)); const db = srcBins[binIdx]; if (db !== void 0) waterfallLutWrite(textureData, rowBase + x * 4, db); } } const textureData = spectrumWfTexData; if (needsFull) { for (let y = 0; y < iH; y++) { const row = rows[y]; if (row) renderRow(y, row); } spectrumWfTexPushCount = spectrumWfPushCount; spectrumWfTexPalKey = palKey; texUpdated = true; } else if (newPushes > 0) { const newCount = Math.min(newPushes, iH); if (newCount >= iH) { for (let y = 0; y < iH; y++) { const row = rows[y]; if (row) renderRow(y, row); } } else { const shiftBytes = newCount * rowStride; textureData.copyWithin(0, shiftBytes); const startRow = iH - newCount; for (let y = startRow; y < iH; y++) { const row = rows[y]; if (row) renderRow(y, row); } } spectrumWfTexPushCount = spectrumWfPushCount; spectrumWfTexPalKey = palKey; texUpdated = true; } if (texUpdated || !spectrumWfTexReady) { spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, textureData, "linear"); spectrumWfTexReady = true; } spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true); } function bmLuminance(hex) { const lin = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); const r = lin(parseInt(hex.slice(1, 3), 16) / 255); const g = lin(parseInt(hex.slice(3, 5), 16) / 255); const b = lin(parseInt(hex.slice(5, 7), 16) / 255); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } function bmContrastFg(bgHex) { return bmLuminance(bgHex) >= 0.4 ? "#1a202c" : "#ffffff"; } function bmResolveThemeColor(name, fallbackHex) { const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); if (/^#[0-9a-f]{6}$/i.test(val)) return val; if (/^#[0-9a-f]{3}$/i.test(val)) return "#" + [...val.slice(1)].map((c) => c + c).join(""); const m = val.match(/\d+/g); if (m && m.length >= 3) return "#" + m.slice(0, 3).map((n) => (+n).toString(16).padStart(2, "0")).join(""); return fallbackHex; } function bmBlendHex(aHex, bHex, ratio = 0.5) { const mix = Math.max(0, Math.min(1, isFiniteNumber(ratio) ? ratio : 0.5)); const aR = parseInt(aHex.slice(1, 3), 16); const aG = parseInt(aHex.slice(3, 5), 16); const aB = parseInt(aHex.slice(5, 7), 16); const bR = parseInt(bHex.slice(1, 3), 16); const bG = parseInt(bHex.slice(3, 5), 16); const bB = parseInt(bHex.slice(5, 7), 16); const toHex = (value) => Math.round(value).toString(16).padStart(2, "0"); return "#" + [ aR + (bR - aR) * mix, aG + (bG - aG) * mix, aB + (bB - aB) * mix ].map(toHex).join(""); } function bmThemePalette() { const yellow = bmResolveThemeColor("--accent-yellow", "#f0ad4e"); const green = bmResolveThemeColor("--accent-green", "#c24b1a"); const red = bmResolveThemeColor("--accent-red", "#e55353"); const heading = bmResolveThemeColor("--text-heading", "#c6d5ea"); const border = bmResolveThemeColor("--border-light", "#304766"); return [ yellow, bmBlendHex(yellow, heading, 0.28), bmBlendHex(yellow, green, 0.45), bmBlendHex(green, heading, 0.22), bmBlendHex(yellow, red, 0.42), bmBlendHex(red, heading, 0.18), bmBlendHex(border, yellow, 0.58), bmBlendHex(border, heading, 0.5) ]; } function bmCategoryColorMap() { const ref = window.trx.modules.bookmarks?.overlayList ?? []; const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort(); const palette = bmThemePalette(); const fallback = palette[0] ?? BOOKMARK_MARKER_FALLBACK; const map = { "": fallback }; cats.forEach((cat, i) => { if (cat) map[cat] = palette[(i + 1) % palette.length] ?? fallback; }); return map; } function createBookmarkChip(bm, colorMap, options = {}) { const span = document.createElement("span"); const freqStr = window.trx.modules.bookmarks ? window.trx.modules.bookmarks.formatFrequency(bm.freq_hz) : bm.freq_hz + " Hz"; span.className = "spectrum-bookmark-chip"; if (options.sideStack) { span.classList.add("spectrum-bookmark-chip-side"); } else { span.style.top = "2px"; } span.title = buildBookmarkTooltipText(bm) || bm.name + " — " + freqStr + (bm.comment ? "\n" + bm.comment : ""); span.dataset.bmId = bm.id; const labelHtml = options.sideStack ? `${escapeHtml(freqStr)}${escapeHtml(bm.name)}` : " " + escapeHtml(bm.name) + ""; 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.toggle("bm-axis-visible", hasVisible); if (!hasVisible) { if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; } return; } const rev = window.trx.modules.bookmarks?.overlayRevision ?? 0; const newKey = `${rev}:${visBookmarks.map((b) => b.id).join(",")}`; if (axisEl.dataset.bmKey !== newKey) { axisEl.dataset.bmKey = newKey; axisEl.replaceChildren(); for (const bm of visBookmarks) { axisEl.appendChild(createBookmarkChip(bm, colorMap)); } } const axisWidth = axisEl.clientWidth || 0; const edgePad = 8; const spans = axisEl.querySelectorAll(":scope > span"); const widths = []; for (const span of spans) widths.push(span.offsetWidth || 0); visBookmarks.forEach((bm, i) => { const span = spans[i]; if (!span) return; const frac = (bm.freq_hz - range.visLoHz) / range.visSpanHz; if (axisWidth > 0) { const lw = widths[i] ?? 0; const clamped = Math.max(edgePad + lw / 2, Math.min(axisWidth - edgePad - lw / 2, frac * axisWidth)); span.style.transform = `translateX(${clamped - lw / 2}px)`; } else { span.style.left = (frac * 100).toFixed(2) + "%"; } }); } function updateSpectrumFreqAxis(range) { if (!spectrumFreqAxis) return; const spanHz = range.visSpanHz; const targets = [ 100, 200, 500, 1e3, 2e3, 5e3, 1e4, 2e4, 5e4, 1e5, 2e5, 5e5, 1e6, 2e6, 5e6, 1e7 ]; const ideal = spanHz / 5; const firstTarget = targets[0] ?? 100; const stepHz = targets.reduce((best, s) => Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, firstTarget); const axisKey = [ Math.round(range.visLoHz), Math.round(range.visHiHz), Math.round(stepHz), spectrumFreqAxis.clientWidth || 0 ].join(":"); if (axisKey === spectrumAxisKey) return; spectrumAxisKey = axisKey; const firstHz = Math.ceil(range.visLoHz / stepHz) * stepHz; const leftShiftBtn = document.getElementById("spectrum-center-left-btn"); const rightShiftBtn = document.getElementById("spectrum-center-right-btn"); spectrumFreqAxis.replaceChildren(); if (leftShiftBtn) spectrumFreqAxis.appendChild(leftShiftBtn); if (rightShiftBtn) spectrumFreqAxis.appendChild(rightShiftBtn); const axisWidth = spectrumFreqAxis.clientWidth || 0; const buttonReserve = Math.max( leftShiftBtn?.offsetWidth || 0, rightShiftBtn?.offsetWidth || 0, 0 ); const edgePad = Math.max(6, buttonReserve + 10); for (let hz = firstHz; hz <= range.visHiHz + stepHz * 0.01; hz += stepHz) { const frac = (hz - range.visLoHz) / range.visSpanHz; if (frac < 0 || frac > 1) continue; const label = hz >= 1e6 ? (hz / 1e6).toFixed(stepHz < 1e6 ? stepHz < 1e5 ? 3 : 1 : 0) + " M" : hz >= 1e3 ? (hz / 1e3).toFixed(stepHz < 1e3 ? 1 : 0) + " k" : hz.toFixed(0); const span = document.createElement("span"); span.textContent = label; spectrumFreqAxis.appendChild(span); const labelWidth = span.offsetWidth || 0; if (axisWidth > 0 && labelWidth > 0) { const minCenter = edgePad + labelWidth / 2; const maxCenter = axisWidth - edgePad - labelWidth / 2; const desiredCenter = frac * axisWidth; const clampedCenter = Math.max(minCenter, Math.min(maxCenter, desiredCenter)); span.style.left = `${clampedCenter}px`; } else { span.style.left = (frac * 100).toFixed(2) + "%"; } } } function updateSpectrumDbAxis(dbMin, dbMax, gridStep, heightPx, dpr) { if (!spectrumDbAxis) return; const key = [ Math.round(dbMin), Math.round(dbMax), Math.round(gridStep), Math.round(heightPx), Math.round((dpr || 1) * 100), currentTheme(), currentStyle() ].join(":"); if (key === spectrumDbAxisKey) return; spectrumDbAxisKey = key; spectrumDbAxis.replaceChildren(); const spanDb = Math.max(1, dbMax - dbMin); const cssHeight = heightPx / Math.max(1, dpr || 1); for (let db = Math.ceil(dbMin / gridStep) * gridStep; db <= dbMax; db += gridStep) { const yPx = Math.round(heightPx * (1 - (db - dbMin) / spanDb)); const yCss = yPx / Math.max(1, dpr || 1); if (yCss <= 7 || yCss >= cssHeight - 4) continue; const span = document.createElement("span"); span.textContent = `${db}`; span.style.top = `${yCss}px`; spectrumDbAxis.appendChild(span); } } function shouldIgnoreGlobalShortcut(target) { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; if (target.isContentEditable) return true; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; return !!target.closest("[contenteditable='true']"); } function toggleShortcutOverlay() { const el = document.getElementById("shortcut-overlay"); if (!el) return; el.classList.toggle("is-hidden"); } function hideShortcutOverlay() { const el = document.getElementById("shortcut-overlay"); if (el) el.classList.add("is-hidden"); } function isShortcutOverlayVisible() { const el = document.getElementById("shortcut-overlay"); return el && !el.classList.contains("is-hidden"); } document.addEventListener("DOMContentLoaded", () => { const overlay = document.getElementById("shortcut-overlay"); if (overlay) overlay.addEventListener("click", (e) => { if (e.target === overlay) hideShortcutOverlay(); }); }); window.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.repeat || event.isComposing) return; const key = (event.key || "").toLowerCase(); if (event.key === "F1") { event.preventDefault(); toggleShortcutOverlay(); return; } if (event.key === "Escape" && isShortcutOverlayVisible()) { event.preventDefault(); hideShortcutOverlay(); return; } if (key === "f" && !event.ctrlKey && !event.metaKey && !event.altKey && !shouldIgnoreGlobalShortcut(event.target)) { event.preventDefault(); const fi = document.getElementById("freq"); if (fi) { fi.focus(); fi.select(); } return; } if (event.ctrlKey || event.metaKey || event.altKey) return; if (shouldIgnoreGlobalShortcut(event.target)) return; if (key === "s") { event.preventDefault(); if (window.trx.modules.screenshot) { void window.trx.modules.screenshot.captureSpectrumScreenshot(); } else void import("./screenshot.js").then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot()).catch((error) => { console.error("Screenshot module failed to load", error); }); return; } if (key === "r") { event.preventDefault(); if (lastLocked) { showHint("Locked", 1500); return; } if (lastFreqHz != null) { const step = Math.max(1, jogStep); const rounded = Math.round(lastFreqHz / step) * step; if (rounded !== lastFreqHz) { if (!freqAllowed(rounded)) { showUnsupportedFreqPopup(rounded); return; } setRigFrequency(rounded); showHint(`Rounded → ${formatFrequency(rounded)}`, 1200); } else { showHint("Already on step", 1200); } } return; } if (key === "b") { event.preventDefault(); void restorePreviousTuneState(); return; } if (key === "[") { event.preventDefault(); const [, minBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); const next = Math.max(minBw, currentBandwidthHz - 1e4); if (next !== currentBandwidthHz) { currentBandwidthHz = next; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(next); positionFastOverlay(lastFreqHz, next); if (lastSpectrumData) scheduleSpectrumDraw(); postPath(`/set_bandwidth?hz=${next}`).catch(() => { }); showHint(`BW ${formatBwLabel(next)}`, 1200); } return; } if (key === "]") { event.preventDefault(); const [, , maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); const next = Math.min(maxBw, currentBandwidthHz + 1e4); if (next !== currentBandwidthHz) { currentBandwidthHz = next; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(next); positionFastOverlay(lastFreqHz, next); if (lastSpectrumData) scheduleSpectrumDraw(); postPath(`/set_bandwidth?hz=${next}`).catch(() => { }); showHint(`BW ${formatBwLabel(next)}`, 1200); } return; } if (key === "arrowleft" || key === "arrowright") { event.preventDefault(); jogFreq(key === "arrowright" ? 1 : -1); return; } if (key === "arrowup" || key === "arrowdown") { event.preventDefault(); void shiftSpectrumCenter(key === "arrowup" ? 1 : -1); return; } if (key === "m") { event.preventDefault(); if (modeEl && !modeEl.disabled) { modeEl.focus(); modeEl.click(); if (typeof modeEl.showPicker === "function") { try { modeEl.showPicker(); } catch (_) { } } } return; } if (key === "z") { event.preventDefault(); if (wfmAudioModeEl) { const next = wfmAudioModeEl.value === "mono" ? "stereo" : "mono"; wfmAudioModeEl.value = next; saveSetting("wfmAudioMode", next); const enabled = next !== "mono"; postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => { }); showHint(next === "stereo" ? "Stereo" : "Mono", 1200); } else { showHint("Stereo N/A", 1200); } return; } if (key === "n") { event.preventDefault(); if (sdrNbSupported && sdrNbEnabledEl) { sdrNbEnabledEl.checked = !sdrNbEnabledEl.checked; submitSdrNbState(); showHint(sdrNbEnabledEl.checked ? "NB On" : "NB Off", 1200); } else { showHint("NB N/A", 1200); } return; } if (key === "q") { event.preventDefault(); if (sdrSquelchSupported && sdrSquelchEl) { const current = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); let nextPct; if (current > 0) { nextPct = 0; } else { let auto = 30; const data = lastSpectrumData || window.lastSpectrumData; if (data && isNumericBins(data.bins) && data.bins.length > 0) { const noiseDb = estimateNoiseFloorDb(data.bins); if (noiseDb != null && isFiniteNumber(noiseDb)) { const thresholdDb = noiseDb + 6; const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb)); auto = clampSdrSquelchPercent( (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100 ); } } nextPct = auto; } sdrSquelchEl.value = String(nextPct); updateSdrSquelchPctLabel(); saveSetting("sdrSquelchPct", nextPct); submitSdrSquelchPercent(nextPct); showHint(nextPct > 0 ? `Squelch ${nextPct}%` : "Squelch Off", 1200); } else { showHint("Squelch N/A", 1200); } return; } if (lastSpectrumData && spectrumCanvas) { if (key === "+" || key === "=") { event.preventDefault(); const cssW = spectrumCanvas.clientWidth || 640; spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1.25); scheduleSpectrumDraw(); scheduleOverviewDraw(); return; } if (key === "-") { event.preventDefault(); const cssW = spectrumCanvas.clientWidth || 640; spectrumZoomAt(cssW / 2, cssW, lastSpectrumData, 1 / 1.25); scheduleSpectrumDraw(); scheduleOverviewDraw(); return; } if (key === "0") { event.preventDefault(); spectrumZoom = 1; spectrumPanFrac = 0.5; scheduleSpectrumDraw(); scheduleOverviewDraw(); return; } } }, { capture: true }); function spectrumZoomAt(cssX, cssW, data, factor) { const range = spectrumVisibleRange(data); const hzAtCursor = canvasXToHz(cssX, cssW, range); const frac = cssX / cssW; spectrumZoom = Math.max(1, Math.min(64, spectrumZoom * factor)); const newVisSpan = data.sample_rate / spectrumZoom; const newVisCenter = hzAtCursor + (0.5 - frac) * newVisSpan; const loHz = data.center_hz - data.sample_rate / 2; spectrumPanFrac = (newVisCenter - loHz) / data.sample_rate; } function handleSpectrumWheel(e, canvasEl) { e.preventDefault(); if (!lastSpectrumData || !canvasEl) return; if (e.ctrlKey) { const direction = e.deltaY < 0 ? 1 : -1; jogFreq(direction); return; } const rect = canvasEl.getBoundingClientRect(); const cssX = e.clientX - rect.left; const factor = e.deltaY < 0 ? 1.25 : 1 / 1.25; spectrumZoomAt(cssX, rect.width, lastSpectrumData, factor); scheduleSpectrumDraw(); scheduleOverviewDraw(); } function handleSpectrumClick(e, canvasEl) { if (_sDragMoved) { _sDragMoved = false; return; } if (!lastSpectrumData || !canvasEl) return; const rect = canvasEl.getBoundingClientRect(); const cssX = e.clientX - rect.left; const targetHz = spectrumTargetHzAt(cssX, rect.width, lastSpectrumData); if (!isFiniteNumber(targetHz)) return; setRigFrequency(targetHz); } if (spectrumCanvas) { spectrumCanvas.addEventListener("wheel", (e) => { handleSpectrumWheel(e, spectrumCanvas); }, { passive: false }); } if (overviewCanvas) { overviewCanvas.addEventListener("wheel", (e) => { handleSpectrumWheel(e, overviewCanvas); }, { passive: false }); overviewCanvas.addEventListener("click", (e) => { handleSpectrumClick(e, overviewCanvas); }); } if (spectrumWaterfallCanvas) { spectrumWaterfallCanvas.addEventListener("wheel", (e) => { handleSpectrumWheel(e, spectrumWaterfallCanvas); }, { passive: false }); spectrumWaterfallCanvas.addEventListener("click", (e) => { handleSpectrumClick(e, spectrumWaterfallCanvas); }); spectrumWaterfallCanvas.addEventListener("mousedown", (e) => { onSpectrumMouseDown(e, spectrumWaterfallCanvas); }); } function getBwEdgeHit(cssX, cssW, range) { const bwCenterHz = activeBandwidthCenterHz(); if (!isFiniteNumber(bwCenterHz) || !currentBandwidthHz || !lastSpectrumData) return null; const HIT = 8; let bestEdge = null; let bestDist = Number.POSITIVE_INFINITY; for (const spec of visibleBandwidthSpecs(bwCenterHz)) { const span = displaySpanForBandwidthSpec(spec); const xL = (span.loHz - range.visLoHz) / range.visSpanHz * cssW; const xR = (span.hiHz - range.visLoHz) / range.visSpanHz * cssW; if (span.side < 0) { const distL2 = Math.abs(cssX - xL); if (distL2 < HIT && distL2 < bestDist) { bestEdge = "left"; bestDist = distL2; } continue; } if (span.side > 0) { const distR2 = Math.abs(cssX - xR); if (distR2 < HIT && distR2 < bestDist) { bestEdge = "right"; bestDist = distR2; } continue; } const distL = Math.abs(cssX - xL); const distR = Math.abs(cssX - xR); if (distL < HIT && distL < bestDist) { bestEdge = "left"; bestDist = distL; } if (distR < HIT && distR < bestDist) { bestEdge = "right"; bestDist = distR; } } if (bestEdge) return bestEdge; return null; } var _sDragStart = null; var _sDragMoved = false; var _sDragCanvas = null; function onSpectrumMouseDown(e, canvasEl) { if (!canvasEl || e.button !== 0) return; if (lastSpectrumData) { const rect = canvasEl.getBoundingClientRect(); const cssX = e.clientX - rect.left; const range = spectrumVisibleRange(lastSpectrumData); const edge = getBwEdgeHit(cssX, rect.width, range); if (edge) { _bwDragEdge = edge; _bwDragStartX = cssX; _bwDragStartBwHz = currentBandwidthHz; _bwDragCanvas = canvasEl; _sDragStart = null; _sDragCanvas = null; _sDragMoved = true; return; } } _sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac }; _sDragCanvas = canvasEl; _sDragMoved = false; } if (spectrumCanvas) { spectrumCanvas.addEventListener("mousedown", (e) => { onSpectrumMouseDown(e, spectrumCanvas); }); } if (overviewCanvas) { overviewCanvas.addEventListener("mousedown", (e) => { onSpectrumMouseDown(e, overviewCanvas); }); } if (spectrumCanvas || overviewCanvas) { window.addEventListener("mousemove", (e) => { if (_bwDragEdge && lastSpectrumData) { const dragCanvas2 = _bwDragCanvas || spectrumCanvas; if (!dragCanvas2) return; const rect2 = dragCanvas2.getBoundingClientRect(); const cssX = e.clientX - rect2.left; const range = spectrumVisibleRange(lastSpectrumData); const dxHz = (cssX - _bwDragStartX) / rect2.width * range.visSpanHz; const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); let newBw; if (side === 0) { newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz * 2 : _bwDragStartBwHz - dxHz * 2; } else { newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz : _bwDragStartBwHz - dxHz; } const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw))); currentBandwidthHz = newBw; window.currentBandwidthHz = currentBandwidthHz; syncBandwidthInput(newBw); positionFastOverlay(lastFreqHz, newBw); scheduleSpectrumDraw(); scheduleOverviewDraw(); return; } if (!_sDragStart || !lastSpectrumData) return; const dragCanvas = _sDragCanvas || spectrumCanvas || overviewCanvas; if (!dragCanvas) return; const rect = dragCanvas.getBoundingClientRect(); const dx = e.clientX - _sDragStart.clientX; if (Math.abs(dx) > 3) _sDragMoved = true; spectrumPanFrac = _sDragStart.panFrac - dx / rect.width / spectrumZoom; scheduleSpectrumDraw(); }); window.addEventListener("mouseup", async () => { if (_bwDragEdge) { try { const bwHz = Math.round(currentBandwidthHz); if (!await window.trx.modules.vchan?.interceptBandwidth(bwHz)) { await postPath(`/set_bandwidth?hz=${bwHz}`); if (isFiniteNumber(lastFreqHz)) { await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz); } } } catch (error) { window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) } }); } _bwDragEdge = null; _bwDragCanvas = null; return; } _sDragStart = null; _sDragCanvas = null; }); } var _sTouch = null; if (spectrumCanvas) { spectrumCanvas.addEventListener("touchstart", (e) => { e.preventDefault(); if (e.touches.length === 2) { const t0 = e.touches[0], t1 = e.touches[1]; if (!t0 || !t1) return; _sTouch = { type: "pinch", dist: Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY), midX: (t0.clientX + t1.clientX) / 2, zoom: spectrumZoom, panFrac: spectrumPanFrac }; } else if (e.touches.length === 1) { const touch = e.touches[0]; if (touch) _sTouch = { type: "pan", clientX: touch.clientX, panFrac: spectrumPanFrac }; } }, { passive: false }); spectrumCanvas.addEventListener("touchmove", (e) => { e.preventDefault(); if (!_sTouch || !lastSpectrumData) return; const rect = spectrumCanvas.getBoundingClientRect(); if (_sTouch.type === "pinch" && e.touches.length === 2) { const t0 = e.touches[0], t1 = e.touches[1]; if (!t0 || !t1) return; const newDist = Math.hypot(t1.clientX - t0.clientX, t1.clientY - t0.clientY); const newMidX = (t0.clientX + t1.clientX) / 2; const scale = newDist / _sTouch.dist; const newZoom = Math.max(1, Math.min(64, _sTouch.zoom * scale)); const loHz = lastSpectrumData.center_hz - lastSpectrumData.sample_rate / 2; const oldVisSpan = lastSpectrumData.sample_rate / _sTouch.zoom; const oldVisLo = loHz + _sTouch.panFrac * lastSpectrumData.sample_rate - oldVisSpan / 2; const midFrac = (_sTouch.midX - rect.left) / rect.width; const midHz = oldVisLo + midFrac * oldVisSpan; const newVisSpan = lastSpectrumData.sample_rate / newZoom; const newVisCenter = midHz + (0.5 - midFrac) * newVisSpan; spectrumZoom = newZoom; spectrumPanFrac = (newVisCenter - loHz) / lastSpectrumData.sample_rate; const dxMid = newMidX - _sTouch.midX; spectrumPanFrac -= dxMid / rect.width / spectrumZoom; scheduleSpectrumDraw(); } else if (_sTouch.type === "pan" && e.touches.length === 1) { const touch = e.touches[0]; if (!touch) return; const dx = touch.clientX - _sTouch.clientX; spectrumPanFrac = _sTouch.panFrac - dx / rect.width / spectrumZoom; scheduleSpectrumDraw(); } }, { passive: false }); spectrumCanvas.addEventListener("touchend", () => { _sTouch = null; }); } if (spectrumCanvas) { spectrumCanvas.addEventListener("mousemove", (e) => { if (!lastSpectrumData || !spectrumTooltip) return; const rect = spectrumCanvas.getBoundingClientRect(); const cssX = e.clientX - rect.left; const range = spectrumVisibleRange(lastSpectrumData); const edge = getBwEdgeHit(cssX, rect.width, range); spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair"; const hz = canvasXToHz(cssX, rect.width, range); const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range); const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData); const peakHz = peak?.hz ?? null; const peakDb = peak && isFiniteNumber(peak.db) ? `${peak.db.toFixed(1)} dB` : null; if (bookmark) { spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark); } else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) { spectrumTooltip.textContent = peakDb ? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}` : `Peak ${formatSpectrumFreq(peakHz)}`; } else { const baseText = formatSpectrumFreq(peakHz ?? hz); spectrumTooltip.textContent = peakDb ? `${baseText} · ${peakDb}` : baseText; } spectrumTooltip.style.display = "block"; const tw = spectrumTooltip.offsetWidth; let tx = cssX + 10; if (tx + tw > rect.width) tx = cssX - tw - 10; spectrumTooltip.style.left = tx + "px"; spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px"; spectrumCrosshairX = cssX; spectrumCrosshairY = e.clientY - rect.top; scheduleSpectrumDraw(); }); spectrumCanvas.addEventListener("mouseleave", () => { if (spectrumTooltip) spectrumTooltip.style.display = "none"; spectrumCanvas.style.cursor = "crosshair"; spectrumCrosshairX = null; spectrumCrosshairY = null; scheduleSpectrumDraw(); }); } if (spectrumCanvas) { spectrumCanvas.addEventListener("click", (e) => { handleSpectrumClick(e, spectrumCanvas); }); } if (spectrumCenterLeftBtn) { spectrumCenterLeftBtn.addEventListener("click", () => { shiftSpectrumCenter(-1).catch(() => { }); }); } if (spectrumCenterRightBtn) { spectrumCenterRightBtn.addEventListener("click", () => { shiftSpectrumCenter(1).catch(() => { }); }); } (function() { const floorInput = document.getElementById("spectrum-floor-input"); const autoBtn = document.getElementById("spectrum-auto-btn"); if (floorInput) { floorInput.addEventListener("change", () => { const v = Number(floorInput.value); if (!isNaN(v)) { spectrumFloor = v; if (lastSpectrumData) scheduleSpectrumDraw(); } }); } const rangeInput = document.getElementById("spectrum-range-input"); if (rangeInput) { rangeInput.value = String(spectrumRange); rangeInput.addEventListener("change", () => { const v = Number(rangeInput.value); if (!isNaN(v) && v >= 10) { spectrumRange = v; if (lastSpectrumData) scheduleSpectrumDraw(); } }); } if (autoBtn) { autoBtn.addEventListener("click", () => { if (!lastSpectrumData) return; const sorted = [...lastSpectrumData.bins].sort((a, b) => a - b); const noise = sorted[Math.floor(sorted.length * 0.15)]; const peak = sorted[sorted.length - 1]; if (noise === void 0 || peak === void 0) return; spectrumFloor = Math.floor(noise / 10) * 10 - 10; spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB); if (floorInput) floorInput.value = String(spectrumFloor); if (rangeInput) rangeInput.value = String(spectrumRange); scheduleSpectrumDraw(); }); } const gammaInput = document.getElementById("spectrum-gamma-input"); const gammaValue = document.getElementById("spectrum-gamma-value"); if (gammaInput) { gammaInput.addEventListener("input", () => { const v = Number(gammaInput.value); if (isFiniteNumber(v) && v > 0) { waterfallGamma = v; if (gammaValue) gammaValue.textContent = v.toFixed(1); if (lastSpectrumData) scheduleSpectrumDraw(); } }); gammaInput.addEventListener("dblclick", () => { waterfallGamma = 1; gammaInput.value = "1.0"; if (gammaValue) gammaValue.textContent = "1.0"; if (lastSpectrumData) scheduleSpectrumDraw(); }); } })(); var bandplanData = null; var bandplanRegion = loadSetting("bandplanRegion", "off"); var bandplanShowLabels = loadSetting("bandplanLabels", true); var _bandplanServerDefaultApplied = false; var bandplanSegmentsCache = null; var bandplanCacheKey = ""; var bandplanStripEl = document.getElementById("spectrum-bandplan-strip"); var bandplanRegionSelect = document.getElementById("bandplan-region-select"); var bandplanLabelsCheck = document.getElementById("bandplan-labels-check"); (function loadBandplanJson() { 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 = ""; }).catch(() => { }); })(); 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 _hideBandplanStrip() { if (!bandplanStripEl) return; bandplanStripEl.classList.remove("bp-visible"); bandplanStripEl.replaceChildren(); bandplanCacheKey = ""; } function updateBandplanStrip(range) { if (!bandplanStripEl) return; if (!range || bandplanRegion === "off" || !bandplanData) { if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); return; } const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz); if (segments.length === 0) { if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); return; } bandplanStripEl.classList.add("bp-visible"); 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" : ""; }); }