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 = `
+
`;
+ 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 = '| Rig | VChan | File | Started |
';
- for (const r of list) {
- const started = new Date(r.started_at * 1e3).toLocaleTimeString();
- const fname = r.path.split("/").pop();
- html += `| ${escapeHtml(r.rig_id)} | ${r.vchan_id ? escapeHtml(r.vchan_id) : "-"} | ${escapeHtml(fname)} | ${started} |
`;
- }
- html += "
";
- 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 = '| File | Size | Actions |
';
- for (const f of page) {
- const safeName = escapeHtml(f.name);
- const encodedName = encodeURIComponent(f.name);
- html += '| ' + safeName + " | " + formatByteSize(f.size) + ' | |
';
- }
- html += "
";
- 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 = '| Rig | VChan | File | Started |
';
+ for (const r of list) {
+ const started = new Date(r.started_at * 1e3).toLocaleTimeString();
+ const fname = r.path.split("/").pop();
+ html += `| ${escapeHtml(r.rig_id)} | ${r.vchan_id ? escapeHtml(r.vchan_id) : "-"} | ${escapeHtml(fname)} | ${started} |
`;
}
- 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 += "
";
+ 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 = '| File | Size | Actions |
';
+ for (const f of page) {
+ const safeName = escapeHtml(f.name);
+ const encodedName = encodeURIComponent(f.name);
+ html += '| ' + safeName + " | " + formatByteSize(f.size) + ' | |
';
+ }
+ html += "
";
+ 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 = `
- `;
- 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
-
-
-
-
-
-
-
+
+