From d929ad3bdedf4d9582ea434c6c4856cf11356b70 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 21:54:00 +0200 Subject: [PATCH] build: consolidate typed frontend module graph --- .gitea/workflows/ci.yml | 10 + .../assets/web/generated/api-client.js | 71 - .../assets/web/generated/app.js | 16825 ++++++++-------- .../web/generated/leaflet-ais-tracksymbol.js | 95 - .../assets/web/generated/plugin-loader.js | 45 - .../assets/web/generated/plugin-runtime.js | 80 - .../assets/web/generated/screenshot.js | 4 +- .../assets/web/generated/ui-core.js | 360 - .../assets/web/generated/webgl-renderer.js | 498 - .../trx-frontend-http/assets/web/index.html | 9 +- .../trx-frontend-http/frontend/build.mjs | 34 +- .../trx-frontend-http/frontend/src/README.md | 46 +- .../trx-frontend-http/frontend/src/app.ts | 16 +- .../frontend/src/bootstrap.ts | 6 +- .../frontend/src/plugin-loader.ts | 21 +- .../frontend/src/screenshot.ts | 1 + .../frontend/tests/ais.test.mjs | 3 +- .../frontend/tests/aprs.test.mjs | 3 +- .../frontend/tests/cw.test.mjs | 3 +- .../frontend/tests/ftx-family.test.mjs | 5 +- .../frontend/tests/hf-aprs.test.mjs | 3 +- .../tests/leaflet-ais-tracksymbol.test.mjs | 7 +- .../frontend/tests/plugin-runtime.test.mjs | 4 +- .../frontend/tests/sat.test.mjs | 3 +- .../frontend/tests/smoke.test.mjs | 7 +- .../frontend/tests/ui-core.test.mjs | 4 +- .../frontend/tests/vdes.test.mjs | 3 +- .../frontend/tests/webgl-renderer.test.mjs | 7 +- .../frontend/tests/wefax.test.mjs | 3 +- .../frontend/tests/wspr.test.mjs | 3 +- .../trx-frontend-http/src/status.rs | 38 +- 31 files changed, 9065 insertions(+), 9152 deletions(-) delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js delete mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1dee405d..d8fcf43a 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -43,6 +43,13 @@ jobs: working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend steps: - uses: actions/checkout@v4 + - name: Cache npm downloads + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json') }} + restore-keys: | + npm-${{ runner.os }}- - name: Install locked frontend dependencies run: npm ci - name: Type-check @@ -57,6 +64,9 @@ jobs: run: npm run test:browser - name: Verify generated assets run: npm run verify-generated + - name: Verify generated-file licensing + working-directory: . + run: reuse lint reuse: runs-on: ubuntu-latest diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js deleted file mode 100644 index 4b57b3db..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/api-client.js +++ /dev/null @@ -1,71 +0,0 @@ -"use strict"; -export class ApiError extends Error { - constructor(status, message) { - super(message); - this.status = status; - this.name = "ApiError"; - } -} -function isRecord(value) { - return typeof value === "object" && value !== null && !Array.isArray(value); -} -export 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"; -} -export 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" - ); -} -export function isDecoderRegistry(value) { - return Array.isArray(value) && value.every( - (decoder) => isRecord(decoder) && typeof decoder.id === "string" && typeof decoder.label === "string" && (decoder.activation === "mode_bound" || decoder.activation === "toggle") && Array.isArray(decoder.active_modes) && decoder.active_modes.every((mode) => typeof mode === "string") && typeof decoder.background_decode === "boolean" && typeof decoder.bookmark_selectable === "boolean" - ); -} -export class TrxApi { - constructor(baseUrl = "") { - this.baseUrl = baseUrl; - } - async get(path, validate) { - return this.request(path, { cache: "no-store" }, validate); - } - async post(path, body, validate) { - return this.request( - path, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) - }, - validate - ); - } - async request(path, init, validate) { - const response = await fetch(`${this.baseUrl}${path}`, init); - if (!response.ok) { - const detail = await response.text(); - throw new ApiError(response.status, detail || response.statusText); - } - const value = await response.json(); - if (!validate(value)) { - throw new ApiError(response.status, `Malformed response from ${path}`); - } - return value; - } -} -export function decodeServerEvent(event, validate) { - let value; - try { - value = JSON.parse(event.data); - } catch (error) { - throw new TypeError("Server event is not valid JSON", { cause: error }); - } - if (!validate(value)) { - throw new TypeError("Server event has an unexpected shape"); - } - return value; -} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index dceeee09..6f4a4491 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -1,5487 +1,6533 @@ -"use strict"; -(() => { - // 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/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); - } - 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 vfoPicker2 = document.getElementById("vfo-picker"); - 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) { - 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 = requiredElement("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 = requiredElement("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 headerRigSummary = document.getElementById("header-rig-summary"); - 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 lastControl; - 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 updateRigIdentitySummary(rigId, pending = false) { - if (!headerRigSummary) return; - const rig = serverRigs.find((entry) => entry?.remote === rigId); - if (!rig) { - headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available"; - return; - } - const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote; - const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []; - const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)]; - if (modes.length > 5) features.push(`+${modes.length - 5} modes`); - headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; - } - 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); - updateRigIdentitySummary(lastActiveRigId); - window.trxUi?.setActiveRig(lastActiveRigId); - if (rigListChanged) { - window.trx.modules.scheduler?.setRig(lastActiveRigId); - window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); - window.trx.modules.bookmarks?.populateScopePicker(); - 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 resp.json(); - const rigs = Array.isArray(data.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 || null; - 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 parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; - const parsed = parser ? parser(color) : [0, 0, 0, 1]; +// 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 [ - parsed[0] ?? 0, - parsed[1] ?? 0, - parsed[2] ?? 0, - Math.max(0, Math.min(1, (parsed[3] ?? 1) * alphaMul)) + 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 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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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(); + 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; + 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 details = document.getElementById("advanced-radio-controls"); + if (details) details.open = layout.advanced; + const audioDetails = document.getElementById("audio-controls"); + if (audioDetails) audioDetails.open = layout.audio; + const schedulerDetails = document.getElementById("scheduler-controls"); + if (schedulerDetails) schedulerDetails.open = layout.scheduler; + 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 }); + } + } + 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(); + 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; } - 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); + if (action.kind === "batch" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; } - function drawSignalOverlay() { - if (!signalOverlayCanvas || !signalVisualBlockEl || !signalOverlayGl || !signalOverlayGl.ready) return; - if (!lastSpectrumData) { - signalOverlayCanvas.style.height = "0"; - signalOverlayCanvas.width = 0; - signalOverlayCanvas.height = 0; - return; + 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; } - 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; + if (action.kind === "restore" && plugin.onBatch) { + plugin.onBatch(action.payload); + return true; } - 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 (Array.isArray(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; - if (!grouped.has(color)) grouped.set(color, []); - grouped.get(color).push(x, 0, x, H); - } - 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) - ); - } + 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/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); +} +function requestPlugins(group) { + void loadPlugins(group).catch((error) => { + console.error(error); + }); +} +var loaderWindow = window; +loaderWindow.loadEagerPlugins = async () => { + await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); +}; +loaderWindow.loadPluginsForTab = loadPlugins; +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) return; + const tab = event.target.closest("[data-tab]")?.dataset.tab; + if (tab) requestPlugins(tab); +}); + +// 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`; } - if (lastFreqHz != null) { - const xf = hzToX(lastFreqHz); - if (xf >= 0 && xf <= W) { + }); + 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/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); +} +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 vfoPicker2 = document.getElementById("vfo-picker"); + 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) { + 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 = requiredElement("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 = requiredElement("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 headerRigSummary = document.getElementById("header-rig-summary"); +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 lastControl; +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 updateRigIdentitySummary(rigId, pending = false) { + if (!headerRigSummary) return; + const rig = serverRigs.find((entry) => entry?.remote === rigId); + if (!rig) { + headerRigSummary.textContent = pending ? "Switching rigs…" : "No rig details available"; + return; + } + const hardware = [rig.manufacturer, rig.model].map((value) => String(value || "").trim()).filter(Boolean).join(" ") || rig.remote; + const modes = Array.isArray(rig.supported_modes) ? rig.supported_modes.map(normalizeMode).filter(Boolean) : []; + const features = [rig.tx ? "TX" : "RX", rig.filter_controls ? "SDR filters" : null, ...modes.slice(0, 5)]; + if (modes.length > 5) features.push(`+${modes.length - 5} modes`); + headerRigSummary.textContent = `${pending ? "Switching to " : ""}${hardware} · ${features.filter(Boolean).join(" · ")}`; +} +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); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); + if (rigListChanged) { + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + window.trx.modules.bookmarks?.populateScopePicker(); + 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 resp.json(); + const rigs = Array.isArray(data.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 || null; + 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 parser = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; + const parsed = parser ? parser(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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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 (Array.isArray(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; + if (!grouped.has(color)) grouped.set(color, []); + grouped.get(color).push(x, 0, x, H); + } + 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( - xf, + xc, 0, H, Math.max(2, Math.round(4 * dpr)), - Math.max(2, Math.round(4 * dpr)), - cssColorToRgba("#ff1744"), + Math.max(3, Math.round(6 * dpr)), + color, 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); + 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 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 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(); } - function pushOverviewWaterfallFrame(data) { - if (!overviewCanvas || !data || !isNumericBins(data.bins) || data.bins.length === 0) return; - overviewWaterfallRows.push(data.bins.slice()); - overviewWaterfallPushCount++; - trimOverviewWaterfallRows(); - scheduleOverviewDraw(); + 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 startHeaderSignalSampling() { - if (!overviewCanvas || overviewSignalTimer) return; - overviewSignalTimer = setInterval(() => { - pushHeaderSignalSample(isFiniteNumber(sigLastSUnits) ? sigLastSUnits : 0); - }, 120); +} +function overviewVisibleBinWindow(data, binCount) { + if (!data || !isFiniteNumber(data.sample_rate) || binCount <= 1) { + return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; } - 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()); + 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); } - 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); + 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); } - 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); } - if (needsFull) { + 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); } - 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); - } + } 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); + overviewWfTexPushCount = overviewWaterfallPushCount; + overviewWfTexPalKey = palKey; + texUpdated = 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); + 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.drawSegments(gridSegments, cssColorToRgba(pal.waveformGrid), 1); - const linePoints = []; - samples.forEach((sample, idx) => { + }); + 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(sample.v); - if (idx === 0 || x >= (linePoints[linePoints.length - 2] ?? -Infinity)) { - linePoints.push(x, y); + const y = toY(peak); + if (i === 0 || x >= (holdPoints[holdPoints.length - 2] ?? -Infinity)) { + holdPoints.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); + } + 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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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 (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; } } - 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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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 (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; + return primaryRds; +} +function activeChannelIsWfm() { + const virtualChannels = window.trx.modules.vchan?.channels || []; + if (virtualChannels.length > 0) { 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; - } + 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 }); } - return primaryRds; + } else if (lastModeName === "WFM" && primaryRds && lastFreqHz !== null && isFiniteNumber(lastFreqHz)) { + entries.push({ id: "primary", freq_hz: lastFreqHz, rds: 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(); + return entries; +} +function renderRdsOverlays() { + if (!rdsPsOverlay) return; + if (!lastSpectrumData || !overviewCanvas) { rdsOverlayEntries = []; - rdsPsOverlay.replaceChildren(); - if (entries.length === 0) { - rdsPsOverlay.style.display = "none"; + 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(); + 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; } - 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(); - 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; + 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"; } - 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; - }); + 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 positionRdsPsOverlay() { - positionRdsOverlays(); - } - function resetRdsDisplay() { - updateRdsPsOverlay(primaryRds); - } - function resetDecoderStateOnRigSwitch() { +} +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; - 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"); + lastFreqHz = hz; + window.lastFreqHz = lastFreqHz; + updateDocumentTitle(activeChannelRds()); + refreshWavelengthDisplay(lastFreqHz); + if (forceDisplay) { + freqDirty = false; } - 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"); - } + if (forceDisplay || !freqDirty) { + refreshFreqDisplay(); } - function resetIntfBars() { - updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); - updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); + window.ft8BaseHz = lastFreqHz; + if (window.updateFt8RfDisplay) { + window.updateFt8RfDisplay(); } - 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"; - } - } + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); } - 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(); + positionFastOverlay(lastFreqHz, currentBandwidthHz); + if (freqChanged && lastSpectrumData) { + scheduleSpectrumDraw(); } - function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { - const [, , maxBw] = mwDefaultsForMode(mode); - return Math.max(0, isFiniteNumber(maxBw) ? maxBw : currentBandwidthHz); + if (freqChanged && !lastSpectrumData) { + updateBandplanStrip(bandplanComputeRange()); } - 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 }]; + 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 } + ]; } - 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; + 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 }; } - 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 }; + if (side > 0) { + return { loHz: centerHz, hiHz: centerHz + widthHz, 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 setRigFrequency(freqHz) { - const targetHz = Math.round(freqHz); - if (!freqAllowed(targetHz)) { - showUnsupportedFreqPopup(targetHz); - throw new Error(`Unsupported frequency: ${targetHz}`); - } - 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( + 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, - Math.min(maxIdx - usableBins, Math.round((minCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + isFiniteNumber(spec.widthHz) ? spec.widthHz : Math.max(0, isFiniteNumber(bandwidthHz) ? bandwidthHz : 0) ); - const startMaxIdx = Math.max( - startMinIdx, - Math.min(maxIdx - usableBins, Math.round((maxCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + 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 setRigFrequency(freqHz) { + const targetHz = Math.round(freqHz); + if (!freqAllowed(targetHz)) { + showUnsupportedFreqPopup(targetHz); + throw new Error(`Unsupported frequency: ${targetHz}`); + } + 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) ); - 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 - }; + if (!isFiniteNumber(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; } - 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; + 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 }; } - function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { - if (!data || !isFiniteNumber(freqHz) || !isFiniteNumber(bandwidthHz) || bandwidthHz <= 0) { - return []; + 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 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); + 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; } } - 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 candidate = sweetSpotCandidateForFrame(probeFrame, lastFreqHz, currentBandwidthHz); + if (!candidate || !isFiniteNumber(candidate.centerHz)) continue; + if (!bestCandidate || candidate.score < bestCandidate.score) { + bestCandidate = candidate; } } - 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))) { + 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; } - 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; + if (!lastSpectrumData || Math.abs(targetCenterHz - Number(lastSpectrumData.center_hz)) >= 1) { + await postPath(`/set_center_freq?hz=${targetCenterHz}`); } - } - 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); + centerFreqEl.value = formatFrequencyForStep(targetCenterHz, 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 (modeVal && typeof modeVal === "object") { - 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 })); + if (isFiniteNumber(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { + showHint("Already at sweet spot", 900); } else { - supportedBands = []; + 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 (modeVal && typeof modeVal === "object") { + 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; } } - function freqAllowed(hz) { - if (!isFiniteNumber(hz)) return false; - if (supportedBands.length === 0) return true; - return supportedBands.some((b) => hz >= b.low && hz <= b.high); + 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 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)}. +} +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) - ); + 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 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 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 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; +} +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(); } - if (typeof update.owner_website_url === "string" && update.owner_website_url.length > 0) { - ownerWebsiteUrl = update.owner_website_url; + 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); } - 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(); - } - } + }); + 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(); - 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) { - 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"; - } - lastControl = update.enabled; - 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 resp.json(); - render(data); - refreshRigList(); - lastEventAt = Date.now(); - } catch (e) { - } - } - function connect() { - if (es) { - es.close(); - sseSessionId = null; - } - if (esHeartbeat) { - clearInterval(esHeartbeat); - } - stopMeterStreaming(); - startMeterStreaming(); - 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(); - pollFreshSnapshot(); - refreshRigList(); - }; - source.onmessage = (evt) => { - try { - if (evt.data === lastRendered) return; - const data = JSON.parse(evt.data); - 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 d = JSON.parse(evt.data); - sseSessionId = d.session_id || null; - } catch (_) { - } - window.trx.modules.vchan?.handleSession(evt.data); - }); - source.addEventListener("channels", (evt) => { - window.trx.modules.vchan?.handleChannels(evt.data); - }); - source.onerror = () => { - if (source.readyState === EventSource.CLOSED) { - powerHint.textContent = "trx-client connection lost, retrying…"; - setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); - source.close(); - 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(); - 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"); - updateRigIdentitySummary(nextRig, true); - 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); - updateRigIdentitySummary(lastActiveRigId); - window.trxUi?.setActiveRig(lastActiveRigId); - window.trx.modules.scheduler?.setRig(lastActiveRigId); - window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); - 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 || ""; - updateRigIdentitySummary(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", () => { - 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); - } +} +if (signalSplitSliderEl) { + signalSplitSliderEl.value = String(signalSplitPercent); + signalSplitSliderEl.addEventListener("input", () => { + signalSplitPercent = clampSignalSplitPercent(Number(signalSplitSliderEl.value)); + signalSplitSliderEl.value = String(signalSplitPercent); + updateSignalSplitControlText(); + saveSetting("signalSplitPercent", signalSplitPercent); + scheduleSpectrumLayout(); }); - 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); + 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"; } } - 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(); - applyCenterFreqFromInput(); - } else if (e.key === "Escape") { - centerFreqDirty = false; - refreshCenterFreqDisplay(); - centerFreqEl.blur(); + 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(); } - }); - 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(); + 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 = ""; } - 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); + 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); }); } - 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); + if (update.info && update.info.capabilities) { + updateJogStepSupport(update.info.capabilities); + updateSupportedBands(update.info.capabilities); + applyCapabilities(update.info.capabilities); } - 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; + 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(); } - }, { 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; + if (sdrGainEl && typeof update.filter.sdr_gain_db === "number" && document.activeElement !== sdrGainEl) { + sdrGainEl.value = String(Math.round(update.filter.sdr_gain_db)); } - }); - 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(); + 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); } - }); - 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)); + 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 { - jogMult = 1; + if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) <= 1) { + _freqOptimisticHz = null; + } + applyLocalTunedFrequency(sseHz); } } - jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); } - async function applyModeFromPicker() { - const mode = modeEl.value || ""; - if (!mode) { - showHint("Mode missing", 1500); - return; + 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) { + applyBwDefaultForMode(mode, false); + } } 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); + 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"; } } - modeEl.addEventListener("change", applyModeFromPicker); - txLimitInput.addEventListener("keydown", (e) => { + 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"; + } + lastControl = update.enabled; + 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 resp.json(); + render(data); + refreshRigList(); + lastEventAt = Date.now(); + } catch (e) { + } +} +function connect() { + if (es) { + es.close(); + sseSessionId = null; + } + if (esHeartbeat) { + clearInterval(esHeartbeat); + } + stopMeterStreaming(); + startMeterStreaming(); + 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(); + pollFreshSnapshot(); + refreshRigList(); + }; + source.onmessage = (evt) => { + try { + if (evt.data === lastRendered) return; + const data = JSON.parse(evt.data); + 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 d = JSON.parse(evt.data); + sseSessionId = d.session_id || null; + } catch (_) { + } + window.trx.modules.vchan?.handleSession(evt.data); + }); + source.addEventListener("channels", (evt) => { + window.trx.modules.vchan?.handleChannels(evt.data); + }); + source.onerror = () => { + if (source.readyState === EventSource.CLOSED) { + powerHint.textContent = "trx-client connection lost, retrying…"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); + source.close(); + 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(); + 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"); + updateRigIdentitySummary(nextRig, true); + 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); + updateRigIdentitySummary(lastActiveRigId); + window.trxUi?.setActiveRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); + window.trx.modules.backgroundDecode?.setRig(lastActiveRigId); + 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 || ""; + updateRigIdentitySummary(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", () => { + 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(); - txLimitBtn.click(); + applyCenterFreqFromInput(); + } else if (e.key === "Escape") { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + centerFreqEl.blur(); } }); - 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); + centerFreqEl.addEventListener("blur", () => { + if (centerFreqDirty) { + centerFreqDirty = false; + refreshCenterFreqDisplay(); } }); - 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(); - applyBandwidthFromInput(); - } - }); - } - if (spectrumBwSetBtn) { - spectrumBwSetBtn.addEventListener("click", () => { - applyBandwidthFromInput(); - }); - } - if (spectrumBwAutoBtn) { - spectrumBwAutoBtn.addEventListener("click", () => { - 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 = ""; - 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(); - } - if (updateHistory) { - updateTabHistory(name, replaceHistory); - } - scheduleSpectrumLayout(); - if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name); - if (name === "map") { - _initMapWhenReady(); - } - if (name === "statistics") { - window.trx.modules.map?.scheduleStatsRender(); - } - if (name === "recorder") { - refreshRecorderStatus(); - } - } - 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) => { + centerFreqEl.addEventListener("wheel", (e) => { e.preventDefault(); - const passphraseEl = requiredElement("auth-passphrase"); - const passphrase = passphraseEl.value; - const btn = requiredElement("auth-form").querySelector("button[type=submit]"); + 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; - 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"; + 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; } - }); - var guestBtn = document.getElementById("auth-guest-btn"); - if (guestBtn) { - guestBtn.addEventListener("click", async () => { - 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); - } - }); + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); +} +async function applyModeFromPicker() { + const mode = modeEl.value || ""; + if (!mode) { + showHint("Mode missing", 1500); + return; } - var trxState = /* @__PURE__ */ Object.create(null); - var trxModules = /* @__PURE__ */ Object.create(null); - 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; - } }, - 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; - } }, - 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, - 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 }); - if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins(); - 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 (!Array.isArray(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; + updateWfmControls(); + setControlPending(modeEl, true); + showHint("Setting mode…"); + try { + if (await window.trx.modules.vchan?.interceptMode(mode)) { + showHint("Channel mode set", 1500); 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; + 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) } }); } } - function stopSignalMeasurement() { - if (sigMeasureTimer) { - clearInterval(sigMeasureTimer); - sigMeasureTimer = null; - } - sigMeasuring = false; - sigMeasureBtn.textContent = "Measure"; - sigMeasureBtn.style.borderColor = ""; - sigMeasureBtn.style.color = ""; +} +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; } - 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)`; - } + 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(); + applyBandwidthFromInput(); } }); - sigClearBtn.addEventListener("click", () => { - stopSignalMeasurement(); - resetSignalMeasurementState(); - sigResult.textContent = ""; +} +if (spectrumBwSetBtn) { + spectrumBwSetBtn.addEventListener("click", () => { + applyBandwidthFromInput(); }); - 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 sdrGainControlsEl = document.getElementById("sdr-gain-controls"); - 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(() => { +} +if (spectrumBwAutoBtn) { + spectrumBwAutoBtn.addEventListener("click", () => { + applyAutoBandwidth(); }); - 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 (spectrumBwSweetBtn) { + spectrumBwSweetBtn.addEventListener("click", () => { + applySweetSpotCenter().catch(() => { }); - if (!rxGainNode) { - rxGainNode = audioCtx.createGain(); - rxGainNode.connect(audioCtx.destination); + }); +} +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; } - 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; + if (loadingEl2) loadingEl2.classList.remove("is-hidden"); + if (!_mapInitTimer) { + _mapInitTimer = setInterval(() => { + if (_activeTab !== "map") { + if (_mapInitTimer) clearInterval(_mapInitTimer); + _mapInitTimer = null; + return; } - samples += limit; + _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 = ""; + 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(); + } + if (updateHistory) { + updateTabHistory(name, replaceHistory); + } + scheduleSpectrumLayout(); + if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name); + if (name === "map") { + _initMapWhenReady(); + } + if (name === "statistics") { + window.trx.modules.map?.scheduleStatsRender(); + } + if (name === "recorder") { + refreshRecorderStatus(); + } +} +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", async () => { + 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); } - if (samples <= 0) return 0; - const rms = Math.sqrt(sumSquares / samples); - return Math.min(100, rms * 220); + }); +} +var trxState = /* @__PURE__ */ Object.create(null); +var trxModules = /* @__PURE__ */ Object.create(null); +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; + } }, + 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; + } }, + 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, + 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 }); +if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins(); +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); } - function normalizeWfmDenoiseLevel(value) { - const next = String(value ?? "").toLowerCase(); - if (next === "off" || next === "auto" || next === "low" || next === "medium" || next === "high") return next; - return "auto"; + return text; +} +function nearestBookmarkForHz(hz, widthPx, range) { + const ref = window.trx.modules.bookmarks?.overlayList ?? null; + if (!Array.isArray(ref) || !isFiniteNumber(hz) || !widthPx || !range || !isFiniteNumber(range.visSpanHz) || range.visSpanHz <= 0) { + return null; } - 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 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; } - 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); + 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; } - function updateSdrSquelchPctLabel() { - if (!sdrSquelchEl || !sdrSquelchPctEl) 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 sdrGainControlsEl = document.getElementById("sdr-gain-controls"); +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 = String(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)); - 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 (!syncFromServerSdrSquelch) { + submitSdrSquelchPercent(pct); + } + }); +} +var sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto"); +if (sdrSquelchAutoBtn) { + sdrSquelchAutoBtn.addEventListener("click", () => { 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)); + 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); - if (!syncFromServerSdrSquelch) { - submitSdrSquelchPercent(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"); + 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; } - 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 (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 { + configureRxStream(JSON.parse(evt.data)); + } 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 (sdrSquelchEl) { - sdrSquelchEl.value = String(pct); - updateSdrSquelchPctLabel(); - saveSetting("sdrSquelchPct", pct); + 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; + } } - 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"); - 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(); + opusDecoder.decode(new EncodedAudioChunk({ + type: "key", + timestamp: performance.now() * 1e3, + data + })); } catch (e) { } - opusDecoder = null; - } - if (wasmOpusDecoder) { + } else if (wasmOpusDecoder) { try { - wasmOpusDecoder.free(); + const result = wasmOpusDecoder.decodeFrame(data); + if (result && result.samplesDecoded > 0) { + scheduleDecodedAudio(result.channelData, result.samplesDecoded, result.sampleRate ?? streamInfo?.sample_rate ?? 48e3); + } } 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; + }; + audioWs.onclose = () => { + if (txActive) { + stopTxAudio(); } - 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 { - configureRxStream(JSON.parse(evt.data)); - } 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) { - 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) { - audioCtx.close(); - audioCtx = null; - } updateWfmControls(); + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + setAudioLevel(0); rxGainNode = null; if (opusDecoder) { try { @@ -5498,2679 +6544,2708 @@ ${unsupportedBandSummary()}`; wasmOpusDecoder = null; } nextPlayTime = 0; - rxAudioBtn.style.borderColor = ""; - rxAudioBtn.style.color = ""; - audioStatus.textContent = "Off"; - setAudioLevel(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; } - function startTxAudio() { - if (txActive) { - stopTxAudio(); - return; + if (audioCtx) { + audioCtx.close(); + audioCtx = null; + } + updateWfmControls(); + rxGainNode = null; + if (opusDecoder) { + try { + opusDecoder.close(); + } catch (e) { } - if (!hasWebCodecs) { - audioStatus.textContent = "Audio requires Chrome/Edge"; - return; + opusDecoder = null; + } + if (wasmOpusDecoder) { + try { + wasmOpusDecoder.free(); + } catch (e) { } - if (!audioWs || audioWs.readyState !== WebSocket.OPEN) { - audioStatus.textContent = "RX first"; - return; + wasmOpusDecoder = null; + } + nextPlayTime = 0; + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + setAudioLevel(0); + syncHeaderAudioBtn(); +} +function startTxAudio() { + if (txActive) { + 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); } - 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"; + 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(); - startTxTimeoutCountdown(); + const monoData = input.getChannelData(0); try { - await postPath("/set_ptt?ptt=true"); - } catch (e) { - console.error("PTT on failed", e); + 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) { } - 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"; - }); + }; + 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); } - async function stopTxAudio() { - if (!txActive) return; - txActive = false; - window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" }); - clearTxTimeout(); + if (txStream) { + txStream.getTracks().forEach((t) => t.stop()); + txStream = null; + } + if (txProcessor) { + txProcessor.source.disconnect(); + txProcessor.processor.disconnect(); + txProcessor = null; + } + if (txEncoder) { try { - await postPath("/set_ptt?ptt=false"); + txEncoder.close(); } catch (e) { - console.error("PTT off failed", e); } - if (txStream) { - txStream.getTracks().forEach((t) => t.stop()); - txStream = null; + 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 (txProcessor) { - txProcessor.source.disconnect(); - txProcessor.processor.disconnect(); - txProcessor = null; + }); +} +if (recorderStopBtn) { + recorderStopBtn.addEventListener("click", async () => { + try { + await postPath("/api/recorder/stop"); + } catch (e) { + console.error("Recorder stop failed", e); } - 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 { + }); +} +if (headerRecBtn) { + headerRecBtn.addEventListener("click", async () => { + try { + if (recorderActive) { 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 statusResp.json(); - renderRecorderActive(active); - } - if (filesResp.ok) { - _recorderFiles = await filesResp.json(); - renderRecorderFiles(); + } else { + await postPath("/api/recorder/start"); } } catch (e) { - console.error("Recorder status fetch failed", e); + console.error("Recorder toggle 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"; - try { - 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 decodeConnected = false; - 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); +} +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 statusResp.json(); + renderRecorderActive(active); } - 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; + if (filesResp.ok) { + _recorderFiles = await filesResp.json(); + renderRecorderFiles(); } + } catch (e) { + console.error("Recorder status fetch failed", e); } - 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(); - } +} +function renderRecorderActive(list) { + const el = document.getElementById("recorder-active-list"); + if (!el) return; + if (!list.length) { + el.innerHTML = '

No active recordings.

'; + return; } - 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; + let html = ''; + for (const r of list) { + const started = new Date(r.started_at * 1e3).toLocaleTimeString(); + const fname = r.path.split("/").pop(); + html += ``; } - 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); + 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); }); } - 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); + 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; } - 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 (_) { + 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 (_) { + } } - } - 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; + next.remove(); + btn.setAttribute("aria-expanded", "false"); + btn.textContent = "Play"; 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 (data.type === "status") { - const phase = String(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"); + el.querySelectorAll(".rec-player-row").forEach(function(r) { + const a = r.querySelector("audio"); + if (a) { + try { + a.pause(); + } catch (_) { } - 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") { - enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages); - return; - } - if (data.type === "done") { - historyWorkerDone = true; - clearTimeout(historyTimeout); - terminateDecodeHistoryWorker(); - maybeFinishHistoryReplay(); - return; - } - if (data.type === "error") { - console.error("Decode history worker failed", data.message || "unknown worker failure"); - terminateDecodeHistoryWorker(); - startDecodeHistoryFallback(); - } - }; - worker.postMessage({ - type: "fetch-history", - url: decodeHistoryUrl(), - batchLimit: DECODE_HISTORY_WORKER_GROUP_LIMIT + r.remove(); }); - 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 = () => { - decodeConnected = true; - updateDecodeStatus("Connected, listening for packets"); - }; - source.onmessage = (evt) => { + 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"; try { - const msg = JSON.parse(evt.data); - if (historySettled) dispatchDecodeMessage(msg); - else liveBuffer.push(msg); + 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" }); } - }; - source.onerror = () => { - const wasClosed = source.readyState === 2; - source.close(); - decodeConnected = false; - 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); + }); + }); +} +(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 decodeConnected = false; +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 (_) { } - }; - if (!startDecodeHistoryWorkerReplay()) { - startDecodeHistoryFallback(); + } + 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(); } } - window.addEventListener("beforeunload", () => { - if (txActive) { - navigator.sendBeacon("/set_ptt?ptt=false", ""); + 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 (data.type === "status") { + const phase = String(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") { + enqueueDecodeHistoryGroup(String(data.kind || ""), data.messages); + return; + } + if (data.type === "done") { + historyWorkerDone = true; + clearTimeout(historyTimeout); + terminateDecodeHistoryWorker(); + maybeFinishHistoryReplay(); + return; + } + if (data.type === "error") { + console.error("Decode history worker failed", 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 = () => { + decodeConnected = true; + updateDecodeStatus("Connected, listening for packets"); + }; + source.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + if (historySettled) dispatchDecodeMessage(msg); + else liveBuffer.push(msg); + } catch (e) { + } + }; + source.onerror = () => { + const wasClosed = source.readyState === 2; + source.close(); + decodeConnected = false; + 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 commaA = evt.data.indexOf(","); + const commaB = evt.data.indexOf(",", commaA + 1); + const centerHz = Number(evt.data.slice(0, commaA)); + const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); + const b64 = evt.data.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 (_) { } }); - 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; + source.addEventListener("rds", (evt) => { + try { + const rds = evt.data === "null" ? void 0 : JSON.parse(evt.data); + if (lastSpectrumData) lastSpectrumData.rds = rds; + updateRdsPsOverlay(rds ?? null); + } catch (_) { + } + }); + source.addEventListener("rds_vchan", (evt) => { + try { + const payload = evt.data === "null" ? [] : JSON.parse(evt.data); + const next = /* @__PURE__ */ new Map(); + const nextSig = /* @__PURE__ */ new Map(); + if (Array.isArray(payload)) { + payload.forEach((entry) => { + if (entry && entry.id) { + next.set(entry.id, entry.rds ?? null); + if (typeof entry.signal_db === "number") nextSig.set(entry.id, entry.signal_db); + } + }); } - if (waiter.timer) { - clearTimeout(waiter.timer); - waiter.timer = null; - } - if (typeof waiter.resolve === "function") { - waiter.resolve(frame); + vchanRdsById = next; + vchanSignalDbById = nextSig; + const virtualChannelId = window.trx.modules.vchan?.activeId; + if (virtualChannelId && nextSig.has(virtualChannelId)) { + sigLastDbm = nextSig.get(virtualChannelId); + refreshSigStrengthDisplay(); } + updateRdsPsOverlay(primaryRds); + } catch (_) { } - 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 commaA = evt.data.indexOf(","); - const commaB = evt.data.indexOf(",", commaA + 1); - const centerHz = Number(evt.data.slice(0, commaA)); - const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); - const b64 = evt.data.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 rds = evt.data === "null" ? void 0 : JSON.parse(evt.data); - if (lastSpectrumData) lastSpectrumData.rds = rds; - updateRdsPsOverlay(rds ?? null); - } catch (_) { - } - }); - source.addEventListener("rds_vchan", (evt) => { - try { - const payload = evt.data === "null" ? [] : JSON.parse(evt.data); - const next = /* @__PURE__ */ new Map(); - const nextSig = /* @__PURE__ */ new Map(); - if (Array.isArray(payload)) { - payload.forEach((entry) => { - if (entry && entry.id) { - 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); - refreshSigStrengthDisplay(); - } - updateRdsPsOverlay(primaryRds); - } catch (_) { - } - }); - source.onerror = () => { - rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected")); - if (spectrumSource) { - spectrumSource.close(); - spectrumSource = null; - } - scheduleSpectrumReconnect(); - }; - } - function stopSpectrumStreaming() { - if (spectrumSource !== null) { + }); + source.onerror = () => { + rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected")); + if (spectrumSource) { spectrumSource.close(); spectrumSource = null; } - if (spectrumReconnectTimer !== null) { - clearTimeout(spectrumReconnectTimer); - spectrumReconnectTimer = 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 { sig } = JSON.parse(evt.data); + applyMeterSample(sig); + } catch (_) { } - 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 { sig } = JSON.parse(evt.data); - applyMeterSample(sig); - } catch (_) { - } - }; - meterSource.onerror = () => { - if (meterSource) { - meterSource.close(); - meterSource = null; - } - scheduleMeterReconnect(); - }; - } - function stopMeterStreaming() { - if (meterSource !== null) { + }; + meterSource.onerror = () => { + if (meterSource) { meterSource.close(); meterSource = null; } - if (meterReconnectTimer !== null) { - clearTimeout(meterReconnectTimer); - meterReconnectTimer = null; - } - meterSmoothedDbm = null; + scheduleMeterReconnect(); + }; +} +function stopMeterStreaming() { + if (meterSource !== null) { + meterSource.close(); + meterSource = 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 = ""; + 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 String(ps ?? "").slice(0, 8).padEnd(8, "_").replaceAll(" ", "_"); +} +function formatPsHtml(ps) { + const clipped = String(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); } } - function formatOverlayPs(ps) { - return String(ps ?? "").slice(0, 8).padEnd(8, "_").replaceAll(" ", "_"); + return html; +} +function formatOverlayPi(pi) { + return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --"; +} +function formatOverlayPty(pty, ptyName) { + if (ptyName) return ptyName; + return pty != null ? String(pty) : "--"; +} +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; } - function formatPsHtml(ps) { - const clipped = String(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); - } + 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", () => { + copyRdsPsToClipboard(); + }); +} +var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); +if (rdsRawCopyBtn) { + rdsRawCopyBtn.addEventListener("click", () => { + 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); } - return html; - } - function formatOverlayPi(pi) { - return pi != null ? `PI 0x${Number(pi).toString(16).toUpperCase().padStart(4, "0")}` : "PI --"; - } - function formatOverlayPty(pty, ptyName) { - if (ptyName) return ptyName; - return pty != null ? String(pty) : "--"; - } - 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; + }); +} +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); } - 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 = "--"; + return; } - 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", () => { - copyRdsPsToClipboard(); - }); - } - var rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); - if (rdsRawCopyBtn) { - rdsRawCopyBtn.addEventListener("click", () => { - 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 = "--"; + 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 = "--"; - 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(); - } - }); + 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); } - 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; + 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++) { - spectrumTmpFillPoints.push(binX(i), binYFromBins(bins, i)); + spectrumTmpPeakPoints.push(binX(i), binYFromBins(peakHoldBins, 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(); + spectrumGl.drawPolyline(spectrumTmpPeakPoints, rgbaWithAlpha(pal.waveformPeak, 0.7), Math.max(1, dpr * 0.9)); } - 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; + 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); } - if (spectrumWaterfallCanvas) { - _cachedSpecWfCssW = spectrumWaterfallCanvas.clientWidth || 640; - _cachedSpecWfCssH = spectrumWaterfallCanvas.clientHeight || 120; + 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"; } } - 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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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); + 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"; } - const textureData = spectrumWfTexData; - if (needsFull) { + } + 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}|${pal.waterfallSat}|${pal.waterfallLight}|${pal.waterfallAlpha}|${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); } - 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"; - const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); - 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 })); + 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); } } - container.classList.add("bm-side-visible"); + spectrumWfTexPushCount = spectrumWfPushCount; + spectrumWfTexPalKey = palKey; + texUpdated = true; } - 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 _bmRef = window.trx.modules.bookmarks?.overlayList ?? null; - const allBookmarks = Array.isArray(_bmRef) ? _bmRef : []; - 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) + "%"; - } - }); + if (texUpdated || !spectrumWfTexReady) { + spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, textureData, "linear"); + spectrumWfTexReady = true; } - 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(); - }); + 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; }); - 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 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"; + const esc = (s) => String(s).replace(/&/g, "&").replace(//g, ">"); + 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 _bmRef = window.trx.modules.bookmarks?.overlayList ?? null; + const allBookmarks = Array.isArray(_bmRef) ? _bmRef : []; + 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 (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(); + 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 { - const s = document.createElement("script"); - s.src = "/screenshot.js"; - s.onload = () => { - void window.trx.modules.screenshot?.captureSpectrumScreenshot(); - }; - document.body.appendChild(s); - } - 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; + showHint("Already on step", 1200); } } - }, { 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; + return; } - function handleSpectrumWheel(e, canvasEl) { - e.preventDefault(); - if (!lastSpectrumData || !canvasEl) return; - if (e.ctrlKey) { - const direction = e.deltaY < 0 ? 1 : -1; - jogFreq(direction); - 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); } - 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(); + return; } - function handleSpectrumClick(e, canvasEl) { - if (_sDragMoved) { - _sDragMoved = false; - 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); } - 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); + return; } - if (spectrumCanvas) { - spectrumCanvas.addEventListener("wheel", (e) => { - handleSpectrumWheel(e, spectrumCanvas); - }, { passive: false }); + if (key === "arrowleft" || key === "arrowright") { + event.preventDefault(); + jogFreq(key === "arrowright" ? 1 : -1); + return; } - if (overviewCanvas) { - overviewCanvas.addEventListener("wheel", (e) => { - handleSpectrumWheel(e, overviewCanvas); - }, { passive: false }); - overviewCanvas.addEventListener("click", (e) => { - handleSpectrumClick(e, overviewCanvas); - }); + if (key === "arrowup" || key === "arrowdown") { + event.preventDefault(); + void shiftSpectrumCenter(key === "arrowup" ? 1 : -1); + return; } - 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) { + if (key === "m") { + event.preventDefault(); + if (modeEl && !modeEl.disabled) { + modeEl.focus(); + modeEl.click(); + if (typeof modeEl.showPicker === "function") { 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)}`) } - }); + modeEl.showPicker(); + } catch (_) { } - _bwDragEdge = null; - _bwDragCanvas = null; - return; } + } + 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; + } } - 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; + _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 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)}`; + 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 { - const baseText = formatSpectrumFreq(peakHz ?? hz); - spectrumTooltip.textContent = peakDb ? `${baseText} · ${peakDb}` : baseText; + newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz : _bwDragStartBwHz - dxHz; } - 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; + 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(); - }); - 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(); + 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); + } } - }); - } - 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((r) => { - if (!r.ok) throw new Error(String(r.status)); - return r.json(); - }).then((d) => { - bandplanData = d; - 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 + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { + kind: "error", + action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) } }); } - } - 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(); + _bwDragEdge = null; + _bwDragCanvas = null; return; } - const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz); - if (segments.length === 0) { - if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); - 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 }; } - 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; + }, { 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(); } - 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" : ""; + }, { 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(); } }); - 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" : ""; + } + 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((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json(); + }).then((d) => { + bandplanData = d; + 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" : ""; + }); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js deleted file mode 100644 index 8ee66d38..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js +++ /dev/null @@ -1,95 +0,0 @@ -// 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); - }; -})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js deleted file mode 100644 index 2fa74545..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js +++ /dev/null @@ -1,45 +0,0 @@ -"use strict"; -const 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"] -}; -const loaded = /* @__PURE__ */ new Set(); -const 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); -} -function requestPlugins(group) { - void loadPlugins(group).catch((error) => { - console.error(error); - }); -} -const loaderWindow = window; -loaderWindow.loadEagerPlugins = async () => { - await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); -}; -loaderWindow.loadPluginsForTab = loadPlugins; -document.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) return; - const tab = event.target.closest("[data-tab]")?.dataset.tab; - if (tab) requestPlugins(tab); -}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js deleted file mode 100644 index 7f597432..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-runtime.js +++ /dev/null @@ -1,80 +0,0 @@ -"use strict"; -const decoders = /* @__PURE__ */ new Map(); -const queued = /* @__PURE__ */ new Map(); -const 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; -} -const 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; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js index fb75b7c5..e577b4bb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js @@ -1,5 +1,5 @@ -"use strict"; -const screenshotWindow = window; +// src/screenshot.ts +var screenshotWindow = window; (function() { "use strict"; const T = screenshotWindow.trx; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js deleted file mode 100644 index 7f1dc89a..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js +++ /dev/null @@ -1,360 +0,0 @@ -"use strict"; -const browserWindow = window; -const 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; - 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 details = document.getElementById("advanced-radio-controls"); - if (details) details.open = layout.advanced; - const audioDetails = document.getElementById("audio-controls"); - if (audioDetails) audioDetails.open = layout.audio; - const schedulerDetails = document.getElementById("scheduler-controls"); - if (schedulerDetails) schedulerDetails.open = layout.scheduler; - 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 }); - } - } - 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(); - 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(); -})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js deleted file mode 100644 index 41afa5d7..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js +++ /dev/null @@ -1,498 +0,0 @@ -"use strict"; -(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); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index e56fbd00..3f45290c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -1637,12 +1637,7 @@ SPDX-License-Identifier: GPL-2.0-or-later - - - - - - - + + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 3246eace..52015816 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -15,43 +15,12 @@ await rm(outputDir, { recursive: true, force: true }); await build({ entryPoints: { - "api-client": path.join(sourceDir, "api", "client.ts"), - "ui-core": path.join(sourceDir, "ui-core.ts"), - "plugin-loader": path.join(sourceDir, "plugin-loader.ts"), - "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"), - screenshot: path.join(sourceDir, "screenshot.ts"), - "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), - }, - outdir: outputDir, - bundle: false, - target: "es2022", - sourcemap: false, - legalComments: "inline", - charset: "utf8", - logLevel: "info", -}); - -await build({ - entryPoints: { app: path.join(sourceDir, "bootstrap.ts") }, - outdir: outputDir, - bundle: true, - format: "iife", - platform: "browser", - target: "es2022", - sourcemap: false, - legalComments: "inline", - charset: "utf8", - logLevel: "info", -}); - -await build({ - entryPoints: { + app: path.join(sourceDir, "bootstrap.ts"), ft2: path.join(sourceDir, "plugins", "ft2.ts"), ft4: path.join(sourceDir, "plugins", "ft4.ts"), wspr: path.join(sourceDir, "plugins", "wspr.ts"), cw: path.join(sourceDir, "plugins", "cw.ts"), ft8: path.join(sourceDir, "plugins", "ft8.ts"), - "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"), vdes: path.join(sourceDir, "plugins", "vdes.ts"), wefax: path.join(sourceDir, "plugins", "wefax.ts"), "background-decode": path.join(sourceDir, "plugins", "background-decode.ts"), @@ -64,6 +33,7 @@ await build({ bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), "map-core": path.join(sourceDir, "map-core.ts"), + screenshot: path.join(sourceDir, "screenshot.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md index b6c76922..edd71e05 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md @@ -6,7 +6,45 @@ SPDX-License-Identifier: GPL-2.0-or-later # Frontend source -This directory is the source of the browser assets embedded by -`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory -after changing a source file. Cargo consumes the committed output under -`../assets/web/generated` and does not invoke Node.js. +This directory contains the strict TypeScript source for the browser assets +embedded by `trx-frontend-http`. `bootstrap.ts` is the production entry point; +esbuild follows its imports and emits `/app.js`. Feature plugins are ESM entry +points loaded on demand by `plugin-loader.ts`, and the decode-history worker is +built with its own Web Worker TypeScript configuration. + +Run all frontend commands from the parent `frontend` directory: + +```sh +npm ci +npm run typecheck +npm run lint +npm test +npm run test:browser +npm run build +npm run verify-generated +``` + +`npm run verify-generated` regenerates Rust wire contracts and browser bundles, +then rejects drift from the committed files. The browser smoke test needs a +local Chromium-family executable; set `CHROMIUM_PATH` when it is not installed +at a conventional system path. + +Cargo consumes committed output under `../assets/web/generated`. It never +invokes Node.js, installs packages, or accesses the network. After changing a +source file, commit the deterministic generated asset changes together with the +source change. + +## Boundaries + +- `api/generated.ts` is generated from Rust and must not be edited manually. +- `api/client.ts` owns runtime validation at version-sensitive HTTP and SSE + boundaries. +- `core/` contains dependency-light shared helpers. +- `features/` contains application behavior grouped by responsibility. +- `plugins/` contains lazy decoder and high-coupling feature entries. +- Files under `assets/web/vendor/` are vendored JavaScript and are not part of + the TypeScript migration. + +First-party features communicate through imports, the typed plugin runtime, or +the documented `window.trx` host interface. New standalone `window` callbacks +are not permitted. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 82bd5d06..f9f545bf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -46,6 +46,7 @@ import type { NumericBins } from "./features/spectrum/math.js"; import type { AuthRole } from "./api/auth.js"; import type { RdsData, RigCapabilities, RigListItem, RigSnapshot } from "./api/generated.js"; import type { TrxPluginRuntime } from "./plugins/runtime-contract.js"; +import { loadEagerPlugins, loadPluginsForTab } from "./plugin-loader.js"; interface SpectrumFrame { bins: NumericBins; @@ -4530,7 +4531,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac updateTabHistory(name, replaceHistory); } scheduleSpectrumLayout(); - if (typeof window.loadPluginsForTab === "function") window.loadPluginsForTab(name); + void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); }); if (name === "map") { _initMapWhenReady(); } @@ -4787,7 +4788,7 @@ window.trx = Object.freeze({ state: trxState, core: trxCore, modules: trxModules // Load plugin scripts now that window.trx is populated. Dynamic scripts are // async so they must not be created before the namespace they depend on exists. -if (typeof window.loadEagerPlugins === "function") window.loadEagerPlugins(); +void loadEagerPlugins().catch((error: unknown) => { console.error(error); }); // Start the app initializeApp(); @@ -7829,17 +7830,14 @@ window.addEventListener("keydown", (event) => { if (event.ctrlKey || event.metaKey || event.altKey) return; if (shouldIgnoreGlobalShortcut(event.target)) return; - // S — spectrum screenshot (lazy-loads screenshot.js on first use) + // S — spectrum screenshot (lazy-loads its typed module on first use) if (key === "s") { event.preventDefault(); if (window.trx.modules.screenshot) { void window.trx.modules.screenshot.captureSpectrumScreenshot(); - } else { - const s = document.createElement("script"); - s.src = "/screenshot.js"; - s.onload = () => { void window.trx.modules.screenshot?.captureSpectrumScreenshot(); }; - document.body.appendChild(s); - } + } else void import("./screenshot.js") + .then(() => window.trx.modules.screenshot?.captureSpectrumScreenshot()) + .catch((error: unknown) => { console.error("Screenshot module failed to load", error); }); return; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/bootstrap.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/bootstrap.ts index e2680770..41011e90 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/bootstrap.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/bootstrap.ts @@ -2,4 +2,8 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -import "./app.ts"; +import "./webgl-renderer.js"; +import "./ui-core.js"; +import "./plugin-runtime.js"; +import "./leaflet-ais-tracksymbol.js"; +import "./app.js"; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index 46685f3d..2af00620 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -36,21 +36,10 @@ async function loadPlugins(group: string): Promise { for (const path of pluginGroups[group as PluginGroup]) await loadPlugin(path); } -function requestPlugins(group: string): void { - void loadPlugins(group).catch((error: unknown) => { console.error(error); }); +export async function loadEagerPlugins(): Promise { + await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); } -const loaderWindow = window as typeof window & { - loadEagerPlugins?: () => Promise; - loadPluginsForTab?: (tab: string) => Promise; -}; -loaderWindow.loadEagerPlugins = async () => { - await Promise.all(["digital-modes", "map-data", "bookmarks", "settings"].map(loadPlugins)); -}; -loaderWindow.loadPluginsForTab = loadPlugins; - -document.addEventListener("click", (event) => { - if (!(event.target instanceof Element)) return; - const tab = event.target.closest("[data-tab]")?.dataset.tab; - if (tab) requestPlugins(tab); -}); +export async function loadPluginsForTab(tab: string): Promise { + await loadPlugins(tab); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.ts index 5663fb1f..6e6dc14f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.ts @@ -21,6 +21,7 @@ interface TrxScreenshotBridge { showHint(message: string, durationMs: number): void; modules: { screenshot?: ScreenshotModule }; } +export {}; const screenshotWindow = window as typeof window & { trx: TrxScreenshotBridge }; (function () { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs index 4a12e643..58d36fc5 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("AIS entry forwards positioned vessels with normalized metadata", async () => { const forwarded = []; @@ -23,7 +24,7 @@ test("AIS entry forwards positioned vessels with normalized metadata", async () Map, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs index 6955db3c..a9963b0b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; @@ -26,7 +25,7 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as Reflect, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url)); assert.equal(source.includes("raw.githubusercontent.com"), false); new vm.Script(runtime).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs index 6c65fe24..d09e4a66 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/cw.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; class ElementFixture { constructor() { this.children = []; this.textContent = ""; this.style = {}; this.classList = { toggle() {} }; } @@ -37,7 +38,7 @@ test("CW entry appends server-decoded text and registers lifecycle callbacks", a Math, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/cw.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs index e1373dc3..c2238ba3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; @@ -30,7 +29,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async ( Array, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await bundleEntry(new URL("../src/plugins/ft2.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); @@ -62,7 +61,7 @@ test("FT8 entry installs shared parsing without relying on script globals", asyn Set, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await bundleEntry(new URL("../src/plugins/ft8.ts", import.meta.url)); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs index 224bd318..74b5ab3d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; import { bundleEntry } from "./bundle-entry.mjs"; @@ -22,7 +21,7 @@ test("HF APRS entry uses shared typed normalization and local symbols", async () Reflect, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await bundleEntry(new URL("../src/plugins/hf-aprs.ts", import.meta.url)); assert.equal(source.includes("raw.githubusercontent.com"), false); new vm.Script(runtime).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs index 46ccbda6..73c810a1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs @@ -3,9 +3,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("local AIS track-symbol adapter renders immediately on first add", async () => { const listeners = new Map(); @@ -34,10 +34,7 @@ test("local AIS track-symbol adapter renders immediately on first add", async () Util: { extend: Object.assign }, divIcon: (options) => options, }; - const source = await readFile( - new URL("../../assets/web/generated/leaflet-ais-tracksymbol.js", import.meta.url), - "utf8", - ); + const source = await bundleEntry(new URL("../src/leaflet-ais-tracksymbol.ts", import.meta.url)); new vm.Script(source).runInNewContext({ L, console }); Object.assign(L.TrxAisTrackSymbol.prototype, { onAdd: markerPrototype.onAdd, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs index b5b1ba81..e70b5c8b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/plugin-runtime.test.mjs @@ -3,14 +3,14 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("plugin runtime queues lazy decoder traffic and flushes in order on registration", async () => { const window = {}; const context = vm.createContext({ window, Map, Error }); - const source = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const source = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); new vm.Script(source).runInContext(context); assert.equal(window.trxPluginRuntime.dispatch("late", { sequence: 1 }), false); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs index 2f8b4ee6..6a26205b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/sat.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => { const overlays = []; @@ -26,7 +27,7 @@ test("satellite entry registers lifecycle callbacks and forwards georeferenced i Error, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs index 5d9b1dee..6370d8c7 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs @@ -10,10 +10,11 @@ const indexPath = new URL("../../assets/web/index.html", import.meta.url); const pluginLoaderPath = new URL("../src/plugin-loader.ts", import.meta.url); const mapCorePath = new URL("../src/map-core.ts", import.meta.url); -test("index loads the shared UI before the application", async () => { +test("index loads one first-party application bootstrap", async () => { const html = await readFile(indexPath, "utf8"); - assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js")); - assert.ok(html.indexOf("/plugin-loader.js") < html.indexOf("/app.js")); + assert.equal((html.match(/]+src="\/(?!vendor\/)[^"]+\.js"/g) ?? []).length, 1); + assert.match(html, /src="\/app\.js"/); + assert.doesNotMatch(html, /src="\/(?:ui-core|plugin-runtime|plugin-loader|webgl-renderer|leaflet-ais-tracksymbol)\.js"/); assert.equal(html.includes("var pluginScripts"), false); }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs index 90a4436f..1828ba97 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ui-core.test.mjs @@ -3,8 +3,8 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; class ClassList { constructor() { this.values = new Set(); } @@ -104,7 +104,7 @@ const context = vm.createContext({ console, }); -const source = await readFile(new URL("../../assets/web/generated/ui-core.js", import.meta.url), "utf8"); +const source = await bundleEntry(new URL("../src/ui-core.ts", import.meta.url)); new vm.Script(source, { filename: "ui-core.js" }).runInContext(context); const ui = window.trxUi; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs index df7bd10a..3e838d79 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/vdes.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("VDES entry normalizes and forwards positioned server messages", async () => { const forwarded = []; @@ -22,7 +23,7 @@ test("VDES entry normalizes and forwards positioned server messages", async () = Array, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/vdes.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/webgl-renderer.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/webgl-renderer.test.mjs index 72e21ba9..2dd9c715 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/webgl-renderer.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/webgl-renderer.test.mjs @@ -3,9 +3,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("WebGL color helpers normalize CSS and HSL colors", async () => { const window = {}; @@ -22,10 +22,7 @@ test("WebGL color helpers normalize CSS and HSL colors", async () => { String, parseInt, }); - const source = await readFile( - new URL("../../assets/web/generated/webgl-renderer.js", import.meta.url), - "utf8", - ); + const source = await bundleEntry(new URL("../src/webgl-renderer.ts", import.meta.url)); new vm.Script(source).runInContext(context); assert.deepEqual(Array.from(window.trxParseCssColor("#ff800080")), [1, 128 / 255, 0, 128 / 255]); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs index 4e413e17..be18586c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wefax.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", async () => { const status = { textContent: "", style: { color: "" } }; @@ -19,7 +20,7 @@ test("WEFAX entry exposes typed lifecycle handlers and renders decoder state", a Uint8Array, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/wefax.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs index 540f8ae7..af964cc2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/wspr.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("WSPR entry forwards decoded locators at their absolute frequency", async () => { const forwarded = []; @@ -25,7 +26,7 @@ test("WSPR entry forwards decoded locators at their absolute frequency", async ( Element: class Element {}, console, }); - const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8"); + const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url)); const source = await readFile(new URL("../../assets/web/generated/wspr.js", import.meta.url), "utf8"); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs index 4a61148d..39f34f3e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/status.rs @@ -62,34 +62,18 @@ mod tests { use super::*; #[test] - fn shared_ui_core_is_embedded_and_loaded_before_app() { + fn typed_application_bundle_contains_shared_runtime() { let html = index_html(); - let ui_core = html.find("/ui-core.js").expect("ui-core script is loaded"); - let app = html.find("/app.js").expect("application script is loaded"); - - assert!( - ui_core < app, - "UI primitives must load before application code" - ); - assert!(generated_asset("ui-core.js").is_some_and(|asset| { - std::str::from_utf8(asset).is_ok_and(|script| script.contains("trxUi")) - })); - let plugin_loader = html - .find("/plugin-loader.js") - .expect("typed plugin loader is loaded"); - assert!(plugin_loader < app, "plugin registry must load before app"); - let plugin_runtime = html - .find("/plugin-runtime.js") - .expect("typed plugin runtime is loaded"); - assert!( - plugin_runtime < plugin_loader, - "plugin runtime must load before the lazy loader" - ); - assert!(generated_asset("plugin-runtime.js").is_some_and(|asset| { - std::str::from_utf8(asset).is_ok_and(|script| script.contains("registerDecoder")) - })); - assert!(generated_asset("plugin-loader.js").is_some_and(|asset| { - std::str::from_utf8(asset).is_ok_and(|script| script.contains("import(path)")) + assert_eq!(html.matches("/app.js").count(), 1); + assert!(!html.contains("/ui-core.js")); + assert!(!html.contains("/plugin-runtime.js")); + assert!(!html.contains("/plugin-loader.js")); + assert!(generated_asset("app.js").is_some_and(|asset| { + std::str::from_utf8(asset).is_ok_and(|script| { + script.contains("trxUi") + && script.contains("registerDecoder") + && script.contains("TrxAisTrackSymbol") + }) })); assert!(html.contains("Scheduler controls")); assert!(html.contains("Audio controls"));