From 888f793eb86ffa7a3964aad205a978ee5e36a148 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 1 Aug 2026 11:52:47 +0200 Subject: [PATCH] build: add frontend TypeScript toolchain --- .gitea/workflows/ci.yml | 18 + .gitignore | 3 + .../assets/web/generated/ais.js | 335 + .../assets/web/generated/app.js | 8271 +++++++++++++++ .../assets/web/generated/aprs.js | 407 + .../assets/web/generated/background-decode.js | 355 + .../assets/web/generated/bookmarks.js | 647 ++ .../assets/web/generated/cw.js | 386 + .../web/generated/decode-history-worker.js | 167 + .../assets/web/generated/ft2.js | 195 + .../assets/web/generated/ft4.js | 195 + .../assets/web/generated/ft8.js | 425 + .../assets/web/generated/hf-aprs.js | 360 + .../web/generated/leaflet-ais-tracksymbol.js | 94 + .../assets/web/generated/map-core.js | 3119 ++++++ .../assets/web/generated/sat-scheduler.js | 256 + .../assets/web/generated/sat.js | 447 + .../assets/web/generated/scheduler.js | 1195 +++ .../assets/web/generated/screenshot.js | 236 + .../assets/web/generated/ui-core.js | 329 + .../assets/web/generated/vchan.js | 453 + .../assets/web/generated/vdes.js | 282 + .../assets/web/generated/webgl-renderer.js | 485 + .../assets/web/generated/wefax.js | 304 + .../assets/web/generated/wspr.js | 251 + .../trx-frontend-http/frontend/build.mjs | 49 + .../frontend/eslint.config.js | 42 + .../frontend/package-lock.json | 1971 ++++ .../trx-frontend-http/frontend/package.json | 24 + .../trx-frontend-http/frontend/src/README.md | 12 + .../trx-frontend-http/frontend/src/app.js | 8934 +++++++++++++++++ .../frontend/src/decode-history-worker.js | 180 + .../frontend/src/leaflet-ais-tracksymbol.js | 120 + .../frontend/src/map-core.js | 3515 +++++++ .../frontend/src/plugins/ais.js | 407 + .../frontend/src/plugins/aprs.js | 498 + .../frontend/src/plugins/background-decode.js | 410 + .../frontend/src/plugins/bookmarks.js | 807 ++ .../frontend/src/plugins/cw.js | 451 + .../frontend/src/plugins/ft2.js | 207 + .../frontend/src/plugins/ft4.js | 207 + .../frontend/src/plugins/ft8.js | 486 + .../frontend/src/plugins/hf-aprs.js | 444 + .../frontend/src/plugins/sat-scheduler.js | 321 + .../frontend/src/plugins/sat.js | 546 + .../frontend/src/plugins/scheduler.js | 1526 +++ .../frontend/src/plugins/vchan.js | 565 ++ .../frontend/src/plugins/vdes.js | 352 + .../frontend/src/plugins/wefax.js | 386 + .../frontend/src/plugins/wspr.js | 292 + .../frontend/src/screenshot.js | 265 + .../trx-frontend-http/frontend/src/ui-core.js | 355 + .../frontend/src/webgl-renderer.js | 535 + .../frontend/tests/smoke.test.mjs | 12 + .../trx-frontend-http/frontend/tsconfig.json | 17 + .../trx-frontend-http/src/status.rs | 47 +- 56 files changed, 43175 insertions(+), 23 deletions(-) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/decode-history-worker.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/background-decode.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/bookmarks.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/cw.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft2.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft4.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ft8.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vchan.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/vdes.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wefax.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/wspr.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/screenshot.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/src/webgl-renderer.js create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/smoke.test.mjs create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tsconfig.json diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7cc2a4d0..aa545ada 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -36,6 +36,24 @@ jobs: - name: Test run: cargo test --workspace --locked + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend + steps: + - uses: actions/checkout@v4 + - name: Install locked frontend dependencies + run: npm ci + - name: Type-check + run: npm run typecheck + - name: Lint + run: npm run lint + - name: Test + run: npm test + - name: Verify generated assets + run: npm run verify-generated + reuse: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 7fab3692..88f3a147 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ Cargo.lock coverage/ benchmarks/ +# Frontend dependencies +**/node_modules/ + # Env .env .env.* diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js new file mode 100644 index 00000000..0a8c4212 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -0,0 +1,335 @@ +"use strict"; +const aisStatus = document.getElementById("ais-status"); +const aisMessagesEl = document.getElementById("ais-messages"); +const aisFilterInput = document.getElementById("ais-filter"); +const aisBarOverlay = document.getElementById("ais-bar-overlay"); +const aisChannelSummaryEl = document.getElementById("ais-channel-summary"); +const aisVesselCountEl = document.getElementById("ais-vessel-count"); +const aisLatestSeenEl = document.getElementById("ais-latest-seen"); +const AIS_BAR_WINDOW_MS = 15 * 60 * 1e3; +const AIS_DEFAULT_A_HZ = 161975e3; +const AIS_CHANNEL_SPACING_HZ = 5e4; +let aisFilterText = ""; +let aisMessageHistory = []; +function currentAisHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneAisMessageHistory() { + const cutoffMs = Date.now() - currentAisHistoryRetentionMs(); + aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); +} +function scheduleAisUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleAisHistoryRender() { + scheduleAisUi("ais-history", () => renderAisHistory()); +} +function scheduleAisBarUpdate() { + scheduleAisUi("ais-bar", () => updateAisBar()); +} +function formatAisMhz(freqHz) { + return `${(freqHz / 1e6).toFixed(3)} MHz`; +} +function currentAisChannelPlan() { + const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); + const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; + const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; + return { + aHz: safeAHz, + bHz: safeAHz + AIS_CHANNEL_SPACING_HZ + }; +} +function aisChannelInfo(channel) { + const plan = currentAisChannelPlan(); + const ch = String(channel || "").trim().toUpperCase(); + if (ch === "B") { + return { + label: "AIS-B", + badgeClass: "ais-badge ais-badge-channel-b", + freqText: formatAisMhz(plan.bHz) + }; + } + return { + label: "AIS-A", + badgeClass: "ais-badge ais-badge-channel-a", + freqText: formatAisMhz(plan.aHz) + }; +} +function aisDisplayName(msg) { + return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`; +} +function aisDisplayNameHtml(msg) { + const label = escapeMapHtml(aisDisplayName(msg)); + const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; + if (!url) return label; + return `${label}`; +} +function aisTypeLabel(type) { + switch (Number(type)) { + case 1: + case 2: + case 3: + return "Class A Position"; + case 4: + return "Base Station"; + case 5: + return "Static/Voyage"; + case 18: + return "Class B Position"; + case 19: + return "Class B Extended"; + case 21: + return "Aid to Nav"; + case 24: + return "Class B Static"; + default: + return `Type ${type ?? "--"}`; + } +} +function aisAgeText(tsMs) { + if (!Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function aisMotionText(msg) { + const parts = [ + msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, + msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}Β° COG` : null, + msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}Β° HDG` : null + ].filter(Boolean); + return parts.join(" Β· "); +} +function aisRouteText(msg) { + return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); +} +function aisDistanceText(msg) { + if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) { + return ""; + } + const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function aisLatestByVessel(messages) { + const byMmsi = /* @__PURE__ */ new Map(); + for (const msg of messages) { + const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`; + if (!byMmsi.has(key)) byMmsi.set(key, msg); + } + return Array.from(byMmsi.values()); +} +function updateAisSummary() { + const plan = currentAisChannelPlan(); + if (aisChannelSummaryEl) { + aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} Β· B ${formatAisMhz(plan.bHz)}`; + } + const vessels = aisLatestByVessel(aisMessageHistory); + if (aisVesselCountEl) { + const count = vessels.length; + aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`; + } + if (aisLatestSeenEl) { + const latest = aisMessageHistory[0]; + if (!latest) { + aisLatestSeenEl.textContent = "No traffic yet"; + } else { + const channel = aisChannelInfo(latest.channel); + aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`; + } + } +} +function renderAisRow(msg) { + const row = document.createElement("div"); + row.className = "ais-message"; + const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + const name = aisDisplayName(msg); + const nameHtml = aisDisplayNameHtml(msg); + const channel = aisChannelInfo(msg.channel); + const motion = aisMotionText(msg); + const route = aisRouteText(msg); + const distance = aisDistanceText(msg); + const pos = msg.lat != null && msg.lon != null ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; + row.dataset.filterText = [ + name, + msg.mmsi, + msg.channel, + channel.label, + msg.vessel_name, + msg.callsign, + msg.destination, + aisTypeLabel(msg.message_type) + ].filter(Boolean).join(" ").toUpperCase(); + row.innerHTML = `
${ts}${nameHtml}${escapeMapHtml(channel.label)}${escapeMapHtml(aisTypeLabel(msg.message_type))}
MMSI ${escapeMapHtml(String(msg.mmsi))}` + (route ? `${escapeMapHtml(route)}` : "") + `${escapeMapHtml(channel.freqText)}
` + (motion ? `${escapeMapHtml(motion)}` : `No motion data`) + (distance ? `${escapeMapHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeMapHtml(aisAgeText(msg._tsMs))}
`; + applyAisFilterToRow(row); + return row; +} +function applyAisFilterToRow(row) { + if (!aisFilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.filterText || ""; + row.style.display = message.includes(aisFilterText) ? "" : "none"; +} +function applyAisFilterToAll() { + if (!aisMessagesEl) return; + const rows = aisMessagesEl.querySelectorAll(".ais-message"); + rows.forEach((row) => applyAisFilterToRow(row)); +} +function updateAisBar() { + if (!aisBarOverlay) return; + updateAisSummary(); + const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS"; + const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS; + const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs); + const messages = aisLatestByVessel(recent).slice(0, 8); + if (!isAis || messages.length === 0) { + aisBarOverlay.style.display = "none"; + aisBarOverlay.innerHTML = ""; + return; + } + let html = `
AISLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + const pin = msg.lat != null && msg.lon != null ? `` : ""; + const name = `${aisDisplayNameHtml(msg)}`; + const channel = aisChannelInfo(msg.channel); + const distance = aisDistanceText(msg); + const details = [ + `MMSI ${escapeMapHtml(String(msg.mmsi))}`, + escapeMapHtml(channel.label), + msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, + msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}Β°` : null, + distance ? escapeMapHtml(distance) : null, + escapeMapHtml(aisAgeText(msg._tsMs)) + ].filter(Boolean).join(" Β· "); + html += `
${ts}${pin}${name}: ${details}
`; + } + aisBarOverlay.innerHTML = html; + aisBarOverlay.style.display = "flex"; +} +window.updateAisBar = updateAisBar; +window.clearAisBar = function() { + window.resetAisHistoryView(); +}; +window.resetAisHistoryView = function() { + if (aisMessagesEl) aisMessagesEl.innerHTML = ""; + aisMessageHistory = []; + updateAisBar(); + renderAisHistory(); + if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais"); +}; +function renderAisHistory() { + pruneAisMessageHistory(); + if (!aisMessagesEl) { + updateAisSummary(); + return; + } + const fragment = document.createDocumentFragment(); + for (let i = 0; i < aisMessageHistory.length; i += 1) { + fragment.appendChild(renderAisRow(aisMessageHistory[i])); + } + aisMessagesEl.replaceChildren(fragment); + updateAisSummary(); +} +function addAisMessage(msg) { + const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + aisMessageHistory.unshift(msg); + pruneAisMessageHistory(); + scheduleAisBarUpdate(); + scheduleAisHistoryRender(); + if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) { + window.aisMapAddVessel(msg); + } +} +function normalizeServerAisMessage(msg) { + return { + rig_id: msg.rig_id || null, + channel: msg.channel, + message_type: msg.message_type, + mmsi: msg.mmsi, + lat: msg.lat, + lon: msg.lon, + sog_knots: msg.sog_knots, + cog_deg: msg.cog_deg, + heading_deg: msg.heading_deg, + vessel_name: msg.vessel_name, + callsign: msg.callsign, + destination: msg.destination, + ts_ms: msg.ts_ms + }; +} +window.onServerAisBatch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (aisStatus) aisStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerAisMessage(msg); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + if (next.lat != null && next.lon != null && window.aisMapAddVessel) { + window.aisMapAddVessel(next); + } + normalized.push(next); + } + normalized.reverse(); + aisMessageHistory = normalized.concat(aisMessageHistory); + pruneAisMessageHistory(); + scheduleAisBarUpdate(); + scheduleAisHistoryRender(); +}; +window.restoreAisHistory = function(messages) { + window.onServerAisBatch(messages); +}; +window.pruneAisHistoryView = function() { + pruneAisMessageHistory(); + updateAisBar(); + renderAisHistory(); +}; +document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_ais_decode"); + window.resetAisHistoryView(); + } catch (e) { + console.error("AIS history clear failed", e); + } +}); +if (aisFilterInput) { + aisFilterInput.addEventListener("input", () => { + aisFilterText = aisFilterInput.value.trim().toUpperCase(); + renderAisHistory(); + }); +} +window.onServerAis = function(msg) { + if (aisStatus) aisStatus.textContent = "Receiving"; + addAisMessage(normalizeServerAisMessage(msg)); +}; +updateAisSummary(); +if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais"); 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 new file mode 100644 index 00000000..948ef53e --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -0,0 +1,8271 @@ +"use strict"; +let decoderRegistry = []; +window.decoderRegistry = decoderRegistry; +const _decoderRegistryReadyCallbacks = []; +window.onDecoderRegistryReady = function(fn) { + if (decoderRegistry.length > 0) fn(); + else _decoderRegistryReadyCallbacks.push(fn); +}; +(async function fetchDecoderRegistry() { + try { + const resp = await fetch("/decoders"); + if (resp.ok) { + decoderRegistry = await resp.json(); + window.decoderRegistry = decoderRegistry; + for (const fn of _decoderRegistryReadyCallbacks) fn(); + _decoderRegistryReadyCallbacks.length = 0; + hideUnsupportedDecoderTabs(); + refreshOperatorLayoutCapabilities(); + } + } catch (e) { + console.error("Failed to fetch decoder registry:", e); + } +})(); +function hideUnsupportedDecoderTabs() { + const knownIds = new Set(decoderRegistry.map(function(d) { + return d.id; + })); + const alwaysShow = /* @__PURE__ */ new Set(["overview", "rds", "sat"]); + document.querySelectorAll("#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]").forEach(function(btn) { + const id = btn.dataset.subtab; + if (alwaysShow.has(id) || knownIds.has(id)) return; + btn.style.display = "none"; + var panel = document.getElementById("subtab-" + id); + if (panel) panel.style.display = "none"; + }); + document.querySelectorAll('[id^="about-dec-"]').forEach(function(el) { + var id = el.id.replace("about-dec-", ""); + if (!alwaysShow.has(id) && !knownIds.has(id)) { + var row = el.closest("tr"); + if (row) row.style.display = "none"; + } + }); + document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach(function(el) { + var m = el.id.match(/^settings-clear-(.+)-history$/); + if (m && !alwaysShow.has(m[1]) && !knownIds.has(m[1])) { + el.style.display = "none"; + } + }); + document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach(function(el) { + var id = el.dataset.decoder; + if (!alwaysShow.has(id) && !knownIds.has(id)) { + el.style.display = "none"; + } + }); +} +const STORAGE_PREFIX = "trx_"; +function saveSetting(key, value) { + try { + localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); + } catch (e) { + } +} +function loadSetting(key, fallback) { + try { + const v = localStorage.getItem(STORAGE_PREFIX + key); + return v !== null ? JSON.parse(v) : fallback; + } catch (e) { + return fallback; + } +} +function escapeMapHtml(input) { + return String(input).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +let authRole = null; +let authEnabled = true; +async function checkAuthStatus() { + try { + const resp = await fetch("/auth/session"); + if (resp.status === 404) { + return { authenticated: true, role: "control", auth_disabled: true }; + } + if (!resp.ok) return { authenticated: false }; + const data = await resp.json(); + return data; + } catch (e) { + console.error("Auth check failed:", e); + return { authenticated: false }; + } +} +async function authLogin(passphrase) { + try { + const resp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }) + }); + if (resp.status === 404) { + return { authenticated: true, role: "control", auth_disabled: true }; + } + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text || "Login failed"); + } + const data = await resp.json(); + return data; + } catch (e) { + throw e; + } +} +async function authLogout() { + try { + const resp = await fetch("/auth/logout", { method: "POST" }); + if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed"); + authRole = null; + disconnect(); + setDecodeHistoryOverlayVisible(false); + document.getElementById("content").style.display = "none"; + document.getElementById("loading").style.display = "none"; + document.getElementById("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); + document.getElementById("loading").style.display = "none"; + document.getElementById("content").style.display = "none"; + const authGate = document.getElementById("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 = document.getElementById("auth-gate"); + authGate.style.display = "none"; + document.getElementById("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(tabFromPath(), { updateHistory: false, replaceHistory: true }); + syncTopBarAccess(); +} +function showAuthError(msg) { + const el = document.getElementById("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 && btn.tagName === "BUTTON") { + btn.disabled = true; + } else if (btn && 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(); + if (typeof vchanApplyCapabilities === "function") vchanApplyCapabilities(caps); +} +const freqEl = document.getElementById("freq"); +const centerFreqEl = document.getElementById("center-freq"); +const wavelengthEl = document.getElementById("wavelength"); +const sigStrengthEl = document.getElementById("sig-strength"); +const modeEl = document.getElementById("mode"); +const bandLabel = document.getElementById("band-label"); +const powerBtn = document.getElementById("power-btn"); +const powerHint = document.getElementById("power-hint"); +const vfoPicker = document.getElementById("vfo-picker"); +const signalBar = document.getElementById("signal-bar"); +const signalValue = document.getElementById("signal-value"); +const pttBtn = document.getElementById("ptt-btn"); +const txLimitInput = document.getElementById("tx-limit"); +const txLimitBtn = document.getElementById("tx-limit-btn"); +const txLimitRow = document.getElementById("tx-limit-row"); +const lockBtn = document.getElementById("lock-btn"); +const txMeters = document.getElementById("tx-meters"); +const pwrBar = document.getElementById("pwr-bar"); +const pwrValue = document.getElementById("pwr-value"); +const swrBar = document.getElementById("swr-bar"); +const swrValue = document.getElementById("swr-value"); +const loadingEl = document.getElementById("loading"); +const contentEl = document.getElementById("content"); +const serverSubtitle = document.getElementById("server-subtitle"); +const rigSubtitle = document.getElementById("rig-subtitle"); +const ownerSubtitle = document.getElementById("owner-subtitle"); +const locationSubtitle = document.getElementById("location-subtitle"); +const loadingTitle = document.getElementById("loading-title"); +const loadingSub = document.getElementById("loading-sub"); +const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); +const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); +const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); +const connLostOverlayEl = document.getElementById("conn-lost-overlay"); +const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); +const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); +const overviewCanvas = document.getElementById("overview-canvas"); +const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); +const spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; +const overviewGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) : null; +const signalOverlayGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) : null; +const signalVisualBlockEl = document.querySelector(".signal-visual-block"); +const signalSplitControlEl = document.getElementById("signal-split-control"); +const signalSplitSliderEl = document.getElementById("signal-split-slider"); +const signalSplitValueEl = document.getElementById("signal-split-value"); +const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); +const themeToggleBtn = document.getElementById("theme-toggle"); +const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); +const headerRigSummary = document.getElementById("header-rig-summary"); +const headerStylePickSelect = document.getElementById("header-style-pick-select"); +const rdsPsOverlay = document.getElementById("rds-ps-overlay"); +const tabMainEl = document.getElementById("tab-main"); +let aboutServerVerEl = null; +let aboutServerBuildDateEl = null; +let aboutServerAddrEl = null; +let aboutServerCallEl = null; +let aboutServerLocationEl = null; +let aboutRigInfoEl = null; +let aboutRigAccessEl = null; +let aboutModesEl = null; +let aboutVfosEl = null; +let aboutActiveRigEl = null; +let aboutAudioCodecEl = null; +let aboutAudioSamplerateEl = null; +let aboutAudioChannelsEl = null; +let aboutAudioBitrateEl = null; +let aboutAudioFrameEl = null; +let aboutAudioRxEl = null; +let aboutAudioStreamsEl = null; +let aboutPskreporterEl = null; +let aboutAprsIsEl = null; +let aboutRigctlClientsEl = null; +let aboutRigctlEndpointEl = null; +let aboutClientsEl = null; +let _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"); +} +const cwAutoEl = document.getElementById("cw-auto"); +const cwWpmEl = document.getElementById("cw-wpm"); +const cwToneEl = document.getElementById("cw-tone"); +let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2e3)); +let decodeHistoryRetentionMin = 24 * 60; +const _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" : ""; +} +const _aboutDecIds = [ + "about-dec-ft8", + "about-dec-ft4", + "about-dec-ft2", + "about-dec-wspr", + "about-dec-cw", + "about-dec-aprs", + "about-dec-lrpt" +]; +let _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); +function _resolveAboutDecEls() { + if (_aboutDecEls[0].el) return; + for (let i = 0; i < _aboutDecIds.length; i++) { + _aboutDecEls[i].el = document.getElementById(_aboutDecIds[i]); + } +} +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"; +} +let primaryRds = null; +let vchanRdsById = /* @__PURE__ */ new Map(); +let vchanSignalDbById = /* @__PURE__ */ new Map(); +let rdsOverlayEntries = []; +function currentDecodeHistoryRetentionMs() { + const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || 24 * 60)); + return minutes * 60 * 1e3; +} +window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; +window.applyDecodeHistoryRetention = function() { + if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); + if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); + if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); + if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); + if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); + if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); +}; +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; + } +} +let 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); +} +const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; +let decodeHistoryReplayActive = false; +let decodeMapSyncPending = false; +const _pendingDecodeHistory = {}; +const _pendingDecodeLive = {}; +window._trxDrainPendingDecode = function(kind) { + const historyKey = { + ais: "restoreAisHistory", + vdes: "restoreVdesHistory", + aprs: "restoreAprsHistory", + hf_aprs: "restoreHfAprsHistory" + }[kind]; + if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { + const msgs = _pendingDecodeHistory[kind]; + delete _pendingDecodeHistory[kind]; + window[historyKey](msgs); + } + const liveKey = { + ais: "onServerAis", + vdes: "onServerVdes", + aprs: "onServerAprs", + hf_aprs: "onServerHfAprs" + }[kind]; + if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { + const msgs = _pendingDecodeLive[kind]; + delete _pendingDecodeLive[kind]; + for (const msg of msgs) { + try { + window[liveKey](msg); + } catch (_) { + } + } + } +}; +function markDecodeMapSyncPending() { + decodeMapSyncPending = true; +} +function flushDeferredDecodeMapSync() { + if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.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?.map?.aprsMap; +} +function decodeCborUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + if (additional === 24) { + if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 1; + return bytes[offset]; + } + if (additional === 25) { + if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 2; + return view.getUint16(offset); + } + if (additional === 26) { + if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 4; + return view.getUint32(offset); + } + if (additional === 27) { + if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getBigUint64(offset); + state.offset += 8; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; + } + throw new Error("Unsupported CBOR additional info"); +} +function decodeCborFloat16(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 * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 31) { + return fraction === 0 ? sign * Infinity : Number.NaN; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); +} +function decodeCborItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + const major = initial >> 5; + const additional = initial & 31; + if (major === 0) return decodeCborUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); + if (major === 2) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return Array.from(chunk); + } + if (major === 3) { + const length = decodeCborUint(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; + return decodeHistoryTextDecoder ? decodeHistoryTextDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeCborUint(view, bytes, state, additional); + const items = new Array(length); + for (let i = 0; i < length; i += 1) { + items[i] = decodeCborItem(view, bytes, state); + } + return items; + } + if (major === 5) { + const length = decodeCborUint(view, bytes, state, additional); + const value = {}; + for (let i = 0; i < length; i += 1) { + const key = decodeCborItem(view, bytes, state); + value[String(key)] = decodeCborItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeCborUint(view, bytes, state, additional); + return decodeCborItem(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; + if (additional === 25) { + if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + const bits = view.getUint16(state.offset); + state.offset += 2; + return decodeCborFloat16(bits); + } + if (additional === 26) { + if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat32(state.offset); + state.offset += 4; + return value; + } + if (additional === 27) { + if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat64(state.offset); + state.offset += 8; + return value; + } + } + throw new Error("Unsupported CBOR major type"); +} +function decodeCborPayload(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 = decodeCborItem(view, bytes, state); + if (state.offset !== bytes.length) { + throw new Error("Unexpected trailing bytes in CBOR payload"); + } + return value; +} +let lastSpectrumData = null; +window.lastSpectrumData = null; +let lastControl; +let lastTxEn = null; +let lastHasTx = true; +let lastRendered = null; +let prevRenderData = {}; +let hintTimer = null; +let sigMeasuring = false; +let sigLastSUnits = null; +let sigLastDbm = null; +const SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; +let sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); +function sigUnit(u) { + return `${u}`; +} +function formatSigStrength(dbm) { + if (!Number.isFinite(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(); + }); +} +let sigMeasureTimer = null; +let sigMeasureLastTickMs = 0; +let sigMeasureAccumMs = 0; +let sigMeasureWeighted = 0; +let sigMeasurePeak = null; +let lastFreqHz = null; +window.lastFreqHz = null; +let centerFreqDirty = false; +let jogUnit = loadSetting("jogUnit", 1e3); +let jogMult = loadSetting("jogMult", 1); +let jogStep = Math.max(Math.round(jogUnit / jogMult), 1); +let minFreqStepHz = 1; +let lastModeName = ""; +let lastWfmCci = 0; +let lastWfmAci = 0; +const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; +function vfoColor(idx) { + if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; + const hue = idx * 137 % 360; + return `hsl(${hue}, 70%, 55%)`; +} +let jogAngle = 0; +let lastClientCount = null; +let lastLocked = false; +let sdrSquelchSupported = false; +let 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 (Number.isFinite(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { + currentBandwidthHz = saved.bandwidthHz; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(currentBandwidthHz); + await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`); + } + if (Number.isFinite(saved.freqHz)) { + setRigFrequency(saved.freqHz); + } + if (Number.isFinite(saved.centerHz)) { + await postPath(`/set_center_freq?hz=${saved.centerHz}`); + } + showHint("Restored previous", 1500); +} +let lastRigIds = []; +let lastRigDisplayNames = {}; +let lastActiveRigId = null; +let rigSwitchInProgress = false; +let lastCityLabel = ""; +let sseSessionId = null; +const originalTitle = document.title; +const savedTheme = loadSetting("theme", null); +function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; +} +function updateDocumentTitle(rds = null) { + const freqHz = activeChannelFreqHz(); + if (!Number.isFinite(freqHz)) { + document.title = originalTitle; + return; + } + const parts = [formatFreq(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() { + if (typeof bmOverlayRevision === "undefined") return; + bmOverlayRevision++; + void getComputedStyle(document.documentElement).getPropertyValue("--bg"); + const colorMap = bmCategoryColorMap(); + const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; + 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 (_) { + } +} +const 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() { + return document.documentElement.getAttribute("data-style") || "original"; +} +function canvasPalette() { + const s = currentStyle(); + const t = currentTheme(); + return (CANVAS_PALETTE[s] ?? CANVAS_PALETTE.original)[t]; +} +function setStyle(style) { + const remapped = style === "nord" ? "arctic" : style === "monokai" ? "lime" : style === "blood" ? "fire" : style; + const valid = ["original", "arctic", "lime", "contrast", "neon-disco", "golden-rain", "amber", "fire", "phosphor"]; + const next = valid.includes(remapped) ? 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 (!Number.isFinite(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"); +} +const savedStyle = loadSetting("style", "original"); +setStyle(savedStyle); +if (themeToggleBtn) { + themeToggleBtn.addEventListener("click", () => { + setTheme(currentTheme() === "dark" ? "light" : "dark"); + updateMapBaseLayerForTheme(currentTheme()); + syncLocatorMarkerStyles(); + refreshAisMarkerColors(); + scheduleOverviewDraw(); + if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw(); + }); +} +if (headerStylePickSelect) { + headerStylePickSelect.addEventListener("change", () => { + setStyle(headerStylePickSelect.value); + updateMapBaseLayerForTheme(currentTheme()); + syncLocatorMarkerStyles(); + 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) { + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); + if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); + if (typeof bmFetch === "function") bmFetch(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 && 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" }); + } +} +let supportedModes = []; +let supportedBands = []; +let lastUnsupportedFreqPopupAt = 0; +let freqDirty = false; +let initialized = false; +let lastEventAt = Date.now(); +let aboutUptimeStart = null; +let es; +let esHeartbeat; +function formatUptime(ms) { + const s = Math.floor(ms / 1e3); + const d = Math.floor(s / 86400); + const h = Math.floor(s % 86400 / 3600); + const m = Math.floor(s % 3600 / 60); + const sec = s % 60; + const parts = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0 || d > 0) parts.push(`${h}h`); + parts.push(`${m}m`); + parts.push(`${sec}s`); + return parts.join(" "); +} +setInterval(() => { + if (!aboutUptimeStart) return; + const el = document.getElementById("about-uptime"); + if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart); +}, 1e3); +let reconnectTimer = null; +let overviewSignalSamples = []; +let overviewSignalTimer = null; +let overviewWaterfallRows = []; +let overviewWaterfallPushCount = 0; +const HEADER_SIG_WINDOW_MS = 1e4; +const OVERVIEW_WF_TEX_MAX_W = 512; +let overviewWfTexData = null; +let overviewWfTexWidth = 0; +let overviewWfTexHeight = 0; +let overviewWfTexPushCount = 0; +let overviewWfTexPalKey = ""; +let 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], + parsed[1], + parsed[2], + Math.max(0, Math.min(1, parsed[3] * alphaMul)) + ]; +} +function rgbaWithAlpha(color, alphaMul = 1) { + return cssColorToRgba(color, alphaMul); +} +const 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] +}; +const 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 = typeof bmOverlayList !== "undefined" ? bmOverlayList : 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 (!Number.isFinite(f) || f < range.visLoHz || f > range.visHiHz) continue; + if (Number.isFinite(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; + const x = hzToX(f); + if (!Number.isFinite(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)); + } + } + } + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels)) { + vchanChannels.forEach((ch) => { + if (!Number.isFinite(ch.freq_hz) || ch.freq_hz <= 0) return; + const xc = hzToX(ch.freq_hz); + if (xc < 0 || xc > W) return; + const isActive = ch.id === vchanActiveId; + const color = cssColorToRgba("#38bdf8"); + if (isActive) { + signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5)); + } else { + signalOverlayGl.drawDashedVerticalLine( + xc, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(3, Math.round(6 * dpr)), + color, + Math.max(1, dpr) + ); + } + }); + } + if (lastFreqHz != null) { + const xf = hzToX(lastFreqHz); + if (xf >= 0 && xf <= W) { + signalOverlayGl.drawDashedVerticalLine( + xf, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(2, Math.round(4 * dpr)), + cssColorToRgba("#ff1744"), + Math.max(1, dpr) + ); + } + } +} +function scheduleOverviewDraw() { + if (!overviewCanvas || overviewDrawPending) return; + overviewDrawPending = true; + requestAnimationFrame(() => { + overviewDrawPending = false; + drawHeaderSignalGraph(); + }); +} +function pushHeaderSignalSample(sUnits) { + if (!overviewCanvas) return; + const now = Date.now(); + const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; + overviewSignalSamples.push({ t: now, v: sample }); + while (overviewSignalSamples.length && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { + overviewSignalSamples.shift(); + } + scheduleOverviewDraw(); +} +function trimOverviewWaterfallRows() { + if (!overviewCanvas) return; + const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr)); + if (overviewWaterfallRows.length > maxRows) { + overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows); + } +} +function overviewVisibleBinWindow(data, binCount) { + if (!data || !Number.isFinite(data.sample_rate) || binCount <= 1) { + return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; + } + const range = spectrumVisibleRange(data); + const fullLoHz = data.center_hz - data.sample_rate / 2; + const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate; + const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate; + const maxIdx = binCount - 1; + const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx))); + const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx))); + return { startIdx, endIdx }; +} +function pushOverviewWaterfallFrame(data) { + if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; + overviewWaterfallRows.push(data.bins.slice()); + overviewWaterfallPushCount++; + trimOverviewWaterfallRows(); + scheduleOverviewDraw(); +} +function startHeaderSignalSampling() { + if (!overviewCanvas || overviewSignalTimer) return; + overviewSignalTimer = setInterval(() => { + pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0); + }, 120); +} +function drawHeaderSignalGraph() { + if (!ensureOverviewCanvasBackingStore()) return; + if (!overviewGl || !overviewGl.ready) return; + const pal = canvasPalette(); + const W = overviewCanvas.width; + const H = overviewCanvas.height; + if (W <= 0 || H <= 0) return; + overviewGl.clear(cssColorToRgba(pal.bg)); + if (lastSpectrumData && overviewWaterfallRows.length > 0) { + drawOverviewWaterfall(W, H, pal); + } else { + drawOverviewSignalHistory(W, H, pal); + } + positionRdsPsOverlay(); + drawSignalOverlay(); + updateBandplanStrip(bandplanComputeRange()); +} +function drawOverviewWaterfall(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; + const maxVisible = Math.max(1, Math.floor(H)); + const rows = overviewWaterfallRows.slice(-maxVisible); + if (rows.length === 0) return; + const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2))); + const iH = Math.max(1, rows.length); + const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, Number.isFinite(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); + } + overviewWfTexWidth = iW; + overviewWfTexHeight = iH; + ensureWaterfallLut(pal, minDb, maxDb); + function renderRow(dstY, srcBins) { + if (!isBinsArray(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)); + waterfallLutWrite(overviewWfTexData, rowBase + x * 4, srcBins[binIdx]); + } + } + if (needsFull) { + for (let y = 0; y < iH; y++) { + renderRow(y, rows[y]); + } + 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++) renderRow(y, rows[y]); + } else { + const shiftBytes = newCount * rowStride; + overviewWfTexData.copyWithin(0, shiftBytes); + const startRow = iH - newCount; + for (let y = startRow; y < iH; y++) { + renderRow(y, rows[y]); + } + } + overviewWfTexPushCount = overviewWaterfallPushCount; + overviewWfTexPalKey = palKey; + texUpdated = true; + } + if (texUpdated || !overviewWfTexReady) { + overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, overviewWfTexData, "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]) { + linePoints.push(x, y); + } + }); + overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); + const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs > 0) { + const holdPoints = []; + for (let i = 0; i < samples.length; i++) { + let peak = samples[i].v; + for (let j = i; j >= 0; j--) { + if (samples[i].t - samples[j].t > holdMs) break; + if (samples[j].v > peak) peak = samples[j].v; + } + const x = toX(samples[i].t); + const y = toY(peak); + if (i === 0 || x >= holdPoints[holdPoints.length - 2]) { + holdPoints.push(x, y); + } + } + overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1); + } +} +function waterfallColorRgba(db, pal, minDb, maxDb) { + const lo = Number.isFinite(minDb) ? minDb : Number.isFinite(spectrumFloor) ? spectrumFloor : -115; + const hi = Number.isFinite(maxDb) ? maxDb : lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90); + const safeDb = Number.isFinite(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})`); +} +let _wfLutKey = ""; +const _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]; + texData[offset + 1] = _wfLut[p + 1]; + texData[offset + 2] = _wfLut[p + 2]; + texData[offset + 3] = _wfLut[p + 3]; +} +function formatFreq(hz) { + if (!Number.isFinite(hz)) return "--"; + if (hz >= 1e9) { + return `${(hz / 1e9).toFixed(3)} GHz`; + } + if (hz >= 1e7) { + return `${(hz / 1e6).toFixed(3)} MHz`; + } + return `${(hz / 1e3).toFixed(1)} kHz`; +} +function formatFreqForStep(hz, step) { + if (!Number.isFinite(hz)) return "--"; + if (step >= 1e6) return (hz / 1e6).toFixed(6); + if (step >= 1e3) return (hz / 1e3).toFixed(3); + if (step >= 1) return String(Math.round(hz)); + return formatFreq(hz); +} +function formatWavelength(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + const meters = 299792458 / hz; + if (meters >= 1) return `${Math.round(meters)} m`; + return `${Math.round(meters * 100)} cm`; +} +function refreshWavelengthDisplay(hz) { + if (!wavelengthEl) return; + wavelengthEl.textContent = formatWavelength(hz); +} +function refreshFreqDisplay() { + if (lastFreqHz == null || freqDirty) return; + freqEl.value = formatFreqForStep(lastFreqHz, jogUnit); + refreshWavelengthDisplay(lastFreqHz); +} +function activeRdsChannelId() { + if (typeof vchanActiveId !== "undefined" && vchanActiveId) return vchanActiveId; + return null; +} +function activeChannelRds() { + if (!activeChannelIsWfm()) return null; + const activeId = activeRdsChannelId(); + if (activeId) { + const rds = vchanRdsById.get(activeId); + if (rds) return rds; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + if (vchanChannels[0].id === activeId) return primaryRds; + } + } + return primaryRds; +} +function activeChannelIsWfm() { + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + const activeId = activeRdsChannelId(); + const active = vchanChannels.find((ch) => ch.id === activeId) || vchanChannels[0]; + return String(active?.mode || "").toUpperCase() === "WFM"; + } + return lastModeName === "WFM"; +} +function activeChannelFreqHz() { + if (typeof vchanActiveChannel === "function") { + const ch = vchanActiveChannel(); + if (Number.isFinite(ch?.freq_hz)) return ch.freq_hz; + } + return lastFreqHz; +} +function activeBandwidthCenterHz() { + const freqHz = activeChannelFreqHz(); + return Number.isFinite(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) : escapeMapHtml(mainText)}${escapeMapHtml(metaText)}${trafficFlags}`; +} +function collectRdsOverlayEntries() { + const entries = []; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + for (const ch of vchanChannels) { + if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; + if (!Number.isFinite(ch?.freq_hz)) continue; + const rds = vchanRdsById.get(ch.id) || (vchanChannels[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 && Number.isFinite(lastFreqHz)) { + entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); + } + return entries; +} +function renderRdsOverlays() { + if (!rdsPsOverlay) return; + if (!lastSpectrumData || !overviewCanvas) { + rdsOverlayEntries = []; + rdsPsOverlay.style.display = "none"; + return; + } + const entries = collectRdsOverlayEntries(); + rdsOverlayEntries = []; + rdsPsOverlay.replaceChildren(); + if (entries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + entries.forEach((entry) => { + const html = buildRdsOverlayHtml(entry.rds); + if (!html) return; + const el = document.createElement("div"); + el.className = "rds-ps-overlay-item"; + el.dataset.freqHz = String(entry.freq_hz); + el.innerHTML = html; + el.addEventListener("click", (evt) => { + evt.stopPropagation(); + 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 (!Number.isFinite(range.visLoHz) || !Number.isFinite(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 (!Number.isFinite(entry.freq_hz)) { + el.style.display = "none"; + return; + } + el.style.display = ""; + const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; + const clamped = Math.max(0.06, Math.min(0.94, rel)); + el.style.left = `${clamped * width}px`; + el.style.top = "50%"; + const z = String(freqZMap.get(entry.id) ?? idx + 1); + el.style.zIndex = z; + el.dataset.defaultZ = z; + }); +} +function positionRdsPsOverlay() { + positionRdsOverlays(); +} +function resetRdsDisplay() { + updateRdsPsOverlay(primaryRds); +} +function resetDecoderStateOnRigSwitch() { + primaryRds = null; + vchanRdsById = /* @__PURE__ */ new Map(); + resetRdsDisplay(); + resetWfmStereoIndicator(); + resetIntfBars(); + lastSpectrumData = null; + window.lastSpectrumData = null; + lastSpectrumRenderData = null; + const decoderIds = ["ais-status", "vdes-status", "aprs-status", "cw-status", "ft8-status", "wspr-status"]; + decoderIds.forEach((id) => { + const el = document.getElementById(id); + if (el) el.textContent = "--"; + }); +} +function resetWfmStereoIndicator() { + if (!wfmStFlagEl) return; + wfmStFlagEl.textContent = "MO"; + wfmStFlagEl.classList.remove("wfm-st-flag-stereo"); + wfmStFlagEl.classList.add("wfm-st-flag-mono"); +} +function updateIntfBar(fillEl, valEl, level) { + if (!fillEl || !valEl) return; + const v = Math.round(Math.min(Math.max(level, 0), 100)); + valEl.textContent = String(v); + fillEl.style.width = v + "%"; + fillEl.classList.toggle("wfm-intf-warn", v >= 35 && v < 65); + fillEl.classList.toggle("wfm-intf-high", v >= 65); + if (v < 35) { + fillEl.classList.remove("wfm-intf-warn", "wfm-intf-high"); + } +} +function resetIntfBars() { + updateIntfBar(wfmCciFillEl, wfmCciValEl, 0); + updateIntfBar(wfmAciFillEl, wfmAciValEl, 0); +} +const _fastFreqMarker = document.getElementById("fast-freq-marker"); +const _fastBwLeft = document.getElementById("fast-bw-left"); +const _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 && Number.isFinite(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 && Number.isFinite(freqHz) && Number.isFinite(bwHz) && bwHz > 0) { + const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); + let loHz, hiHz; + if (side < 0) { + loHz = freqHz - bwHz; + hiHz = freqHz; + } else if (side > 0) { + loHz = freqHz; + hiHz = freqHz + bwHz; + } else { + loHz = freqHz - bwHz / 2; + hiHz = freqHz + bwHz / 2; + } + const lFrac = hzToFrac(loHz); + const rFrac = hzToFrac(hiHz); + const cFrac = hzToFrac(freqHz); + if (lFrac < cFrac && cFrac >= 0 && lFrac <= 1) { + const x = Math.max(0, lFrac) * cssW; + const w = (Math.min(1, cFrac) - Math.max(0, lFrac)) * cssW; + _fastBwLeft.style.display = ""; + _fastBwLeft.style.transform = `translateX(${x}px)`; + _fastBwLeft.style.width = `${w}px`; + } else { + _fastBwLeft.style.display = "none"; + } + if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) { + const x = Math.max(0, cFrac) * cssW; + const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW; + _fastBwRight.style.display = ""; + _fastBwRight.style.transform = `translateX(${x}px)`; + _fastBwRight.style.width = `${w}px`; + } else { + _fastBwRight.style.display = "none"; + } + } +} +function applyLocalTunedFrequency(hz, forceDisplay = false) { + if (!Number.isFinite(hz)) return; + const freqChanged = lastFreqHz !== hz; + if (!freqChanged && !forceDisplay) return; + if (freqChanged) { + if (lastFreqHz != null) savePreviousTuneState(); + primaryRds = null; + resetRdsDisplay(); + resetWfmStereoIndicator(); + resetIntfBars(); + } + lastFreqHz = hz; + window.lastFreqHz = lastFreqHz; + updateDocumentTitle(activeChannelRds()); + refreshWavelengthDisplay(lastFreqHz); + if (forceDisplay) { + freqDirty = false; + } + if (forceDisplay || !freqDirty) { + refreshFreqDisplay(); + } + window.ft8BaseHz = lastFreqHz; + if (window.updateFt8RfDisplay) { + window.updateFt8RfDisplay(); + } + if (window.refreshCwTonePicker) { + window.refreshCwTonePicker(); + } + positionFastOverlay(lastFreqHz, currentBandwidthHz); + if (freqChanged && lastSpectrumData) { + scheduleSpectrumDraw(); + } + if (freqChanged && !lastSpectrumData) { + updateBandplanStrip(bandplanComputeRange()); + } + positionRdsPsOverlay(); +} +function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { + const [, , maxBw] = mwDefaultsForMode(mode); + return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz); +} +function isAisMode(mode = modeEl ? modeEl.value : "") { + return String(mode || "").toUpperCase() === "AIS"; +} +function isVdesMode(mode = modeEl ? modeEl.value : "") { + return String(mode || "").toUpperCase() === "VDES"; +} +function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { + if (!Number.isFinite(freqHz)) return []; + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "AIS") { + return [ + { centerHz: freqHz, widthHz: currentBandwidthHz }, + { centerHz: freqHz + 5e4, widthHz: currentBandwidthHz } + ]; + } + return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; +} +function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "LSB" || modeUpper === "CWR") return -1; + if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; + return 0; +} +function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { + const centerHz = Number(spec?.centerHz); + const widthHz = Math.max(0, Number.isFinite(spec?.widthHz) ? Number(spec.widthHz) : 0); + const side = sidebandDirectionForMode(mode); + if (side < 0) { + return { loHz: centerHz - widthHz, hiHz: centerHz, side }; + } + if (side > 0) { + return { loHz: centerHz, hiHz: centerHz + widthHz, side }; + } + const halfBw = widthHz / 2; + return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; +} +function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { + if (!Number.isFinite(freqHz)) return null; + const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { + const widthHz = Math.max( + 0, + Number.isFinite(spec.widthHz) ? spec.widthHz : Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0) + ); + return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); + }); + if (specs.length === 0) return null; + let loHz = specs[0].loHz; + let hiHz = specs[0].hiHz; + for (const spec of specs.slice(1)) { + loHz = Math.min(loHz, spec.loHz); + hiHz = Math.max(hiHz, spec.hiHz); + } + return { loHz, hiHz }; +} +function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { + return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz); +} +function effectiveSpectrumCoverageSpanHz(sampleRateHz) { + const sampleRate = Number(sampleRateHz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0; + const ratio = Number.isFinite(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; + return sampleRate * Math.max(0.01, Math.min(1, ratio)); +} +function sweetSpotMinimumOffsetHz(bandwidthHz) { + if (!Number.isFinite(bandwidthHz) || bandwidthHz <= 0) return 0; + return bandwidthHz / 2; +} +function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { + if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz)) return false; + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) return true; + return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; +} +function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { + if (!Number.isFinite(freqHz) || !Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + return null; + } + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) { + const fallbackCenterHz = Number.isFinite(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 (Number.isFinite(preferredCenterHz)) { + let bestCenterHz = targetCentersHz[0]; + 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 || !Number.isFinite(freqHz)) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); + const currentCenterHz = Number(data.center_hz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(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 (!Number.isFinite(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 = formatFreqForStep(nextCenterHz, jogUnit); + } +} +let _freqOptimisticHz = null; +let _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 || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(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 = Number.isFinite(db) ? db : -160; + const clamped = Math.max(-160, Math.min(40, value)); + return 10 ** (clamped / 10); +} +function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { + return null; + } + if (!Number.isFinite(freqHz) || !Number.isFinite(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 (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(usableSpanHz) || usableSpanHz <= 0 || !Number.isFinite(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 (!Number.isFinite(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 (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + evalMinCenterHz, + evalMaxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) + ); + if (!Number.isFinite(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; + } + const maxIdx = bins.length - 1; + const usableBins = Math.max(4, Math.min(maxIdx, Math.round(usableSpanHz / sampleRate * maxIdx))); + const fullLoHz = currentCenterHz - fullHalfSpanHz; + const startMinIdx = Math.max( + 0, + Math.min(maxIdx - usableBins, Math.round((minCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + ); + const startMaxIdx = Math.max( + startMinIdx, + Math.min(maxIdx - usableBins, Math.round((maxCenterHz - halfUsableSpanHz - fullLoHz) / sampleRate * maxIdx)) + ); + let bestStartIdx = null; + let bestScore = Number.POSITIVE_INFINITY; + const signalLoHz = span.loHz; + const signalHiHz = span.hiHz; + for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) { + const endIdx = Math.min(maxIdx, startIdx + usableBins); + const windowLoHz = fullLoHz + startIdx / maxIdx * sampleRate; + const candidateCenterHz = windowLoHz + halfUsableSpanHz; + if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { + continue; + } + const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz))); + const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz))); + let score = 0; + for (let i = startIdx; i <= endIdx; i++) { + if (i >= signalLoIdx && i <= signalHiIdx) continue; + score += spectrumPowerScore(bins[i]); + } + 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 (!Number.isFinite(bestScore) || bestStartIdx == null) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + minCenterHz, + maxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz) + ); + if (!Number.isFinite(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) { + const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); + return candidate && Number.isFinite(candidate.centerHz) ? candidate.centerHz : null; +} +function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { + if (!data || !Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return []; + } + const sampleRate = Number(data.sample_rate); + const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); + if (!Number.isFinite(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 (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + return []; + } + const points = 5; + const centers = []; + for (let i = 0; i < points; i++) { + const frac = points === 1 ? 0.5 : 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 (Number.isFinite(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 (!Number.isFinite(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 || !Number.isFinite(bestCandidate.centerHz))) { + showHint("Sweet-spot unavailable", 1100); + return; + } + sweetSpotScanInFlight = true; + try { + showHint("Scanning sweet spot...", 1400); + for (const probeCenterHz of probeCentersHz) { + if (!Number.isFinite(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 || !Number.isFinite(candidate.centerHz)) continue; + if (!bestCandidate || candidate.score < bestCandidate.score) { + bestCandidate = candidate; + } + } + const targetCenterHz = bestCandidate && Number.isFinite(bestCandidate.centerHz) ? bestCandidate.centerHz : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); + if (!Number.isFinite(targetCenterHz)) { + if (Number.isFinite(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 = formatFreqForStep(targetCenterHz, jogUnit); + } + if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { + showHint("Already at sweet spot", 900); + } else { + showHint("Sweet-spot set", 1200); + } + } finally { + sweetSpotScanInFlight = false; + } +} +function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { + if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + if (!Number.isFinite(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)); +} +let spectrumCenterPendingHz = null; +async function shiftSpectrumCenter(direction) { + if (!lastSpectrumData || !Number.isFinite(direction) || direction === 0) return; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(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 = formatFreqForStep(nextCenterHz, jogUnit); + } + const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); + if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { + await postPath(`/set_freq?hz=${nextFreqHz}`); + applyLocalTunedFrequency(nextFreqHz); + } +} +function refreshCenterFreqDisplay() { + if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; + centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit); +} +function parseFreqInput(val, defaultStep) { + if (!val) return null; + const trimmed = val.trim().toLowerCase(); + const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); + if (!match) return null; + const rawNumber = match[1]; + let num = parseFloat(rawNumber.replace(",", ".")); + const unit = match[2] || ""; + if (Number.isNaN(num)) return null; + if (unit.startsWith("gh") || unit === "g") { + num *= 1e9; + } else if (unit.startsWith("mh") || unit === "m") { + num *= 1e6; + } else if (unit.startsWith("kh") || unit === "k") { + num *= 1e3; + } else if (!unit) { + const mode = (modeEl?.value || "").toUpperCase(); + const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); + if (mode === "WFM") { + if (hasDecimalSeparator && num >= 50 && num < 200) { + num *= 1e6; + return Math.round(num); + } + if (!hasDecimalSeparator && num >= 875 && num <= 1080) { + num = num / 10 * 1e6; + return Math.round(num); + } + } + if (defaultStep >= 1e6) { + num *= 1e6; + } else if (defaultStep >= 1e3) { + num *= 1e3; + } else if (defaultStep >= 1) { + } else { + if (num >= 1e6) { + } else if (num >= 1e3) { + num *= 1e3; + } else { + num *= 1e6; + } + } + } + return Math.round(num); +} +function normalizeMinFreqStep(cap) { + const val = Number(cap && cap.min_freq_step_hz); + if (!Number.isFinite(val) || val < 1) return 1; + return Math.round(val); +} +function alignFreqToRigStep(hz) { + if (!Number.isFinite(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 (Number.isFinite(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) => Number.isFinite(s) && s > 0); + if (steps.length === 0) return; + const current = Number(jogUnit); + const desired = Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz); + jogUnit = steps.reduce((best, s) => Math.abs(s - desired) < Math.abs(best - desired) ? s : best, steps[0]); + 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 [variant, value] = entries[0]; + if (variant === "Other" && typeof value === "string") return value; + return variant; + } + } + return ""; +} +function updateSupportedBands(cap) { + if (cap && Array.isArray(cap.supported_bands)) { + supportedBands = cap.supported_bands.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number").map((b) => ({ low: b.low_hz, high: b.high_hz })); + } else { + supportedBands = []; + } +} +function freqAllowed(hz) { + if (!Number.isFinite(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) => `${formatFreqForHumans(b.low)} to ${formatFreqForHumans(b.high)}`); + return `Supported ranges: ${ranges.join(", ")}`; +} +function formatFreqForHumans(hz) { + if (!Number.isFinite(hz)) return "--"; + if (hz >= 1e9) return `${(hz / 1e9).toFixed(3)} GHz`; + if (hz >= 1e6) return `${(hz / 1e6).toFixed(3)} MHz`; + if (hz >= 1e3) return `${(hz / 1e3).toFixed(3)} kHz`; + return `${Math.round(hz)} Hz`; +} +function showUnsupportedFreqPopup(hz) { + const message = `Unsupported frequency: ${formatFreqForHumans(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 (!Number.isFinite(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 (!Number.isFinite(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; + }); +} +let serverVersion = null; +let serverBuildDate = null; +let serverCallsign = null; +let ownerCallsign = null; +let ownerWebsiteUrl = null; +let ownerWebsiteName = null; +let aisVesselUrlBase = null; +let serverRigs = []; +let serverActiveRigId = null; +let serverLat = null; +let serverLon = null; +let initialMapZoom = 10; +let spectrumCoverageMarginHz = 5e4; +let spectrumUsableSpanRatio = 0.92; +const DEFAULT_OVERVIEW_PLOT_HEIGHT_PX = 160; +const DEFAULT_SPECTRUM_PLOT_HEIGHT_PX = 160; +const MIN_OVERVIEW_PLOT_HEIGHT_PX = 90; +const MIN_SPECTRUM_PLOT_HEIGHT_PX = 130; +const DEFAULT_SIGNAL_SPLIT_PERCENT = 50; +const MIN_SIGNAL_SPLIT_PERCENT = 20; +const MAX_SIGNAL_SPLIT_PERCENT = 80; +let spectrumLayoutPending = false; +let spectrumManualTotalPlotHeightPx = null; +let spectrumResizeState = null; +let 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 = Number.isFinite(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(tabMainEl2, contentEl2, overviewCanvasEl, spectrumCanvasEl) { + const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); + const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); + const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight; + const tabBottom = tabMainEl2.getBoundingClientRect().bottom; + const contentBottom = contentEl2.getBoundingClientRect().bottom; + const slackPx = Math.floor(tabBottom - contentBottom); + const minTotalHeight = MIN_OVERVIEW_PLOT_HEIGHT_PX + MIN_SPECTRUM_PLOT_HEIGHT_PX; + const maxAutoTotalHeight = Math.max( + minTotalHeight, + currentTotalHeight + slackPx - 2 + ); + return { + minTotal: minTotalHeight, + autoMaxTotal: maxAutoTotalHeight + }; +} +function updateSpectrumAutoHeight() { + const root = document.documentElement; + const overviewCanvasEl = document.getElementById("overview-canvas"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + if (!root || !tabMainEl || !contentEl || !overviewCanvasEl || !spectrumPanelEl || !spectrumCanvasEl) return; + const mainVisible = getComputedStyle(tabMainEl).display !== "none"; + const contentVisible = getComputedStyle(contentEl).display !== "none"; + const spectrumVisible = getComputedStyle(spectrumPanelEl).display !== "none"; + const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); + const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); + if (!mainVisible || !contentVisible || !spectrumVisible) { + setSignalSplitControlVisible(false); + const dimensionsChanged = currentOverviewHeight !== DEFAULT_OVERVIEW_PLOT_HEIGHT_PX || currentSpectrumHeight !== DEFAULT_SPECTRUM_PLOT_HEIGHT_PX; + root.style.setProperty("--overview-plot-height", `${DEFAULT_OVERVIEW_PLOT_HEIGHT_PX}px`); + root.style.setProperty("--spectrum-plot-height", `${DEFAULT_SPECTRUM_PLOT_HEIGHT_PX}px`); + if (dimensionsChanged) { + resizeHeaderSignalCanvas(); + scheduleOverviewDraw(); + if (lastSpectrumData) scheduleSpectrumDraw(); + } + return; + } + setSignalSplitControlVisible(true); + const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); + const nextTotalHeight = spectrumManualTotalPlotHeightPx == null ? bounds.autoMaxTotal : Math.max(bounds.minTotal, Math.round(spectrumManualTotalPlotHeightPx)); + if (spectrumManualTotalPlotHeightPx != null) { + spectrumManualTotalPlotHeightPx = nextTotalHeight; + } + const requestedOverviewHeight = Math.round(nextTotalHeight * signalSplitPercent / 100); + const nextOverviewHeight = Math.max( + MIN_OVERVIEW_PLOT_HEIGHT_PX, + Math.min(nextTotalHeight - MIN_SPECTRUM_PLOT_HEIGHT_PX, requestedOverviewHeight) + ); + const nextSpectrumHeight = Math.max( + MIN_SPECTRUM_PLOT_HEIGHT_PX, + nextTotalHeight - nextOverviewHeight + ); + if (Math.abs(nextOverviewHeight - currentOverviewHeight) < 2 && Math.abs(nextSpectrumHeight - currentSpectrumHeight) < 2) return; + root.style.setProperty("--overview-plot-height", `${nextOverviewHeight}px`); + root.style.setProperty("--spectrum-plot-height", `${nextSpectrumHeight}px`); + if (typeof _updateCachedCanvasSizes === "function") _updateCachedCanvasSizes(); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + scheduleSpectrumWaterfallDraw(); + } +} +function beginSpectrumResize(clientY) { + const overviewCanvasEl = document.getElementById("overview-canvas"); + const spectrumCanvasEl = document.getElementById("spectrum-canvas"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + if (!tabMainEl || !contentEl || !overviewCanvasEl || !spectrumCanvasEl || !spectrumPanelEl) return false; + if (getComputedStyle(spectrumPanelEl).display === "none") return false; + const bounds = spectrumHeightBoundsPx(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl); + const startTotalHeight = Math.max( + bounds.minTotal, + currentOverviewHeightPx(overviewCanvasEl) + currentSpectrumHeightPx(spectrumCanvasEl) + ); + spectrumResizeState = { + startY: clientY, + startTotalHeight, + minTotalHeight: bounds.minTotal + }; + document.body.classList.add("spectrum-resizing"); + return true; +} +function updateSpectrumResize(clientY) { + if (!spectrumResizeState) return; + const deltaY = clientY - spectrumResizeState.startY; + spectrumManualTotalPlotHeightPx = Math.max( + spectrumResizeState.minTotalHeight, + Math.round(spectrumResizeState.startTotalHeight + deltaY) + ); + updateSpectrumAutoHeight(); +} +function endSpectrumResize() { + spectrumResizeState = null; + document.body.classList.remove("spectrum-resizing"); +} +const 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 = `${escapeMapHtml(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 || !Number.isFinite(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 && Number.isFinite(serverLat) && Number.isFinite(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" && Number.isFinite(update.initial_map_zoom)) { + initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); + } + if (typeof update.spectrum_coverage_margin_hz === "number" && Number.isFinite(update.spectrum_coverage_margin_hz)) { + spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); + } + if (typeof update.spectrum_usable_span_ratio === "number" && Number.isFinite(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" && Number.isFinite(update.decode_history_retention_min) && update.decode_history_retention_min > 0) { + const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); + if (nextRetentionMin !== decodeHistoryRetentionMin) { + decodeHistoryRetentionMin = nextRetentionMin; + if (typeof window.applyDecodeHistoryRetention === "function") { + window.applyDecodeHistoryRetention(); + } + } + } + scheduleSpectrumLayout(); + updateTitle(); + initialized = !!update.initialized; + const hasUsableSnapshot = !!update.info && !!update.status && !!update.status.freq && typeof update.status.freq.hz === "number"; + if (!initialized) { + const fallbackRigName = originalTitle || "Rig"; + const manu = update.info && update.info.manufacturer || fallbackRigName; + const model = update.info && update.info.model || fallbackRigName; + const rev = update.info && update.info.revision || ""; + const parts = [manu, model, rev].filter(Boolean).join(" "); + if (!hasUsableSnapshot) { + loadingTitle.textContent = `Initializing ${parts}…`; + loadingSub.textContent = ""; + console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); + loadingEl.style.display = ""; + if (contentEl) contentEl.style.display = "none"; + powerHint.textContent = "Initializing rig…"; + setDisabled(true); + return; + } + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + powerHint.textContent = "Rig not fully initialized yet"; + } else { + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + } + if (serverSubtitle && update.server_callsign) { + const base = serverSubtitle.textContent.split(" hosted by")[0]; + const safeCallsign = escapeMapHtml(update.server_callsign); + const encodedCallsign = encodeURIComponent(update.server_callsign); + serverSubtitle.innerHTML = `${escapeMapHtml(base)} hosted by ${safeCallsign}`; + } + updateRigSubtitle(lastActiveRigId); + if (ownerSubtitle) { + if (ownerCallsign) { + const safeOwner = escapeMapHtml(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, + hasSdrSquelchThreshold ? 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; + } + if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) { + 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(STORAGE_PREFIX + "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 = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + 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 = update.cw_wpm; + } + if (cwToneEl && typeof update.cw_tone_hz === "number") { + cwToneEl.value = 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)}: ${formatFreq(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 = 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 (Number.isFinite(serverLat) && Number.isFinite(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 (access.Serial) { + const serialPath = access.Serial.path || access.Serial.port || "?"; + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; + } else if (access.Tcp) { + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; + } 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 = 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 = 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 = 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 = update.clients; + } + } + if (Array.isArray(update.remotes)) { + applyRigList(update.active_remote, update.remotes); + } + powerHint.textContent = readyText(); + lastLocked = update.status && 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); + lastEventAt = Date.now(); + es.onopen = () => { + setConnLostOverlay(false); + if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); + if (!aboutUptimeStart) aboutUptimeStart = Date.now(); + pollFreshSnapshot(); + refreshRigList(); + }; + es.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); + } + }; + es.addEventListener("ping", () => { + lastEventAt = Date.now(); + }); + es.addEventListener("session", (evt) => { + try { + const d = JSON.parse(evt.data); + sseSessionId = d.session_id || null; + } catch (_) { + } + if (typeof vchanHandleSession === "function") vchanHandleSession(evt.data); + }); + es.addEventListener("channels", (evt) => { + if (typeof vchanHandleChannels === "function") vchanHandleChannels(evt.data); + }); + es.onerror = () => { + if (es.readyState === EventSource.CLOSED) { + powerHint.textContent = "trx-client connection lost, retrying…"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying…", true); + es.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); + es.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); +} +function yieldToMain() { + if (typeof scheduler !== "undefined" && typeof scheduler.yield === "function") { + return scheduler.yield(); + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} +const uiFrameJobs = /* @__PURE__ */ new Map(); +let 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; + if (typeof window.vchanTakeSchedulerControl === "function") { + await window.vchanTakeSchedulerControl(); + } +} +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); + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); + if (typeof bmFetch === "function") bmFetch(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 = parseFreqInput(freqEl.value, jogUnit); + const parsed = alignFreqToRigStep(parsedRaw); + if (parsed === null) { + showHint("Freq missing", 1500); + return; + } + if (!freqAllowed(parsed)) { + showUnsupportedFreqPopup(parsed); + return; + } + freqDirty = false; + setRigFrequency(parsed); +} +async function applyCenterFreqFromInput() { + if (!centerFreqEl) return; + const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit); + const parsed = alignFreqToRigStep(parsedRaw); + if (parsed === null) { + showHint("Central freq missing", 1500); + return; + } + 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(); + applyCenterFreqFromInput(); + } else if (e.key === "Escape") { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + centerFreqEl.blur(); + } + }); + centerFreqEl.addEventListener("blur", () => { + if (centerFreqDirty) { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + } + }); + centerFreqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); + }, { passive: false }); +} +freqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); +}, { passive: false }); +const jogWheel = document.getElementById("jog-wheel"); +const jogIndicator = document.getElementById("jog-indicator"); +const jogDownBtn = document.getElementById("jog-down"); +const jogUpBtn = document.getElementById("jog-up"); +const jogStepEl = document.getElementById("jog-step"); +const jogMultEl = document.getElementById("jog-mult"); +const 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 }); +let jogTouchY = null; +jogWheel.addEventListener("touchstart", (e) => { + e.preventDefault(); + jogTouchY = e.touches[0].clientY; +}, { passive: false }); +jogWheel.addEventListener("touchmove", (e) => { + e.preventDefault(); + if (jogTouchY === null) return; + const dy = jogTouchY - e.touches[0].clientY; + if (Math.abs(dy) > 12) { + jogFreq(dy > 0 ? 1 : -1); + jogTouchY = e.touches[0].clientY; + } +}, { passive: false }); +jogWheel.addEventListener("touchend", () => { + jogTouchY = null; +}); +let 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.closest("button[data-step]"); + 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.closest("button[data-mult]"); + if (!btn) return; + setJogDivisor(parseInt(btn.dataset.mult, 10)); + }); +} +{ + const unitBtns = Array.from(jogStepEl.querySelectorAll("button[data-step]")); + const activeUnit = unitBtns.find((b) => parseInt(b.dataset.step, 10) === jogUnit) || unitBtns.find((b) => parseInt(b.dataset.step, 10) === 1e3) || unitBtns[0]; + if (activeUnit) { + jogUnit = parseInt(activeUnit.dataset.step, 10); + unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); + } + if (jogMultEl) { + const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); + const activeMult = multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) || multBtns[0]; + if (activeMult) { + jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult, 10)) ? parseInt(activeMult.dataset.mult, 10) : 1; + multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); + } else { + jogMult = 1; + } + } + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); +} +async function applyModeFromPicker() { + const mode = modeEl.value || ""; + if (!mode) { + showHint("Mode missing", 1500); + return; + } + updateWfmControls(); + setControlPending(modeEl, true); + showHint("Setting mode…"); + try { + if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { + showHint("Channel mode set", 1500); + return; + } + await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); + showHint("Mode set", 1500); + if (mode.toUpperCase() === "WFM") { + setJogDivisor(10); + } + await applyBwDefaultForMode(mode, true); + } catch (err) { + showHint("Set mode failed", 2e3); + console.error(err); + } finally { + setControlPending(modeEl, false); + } +} +modeEl.addEventListener("change", applyModeFromPicker); +txLimitInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + txLimitBtn.click(); + } +}); +txLimitBtn.addEventListener("click", async () => { + const limit = txLimitInput.value; + if (limit === "" || limit === "--") { + showHint("Limit missing", 1500); + return; + } + setControlPending(txLimitBtn, true); + showHint("Setting TX limit…"); + try { + await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`); + showHint("TX limit set", 1500); + } catch (err) { + showHint("TX limit failed", 2e3); + console.error(err); + } finally { + setControlPending(txLimitBtn, false); + } +}); +lockBtn.addEventListener("click", async () => { + setControlPending(lockBtn, true); + showHint("Toggling lock…"); + try { + const nextLock = !lastLocked; + await postPath(nextLock ? "/lock" : "/unlock"); + showHint("Lock toggled", 1500); + } catch (err) { + showHint("Lock toggle failed", 2e3); + console.error(err); + } finally { + setControlPending(lockBtn, false); + } +}); +const 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] +}; +const 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"; +} +let currentBandwidthHz = 3e3; +window.currentBandwidthHz = currentBandwidthHz; +const spectrumBwInput = document.getElementById("spectrum-bw-input"); +const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn"); +const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn"); +const spectrumBwSweetBtn = document.getElementById("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 || !Number.isFinite(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 (!Number.isFinite(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 (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(clamped)) return; + await postPath(`/set_bandwidth?hz=${clamped}`); + if (Number.isFinite(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz); + } + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); + } +} +function estimateOccupiedBandwidth(data, centerHz, interference = {}) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { + return null; + } + const bins = data.bins; + 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 mode = (modeEl ? modeEl.value : "USB").toUpperCase(); + const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); + const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" ? 1 : mode === "LSB" || mode === "CWR" ? -1 : 0; + const isWfm = mode === "WFM"; + const smoothRadius = isWfm ? 3 : 1; + const smoothed = bins.map((_, i) => { + let sum = 0; + let count = 0; + for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) { + sum += bins[j]; + count += 1; + } + return sum / count; + }); + const sorted = [...bins].sort((a, b) => a - b); + const noise = sorted[Math.floor(sorted.length * 0.2)]; + 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 i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]); + 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)); + function occupiedExtent(direction, limitBins) { + let lastOccupied = centerIdx; + let gap = 0; + for (let n = 0; n <= limitBins; n++) { + const i = centerIdx + direction * n; + if (i <= 0 || i >= maxIdx) break; + if (smoothed[i] >= threshold) { + lastOccupied = i; + gap = 0; + } else if (++gap > allowedGap) { + break; + } + } + return Math.abs(lastOccupied - centerIdx) * hzPerBin; + } + let rawBw; + if (oneSided !== 0) { + rawBw = occupiedExtent(oneSided, maxSpanBins); + } else { + const leftHz = occupiedExtent(-1, searchHalfBins); + const rightHz = occupiedExtent(1, searchHalfBins); + rawBw = 2 * Math.max(leftHz, rightHz); + } + rawBw *= isWfm ? 1.08 : 1.12; + if (isWfm) { + const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; + const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; + 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); +} +async function applyAutoBandwidth() { + if (!lastSpectrumData || lastFreqHz == null) return; + const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; + const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); + if (!Number.isFinite(estimated) || estimated <= 0) { + syncBandwidthInput(currentBandwidthHz); + return; + } + currentBandwidthHz = estimated; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(estimated); + positionFastOverlay(lastFreqHz, estimated); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + } + const mode = (modeEl?.value || "").toUpperCase(); + 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 (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; + await postPath(`/set_bandwidth?hz=${estimated}`); + if (Number.isFinite(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(() => { + }); + }); +} +let _activeTab = "main"; +const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"]; +const TAB_PATHS = { + main: "/", + bookmarks: "/bookmarks", + "digital-modes": "/digital-modes", + map: "/map", + recorder: "/recorder", + settings: "/settings", + about: "/about" +}; +function normalizeTabPath(pathname) { + const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/"; + if (raw === "/") return "/"; + return raw.replace(/\/+$/, "") || "/"; +} +function tabFromPath(pathname = window.location.pathname) { + const normalized = normalizeTabPath(pathname); + for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) { + if (normalized === tabPath) return tabName; + } + return "main"; +} +function updateTabHistory(name, replaceHistory = false) { + const targetPath = TAB_PATHS[name] || "/"; + if (normalizeTabPath(window.location.pathname) === targetPath) return; + const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; + const method = replaceHistory ? "replaceState" : "pushState"; + window.history[method]({}, "", nextUrl); +} +let _mapInitTimer = null; +function _initMapWhenReady() { + const loadingEl2 = document.getElementById("map-loading"); + if (window.trx.modules.map && typeof L !== "undefined") { + if (_mapInitTimer) { + clearInterval(_mapInitTimer); + _mapInitTimer = null; + } + if (loadingEl2) loadingEl2.classList.add("is-hidden"); + window.trx.modules.map.initAprsMap(); + window.trx.modules.map.sizeAprsMapToViewport(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.trx.modules.map.sizeAprsMapToViewport(); + if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize(); + }); + }); + return; + } + if (loadingEl2) loadingEl2.classList.remove("is-hidden"); + if (!_mapInitTimer) { + _mapInitTimer = setInterval(() => { + if (_activeTab !== "map") { + 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}`); + 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) hideUnsupportedDecoderTabs(); + } + 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; +document.querySelector(".tab-bar").addEventListener("click", (e) => { + const btn = e.target.closest(".tab[data-tab]"); + if (!btn) return; + navigateToTab(btn.dataset.tab); +}); +window.addEventListener("popstate", () => { + navigateToTab(tabFromPath(), { 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 (isExcluded(e.target)) return; + tx = e.touches[0].clientX; + ty = e.touches[0].clientY; + }, { passive: true }); + document.addEventListener("touchend", (e) => { + if (e.changedTouches.length !== 1 || tx === 0) return; + const dx = e.changedTouches[0].clientX - tx; + const dy = e.changedTouches[0].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; + if (next >= 0 && next < TAB_ORDER.length) navigateToTab(TAB_ORDER[next]); + }, { passive: true }); +})(); +window.addEventListener("resize", () => { + scheduleSpectrumLayout(); +}); +function getAvailableRigIds() { + return lastRigIds || []; +} +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; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + } else { + const allowGuest = authStatus.role === "rx"; + showAuthGate(allowGuest); + } +} +function initSettingsUI() { + if (typeof initScheduler === "function") { + initScheduler(lastActiveRigId, authRole); + wireSchedulerEvents(); + } + if (typeof initBackgroundDecode === "function") { + initBackgroundDecode(lastActiveRigId, authRole); + wireBackgroundDecodeEvents(); + } +} +document.getElementById("auth-form").addEventListener("submit", async (e) => { + e.preventDefault(); + const passphrase = document.getElementById("auth-passphrase").value; + const btn = document.querySelector("#auth-form button[type=submit]"); + btn.disabled = true; + btn.textContent = "Logging in..."; + try { + const result = await authLogin(passphrase); + authRole = result.role; + document.getElementById("auth-passphrase").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"; + } +}); +const guestBtn = document.getElementById("auth-guest-btn"); +if (guestBtn) { + guestBtn.addEventListener("click", async () => { + authRole = "rx"; + document.getElementById("auth-passphrase").value = ""; + hideAuthGate(); + updateAuthUI(); + applyAuthRestrictions(); + connect(); + connectDecode(); + initSettingsUI(); + resizeHeaderSignalCanvas(); + startHeaderSignalSampling(); + }); +} +const 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); + } + }); +} +const trxState = /* @__PURE__ */ Object.create(null); +const 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; + } } +}); +const trxCore = Object.freeze({ + saveSetting, + loadSetting, + showHint, + escapeMapHtml, + formatFreq, + formatFreqForHumans, + formatWavelength, + formatBwLabel, + formatUptime, + formatSigStrength, + formatSignal, + postPath, + scheduleUiFrameJob, + navigateToTab, + rigBadgeColor, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo, + bookmarkDistanceText, + buildBookmarkTooltipText, + nearestBookmarkForHz, + currentDecodeHistoryRetentionMs, + currentTheme, + canvasPalette, + currentStyle, + cssColorToRgba, + rgbaWithAlpha, + isBinsArray, + 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 haversineKm(lat1, lon1, lat2, lon2) { + const R = 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 R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} +function locatorToLatLon(locator) { + const raw = String(locator || "").trim().toUpperCase(); + if (!/^[A-R]{2}\d{2}([A-X]{2})?$/.test(raw)) return null; + let lon = -180; + let lat = -90; + lon += (raw.charCodeAt(0) - 65) * 20; + lat += (raw.charCodeAt(1) - 65) * 10; + lon += Number(raw.slice(2, 3)) * 2; + lat += Number(raw.slice(3, 4)); + if (raw.length >= 6) { + lon += (raw.charCodeAt(4) - 65) * (5 / 60); + lat += (raw.charCodeAt(5) - 65) * (2.5 / 60); + lon += 2.5 / 60; + lat += 1.25 / 60; + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; +} +function formatDistanceKm(distKm) { + if (!Number.isFinite(distKm)) return null; + return distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km`; +} +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 (typeof bmFmtFreq === "function") parts.push(bmFmtFreq(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 = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; + if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(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 formatTimeAgo(tsMs) { + if (!tsMs) return null; + const secs = Math.round((Date.now() - tsMs) / 1e3); + if (secs < 60) return `${secs}s ago`; + const mins = Math.round(secs / 60); + if (mins < 60) return `${mins} min ago`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return remMins > 0 ? `${hrs}h ${remMins}min ago` : `${hrs}h ago`; +} +function latLonToMaidenhead(lat, lon) { + const adjustedLon = lon + 180; + const adjustedLat = lat + 90; + const A = "A".charCodeAt(0); + const a = "a".charCodeAt(0); + const field1 = String.fromCharCode(A + Math.floor(adjustedLon / 20)); + const field2 = String.fromCharCode(A + Math.floor(adjustedLat / 10)); + const square1 = Math.floor(adjustedLon % 20 / 2); + const square2 = Math.floor(adjustedLat % 10); + const sub1 = String.fromCharCode(a + Math.floor(adjustedLon % 2 * 12)); + const sub2 = String.fromCharCode(a + Math.floor(adjustedLat % 1 * 24)); + return `${field1}${field2}${square1}${square2}${sub1}${sub2}`; +} +function _wireSubTabBar(bar) { + if (bar._subtabWired) return; + bar._subtabWired = true; + window.trxUi?.prepareTabList(bar, "secondary"); + bar.addEventListener("click", (e) => { + const btn = e.target.closest(".sub-tab[data-subtab]"); + 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; + 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(); +}); +const sigMeasureBtn = document.getElementById("sig-measure-btn"); +const sigClearBtn = document.getElementById("sig-clear-btn"); +const sigResult = document.getElementById("sig-result"); +function resetSignalMeasurementState() { + sigMeasureLastTickMs = 0; + sigMeasureAccumMs = 0; + sigMeasureWeighted = 0; + sigMeasurePeak = null; +} +function updateSignalMeasurement(nowMs) { + if (!sigMeasuring) return; + if (sigMeasureLastTickMs === 0) { + sigMeasureLastTickMs = nowMs; + return; + } + const dt = Math.max(0, nowMs - sigMeasureLastTickMs); + sigMeasureLastTickMs = nowMs; + if (!Number.isFinite(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; + sigResult.innerHTML = `Avg ${formatSignal(avg)} / Peak ${formatSignal(peak)} (${(sigMeasureAccumMs / 1e3).toFixed(1)}s)`; + } + } +}); +sigClearBtn.addEventListener("click", () => { + stopSignalMeasurement(); + resetSignalMeasurementState(); + sigResult.textContent = ""; +}); +const rxAudioBtn = document.getElementById("rx-audio-btn"); +const txAudioBtn = document.getElementById("tx-audio-btn"); +const RX_AUDIO_LABEL = "Play Audio"; +const TX_AUDIO_LABEL = "Transmit Audio"; +const audioStatus = document.getElementById("audio-status"); +const audioLevelFill = document.getElementById("audio-level-fill"); +const audioRow = document.getElementById("audio-row"); +const wfmControlsCol = document.getElementById("wfm-controls-col"); +const wfmDeemphasisEl = document.getElementById("wfm-deemphasis"); +const wfmAudioModeEl = document.getElementById("wfm-audio-mode"); +const wfmDenoiseEl = document.getElementById("wfm-denoise"); +const sdrSettingsRowEl = document.getElementById("sdr-settings-row"); +const sdrGainControlsEl = document.getElementById("sdr-gain-controls"); +const sdrGainEl = document.getElementById("sdr-gain-db"); +const sdrGainSetBtn = document.getElementById("sdr-gain-set"); +const sdrLnaGainControlsEl = document.getElementById("sdr-lna-gain-controls"); +const sdrLnaGainEl = document.getElementById("sdr-lna-gain-db"); +const sdrLnaGainSetBtn = document.getElementById("sdr-lna-gain-set"); +const sdrAgcEl = document.getElementById("sdr-agc-enabled"); +const wfmStFlagEl = document.getElementById("wfm-st-flag"); +const wfmCciFillEl = document.getElementById("wfm-cci-fill"); +const wfmCciValEl = document.getElementById("wfm-cci-val"); +const wfmAciFillEl = document.getElementById("wfm-aci-fill"); +const wfmAciValEl = document.getElementById("wfm-aci-val"); +const samControlsCol = document.getElementById("sam-controls-col"); +const samStereoWidthEl = document.getElementById("sam-stereo-width"); +const samCarrierSyncEl = document.getElementById("sam-carrier-sync"); +const sdrSquelchWrapEl = document.getElementById("sdr-squelch-wrap"); +const sdrSquelchEl = document.getElementById("sdr-squelch"); +const sdrSquelchPctEl = document.getElementById("sdr-squelch-pct"); +const SDR_SQUELCH_MIN_DB = -120; +const SDR_SQUELCH_MAX_DB = -30; +let syncFromServerSdrSquelch = false; +const sdrNbWrapEl = document.getElementById("sdr-nb-wrap"); +const sdrNbEnabledEl = document.getElementById("sdr-nb-enabled"); +const sdrNbThresholdControlsEl = document.getElementById("sdr-nb-threshold-controls"); +const sdrNbThresholdEl = document.getElementById("sdr-nb-threshold"); +const sdrNbThresholdSetBtn = document.getElementById("sdr-nb-threshold-set"); +let sdrNbSupported = false; +fetch("/audio", { method: "GET" }).then((r) => { + if (r.status === 404) audioRow.style.display = "none"; +}).catch(() => { +}); +let audioWs = null; +let audioCtx = null; +let rxActive = false; +let txActive = false; +let txStream = null; +let txProcessor = null; +let streamInfo = null; +let opusDecoder = null; +let wasmOpusDecoder = null; +let txEncoder = null; +let nextPlayTime = 0; +let lastLevelUpdate = 0; +let rxGainNode = null; +let txGainNode = null; +const rxVolSlider = document.getElementById("rx-vol"); +const txVolSlider = document.getElementById("tx-vol"); +const TX_TIMEOUT_SECS = 120; +let txTimeoutTimer = null; +let txTimeoutRemaining = 0; +let txTimeoutInterval = null; +const hasWebCodecs = typeof AudioDecoder !== "undefined" && typeof AudioEncoder !== "undefined"; +const hasWasmOpus = typeof window["opus-decoder"] !== "undefined" && typeof window["opus-decoder"].OpusDecoder !== "undefined"; +const MAX_RX_BUFFER_SECS = 0.25; +const TARGET_RX_BUFFER_SECS = 0.04; +const 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, Number.isFinite(levelPct) ? levelPct : 0)); + audioLevelFill.style.width = `${clamped}%`; +} +function ensureRxAudioContext(preferredSampleRate) { + if (!audioCtx) { + try { + audioCtx = Number.isFinite(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 || !Number.isFinite(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]; + 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 (!Number.isFinite(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 (!Number.isFinite(thresholdDb)) return 0; + const ratio = (thresholdDb - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB); + return clampSdrSquelchPercent(ratio * 100); +} +function updateSdrSquelchPctLabel() { + if (!sdrSquelchEl || !sdrSquelchPctEl) return; + const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); + sdrSquelchPctEl.textContent = pct <= 0 ? "Open" : `${pct}%`; +} +function updateSdrSquelchControlVisibility() { + if (!sdrSquelchWrapEl) return; + const mode = (modeEl && modeEl.value ? modeEl.value : "").toUpperCase(); + sdrSquelchWrapEl.style.display = sdrSquelchSupported && mode !== "WFM" ? "" : "none"; +} +function syncSdrSquelchFromServer(enabled, thresholdDb) { + if (!sdrSquelchEl) return; + if (document.activeElement === sdrSquelchEl) return; + const pct = sdrSquelchServerToPercent(enabled, thresholdDb); + syncFromServerSdrSquelch = true; + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + syncFromServerSdrSquelch = false; + saveSetting("sdrSquelchPct", pct); +} +function submitSdrSquelchPercent(percent) { + if (!sdrSquelchSupported) return; + const { enabled, thresholdDb } = sdrSquelchPercentToServer(percent); + postPath( + `/set_sdr_squelch?enabled=${enabled ? "true" : "false"}&threshold_db=${encodeURIComponent(thresholdDb.toFixed(2))}` + ).catch(() => { + }); +} +if (sdrSquelchEl) { + const savedPct = clampSdrSquelchPercent(Number(loadSetting("sdrSquelchPct", 0))); + sdrSquelchEl.value = String(savedPct); + updateSdrSquelchPctLabel(); + sdrSquelchEl.addEventListener("input", () => { + const pct = clampSdrSquelchPercent(Number(sdrSquelchEl.value)); + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", pct); + if (!syncFromServerSdrSquelch) { + submitSdrSquelchPercent(pct); + } + }); +} +const sdrSquelchAutoBtn = document.getElementById("sdr-squelch-auto"); +if (sdrSquelchAutoBtn) { + sdrSquelchAutoBtn.addEventListener("click", () => { + if (!sdrSquelchSupported) return; + let pct = 0; + const data = lastSpectrumData || window.lastSpectrumData; + if (data && isBinsArray(data.bins) && data.bins.length > 0) { + const noiseDb = estimateNoiseFloorDb(data.bins); + if (noiseDb != null && Number.isFinite(noiseDb)) { + const thresholdDb = noiseDb + 6; + const clamped = Math.max(SDR_SQUELCH_MIN_DB, Math.min(SDR_SQUELCH_MAX_DB, thresholdDb)); + pct = clampSdrSquelchPercent( + (clamped - SDR_SQUELCH_MIN_DB) / (SDR_SQUELCH_MAX_DB - SDR_SQUELCH_MIN_DB) * 100 + ); + } + } + if (sdrSquelchEl) { + sdrSquelchEl.value = String(pct); + updateSdrSquelchPctLabel(); + saveSetting("sdrSquelchPct", pct); + } + submitSdrSquelchPercent(pct); + }); +} +if (wfmAudioModeEl) { + wfmAudioModeEl.value = loadSetting("wfmAudioMode", "stereo"); + wfmAudioModeEl.addEventListener("change", () => { + saveSetting("wfmAudioMode", wfmAudioModeEl.value); + const enabled = wfmAudioModeEl.value !== "mono"; + postPath(`/set_wfm_stereo?enabled=${enabled ? "true" : "false"}`).catch(() => { + }); + }); +} +if (wfmDenoiseEl) { + wfmDenoiseEl.value = normalizeWfmDenoiseLevel(loadSetting("wfmDenoise", "auto")); + wfmDenoiseEl.addEventListener("change", () => { + const level = normalizeWfmDenoiseLevel(wfmDenoiseEl.value); + wfmDenoiseEl.value = level; + saveSetting("wfmDenoise", level); + postPath(`/set_wfm_denoise?level=${encodeURIComponent(level)}`).catch(() => { + }); + }); +} +if (wfmDeemphasisEl) { + wfmDeemphasisEl.addEventListener("change", () => { + postPath(`/set_wfm_deemphasis?us=${encodeURIComponent(wfmDeemphasisEl.value)}`).catch(() => { + }); + }); +} +if (samStereoWidthEl) { + samStereoWidthEl.addEventListener("input", () => { + const width = Number(samStereoWidthEl.value) / 100; + postPath(`/set_sam_stereo_width?width=${width}`).catch(() => { + }); + }); +} +if (samCarrierSyncEl) { + samCarrierSyncEl.addEventListener("change", () => { + const enabled = samCarrierSyncEl.value === "on"; + postPath(`/set_sam_carrier_sync?enabled=${enabled}`).catch(() => { + }); + }); +} +function submitSdrGain() { + if (!sdrGainEl) return; + const parsed = Number.parseFloat(sdrGainEl.value); + if (!Number.isFinite(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 (!Number.isFinite(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 (!Number.isFinite(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 (!Number.isFinite(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(); + } 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); + rxGainNode.gain.value = 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++) { + out2[ch][i] = interleaved[i * channels + ch]; + } + } + 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; +} +let _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]; + for (let i = 0; i < frameCount; i++) monoData[i] += plane[i]; + } + const inv = 1 / Math.max(1, channelData.length); + for (let i = 0; i < frameCount; i++) monoData[i] *= inv; + ab.copyToChannel(monoData, 0); + } else { + for (let ch = 0; ch < channelData.length; ch++) { + ab.copyToChannel(channelData[ch], 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]; + wasmOpusDecoder = new window["opus-decoder"].OpusDecoder({ + sampleRate, + channels, + streamCount: 1, + coupledStreamCount, + channelMappingTable: mapping, + preSkip: 0 + }); + wasmOpusDecoder.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); + } + } 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(); + rxGainNode = null; + if (opusDecoder) { + try { + opusDecoder.close(); + } catch (e) { + } + opusDecoder = null; + } + if (wasmOpusDecoder) { + try { + wasmOpusDecoder.free(); + } catch (e) { + } + wasmOpusDecoder = null; + } + nextPlayTime = 0; + rxAudioBtn.style.borderColor = ""; + rxAudioBtn.style.color = ""; + audioStatus.textContent = "Off"; + setAudioLevel(0); + syncHeaderAudioBtn(); +} +function startTxAudio() { + if (txActive) { + 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; + navigator.mediaDevices.getUserMedia({ + audio: { sampleRate: streamInfo.sample_rate || 48e3, channelCount: streamInfo.channels || 1 } + }).then(async (stream) => { + txStream = stream; + txActive = true; + window.trxUi?.setButtonState(txAudioBtn, { active: true, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" }); + txAudioBtn.style.borderColor = "#e55353"; + txAudioBtn.style.color = "#e55353"; + audioStatus.textContent = "RX+TX"; + resetTxTimeout(); + startTxTimeoutCountdown(); + try { + await postPath("/set_ptt?ptt=true"); + } catch (e) { + console.error("PTT on failed", e); + } + const sampleRate = streamInfo.sample_rate || 48e3; + const channels = streamInfo.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: streamInfo.bitrate_bps || 24e3 + }); + txEncoder = encoder; + if (!audioCtx) audioCtx = new AudioContext({ sampleRate }); + const source = audioCtx.createMediaStreamSource(stream); + const frameDuration = (streamInfo.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 = txVolSlider.value / 100; + source.connect(txGainNode); + txGainNode.connect(processor); + processor.connect(audioCtx.destination); + txProcessor = { source, processor }; + }).catch((err) => { + console.error("getUserMedia failed:", err); + audioStatus.textContent = "Mic denied"; + }); +} +async function stopTxAudio() { + if (!txActive) return; + txActive = false; + window.trxUi?.setButtonState(txAudioBtn, { active: false, activeLabel: "Stop Transmitting", inactiveLabel: "Transmit Audio" }); + clearTxTimeout(); + try { + await postPath("/set_ptt?ptt=false"); + } catch (e) { + console.error("PTT off failed", e); + } + if (txStream) { + txStream.getTracks().forEach((t) => t.stop()); + txStream = null; + } + if (txProcessor) { + txProcessor.source.disconnect(); + txProcessor.processor.disconnect(); + txProcessor = null; + } + if (txEncoder) { + try { + txEncoder.close(); + } catch (e) { + } + txEncoder = null; + } + txGainNode = null; + txAudioBtn.style.borderColor = ""; + txAudioBtn.style.color = ""; + audioStatus.textContent = rxActive ? "RX" : "Off"; +} +rxAudioBtn.addEventListener("click", startRxAudio); +txAudioBtn.addEventListener("click", startTxAudio); +const headerAudioToggle = document.getElementById("header-audio-toggle"); +const _audioIconPlay = ''; +const _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); +} +let recorderActive = false; +const recorderStartBtn = document.getElementById("recorder-start-btn"); +const recorderStopBtn = document.getElementById("recorder-stop-btn"); +const recorderStatusInd = document.getElementById("recorder-status-indicator"); +const headerRecBtn = document.getElementById("header-rec-btn"); +function syncRecorderUi() { + if (recorderStartBtn) recorderStartBtn.disabled = recorderActive; + if (recorderStopBtn) recorderStopBtn.disabled = !recorderActive; + if (recorderStatusInd) { + recorderStatusInd.textContent = recorderActive ? "Recording" : ""; + recorderStatusInd.classList.toggle("rec-active", recorderActive); + } + if (headerRecBtn) headerRecBtn.classList.toggle("rec-active", recorderActive); + const tabBtn = document.querySelector('.tab[data-tab="recorder"]'); + if (tabBtn) tabBtn.classList.toggle("rec-active", recorderActive); +} +if (recorderStartBtn) { + recorderStartBtn.addEventListener("click", async () => { + try { + await postPath("/api/recorder/start"); + } catch (e) { + console.error("Recorder start failed", e); + } + }); +} +if (recorderStopBtn) { + recorderStopBtn.addEventListener("click", async () => { + try { + await postPath("/api/recorder/stop"); + } catch (e) { + console.error("Recorder stop failed", e); + } + }); +} +if (headerRecBtn) { + headerRecBtn.addEventListener("click", async () => { + try { + if (recorderActive) { + await postPath("/api/recorder/stop"); + } else { + await postPath("/api/recorder/start"); + } + } catch (e) { + console.error("Recorder toggle failed", e); + } + }); +} +window._syncRecorderState = function(enabled) { + recorderActive = enabled; + syncRecorderUi(); +}; +let _recorderFiles = []; +let _recFilesPage = 0; +const 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(); + } + } catch (e) { + console.error("Recorder status fetch failed", e); + } +} +function renderRecorderActive(list) { + const el = document.getElementById("recorder-active-list"); + if (!el) return; + if (!list.length) { + el.innerHTML = '

No active recordings.

'; + return; + } + let html = ''; + for (const r of list) { + const started = new Date(r.started_at * 1e3).toLocaleTimeString(); + const fname = r.path.split("/").pop(); + html += ``; + } + html += "
RigVChanFileStarted
${escapeMapHtml(r.rig_id)}${r.vchan_id ? escapeMapHtml(r.vchan_id) : "-"}${escapeMapHtml(fname)}${started}
"; + el.innerHTML = html; +} +function recorderFormatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB"; + return (bytes / 1048576).toFixed(1) + " MB"; +} +function recFilterAndSort() { + const filterEl = document.getElementById("recorder-filter"); + const sortEl = document.getElementById("recorder-sort"); + const filter = (filterEl ? filterEl.value : "").toLowerCase(); + const sortMode = sortEl ? sortEl.value : "name-desc"; + let filtered = _recorderFiles; + if (filter) { + filtered = filtered.filter(function(f) { + return f.name.toLowerCase().includes(filter); + }); + } + const sorted = filtered.slice(); + switch (sortMode) { + case "name-asc": + sorted.sort(function(a, b) { + return a.name.localeCompare(b.name); + }); + break; + case "name-desc": + sorted.sort(function(a, b) { + return b.name.localeCompare(a.name); + }); + break; + case "size-asc": + sorted.sort(function(a, b) { + return a.size - b.size; + }); + break; + case "size-desc": + sorted.sort(function(a, b) { + return b.size - a.size; + }); + break; + } + return sorted; +} +function renderRecorderFiles() { + const el = document.getElementById("recorder-files-list"); + if (!el) return; + const sorted = recFilterAndSort(); + const total = sorted.length; + const totalPages = Math.max(1, Math.ceil(total / REC_PAGE_SIZE)); + if (_recFilesPage >= totalPages) _recFilesPage = totalPages - 1; + if (_recFilesPage < 0) _recFilesPage = 0; + const start = _recFilesPage * REC_PAGE_SIZE; + const page = sorted.slice(start, start + REC_PAGE_SIZE); + const summaryEl = document.getElementById("rec-page-summary"); + const indicatorEl = document.getElementById("rec-page-indicator"); + const prevBtn = document.getElementById("rec-page-prev"); + const nextBtn = document.getElementById("rec-page-next"); + if (summaryEl) { + summaryEl.textContent = total ? "Showing " + (start + 1) + "-" + Math.min(start + REC_PAGE_SIZE, total) + " of " + total : "Showing 0-0 of 0"; + } + if (indicatorEl) indicatorEl.textContent = "Page " + (_recFilesPage + 1) + " of " + totalPages; + if (prevBtn) prevBtn.disabled = _recFilesPage <= 0; + if (nextBtn) nextBtn.disabled = _recFilesPage >= totalPages - 1; + const filterEl = document.getElementById("recorder-filter"); + const filter = filterEl ? filterEl.value : ""; + if (!page.length) { + el.innerHTML = '

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

"; + return; + } + let html = ''; + for (const f of page) { + const safeName = escapeMapHtml(f.name); + const encodedName = encodeURIComponent(f.name); + html += ''; + } + html += "
FileSizeActions
' + safeName + "" + recorderFormatSize(f.size) + '
Download
"; + el.innerHTML = html; + el.querySelectorAll(".rec-play-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const row = btn.closest("tr"); + if (!row) return; + const next = row.nextElementSibling; + if (next && next.classList.contains("rec-player-row")) { + const audio2 = next.querySelector("audio"); + if (audio2) { + try { + audio2.pause(); + } catch (_) { + } + } + next.remove(); + btn.setAttribute("aria-expanded", "false"); + btn.textContent = "Play"; + return; + } + el.querySelectorAll(".rec-player-row").forEach(function(r) { + const a = r.querySelector("audio"); + if (a) { + try { + a.pause(); + } catch (_) { + } + } + r.remove(); + }); + el.querySelectorAll(".rec-play-btn").forEach(function(b) { + b.setAttribute("aria-expanded", "false"); + b.textContent = "Play"; + }); + const playerRow = document.createElement("tr"); + playerRow.className = "rec-player-row"; + const cell = document.createElement("td"); + cell.colSpan = 3; + const audio = document.createElement("audio"); + audio.controls = true; + audio.preload = "metadata"; + audio.src = btn.dataset.url; + audio.className = "rec-player-audio"; + cell.appendChild(audio); + playerRow.appendChild(cell); + row.parentNode.insertBefore(playerRow, row.nextSibling); + btn.setAttribute("aria-expanded", "true"); + btn.textContent = "Hide"; + try { + audio.play(); + } catch (_) { + } + }); + }); + el.querySelectorAll(".rec-delete-btn").forEach(function(btn) { + btn.addEventListener("click", async function() { + const name = btn.dataset.name; + if (!await window.trxUi.confirm({ title: "Delete recording?", message: `${name} will be permanently removed.`, confirmLabel: "Delete" })) return; + try { + const resp = await fetch("/api/recorder/files/" + encodeURIComponent(name), { method: "DELETE" }); + if (!resp.ok) throw new Error("HTTP " + resp.status); + _recorderFiles = _recorderFiles.filter(function(f) { + return f.name !== name; + }); + renderRecorderFiles(); + } catch (e) { + console.error("Delete failed", e); + window.trxUi?.notify("Recording could not be deleted", { kind: "error" }); + } + }); + }); +} +(function() { + const filterEl = document.getElementById("recorder-filter"); + const sortEl = document.getElementById("recorder-sort"); + if (filterEl) filterEl.addEventListener("input", function() { + _recFilesPage = 0; + renderRecorderFiles(); + }); + if (sortEl) sortEl.addEventListener("change", function() { + _recFilesPage = 0; + renderRecorderFiles(); + }); + const prevBtn = document.getElementById("rec-page-prev"); + const nextBtn = document.getElementById("rec-page-next"); + if (prevBtn) prevBtn.addEventListener("click", function() { + _recFilesPage--; + renderRecorderFiles(); + }); + if (nextBtn) nextBtn.addEventListener("click", function() { + _recFilesPage++; + renderRecorderFiles(); + }); +})(); +const rxVolPct = document.getElementById("rx-vol-pct"); +const txVolPct = document.getElementById("tx-vol-pct"); +rxVolSlider.value = loadSetting("rxVol", 80); +txVolSlider.value = 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 = 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 = 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 }); +} +document.getElementById("copyright-year").textContent = (/* @__PURE__ */ new Date()).getFullYear(); +let decodeSource = null; +let decodeConnected = false; +let 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; +} +const _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) { + if (msg.type === "ais") { + if (window.onServerAis) window.onServerAis(msg); + else (_pendingDecodeLive.ais = _pendingDecodeLive.ais || []).push(msg); + } + if (msg.type === "vdes") { + if (window.onServerVdes) window.onServerVdes(msg); + else (_pendingDecodeLive.vdes = _pendingDecodeLive.vdes || []).push(msg); + } + if (msg.type === "aprs") { + if (window.onServerAprs) window.onServerAprs(msg); + else (_pendingDecodeLive.aprs = _pendingDecodeLive.aprs || []).push(msg); + } + if (msg.type === "hf_aprs") { + if (window.onServerHfAprs) window.onServerHfAprs(msg); + else (_pendingDecodeLive.hf_aprs = _pendingDecodeLive.hf_aprs || []).push(msg); + } + if (msg.type === "cw" && window.onServerCw) window.onServerCw(msg); + if (msg.type === "ft8" && window.onServerFt8) window.onServerFt8(msg); + if (msg.type === "ft4" && window.onServerFt4) window.onServerFt4(msg); + if (msg.type === "ft2" && window.onServerFt2) window.onServerFt2(msg); + if (msg.type === "wspr" && window.onServerWspr) window.onServerWspr(msg); + if (msg.type === "lrpt_image" && window.onServerLrptImage) window.onServerLrptImage(msg); + if (msg.type === "lrpt_progress" && window.onServerLrptProgress) window.onServerLrptProgress(msg); + if (msg.type === "wefax" && window.onServerWefax) window.onServerWefax(msg); + if (msg.type === "wefax_progress" && window.onServerWefaxProgress) window.onServerWefaxProgress(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 dispatchDecodeBatch(batch) { + if (!Array.isArray(batch) || batch.length === 0) return; + for (const msg of batch) { + if (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(); + const type = String(batch[0]?.type || ""); + const uniformType = batch.every((msg) => String(msg?.type || "") === type); + if (uniformType) { + if (type === "ais" && window.onServerAisBatch) { + window.onServerAisBatch(batch); + return; + } + if (type === "vdes" && window.onServerVdesBatch) { + window.onServerVdesBatch(batch); + return; + } + if (type === "aprs" && window.onServerAprsBatch) { + window.onServerAprsBatch(batch); + return; + } + if (type === "hf_aprs" && window.onServerHfAprsBatch) { + window.onServerHfAprsBatch(batch); + return; + } + if (type === "ft8" && window.onServerFt8Batch) { + window.onServerFt8Batch(batch); + return; + } + if (type === "ft4" && window.onServerFt4Batch) { + window.onServerFt4Batch(batch); + return; + } + if (type === "ft2" && window.onServerFt2Batch) { + window.onServerFt2Batch(batch); + return; + } + if (type === "wspr" && window.onServerWsprBatch) { + window.onServerWsprBatch(batch); + return; + } + } + for (const msg of batch) { + dispatchDecodeMessage(msg, true); + } +} +const DECODE_HISTORY_TYPE_BATCH_LIMIT = 192; +const DECODE_HISTORY_WORKER_GROUP_LIMIT = 512; +const 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 decodeCborPayload(payload); + }).then((groups) => { + if (typeof onReady === "function") onReady(groups && typeof groups === "object" ? groups : {}); + }).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(); + } + if (kind === "ais") { + if (window.restoreAisHistory) { + window.restoreAisHistory(messages); + } else { + _pendingDecodeHistory.ais = (_pendingDecodeHistory.ais || []).concat(messages); + } + return; + } + if (kind === "vdes") { + if (window.restoreVdesHistory) { + window.restoreVdesHistory(messages); + } else { + _pendingDecodeHistory.vdes = (_pendingDecodeHistory.vdes || []).concat(messages); + } + return; + } + if (kind === "aprs") { + if (window.restoreAprsHistory) { + window.restoreAprsHistory(messages); + } else { + _pendingDecodeHistory.aprs = (_pendingDecodeHistory.aprs || []).concat(messages); + } + return; + } + if (kind === "hf_aprs") { + if (window.restoreHfAprsHistory) { + window.restoreHfAprsHistory(messages); + } else { + _pendingDecodeHistory.hf_aprs = (_pendingDecodeHistory.hf_aprs || []).concat(messages); + } + return; + } + if (kind === "cw" && window.restoreCwHistory) { + window.restoreCwHistory(messages); + return; + } + if (kind === "ft8" && window.restoreFt8History) { + window.restoreFt8History(messages); + return; + } + if (kind === "ft4" && window.restoreFt4History) { + window.restoreFt4History(messages); + return; + } + if (kind === "ft2" && window.restoreFt2History) { + window.restoreFt2History(messages); + return; + } + if (kind === "wspr" && window.restoreWsprHistory) { + window.restoreWsprHistory(messages); + return; + } + if (kind === "wefax" && window.restoreWefaxHistory) { + window.restoreWefaxHistory(messages); + return; + } +} +function connectDecode() { + if (decodeSource) { + decodeSource.close(); + } + terminateDecodeHistoryWorker(); + decodeHistoryReplayActive = false; + decodeMapSyncPending = false; + for (const k in _pendingDecodeHistory) delete _pendingDecodeHistory[k]; + for (const k in _pendingDecodeLive) delete _pendingDecodeLive[k]; + if (window.resetAisHistoryView) window.resetAisHistoryView(); + if (window.resetVdesHistoryView) window.resetVdesHistoryView(); + if (window.resetAprsHistoryView) window.resetAprsHistoryView(); + if (window.resetCwHistoryView) window.resetCwHistoryView(); + if (window.resetFt8HistoryView) window.resetFt8HistoryView(); + if (window.resetFt4HistoryView) window.resetFt4HistoryView(); + if (window.resetWsprHistoryView) window.resetWsprHistoryView(); + let historySettled = false; + let historyWorkerDone = false; + let historyFallbackStarted = false; + let historyBatchDrainScheduled = false; + let historyTotal = 0; + let historyProcessed = 0; + const historyGroupQueue = []; + const liveBuffer = []; + function flushLiveBuffer() { + historySettled = true; + terminateDecodeHistoryWorker(); + setDecodeHistoryReplayActive(false); + setDecodeHistoryOverlayVisible(false); + for (const msg of liveBuffer) { + try { + dispatchDecodeMessage(msg); + } catch (_) { + } + } + liveBuffer.length = 0; + } + function updateHistoryReplayOverlay() { + setDecodeHistoryOverlayVisible( + true, + "Loading decode history…", + `Replaying ${historyProcessed} / ${historyTotal} decoded messages` + ); + } + function maybeFinishHistoryReplay() { + if (historySettled) return; + if (historyWorkerDone && historyGroupQueue.length === 0) { + clearTimeout(historyTimeout); + flushLiveBuffer(); + } + } + function pumpDecodeHistoryGroupQueue() { + historyBatchDrainScheduled = false; + const startedAt = typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : 0; + while (historyGroupQueue.length > 0) { + const next = historyGroupQueue.shift(); + 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"); + decodeSource.onopen = () => { + decodeConnected = true; + updateDecodeStatus("Connected, listening for packets"); + }; + decodeSource.onmessage = (evt) => { + try { + const msg = JSON.parse(evt.data); + if (historySettled) dispatchDecodeMessage(msg); + else liveBuffer.push(msg); + } catch (e) { + } + }; + decodeSource.onerror = () => { + const wasClosed = decodeSource.readyState === 2; + decodeSource.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", ""); + } +}); +const spectrumCanvas = document.getElementById("spectrum-canvas"); +const spectrumGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(spectrumCanvas, spectrumSnapshotGlOptions) : null; +const spectrumDbAxis = document.getElementById("spectrum-db-axis"); +const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); +const spectrumTooltip = document.getElementById("spectrum-tooltip"); +const spectrumCenterLeftBtn = document.getElementById("spectrum-center-left-btn"); +const spectrumCenterRightBtn = document.getElementById("spectrum-center-right-btn"); +let spectrumSource = null; +let spectrumReconnectTimer = null; +let meterSource = null; +let meterReconnectTimer = null; +let spectrumDrawPending = false; +let spectrumAxisKey = ""; +let spectrumDbAxisKey = ""; +let lastSpectrumRenderData = null; +let spectrumPeakHoldFrames = []; +let pendingSpectrumFrameWaiters = []; +let sweetSpotScanInFlight = false; +const spectrumTmpGridSegments = []; +const spectrumTmpFillPoints = []; +const spectrumTmpPeakPoints = []; +const spectrumTmpMarkerPoints = []; +let spectrumZoom = 1; +let spectrumPanFrac = 0.5; +let spectrumFloor = -115; +let spectrumRange = 90; +let waterfallGamma = 1; +const SPECTRUM_HEADROOM_DB = 20; +const SPECTRUM_SMOOTH_ALPHA = 0.42; +let _spectrumBinBuf = []; +const _b64Lut = new Uint8Array(128); +for (let i = 0; i < 128; i++) _b64Lut[i] = 255; +"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".split("").forEach((c, i) => { + _b64Lut[c.charCodeAt(0)] = i; +}); +let _spectrumBinI8 = new Int8Array(0); +function isBinsArray(v) { + return Array.isArray(v) || ArrayBuffer.isView(v); +} +function decodeBase64ToInt8(b64) { + let end = b64.length; + while (end > 0 && b64.charCodeAt(end - 1) === 61) end--; + const outLen = end * 3 >>> 2; + if (_spectrumBinI8.length !== outLen) _spectrumBinI8 = new Int8Array(outLen); + const out = _spectrumBinI8; + let j = 0; + for (let i = 0; i < end; ) { + const a = _b64Lut[b64.charCodeAt(i++)]; + const b = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const c = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const d = i < end ? _b64Lut[b64.charCodeAt(i++)] : 0; + const n = a << 18 | b << 12 | c << 6 | d; + if (j < outLen) out[j++] = n >> 16 & 255; + if (j < outLen) out[j++] = n >> 8 & 255; + if (j < outLen) out[j++] = n & 255; + } + return out; +} +let spectrumCrosshairX = null; +let spectrumCrosshairY = null; +let _bwDragEdge = null; +let _bwDragStartX = 0; +let _bwDragStartBwHz = 0; +let _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 (Number.isFinite(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 = Number(expectedCenterHz); + if (lastSpectrumData && (!Number.isFinite(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, Number.isFinite(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 && isBinsArray(f.bins) && now - f.t <= holdMs) break; + removeCount++; + } + if (removeCount > 0) spectrumPeakHoldFrames.splice(0, removeCount); +} +function pushSpectrumPeakHoldFrame(frame) { + if (!frame || !isBinsArray(frame.bins) || frame.bins.length === 0) { + clearSpectrumPeakHoldFrames(); + return; + } + const holdMs = Math.max(0, Number.isFinite(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, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs <= 0 || !isBinsArray(currentBins) || currentBins.length === 0) { + return null; + } + pruneSpectrumPeakHoldFrames(); + if (spectrumPeakHoldFrames.length === 0) return null; + const peakBins = currentBins.slice(); + for (const frame of spectrumPeakHoldFrames) { + if (!frame || !isBinsArray(frame.bins) || frame.bins.length !== peakBins.length) continue; + for (let i = 0; i < peakBins.length; i++) { + if (frame.bins[i] > peakBins[i]) peakBins[i] = frame.bins[i]; + } + } + return peakBins; +} +function estimateNoiseFloorDb(bins) { + if (!isBinsArray(bins) || bins.length === 0) return null; + const k = Math.floor(bins.length * 0.15); + return nthElement(bins, k); +} +function nthElement(arr, k) { + const tmp = _nthScratch.length >= arr.length ? _nthScratch : new Float64Array(arr.length); + if (tmp.length > _nthScratch.length) _nthScratch = tmp; + for (let i = 0; i < arr.length; i++) tmp[i] = arr[i]; + let lo = 0, hi = arr.length - 1; + while (lo < hi) { + const pivot = tmp[lo + (hi - lo >> 1)]; + let i = lo, j = hi; + while (i <= j) { + while (tmp[i] < pivot) i++; + while (tmp[j] > pivot) j--; + if (i <= j) { + const t = tmp[i]; + tmp[i] = tmp[j]; + tmp[j] = t; + i++; + j--; + } + } + if (j < k) lo = i; + if (k < i) hi = j; + } + return tmp[k]; +} +let _nthScratch = new Float64Array(0); +let _smoothBins = []; +function buildSpectrumRenderData(frame) { + if (!frame || !isBinsArray(frame.bins)) return frame; + const n = frame.bins.length; + const prev = lastSpectrumRenderData; + const canBlend = prev && isBinsArray(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++) { + _smoothBins[i] = prevBins[i] + (src[i] - prevBins[i]) * alpha; + } + } else { + for (let i = 0; i < n; i++) _smoothBins[i] = src[i]; + } + return { bins: _smoothBins, center_hz: frame.center_hz, sample_rate: frame.sample_rate, rds: frame.rds }; +} +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 || !isBinsArray(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]; + if (val > windowMax) windowMax = val; + if (val >= bins[i - 1] && val >= bins[i + 1]) { + localPeaks.push(i); + } + } + const candidates = localPeaks.filter((i) => bins[i] >= 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] - bins[a]; + }); + let snappedIdx = ranked[0]; + if (snappedIdx == null) { + snappedIdx = targetIdx; + for (let i = searchLo; i <= searchHi; i++) { + if (bins[i] > bins[snappedIdx]) snappedIdx = i; + } + } + return { + index: snappedIdx, + hz: Math.round(fullLoHz + snappedIdx / maxIdx * data.sample_rate), + db: bins[snappedIdx] + }; +} +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 || !isBinsArray(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]; + if (v >= bins[i - 1] && v >= bins[i + 1]) { + peaks.push(i); + } + } + if (peaks.length === 0) { + return []; + } + const peakValues = peaks.map((i) => bins[i]).sort((a, b) => a - b); + const cutoff = peakValues[Math.max(0, Math.floor(peakValues.length * 0.7))]; + return peaks.filter((i) => bins[i] >= cutoff).sort((a, b) => bins[b] - bins[a]).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); + spectrumSource.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); + } + }; + spectrumSource.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 = decodeBase64ToInt8(b64); + const rds = lastSpectrumData?.rds; + lastSpectrumData = { bins, center_hz: centerHz, sample_rate: sampleRate, rds }; + 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] > bins[peakIndex]) peakIndex = i; + const peakHz = centerHz - sampleRate / 2 + peakIndex / Math.max(1, bins.length - 1) * sampleRate; + spectrumSummary.textContent = `Spectrum centered at ${formatFreqForHumans(centerHz)}, spanning ${formatFreqForHumans(sampleRate)}. Strongest visible bin near ${formatFreqForHumans(peakHz)} at ${bins[peakIndex]} dB.`; + } + if (spectrumCenterPendingHz !== null && Math.abs(centerHz - spectrumCenterPendingHz) < 1e3) { + spectrumCenterPendingHz = null; + } + lastSpectrumRenderData = buildSpectrumRenderData(lastSpectrumData); + settlePendingSpectrumFrameWaiters(lastSpectrumData); + pushSpectrumPeakHoldFrame(lastSpectrumRenderData); + pushOverviewWaterfallFrame(lastSpectrumData); + pushSpectrumWaterfallFrame(lastSpectrumData); + refreshCenterFreqDisplay(); + if (window.refreshCwTonePicker) window.refreshCwTonePicker(); + scheduleSpectrumDraw(); + if (!hadSpectrum) { + updateRdsPsOverlay(lastSpectrumData.rds); + } else { + positionRdsPsOverlay(); + } + } catch (_) { + } + }); + spectrumSource.addEventListener("rds", (evt) => { + try { + const rds = evt.data === "null" ? void 0 : JSON.parse(evt.data); + if (lastSpectrumData) lastSpectrumData.rds = rds; + updateRdsPsOverlay(rds ?? null); + } catch (_) { + } + }); + spectrumSource.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; + if (typeof vchanActiveId !== "undefined" && vchanActiveId && nextSig.has(vchanActiveId)) { + sigLastDbm = nextSig.get(vchanActiveId); + refreshSigStrengthDisplay(); + } + updateRdsPsOverlay(primaryRds); + } catch (_) { + } + }); + spectrumSource.onerror = () => { + rejectPendingSpectrumFrameWaiters(new Error("Spectrum stream disconnected")); + if (spectrumSource) { + spectrumSource.close(); + spectrumSource = null; + } + scheduleSpectrumReconnect(); + }; +} +function stopSpectrumStreaming() { + if (spectrumSource !== null) { + spectrumSource.close(); + spectrumSource = null; + } + if (spectrumReconnectTimer !== null) { + clearTimeout(spectrumReconnectTimer); + spectrumReconnectTimer = null; + } + spectrumDrawPending = false; + lastSpectrumData = null; + lastSpectrumRenderData = null; + rejectPendingSpectrumFrameWaiters(new Error("Spectrum streaming stopped")); + clearSpectrumPeakHoldFrames(); + overviewWaterfallRows = []; + overviewWaterfallPushCount = 0; + overviewWfResetTextureCache(); + spectrumWfRows = []; + spectrumWfPushCount = 0; + spectrumWfTexReady = false; + scheduleOverviewDraw(); + updateRdsPsOverlay(null); + clearSpectrumCanvas(); +} +const METER_ATTACK_ALPHA = 0.08; +const METER_DECAY_ALPHA = 0.03; +let meterSmoothedDbm = null; +let meterRafPending = false; +function scheduleMeterReconnect() { + if (meterReconnectTimer !== null) return; + meterReconnectTimer = setTimeout(() => { + meterReconnectTimer = null; + startMeterStreaming(); + }, 1e3); +} +function applyMeterSample(dbm) { + if (typeof dbm !== "number" || !Number.isFinite(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.close(); + meterSource = null; + } + if (meterReconnectTimer !== null) { + clearTimeout(meterReconnectTimer); + meterReconnectTimer = null; + } + meterSmoothedDbm = null; +} +function clearSpectrumCanvas() { + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; + const cssW = spectrumCanvas.clientWidth || 1; + const cssH = spectrumCanvas.clientHeight || 1; + spectrumGl.ensureSize(cssW, cssH, window.devicePixelRatio || 1); + spectrumGl.clear(cssColorToRgba(spectrumBgColor())); + if (spectrumDbAxis) { + spectrumDbAxis.replaceChildren(); + spectrumDbAxisKey = ""; + } +} +function formatOverlayPs(ps) { + return 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 += escapeMapHtml(ch); + } + } + return html; +} +function formatOverlayPi(pi) { + return pi != null ? `PI 0x${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: Number.isFinite(freqHz) ? Math.round(freqHz) : null, + ...rds + }; +} +function formatRdsAfMHz(hz) { + return `${(hz / 1e6).toFixed(1)} MHz`; +} +function tuneRdsAlternativeFrequency(hz) { + if (!Number.isFinite(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) => Number.isFinite(hz) && hz > 0).map((hz) => Math.round(hz)) : []; + const afKey = afs.join(","); + if (!afs.length) { + if (afEl.dataset.afKey === "") return; + afEl.dataset.afKey = ""; + afEl.textContent = "--"; + return; + } + if (afEl.dataset.afKey === afKey) return; + afEl.dataset.afKey = afKey; + afEl.replaceChildren(); + for (const hz of afs) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "rds-af-btn"; + btn.dataset.hz = String(hz); + btn.textContent = formatRdsAfMHz(hz); + afEl.appendChild(btn); + } + if (!afEl.childElementCount) afEl.textContent = "--"; +} +async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) { + const rds = rdsOverride || activeChannelRds(); + const ps = rds?.program_service; + if (!rds || !ps || ps.length === 0) { + showHint("No RDS PS", 1200); + return; + } + const freqHz = Number.isFinite(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz(); + const freqMhz = Number.isFinite(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); + } +} +const rdsPsValueEl = document.getElementById("rds-ps"); +if (rdsPsValueEl) { + rdsPsValueEl.addEventListener("click", () => { + copyRdsPsToClipboard(); + }); +} +const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); +if (rdsRawCopyBtn) { + rdsRawCopyBtn.addEventListener("click", () => { + copyRdsRawToClipboard(); + }); +} +const 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 && Number.isFinite(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) 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: Number.isFinite(freqHz) ? Math.round(freqHz) : null, + ...rest + }, null, 2); + } + return; + } + statusEl.textContent = "Decoding"; + statusEl.className = "rds-value rds-decoding"; + piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; + if (psEl) { + if (activeRds.program_service) { + psEl.innerHTML = formatPsHtml(activeRds.program_service); + } else { + psEl.textContent = "--"; + } + } + ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--"); + ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--"; + if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--"; + if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program); + if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement); + if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music); + if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo); + if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed); + if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head); + if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty); + renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz); + if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--"; + rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2); +} +window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds); +function scheduleSpectrumDraw() { + if (spectrumDrawPending) return; + spectrumDrawPending = true; + requestAnimationFrame(() => { + spectrumDrawPending = false; + if (lastSpectrumRenderData) { + drawSpectrum(lastSpectrumRenderData); + if (overviewWaterfallRows.length > 0) scheduleOverviewDraw(); + if (spectrumWfRows.length > 0) scheduleSpectrumWaterfallDraw(); + } + }); +} +function drawSpectrum(data) { + if (!spectrumCanvas || !spectrumGl || !spectrumGl.ready) return; + const dpr = _cachedDpr; + const cssW = _cachedSpectrumCssW; + const cssH = _cachedSpectrumCssH; + spectrumGl.ensureSize(cssW, cssH, dpr); + const W = spectrumCanvas.width; + const H = spectrumCanvas.height; + const pal = canvasPalette(); + const range = spectrumVisibleRange(data); + const bins = data.bins; + const peakHoldBins = buildSpectrumPeakHoldBins(bins); + const n = bins.length; + spectrumGl.clear(cssColorToRgba(pal.bg)); + if (!n) return; + const DB_MIN = spectrumFloor; + const DB_MAX = spectrumFloor + spectrumRange; + const dbRange = DB_MAX - DB_MIN; + const fullSpanHz = data.sample_rate; + const loHz = data.center_hz - fullSpanHz / 2; + const gridStep = spectrumRange > 100 ? 20 : 10; + spectrumTmpGridSegments.length = 0; + for (let db = Math.ceil(DB_MIN / gridStep) * gridStep; db <= DB_MAX; db += gridStep) { + const y = Math.round(H * (1 - (db - DB_MIN) / dbRange)); + spectrumTmpGridSegments.push(0, y, W, y); + } + spectrumGl.drawSegments(spectrumTmpGridSegments, cssColorToRgba(pal.spectrumGrid), 1); + updateSpectrumDbAxis(DB_MIN, DB_MAX, gridStep, H, dpr); + function hzToX(hz) { + return (hz - range.visLoHz) / range.visSpanHz * W; + } + function binX(i) { + return hzToX(loHz + i / (n - 1) * fullSpanHz); + } + function binYFromBins(srcBins, i) { + const db = Math.max(DB_MIN, Math.min(DB_MAX, srcBins[i])); + 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 (isBinsArray(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(); +} +const spectrumWaterfallCanvas = document.getElementById("spectrum-waterfall-canvas"); +const spectrumWaterfallGl = typeof createTrxWebGlRenderer === "function" && spectrumWaterfallCanvas ? createTrxWebGlRenderer(spectrumWaterfallCanvas, spectrumSnapshotGlOptions) : null; +let spectrumWfRows = []; +let spectrumWfPushCount = 0; +let spectrumWfTexData = null; +let spectrumWfTexWidth = 0; +let spectrumWfTexHeight = 0; +let spectrumWfTexPushCount = 0; +let spectrumWfTexPalKey = ""; +let spectrumWfTexReady = false; +let spectrumWfDrawPending = false; +const SPECTRUM_WF_TEX_MAX_W = 1024; +const _spectrumZoomEl = document.getElementById("spectrum-zoom-indicator"); +const _spectrumMinimapEl = document.getElementById("spectrum-minimap"); +const _spectrumMinimapInner = _spectrumMinimapEl ? _spectrumMinimapEl.querySelector(".minimap-view") : null; +let _cachedSpectrumCssW = 640, _cachedSpectrumCssH = 160; +let _cachedSpecWfCssW = 640, _cachedSpecWfCssH = 120; +let _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 || !isBinsArray(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 = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, Number.isFinite(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 (!isBinsArray(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)); + waterfallLutWrite(spectrumWfTexData, rowBase + x * 4, srcBins[binIdx]); + } + } + if (needsFull) { + for (let y = 0; y < iH; y++) renderRow(y, rows[y]); + 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++) renderRow(y, rows[y]); + } else { + const shiftBytes = newCount * rowStride; + spectrumWfTexData.copyWithin(0, shiftBytes); + const startRow = iH - newCount; + for (let y = startRow; y < iH; y++) renderRow(y, rows[y]); + } + spectrumWfTexPushCount = spectrumWfPushCount; + spectrumWfTexPalKey = palKey; + texUpdated = true; + } + if (texUpdated || !spectrumWfTexReady) { + spectrumWaterfallGl.uploadRgbaTexture("spectrum-waterfall", iW, iH, spectrumWfTexData, "linear"); + spectrumWfTexReady = true; + } + spectrumWaterfallGl.drawTexture("spectrum-waterfall", 0, 0, W, H, 1, true); +} +function bmHexToRgba(hex, alpha) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} +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, Number.isFinite(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 = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; + const cats = [...new Set(ref.map((b) => b.category).filter(Boolean))].sort(); + const palette = bmThemePalette(); + const map = { "": palette[0] }; + cats.forEach((cat, i) => { + map[cat] = palette[(i + 1) % palette.length]; + }); + return map; +} +function createBookmarkChip(bm, colorMap, options = {}) { + const span = document.createElement("span"); + const freqStr = typeof bmFmtFreq === "function" ? bmFmtFreq(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 ? `${escapeMapHtml(freqStr)}${escapeMapHtml(bm.name)}` : "Β " + escapeMapHtml(bm.name) + ""; + span.innerHTML = labelHtml; + const col = colorMap[bm.category || ""]; + span.style.setProperty("--bm-cat-bg", col); + span.style.setProperty("--bm-cat-fg", bmContrastFg(col)); + span.addEventListener("click", () => { + if (typeof bmApply === "function") bmApply(bm); + }); + return span; +} +function updateSideBookmarkStack(container, bookmarks, colorMap) { + if (!container) return; + const rev = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 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 = typeof bmOverlayList !== "undefined" ? bmOverlayList : 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 = typeof bmOverlayRevision !== "undefined" ? bmOverlayRevision : 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 (let i = 0; i < spans.length; i++) widths.push(spans[i].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]; + 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 stepHz = targets.reduce((best, s) => Math.abs(s - ideal) < Math.abs(best - ideal) ? s : best, targets[0]); + 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 { + 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 β†’ ${formatFreq(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 && isBinsArray(data.bins) && data.bins.length > 0) { + const noiseDb = estimateNoiseFloorDb(data.bins); + if (noiseDb != null && Number.isFinite(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 (!Number.isFinite(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 (!Number.isFinite(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; +} +let _sDragStart = null; +let _sDragMoved = false; +let _sDragCanvas = null; +function onSpectrumMouseDown(e, canvasEl) { + if (!canvasEl || e.button !== 0) return; + if (lastSpectrumData) { + const rect = canvasEl.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const range = spectrumVisibleRange(lastSpectrumData); + const edge = getBwEdgeHit(cssX, rect.width, range); + if (edge) { + _bwDragEdge = edge; + _bwDragStartX = cssX; + _bwDragStartBwHz = currentBandwidthHz; + _bwDragCanvas = canvasEl; + _sDragStart = null; + _sDragCanvas = null; + _sDragMoved = true; + return; + } + } + _sDragStart = { clientX: e.clientX, panFrac: spectrumPanFrac }; + _sDragCanvas = canvasEl; + _sDragMoved = false; +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("mousedown", (e) => { + onSpectrumMouseDown(e, spectrumCanvas); + }); +} +if (overviewCanvas) { + overviewCanvas.addEventListener("mousedown", (e) => { + onSpectrumMouseDown(e, overviewCanvas); + }); +} +if (spectrumCanvas || overviewCanvas) { + window.addEventListener("mousemove", (e) => { + if (_bwDragEdge && lastSpectrumData) { + const dragCanvas2 = _bwDragCanvas || spectrumCanvas; + if (!dragCanvas2) return; + const rect2 = dragCanvas2.getBoundingClientRect(); + const cssX = e.clientX - rect2.left; + const range = spectrumVisibleRange(lastSpectrumData); + const dxHz = (cssX - _bwDragStartX) / rect2.width * range.visSpanHz; + const side = sidebandDirectionForMode(modeEl ? modeEl.value : "USB"); + let newBw; + if (side === 0) { + newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz * 2 : _bwDragStartBwHz - dxHz * 2; + } else { + newBw = _bwDragEdge === "right" ? _bwDragStartBwHz + dxHz : _bwDragStartBwHz - dxHz; + } + const [, minBw, maxBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + newBw = Math.round(Math.max(minBw, Math.min(maxBw, newBw))); + currentBandwidthHz = newBw; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(newBw); + positionFastOverlay(lastFreqHz, newBw); + scheduleSpectrumDraw(); + scheduleOverviewDraw(); + return; + } + if (!_sDragStart || !lastSpectrumData) return; + const dragCanvas = _sDragCanvas || spectrumCanvas || overviewCanvas; + if (!dragCanvas) return; + const rect = dragCanvas.getBoundingClientRect(); + const dx = e.clientX - _sDragStart.clientX; + if (Math.abs(dx) > 3) _sDragMoved = true; + spectrumPanFrac = _sDragStart.panFrac - dx / rect.width / spectrumZoom; + scheduleSpectrumDraw(); + }); + window.addEventListener("mouseup", async () => { + if (_bwDragEdge) { + try { + const bwHz = Math.round(currentBandwidthHz); + if (!(typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bwHz))) { + await postPath(`/set_bandwidth?hz=${bwHz}`); + if (Number.isFinite(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz, currentBandwidthHz); + } + } + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { + kind: "error", + action: { label: "Retry", run: () => postPath(`/set_bandwidth?hz=${Math.round(currentBandwidthHz)}`) } + }); + } + _bwDragEdge = null; + _bwDragCanvas = null; + return; + } + _sDragStart = null; + _sDragCanvas = null; + }); +} +let _sTouch = null; +if (spectrumCanvas) { + spectrumCanvas.addEventListener("touchstart", (e) => { + e.preventDefault(); + if (e.touches.length === 2) { + const t0 = e.touches[0], t1 = e.touches[1]; + _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) { + _sTouch = { type: "pan", clientX: e.touches[0].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]; + 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 dx = e.touches[0].clientX - _sTouch.clientX; + spectrumPanFrac = _sTouch.panFrac - dx / rect.width / spectrumZoom; + scheduleSpectrumDraw(); + } + }, { passive: false }); + spectrumCanvas.addEventListener("touchend", () => { + _sTouch = null; + }); +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("mousemove", (e) => { + if (!lastSpectrumData || !spectrumTooltip) return; + const rect = spectrumCanvas.getBoundingClientRect(); + const cssX = e.clientX - rect.left; + const range = spectrumVisibleRange(lastSpectrumData); + const edge = getBwEdgeHit(cssX, rect.width, range); + spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair"; + const hz = canvasXToHz(cssX, rect.width, range); + const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range); + const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData); + const peakHz = peak?.hz ?? null; + const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : null; + if (bookmark) { + spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark); + } else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) { + spectrumTooltip.textContent = peakDb ? `Peak ${formatSpectrumFreq(peakHz)} Β· ${peakDb}` : `Peak ${formatSpectrumFreq(peakHz)}`; + } else { + const baseText = formatSpectrumFreq(peakHz ?? hz); + spectrumTooltip.textContent = peakDb ? `${baseText} Β· ${peakDb}` : baseText; + } + spectrumTooltip.style.display = "block"; + const tw = spectrumTooltip.offsetWidth; + let tx = cssX + 10; + if (tx + tw > rect.width) tx = cssX - tw - 10; + spectrumTooltip.style.left = tx + "px"; + spectrumTooltip.style.top = Math.max(0, e.clientY - rect.top - 28) + "px"; + spectrumCrosshairX = cssX; + spectrumCrosshairY = e.clientY - rect.top; + scheduleSpectrumDraw(); + }); + spectrumCanvas.addEventListener("mouseleave", () => { + if (spectrumTooltip) spectrumTooltip.style.display = "none"; + spectrumCanvas.style.cursor = "crosshair"; + spectrumCrosshairX = null; + spectrumCrosshairY = null; + scheduleSpectrumDraw(); + }); +} +if (spectrumCanvas) { + spectrumCanvas.addEventListener("click", (e) => { + handleSpectrumClick(e, spectrumCanvas); + }); +} +if (spectrumCenterLeftBtn) { + spectrumCenterLeftBtn.addEventListener("click", () => { + shiftSpectrumCenter(-1).catch(() => { + }); + }); +} +if (spectrumCenterRightBtn) { + spectrumCenterRightBtn.addEventListener("click", () => { + shiftSpectrumCenter(1).catch(() => { + }); + }); +} +(function() { + const floorInput = document.getElementById("spectrum-floor-input"); + const autoBtn = document.getElementById("spectrum-auto-btn"); + if (floorInput) { + floorInput.addEventListener("change", () => { + const v = Number(floorInput.value); + if (!isNaN(v)) { + spectrumFloor = v; + if (lastSpectrumData) scheduleSpectrumDraw(); + } + }); + } + const rangeInput = document.getElementById("spectrum-range-input"); + if (rangeInput) { + rangeInput.value = 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]; + spectrumFloor = Math.floor(noise / 10) * 10 - 10; + spectrumRange = Math.max(60, Math.ceil((peak - spectrumFloor) / 10) * 10 + SPECTRUM_HEADROOM_DB); + if (floorInput) floorInput.value = spectrumFloor; + if (rangeInput) rangeInput.value = 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 (Number.isFinite(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(); + }); + } +})(); +let bandplanData = null; +let bandplanRegion = loadSetting("bandplanRegion", "off"); +let bandplanShowLabels = loadSetting("bandplanLabels", true); +let _bandplanServerDefaultApplied = false; +let bandplanSegmentsCache = null; +let bandplanCacheKey = ""; +const bandplanStripEl = document.getElementById("spectrum-bandplan-strip"); +const bandplanRegionSelect = document.getElementById("bandplan-region-select"); +const bandplanLabelsCheck = document.getElementById("bandplan-labels-check"); +(function loadBandplanJson() { + fetch("/bandplan.json").then((r) => { + if (!r.ok) throw new Error(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 || !Number.isFinite(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 = 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/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js new file mode 100644 index 00000000..34c90fe4 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -0,0 +1,407 @@ +"use strict"; +const aprsStatus = document.getElementById("aprs-status"); +const aprsPacketsEl = document.getElementById("aprs-packets"); +const aprsFilterInput = document.getElementById("aprs-filter"); +const aprsBarOverlay = document.getElementById("aprs-bar-overlay"); +const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); +const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); +const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); +const aprsTotalCountEl = document.getElementById("aprs-total-count"); +const aprsVisibleCountEl = document.getElementById("aprs-visible-count"); +const aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); +const APRS_BAR_WINDOW_MS = 15 * 60 * 1e3; +let aprsFilterText = ""; +let aprsPacketHistory = []; +let aprsBarDismissedAtMs = 0; +let aprsOnlyPos = false; +let aprsHideCrc = false; +let aprsCollapseDup = false; +let aprsTypeFilter = "all"; +function currentAprsHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneAprsPacketHistory() { + const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); + aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); +} +function scheduleAprsUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleAprsHistoryRender() { + scheduleAprsUi("aprs-history", () => renderAprsHistory()); +} +function scheduleAprsBarUpdate() { + scheduleAprsUi("aprs-bar", () => updateAprsBar()); +} +function renderAprsInfo(pkt) { + const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null; + if (bytes && bytes.length > 0) { + let out2 = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b >= 32 && b <= 126) { + const ch = String.fromCharCode(b); + if (ch === "<") out2 += "<"; + else if (ch === ">") out2 += ">"; + else if (ch === "&") out2 += "&"; + else if (ch === '"') out2 += """; + else out2 += ch; + } else { + const hex = b.toString(16).toUpperCase().padStart(2, "0"); + out2 += `0x${hex}`; + } + } + return out2; + } + const str = pkt.info || ""; + let out = ""; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code >= 32 && code <= 126) { + const ch = str[i]; + if (ch === "<") out += "<"; + else if (ch === ">") out += ">"; + else if (ch === "&") out += "&"; + else if (ch === '"') out += """; + else out += ch; + } else { + const hex = code.toString(16).toUpperCase().padStart(2, "0"); + out += `0x${hex}`; + } + } + return out; +} +function aprsPacketCategory(pkt) { + const type = String(pkt.type || "").toLowerCase(); + const info = String(pkt.info || "").toLowerCase(); + if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position"; + if (type.includes("message") || info.startsWith(":")) return "message"; + if (type.includes("weather") || info.startsWith("_")) return "weather"; + if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; + return "other"; +} +function aprsCategoryLabel(category) { + switch (category) { + case "position": + return "Position"; + case "message": + return "Message"; + case "weather": + return "Weather"; + case "telemetry": + return "Telemetry"; + default: + return "Other"; + } +} +function aprsAgeText(tsMs) { + if (!Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function aprsDistanceText(pkt) { + if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function aprsPacketSignature(pkt) { + return [ + pkt.srcCall || "", + pkt.destCall || "", + pkt.path || "", + pkt.info || "", + pkt.type || "", + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "" + ].join("|"); +} +function aprsHexBytes(bytes) { + if (!Array.isArray(bytes) || bytes.length === 0) return "--"; + return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" "); +} +function aprsFilterMatch(pkt) { + if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; + if (aprsHideCrc && !pkt.crcOk) return false; + if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false; + if (!aprsFilterText) return true; + const haystack = [ + pkt.srcCall, + pkt.destCall, + pkt.path, + pkt.info, + pkt.type, + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "", + aprsPacketCategory(pkt) + ].filter(Boolean).join(" ").toUpperCase(); + return haystack.includes(aprsFilterText); +} +function aprsVisiblePackets() { + const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory; + return packets.filter(aprsFilterMatch); +} +function collapseAprsDuplicates(packets) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const pkt of packets) { + const key = aprsPacketSignature(pkt); + if (seen.has(key)) continue; + seen.add(key); + out.push(pkt); + } + return out; +} +function updateAprsSummary() { + const visible = aprsVisiblePackets(); + if (aprsTotalCountEl) { + aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`; + } + if (aprsVisibleCountEl) { + aprsVisibleCountEl.textContent = `${visible.length} shown`; + } + if (aprsLatestSeenEl) { + const latest = aprsPacketHistory[0]; + if (!latest) { + aprsLatestSeenEl.textContent = "No packets yet"; + } else { + aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`; + } + } +} +function updateAprsChipState() { + document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => { + btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`); + }); + aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos); + aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); + aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); +} +function renderAprsRow(pkt, isFresh) { + const row = document.createElement("div"); + row.className = "aprs-packet"; + if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); + if (isFresh) row.classList.add("aprs-packet-new"); + const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const age = aprsAgeText(pkt._tsMs); + const category = aprsPacketCategory(pkt); + const categoryLabel = aprsCategoryLabel(category); + const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; + const pathBadge = pkt.path ? `${escapeMapHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + let symbolHtml = ""; + if (pkt.symbolTable && pkt.symbolCode) { + const sheet = pkt.symbolTable === "/" ? 0 : 1; + const code = pkt.symbolCode.charCodeAt(0) - 33; + const col = code % 16; + const row2 = Math.floor(code / 16); + const bgX = -(col * 24); + const bgY = -(row2 * 24); + symbolHtml = ``; + } + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = aprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + symbolHtml + `${escapeMapHtml(pkt.srcCall)}>${escapeMapHtml(pkt.destCall || "")}${escapeMapHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeMapHtml(age)}` + (distance ? `${escapeMapHtml(distance)}` : "") + `${escapeMapHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeMapHtml(pkt.srcCall || "--")}Destination${escapeMapHtml(pkt.destCall || "--")}Type${escapeMapHtml(pkt.type || "--")}Path${escapeMapHtml(pkt.path || "--")}Age${escapeMapHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeMapHtml(pkt.info || "--")}Info Bytes${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}
`; + row.querySelectorAll("[data-aprs-map]").forEach((el) => { + el.addEventListener("click", (evt) => { + evt.preventDefault(); + const raw = String(el.dataset.aprsMap || ""); + const [lat, lon] = raw.split(",").map(Number); + if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) { + window.navigateToAprsMap(lat, lon); + } + }); + }); + const copyBtn = row.querySelector("[data-aprs-copy]"); + if (copyBtn) { + copyBtn.addEventListener("click", async () => { + const raw = String(copyBtn.dataset.aprsCopy || ""); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(raw); + showHint("Coordinates copied", 1200); + } + } catch (_e) { + showHint("Copy failed", 1500); + } + }); + } + return row; +} +function renderAprsHistory() { + pruneAprsPacketHistory(); + if (!aprsPacketsEl) { + updateAprsSummary(); + updateAprsChipState(); + return; + } + const visible = aprsVisiblePackets(); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < visible.length; i++) { + fragment.appendChild(renderAprsRow(visible[i], i === 0)); + } + aprsPacketsEl.replaceChildren(fragment); + updateAprsSummary(); + updateAprsChipState(); +} +function updateAprsBar() { + if (!aprsBarOverlay) return; + const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; + const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; + const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs); + const frames = collapseAprsDuplicates(okFrames).slice(0, 8); + const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0); + if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) { + aprsBarOverlay.style.display = "none"; + aprsBarOverlay.innerHTML = ""; + return; + } + let html = `
APRSLiveLast 15 minutesClear
`; + for (const pkt of frames) { + const ts = pkt._ts ? `${pkt._ts}` : ""; + const call = `${escapeMapHtml(pkt.srcCall)}`; + const dest = escapeMapHtml(pkt.destCall || ""); + const info = escapeMapHtml(pkt.info || ""); + const pin = pkt.lat != null && pkt.lon != null ? `` : ""; + html += `
${ts}${pin}${call}>${dest}: ${info}
`; + } + aprsBarOverlay.innerHTML = html; + aprsBarOverlay.style.display = "flex"; +} +window.updateAprsBar = updateAprsBar; +window.clearAprsBar = function() { + window.resetAprsHistoryView(); +}; +window.closeAprsBar = function() { + aprsBarDismissedAtMs = Date.now(); + if (aprsBarOverlay) { + aprsBarOverlay.style.display = "none"; + aprsBarOverlay.innerHTML = ""; + } +}; +window.resetAprsHistoryView = function() { + if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; + aprsPacketHistory = []; + updateAprsBar(); + renderAprsHistory(); + if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs"); +}; +window.pruneAprsHistoryView = function() { + pruneAprsPacketHistory(); + updateAprsBar(); + renderAprsHistory(); +}; +function addAprsPacket(pkt) { + const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); + pkt._tsMs = tsMs; + pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + aprsPacketHistory.unshift(pkt); + pruneAprsPacketHistory(); + if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) { + window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt); + } + if (pkt.crcOk) scheduleAprsBarUpdate(); + scheduleAprsHistoryRender(); +} +function normalizeServerAprsPacket(pkt) { + return { + rig_id: pkt.rig_id || null, + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + srcCall: pkt.src_call, + destCall: pkt.dest_call, + path: pkt.path, + info: pkt.info, + info_bytes: pkt.info_bytes, + type: pkt.packet_type, + crcOk: pkt.crc_ok, + ts_ms: pkt.ts_ms, + lat: pkt.lat, + lon: pkt.lon, + symbolTable: pkt.symbol_table, + symbolCode: pkt.symbol_code + }; +} +window.onServerAprsBatch = function(packets) { + if (!Array.isArray(packets) || packets.length === 0) return; + aprsStatus.textContent = "Receiving"; + const normalized = []; + let hasCrcOk = false; + for (const pkt of packets) { + const next = normalizeServerAprsPacket(pkt); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + if (next.lat != null && next.lon != null && window.aprsMapAddStation) { + window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next); + } + if (next.crcOk) hasCrcOk = true; + normalized.push(next); + } + normalized.reverse(); + aprsPacketHistory = normalized.concat(aprsPacketHistory); + pruneAprsPacketHistory(); + if (hasCrcOk) scheduleAprsBarUpdate(); + scheduleAprsHistoryRender(); +}; +window.restoreAprsHistory = function(packets) { + window.onServerAprsBatch(packets); +}; +document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_aprs_decode"); + window.resetAprsHistoryView(); + } catch (e) { + console.error("APRS history clear failed", e); + } +}); +if (aprsOnlyPosBtn) { + aprsOnlyPosBtn.addEventListener("click", () => { + aprsOnlyPos = !aprsOnlyPos; + renderAprsHistory(); + }); +} +if (aprsHideCrcBtn) { + aprsHideCrcBtn.addEventListener("click", () => { + aprsHideCrc = !aprsHideCrc; + renderAprsHistory(); + }); +} +if (aprsCollapseDupBtn) { + aprsCollapseDupBtn.addEventListener("click", () => { + aprsCollapseDup = !aprsCollapseDup; + renderAprsHistory(); + }); +} +["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { + const btn = document.getElementById(`aprs-type-${type}`); + if (!btn) return; + btn.addEventListener("click", () => { + aprsTypeFilter = type; + renderAprsHistory(); + }); +}); +if (aprsFilterInput) { + aprsFilterInput.addEventListener("input", () => { + aprsFilterText = aprsFilterInput.value.trim().toUpperCase(); + renderAprsHistory(); + }); +} +window.onServerAprs = function(pkt) { + aprsStatus.textContent = "Receiving"; + addAprsPacket(normalizeServerAprsPacket(pkt)); +}; +renderAprsHistory(); +if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js new file mode 100644 index 00000000..49400519 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js @@ -0,0 +1,355 @@ +"use strict"; +(function() { + "use strict"; + function bgdSupportedIds() { + return (window.decoderRegistry || []).filter(function(d) { + return d.background_decode; + }).map(function(d) { + return d.id; + }); + } + let backgroundDecodeRole = null; + let currentRigId = null; + let currentConfig = null; + let bookmarkList = []; + let statusInterval = null; + let bgdDirty = false; + function initBackgroundDecode(rigId, role) { + backgroundDecodeRole = role; + currentRigId = rigId || null; + if (currentRigId) loadBackgroundDecode(); + startStatusPolling(); + } + function setBackgroundDecodeRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + if (!currentRigId) return; + loadBackgroundDecode(); + } + function apiGetConfig(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiPutConfig(rigId, config) { + return fetch("/background-decode/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiResetConfig(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetStatus(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetBookmarks() { + return fetch("/bookmarks").then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function loadBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) { + currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] }; + bookmarkList = Array.isArray(bookmarks) ? bookmarks : []; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + }).catch(function(err) { + console.error("background decode load failed", err); + }); + } + function supportedBookmarks() { + return bookmarkList.filter(function(bookmark) { + return bookmarkDecoderKinds(bookmark).length > 0; + }); + } + function bookmarkDecoderKinds(bookmark) { + var ids = bgdSupportedIds(); + var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : []; + var explicit = decoders.map(function(item) { + return String(item || "").trim().toLowerCase(); + }).filter(function(item, index, arr) { + return ids.indexOf(item) >= 0 && arr.indexOf(item) === index; + }); + if (explicit.length > 0) return explicit; + var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase(); + return (window.decoderRegistry || []).filter(function(d) { + return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0; + }).map(function(d) { + return d.id; + }); + } + function renderBackgroundDecode() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + setCheckbox("background-decode-enabled", !!currentConfig.enabled); + renderBookmarkChecklist(); + const isControl = backgroundDecodeRole === "control" || typeof authEnabled !== "undefined" && !authEnabled; + const panel = document.getElementById("background-decode-panel"); + if (panel) { + panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) { + el.disabled = !isControl; + }); + } + const saveBtn = document.getElementById("background-decode-save-btn"); + const resetBtn = document.getElementById("background-decode-reset-btn"); + if (saveBtn) saveBtn.style.display = isControl ? "" : "none"; + if (resetBtn) resetBtn.style.display = isControl ? "" : "none"; + } + function renderBookmarkChecklist(filterText) { + const container = document.getElementById("bgd-bookmark-checklist"); + if (!container) return; + container.innerHTML = ""; + const selectedIds = new Set( + currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : [] + ); + const all = supportedBookmarks(); + const filter = (filterText || "").trim().toLowerCase(); + const filtered = filter ? all.filter(function(bm) { + var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase(); + return text.indexOf(filter) >= 0; + }) : all; + if (filtered.length === 0) { + container.innerHTML = '
' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "
"; + return; + } + filtered.forEach(function(bookmark) { + var row = document.createElement("label"); + row.className = "bgd-checklist-row"; + var decoders = bookmarkDecoderKinds(bookmark); + var checked = selectedIds.has(bookmark.id) ? " checked" : ""; + row.innerHTML = '' + escHtml(bookmark.name) + '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " Β· " + decoders.join("/").toUpperCase()) + ""; + row.querySelector("input").addEventListener("change", function(e) { + onChecklistToggle(bookmark.id, e.target.checked); + }); + container.appendChild(row); + }); + } + function onChecklistToggle(bookmarkId, checked) { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = []; + if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) { + currentConfig.bookmark_ids.push(bookmarkId); + } else if (!checked) { + currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) { + return id !== bookmarkId; + }); + } + markBgdDirty(); + } + function saveBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + const payload = { + remote: rigId, + enabled: !!document.getElementById("background-decode-enabled").checked, + bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [] + }; + const btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.disabled = true; + apiPutConfig(rigId, payload).then(function(saved) { + currentConfig = saved; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + showToast("Background decode saved."); + }).catch(function(err) { + showToast("Save failed: " + err.message, true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + async function resetBackgroundDecode() { + const rigId = currentRigId; + if (!rigId) return; + if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return; + apiResetConfig(rigId).then(function(saved) { + currentConfig = saved; + renderBackgroundDecode(); + clearBgdDirty(); + pollBackgroundDecodeStatus(); + showToast("Background decode reset."); + }).catch(function(err) { + showToast("Reset failed: " + err.message, true); + }); + } + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3); + } + function pollBackgroundDecodeStatus() { + const rigId = currentRigId; + if (!rigId) return; + apiGetStatus(rigId).then(renderStatus).catch(function() { + }); + } + function renderStatus(status) { + const card = document.getElementById("background-decode-status-card"); + if (!card) return; + const entries = Array.isArray(status && status.entries) ? status.entries : []; + if (!entries.length) { + card.textContent = "No background decode bookmarks configured."; + return; + } + const summary = []; + if (status.active_rig) { + if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz)); + if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span Β±" + formatFreq(status.sample_rate / 2)); + } else { + summary.push("This rig is not currently selected for audio."); + } + let html = summary.length ? '
' + escHtml(summary.join(" Β· ")) + "
" : ""; + html += '
'; + entries.forEach(function(entry) { + const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark"; + const parts = []; + if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz)); + if (entry.mode) parts.push(entry.mode); + if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) { + parts.push(entry.decoder_kinds.join("/").toUpperCase()); + } + html += '
' + escHtml(name) + '
' + escHtml(parts.join(" Β· ")) + '
' + escHtml(prettyState(entry.state)) + "
"; + }); + html += "
"; + card.innerHTML = html; + } + function prettyState(state) { + switch (state) { + case "active": + return "βœ“ Active"; + case "out_of_span": + return "β–³ Out of span"; + case "waiting_for_spectrum": + return "β–³ Waiting"; + case "waiting_for_user": + return "β–³ No user"; + case "missing_bookmark": + return "βœ— Missing"; + case "no_supported_decoders": + return "βœ— Unsupported"; + case "disabled": + return "β–³ Disabled"; + case "handled_by_scheduler": + return "β–³ Scheduler"; + case "scheduler_has_control": + return "β–³ Scheduler"; + case "handled_by_virtual_channel": + return "β–³ VChan"; + default: + return "β–³ Inactive"; + } + } + function setCheckbox(id, value) { + const el = document.getElementById(id); + if (el) el.checked = !!value; + } + function formatFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; + return hz + " Hz"; + } + function escHtml(value) { + return String(value == null ? "" : value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function markBgdDirty() { + if (bgdDirty) return; + bgdDirty = true; + var btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearBgdDirty() { + bgdDirty = false; + var btn = document.getElementById("background-decode-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showToast(msg, isError) { + const el = document.getElementById("background-decode-toast"); + if (!el) return; + el.textContent = msg; + el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; + el.style.display = "block"; + setTimeout(function() { + el.style.display = "none"; + }, 3e3); + } + function selectAllBookmarks() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + var ids = supportedBookmarks().map(function(bm) { + return bm.id; + }); + currentConfig.bookmark_ids = ids; + renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); + markBgdDirty(); + } + function deselectAllBookmarks() { + if (!currentConfig) { + currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] }; + } + currentConfig.bookmark_ids = []; + renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value); + markBgdDirty(); + } + function wireBackgroundDecodeEvents() { + const filterInput = document.getElementById("bgd-bookmark-filter"); + if (filterInput && !filterInput._wired) { + filterInput._wired = true; + filterInput.addEventListener("input", function() { + renderBookmarkChecklist(filterInput.value); + }); + } + const enabledCb = document.getElementById("background-decode-enabled"); + if (enabledCb && !enabledCb._wired) { + enabledCb._wired = true; + enabledCb.addEventListener("change", function() { + markBgdDirty(); + }); + } + const selectAllBtn = document.getElementById("bgd-select-all-btn"); + if (selectAllBtn && !selectAllBtn._wired) { + selectAllBtn._wired = true; + selectAllBtn.addEventListener("click", selectAllBookmarks); + } + const deselectAllBtn = document.getElementById("bgd-deselect-all-btn"); + if (deselectAllBtn && !deselectAllBtn._wired) { + deselectAllBtn._wired = true; + deselectAllBtn.addEventListener("click", deselectAllBookmarks); + } + const saveBtn = document.getElementById("background-decode-save-btn"); + if (saveBtn && !saveBtn._wired) { + saveBtn._wired = true; + saveBtn.addEventListener("click", saveBackgroundDecode); + } + const resetBtn = document.getElementById("background-decode-reset-btn"); + if (resetBtn && !resetBtn._wired) { + resetBtn._wired = true; + resetBtn.addEventListener("click", resetBackgroundDecode); + } + } + window.initBackgroundDecode = initBackgroundDecode; + window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents; + window.setBackgroundDecodeRig = setBackgroundDecodeRig; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js new file mode 100644 index 00000000..d38bd3d1 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -0,0 +1,647 @@ +"use strict"; +let bmScope = "general"; +function bmScopeParam(prefix, scope) { + const sep = prefix ? "&" : "?"; + return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); +} +var bmList = []; +var bmRevision = 0; +var bmOverlayList = []; +var bmOverlayRevision = 0; +let bmFilteredList = []; +let bmEditId = null; +let bmEditScope = null; +let bmCurrentPage = 1; +const BM_PAGE_SIZE = 25; +const bmSelected = /* @__PURE__ */ new Set(); +function bmFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "β€―GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "β€―MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "β€―kHz"; + return hz + "β€―Hz"; +} +function bmEsc(str) { + const d = document.createElement("div"); + d.appendChild(document.createTextNode(String(str))); + return d.innerHTML; +} +function bmCanControl() { + return typeof authEnabled !== "undefined" && !authEnabled || typeof authRole !== "undefined" && authRole === "control"; +} +function bmSyncAccess() { + const canCtrl = bmCanControl(); + const addBtn = document.getElementById("bm-add-btn"); + const selectAllBtn = document.getElementById("bm-select-all-btn"); + if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; + if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; +} +function bmListScope() { + const rig = typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null; + return rig || "general"; +} +async function bmFetchOverlay() { + const overlayScope = bmListScope(); + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); + if (!resp.ok) throw new Error("HTTP " + resp.status); + bmOverlayList = await resp.json(); + } catch (e) { + console.error("Failed to fetch overlay bookmarks:", e); + bmOverlayList = []; + } + bmOverlayRevision++; + if (typeof window.syncBookmarkMapLocators === "function") { + window.syncBookmarkMapLocators(bmOverlayList); + } + if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); +} +async function bmFetch(categoryFilter) { + let url = "/bookmarks"; + let hasQuery = false; + if (categoryFilter && categoryFilter !== "") { + url += "?category=" + encodeURIComponent(categoryFilter); + hasQuery = true; + } + url += bmScopeParam(hasQuery); + const overlayPromise = bmFetchOverlay(); + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error("HTTP " + resp.status); + bmList = await resp.json(); + } catch (e) { + console.error("Failed to fetch bookmarks:", e); + bmList = []; + } + bmRevision++; + bmSelected.clear(); + bmUpdateSelectionUi(); + bmSyncAccess(); + bmApplyFilters(); + bmRefreshCategoryFilter(categoryFilter); + await overlayPromise; +} +function bmApplyFilters() { + const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase(); + const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase(); + let filtered = modeFilter ? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter) : bmList; + filtered = text ? filtered.filter( + (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text) + ) : filtered; + bmFilteredList = filtered; + bmCurrentPage = 1; + bmRender(filtered); +} +async function bmRefreshCategoryFilter(keepValue) { + const sel = document.getElementById("bm-category-filter"); + const modeSel = document.getElementById("bm-mode-filter"); + if (!sel && !modeSel) return; + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false)); + if (!resp.ok) return; + const all = await resp.json(); + if (sel) { + const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); + while (sel.options.length > 1) sel.remove(1); + cats.forEach((cat) => { + const opt = document.createElement("option"); + opt.value = cat; + opt.textContent = cat; + sel.add(opt); + }); + if (keepValue && cats.includes(keepValue)) sel.value = keepValue; + } + if (modeSel) { + const keepMode = modeSel.value; + const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); + while (modeSel.options.length > 1) modeSel.remove(1); + modes.forEach((mode) => { + const opt = document.createElement("option"); + opt.value = mode; + opt.textContent = mode; + modeSel.add(opt); + }); + if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; + } + } catch (_) { + } +} +function bmRender(list) { + const tbody = document.getElementById("bm-tbody"); + const emptyEl = document.getElementById("bm-empty"); + const paginatorEl = document.getElementById("bm-paginator"); + const pageSummaryEl = document.getElementById("bm-page-summary"); + const pageIndicatorEl = document.getElementById("bm-page-indicator"); + const prevBtn = document.getElementById("bm-page-prev"); + const nextBtn = document.getElementById("bm-page-next"); + if (!tbody) return; + tbody.innerHTML = ""; + if (list.length === 0) { + if (emptyEl) emptyEl.style.display = ""; + if (paginatorEl) paginatorEl.style.display = "none"; + return; + } + if (emptyEl) emptyEl.style.display = "none"; + const canControl = bmCanControl(); + const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE)); + const page = Math.min(Math.max(bmCurrentPage, 1), totalPages); + bmCurrentPage = page; + const startIndex = (page - 1) * BM_PAGE_SIZE; + const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length); + const pageItems = list.slice(startIndex, endIndex); + const showScope = bmScope !== "general"; + pageItems.forEach((bm) => { + const tr = document.createElement("tr"); + tr.dataset.bmId = bm.id; + const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--"; + const locatorCell = bm.locator || "--"; + const catCell = bm.category || "Uncategorised"; + const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--"; + const commentCell = bm.comment || ""; + const checked = bmSelected.has(bm.id) ? " checked" : ""; + const scopeBadge = showScope && bm.scope === "general" ? ' G' : ""; + tr.innerHTML = `${bmEsc(bm.name)}${scopeBadge}${bmFmtFreq(bm.freq_hz)}${bmEsc(bm.mode)}${bwCell}${bmEsc(locatorCell)}${bmEsc(catCell)}${bmEsc(decoderCell)}${bmEsc(commentCell)}` + (canControl ? `` : "") + ``; + tbody.appendChild(tr); + }); + bmSyncSelectAllCheckbox(); + if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : ""; + if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`; + if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`; + if (prevBtn) prevBtn.disabled = page <= 1; + if (nextBtn) nextBtn.disabled = page >= totalPages; +} +function bmChangePage(delta) { + const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); + const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); + if (nextPage === bmCurrentPage) return; + bmCurrentPage = nextPage; + bmRender(bmFilteredList); +} +function bmReadDecoders() { + return (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => document.getElementById("bm-dec-" + d.id)?.checked).map((d) => d.id); +} +function bmWriteDecoders(decoders) { + const set = new Set(decoders || []); + (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const el = document.getElementById("bm-dec-" + d.id); + if (el) el.checked = set.has(d.id); + }); +} +function bmBuildDecoderCheckboxes() { + const container = document.getElementById("bm-decoder-checkboxes"); + if (!container) return; + container.innerHTML = ""; + (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const label = document.createElement("label"); + label.className = "bm-decoder-check"; + label.innerHTML = ' ' + d.label; + container.appendChild(label); + }); +} +function bmOpenForm(bm) { + const wrap = document.getElementById("bm-form-wrap"); + if (!wrap) return; + bmEditId = bm ? bm.id : null; + bmEditScope = bm ? bm.scope || bmScope : null; + bmBuildDecoderCheckboxes(); + document.getElementById("bm-id").value = bm ? bm.id : ""; + document.getElementById("bm-name").value = bm ? bm.name : ""; + document.getElementById("bm-freq").value = bm ? bm.freq_hz : ""; + document.getElementById("bm-mode").value = bm ? bm.mode : ""; + document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : ""; + document.getElementById("bm-locator").value = bm ? bm.locator || "" : ""; + document.getElementById("bm-category-input").value = bm ? bm.category || "" : ""; + document.getElementById("bm-comment").value = bm ? bm.comment || "" : ""; + bmWriteDecoders(bm ? bm.decoders : []); + document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; + wrap.style.display = "flex"; + document.getElementById("bm-name").focus(); +} +function bmCloseForm() { + const wrap = document.getElementById("bm-form-wrap"); + if (wrap) wrap.style.display = "none"; + bmEditId = null; +} +function bmPrefillFromStatus() { + if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) { + document.getElementById("bm-freq").value = Math.round(lastFreqHz); + } + if (typeof lastModeName === "string" && lastModeName) { + document.getElementById("bm-mode").value = lastModeName; + } + if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) { + document.getElementById("bm-bw").value = Math.round(currentBandwidthHz); + } + const activeDecoders = (window.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { + const btn = document.getElementById(d.id + "-decode-toggle-btn"); + return btn && btn.dataset.enabled === "true"; + }).map((d) => d.id); + bmWriteDecoders(activeDecoders); +} +async function bmSave(e) { + e.preventDefault(); + const id = document.getElementById("bm-id").value; + const name = document.getElementById("bm-name").value.trim(); + const freqStr = document.getElementById("bm-freq").value; + const freq_hz = parseInt(freqStr, 10); + const mode = document.getElementById("bm-mode").value.trim(); + const bwStr = document.getElementById("bm-bw").value; + const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; + const locator = document.getElementById("bm-locator").value.trim().toUpperCase(); + const category = document.getElementById("bm-category-input").value.trim(); + const comment = document.getElementById("bm-comment").value.trim(); + const decoders = bmReadDecoders(); + const formError = document.getElementById("bm-form-error"); + if (formError) formError.textContent = ""; + if (!name || !Number.isFinite(freq_hz) || !mode) { + if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; + const invalid = !name ? document.getElementById("bm-name") : !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode"); + invalid?.focus(); + return; + } + const body = { + name, + freq_hz, + mode, + bandwidth_hz, + locator: locator || null, + category, + comment, + decoders + }; + try { + let resp; + if (id) { + resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } else { + resp = await fetch("/bookmarks" + bmScopeParam(false), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } + if (!resp.ok) { + const text = await resp.text(); + if (resp.status === 409) { + throw new Error("A bookmark for that frequency already exists."); + } + throw new Error(text || "HTTP " + resp.status); + } + bmCloseForm(); + await bmFetch(document.getElementById("bm-category-filter").value); + } catch (err) { + console.error("Failed to save bookmark:", err); + if (formError) formError.textContent = "Failed to save bookmark: " + err.message; + window.trxUi?.notify("Bookmark could not be saved", { kind: "error" }); + } +} +async function bmDelete(id) { + if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; + const bm = bmList.find((b) => b.id === id); + const scope = bm ? bm.scope : void 0; + try { + const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { + method: "DELETE" + }); + if (!resp.ok) throw new Error("HTTP " + resp.status); + await bmFetch(document.getElementById("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmark:", err); + window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" }); + } +} +async function bmApply(bm) { + try { + if (typeof modeEl !== "undefined" && modeEl) { + modeEl.value = String(bm.mode || "").toUpperCase(); + } + if (bm.bandwidth_hz) { + if (typeof currentBandwidthHz !== "undefined") { + currentBandwidthHz = bm.bandwidth_hz; + } + window.currentBandwidthHz = bm.bandwidth_hz; + if (typeof syncBandwidthInput === "function") { + syncBandwidthInput(bm.bandwidth_hz); + } + } + if (typeof applyLocalTunedFrequency === "function") { + if (typeof _freqOptimisticSeq !== "undefined") { + ++_freqOptimisticSeq; + _freqOptimisticHz = bm.freq_hz; + } + applyLocalTunedFrequency(bm.freq_hz, true); + } + if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) { + scheduleSpectrumDraw(); + } + const tunePromise = (async () => { + if (typeof vchanTakeSchedulerControl === "function") { + await vchanTakeSchedulerControl(); + } + const onVirtual = typeof vchanInterceptMode === "function" && await vchanInterceptMode(bm.mode); + if (!onVirtual) { + await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); + } + if (bm.bandwidth_hz) { + const bwHandledByVchan = typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bm.bandwidth_hz); + if (!bwHandledByVchan) { + await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz); + } + } + if (typeof setRigFrequency === "function") { + await setRigFrequency(bm.freq_hz); + } else { + await postPath("/set_freq?hz=" + bm.freq_hz); + } + })(); + const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; + const modeUp = (bm.mode || "").toUpperCase(); + const allToggleDecoders = (window.decoderRegistry || []).filter( + (d) => d.activation === "toggle" + ); + const decoderPromise = allToggleDecoders.length ? (async () => { + let statusUrl = "/status"; + if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) { + statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId); + } + const statusResp = await fetch(statusUrl); + if (!statusResp.ok) return; + const st = await statusResp.json(); + const toggles = []; + for (const d of allToggleDecoders) { + const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; + const currentlyOn = !!st[statusKey]; + const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp); + let wanted; + if (!compatible) { + wanted = false; + } else if (hasDecoders) { + wanted = bm.decoders.includes(d.id); + } else { + wanted = currentlyOn; + } + if (wanted !== currentlyOn) { + toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); + } + } + if (toggles.length) await Promise.all(toggles); + })() : Promise.resolve(); + Promise.all([tunePromise, decoderPromise]).catch( + (err) => console.error("Bookmark apply background error:", err) + ); + } catch (err) { + console.error("Failed to apply bookmark:", err); + } +} +function bmUpdateSelectionUi() { + const count = bmSelected.size; + const canCtrl = bmCanControl(); + const visible = count > 0 && canCtrl; + const btn = document.getElementById("bm-del-selected-btn"); + const countEl = document.getElementById("bm-del-selected-count"); + if (btn) btn.style.display = visible ? "" : "none"; + if (countEl) countEl.textContent = count; + const moveWrap = document.getElementById("bm-move-selected-wrap"); + const moveCountEl = document.getElementById("bm-move-selected-count"); + if (moveWrap) moveWrap.style.display = visible ? "" : "none"; + if (moveCountEl) moveCountEl.textContent = count; + if (visible) bmPopulateMoveTarget(); + const selectAllBtn = document.getElementById("bm-select-all-btn"); + if (selectAllBtn && bmCanControl()) { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; + } +} +function bmPopulateMoveTarget() { + const sel = document.getElementById("bm-move-target"); + if (!sel) return; + const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : []; + const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {}; + const prev = sel.value; + sel.innerHTML = ""; + if (bmScope !== "general") { + const opt = document.createElement("option"); + opt.value = "general"; + opt.textContent = "General"; + sel.appendChild(opt); + } + rigIds.forEach((id) => { + if (id === bmScope) return; + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + sel.appendChild(opt); + }); + if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) { + sel.value = prev; + } +} +async function bmMoveSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + const target = document.getElementById("bm-move-target")?.value; + if (!target) return; + const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target; + if (!await window.trxUi.confirm({ + title: "Move selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to β€œ${targetLabel}”.`, + confirmLabel: "Move", + danger: false + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + if (scope === target) continue; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds, to: target }) + }).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(document.getElementById("bm-category-filter").value); + } catch (err) { + console.error("Failed to move bookmarks:", err); + window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" }); + } +} +function bmSyncSelectAllCheckbox() { + const selectAll = document.getElementById("bm-select-all"); + if (!selectAll) return; + const checkboxes = document.querySelectorAll(".bm-row-sel"); + if (checkboxes.length === 0) { + selectAll.checked = false; + selectAll.indeterminate = false; + return; + } + const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length; + selectAll.checked = checkedCount === checkboxes.length; + selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; +} +async function bmDeleteSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + if (!await window.trxUi.confirm({ + title: "Delete selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`, + confirmLabel: "Delete" + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds }) + }).then((r) => { + if (!r.ok) throw new Error("HTTP " + r.status); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(document.getElementById("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmarks:", err); + window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" }); + } +} +function bmPopulateScopePicker() { + const picker = document.getElementById("bm-scope-picker"); + if (!picker) return; + const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : []; + const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {}; + const prev = picker.value; + while (picker.options.length > 1) picker.remove(1); + rigIds.forEach((id) => { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + picker.appendChild(opt); + }); + if (prev && (prev === "general" || rigIds.includes(prev))) { + picker.value = prev; + } else { + picker.value = "general"; + } + bmScope = picker.value; +} +(function initBookmarks() { + bmSyncAccess(); + bmBuildDecoderCheckboxes(); + if (typeof window.onDecoderRegistryReady === "function") { + window.onDecoderRegistryReady(bmBuildDecoderCheckboxes); + } + bmPopulateScopePicker(); + const scopePicker = document.getElementById("bm-scope-picker"); + if (scopePicker) { + scopePicker.addEventListener("change", (e) => { + bmScope = e.target.value; + bmFetch(document.getElementById("bm-category-filter")?.value || ""); + }); + } + document.querySelector(".tab-bar").addEventListener("click", (e) => { + const btn = e.target.closest('.tab[data-tab="bookmarks"]'); + if (!btn) return; + bmFetch(document.getElementById("bm-category-filter").value); + }); + document.getElementById("bm-add-btn").addEventListener("click", () => { + bmOpenForm(null); + bmPrefillFromStatus(); + }); + document.getElementById("bm-category-filter").addEventListener("change", (e) => { + bmFetch(e.target.value); + }); + document.getElementById("bm-mode-filter").addEventListener("change", () => { + bmApplyFilters(); + }); + document.getElementById("bm-text-filter").addEventListener("input", () => { + bmApplyFilters(); + }); + document.getElementById("bm-page-prev").addEventListener("click", () => { + bmChangePage(-1); + }); + document.getElementById("bm-page-next").addEventListener("click", () => { + bmChangePage(1); + }); + document.getElementById("bm-form").addEventListener("submit", bmSave); + document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm); + const formWrap = document.getElementById("bm-form-wrap"); + if (formWrap) { + formWrap.addEventListener("click", (e) => { + if (e.target === formWrap) bmCloseForm(); + }); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") { + bmCloseForm(); + } + }); + document.getElementById("bm-select-all").addEventListener("change", (e) => { + const checked = e.target.checked; + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = checked; + if (checked) bmSelected.add(cb.dataset.bmId); + else bmSelected.delete(cb.dataset.bmId); + }); + bmUpdateSelectionUi(); + }); + document.getElementById("bm-select-all-btn").addEventListener("click", () => { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + if (allSelected) { + bmSelected.clear(); + } else { + bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); + } + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = bmSelected.has(cb.dataset.bmId); + }); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + }); + document.getElementById("bm-del-selected-btn").addEventListener("click", () => { + bmDeleteSelected(); + }); + document.getElementById("bm-move-selected-btn").addEventListener("click", () => { + bmMoveSelected(); + }); + document.getElementById("bm-tbody").addEventListener("click", async (e) => { + const checkbox = e.target.closest(".bm-row-sel"); + if (checkbox) { + if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId); + else bmSelected.delete(checkbox.dataset.bmId); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + return; + } + const tuneBtn = e.target.closest(".bm-tune-btn"); + const editBtn = e.target.closest(".bm-edit-btn"); + const delBtn = e.target.closest(".bm-del-btn"); + if (tuneBtn) { + const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); + if (bm) await bmApply(bm); + } else if (editBtn) { + const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); + if (bm) bmOpenForm(bm); + } else if (delBtn) { + await bmDelete(delBtn.dataset.bmId); + } + }); + bmFetch(""); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js new file mode 100644 index 00000000..eee6c526 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -0,0 +1,386 @@ +"use strict"; +const cwStatusEl = document.getElementById("cw-status"); +const cwOutputEl = document.getElementById("cw-output"); +const cwAutoInput = document.getElementById("cw-auto"); +const cwWpmInput = document.getElementById("cw-wpm"); +const cwToneInput = document.getElementById("cw-tone"); +const cwSignalIndicator = document.getElementById("cw-signal-indicator"); +const cwToneCanvas = document.getElementById("cw-tone-waterfall"); +const cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; +const cwTonePickerEl = document.querySelector(".cw-tone-picker"); +const cwToneRangeEl = document.getElementById("cw-tone-range"); +const cwBarOverlay = document.getElementById("cw-bar-overlay"); +const CW_MAX_LINES = 200; +const CW_TONE_MIN_HZ = 100; +const CW_TONE_MAX_HZ = 1e4; +const CW_WPM_MIN = 5; +const CW_WPM_MAX = 40; +const CW_BAR_WINDOW_MS = 15 * 60 * 1e3; +const CW_BAR_LINE_GAP_MS = 5e3; +let cwLastAppendTime = 0; +let cwTonePickerRaf = null; +let cwBarHistory = []; +let cwBarCurrentLine = null; +let cwBarDismissedAtMs = 0; +let cwAutoLocalOverride = null; +function applyCwAutoUi(enabled) { + if (cwAutoInput) cwAutoInput.checked = enabled; + if (cwWpmInput) { + cwWpmInput.disabled = enabled; + cwWpmInput.readOnly = enabled; + } + if (cwToneInput) { + cwToneInput.disabled = enabled; + cwToneInput.readOnly = enabled; + } + if (cwTonePickerEl) { + cwTonePickerEl.classList.toggle("is-auto", enabled); + } +} +window.applyCwAutoUi = applyCwAutoUi; +window.applyCwAutoUiFromServer = function(enabled) { + if (cwAutoLocalOverride !== null) return; + applyCwAutoUi(enabled); +}; +function cwBarFlushCurrentLine() { + if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) { + cwBarHistory.unshift(cwBarCurrentLine); + if (cwBarHistory.length > 50) cwBarHistory.length = 50; + } + cwBarCurrentLine = null; +} +function updateCwBar() { + if (!cwBarOverlay) return; + const mode = (document.getElementById("mode")?.value || "").toUpperCase(); + const isCw = mode === "CW" || mode === "CWR"; + const cutoffMs = Date.now() - CW_BAR_WINDOW_MS; + const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs); + const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent; + const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0); + if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) { + cwBarOverlay.style.display = "none"; + cwBarOverlay.innerHTML = ""; + return; + } + let html = `
CWLiveLast 15 minutesClear
`; + for (const line of liveLines.slice(0, 8)) { + const ts = line.ts ? `${line.ts}` : ""; + const meta = [ + line.wpm ? `${line.wpm} WPM` : null, + line.tone_hz ? `${line.tone_hz} Hz` : null + ].filter(Boolean).join(" Β· "); + html += `
${ts}${escapeMapHtml(line.text)}` + (meta ? ` ${escapeMapHtml(meta)}` : "") + `
`; + } + cwBarOverlay.innerHTML = html; + cwBarOverlay.style.display = "flex"; +} +window.updateCwBar = updateCwBar; +window.clearCwBar = function() { + window.resetCwHistoryView(); +}; +window.closeCwBar = function() { + cwBarDismissedAtMs = Date.now(); + if (cwBarOverlay) { + cwBarOverlay.style.display = "none"; + cwBarOverlay.innerHTML = ""; + } +}; +function clampCwWpm(wpm) { + const numeric = Number(wpm); + if (!Number.isFinite(numeric)) return 15; + return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric))); +} +function clampCwTone(tone) { + const numeric = Number(tone); + if (!Number.isFinite(numeric)) return 700; + return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric))); +} +function currentCwToneRange() { + const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN; + const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN; + if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return null; + } + const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); + const lowerSideband = mode === "CWR"; + const upperSideband = mode === "CW"; + if (!lowerSideband && !upperSideband) return null; + const toneMinHz = CW_TONE_MIN_HZ; + const toneMaxHz = CW_TONE_MAX_HZ; + if (toneMaxHz < toneMinHz) { + return null; + } + return { + tunedHz, + bandwidthHz, + toneMinHz, + toneMaxHz, + toneSpanHz: Math.max(1, toneMaxHz - toneMinHz), + lowerSideband, + mode + }; +} +function cwToneToRfHz(range, toneHz) { + if (!range) return NaN; + return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz; +} +function toneClampForRange(tone, range) { + const clamped = clampCwTone(tone); + if (!range) return clamped; + return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped)); +} +function ensureCwToneCanvasResolution() { + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false; + const rect = cwToneCanvas.getBoundingClientRect(); + const cssWidth = Math.round(rect.width); + const cssHeight = Math.round(rect.height); + if (cssWidth < 8 || cssHeight < 8) { + return false; + } + const dpr = window.devicePixelRatio || 1; + return cwToneGl.ensureSize(cssWidth, cssHeight, dpr); +} +function drawCwTonePicker() { + if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return; + ensureCwToneCanvasResolution(); + if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return; + const width = cwToneCanvas.width; + const height = cwToneCanvas.height; + cwToneGl.clear([0, 0, 0, 0]); + const range = currentCwToneRange(); + if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) { + if (cwToneRangeEl) { + const mode = String(document.getElementById("mode")?.value || "").toUpperCase(); + if (mode !== "CW" && mode !== "CWR") { + cwToneRangeEl.textContent = "CW/CWR mode required"; + } else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) { + cwToneRangeEl.textContent = "Waiting for spectrum"; + } + } + cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]); + return; + } + if (cwToneRangeEl) { + const side = range.lowerSideband ? "Lower side" : "Upper side"; + cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz Β· ${side}`; + } + const bins = window.lastSpectrumData.bins; + const sampleRate = Number(window.lastSpectrumData.sample_rate); + const centerHz = Number(window.lastSpectrumData.center_hz); + const maxIdx = Math.max(1, bins.length - 1); + const fullLoHz = centerHz - sampleRate / 2; + const tones = new Array(width).fill(-140); + for (let x = 0; x < width; x += 1) { + const frac = width <= 1 ? 0 : x / (width - 1); + const toneHz = range.toneMinHz + frac * range.toneSpanHz; + const rfHz = cwToneToRfHz(range, toneHz); + const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx))); + const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140; + tones[x] = power; + } + const smoothed = new Array(width).fill(-140); + const smoothRadius = Math.max(1, Math.round(width / 180)); + for (let x = 0; x < width; x += 1) { + let sum = 0; + let count = 0; + for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) { + if (i < 0 || i >= width) continue; + sum += tones[i]; + count += 1; + } + smoothed[x] = count > 0 ? sum / count : tones[x]; + } + const sorted = smoothed.slice().sort((a, b) => a - b); + const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120; + const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70; + const floorDb = Math.min(q20 - 2, q95 - 10); + const ceilDb = Math.max(floorDb + 18, q95 + 2); + const dbSpan = Math.max(1, ceilDb - floorDb); + const yForDb = (db) => { + const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan)); + return Math.round((1 - n) * (height - 1)); + }; + const rootStyle = getComputedStyle(document.documentElement); + const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f"; + const parseColor = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null; + const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1]; + const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15]; + cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]); + const hGridCount = 4; + const gridSegments = []; + for (let i = 1; i <= hGridCount; i += 1) { + const y = Math.round(i / (hGridCount + 1) * (height - 1)); + gridSegments.push(0, y, width, y); + } + cwToneGl.drawSegments(gridSegments, axisColor, 1); + const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200; + const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep; + const tickSegments = []; + for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) { + const frac = (tone - range.toneMinHz) / range.toneSpanHz; + const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1)))); + tickSegments.push(x, 0, x, height); + } + cwToneGl.drawSegments(tickSegments, axisColor, 1); + const linePoints = []; + for (let x = 0; x < width; x += 1) { + linePoints.push(x, yForDb(smoothed[x])); + } + cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]); + cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2)); + const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range); + const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz; + const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1)))); + const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]); + cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5); + cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]); + if (cwAutoInput?.checked) { + cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]); + } +} +async function setCwTone(tone, { syncInput = true } = {}) { + const range = currentCwToneRange(); + const clamped = toneClampForRange(tone, range); + if (cwToneInput && syncInput) { + cwToneInput.value = clamped; + } + try { + await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`); + } catch (e) { + console.error("CW tone set failed", e); + } + drawCwTonePicker(); +} +if (cwAutoInput) { + cwAutoInput.addEventListener("change", async () => { + const enabled = cwAutoInput.checked; + cwAutoLocalOverride = enabled; + applyCwAutoUi(enabled); + try { + await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + drawCwTonePicker(); + } catch (e) { + console.error("CW auto toggle failed", e); + } finally { + cwAutoLocalOverride = null; + } + }); +} +if (cwWpmInput) { + cwWpmInput.addEventListener("change", async () => { + if (cwAutoInput && cwAutoInput.checked) return; + const wpm = clampCwWpm(cwWpmInput.value); + cwWpmInput.value = wpm; + try { + await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); + } catch (e) { + console.error("CW WPM set failed", e); + } + }); +} +if (cwToneInput) { + cwToneInput.addEventListener("change", async () => { + if (cwAutoInput?.checked) return; + await setCwTone(cwToneInput.value); + }); +} +if (cwToneCanvas) { + cwToneCanvas.addEventListener("click", async (event) => { + if (cwAutoInput?.checked) return; + const rect = cwToneCanvas.getBoundingClientRect(); + if (rect.width <= 0) return; + const range = currentCwToneRange(); + if (!range) return; + const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)); + const tone = range.toneMinHz + frac * range.toneSpanHz; + await setCwTone(tone); + }); +} +window.resetCwHistoryView = function() { + if (cwOutputEl) cwOutputEl.innerHTML = ""; + cwLastAppendTime = 0; + cwBarHistory = []; + cwBarCurrentLine = null; + updateCwBar(); + drawCwTonePicker(); +}; +document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_cw_decode"); + window.resetCwHistoryView(); + } catch (e) { + console.error("CW history clear failed", e); + } +}); +window.onServerCw = function(evt) { + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + if (evt.text && cwOutputEl) { + const now = Date.now(); + if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") { + const line = document.createElement("div"); + line.className = "cw-line"; + cwOutputEl.appendChild(line); + } + cwLastAppendTime = now; + const lastLine = cwOutputEl.lastElementChild; + if (lastLine) { + lastLine.textContent += evt.text; + } + while (cwOutputEl.children.length > CW_MAX_LINES) { + cwOutputEl.removeChild(cwOutputEl.firstChild); + } + cwOutputEl.scrollTop = cwOutputEl.scrollHeight; + } + if (evt.text) { + const now = Date.now(); + if (evt.text === "\n") { + cwBarFlushCurrentLine(); + } else { + if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) { + cwBarFlushCurrentLine(); + const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now }; + } + cwBarCurrentLine.text += evt.text; + cwBarCurrentLine.lastMs = now; + if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm); + if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz)); + } + updateCwBar(); + } + if (cwSignalIndicator) { + cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off"; + } + if (!cwAutoInput || cwAutoInput.checked) { + if (cwWpmInput && Number.isFinite(Number(evt.wpm))) { + cwWpmInput.value = clampCwWpm(evt.wpm); + } + if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) { + cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange()); + } + } + if (cwTonePickerRaf != null) return; + cwTonePickerRaf = requestAnimationFrame(() => { + cwTonePickerRaf = null; + drawCwTonePicker(); + }); +}; +window.restoreCwHistory = function(events) { + if (!Array.isArray(events) || events.length === 0) return; + if (cwStatusEl) cwStatusEl.textContent = "Receiving"; + for (const evt of events) { + window.onServerCw(evt); + } +}; +window.refreshCwTonePicker = function refreshCwTonePicker() { + ensureCwToneCanvasResolution(); + drawCwTonePicker(); +}; +window.addEventListener("resize", () => { + if (ensureCwToneCanvasResolution()) drawCwTonePicker(); +}); +applyCwAutoUi(!!cwAutoInput?.checked); +updateCwBar(); +ensureCwToneCanvasResolution(); +drawCwTonePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js new file mode 100644 index 00000000..ef16b3af --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/decode-history-worker.js @@ -0,0 +1,167 @@ +"use strict"; +const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; +const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"]; +function decodeCborUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + if (additional === 24) { + if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 1; + return bytes[offset]; + } + if (additional === 25) { + if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 2; + return view.getUint16(offset); + } + if (additional === 26) { + if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 4; + return view.getUint32(offset); + } + if (additional === 27) { + if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getBigUint64(offset); + state.offset += 8; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; + } + throw new Error("Unsupported CBOR additional info"); +} +function decodeCborFloat16(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 * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 31) { + return fraction === 0 ? sign * Infinity : Number.NaN; + } + return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024); +} +function decodeCborItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + const major = initial >> 5; + const additional = initial & 31; + if (major === 0) return decodeCborUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); + if (major === 2) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return Array.from(chunk); + } + if (major === 3) { + const length = decodeCborUint(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; + return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeCborUint(view, bytes, state, additional); + const items = new Array(length); + for (let i = 0; i < length; i += 1) { + items[i] = decodeCborItem(view, bytes, state); + } + return items; + } + if (major === 5) { + const length = decodeCborUint(view, bytes, state, additional); + const value = {}; + for (let i = 0; i < length; i += 1) { + const key = decodeCborItem(view, bytes, state); + value[String(key)] = decodeCborItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeCborUint(view, bytes, state, additional); + return decodeCborItem(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; + if (additional === 25) { + if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + const bits = view.getUint16(state.offset); + state.offset += 2; + return decodeCborFloat16(bits); + } + if (additional === 26) { + if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat32(state.offset); + state.offset += 4; + return value; + } + if (additional === 27) { + if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat64(state.offset); + state.offset += 8; + return value; + } + } + throw new Error("Unsupported CBOR major type"); +} +function decodeCborPayload(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 = decodeCborItem(view, bytes, state); + if (state.offset !== bytes.length) { + throw new Error("Unexpected trailing bytes in decode history payload"); + } + return value; +} +async function fetchAndDecodeHistory(url, batchLimit) { + self.postMessage({ type: "status", phase: "fetching" }); + const resp = await fetch(url, { credentials: "same-origin" }); + if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`); + const payload = await resp.arrayBuffer(); + if (!payload || payload.byteLength === 0) { + self.postMessage({ type: "start", total: 0 }); + self.postMessage({ type: "done", total: 0 }); + return; + } + self.postMessage({ type: "status", phase: "decoding" }); + const history = decodeCborPayload(payload); + const total = HISTORY_GROUP_KEYS.reduce((sum, key) => { + const items = history && Array.isArray(history[key]) ? history[key] : []; + return sum + items.length; + }, 0); + self.postMessage({ type: "start", total }); + let processed = 0; + const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512)); + for (const kind of HISTORY_GROUP_KEYS) { + const items = history && Array.isArray(history[kind]) ? history[kind] : []; + if (items.length === 0) continue; + for (let index = 0; index < items.length; index += safeLimit) { + const messages = items.slice(index, index + safeLimit); + processed += messages.length; + self.postMessage({ + type: "group", + kind, + messages, + processed, + total + }); + } + } + self.postMessage({ type: "done", total }); +} +self.onmessage = (event) => { + const data = event?.data || {}; + if (data?.type !== "fetch-history") return; + fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((err) => { + self.postMessage({ + type: "error", + message: err && err.message ? err.message : String(err || "unknown worker failure") + }); + }); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js new file mode 100644 index 00000000..c682b6d6 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -0,0 +1,195 @@ +"use strict"; +function ft8RenderMessageFt2(message) { + if (typeof renderFt8Message === "function") return renderFt8Message(message); + if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); + return message; +} +const ft2Status = document.getElementById("ft2-status"); +const ft2PeriodEl = document.getElementById("ft2-period"); +const ft2MessagesEl = document.getElementById("ft2-messages"); +const ft2FilterInput = document.getElementById("ft2-filter"); +const FT2_PERIOD_MS = 3750; +const FT2_MAX_DOM_ROWS = 200; +let ft2FilterText = ""; +let ft2MessageHistory = []; +function currentFt2HistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneFt2MessageHistory() { + const cutoffMs = Date.now() - currentFt2HistoryRetentionMs(); + ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} +function scheduleFt2Ui(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleFt2HistoryRender() { + scheduleFt2Ui("ft2-history", () => renderFt2History()); +} +function normalizeFt2DisplayFreqHz(freqHz) { + const rawHz = Number(freqHz); + if (!Number.isFinite(rawHz)) return null; + const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; + if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) { + return baseHz + rawHz; + } + return rawHz; +} +function updateFt2PeriodTimer() { + if (!ft2PeriodEl) return; + const nowMs = Date.now(); + const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1e3; + ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; +} +updateFt2PeriodTimer(); +setInterval(updateFt2PeriodTimer, 250); +function renderFt2Row(msg) { + const row = document.createElement("div"); + row.className = "ft8-row"; + const rawMessage = (msg.message || "").toString(); + row.dataset.message = rawMessage.toUpperCase(); + row.dataset.decoder = "ft2"; + row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; + const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; + const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; + const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); + const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; + const renderedMessage = ft8RenderMessageFt2(rawMessage); + const tsMs = msg._tsMs ?? msg.ts_ms; + const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; + row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; + return row; +} +function renderFt2History() { + pruneFt2MessageHistory(); + if (!ft2MessagesEl) return; + const filter = ft2FilterText; + const fragment = document.createDocumentFragment(); + let rendered = 0; + for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) { + const msg = ft2MessageHistory[i]; + if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; + fragment.appendChild(renderFt2Row(msg)); + rendered++; + } + ft2MessagesEl.replaceChildren(fragment); +} +function addFt2Message(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); + ft2MessageHistory.unshift(msg); + pruneFt2MessageHistory(); + window.setFt8FamilyBarDecoder?.("ft2"); + window.updateFt8Bar?.(); + scheduleFt2HistoryRender(); +} +function normalizeServerFt2Message(msg) { + const raw = (msg.message || "").toString(); + const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; + const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []; + const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; + const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz); + return { + raw, + grids, + station, + rfHz, + locatorDetails, + history: { + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, + message: msg.message + } + }; +} +window.onServerFt2Batch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (ft2Status) ft2Status.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerFt2Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); + } + next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + ft2MessageHistory = normalized.concat(ft2MessageHistory); + pruneFt2MessageHistory(); + window.setFt8FamilyBarDecoder?.("ft2"); + window.updateFt8Bar?.(); + scheduleFt2HistoryRender(); +}; +window.restoreFt2History = function(messages) { + window.onServerFt2Batch(messages); +}; +window.pruneFt2HistoryView = function() { + pruneFt2MessageHistory(); + renderFt2History(); +}; +window.resetFt2HistoryView = function() { + if (ft2MessagesEl) ft2MessagesEl.innerHTML = ""; + ft2MessageHistory = []; + window.updateFt8Bar?.(); + renderFt2History(); +}; +function buildFt2BarFrames() { + const cutoffMs = Date.now() - 15 * 60 * 1e3; + const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); + const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); + if (messages.length === 0) { + return { count: 0, newestTsMs: 0, html: "" }; + } + let html = ""; + for (const msg of messages) { + const tsMs = msg._tsMs ?? msg.ts_ms; + const ts = tsMs ? `${fmtTime(tsMs)}` : ""; + const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; + const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; + const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz); + const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; + const detail = [snr, dt, rf].filter(Boolean).join(" Β· "); + const text = ft8RenderMessageFt2((msg.message || "").toString()); + html += `
${ts}${text}${detail ? ` Β· ${detail}` : ""}
`; + } + return { count: messages.length, newestTsMs, html }; +} +window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames); +if (ft2FilterInput) { + ft2FilterInput.addEventListener("input", () => { + ft2FilterText = ft2FilterInput.value.trim().toUpperCase(); + renderFt2History(); + }); +} +const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn"); +ft2DecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn); + await postPath("/toggle_ft2_decode"); + } catch (e) { + console.error("FT2 toggle failed", e); + } +}); +document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_ft2_decode"); + window.resetFt2HistoryView(); + } catch (e) { + console.error("FT2 history clear failed", e); + } +}); +window.onServerFt2 = function(msg) { + if (ft2Status) ft2Status.textContent = "Receiving"; + const next = normalizeServerFt2Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); + } + addFt2Message(next.history); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js new file mode 100644 index 00000000..4387020e --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -0,0 +1,195 @@ +"use strict"; +function ft8RenderMessage(message) { + if (typeof renderFt8Message === "function") return renderFt8Message(message); + if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message); + return message; +} +const ft4Status = document.getElementById("ft4-status"); +const ft4PeriodEl = document.getElementById("ft4-period"); +const ft4MessagesEl = document.getElementById("ft4-messages"); +const ft4FilterInput = document.getElementById("ft4-filter"); +const FT4_PERIOD_MS = 7500; +const FT4_MAX_DOM_ROWS = 200; +let ft4FilterText = ""; +let ft4MessageHistory = []; +function currentFt4HistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneFt4MessageHistory() { + const cutoffMs = Date.now() - currentFt4HistoryRetentionMs(); + ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} +function scheduleFt4Ui(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleFt4HistoryRender() { + scheduleFt4Ui("ft4-history", () => renderFt4History()); +} +function normalizeFt4DisplayFreqHz(freqHz) { + const rawHz = Number(freqHz); + if (!Number.isFinite(rawHz)) return null; + const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; + if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) { + return baseHz + rawHz; + } + return rawHz; +} +function updateFt4PeriodTimer() { + if (!ft4PeriodEl) return; + const nowMs = Date.now(); + const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1e3; + ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`; +} +updateFt4PeriodTimer(); +setInterval(updateFt4PeriodTimer, 250); +function renderFt4Row(msg) { + const row = document.createElement("div"); + row.className = "ft8-row"; + const rawMessage = (msg.message || "").toString(); + row.dataset.message = rawMessage.toUpperCase(); + row.dataset.decoder = "ft4"; + row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; + const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; + const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; + const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); + const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; + const renderedMessage = ft8RenderMessage(rawMessage); + const tsMs = msg._tsMs ?? msg.ts_ms; + const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--"; + row.innerHTML = `${timeStr}${snr}${dt}${freq}${renderedMessage}`; + return row; +} +function renderFt4History() { + pruneFt4MessageHistory(); + if (!ft4MessagesEl) return; + const filter = ft4FilterText; + const fragment = document.createDocumentFragment(); + let rendered = 0; + for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) { + const msg = ft4MessageHistory[i]; + if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue; + fragment.appendChild(renderFt4Row(msg)); + rendered++; + } + ft4MessagesEl.replaceChildren(fragment); +} +function addFt4Message(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); + ft4MessageHistory.unshift(msg); + pruneFt4MessageHistory(); + window.setFt8FamilyBarDecoder?.("ft4"); + window.updateFt8Bar?.(); + scheduleFt4HistoryRender(); +} +function normalizeServerFt4Message(msg) { + const raw = (msg.message || "").toString(); + const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : []; + const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []; + const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null; + const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz); + return { + raw, + grids, + station, + rfHz, + locatorDetails, + history: { + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, + message: msg.message + } + }; +} +window.onServerFt4Batch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (ft4Status) ft4Status.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerFt4Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); + } + next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + ft4MessageHistory = normalized.concat(ft4MessageHistory); + pruneFt4MessageHistory(); + window.setFt8FamilyBarDecoder?.("ft4"); + window.updateFt8Bar?.(); + scheduleFt4HistoryRender(); +}; +window.restoreFt4History = function(messages) { + window.onServerFt4Batch(messages); +}; +window.pruneFt4HistoryView = function() { + pruneFt4MessageHistory(); + renderFt4History(); +}; +window.resetFt4HistoryView = function() { + if (ft4MessagesEl) ft4MessagesEl.innerHTML = ""; + ft4MessageHistory = []; + window.updateFt8Bar?.(); + renderFt4History(); +}; +function buildFt4BarFrames() { + const cutoffMs = Date.now() - 15 * 60 * 1e3; + const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8); + const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0); + if (messages.length === 0) { + return { count: 0, newestTsMs: 0, html: "" }; + } + let html = ""; + for (const msg of messages) { + const tsMs = msg._tsMs ?? msg.ts_ms; + const ts = tsMs ? `${fmtTime(tsMs)}` : ""; + const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; + const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; + const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz); + const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null; + const detail = [snr, dt, rf].filter(Boolean).join(" Β· "); + const text = ft8RenderMessage((msg.message || "").toString()); + html += `
${ts}${text}${detail ? ` Β· ${detail}` : ""}
`; + } + return { count: messages.length, newestTsMs, html }; +} +window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames); +if (ft4FilterInput) { + ft4FilterInput.addEventListener("input", () => { + ft4FilterText = ft4FilterInput.value.trim().toUpperCase(); + renderFt4History(); + }); +} +const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn"); +ft4DecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn); + await postPath("/toggle_ft4_decode"); + } catch (e) { + console.error("FT4 toggle failed", e); + } +}); +document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_ft4_decode"); + window.resetFt4HistoryView(); + } catch (e) { + console.error("FT4 history clear failed", e); + } +}); +window.onServerFt4 = function(msg) { + if (ft4Status) ft4Status.textContent = "Receiving"; + const next = normalizeServerFt4Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails }); + } + addFt4Message(next.history); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js new file mode 100644 index 00000000..b00bae6e --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -0,0 +1,425 @@ +"use strict"; +const ft8Status = document.getElementById("ft8-status"); +const ft8PeriodEl = document.getElementById("ft8-period"); +const ft8MessagesEl = document.getElementById("ft8-messages"); +const ft8FilterInput = document.getElementById("ft8-filter"); +const ft8BarOverlay = document.getElementById("ft8-bar-overlay"); +const FT8_BAR_WINDOW_MS = 15 * 60 * 1e3; +const FT8_PERIOD_SECONDS = 15; +const FT8_MAX_DOM_ROWS = 200; +const FT8_BAR_DECODER_LABELS = { + ft8: "FT8", + ft4: "FT4", + ft2: "FT2" +}; +let ft8FilterText = ""; +let ft8MessageHistory = []; +let ft8BarActiveDecoder = "ft8"; +const ft8BarBuilders = {}; +const ft8BarDismissedAtMsByDecoder = { + ft8: 0, + ft4: 0, + ft2: 0 +}; +function currentFt8HistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneFt8MessageHistory() { + const cutoffMs = Date.now() - currentFt8HistoryRetentionMs(); + ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} +function scheduleFt8Ui(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleFt8HistoryRender() { + scheduleFt8Ui("ft8-history", () => renderFt8History()); +} +function scheduleFt8BarUpdate() { + scheduleFt8Ui("ft8-bar", () => updateFt8Bar()); +} +window.registerFt8FamilyBarRenderer = function(decoder, builder) { + if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return; + ft8BarBuilders[decoder] = builder; +}; +window.setFt8FamilyBarDecoder = function(decoder) { + if (!FT8_BAR_DECODER_LABELS[decoder]) return; + ft8BarActiveDecoder = decoder; + scheduleFt8BarUpdate(); +}; +function normalizeFt8DisplayFreqHz(freqHz) { + const rawHz = Number(freqHz); + if (!Number.isFinite(rawHz)) return null; + const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; + if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) { + return baseHz + rawHz; + } + return rawHz; +} +function fmtTime(tsMs) { + if (!tsMs) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} +function updateFt8PeriodTimer() { + if (!ft8PeriodEl) return; + const nowSec = Math.floor(Date.now() / 1e3); + const remaining = FT8_PERIOD_SECONDS - nowSec % FT8_PERIOD_SECONDS; + ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`; +} +updateFt8PeriodTimer(); +setInterval(updateFt8PeriodTimer, 500); +function renderFt8Row(msg) { + const row = document.createElement("div"); + row.className = "ft8-row"; + const rawMessage = (msg.message || "").toString(); + row.dataset.message = rawMessage.toUpperCase(); + row.dataset.decoder = "ft8"; + row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : ""; + const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; + const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; + const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); + const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--"; + const renderedMessage = renderFt8Message(rawMessage); + row.innerHTML = `${fmtTime(msg.ts_ms)}${snr}${dt}${freq}${renderedMessage}`; + applyFt8FilterToRow(row); + return row; +} +function renderFt8History() { + pruneFt8MessageHistory(); + if (!ft8MessagesEl) return; + const fragment = document.createDocumentFragment(); + const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS); + for (let i = 0; i < limit; i += 1) { + fragment.appendChild(renderFt8Row(ft8MessageHistory[i])); + } + ft8MessagesEl.replaceChildren(fragment); +} +function addFt8Message(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); + ft8MessageHistory.unshift(msg); + pruneFt8MessageHistory(); + ft8BarActiveDecoder = "ft8"; + scheduleFt8BarUpdate(); + scheduleFt8HistoryRender(); +} +function normalizeServerFt8Message(msg) { + const raw = (msg.message || "").toString(); + const locatorDetails = ft8ExtractLocatorDetails(raw); + const grids = locatorDetails.length > 0 ? locatorDetails.map((detail) => detail.grid) : ft8ExtractAllGrids(raw); + const station = ft8ExtractLikelyCallsign(raw); + const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz); + return { + raw, + grids, + station, + rfHz, + locatorDetails, + history: { + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz, + message: msg.message + } + }; +} +window.onServerFt8Batch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + ft8Status.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerFt8Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { + ...msg, + freq_hz: next.rfHz, + locator_details: next.locatorDetails + }); + } + next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + ft8MessageHistory = normalized.concat(ft8MessageHistory); + pruneFt8MessageHistory(); + ft8BarActiveDecoder = "ft8"; + scheduleFt8BarUpdate(); + scheduleFt8HistoryRender(); +}; +window.restoreFt8History = function(messages) { + window.onServerFt8Batch(messages); +}; +window.pruneFt8HistoryView = function() { + pruneFt8MessageHistory(); + updateFt8Bar(); + renderFt8History(); +}; +function ft8BarRfText(msg) { + const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz); + if (!Number.isFinite(displayFreqHz)) return null; + return `${displayFreqHz.toFixed(0)} Hz`; +} +function buildFt8BarFrames() { + const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS; + const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8); + const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0); + if (messages.length === 0) { + return { count: 0, newestTsMs: 0, html: "" }; + } + let html = ""; + for (const msg of messages) { + const ts = msg.ts_ms ? `${fmtTime(msg.ts_ms)}` : ""; + const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB"; + const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null; + const rf = ft8BarRfText(msg); + const detail = [snr, dt, rf].filter(Boolean).join(" Β· "); + const text = ft8EscapeHtml((msg.message || "").toString()); + html += `
${ts}${text}${detail ? ` Β· ${detail}` : ""}
`; + } + return { count: messages.length, newestTsMs, html }; +} +function updateFt8Bar() { + if (!ft8BarOverlay) return; + const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase(); + const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB"; + const decoder = ft8BarActiveDecoder; + const builder = ft8BarBuilders[decoder]; + const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8"; + const result = typeof builder === "function" ? builder() : null; + const newestTsMs = Number(result?.newestTsMs) || 0; + if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) { + ft8BarOverlay.style.display = "none"; + ft8BarOverlay.innerHTML = ""; + return; + } + ft8BarOverlay.innerHTML = `
${label}LiveLast 15 minutesClear
${result.html}`; + ft8BarOverlay.style.display = "flex"; +} +window.updateFt8Bar = updateFt8Bar; +window.clearFt8Bar = function() { + const decoder = ft8BarActiveDecoder; + if (decoder === "ft4") { + window.resetFt4HistoryView?.(); + return; + } + if (decoder === "ft2") { + window.resetFt2HistoryView?.(); + return; + } + window.resetFt8HistoryView?.(); +}; +window.closeFt8Bar = function() { + ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now(); + if (ft8BarOverlay) { + ft8BarOverlay.style.display = "none"; + ft8BarOverlay.innerHTML = ""; + } +}; +window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames); +function renderFt8Message(message) { + let out = ""; + let i = 0; + while (i < message.length) { + const ch = message[i]; + if (ft8IsAlphaNum(ch)) { + let j = i + 1; + while (j < message.length && ft8IsAlphaNum(message[j])) j++; + const token = message.slice(i, j); + const grid = token.toUpperCase(); + if (ft8IsMaidenheadGridToken(grid)) { + out += `${grid}`; + } else { + out += ft8EscapeHtml(token); + } + i = j; + } else { + out += ft8EscapeHtml(ch); + i += 1; + } + } + return out; +} +function ft8TokenizeMessage(message) { + return String(message || "").toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean); +} +function ft8ExtractAllGrids(message) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + let i = 0; + while (i < message.length) { + if (ft8IsAlphaNum(message[i])) { + let j = i + 1; + while (j < message.length && ft8IsAlphaNum(message[j])) j++; + const token = message.slice(i, j); + const grid = token.toUpperCase(); + if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) { + seen.add(grid); + out.push(grid); + } + i = j; + } else { + i += 1; + } + } + return out; +} +function ft8ExtractLocatorDetails(message) { + const tokens = ft8TokenizeMessage(message); + const grids = ft8ExtractAllGrids(String(message || "")); + if (tokens.length === 0 || grids.length === 0) return []; + const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token)); + const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length; + const callsigns = []; + for (let i = 0; i < limit; i += 1) { + if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]); + } + let source = null; + let target = null; + const head = tokens[0]; + if (callsigns.length > 0) { + if (head === "CQ" || head === "DE" || head === "QRZ") { + source = callsigns[0]; + } else if (callsigns.length >= 2) { + target = callsigns[0]; + source = callsigns[1]; + } else { + source = callsigns[0]; + } + } + return grids.map((grid) => ({ + grid, + station: source || null, + source: source || null, + target: target || null + })); +} +function ft8ExtractLikelyCallsign(message) { + const locatorDetails = ft8ExtractLocatorDetails(message); + if (locatorDetails.length > 0 && locatorDetails[0].station) { + return locatorDetails[0].station; + } + const tokens = ft8TokenizeMessage(message); + for (const token of tokens) { + if (ft8IsLikelyCallsignToken(token)) return token; + } + return null; +} +function ft8IsLikelyCallsignToken(token) { + if (!token) return false; + if (token.length < 3 || token.length > 12) return false; + if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false; + if (ft8IsMaidenheadGridToken(token)) return false; + return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token); +} +function ft8IsFarewellToken(token) { + const normalized = String(token || "").trim().toUpperCase(); + return normalized === "RR73" || normalized === "73" || normalized === "RR"; +} +function ft8IsMaidenheadGridToken(token) { + const normalized = String(token || "").trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized); +} +function ft8EscapeHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function ft8IsAlphaNum(ch) { + return /[A-Za-z0-9]/.test(ch); +} +function activateFt8HistoryLocator(targetEl) { + const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); + if (!locatorEl) return false; + const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); + if (!grid) return false; + if (typeof window.navigateToMapLocator === "function") { + window.navigateToMapLocator(grid, "ft8"); + } + return true; +} +function applyFt8FilterToRow(row) { + if (!ft8FilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.message || ""; + row.style.display = message.includes(ft8FilterText) ? "" : "none"; +} +function applyFt8FilterToAll() { + const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); + rows.forEach((row) => applyFt8FilterToRow(row)); +} +function updateFt8RowRf(row) { + const freqEl = row.querySelector(".ft8-freq"); + if (!freqEl) return; + const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN; + const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz); + if (Number.isFinite(displayFreqHz)) { + freqEl.textContent = displayFreqHz.toFixed(0); + } else { + freqEl.textContent = "--"; + } +} +window.updateFt8RfDisplay = function() { + const rows = ft8MessagesEl.querySelectorAll(".ft8-row"); + rows.forEach((row) => updateFt8RowRf(row)); + updateFt8Bar(); +}; +window.resetFt8HistoryView = function() { + ft8MessagesEl.innerHTML = ""; + ft8MessageHistory = []; + updateFt8Bar(); + renderFt8History(); + if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8"); +}; +if (ft8FilterInput) { + ft8FilterInput.addEventListener("input", () => { + ft8FilterText = ft8FilterInput.value.trim().toUpperCase(); + renderFt8History(); + }); +} +if (ft8MessagesEl) { + ft8MessagesEl.addEventListener("click", (event) => { + if (!activateFt8HistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); + ft8MessagesEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (!activateFt8HistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); +} +const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn"); +ft8DecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn); + await postPath("/toggle_ft8_decode"); + } catch (e) { + console.error("FT8 toggle failed", e); + } +}); +document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_ft8_decode"); + window.resetFt8HistoryView(); + } catch (e) { + console.error("FT8 history clear failed", e); + } +}); +window.onServerFt8 = function(msg) { + ft8Status.textContent = "Receiving"; + const next = normalizeServerFt8Message(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "ft8", next.station, { + ...msg, + freq_hz: next.rfHz, + locator_details: next.locatorDetails + }); + } + addFt8Message(next.history); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js new file mode 100644 index 00000000..22f7a956 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -0,0 +1,360 @@ +"use strict"; +const hfAprsStatus = document.getElementById("hf-aprs-status"); +const hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); +const hfAprsFilterInput = document.getElementById("hf-aprs-filter"); +const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn"); +const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn"); +const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn"); +const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count"); +const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count"); +const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen"); +let hfAprsFilterText = ""; +let hfAprsPacketHistory = []; +let hfAprsOnlyPos = false; +let hfAprsHideCrc = false; +let hfAprsCollapseDup = false; +let hfAprsTypeFilter = "all"; +function currentHfAprsHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneHfAprsPacketHistory() { + const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs(); + hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs); +} +function scheduleHfAprsHistoryRender() { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory()); + return; + } + renderHfAprsHistory(); +} +function hfAprsPacketCategory(pkt) { + const type = String(pkt.type || "").toLowerCase(); + const info = String(pkt.info || "").toLowerCase(); + if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position"; + if (type.includes("message") || info.startsWith(":")) return "message"; + if (type.includes("weather") || info.startsWith("_")) return "weather"; + if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; + return "other"; +} +function hfAprsCategoryLabel(category) { + switch (category) { + case "position": + return "Position"; + case "message": + return "Message"; + case "weather": + return "Weather"; + case "telemetry": + return "Telemetry"; + default: + return "Other"; + } +} +function hfAprsAgeText(tsMs) { + if (!Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function hfAprsDistanceText(pkt) { + if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return ""; + const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon); + if (!Number.isFinite(distKm)) return ""; + if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`; + return `${distKm.toFixed(1)} km from TRX`; +} +function hfAprsPacketSignature(pkt) { + return [ + pkt.srcCall || "", + pkt.destCall || "", + pkt.path || "", + pkt.info || "", + pkt.type || "", + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "" + ].join("|"); +} +function hfAprsHexBytes(bytes) { + if (!Array.isArray(bytes) || bytes.length === 0) return "--"; + return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" "); +} +function hfAprsFilterMatch(pkt) { + if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false; + if (hfAprsHideCrc && !pkt.crcOk) return false; + if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false; + if (!hfAprsFilterText) return true; + const haystack = [ + pkt.srcCall, + pkt.destCall, + pkt.path, + pkt.info, + pkt.type, + pkt.lat != null ? pkt.lat.toFixed(4) : "", + pkt.lon != null ? pkt.lon.toFixed(4) : "", + hfAprsPacketCategory(pkt) + ].filter(Boolean).join(" ").toUpperCase(); + return haystack.includes(hfAprsFilterText); +} +function hfAprsVisiblePackets() { + const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory; + return packets.filter(hfAprsFilterMatch); +} +function collapseHfAprsDuplicates(packets) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const pkt of packets) { + const key = hfAprsPacketSignature(pkt); + if (seen.has(key)) continue; + seen.add(key); + out.push(pkt); + } + return out; +} +function updateHfAprsSummary() { + const visible = hfAprsVisiblePackets(); + if (hfAprsTotalCountEl) { + hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`; + } + if (hfAprsVisibleCountEl) { + hfAprsVisibleCountEl.textContent = `${visible.length} shown`; + } + if (hfAprsLatestSeenEl) { + const latest = hfAprsPacketHistory[0]; + if (!latest) { + hfAprsLatestSeenEl.textContent = "No packets yet"; + } else { + hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`; + } + } +} +function updateHfAprsChipState() { + document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => { + btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`); + }); + hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos); + hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc); + hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); +} +function renderHfAprsInfo(pkt) { + const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null; + if (bytes && bytes.length > 0) { + let out2 = ""; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (b >= 32 && b <= 126) { + const ch = String.fromCharCode(b); + if (ch === "<") out2 += "<"; + else if (ch === ">") out2 += ">"; + else if (ch === "&") out2 += "&"; + else if (ch === '"') out2 += """; + else out2 += ch; + } else { + const hex = b.toString(16).toUpperCase().padStart(2, "0"); + out2 += `0x${hex}`; + } + } + return out2; + } + const str = pkt.info || ""; + let out = ""; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code >= 32 && code <= 126) { + const ch = str[i]; + if (ch === "<") out += "<"; + else if (ch === ">") out += ">"; + else if (ch === "&") out += "&"; + else if (ch === '"') out += """; + else out += ch; + } else { + const hex = code.toString(16).toUpperCase().padStart(2, "0"); + out += `0x${hex}`; + } + } + return out; +} +function renderHfAprsRow(pkt, isFresh) { + const row = document.createElement("div"); + row.className = "aprs-packet"; + if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); + if (isFresh) row.classList.add("aprs-packet-new"); + const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const age = hfAprsAgeText(pkt._tsMs); + const category = hfAprsPacketCategory(pkt); + const categoryLabel = hfAprsCategoryLabel(category); + const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; + const pathBadge = pkt.path ? `${escapeMapHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + const hfBadge = 'HF'; + let symbolHtml = ""; + if (pkt.symbolTable && pkt.symbolCode) { + const sheet = pkt.symbolTable === "/" ? 0 : 1; + const code = pkt.symbolCode.charCodeAt(0) - 33; + const col = code % 16; + const row2 = Math.floor(code / 16); + const bgX = -(col * 24); + const bgY = -(row2 * 24); + symbolHtml = ``; + } + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = hfAprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeMapHtml(pkt.srcCall)}>${escapeMapHtml(pkt.destCall || "")}${escapeMapHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeMapHtml(age)}` + (distance ? `${escapeMapHtml(distance)}` : "") + `${escapeMapHtml(pkt.type || "--")}
${renderHfAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeMapHtml(pkt.srcCall || "--")}Destination${escapeMapHtml(pkt.destCall || "--")}Type${escapeMapHtml(pkt.type || "--")}Path${escapeMapHtml(pkt.path || "--")}Age${escapeMapHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeMapHtml(pkt.info || "--")}Info Bytes${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}
`; + row.querySelectorAll("[data-aprs-map]").forEach((el) => { + el.addEventListener("click", (evt) => { + evt.preventDefault(); + const raw = String(el.dataset.aprsMap || ""); + const [lat, lon] = raw.split(",").map(Number); + if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) { + window.navigateToAprsMap(lat, lon); + } + }); + }); + const copyBtn = row.querySelector("[data-aprs-copy]"); + if (copyBtn) { + copyBtn.addEventListener("click", async () => { + const raw = String(copyBtn.dataset.aprsCopy || ""); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(raw); + showHint("Coordinates copied", 1200); + } + } catch (_e) { + showHint("Copy failed", 1500); + } + }); + } + return row; +} +function renderHfAprsHistory() { + pruneHfAprsPacketHistory(); + if (!hfAprsPacketsEl) { + updateHfAprsSummary(); + updateHfAprsChipState(); + return; + } + const visible = hfAprsVisiblePackets(); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < visible.length; i++) { + fragment.appendChild(renderHfAprsRow(visible[i], i === 0)); + } + hfAprsPacketsEl.replaceChildren(fragment); + updateHfAprsSummary(); + updateHfAprsChipState(); +} +window.resetHfAprsHistoryView = function() { + if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = ""; + hfAprsPacketHistory = []; + renderHfAprsHistory(); +}; +window.pruneHfAprsHistoryView = function() { + pruneHfAprsPacketHistory(); + renderHfAprsHistory(); +}; +function addHfAprsPacket(pkt) { + const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); + pkt._tsMs = tsMs; + pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + hfAprsPacketHistory.unshift(pkt); + pruneHfAprsPacketHistory(); + scheduleHfAprsHistoryRender(); +} +function normalizeServerHfAprsPacket(pkt) { + return { + rig_id: pkt.rig_id || null, + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + srcCall: pkt.src_call, + destCall: pkt.dest_call, + path: pkt.path, + info: pkt.info, + info_bytes: pkt.info_bytes, + type: pkt.packet_type, + crcOk: pkt.crc_ok, + ts_ms: pkt.ts_ms, + lat: pkt.lat, + lon: pkt.lon, + symbolTable: pkt.symbol_table, + symbolCode: pkt.symbol_code + }; +} +window.onServerHfAprsBatch = function(packets) { + if (!Array.isArray(packets) || packets.length === 0) return; + if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; + const normalized = []; + for (const pkt of packets) { + const next = normalizeServerHfAprsPacket(pkt); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + normalized.push(next); + } + normalized.reverse(); + hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory); + pruneHfAprsPacketHistory(); + scheduleHfAprsHistoryRender(); +}; +window.restoreHfAprsHistory = function(packets) { + window.onServerHfAprsBatch(packets); +}; +const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn"); +hfAprsDecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn); + await postPath("/toggle_hf_aprs_decode"); + } catch (e) { + console.error("HF APRS toggle failed", e); + } +}); +document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_hf_aprs_decode"); + window.resetHfAprsHistoryView(); + } catch (e) { + console.error("HF APRS history clear failed", e); + } +}); +if (hfAprsOnlyPosBtn) { + hfAprsOnlyPosBtn.addEventListener("click", () => { + hfAprsOnlyPos = !hfAprsOnlyPos; + renderHfAprsHistory(); + }); +} +if (hfAprsHideCrcBtn) { + hfAprsHideCrcBtn.addEventListener("click", () => { + hfAprsHideCrc = !hfAprsHideCrc; + renderHfAprsHistory(); + }); +} +if (hfAprsCollapseDupBtn) { + hfAprsCollapseDupBtn.addEventListener("click", () => { + hfAprsCollapseDup = !hfAprsCollapseDup; + renderHfAprsHistory(); + }); +} +["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => { + const btn = document.getElementById(`hf-aprs-type-${type}`); + if (!btn) return; + btn.addEventListener("click", () => { + hfAprsTypeFilter = type; + renderHfAprsHistory(); + }); +}); +if (hfAprsFilterInput) { + hfAprsFilterInput.addEventListener("input", () => { + hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase(); + renderHfAprsHistory(); + }); +} +window.onServerHfAprs = function(pkt) { + if (hfAprsStatus) hfAprsStatus.textContent = "Receiving"; + addHfAprsPacket(normalizeServerHfAprsPacket(pkt)); +}; +renderHfAprsHistory(); +if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs"); 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 new file mode 100644 index 00000000..b46ec68e --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js @@ -0,0 +1,94 @@ +"use strict"; +(function() { + if (typeof L === "undefined") return; + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + function finiteAngle(value) { + if (!Number.isFinite(value)) return null; + const normalized = (Number(value) % 360 + 360) % 360; + return normalized; + } + function svgColor(value, fallback) { + const text = String(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) ? Number(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 ``; + } + L.TrxAisTrackSymbol = L.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 = L.Util.extend({}, this.options, options || {}); + merged.icon = L.divIcon({ + className: "trx-ais-track-symbol-icon", + html: "", + iconSize: [merged.size, merged.size], + iconAnchor: [merged.size / 2, merged.size / 2] + }); + L.Marker.prototype.initialize.call(this, latlng, merged); + }, + onAdd: function(map) { + L.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; + } + L.Marker.prototype.onRemove.call(this, map); + }, + setAisState: function(next) { + if (next && typeof next === "object") { + 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) ? Number(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`; + } + }); + L.trxAisTrackSymbol = function(latlng, options) { + return new L.TrxAisTrackSymbol(latlng, options); + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js new file mode 100644 index 00000000..8246a1cd --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -0,0 +1,3119 @@ +"use strict"; +(function() { + "use strict"; + const { state: T, core: C, modules } = window.trx; + const { + saveSetting, + loadSetting, + showHint, + escapeMapHtml, + formatFreq, + formatFreqForHumans, + postPath, + scheduleUiFrameJob, + navigateToTab, + rigBadgeColor, + formatUptime, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo, + currentDecodeHistoryRetentionMs, + formatWavelength, + bookmarkDistanceText, + buildBookmarkTooltipText, + nearestBookmarkForHz + } = C; + function updateMapRigFilter() { + const el = document.getElementById("map-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); + } + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + mapRigFilter = ""; + } + updateStatsRigFilter(); + } + let aprsMap = null; + let aprsMapBaseLayer = null; + let aprsMapReceiverMarker = null; + let aprsMapReceiverMarkers = {}; + let aprsRadioPaths = []; + let selectedLocatorMarker = null; + let selectedLocatorPulseRaf = null; + let mapFullscreenListenerBound = false; + let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; + let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; + let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; + const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; + const MAP_QSO_SUMMARY_LIMIT = 5; + const stationMarkers = /* @__PURE__ */ new Map(); + const locatorMarkers = /* @__PURE__ */ new Map(); + const decodeContactPaths = /* @__PURE__ */ new Map(); + let selectedMapQsoKey = null; + const mapMarkers = /* @__PURE__ */ new Set(); + const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; + const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; + let mapSearchFilter = ""; + let mapRigFilter = ""; + let mapHistoryPruneTimer = null; + let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( + Number(loadSetting("mapHistoryLimitMinutes", 1440)) + ); + const APRS_TRACK_MAX_POINTS = 64; + const AIS_TRACK_MAX_POINTS = 64; + const aisMarkers = /* @__PURE__ */ new Map(); + const vdesMarkers = /* @__PURE__ */ new Map(); + let selectedAprsTrackCall = null; + let selectedAisTrackMmsi = null; + const HAM_BANDS = [ + { label: "2200m", meters: 2200 }, + { label: "630m", meters: 630 }, + { label: "160m", meters: 160 }, + { label: "80m", meters: 80 }, + { label: "60m", meters: 60 }, + { label: "40m", meters: 40 }, + { label: "30m", meters: 30 }, + { label: "20m", meters: 20 }, + { label: "17m", meters: 17 }, + { label: "15m", meters: 15 }, + { label: "12m", meters: 12 }, + { label: "10m", meters: 10 }, + { label: "6m", meters: 6 }, + { label: "4m", meters: 4 }, + { label: "3m", meters: 3 }, + { label: "2m", meters: 2 }, + { label: "1m", meters: 1 }, + { label: "70cm", meters: 0.7 }, + { label: "23cm", meters: 0.23 }, + { label: "13cm", meters: 0.13 }, + { label: "9cm", meters: 0.09 }, + { label: "6cm", meters: 0.06 }, + { label: "3cm", meters: 0.03 } + ].map((band) => ({ + ...band, + nominalHz: 299792458 / band.meters + })); + function normalizeLocatorFreqHz(hz) { + if (!Number.isFinite(hz) || hz <= 0) return null; + if (hz >= 1e5) return hz; + const baseHz = Number(window.ft8BaseHz); + if (Number.isFinite(baseHz) && baseHz > 0) { + return baseHz + hz; + } + return hz; + } + function normalizeMapHistoryLimitMinutes(value) { + const minutes = Math.round(Number(value)); + return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; + } + function mapHistoryCutoffMs() { + return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; + } + function trimTrackHistory(history, cutoffMs, maxPoints) { + const list = Array.isArray(history) ? history : []; + const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); + if (trimmed.length > maxPoints) { + trimmed.splice(0, trimmed.length - maxPoints); + } + return trimmed; + } + function refreshAprsTrack(call, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; + } + return; + } + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; + } + const track = L.polyline(entry.trackPoints, { + color: "#f0be4d", + weight: 2, + opacity: 0.72, + lineCap: "round", + lineJoin: "round", + interactive: false + }); + track.__trxType = "aprs"; + track._aprsCall = call; + entry.track = track; + } + function refreshAisTrack(mmsi, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; + } + return; + } + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; + } + const track = L.polyline(entry.trackPoints, { + color: getAisAccentColor(), + weight: 2, + opacity: 0.68, + lineCap: "round", + lineJoin: "round", + interactive: false, + dashArray: "5 4" + }); + track.__trxType = "ais"; + track._aisMmsi = mmsi; + entry.track = track; + } + function removeMapMarker(marker) { + if (!marker) return; + if (marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); + mapMarkers.delete(marker); + } + function setRetainedMapMarkerVisible(marker, visible) { + if (!marker) return; + marker.__trxHistoryVisible = visible !== false; + if (!visible) { + if (marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); + } + } + function ensureAprsMarker(call, entry) { + if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; + _aprsAddMarkerToMap(call, entry); + } + function ensureAisMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); + marker.__trxType = "ais"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aisMmsi = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureVdesMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { + radius: 5, + color: "#5c394f", + fillColor: "#c46392", + fillOpacity: 0.82 + }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); + marker.__trxType = "vdes"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._vdesKey = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureDecodeLocatorMarker(entry) { + if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) return; + const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); + const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); + const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); + marker.__trxType = entry.sourceType; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + sendLocatorOverlayToBack(marker); + assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); + entry.marker = marker; + mapMarkers.add(marker); + } + function pruneAprsEntry(call, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const pktTsMs = Number(entry?.pkt?._tsMs); + const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAprsTrack(call, entry); + } else { + C.markDecodeMapSyncPending(); + } + if (!visible) { + if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { + selectedAprsTrackCall = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; + } + if (!canRenderMap) return true; + ensureAprsMarker(call, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker) { + entry.marker.setLatLng([entry.lat, entry.lon]); + entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); + } + return true; + } + function pruneAisEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const msgTsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAisTrack(key, entry); + } else { + C.markDecodeMapSyncPending(); + } + if (!visible) { + if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { + selectedAisTrackMmsi = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; + } + if (!canRenderMap) return true; + ensureAisMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker) { + updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); + } + return true; + } + function pruneLocatorEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; + if (!(entry.allStationDetails instanceof Map)) { + entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); + } + const nextDetails = /* @__PURE__ */ new Map(); + for (const [detailKey, detail] of entry.allStationDetails.entries()) { + const tsMs = Number(detail?.ts_ms); + if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { + nextDetails.set(detailKey, detail); + } + } + entry.visibleInHistoryWindow = nextDetails.size > 0; + if (nextDetails.size === 0) { + entry.stationDetails = /* @__PURE__ */ new Map(); + entry.stations = /* @__PURE__ */ new Set(); + entry.bandMeta = /* @__PURE__ */ new Map(); + if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); + else C.markDecodeMapSyncPending(); + return false; + } + const nextStations = /* @__PURE__ */ new Set(); + for (const detail of nextDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (source) nextStations.add(source); + } + entry.stationDetails = nextDetails; + entry.stations = nextStations; + entry.bandMeta = collectBandMeta( + Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz)) + ); + const count = Math.max(nextDetails.size, nextStations.size || 0, 1); + if (!canRenderMap) { + C.markDecodeMapSyncPending(); + return true; + } + ensureDecodeLocatorMarker(entry); + setRetainedMapMarkerVisible(entry.marker, true); + if (entry.marker) { + entry.marker.setStyle(locatorStyleForEntry(entry, count)); + entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + } + return true; + } + function pruneMapHistory() { + const cutoffMs = mapHistoryCutoffMs(); + for (const [call, entry] of stationMarkers.entries()) { + pruneAprsEntry(call, entry, cutoffMs); + } + for (const [key, entry] of aisMarkers.entries()) { + pruneAisEntry(key, entry, cutoffMs); + } + for (const [key, entry] of vdesMarkers.entries()) { + const tsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + if (!visible) { + setRetainedMapMarkerVisible(entry?.marker, false); + continue; + } + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker) { + entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); + entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); + } + } + for (const [key, entry] of locatorMarkers.entries()) { + pruneLocatorEntry(key, entry, cutoffMs); + } + if (!aprsMap || T.decodeHistoryReplayActive) { + C.markDecodeMapSyncPending(); + return; + } + rebuildDecodeContactPaths(); + rebuildMapLocatorFilters(); + applyMapFilter(); + } + function locatorSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + if (type === "wspr") return "WSPR"; + if (type === "ft4") return "FT4"; + if (type === "ft2") return "FT2"; + return "FT8"; + } + function mapSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + return String(type || "").toUpperCase(); + } + function locatorFilterColor(type) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const sat = lightTheme ? 66 : 76; + const light = lightTheme ? 42 : 56; + const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function mapSourceColor(type) { + if (type === "ais") return "#38bdf8"; + if (type === "vdes") return "#a78bfa"; + if (type === "sat") return "#f59e0b"; + if (type === "aprs") return "#00d17f"; + return locatorFilterColor(type); + } + function bandForHz(hz) { + const rfHz = normalizeLocatorFreqHz(hz); + if (!Number.isFinite(rfHz) || rfHz <= 0) return null; + let bestBand = null; + let bestDistance = Infinity; + for (const band of HAM_BANDS) { + const distance = Math.abs(Math.log(rfHz / band.nominalHz)); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = band; + } + } + return bestBand; + } + function collectBandMeta(freqs) { + const out = /* @__PURE__ */ new Map(); + if (!Array.isArray(freqs)) return out; + for (const hz of freqs) { + const band = bandForHz(hz); + if (band && !out.has(band.label)) out.set(band.label, band.nominalHz); + } + return out; + } + function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { + if (!marker) return; + const safeMeta = bandMeta instanceof Map ? bandMeta : /* @__PURE__ */ new Map(); + marker._locatorFilterMeta = { + sourceType, + bands: new Set(safeMeta.keys()), + bandMeta: new Map(safeMeta) + }; + } + function parseMapColor(input) { + const value = String(input || "").trim(); + if (!value) return null; + const hex = value.match(/^#([0-9a-f]{3,8})$/i); + if (hex) { + const raw = hex[1]; + if (raw.length === 3 || raw.length === 4) { + const chars = raw.split(""); + return { + r: parseInt(chars[0] + chars[0], 16), + g: parseInt(chars[1] + chars[1], 16), + b: parseInt(chars[2] + chars[2], 16) + }; + } + if (raw.length === 6 || raw.length === 8) { + return { + r: parseInt(raw.slice(0, 2), 16), + g: parseInt(raw.slice(2, 4), 16), + b: parseInt(raw.slice(4, 6), 16) + }; + } + } + const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i); + if (rgb) { + return { + r: Math.max(0, Math.min(255, Number(rgb[1]))), + g: Math.max(0, Math.min(255, Number(rgb[2]))), + b: Math.max(0, Math.min(255, Number(rgb[3]))) + }; + } + return null; + } + function rgbToHsl(rgb) { + if (!rgb) return null; + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) { + return { h: 0, s: 0, l: l * 100 }; + } + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + break; + } + return { h: h * 60 % 360, s: s * 100, l: l * 100 }; + } + function wrapHue(hue) { + const value = Number(hue) || 0; + return (value % 360 + 360) % 360; + } + function paletteHue(input, fallback) { + const hsl = rgbToHsl(parseMapColor(input)); + return Number.isFinite(hsl?.h) ? hsl.h : fallback; + } + function locatorThemeHues() { + const pal = C.canvasPalette(); + const baseHue = paletteHue(pal?.spectrumLine, 145); + const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); + const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); + return { + bookmark: wrapHue(baseHue), + ft8: wrapHue(peakHue), + ft4: wrapHue(peakHue + 30), + ft2: wrapHue(peakHue + 60), + wspr: wrapHue((waveHue + baseHue) / 2), + bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) + }; + } + function locatorBandIndex(label) { + const idx = HAM_BANDS.findIndex((band) => band.label === label); + return idx >= 0 ? idx : 0; + } + function locatorBandChipColor(label) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + const sat = lightTheme ? 68 : 78; + const light = lightTheme ? 44 : 58; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function locatorBandLabelForEntry(entry) { + const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : /* @__PURE__ */ new Map(); + if (meta.size === 0) return null; + if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { + for (const label of mapLocatorFilter.bands) { + if (meta.has(label)) return label; + } + } + let bestLabel = null; + let bestHz = -Infinity; + for (const [label, hz] of meta.entries()) { + const value = Number.isFinite(hz) ? Number(hz) : 0; + if (value > bestHz) { + bestHz = value; + bestLabel = label; + } + } + return bestLabel; + } + function locatorHueForEntry(entry) { + const hues = locatorThemeHues(); + if (mapLocatorFilter.phase === "band") { + const label = locatorBandLabelForEntry(entry); + if (label) { + return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + } + } + if (entry?.sourceType === "bookmark") return hues.bookmark; + if (entry?.sourceType === "wspr") return hues.wspr; + if (entry?.sourceType === "ft4") return hues.ft4; + if (entry?.sourceType === "ft2") return hues.ft2; + return hues.ft8; + } + function locatorStyleForEntry(entry, count) { + const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); + const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); + const hue = locatorHueForEntry(entry); + const lightTheme = C.currentTheme() === "light"; + const strokeSat = lightTheme ? 62 : 74; + const fillSat = lightTheme ? 68 : 78; + const strokeLight = lightTheme ? 40 : 56; + const fillLight = lightTheme ? 60 : 42; + return { + color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`, + opacity: 0.42 + intensity * 0.5, + weight: 1 + intensity * 1.2, + fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, + fillOpacity: 0.16 + intensity * 0.34 + }; + } + function locatorEntryCount(entry) { + if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); + if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); + if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); + return 1; + } + function locatorEntryForMarker(marker) { + if (!marker) return null; + for (const entry of locatorMarkers.values()) { + if (entry?.marker === marker) return entry; + } + return null; + } + function syncLocatorMarkerStyles() { + for (const entry of locatorMarkers.values()) { + if (!entry?.marker) continue; + entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry))); + } + for (const entry of decodeContactPaths.values()) { + if (!entry?.line) continue; + const color = decodeContactPathColor(entry); + entry.line.setStyle({ color, opacity: 0.78 }); + } + } + function stopSelectedLocatorPulse() { + if (selectedLocatorPulseRaf != null) { + cancelAnimationFrame(selectedLocatorPulseRaf); + selectedLocatorPulseRaf = null; + } + } + function startSelectedLocatorPulse(marker) { + stopSelectedLocatorPulse(); + if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; + const tick = (ts) => { + if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { + return; + } + const entry = locatorEntryForMarker(marker); + const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); + const phase = ts % 1600 / 1600; + const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; + marker.setStyle({ + ...base, + opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), + weight: (base.weight || 1.8) + 1.8 * wave + }); + selectedLocatorPulseRaf = requestAnimationFrame(tick); + }; + selectedLocatorPulseRaf = requestAnimationFrame(tick); + } + function clearMapRadioPath() { + for (const p of aprsRadioPaths) p.remove(); + aprsRadioPaths = []; + } + function clearDecodeContactPathRender(entry) { + if (!entry) return; + if (entry.line) { + entry.line.remove(); + entry.line = null; + } + if (entry.labelMarker) { + entry.labelMarker.remove(); + entry.labelMarker = null; + } + } + function clearDecodeContactPaths() { + for (const entry of decodeContactPaths.values()) { + clearDecodeContactPathRender(entry); + } + decodeContactPaths.clear(); + updateMapPathsAnimationClass(); + } + const MAP_PATHS_STATIC_THRESHOLD = 20; + function updateMapPathsAnimationClass() { + const mapEl = document.getElementById("aprs-map"); + if (!mapEl) return; + mapEl.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); + } + function formatDecodeContactDistance(distanceKm) { + const text = formatDistanceKm(distanceKm); + return text || "--"; + } + function decodeLocatorPathVisibility(grid) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!normalizedGrid || !aprsMap) return false; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.grid !== normalizedGrid) continue; + if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; + if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; + } + return false; + } + function midpointLatLon(a, b) { + if (!a || !b) return null; + if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { + return null; + } + return { + lat: (a.lat + b.lat) / 2, + lon: (a.lon + b.lon) / 2 + }; + } + function decodeContactPathColor(entry) { + if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); + const srcEntry = locatorMarkers.get(entry?.sourceGrid); + if (srcEntry) { + const label = locatorBandLabelForEntry(srcEntry); + if (label) return locatorBandChipColor(label); + return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color; + } + return locatorFilterColor("ft8"); + } + function ensureDecodeContactPathRendered(entry) { + if (!entry || !aprsMap) return; + const linePoints = [ + [entry.from.lat, entry.from.lon], + [entry.to.lat, entry.to.lon] + ]; + const color = decodeContactPathColor(entry); + if (!entry.line) { + entry.line = L.polyline(linePoints, { + color, + opacity: 0.78, + className: "decode-contact-path", + weight: 2.8, + interactive: false + }).addTo(aprsMap); + } else { + entry.line.setLatLngs(linePoints); + entry.line.setStyle({ color, opacity: 0.78 }); + if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); + } + const mid = midpointLatLon(entry.from, entry.to); + if (!mid) return; + const title = `${entry.source} ↔ ${entry.target} Β· ${entry.distanceText}`; + const icon = L.divIcon({ + className: "decode-contact-distance-label", + html: `${escapeMapHtml(entry.distanceText)}` + }); + if (!entry.labelMarker) { + entry.labelMarker = L.marker([mid.lat, mid.lon], { + icon, + interactive: false, + keyboard: false, + zIndexOffset: 900 + }).addTo(aprsMap); + } else { + entry.labelMarker.setLatLng([mid.lat, mid.lon]); + entry.labelMarker.setIcon(icon); + if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); + } + if (typeof entry.line.bringToBack === "function") entry.line.bringToBack(); + } + function decodeContactPathMatchesCurrentMap(entry) { + return decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); + } + function decodeContactPathRenderVisible(entry) { + return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); + } + function syncDecodeContactPathVisibility() { + if (selectedMapQsoKey) { + const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); + if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) { + selectedMapQsoKey = null; + } + } + for (const entry of decodeContactPaths.values()) { + const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); + if (!visible) { + clearDecodeContactPathRender(entry); + continue; + } + ensureDecodeContactPathRendered(entry); + } + scheduleStatsRender(); + updateMapPathsAnimationClass(); + } + function _resolveReceiverLocations(rigIds) { + const seen = /* @__PURE__ */ new Set(); + const locations = []; + if (rigIds && rigIds.size) { + for (const rid of rigIds) { + const rig = T.serverRigs.find((r) => r.remote === rid); + if (rig && rig.latitude != null && rig.longitude != null) { + const key = _receiverLocationKey(rig.latitude, rig.longitude); + if (!seen.has(key)) { + seen.add(key); + locations.push([rig.latitude, rig.longitude]); + } + } + } + } + if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { + locations.push([T.serverLat, T.serverLon]); + } + return locations; + } + function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { + clearMapRadioPath(); + if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { + return; + } + const sources = _resolveReceiverLocations(rigIds); + for (const src of sources) { + aprsRadioPaths.push( + L.polyline( + [src, [lat, lon]], + { color, opacity: 0.85, weight: 2, interactive: false, className } + ).addTo(aprsMap) + ); + } + } + function locatorMarkerCenter(marker) { + if (!marker) return null; + if (typeof marker.getBounds === "function") { + const bounds = marker.getBounds(); + if (bounds && typeof bounds.getCenter === "function") { + const center = bounds.getCenter(); + if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { + return { lat: center.lat, lon: center.lng }; + } + } + } + if (typeof marker.getLatLng === "function") { + const ll = marker.getLatLng(); + if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { + return { lat: ll.lat, lon: ll.lng }; + } + } + return null; + } + function setLocatorMarkerHighlight(marker, enabled) { + const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; + if (!element) return; + element.classList.toggle("trx-locator-selected", !!enabled); + } + function setSelectedLocatorMarker(marker) { + if (selectedLocatorMarker && selectedLocatorMarker !== marker) { + setLocatorMarkerHighlight(selectedLocatorMarker, false); + const prevEntry = locatorEntryForMarker(selectedLocatorMarker); + if (prevEntry?.marker) { + prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry))); + } + } + stopSelectedLocatorPulse(); + selectedLocatorMarker = marker || null; + if (selectedLocatorMarker) { + setLocatorMarkerHighlight(selectedLocatorMarker, true); + startSelectedLocatorPulse(selectedLocatorMarker); + } + } + function isLocatorOverlay(marker) { + const type = marker?.__trxType; + return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; + } + function sendLocatorOverlayToBack(marker) { + if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; + marker.bringToBack(); + } + function renderMapLocatorChipRow(container, items, selectedSet, kind) { + if (!container) return; + container.replaceChildren(); + if (!Array.isArray(items) || items.length === 0) { + container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`; + return; + } + let helperText = ""; + const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; + const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); + if (kind === "source") { + if (noneSelected) { + helperText = "All sources visible β€” click to filter"; + } + } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { + helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; + } + for (const item of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-chip"; + const isActive = kind === "source" ? !!mapFilter[item.key] : selectedSet.has(item.key); + if (kind === "source" && noneSelected) { + btn.classList.add("is-default"); + } else if (!isActive) { + btn.classList.add("is-inactive"); + } + btn.dataset.filterKind = kind; + btn.dataset.filterKey = item.key; + btn.style.setProperty("--chip-color", item.color); + btn.innerHTML = `${escapeMapHtml(item.label)}`; + container.appendChild(btn); + } + if (helperText) { + const hint = document.createElement("span"); + hint.className = "map-locator-empty"; + hint.textContent = helperText; + container.appendChild(hint); + } + } + function renderMapLocatorPhaseRow(container, phase) { + if (!container) return; + container.replaceChildren(); + const phases = [ + { key: "type", label: "Source" }, + { key: "band", label: "Band" } + ]; + for (const item of phases) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-phase-btn"; + if (phase === item.key) btn.classList.add("is-active"); + btn.dataset.phase = item.key; + btn.textContent = item.label; + container.appendChild(btn); + } + } + function renderMapLocatorLegend(phase, sourceItems, bandItems) { + const legendEl = document.getElementById("map-band-legend"); + if (!legendEl) return; + const isSourcePhase = phase === "type"; + const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; + if (items.length === 0) { + legendEl.classList.add("is-empty"); + legendEl.replaceChildren(); + return; + } + legendEl.classList.remove("is-empty"); + const rows = items.map((item) => { + const label = escapeMapHtml(item.label); + const color = escapeMapHtml(item.color); + return `${label}`; + }).join(""); + const title = isSourcePhase ? "Source Colors" : "Band Colors"; + legendEl.innerHTML = `
${title}
${rows}
`; + } + window.enableMapSourceFilter = function(key) { + if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { + mapFilter[key] = true; + rebuildMapLocatorFilters(); + applyMapFilter(); + } + }; + function rebuildMapLocatorFilters() { + const phaseEl = document.getElementById("map-locator-phase"); + const choiceEl = document.getElementById("map-locator-choice-filter"); + const choiceLabelEl = document.getElementById("map-locator-choice-label"); + const availableSources = /* @__PURE__ */ new Set(); + for (const entry of aisMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("ais"); + break; + } + } + for (const entry of vdesMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("vdes"); + break; + } + } + for (const entry of stationMarkers.values()) { + if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { + availableSources.add("aprs"); + break; + } + } + const bandMap = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + const sourceType = entry?.sourceType; + if (!sourceType) continue; + if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; + availableSources.add(sourceType); + const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : /* @__PURE__ */ new Map(); + for (const [label, hz] of meta.entries()) { + if (!bandMap.has(label)) { + bandMap.set(label, { + key: label, + label, + color: locatorBandChipColor(label), + kind: "band", + sortHz: Number.isFinite(hz) ? hz : 0 + }); + continue; + } + const existing = bandMap.get(label); + if (existing && Number.isFinite(hz) && (!Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { + existing.sortHz = hz; + } + if (existing && !existing.color) { + existing.color = locatorBandChipColor(label); + } + } + } + for (const key of Array.from(mapLocatorFilter.bands)) { + if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); + } + const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + key, + label: mapSourceLabel(key), + color: mapSourceColor(key), + kind: "source" + })); + const bandItems = Array.from(bandMap.values()).sort((a, b) => b.sortHz - a.sortHz || a.label.localeCompare(b.label)); + renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); + if (!phaseEl || !choiceEl || !choiceLabelEl) return; + renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); + if (mapLocatorFilter.phase === "band") { + choiceLabelEl.textContent = "Visible Bands"; + renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); + } else { + choiceLabelEl.textContent = "Visible Sources"; + renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); + } + syncLocatorMarkerStyles(); + syncDecodeContactPathVisibility(); + } + function markerPassesLocatorFilters(marker) { + const meta = marker?._locatorFilterMeta; + if (!meta) return true; + if (mapLocatorFilter.phase === "band") { + if (mapLocatorFilter.bands.size === 0) return true; + if (!(meta.bands instanceof Set)) return false; + for (const label of mapLocatorFilter.bands) { + if (meta.bands.has(label)) return true; + } + return false; + } + return true; + } + function markerSearchText(marker) { + const type = marker?.__trxType; + if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const entry = locatorEntryForMarker(marker); + const parts = []; + if (entry?.grid) parts.push(entry.grid); + if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); + if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); + if (Array.isArray(entry?.bookmarks)) { + for (const bm of entry.bookmarks) { + if (bm?.name) parts.push(String(bm.name)); + if (bm?.locator) parts.push(String(bm.locator)); + if (bm?.mode) parts.push(String(bm.mode)); + if (bm?.category) parts.push(String(bm.category)); + if (bm?.comment) parts.push(String(bm.comment)); + if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz)))); + } + } + if (entry?.stations instanceof Set) { + parts.push(...Array.from(entry.stations.values()).map((v) => String(v))); + } + if (entry?.stationDetails instanceof Map) { + for (const detail of entry.stationDetails.values()) { + if (detail?.station) parts.push(String(detail.station)); + if (detail?.message) parts.push(String(detail.message)); + if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz)))); + } + } + return parts.join(" ").toLowerCase(); + } + if (type === "aprs") { + const call = marker?._aprsCall ? String(marker._aprsCall) : ""; + const entry = stationMarkers.get(call); + const info = entry?.info ? String(entry.info) : ""; + const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; + return `${call} ${info} ${pktRaw}`.toLowerCase(); + } + if (type === "ais") { + const key = marker?._aisMmsi ? String(marker._aisMmsi) : ""; + const msg = aisMarkers.get(key)?.msg; + return [ + key, + msg?.name, + msg?.callsign, + msg?.destination, + Number.isFinite(msg?.mmsi) ? String(msg.mmsi) : "", + Number.isFinite(msg?.lat) ? String(msg.lat) : "", + Number.isFinite(msg?.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); + } + if (type === "vdes") { + const key = marker?._vdesKey ? String(marker._vdesKey) : ""; + const msg = vdesMarkers.get(key)?.msg; + return [ + key, + msg?.name, + msg?.mmsi, + msg?.message, + msg?.raw, + Number.isFinite(msg?.lat) ? String(msg.lat) : "", + Number.isFinite(msg?.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); + } + return ""; + } + function markerPassesSearchFilter(marker) { + const query = String(mapSearchFilter || "").trim().toLowerCase(); + if (!query) return true; + const terms = query.split(/\s+/).filter(Boolean); + if (terms.length === 0) return true; + const haystack = markerSearchText(marker); + if (!haystack) return false; + return terms.every((term) => haystack.includes(term)); + } + function _receiverLocationKey(lat, lon) { + return lat.toFixed(6) + "," + lon.toFixed(6); + } + function syncAprsReceiverMarker() { + if (!aprsMap) return; + const locGroups = {}; + const activeId = T.lastActiveRigId || T.serverActiveRigId || null; + for (const rig of T.serverRigs) { + if (!rig || !rig.remote) continue; + const lat = rig.latitude, lon = rig.longitude; + if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const key = _receiverLocationKey(lat, lon); + if (!locGroups[key]) locGroups[key] = { lat, lon, rigs: [], hasActive: false }; + locGroups[key].rigs.push(rig.remote); + if (rig.remote === activeId) locGroups[key].hasActive = true; + } + if (T.serverLat != null && T.serverLon != null) { + const key = _receiverLocationKey(T.serverLat, T.serverLon); + if (!locGroups[key]) locGroups[key] = { lat: T.serverLat, lon: T.serverLon, rigs: [], hasActive: true }; + if (!locGroups[key].hasActive) locGroups[key].hasActive = true; + } + const seen = /* @__PURE__ */ new Set(); + let didInitialView = false; + for (const [key, group] of Object.entries(locGroups)) { + seen.add(key); + const latLng = [group.lat, group.lon]; + const isActive = group.hasActive; + let m = aprsMapReceiverMarkers[key]; + if (!m) { + m = L.circleMarker(latLng, { + radius: isActive ? 8 : 6, + className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), + fillOpacity: isActive ? 0.8 : 0.6 + }).addTo(aprsMap).bindPopup(""); + m._receiverLocKey = key; + m._receiverRigs = group.rigs; + aprsMapReceiverMarkers[key] = m; + if (isActive && !didInitialView) { + aprsMap.setView(latLng, Math.max(1, T.initialMapZoom)); + didInitialView = true; + } + } else { + m.setLatLng(latLng); + m._receiverRigs = group.rigs; + m.setRadius(isActive ? 8 : 6); + if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); + } + if (isActive) aprsMapReceiverMarker = m; + } + for (const key of Object.keys(aprsMapReceiverMarkers)) { + if (!seen.has(key)) { + const m = aprsMapReceiverMarkers[key]; + if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); + delete aprsMapReceiverMarkers[key]; + } + } + if (!seen.size) aprsMapReceiverMarker = null; + } + const satOverlays = /* @__PURE__ */ new Map(); + let satOverlaySeq = 0; + window.addSatMapOverlay = function(msg) { + if (!msg || !msg.geo_bounds || !msg.path) return; + const bounds = msg.geo_bounds; + if (!Array.isArray(bounds) || bounds.length !== 4) return; + const latLngBounds = L.latLngBounds( + [bounds[0], bounds[1]], + // SW + [bounds[2], bounds[3]] + // NE + ); + const key = "sat-" + ++satOverlaySeq; + const overlay = L.imageOverlay(msg.path, latLngBounds, { + opacity: 0.55, + interactive: true, + zIndex: 300 + }); + overlay.__trxType = "sat"; + overlay.__trxSatKey = key; + overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); + overlay.__trxHistoryVisible = true; + mapMarkers.add(overlay); + const decoder = "Meteor LRPT"; + const satellite = msg.satellite || "Unknown"; + const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; + overlay.bindPopup( + `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` + ); + let track = null; + if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { + const latlngs = msg.ground_track.map(function(pt) { + return [pt[0], pt[1]]; + }); + track = L.polyline(latlngs, { + color: mapSourceColor("sat"), + weight: 2, + opacity: 0.7, + dashArray: "6, 4" + }); + track.__trxType = "sat"; + track.__trxSatKey = key; + track.__trxRigIds = overlay.__trxRigIds; + track.__trxHistoryVisible = true; + mapMarkers.add(track); + if (aprsMap) { + track.addTo(aprsMap); + } + } + satOverlays.set(key, { overlay, track, msg }); + if (aprsMap) { + overlay.addTo(aprsMap); + } + applyMapFilter(); + }; + window.removeSatMapOverlay = function(key) { + const entry = satOverlays.get(key); + if (!entry) return; + if (entry.overlay) { + mapMarkers.delete(entry.overlay); + if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap); + } + if (entry.track) { + mapMarkers.delete(entry.track); + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + } + satOverlays.delete(key); + }; + window.clearSatMapOverlays = function() { + for (const [key] of satOverlays) { + window.removeSatMapOverlay(key); + } + }; + window.clearMapMarkersByType = function(type) { + if (type === "aprs") { + selectedAprsTrackCall = null; + stationMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); + } + }); + stationMarkers.clear(); + return; + } + if (type === "ais") { + aisMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); + } + }); + selectedAisTrackMmsi = null; + aisMarkers.clear(); + return; + } + if (type === "vdes") { + vdesMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + }); + vdesMarkers.clear(); + return; + } + if (type === "sat") { + window.clearSatMapOverlays(); + return; + } + if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const prefix = `${type}:`; + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith(prefix)) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + rebuildDecodeContactPaths(); + } + if (type === "bookmark") { + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + } + }; + function mapTileSpecForTheme(theme) { + if (theme === "dark") { + return { + url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + options: { + maxZoom: 19, + subdomains: "abcd", + attribution: '© OpenStreetMap © CARTO' + } + }; + } + return { + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + options: { + maxZoom: 19, + attribution: '© OpenStreetMap' + } + }; + } + function updateMapBaseLayerForTheme(theme) { + if (!aprsMap) return; + if (aprsMapBaseLayer) { + aprsMap.removeLayer(aprsMapBaseLayer); + aprsMapBaseLayer = null; + } + const spec = mapTileSpecForTheme(theme); + aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); + } + function mapStageEl() { + return document.getElementById("map-stage"); + } + function mapIsFullscreen() { + const stage = mapStageEl(); + if (!stage) return false; + return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); + } + function mapExitFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.remove("map-fake-fullscreen"); + document.body.classList.remove("map-fake-fullscreen-active"); + } + function mapEnterFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.add("map-fake-fullscreen"); + document.body.classList.add("map-fake-fullscreen-active"); + } + function updateMapFullscreenButton() { + const btn = document.getElementById("map-fullscreen-btn"); + if (!btn) return; + btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; + } + function applyMapOverlayPanelVisibility() { + const panel = document.querySelector("#map-stage .map-overlay-panel"); + if (!panel) return; + panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); + } + function updateMapOverlayToggleButton() { + const btn = document.getElementById("map-overlay-toggle-btn"); + if (!btn) return; + btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; + } + async function toggleMapFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + try { + const isNative = document.fullscreenElement === stage || document.webkitFullscreenElement === stage; + const isFake = stage.classList.contains("map-fake-fullscreen"); + if (isNative) { + if (document.exitFullscreen) await document.exitFullscreen(); + else if (document.webkitExitFullscreen) await document.webkitExitFullscreen(); + } else if (isFake) { + mapExitFakeFullscreen(); + } else { + const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; + if (nativeFn) { + try { + await nativeFn.call(stage); + } catch (_) { + mapEnterFakeFullscreen(); + } + } else { + mapEnterFakeFullscreen(); + } + } + } catch (err) { + console.error("Map fullscreen toggle failed", err); + } finally { + updateMapFullscreenButton(); + requestAnimationFrame(() => sizeAprsMapToViewport()); + } + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const stage = mapStageEl(); + if (stage && stage.classList.contains("map-fake-fullscreen")) { + mapExitFakeFullscreen(); + updateMapFullscreenButton(); + requestAnimationFrame(() => sizeAprsMapToViewport()); + } + } + }); + function materializeBufferedMapLayers() { + if (!aprsMap) return; + for (const [key, entry] of locatorMarkers) { + if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) continue; + entry.sourceType = "bookmark"; + entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); + entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); + entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + mapMarkers.add(entry.marker); + } + pruneMapHistory(); + } + let stageResizeObserver = null; + function initAprsMap() { + if (typeof L === "undefined") return; + const mapEl = document.getElementById("aprs-map"); + if (!mapEl) return; + sizeAprsMapToViewport(); + if (aprsMap) return; + const hasLocation = T.serverLat != null && T.serverLon != null; + const center = hasLocation ? [T.serverLat, T.serverLon] : [20, 0]; + const zoom = hasLocation ? T.initialMapZoom : 2; + aprsMap = L.map("aprs-map").setView(center, zoom); + const stage = mapStageEl(); + if (stage && typeof ResizeObserver !== "undefined") { + if (stageResizeObserver) stageResizeObserver.disconnect(); + stageResizeObserver = new ResizeObserver(() => sizeAprsMapToViewport()); + stageResizeObserver.observe(stage); + } + updateMapBaseLayerForTheme(C.currentTheme()); + syncAprsReceiverMarker(); + aprsMap.on("popupopen", function(e) { + const marker = e.popup._source; + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const prevEntry = stationMarkers.get(String(selectedAprsTrackCall)); + if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { + prevEntry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + if (marker._receiverLocKey) { + e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); + return; + } + if (!marker) return; + const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; + if (marker._aprsCall) { + if (!ll) return; + const entry = stationMarkers.get(marker._aprsCall); + if (!entry) return; + e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); + refreshAprsTrack(String(marker._aprsCall), entry); + if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { + entry.track.addTo(aprsMap); + } + selectedAprsTrackCall = String(marker._aprsCall); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker._aisMmsi) { + if (!ll) return; + const entry = aisMarkers.get(String(marker._aisMmsi)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildAisPopupHtml(entry.msg)); + refreshAisTrack(String(marker._aisMmsi), entry); + selectedAisTrackMmsi = String(marker._aisMmsi); + syncSelectedAisTrackVisibility(); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker._vdesKey) { + if (!ll) return; + const entry = vdesMarkers.get(String(marker._vdesKey)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildVdesPopupHtml(entry.msg)); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { + const center2 = locatorMarkerCenter(marker); + if (center2) { + setSelectedLocatorMarker(marker); + const lEntry = locatorEntryForMarker(marker); + const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color : locatorFilterColor(marker.__trxType); + setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); + } + } else if (marker.__trxType === "bookmark") { + setSelectedLocatorMarker(marker); + } + }); + aprsMap.on("popupclose", function() { + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + }); + materializeBufferedMapLayers(); + const locatorPhaseEl = document.getElementById("map-locator-phase"); + const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); + const mapSearchEl = document.getElementById("map-search-filter"); + const mapHistoryLimitEl = document.getElementById("map-history-limit"); + const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle"); + const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); + const fullscreenBtn = document.getElementById("map-fullscreen-btn"); + const overlayToggleBtn = document.getElementById("map-overlay-toggle-btn"); + if (locatorPhaseEl) { + locatorPhaseEl.addEventListener("click", (e) => { + const btn = e.target.closest(".map-locator-phase-btn[data-phase]"); + if (!btn) return; + const phase = String(btn.dataset.phase || ""); + if (phase !== "type" && phase !== "band") return; + if (mapLocatorFilter.phase === phase) return; + mapLocatorFilter.phase = phase; + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + if (locatorChoiceEl) { + locatorChoiceEl.addEventListener("click", (e) => { + const chip = e.target.closest(".map-locator-chip[data-filter-kind]"); + if (!chip) return; + const kind = String(chip.dataset.filterKind || ""); + const key = String(chip.dataset.filterKey || ""); + if (!key) return; + if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { + mapFilter[key] = !mapFilter[key]; + const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const anySelected = srcKeys.some((k) => mapFilter[k]); + if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) { + const entry = aisMarkers.get(String(selectedAisTrackMmsi)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAisTrackMmsi = null; + } + } else if (kind === "band") { + if (mapLocatorFilter.bands.has(key)) { + mapLocatorFilter.bands.delete(key); + } else { + mapLocatorFilter.bands.add(key); + } + } + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + const mapRigFilterEl = document.getElementById("map-rig-filter"); + if (mapRigFilterEl) { + mapRigFilterEl.addEventListener("change", () => { + mapRigFilter = mapRigFilterEl.value; + applyMapFilter(); + }); + } + if (mapSearchEl) { + mapSearchEl.value = mapSearchFilter; + mapSearchEl.addEventListener("input", () => { + mapSearchFilter = String(mapSearchEl.value || "").trim(); + applyMapFilter(); + }); + } + if (mapHistoryLimitEl) { + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + mapHistoryLimitEl.addEventListener("change", () => { + mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value)); + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes); + pruneMapHistory(); + }); + } + if (mapP2pPathsToggleEl) { + updateMapP2pPathsToggle(); + mapP2pPathsToggleEl.addEventListener("click", () => { + mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled; + saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled); + updateMapP2pPathsToggle(); + if (!mapP2pRadioPathsEnabled) clearMapRadioPath(); + }); + } + if (mapContactPathsToggleEl) { + updateMapContactPathsToggle(); + mapContactPathsToggleEl.addEventListener("click", () => { + mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; + saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); + updateMapContactPathsToggle(); + syncDecodeContactPathVisibility(); + }); + } + if (fullscreenBtn) { + fullscreenBtn.addEventListener("click", () => { + toggleMapFullscreen(); + }); + updateMapFullscreenButton(); + } + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + if (overlayToggleBtn) { + overlayToggleBtn.addEventListener("click", () => { + mapOverlayPanelVisible = !mapOverlayPanelVisible; + saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible); + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + }); + } + if (!mapFullscreenListenerBound) { + const onFullscreenChange = () => { + updateMapFullscreenButton(); + sizeAprsMapToViewport(); + }; + document.addEventListener("fullscreenchange", onFullscreenChange); + document.addEventListener("webkitfullscreenchange", onFullscreenChange); + mapFullscreenListenerBound = true; + } + if (!mapHistoryPruneTimer) { + mapHistoryPruneTimer = setInterval(() => { + pruneMapHistory(); + }, 60 * 1e3); + } + rebuildMapLocatorFilters(); + } + function sizeAprsMapToViewport() { + const mapEl = document.getElementById("aprs-map"); + if (!mapEl) return; + const stage = mapStageEl(); + if (mapIsFullscreen() && stage) { + const isFake = stage.classList.contains("map-fake-fullscreen"); + const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; + const target2 = Math.max(260, Math.floor(stageHeight)); + mapEl.style.height = `${target2}px`; + if (aprsMap) aprsMap.invalidateSize(); + return; + } + const mapRect = mapEl.getBoundingClientRect(); + const width = mapEl.clientWidth || mapRect.width; + const footer = document.querySelector(".footer"); + let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; + if (!mapIsFullscreen() && footer) { + const fr = footer.getBoundingClientRect(); + if (fr.top > mapRect.top + 50) bottom = fr.top; + } + const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); + const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; + const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); + const minHeight = Math.min(260, available); + const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); + mapEl.style.height = `${target}px`; + if (aprsMap) aprsMap.invalidateSize(); + } + function aprsSymbolIcon(symbolTable, symbolCode) { + if (!symbolTable || !symbolCode) return null; + const sheet = symbolTable === "/" ? 0 : 1; + const code = symbolCode.charCodeAt(0) - 33; + const col = code % 16; + const row = Math.floor(code / 16); + const bgX = -(col * 24); + const bgY = -(row * 24); + const url = `https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png`; + return L.divIcon({ + className: "", + html: `
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -12] + }); + } + window.navigateToAprsMap = function(lat, lon) { + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = document.getElementById("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (aprsMap) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + aprsMap.invalidateSize(); + aprsMap.setView([lat, lon], 13); + }); + }); + } + }; + window.navigateToMapLocator = function(grid, preferredType = null) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => t.classList.remove("active")); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = document.getElementById("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (!aprsMap) return false; + const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; + const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; + let entry = null; + for (const key of keys) { + entry = locatorMarkers.get(key); + if (entry?.marker) break; + } + if (!entry?.marker) return false; + if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { + mapFilter[pref] = true; + rebuildMapLocatorFilters(); + applyMapFilter(); + } + const marker = entry.marker; + if (!aprsMap.hasLayer(marker)) { + marker.addTo(aprsMap); + sendLocatorOverlayToBack(marker); + } + const center = locatorMarkerCenter(marker); + const focusMarker = () => { + if (!aprsMap || !marker) return; + sizeAprsMapToViewport(); + aprsMap.invalidateSize(); + if (center) { + const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); + aprsMap.setView([center.lat, center.lon], targetZoom); + if (marker.__trxType !== "bookmark") { + const fEntry = locatorEntryForMarker(marker); + const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color : locatorFilterColor(marker?.__trxType); + setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); + } + } + setSelectedLocatorMarker(marker); + if (typeof marker.openPopup === "function") marker.openPopup(); + }; + focusMarker(); + requestAnimationFrame(() => { + requestAnimationFrame(focusMarker); + }); + return true; + }; + function buildReceiverPopupHtml(rigIds) { + const call = T.serverCallsign || T.ownerCallsign || "Receiver"; + let meta = ""; + if (T.serverVersion) { + meta = `trx-server v${escapeMapHtml(T.serverVersion)}`; + if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`; + } + let rows = ""; + if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { + rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; + } + const rigSet = rigIds && rigIds.length ? new Set(rigIds) : null; + const firstRig = rigSet ? T.serverRigs.find((r) => rigSet.has(r.remote)) : null; + const popupLat = firstRig ? firstRig.latitude : T.serverLat; + const popupLon = firstRig ? firstRig.longitude : T.serverLon; + if (popupLat != null && popupLon != null) { + const grid = latLonToMaidenhead(popupLat, popupLon); + rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; + } + const rigsToShow = rigSet ? T.serverRigs.filter((r) => rigSet.has(r.remote)) : T.serverRigs; + for (const rig of rigsToShow) { + const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); + const active = rig.remote === T.serverActiveRigId ? ` active` : ""; + rows += `Rig${escapeMapHtml(name)}${active}`; + } + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; + } + function buildAprsPopupHtml(call, lat, lon, info, pkt) { + const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; + const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const path = pkt?.path || null; + const type = pkt?.type || null; + let meta = [age, distStr].filter(Boolean).join(" · "); + let rows = ""; + if (type) rows += `Type${escapeMapHtml(type)}`; + if (path) rows += `Path${escapeMapHtml(path)}`; + if (lat != null && lon != null) + rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; + } + function buildAisPopupHtml(msg) { + const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); + let rows = ""; + rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; + rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; + if (distStr) rows += `Range${distStr} from TRX`; + if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; + if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; + if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; + if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" Β· "); + const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); + const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; + const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; + return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function buildVdesPopupHtml(msg) { + const age = formatTimeAgo(msg?.ts_ms); + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [ + age, + distStr, + msg?.message_label ? escapeMapHtml(msg.message_label) : null, + Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null + ].filter(Boolean).join(" · "); + let rows = ""; + if (distStr) rows += `Range${distStr} from TRX`; + rows += `Type${escapeMapHtml(String(msg?.message_type ?? "--"))}`; + if (Number.isFinite(msg?.source_id)) rows += `Source${escapeMapHtml(String(msg.source_id))}`; + if (Number.isFinite(msg?.destination_id)) rows += `Dest${escapeMapHtml(String(msg.destination_id))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + if (Number.isFinite(msg?.sync_score)) rows += `Sync${(Number(msg.sync_score) * 100).toFixed(0)}%`; + if (msg?.fec_state) rows += `FEC${escapeMapHtml(String(msg.fec_state))}`; + const info = [ + msg?.vessel_name, + msg?.callsign, + msg?.destination, + msg?.payload_preview + ].filter(Boolean).map(escapeMapHtml).join(" Β· "); + const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); + return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function aprsPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function aisPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function vdesMarkerKey(msg) { + if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; + if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; + if (msg?.lat != null && msg?.lon != null) { + return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`; + } + return null; + } + function _aprsAddMarkerToMap(call, entry) { + refreshAprsTrack(call, entry); + const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode); + const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); + const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { + radius: 6, + color: "#00d17f", + fillColor: "#00d17f", + fillOpacity: 0.8 + }).addTo(aprsMap).bindPopup(popupContent); + marker.__trxType = "aprs"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aprsCall = call; + entry.marker = marker; + mapMarkers.add(marker); + } + window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + const nextPoint = [lat, lon]; + const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); + const msgRigId = pkt?.rig_id || T.lastActiveRigId; + const existing = stationMarkers.get(call); + if (existing) { + existing.pkt = pkt; + existing.lat = lat; + existing.lon = lon; + existing.info = info; + existing.symbolTable = symbolTable; + existing.symbolCode = symbolCode; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aprsPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat, lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAprsEntry(call, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([lat, lon]); + existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); + } + } else { + const entry = { + marker: null, + track: null, + trackHistory: [{ lat, lon, tsMs }], + trackPoints: [nextPoint], + type: "aprs", + pkt, + lat, + lon, + info, + symbolTable, + symbolCode, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + stationMarkers.set(call, entry); + pruneAprsEntry(call, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); + if (aprsMap) scheduleDecodeMapMaintenance(); + } + }; + function syncSelectedAisTrackVisibility() { + if (!aprsMap) return; + const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; + aisMarkers.forEach((entry, key) => { + const track = entry?.track; + if (!track) return; + const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; + const onMap = aprsMap.hasLayer(track); + if (shouldShow && !onMap) { + track.addTo(aprsMap); + } + if (!shouldShow && onMap) { + track.removeFrom(aprsMap); + } + }); + } + function getAisAccentColor() { + return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; + } + function aisMarkerOptionsFromMessage(msg) { + const color = getAisAccentColor(); + return { + heading: msg?.heading_deg, + course: msg?.cog_deg, + speed: msg?.sog_knots, + color, + outline: "#00000055", + size: 22 + }; + } + function createAisMarker(lat, lon, msg) { + if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { + return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); + } + const color = getAisAccentColor(); + return L.circleMarker([lat, lon], { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.82 + }); + } + function updateAisMarker(marker, msg, popupHtml) { + if (!marker) return; + marker.setLatLng([msg.lat, msg.lon]); + if (typeof marker.setAisState === "function") { + marker.setAisState(aisMarkerOptionsFromMessage(msg)); + } + if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { + const color = getAisAccentColor(); + marker.setStyle({ + radius: 6, + color, + fillColor: color, + fillOpacity: 0.84 + }); + } + marker.setPopupContent(popupHtml); + } + function refreshAisMarkerColors() { + const color = getAisAccentColor(); + aisMarkers.forEach((entry) => { + if (entry.marker) { + if (typeof entry.marker.setAisState === "function") { + entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {})); + } else if (typeof entry.marker.setStyle === "function") { + entry.marker.setStyle({ color, fillColor: color }); + } + } + if (entry.track && typeof entry.track.setStyle === "function") { + entry.track.setStyle({ color }); + } + }); + } + window.aisMapAddVessel = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; + const key = String(msg.mmsi); + const popupHtml = buildAisPopupHtml(msg); + const nextPoint = [msg.lat, msg.lon]; + const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = aisMarkers.get(key); + if (existing) { + existing.msg = msg; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aisPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAisEntry(key, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + updateAisMarker(existing.marker, msg, popupHtml); + } + return; + } + aisMarkers.set(key, { + marker: null, + track: null, + trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], + trackPoints: [nextPoint], + msg, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }); + pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs()); + if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key)); + scheduleDecodeMapMaintenance(); + }; + window.vdesMapAddPoint = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null) return; + const key = vdesMarkerKey(msg); + if (!key) return; + const popupHtml = buildVdesPopupHtml(msg); + const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = vdesMarkers.get(key); + if (existing) { + existing.msg = msg; + existing.visibleInHistoryWindow = visible; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!visible) { + if (!C.decodeHistoryMapRenderingDeferred()) { + setRetainedMapMarkerVisible(existing.marker, false); + } else { + C.markDecodeMapSyncPending(); + } + return; + } + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, existing); + setRetainedMapMarkerVisible(existing.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([msg.lat, msg.lon]); + existing.marker.setPopupContent(popupHtml); + } + return; + } + const entry = { + marker: null, + msg, + visibleInHistoryWindow: visible, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + vdesMarkers.set(key, entry); + if (!visible) return; + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { + entry.marker.setPopupContent(popupHtml); + } + scheduleDecodeMapMaintenance(); + }; + let reverseGeocodeLastKey = null; + function reverseGeocodeLocation(lat, lon, grid) { + const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; + if (key === reverseGeocodeLastKey) return; + reverseGeocodeLastKey = key; + const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; + fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(r.status)).then((data) => { + const addr = data?.address; + if (!addr) return; + const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; + const country = addr.country || ""; + if (!city && !country) return; + const label = city && country ? `${city}, ${country}` : city || country; + T.lastCityLabel = label; + if (T.locationSubtitle) { + T.locationSubtitle.textContent = `Location: ${grid} Β· ${label}`; + } + C.updateDocumentTitle(C.activeChannelRds()); + }).catch(() => { + }); + } + function maidenheadToBounds(grid) { + if (!grid || grid.length < 4) return null; + const g = grid.toUpperCase(); + const A = "A".charCodeAt(0); + const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; + const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; + const squareLon = parseInt(g[2], 10) * 2; + const squareLat = parseInt(g[3], 10) * 1; + let lon = fieldLon + squareLon; + let lat = fieldLat + squareLat; + let lonSpan = 2; + let latSpan = 1; + if (g.length >= 6) { + const subLon = (g.charCodeAt(4) - A) * (5 / 60); + const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); + lon += subLon; + lat += subLat; + lonSpan = 5 / 60; + latSpan = 2.5 / 60; + } + return [ + [lat, lon], + [lat + latSpan, lon + lonSpan] + ]; + } + function applyMapFilter() { + if (!aprsMap) return; + const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const noneSelected = sourceKeys.every((k) => !mapFilter[k]); + mapMarkers.forEach((marker) => { + const type = marker.__trxType; + const sourceVisible = noneSelected ? DEFAULT_MAP_SOURCE_FILTER[type] !== void 0 ? DEFAULT_MAP_SOURCE_FILTER[type] : true : !!mapFilter[type]; + const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); + const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; + const onMap = aprsMap.hasLayer(marker); + if (visible && !onMap) { + marker.addTo(aprsMap); + sendLocatorOverlayToBack(marker); + } + if (!visible && onMap) marker.removeFrom(aprsMap); + }); + syncSelectedAisTrackVisibility(); + syncDecodeContactPathVisibility(); + } + function updateMapContactPathsToggle() { + const btn = document.getElementById("map-contact-paths-toggle"); + if (!btn) return; + btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; + btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); + } + function updateMapP2pPathsToggle() { + const btn = document.getElementById("map-p2p-paths-toggle"); + if (!btn) return; + btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; + btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); + } + function scheduleDecodeMapMaintenance() { + if (C.decodeHistoryMapRenderingDeferred()) { + C.markDecodeMapSyncPending(); + return; + } + scheduleUiFrameJob("decode-map-maintenance", () => { + rebuildDecodeContactPaths(); + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + function formatDecodeLocatorTime(tsMs) { + if (!Number.isFinite(tsMs)) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + function formatMapPopupFreq(hz) { + if (!Number.isFinite(hz)) return "--"; + const value = Number(hz); + if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; + if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; + if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; + return `${Math.round(value)} Hz`; + } + function buildDecodeLocatorTooltipHtml(grid, entry, type) { + const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; + details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); + const title = type === "wspr" ? "WSPR" : "FT8"; + const rows = details.map((detail) => { + const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); + const freq = formatMapPopupFreq(Number(detail?.freq_hz)); + const meta = [ + detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, + Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, + Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, + escapeMapHtml(freq) + ].filter(Boolean).join(" Β· "); + const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; + const rxHtml = remoteIds.map((rid) => { + const label = _receiverLabel(rid); + return label ? `
${escapeMapHtml(label)}
` : ""; + }).filter(Boolean).join(""); + const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; + return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; + }).join(""); + const count = Math.max( + 1, + details.length, + entry?.stations instanceof Set ? entry.stations.size : 0 + ); + return `
${escapeMapHtml(grid)}
${title} Β· ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; + } + function rebuildDecodeContactPaths() { + clearDecodeContactPaths(); + const stationLocators = /* @__PURE__ */ new Map(); + const directedMessages = []; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + const grid = String(entry.grid || "").trim().toUpperCase(); + if (!grid || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + const target = String(detail?.target || "").trim().toUpperCase(); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + if (source) { + const prev = stationLocators.get(source); + if (!prev || tsMs >= prev.tsMs) { + stationLocators.set(source, { grid, tsMs }); + } + } + if (source && target && source !== target) { + const band = bandForHz(Number(detail?.freq_hz)); + directedMessages.push({ + source, + target, + sourceGrid: grid, + sourceType: entry.sourceType, + tsMs, + bandLabel: band?.label || null, + remote: detail?.remote || null + }); + } + } + } + for (const msg of directedMessages) { + const targetLocator = stationLocators.get(msg.target); + if (!targetLocator) continue; + if (msg.sourceGrid === targetLocator.grid) continue; + const sourceCenter = locatorToLatLon(msg.sourceGrid); + const targetCenter = locatorToLatLon(targetLocator.grid); + if (!sourceCenter || !targetCenter) continue; + const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); + const key = [msg.source, msg.target].sort().join("::"); + const prev = decodeContactPaths.get(key); + if (prev && prev.tsMs > msg.tsMs) continue; + decodeContactPaths.set(key, { + pathKey: key, + source: msg.source, + target: msg.target, + sourceGrid: msg.sourceGrid, + targetGrid: targetLocator.grid, + sourceType: msg.sourceType, + bandLabel: msg.bandLabel, + from: sourceCenter, + to: targetCenter, + tsMs: msg.tsMs, + distanceKm, + distanceText: formatDecodeContactDistance(distanceKm), + line: null, + labelMarker: null, + remote: msg.remote + }); + } + syncDecodeContactPathVisibility(); + } + function _receiverLabel(rigId) { + if (!rigId) return null; + const rig = T.serverRigs.find((r) => r.remote === rigId); + const name = T.lastRigDisplayNames[rigId] || rigId; + if (rig && rig.latitude != null && rig.longitude != null) { + const grid = latLonToMaidenhead(rig.latitude, rig.longitude); + return `${name} (${grid})`; + } + return name; + } + function _locatorEntryVisibleOnMap(entry) { + return entry?.marker && aprsMap && aprsMap.hasLayer(entry.marker); + } + function _detailPassesRigFilter(detail) { + if (!mapRigFilter) return true; + if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter); + return detail?.remote === mapRigFilter; + } + function renderMapQsoSummary() { + const listEl = document.getElementById("map-qso-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { + const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); + if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; + return Number(b.tsMs || 0) - Number(a.tsMs || 0); + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { + selectedMapQsoKey = null; + } + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); + card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); + card.addEventListener("click", () => { + selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey; + syncDecodeContactPathVisibility(); + if (selectedMapQsoKey && entry.sourceGrid) { + navigateToMapLocator(entry.sourceGrid, entry.sourceType); + } + }); + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const distance = document.createElement("span"); + distance.className = "map-qso-card-distance"; + distance.textContent = entry.distanceText || "--"; + head.appendChild(distance); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = _receiverLabel(entry.remote); + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapSignalSummary() { + const listEl = document.getElementById("map-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const bestByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = bestByStation.get(station); + if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + bestByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(bestByStation.values()).sort((a, b) => { + const delta = b.snrDb - a.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + navigateToMapLocator(entry.grid, entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = _receiverLabel(entry.remote); + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapWeakSignalSummary() { + const listEl = document.getElementById("map-weak-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const worstByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = worstByStation.get(station); + if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + worstByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(worstByStation.values()).sort((a, b) => { + const delta = a.snrDb - b.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + navigateToMapLocator(entry.grid, entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = _receiverLabel(entry.remote); + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + let statsRigFilter = ""; + let statsHistoryLimitMinutes = 1440; + const statsDecodeLog = []; + const STATS_LOG_MAX = 5e4; + const STATS_TYPE_COLORS = { + ft8: "#4fc3f7", + ft4: "#81c784", + ft2: "#aed581", + wspr: "#ffb74d", + aprs: "#ce93d8", + hf_aprs: "#ba68c8", + ais: "#90a4ae", + vdes: "#78909c", + cw: "#fff176" + }; + const STATS_DX_BUCKETS = [ + { label: "0–500 km", min: 0, max: 500 }, + { label: "500–1k", min: 500, max: 1e3 }, + { label: "1k–2k", min: 1e3, max: 2e3 }, + { label: "2k–5k", min: 2e3, max: 5e3 }, + { label: "5k–10k", min: 5e3, max: 1e4 }, + { label: "10k+ km", min: 1e4, max: Infinity } + ]; + function _statsHistoryCutoffMs() { + return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; + } + function _statsDetailPassesRigFilter(detail) { + if (!statsRigFilter) return true; + if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); + return detail?.remote === statsRigFilter; + } + function updateStatsRigFilter() { + const el = document.getElementById("stats-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); + } + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + statsRigFilter = ""; + } + } + function statsRecordDecode(type, remote, tsMs) { + statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); + if (statsDecodeLog.length > STATS_LOG_MAX) { + statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); + } + } + function _statsFilteredLog() { + const cutoff = _statsHistoryCutoffMs(); + return statsDecodeLog.filter((e) => { + if (e.ts_ms < cutoff) return false; + if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false; + return true; + }); + } + function renderStatsCounters() { + const cutoff = _statsHistoryCutoffMs(); + const log = _statsFilteredLog(); + const totalDecodes = log.length; + const uniqueStations = /* @__PURE__ */ new Set(); + const uniqueGrids = /* @__PURE__ */ new Set(); + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (station) uniqueStations.add(station); + } + if (entry.grid) { + const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some( + (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d) + ); + if (hasVisible) uniqueGrids.add(entry.grid); + } + } + const rateWindow = Date.now() - 6e4; + const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; + const setEl = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = String(val); + }; + setEl("stats-total-decodes", totalDecodes.toLocaleString()); + setEl("stats-unique-stations", uniqueStations.size.toLocaleString()); + setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); + setEl("stats-decode-rate", recentCount.toLocaleString()); + } + function _renderBarChart(containerId, data, emptyMsg) { + const el = document.getElementById(containerId); + if (!el) return; + if (!data || data.length === 0 || data.every((d) => d.count === 0)) { + el.replaceChildren(); + const empty = document.createElement("div"); + empty.className = "stats-bar-empty"; + empty.textContent = emptyMsg || "No data available."; + el.appendChild(empty); + return; + } + const maxVal = Math.max(1, ...data.map((d) => d.count)); + const fragment = document.createDocumentFragment(); + for (const item of data) { + const row = document.createElement("div"); + row.className = "stats-bar-row"; + const label = document.createElement("span"); + label.className = "stats-bar-label"; + label.textContent = item.label; + row.appendChild(label); + const track = document.createElement("div"); + track.className = "stats-bar-track"; + const fill = document.createElement("div"); + fill.className = "stats-bar-fill"; + fill.style.width = `${item.count / maxVal * 100}%`; + fill.style.background = item.color || "var(--accent-green)"; + track.appendChild(fill); + row.appendChild(track); + const count = document.createElement("span"); + count.className = "stats-bar-count"; + count.textContent = item.count.toLocaleString(); + row.appendChild(count); + fragment.appendChild(row); + } + el.replaceChildren(fragment); + } + function renderStatsDecodeTypes() { + const log = _statsFilteredLog(); + const counts = {}; + for (const e of log) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + const data = Object.entries(counts).map(([type, count]) => ({ + label: type.toUpperCase(), + count, + color: STATS_TYPE_COLORS[type] || "#aaa" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); + } + function renderStatsBandActivity() { + const cutoff = _statsHistoryCutoffMs(); + const bandCounts = {}; + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (!Number.isFinite(detail?.freq_hz)) continue; + const band = bandForHz(Number(detail.freq_hz)); + if (band) { + bandCounts[band.label] = (bandCounts[band.label] || 0) + 1; + } + } + } + const data = Object.entries(bandCounts).map(([label, count]) => ({ + label, + count, + color: locatorBandChipColor(label) + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); + } + function renderStatsRigCompare() { + const section = document.getElementById("stats-rig-compare-section"); + if (!section) return; + if (T.lastRigIds.length < 2) { + section.style.display = "none"; + return; + } + section.style.display = ""; + const cutoff = _statsHistoryCutoffMs(); + const rigCounts = {}; + for (const e of statsDecodeLog) { + if (e.ts_ms < cutoff) continue; + const rid = e.remote || "unknown"; + rigCounts[rid] = (rigCounts[rid] || 0) + 1; + } + const data = Object.entries(rigCounts).map(([rid, count]) => ({ + label: T.lastRigDisplayNames[rid] || rid, + count, + color: "var(--accent-green)" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); + } + function renderStatsDxHistogram() { + const cutoff = _statsHistoryCutoffMs(); + const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); + for (const entry of decodeContactPaths.values()) { + if (!entry || !Number.isFinite(entry.distanceKm)) continue; + if (entry.tsMs && entry.tsMs < cutoff) continue; + if (!_statsDetailPassesRigFilter(entry)) continue; + const km = entry.distanceKm; + for (const b of buckets) { + if (km >= b.min && km < b.max) { + b.count++; + break; + } + } + } + const data = buckets.map((b) => ({ + label: b.label, + count: b.count, + color: "#4fc3f7" + })); + _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); + } + let _statsRenderPending = false; + let _statsControlsWired = false; + function _wireStatsControls() { + if (_statsControlsWired) return; + const rigEl = document.getElementById("stats-rig-filter"); + const histEl = document.getElementById("stats-history-limit"); + if (!rigEl && !histEl) return; + _statsControlsWired = true; + if (rigEl) { + rigEl.addEventListener("change", () => { + statsRigFilter = rigEl.value; + scheduleStatsRender(); + }); + } + if (histEl) { + histEl.value = String(statsHistoryLimitMinutes); + histEl.addEventListener("change", () => { + statsHistoryLimitMinutes = Number(histEl.value) || 1440; + scheduleStatsRender(); + }); + } + } + function scheduleStatsRender() { + if (_statsRenderPending) return; + _statsRenderPending = true; + requestAnimationFrame(() => { + _statsRenderPending = false; + _wireStatsControls(); + renderStatsCounters(); + renderStatsDecodeTypes(); + renderStatsBandActivity(); + renderStatsRigCompare(); + renderStatsDxHistogram(); + renderMapQsoSummary(); + renderMapSignalSummary(); + renderMapWeakSignalSummary(); + }); + } + function buildBookmarkLocatorPopupHtml(grid, bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const rows = list.map((bm) => { + const title = escapeMapHtml(String(bm.name || "Bookmark")); + const freq = typeof bmFmtFreq === "function" ? escapeMapHtml(bmFmtFreq(bm.freq_hz)) : escapeMapHtml(String(bm.freq_hz || "--")); + const mode = bm.mode ? ` Β· ${escapeMapHtml(String(bm.mode))}` : ""; + return `${title} ${freq}${mode}`; + }).join("
"); + return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); + } + window.syncBookmarkMapLocators = function(bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const grouped = /* @__PURE__ */ new Map(); + for (const bm of list) { + const grid = String(bm?.locator || "").trim().toUpperCase(); + if (!grid) continue; + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const key = `bookmark:${grid}`; + const bucket = grouped.get(key); + if (bucket) { + bucket.bookmarks.push(bm); + } else { + grouped.set(key, { grid, bounds, bookmarks: [bm] }); + } + } + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (!grouped.has(key)) { + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + } + for (const [key, next] of grouped.entries()) { + const existing = locatorMarkers.get(key); + const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); + const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz))); + if (existing) { + existing.grid = next.grid; + existing.bounds = next.bounds; + existing.bookmarks = next.bookmarks; + existing.sourceType = "bookmark"; + existing.bandMeta = bandMeta; + if (existing.marker) { + existing.marker.setBounds(next.bounds); + existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length)); + existing.marker.setPopupContent(popupHtml); + sendLocatorOverlayToBack(existing.marker); + assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); + } + continue; + } + const entry = { + marker: null, + grid: next.grid, + bounds: next.bounds, + bookmarks: next.bookmarks, + sourceType: "bookmark", + bandMeta + }; + locatorMarkers.set(key, entry); + if (aprsMap) { + entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); + entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + mapMarkers.add(entry.marker); + } + } + rebuildMapLocatorFilters(); + applyMapFilter(); + }; + window.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { + if (!Array.isArray(grids) || grids.length === 0) return; + const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; + const msgRigId = details?.rig_id || T.lastActiveRigId; + const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; + const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; + const locatorDetails = /* @__PURE__ */ new Map(); + if (Array.isArray(details?.locator_details)) { + for (const locatorDetail of details.locator_details) { + const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); + if (!grid) continue; + locatorDetails.set(grid, locatorDetail); + } + } + for (const grid of unique) { + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const locatorDetail = locatorDetails.get(grid); + const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; + const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; + const detailStationId = sourceId || stationId; + const detailEntry = { + station: detailStationId || null, + source: sourceId || null, + target: targetId || null, + ts_ms: Number.isFinite(details?.ts_ms) ? Number(details.ts_ms) : null, + snr_db: Number.isFinite(details?.snr_db) ? Number(details.snr_db) : null, + dt_s: Number.isFinite(details?.dt_s) ? Number(details.dt_s) : null, + freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null, + message: String(details?.message || message || "").trim() || null, + remote: msgRigId || null, + remotes: new Set(msgRigId ? [msgRigId] : []) + }; + const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; + const key = `${markerType}:${grid}`; + const existing = locatorMarkers.get(key); + if (existing) { + existing.grid = grid; + if (!(existing.allStationDetails instanceof Map)) { + existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); + } + const prevDetail = existing.allStationDetails.get(detailKey); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); + if (msgRigId) mergedRemotes.add(msgRigId); + existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); + existing.sourceType = markerType; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); + if (existing.marker) sendLocatorOverlayToBack(existing.marker); + scheduleDecodeMapMaintenance(); + continue; + } + const allStationDetails = /* @__PURE__ */ new Map(); + allStationDetails.set(detailKey, { ...detailEntry }); + const entry = { + marker: null, + grid, + stations: /* @__PURE__ */ new Set(), + stationDetails: /* @__PURE__ */ new Map(), + allStationDetails, + sourceType: markerType, + bandMeta: /* @__PURE__ */ new Map(), + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + locatorMarkers.set(key, entry); + pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); + if (entry.marker) sendLocatorOverlayToBack(entry.marker); + } + scheduleDecodeMapMaintenance(); + }; + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + bar.addEventListener("click", (e) => { + const btn = e.target.closest(".sub-tab[data-subtab]"); + if (!btn) return; + bar.querySelectorAll(".sub-tab").forEach((t) => t.classList.remove("active")); + btn.classList.add("active"); + const parent = bar.parentElement; + 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(); + } + }); + }); + window.addEventListener("resize", () => { + const mapTab = document.getElementById("tab-map"); + if (!mapTab || mapTab.style.display === "none") return; + sizeAprsMapToViewport(); + }); + function flushDeferredDecodeMapSync() { + if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; + T.decodeMapSyncPending = false; + scheduleUiFrameJob("decode-map-maintenance", () => { + pruneMapHistory(); + }); + } + function autoInitIfVisible() { + const panel = document.getElementById("tab-map"); + if (panel && panel.style.display !== "none" && typeof _initMapWhenReady === "function") { + _initMapWhenReady(); + } + } + modules.map = { + initAprsMap, + sizeAprsMapToViewport, + syncAprsReceiverMarker, + updateMapRigFilter, + updateStatsRigFilter, + statsRecordDecode, + scheduleStatsRender, + get aprsMap() { + return aprsMap; + }, + get stationMarkers() { + return stationMarkers; + }, + get locatorMarkers() { + return locatorMarkers; + }, + get aisMarkers() { + return aisMarkers; + }, + get vdesMarkers() { + return vdesMarkers; + }, + get decodeContactPaths() { + return decodeContactPaths; + }, + pruneMapHistory, + aprsSymbolIcon, + buildAprsPopupHtml, + buildAisPopupHtml, + buildVdesPopupHtml, + ensureAprsMarker, + ensureAisMarker, + ensureVdesMarker, + ensureDecodeLocatorMarker, + aprsPositionsEqual, + aisPositionsEqual, + refreshAprsTrack, + refreshAisTrack, + updateAisMarker, + createAisMarker, + getAisAccentColor, + refreshAisMarkerColors, + setMapRadioPathTo, + buildReceiverPopupHtml, + rebuildDecodeContactPaths, + syncDecodeContactPathVisibility, + scheduleDecodeMapMaintenance, + renderMapLocatorLegend, + rebuildMapLocatorFilters, + renderMapQsoSummary, + renderMapSignalSummary, + renderMapWeakSignalSummary, + vdesMarkerKey, + aisMarkerOptionsFromMessage, + materializeBufferedMapLayers, + flushDeferredDecodeMapSync, + bandForHz, + reverseGeocodeLocation + }; + autoInitIfVisible(); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js new file mode 100644 index 00000000..47709cdd --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js @@ -0,0 +1,256 @@ +"use strict"; +(function() { + "use strict"; + const dom = { + enabled: document.getElementById("scheduler-sat-enabled"), + pretune: document.getElementById("scheduler-sat-pretune"), + body: document.getElementById("scheduler-sat-body"), + tbody: document.getElementById("scheduler-sat-tbody"), + addBtn: document.getElementById("scheduler-sat-add-btn"), + passStatus: document.getElementById("scheduler-sat-pass-status"), + formWrap: document.getElementById("sch-sat-form-wrap"), + formTitle: document.getElementById("sch-sat-form-title"), + form: document.getElementById("sch-sat-form"), + formCancel: document.getElementById("sch-sat-form-cancel"), + preset: document.getElementById("scheduler-sat-preset"), + name: document.getElementById("scheduler-sat-name"), + norad: document.getElementById("scheduler-sat-norad"), + bookmark: document.getElementById("scheduler-sat-bookmark"), + minEl: document.getElementById("scheduler-sat-min-el"), + priority: document.getElementById("scheduler-sat-priority"), + centerHz: document.getElementById("scheduler-sat-center-hz") + }; + let editIdx = null; + function getBridge() { + return window.schedulerBridge || {}; + } + function getConfig() { + const b = getBridge(); + return typeof b.getConfig === "function" ? b.getConfig() : null; + } + function getStatus() { + const b = getBridge(); + return typeof b.getStatus === "function" ? b.getStatus() : null; + } + function getBookmarks() { + const b = getBridge(); + return typeof b.getBookmarks === "function" ? b.getBookmarks() : []; + } + function markDirty() { + var b = getBridge(); + if (typeof b.markDirty === "function") b.markDirty(); + } + function bmName(id) { + const bm = getBookmarks().find(function(b) { + return b.id === id; + }); + return bm ? bm.name : String(id || ""); + } + function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return hz + " Hz"; + } + function getSatelliteEntries() { + var config = getConfig(); + return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : []; + } + function ensureSatelliteConfig() { + var config = getConfig(); + if (!config) return { enabled: false, pretune_secs: 60, entries: [] }; + if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] }; + if (!config.satellites.entries) config.satellites.entries = []; + return config.satellites; + } + function collectSatelliteConfig() { + var enabled = dom.enabled ? dom.enabled.checked : false; + var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60; + return { + enabled, + pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune, + entries: getSatelliteEntries() + }; + } + function renderSection() { + var config = getConfig(); + var satCfg = config && config.satellites || {}; + var enabled = !!satCfg.enabled; + if (dom.enabled) dom.enabled.checked = enabled; + if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60; + if (dom.body) dom.body.style.display = enabled ? "" : "none"; + renderEntries(); + renderPassStatus(); + } + function renderEntries() { + if (!dom.tbody) return; + var entries = getSatelliteEntries(); + var frag = document.createDocumentFragment(); + entries.forEach(function(entry, idx) { + var tr = document.createElement("tr"); + var tdSat = document.createElement("td"); + tdSat.textContent = entry.satellite || ""; + tr.appendChild(tdSat); + var tdNorad = document.createElement("td"); + tdNorad.textContent = entry.norad_id || ""; + tr.appendChild(tdNorad); + var tdBm = document.createElement("td"); + tdBm.textContent = bmName(entry.bookmark_id); + tr.appendChild(tdBm); + var tdEl = document.createElement("td"); + tdEl.textContent = entry.min_elevation_deg != null ? entry.min_elevation_deg + "Β°" : "5Β°"; + tr.appendChild(tdEl); + var tdPrio = document.createElement("td"); + tdPrio.textContent = entry.priority || 0; + tr.appendChild(tdPrio); + var tdActions = document.createElement("td"); + var editBtn = document.createElement("button"); + editBtn.className = "sch-write"; + editBtn.type = "button"; + editBtn.textContent = "Edit"; + editBtn.addEventListener("click", function() { + openForm(entry, idx); + }); + tdActions.appendChild(editBtn); + var removeBtn = document.createElement("button"); + removeBtn.className = "sch-write"; + removeBtn.type = "button"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", function() { + removeEntry(idx); + }); + tdActions.appendChild(removeBtn); + tr.appendChild(tdActions); + frag.appendChild(tr); + }); + dom.tbody.replaceChildren(frag); + } + function renderPassStatus() { + if (!dom.passStatus) return; + var entries = getSatelliteEntries(); + if (entries.length === 0) { + dom.passStatus.innerHTML = ""; + return; + } + var status = getStatus(); + if (status && status.active_satellite) { + dom.passStatus.innerHTML = 'PASS ACTIVE: ' + escHtml(status.active_satellite) + ""; + } else { + dom.passStatus.innerHTML = 'No satellite pass active. Predictions available in the SAT tab.'; + } + } + function renderBookmarkSelect(selectedId) { + if (!dom.bookmark) return; + dom.bookmark.innerHTML = ''; + getBookmarks().forEach(function(bm) { + var opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + dom.bookmark.appendChild(opt); + }); + } + function removeEntry(idx) { + var sat = ensureSatelliteConfig(); + sat.entries.splice(idx, 1); + renderEntries(); + markDirty(); + } + function openForm(entry, idx) { + editIdx = idx != null ? idx : null; + if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite"; + if (dom.preset) dom.preset.value = ""; + if (dom.name) dom.name.value = entry ? entry.satellite || "" : ""; + if (dom.norad) dom.norad.value = entry ? entry.norad_id || "" : ""; + if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5; + if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0; + if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : ""; + renderBookmarkSelect(entry ? entry.bookmark_id : null); + if (dom.formWrap) { + dom.formWrap.style.display = "flex"; + if (dom.name) dom.name.focus(); + } + } + function closeForm() { + if (dom.formWrap) dom.formWrap.style.display = "none"; + editIdx = null; + } + function onFormSubmit(e) { + e.preventDefault(); + var satellite = dom.name ? dom.name.value.trim() : ""; + var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN; + var bmId = dom.bookmark ? dom.bookmark.value : ""; + if (!satellite) { + window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); + document.getElementById("scheduler-sat-name")?.focus(); + return; + } + if (isNaN(noradId) || noradId <= 0) { + window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); + document.getElementById("scheduler-sat-norad")?.focus(); + return; + } + if (!bmId) { + window.trxUi?.notify("Select a bookmark.", { kind: "error" }); + document.getElementById("scheduler-sat-bookmark")?.focus(); + return; + } + var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5; + var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0; + var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN; + var sat = ensureSatelliteConfig(); + var entryData = { + satellite, + norad_id: noradId, + bookmark_id: bmId, + min_elevation_deg: isNaN(minEl) ? 5 : minEl, + priority: isNaN(prio) ? 0 : prio, + center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null, + bookmark_ids: [] + }; + if (editIdx !== null) { + var existing = sat.entries[editIdx]; + entryData.id = existing ? existing.id : "sat_" + Date.now().toString(36); + sat.entries[editIdx] = entryData; + } else { + entryData.id = "sat_" + Date.now().toString(36); + sat.entries.push(entryData); + } + closeForm(); + renderEntries(); + markDirty(); + } + function onPresetChange() { + if (!dom.preset || !dom.preset.value) return; + var parts = dom.preset.value.split("|"); + if (dom.name) dom.name.value = parts[0] || ""; + if (dom.norad) dom.norad.value = parts[1] || ""; + } + function wireEvents() { + if (dom.enabled) { + dom.enabled.addEventListener("change", function() { + if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none"; + markDirty(); + }); + } + if (dom.pretune) { + dom.pretune.addEventListener("input", function() { + markDirty(); + }); + } + if (dom.addBtn) dom.addBtn.addEventListener("click", function() { + openForm(null, null); + }); + if (dom.form) dom.form.addEventListener("submit", onFormSubmit); + if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm); + if (dom.preset) dom.preset.addEventListener("change", onPresetChange); + } + window.satScheduler = { + wireEvents, + renderSection, + renderPassStatus, + collectSatelliteConfig + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js new file mode 100644 index 00000000..6d7e98bc --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js @@ -0,0 +1,447 @@ +"use strict"; +const satDom = { + status: document.getElementById("sat-status"), + liveView: document.getElementById("sat-live-view"), + historyView: document.getElementById("sat-history-view"), + predictionsView: document.getElementById("sat-predictions-view"), + liveLatest: document.getElementById("sat-live-latest"), + historyList: document.getElementById("sat-history-list"), + historyCount: document.getElementById("sat-history-count"), + filterInput: document.getElementById("sat-filter"), + sortSelect: document.getElementById("sat-sort"), + typeFilter: document.getElementById("sat-type-filter"), + lrptState: document.getElementById("sat-lrpt-state"), + viewLiveBtn: document.getElementById("sat-view-live"), + viewHistoryBtn: document.getElementById("sat-view-history"), + viewPredBtn: document.getElementById("sat-view-predictions"), + predFilter: document.getElementById("sat-pred-filter"), + predMinEl: document.getElementById("sat-pred-min-el"), + predCategory: document.getElementById("sat-pred-category"), + predCurrentList: document.getElementById("sat-pred-current-list"), + predUpcomingList: document.getElementById("sat-pred-list"), + predCurrentSec: document.getElementById("sat-pred-current-section"), + predUpcomingSec: document.getElementById("sat-pred-upcoming-section"), + predStatus: document.getElementById("sat-pred-status") +}; +let satImageHistory = []; +const SAT_MAX_IMAGES = 100; +const SAT_PRED_PAGE_SIZE = 50; +let satPredShowAll = false; +let satFilterText = ""; +let satActiveView = "live"; +let satPredData = []; +let satPredFilterText = ""; +let satPredMinEl = 0; +let satPredCategory = "all"; +let satPredSatCount = 0; +let satPredCountdownTimer = null; +function scheduleSatUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function switchSatView(view) { + const leavingPredictions = satActiveView === "predictions" && view !== "predictions"; + satActiveView = view; + if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; + if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; + if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none"; + if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); + if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); + if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions"); + if (leavingPredictions) clearPredictionDom(); + if (view === "history") { + renderSatHistoryTable(); + } else if (view === "predictions") { + satPredShowAll = false; + loadSatPredictions(); + } +} +function clearPredictionDom() { + stopCountdownTimer(); + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; +} +window.clearSatPredictionDom = clearPredictionDom; +satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live")); +satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history")); +satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions")); +let _lastSatLrptOn = null; +window.updateSatLiveState = function(update) { + if (!satDom.lrptState) return; + const lrptOn = !!update.lrpt_decode_enabled; + if (lrptOn !== _lastSatLrptOn) { + _lastSatLrptOn = lrptOn; + satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle"; + satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle"); + if (satDom.status) { + if (lrptOn) { + satDom.status.textContent = "Decoder active β€” waiting for signal"; + } else { + satDom.status.textContent = "Decoder idle"; + } + } + } +}; +function renderSatLatestCard() { + if (!satDom.liveLatest) return; + if (satImageHistory.length === 0) { + satDom.liveLatest.innerHTML = '
No images decoded yet. Enable a decoder and wait for a satellite pass.
'; + return; + } + const img = satImageHistory[0]; + const decoder = img._decoder || "unknown"; + const typeName = "Meteor LRPT"; + const satellite = img.satellite || ""; + const channels = img.channels || img.channel_a || ""; + const lines = img.mcu_count || img.line_count || 0; + const unit = "MCU rows"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; + let meta = [typeName]; + if (satellite) meta.push(satellite); + if (channels) meta.push(channels); + meta.push(`${lines} ${unit}`); + meta.push(`${date} ${ts}`); + let html = `
`; + html += `
Latest decoded image
`; + html += `
${meta.join(" · ")}
`; + if (img.path) { + html += `Download PNG`; + } + if (img.geo_bounds) { + html += ` `; + } + html += `
`; + satDom.liveLatest.innerHTML = html; +} +function getSatFilteredHistory() { + let items = satImageHistory; + const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all"; + if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt"); + if (satFilterText) { + items = items.filter((i) => { + const haystack = [ + "meteor lrpt", + i.satellite || "", + i.channels || "", + i.channel_a || "", + i.channel_b || "" + ].join(" ").toUpperCase(); + return haystack.includes(satFilterText); + }); + } + const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest"; + if (sortVal === "oldest") items = items.slice().reverse(); + return items; +} +function renderSatHistoryRow(img) { + const row = document.createElement("div"); + row.className = "sat-history-row"; + const decoder = img._decoder || "unknown"; + const typeName = "Meteor LRPT"; + const typeClass = "sat-type-lrpt"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; + const satellite = img.satellite || "--"; + const channels = img.channels || "--"; + const lines = img.mcu_count || img.line_count || 0; + const unit = "MCU"; + let link = img.path ? `PNG` : "--"; + if (img.geo_bounds) { + link += ` Map`; + } + row.innerHTML = [ + `${date} ${ts}`, + `${typeName}`, + `${satellite}`, + `${channels}`, + `${lines} ${unit}`, + `${link}` + ].join(""); + return row; +} +function renderSatHistoryTable() { + if (!satDom.historyList) return; + const items = getSatFilteredHistory(); + const fragment = document.createDocumentFragment(); + for (let i = 0; i < items.length; i += 1) { + fragment.appendChild(renderSatHistoryRow(items[i])); + } + satDom.historyList.replaceChildren(fragment); + if (satDom.historyCount) { + const total = satImageHistory.length; + const shown = items.length; + satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`; + } +} +function addSatImage(img, decoder) { + const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now(); + img._tsMs = tsMs; + img._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + img._decoder = decoder; + satImageHistory.unshift(img); + if (satImageHistory.length > SAT_MAX_IMAGES) { + satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES); + } + scheduleSatUi("sat-latest", () => renderSatLatestCard()); + if (satActiveView === "history") { + scheduleSatUi("sat-history", () => renderSatHistoryTable()); + } +} +window.onServerLrptProgress = function(msg) { + if (satDom.status && msg.mcu_count > 0) { + satDom.status.textContent = "Receiving β€” " + msg.mcu_count + " MCU rows decoded"; + } +}; +window.onServerLrptImage = function(msg) { + if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)"; + addSatImage(msg, "lrpt"); + if (msg.geo_bounds && msg.path && window.addSatMapOverlay) { + window.addSatMapOverlay(msg); + } +}; +window.resetSatHistoryView = function() { + satImageHistory = []; + if (satDom.historyList) satDom.historyList.innerHTML = ""; + renderSatLatestCard(); + renderSatHistoryTable(); + if (window.clearSatMapOverlays) window.clearSatMapOverlays(); +}; +window.pruneSatHistoryView = function() { + renderSatHistoryTable(); + renderSatLatestCard(); +}; +const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); +lrptDecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); + await postPath("/toggle_lrpt_decode"); + } catch (e) { + console.error("LRPT toggle failed", e); + } +}); +satDom.filterInput?.addEventListener("input", () => { + satFilterText = satDom.filterInput.value.trim().toUpperCase(); + renderSatHistoryTable(); +}); +satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable()); +satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable()); +document.getElementById("settings-clear-sat-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_lrpt_decode"); + window.resetSatHistoryView(); + } catch (e) { + console.error("Weather satellite history clear failed", e); + } +}); +function azToCardinal(deg) { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(deg / 45) % 8]; +} +function formatPredTime(ms) { + const d = new Date(ms); + const now = /* @__PURE__ */ new Date(); + const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : ""; + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + return `${day}${hh}:${mm}`; +} +function formatPredDuration(s) { + if (s >= 60) return `${Math.round(s / 60)} min`; + return `${s}s`; +} +function formatCountdown(ms) { + const totalSec = Math.max(0, Math.floor(ms / 1e3)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} +function elevationClass(deg) { + if (deg >= 45) return "sat-pred-el-high"; + if (deg >= 10) return "sat-pred-el-mid"; + return "sat-pred-el-low"; +} +function stopCountdownTimer() { + if (satPredCountdownTimer) { + clearInterval(satPredCountdownTimer); + satPredCountdownTimer = null; + } +} +function startCountdownTimer(container) { + const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : []; + if (countdownEls.length === 0) return; + satPredCountdownTimer = setInterval(() => { + if (satActiveView !== "predictions") { + stopCountdownTimer(); + return; + } + const n = Date.now(); + let anyActive = false; + for (const el of countdownEls) { + const los = parseInt(el.dataset.los, 10); + const rem = los - n; + if (rem > 0) { + el.textContent = formatCountdown(rem); + anyActive = true; + } else { + el.textContent = "0:00"; + } + } + if (!anyActive) { + stopCountdownTimer(); + renderSatPredictions(getFilteredPredictions()); + } + }, 1e3); +} +function buildCurrentPassRow(pass, now) { + const row = document.createElement("div"); + row.className = "sat-pred-row-current"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} β†’ ${azToCardinal(pass.azimuth_los_deg)}`; + const remaining = Math.max(0, pass.los_ms - now); + row.innerHTML = [ + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}Β°`, + `${formatPredTime(pass.aos_ms)}`, + `${formatPredTime(pass.los_ms)}`, + `${formatCountdown(remaining)}`, + `${dir}` + ].join(""); + return row; +} +function buildUpcomingPassRow(pass) { + const row = document.createElement("div"); + row.className = "sat-pred-row"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} β†’ ${azToCardinal(pass.azimuth_los_deg)}`; + row.innerHTML = [ + `${formatPredTime(pass.aos_ms)}`, + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}Β°`, + `${formatPredDuration(pass.duration_s)}`, + `${dir}` + ].join(""); + return row; +} +function getFilteredPredictions() { + let items = satPredData; + if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory); + if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl); + if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText)); + return items; +} +function applyPredFilters() { + renderSatPredictions(getFilteredPredictions()); +} +satDom.predFilter?.addEventListener("input", () => { + satPredFilterText = satDom.predFilter.value.trim().toUpperCase(); + applyPredFilters(); +}); +satDom.predMinEl?.addEventListener("change", () => { + satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0; + applyPredFilters(); +}); +satDom.predCategory?.addEventListener("change", () => { + satPredCategory = satDom.predCategory.value; + applyPredFilters(); +}); +function renderSatPredictions(passes, error) { + stopCountdownTimer(); + if (error) { + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none"; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none"; + if (satDom.predStatus) satDom.predStatus.textContent = error; + return; + } + if (!Array.isArray(passes) || passes.length === 0) { + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none"; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none"; + if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours."; + return; + } + const now = Date.now(); + const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now); + const upcoming = passes.filter((p) => p.aos_ms > now); + if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none"; + if (satDom.predCurrentList) { + if (current.length === 0) { + satDom.predCurrentList.innerHTML = ""; + } else { + const frag = document.createDocumentFragment(); + for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now)); + satDom.predCurrentList.replaceChildren(frag); + } + } + const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE; + const visibleUpcoming = upcoming.slice(0, upcomingLimit); + const hiddenCount = upcoming.length - visibleUpcoming.length; + if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none"; + if (satDom.predUpcomingList) { + const frag = document.createDocumentFragment(); + for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass)); + if (hiddenCount > 0) { + const moreRow = document.createElement("div"); + moreRow.className = "sat-pred-row"; + moreRow.style.cursor = "pointer"; + moreRow.style.textAlign = "center"; + moreRow.innerHTML = `Show ${hiddenCount} more passes…`; + moreRow.addEventListener("click", () => { + satPredShowAll = true; + renderSatPredictions(getFilteredPredictions()); + }); + frag.appendChild(moreRow); + } + satDom.predUpcomingList.replaceChildren(frag); + } + if (satDom.predStatus) { + let text = `${current.length} active Β· ${upcoming.length} upcoming Β· times in UTC`; + if (satPredSatCount > 0) text += ` Β· ${satPredSatCount} satellites tracked`; + satDom.predStatus.textContent = text; + } + if (current.length > 0 && satActiveView === "predictions") { + startCountdownTimer(satDom.predCurrentList); + } +} +async function loadSatPredictions() { + if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…"; + if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; + if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; + try { + const resp = await fetch("/sat_passes"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + satPredSatCount = data.satellite_count || 0; + if (data.error) { + satPredData = []; + renderSatPredictions([], data.error); + } else { + satPredData = data.passes || []; + renderSatPredictions(getFilteredPredictions()); + } + } catch (e) { + renderSatPredictions([], `Failed to load predictions: ${e.message}`); + } +} +window.satShowOnMap = function(south, west, north, east) { + if (typeof window.enableMapSourceFilter === "function") { + window.enableMapSourceFilter("sat"); + } + const lat = (south + north) / 2; + const lon = (west + east) / 2; + if (window.navigateToAprsMap) { + window.navigateToAprsMap(lat, lon); + } +}; +renderSatLatestCard(); +renderSatHistoryTable(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js new file mode 100644 index 00000000..e5493205 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -0,0 +1,1195 @@ +"use strict"; +(function() { + "use strict"; + let schedulerRole = null; + let currentRigId = null; + let currentConfig = null; + let currentSchedulerStatus = null; + let bookmarkList = []; + let statusInterval = null; + let interleaveTicker = null; + let schedulerStepPending = false; + let schEntryEditIdx = null; + let schedulerDirty = false; + function initScheduler(rigId, role) { + schedulerRole = role; + currentRigId = rigId || null; + if (currentRigId) loadScheduler(); + startStatusPolling(); + startInterleaveTicker(); + } + function destroyScheduler() { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; + } + if (interleaveTicker) { + clearInterval(interleaveTicker); + interleaveTicker = null; + } + } + function setSchedulerRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + renderSchedulerInterleaveStatus(); + if (!currentRigId) return; + loadScheduler(); + pollStatus(); + } + function apiGetScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiPutScheduler(rigId, config) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiDeleteScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetStatus(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( + function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + } + ); + } + function apiActivateSchedulerEntry(rigId, entryId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_id: entryId }) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetBookmarks() { + var url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; + return fetch(url).then(function(r) { + return r.ok ? r.json() : []; + }); + } + function loadScheduler() { + const rig = currentRigId; + if (!rig) return; + Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { + currentConfig = config; + bookmarkList = Array.isArray(bms) ? bms : []; + populateTsBookmarkSelect(); + renderScheduler(); + clearSchedulerDirty(); + renderSchedulerInterleaveStatus(); + }).catch(function(e) { + console.error("scheduler load failed", e); + renderSchedulerInterleaveStatus(); + }); + } + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollStatus, 15e3); + pollStatus(); + } + function startInterleaveTicker() { + if (interleaveTicker) clearInterval(interleaveTicker); + interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); + renderSchedulerInterleaveStatus(); + } + function schedulerUtcSeconds() { + return Math.floor(Date.now() / 1e3); + } + function schedulerUtcMinuteInfo() { + const secs = schedulerUtcSeconds(); + const secsIntoDay = (secs % 86400 + 86400) % 86400; + return { + minuteOfDay: Math.floor(secsIntoDay / 60), + secondOfMinute: secsIntoDay % 60 + }; + } + function schedulerEntryIsActive(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + if (start === end) return true; + if (start < end) return nowMin >= start && nowMin < end; + return nowMin >= start || nowMin < end; + } + function schedulerEntryCurrentWindowStart(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; + if (start === end) return 0; + if (start < end) return start; + return nowMin >= start ? start : start - 1440; + } + function schedulerEntryDisplayName(entry) { + if (!entry) return "Scheduler entry"; + if (entry.label) return String(entry.label); + const bookmarkName = bmName(entry.bookmark_id); + return bookmarkName || "Scheduler entry"; + } + function schedulerInterleaveState(config) { + if (!config || config.mode !== "time_span") { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + const entries = Array.isArray(config.entries) ? config.entries : []; + const minuteInfo = schedulerUtcMinuteInfo(); + const nowMin = minuteInfo.minuteOfDay; + const active = entries.filter(function(entry) { + return schedulerEntryIsActive(entry, nowMin); + }); + if (active.length === 0) { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + if (active.length === 1) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + var exclIdx = active.findIndex(function(e) { + return e.exclusive; + }); + if (exclIdx >= 0) { + return { activeEntries: [active[exclIdx]], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const defaultInterleave = Number(config.interleave_min); + const durations = active.map(function(entry) { + const own = Number(entry && entry.interleave_min); + if (Number.isFinite(own) && own > 0) return Math.floor(own); + if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); + return 0; + }); + const cycleMin = durations.reduce(function(sum, value) { + return sum + value; + }, 0); + if (!(cycleMin > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; + const statusIndex = statusEntryId ? active.findIndex(function(entry) { + return String(entry && entry.id || "") === statusEntryId; + }) : -1; + const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; + if (statusIndex >= 0 && statusAppliedUtc != null) { + const manualDurationMin = durations[statusIndex]; + const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); + const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; + if (remainingSec2 > 0) { + return { + activeEntries: active, + currentIndex: statusIndex, + remainingSec: remainingSec2, + cycleMin + }; + } + } + const overlapStart = active.reduce(function(maxStart, entry) { + return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); + }, Number.NEGATIVE_INFINITY); + if (!Number.isFinite(overlapStart)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; + const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; + let cumulative = 0; + let slotStart = 0; + let currentIndex = 0; + let currentDuration = 0; + for (let i = 0; i < durations.length; i += 1) { + const nextCumulative = cumulative + durations[i]; + if (posMin < nextCumulative) { + slotStart = cumulative; + cumulative = nextCumulative; + currentIndex = i; + currentDuration = durations[i]; + break; + } + cumulative = nextCumulative; + } + if (!(currentDuration > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); + const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); + return { + activeEntries: active, + currentIndex, + remainingSec, + cycleMin + }; + } + function renderSchedulerInterleaveStatus() { + const wrap = document.getElementById("scheduler-cycle-status"); + if (!wrap) return; + const state = schedulerInterleaveState(currentConfig); + const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; + wrap.style.display = isActive ? "" : "none"; + if (isActive) { + var activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + var totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; + var elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; + var ringFill = document.getElementById("interleave-ring-fill"); + if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); + var nameEl = document.getElementById("interleave-active-name"); + if (nameEl) nameEl.textContent = activeName; + var countdownEl = document.getElementById("interleave-countdown"); + if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s Β· " + state.cycleMin + "m cycle"; + } + renderTimelineNeedle(); + renderSchedulerStepControls(); + } + function renderSchedulerStepControls() { + const prevBtn = document.getElementById("scheduler-prev-btn"); + const nextBtn = document.getElementById("scheduler-next-btn"); + if (!prevBtn || !nextBtn) return; + const state = schedulerInterleaveState(currentConfig); + const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + prevBtn.disabled = !enabled; + nextBtn.disabled = !enabled; + const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; + prevBtn.title = hint; + nextBtn.title = hint; + } + function pollStatus() { + const rig = currentRigId; + if (!rig) return; + apiGetStatus(rig).then(function(st) { + currentSchedulerStatus = st || null; + renderStatus(st); + renderSchedulerInterleaveStatus(); + renderActivityLog(); + renderSatPassStatus(); + }).catch(function() { + }); + } + function renderStatus(st) { + const el = document.getElementById("scheduler-status-card"); + if (!el) return; + if (!st || !st.active && !st.last_bookmark_id) { + el.textContent = "No activity yet."; + return; + } + const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; + const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { + return String(item && item.id || "") === statusEntryId; + }) : null; + const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "β€”"; + let ts = ""; + if (st.last_applied_utc) { + const d = new Date(st.last_applied_utc * 1e3); + ts = " at " + d.toUTCString(); + } + const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; + var details = ""; + if (st.freq_hz) { + details += formatFreq(st.freq_hz); + if (st.mode) details += " Β· " + st.mode; + if (st.active_decoders && st.active_decoders.length > 0) { + details += " Β· " + st.active_decoders.join(", ") + " active"; + } + } + if (details) { + el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; + } else { + el.textContent = "Last applied: " + name + satLabel + ts; + } + } + function apiGetSchedulerLog(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { + return r.ok ? r.json() : []; + }); + } + function renderActivityLog() { + var wrap = document.getElementById("scheduler-activity-log-wrap"); + var container = document.getElementById("scheduler-activity-log"); + if (!wrap || !container || !currentRigId) return; + apiGetSchedulerLog(currentRigId).then(function(entries) { + if (!entries || entries.length === 0) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + var html = entries.slice().reverse().map(function(e) { + var d = new Date(e.utc * 1e3); + var ts = d.toUTCString(); + var action = e.action || "unknown"; + var label = e.entry_label || ""; + var bm = e.bookmark_name || ""; + return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; + }).join(""); + container.innerHTML = html; + }).catch(function() { + }); + } + function renderScheduler() { + const panel = document.getElementById("scheduler-panel"); + if (!panel) return; + const mode = currentConfig && currentConfig.mode || "disabled"; + const isControl = schedulerRole === "control"; + setSelected("scheduler-mode-select", mode); + const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; + const controlRow = document.querySelector(".scheduler-control-row"); + if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; + const glSection = document.getElementById("scheduler-grayline-section"); + const tsSection = document.getElementById("scheduler-timespan-section"); + if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; + if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; + renderSatelliteSection(); + if (mode === "grayline" && currentConfig && currentConfig.grayline) { + const gl = currentConfig.grayline; + const lat = gl.lat != null ? gl.lat : typeof serverLat !== "undefined" ? serverLat : ""; + const lon = gl.lon != null ? gl.lon : typeof serverLon !== "undefined" ? serverLon : ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + var gridEl = document.getElementById("scheduler-gl-grid"); + if (gridEl && lat !== "" && lon !== "") { + gridEl.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); + renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); + renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); + renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); + renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); + } else if (mode === "grayline") { + const lat = typeof serverLat !== "undefined" ? serverLat : ""; + const lon = typeof serverLon !== "undefined" ? serverLon : ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + var gridEl2 = document.getElementById("scheduler-gl-grid"); + if (gridEl2 && lat !== "" && lon !== "") { + gridEl2.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", 20); + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } else { + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } + const ilEl = document.getElementById("scheduler-ts-interleave"); + if (ilEl) { + const il = currentConfig && currentConfig.interleave_min; + ilEl.value = il ? il : ""; + } + renderTimespanEntries(); + const formEls = panel.querySelectorAll("input, select, button.sch-write"); + formEls.forEach(function(el) { + el.disabled = !isControl; + }); + const saveBtn = document.getElementById("scheduler-save-btn"); + if (saveBtn) { + saveBtn.style.display = isControl ? "" : "none"; + } + const resetBtn = document.getElementById("scheduler-reset-btn"); + if (resetBtn) { + resetBtn.style.display = isControl ? "" : "none"; + } + } + function setSelected(id, value) { + const el = document.getElementById(id); + if (el) el.value = value; + } + function setInputValue(id, value) { + const el = document.getElementById(id); + if (el) el.value = value; + } + function renderBookmarkSelect(id, selectedId) { + const sel = document.getElementById(id); + if (!sel) return; + sel.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return hz + " Hz"; + } + function schOpenEntryForm(entry, idx) { + schEntryEditIdx = idx != null ? idx : null; + const titleEl = document.getElementById("sch-entry-form-title"); + if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; + const startEl = document.getElementById("scheduler-ts-start"); + const endEl = document.getElementById("scheduler-ts-end"); + const bmEl = document.getElementById("scheduler-ts-bookmark"); + const labelEl = document.getElementById("scheduler-ts-label"); + const ilEl = document.getElementById("scheduler-ts-entry-interleave"); + const centerHzEl = document.getElementById("scheduler-ts-center-hz"); + if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; + if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; + if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; + if (labelEl) labelEl.value = entry ? entry.label || "" : ""; + if (ilEl) ilEl.value = entry && entry.interleave_min ? entry.interleave_min : ""; + if (centerHzEl) centerHzEl.value = entry && entry.center_hz ? entry.center_hz : ""; + const recordEl = document.getElementById("scheduler-ts-entry-record"); + if (recordEl) recordEl.checked = !!(entry && entry.record); + pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + renderExtraBmList(); + const wrap = document.getElementById("sch-entry-form-wrap"); + if (wrap) { + wrap.style.display = "block"; + if (startEl) startEl.focus(); + } + } + function schCloseEntryForm() { + const wrap = document.getElementById("sch-entry-form-wrap"); + if (wrap) wrap.style.display = "none"; + schEntryEditIdx = null; + pendingExtraBmIds = []; + } + function schEntryFormSubmit(e) { + e.preventDefault(); + const startEl = document.getElementById("scheduler-ts-start"); + const endEl = document.getElementById("scheduler-ts-end"); + const bmEl = document.getElementById("scheduler-ts-bookmark"); + const labelEl = document.getElementById("scheduler-ts-label"); + const ilEl = document.getElementById("scheduler-ts-entry-interleave"); + const centerHzEl = document.getElementById("scheduler-ts-center-hz"); + if (!startEl || !endEl || !bmEl) return; + const bmId = bmEl.value; + if (!bmId) { + window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" }); + return; + } + const startMin = hhmmToMin(startEl.value); + const endMin = hhmmToMin(endEl.value); + const label = labelEl ? labelEl.value.trim() : ""; + const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; + const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; + const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; + const extraBmIds = pendingExtraBmIds.slice(); + if (!currentConfig) { + currentConfig = { remote: currentRigId, mode: "time_span", entries: [] }; + } + if (!currentConfig.entries) currentConfig.entries = []; + const recordCb = document.getElementById("scheduler-ts-entry-record"); + const entryRecord = recordCb ? recordCb.checked : false; + const entryData = { + start_min: startMin, + end_min: endMin, + bookmark_id: bmId, + label: label || null, + interleave_min: entryInterleave, + center_hz: centerHz, + bookmark_ids: extraBmIds, + record: entryRecord + }; + if (schEntryEditIdx !== null) { + const existing = currentConfig.entries[schEntryEditIdx]; + entryData.id = existing ? existing.id : "ts_" + Date.now().toString(36); + currentConfig.entries[schEntryEditIdx] = entryData; + } else { + entryData.id = "ts_" + Date.now().toString(36); + currentConfig.entries.push(entryData); + } + schCloseEntryForm(); + renderTimespanEntries(); + markSchedulerDirty(); + } + var TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; + function renderTimeline() { + var container = document.getElementById("scheduler-ts-timeline"); + if (!container) return; + var entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + if (entries.length === 0) { + container.innerHTML = ""; + return; + } + var W = 1e3; + var H = 80; + var BAR_Y = 6; + var BAR_H = 30; + var TICK_Y = BAR_Y + BAR_H + 2; + var svg = ''; + svg += ''; + entries.forEach(function(entry, idx) { + var start = Number(entry.start_min); + var end = Number(entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + var color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; + if (start === end) { + svg += ''; + } else if (start < end) { + var x = start / 1440 * W; + var w = (end - start) / 1440 * W; + svg += ''; + } else { + var x1 = start / 1440 * W; + var w1 = W - x1; + svg += ''; + var w2 = end / 1440 * W; + svg += ''; + } + }); + var interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; + if (interleaveMin > 0 && entries.length > 1) { + for (var m = 0; m < 1440; m += interleaveMin) { + var overlapping = []; + entries.forEach(function(entry, idx) { + if (schedulerEntryIsActive(entry, m)) { + overlapping.push(idx); + } + }); + if (overlapping.length > 1) { + var stripeX = m / 1440 * W; + var stripeW = Math.max(1, interleaveMin / 1440 * W); + var cyclePos = m % (interleaveMin * overlapping.length); + var ownerSlot = Math.floor(cyclePos / interleaveMin); + var ownerIdx = overlapping[ownerSlot % overlapping.length]; + var stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; + svg += ''; + } + } + } + for (var h = 0; h <= 24; h += 3) { + var tx = h / 24 * W; + svg += ''; + if (h < 24) { + svg += '' + String(h).padStart(2, "0") + ""; + } + } + var LOCAL_TICK_Y = TICK_Y + 18; + for (var h = 0; h < 24; h += 3) { + var localMin = h * 60; + var utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); + var utcMin = (localMin + utcOffset + 1440) % 1440; + var tx = utcMin / 1440 * W; + svg += '' + String(h).padStart(2, "0") + "L"; + } + svg += '' + timelineNeedleSvg() + ""; + svg += ""; + container.innerHTML = svg; + container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { + seg.addEventListener("click", function() { + var i = parseInt(seg.getAttribute("data-idx"), 10); + var entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + if (entry) schOpenEntryForm(entry, i); + }); + }); + var svgEl = container.querySelector("svg"); + if (svgEl) { + svgEl.addEventListener("click", function(e) { + if (e.target.classList.contains("sch-timeline-seg")) return; + var rect = svgEl.getBoundingClientRect(); + var xPct = (e.clientX - rect.left) / rect.width; + var clickMin = Math.floor(xPct * 1440); + var startHour = Math.floor(clickMin / 60); + var startMin = startHour * 60; + var endMin = (startHour + 1) % 24 * 60; + schOpenEntryForm(null, null); + var startEl = document.getElementById("scheduler-ts-start"); + var endEl = document.getElementById("scheduler-ts-end"); + if (startEl) startEl.value = minToHHMM(startMin); + if (endEl) endEl.value = minToHHMM(endMin); + }); + svgEl.style.cursor = "crosshair"; + } + } + function timelineNeedleSvg() { + var info = schedulerUtcMinuteInfo(); + var nowMin = info.minuteOfDay + info.secondOfMinute / 60; + var x = nowMin / 1440 * 1e3; + return ''; + } + function renderTimelineNeedle() { + var g = document.getElementById("sch-timeline-needle-g"); + if (g) g.innerHTML = timelineNeedleSvg(); + } + function schInlineEdit(tr, entry, idx) { + var bmOptions = bookmarkList.map(function(bm) { + var sel = bm.id === entry.bookmark_id ? " selected" : ""; + return '"; + }).join(""); + var extraBmOptions = '' + bookmarkList.map(function(bm) { + return '"; + }).join(""); + var inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + tr.innerHTML = 'β ‡' + (entry.center_hz ? formatFreq(entry.center_hz) : "β€”") + '
'; + tr.classList.add("sch-inline-editing"); + var chipsContainer = tr.querySelector(".sch-inline-extra-chips"); + var extraPick = tr.querySelector(".sch-inline-extra-pick"); + var extraAddBtn = tr.querySelector(".sch-inline-extra-add"); + function renderInlineExtraChips() { + chipsContainer.innerHTML = ""; + inlineExtraIds.forEach(function(id, i) { + var chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + var rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "Γ—"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + inlineExtraIds.splice(i, 1); + renderInlineExtraChips(); + }); + chip.appendChild(rmBtn); + chip.appendChild(document.createTextNode(" " + bmName(id))); + chipsContainer.appendChild(chip); + }); + Array.from(extraPick.options).forEach(function(opt) { + if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); + }); + } + renderInlineExtraChips(); + extraAddBtn.addEventListener("click", function() { + if (!extraPick.value) return; + if (!inlineExtraIds.includes(extraPick.value)) { + inlineExtraIds.push(extraPick.value); + renderInlineExtraChips(); + } + extraPick.value = ""; + }); + var exclEl = tr.querySelector('[data-field="exclusive"]'); + var ilInput = tr.querySelector('[data-field="interleave"]'); + if (exclEl && ilInput) { + exclEl.addEventListener("change", function() { + ilInput.disabled = exclEl.checked; + if (exclEl.checked) ilInput.value = ""; + }); + } + tr.querySelector(".sch-inline-save").addEventListener("click", function() { + var startEl = tr.querySelector('[data-field="start"]'); + var endEl = tr.querySelector('[data-field="end"]'); + var bmEl = tr.querySelector('[data-field="bookmark"]'); + var labelEl = tr.querySelector('[data-field="label"]'); + var ilEl = tr.querySelector('[data-field="interleave"]'); + var recEl = tr.querySelector('[data-field="record"]'); + var exEl = tr.querySelector('[data-field="exclusive"]'); + if (bmEl && !bmEl.value) { + window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); + bmEl.focus(); + return; + } + entry.start_min = hhmmToMin(startEl.value); + entry.end_min = hhmmToMin(endEl.value); + entry.bookmark_id = bmEl.value; + entry.label = labelEl.value.trim() || null; + entry.exclusive = exEl ? exEl.checked : false; + var ilVal = parseInt(ilEl.value, 10); + entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + entry.bookmark_ids = inlineExtraIds.slice(); + entry.record = recEl.checked; + currentConfig.entries[idx] = entry; + renderTimespanEntries(); + markSchedulerDirty(); + }); + tr.querySelector(".sch-inline-cancel").addEventListener("click", function() { + renderTimespanEntries(); + }); + } + function renderTimespanEntries() { + const tbody = document.getElementById("scheduler-ts-tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + entries.forEach(function(entry, idx) { + const tr = document.createElement("tr"); + if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { + tr.classList.add("sch-active"); + } + const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "β€”"; + const allDay = entry.start_min === entry.end_min; + const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "β€”"; + const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + const extraCell = extraIds.length ? extraIds.map(function(id) { + return escHtml(bmName(id)); + }).join(", ") : "β€”"; + tr.innerHTML = 'β ‡' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "β€”" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const i = parseInt(btn.dataset.idx, 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + if (entry) schInlineEdit(btn.closest("tr"), entry, i); + }); + }); + tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + removeEntry(parseInt(btn.dataset.idx, 10)); + }); + }); + (function() { + var handles = tbody.querySelectorAll(".sch-drag-handle"); + var dragIdx = null; + handles.forEach(function(handle, idx) { + var row = handle.parentElement; + handle.addEventListener("dragstart", function(e) { + dragIdx = idx; + row.classList.add("sch-dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + }); + row.addEventListener("dragover", function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + row.classList.add("sch-drag-over"); + }); + row.addEventListener("dragleave", function() { + row.classList.remove("sch-drag-over"); + }); + row.addEventListener("drop", function(e) { + e.preventDefault(); + row.classList.remove("sch-drag-over"); + if (dragIdx === null || dragIdx === idx) return; + var entries2 = currentConfig.entries; + var moved = entries2.splice(dragIdx, 1)[0]; + entries2.splice(idx, 0, moved); + renderTimespanEntries(); + markSchedulerDirty(); + }); + handle.addEventListener("dragend", function() { + row.classList.remove("sch-dragging"); + dragIdx = null; + }); + }); + })(); + renderTimeline(); + } + function bmName(id) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + return bm ? bm.name : String(id || ""); + } + function minToLocal(min) { + var now = /* @__PURE__ */ new Date(); + var utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + var utcMs = utcMidnight.getTime() + min * 6e4; + var local = new Date(utcMs); + return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); + } + function minToHHMM(min) { + const h = Math.floor(min / 60) % 24; + const m = min % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + } + function hhmmToMin(str) { + const parts = str.split(":"); + return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); + } + function gridToLatLon(grid) { + grid = String(grid).toUpperCase().trim(); + if (grid.length < 4) return null; + var lonField = grid.charCodeAt(0) - 65; + var latField = grid.charCodeAt(1) - 65; + var lonSquare = parseInt(grid.charAt(2), 10); + var latSquare = parseInt(grid.charAt(3), 10); + if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; + var lon = lonField * 20 + lonSquare * 2 - 180; + var lat = latField * 10 + latSquare * 1 - 90; + if (grid.length >= 6) { + var lonSub = grid.charCodeAt(4) - 65; + var latSub = grid.charCodeAt(5) - 65; + if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { + lon += lonSub * (2 / 24) + 1 / 24; + lat += latSub * (1 / 24) + 0.5 / 24; + } + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; + } + function latLonToGrid(lat, lon) { + lon = parseFloat(lon) + 180; + lat = parseFloat(lat) + 90; + if (isNaN(lon) || isNaN(lat)) return ""; + var lonField = String.fromCharCode(65 + Math.floor(lon / 20)); + var latField = String.fromCharCode(65 + Math.floor(lat / 10)); + var lonSquare = Math.floor(lon % 20 / 2); + var latSquare = Math.floor(lat % 10); + var lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); + var latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); + return lonField + latField + lonSquare + latSquare + lonSub + latSub; + } + function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function schedulerSelectRelativeEntry(delta) { + const state = schedulerInterleaveState(currentConfig); + if (!currentRigId || schedulerStepPending || state.activeEntries.length <= 1) return; + const count = state.activeEntries.length; + const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; + const targetIndex = (currentIndex + delta + count) % count; + const target = state.activeEntries[targetIndex]; + if (!target || !target.id) return; + schedulerStepPending = true; + renderSchedulerStepControls(); + Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null).then(function() { + return apiActivateSchedulerEntry(currentRigId, target.id); + }).then(function(status) { + currentSchedulerStatus = status || null; + return Promise.resolve( + typeof vchanToggleSchedulerRelease === "function" ? vchanToggleSchedulerRelease() : null + ).then(function() { + renderStatus(status); + renderSchedulerInterleaveStatus(); + showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); + pollStatus(); + }); + }).catch(function(e) { + console.error("scheduler entry selection failed", e); + showSchedulerToast("Scheduler entry selection failed: " + e.message, true); + }).finally(function() { + schedulerStepPending = false; + renderSchedulerStepControls(); + }); + } + function removeEntry(idx) { + if (!currentConfig || !currentConfig.entries) return; + currentConfig.entries.splice(idx, 1); + renderTimespanEntries(); + markSchedulerDirty(); + } + function bookmarkExists(id) { + if (!id) return true; + return bookmarkList.some(function(bm) { + return bm.id === id; + }); + } + function saveScheduler() { + const rig = currentRigId; + if (!rig) return; + const modeEl = document.getElementById("scheduler-mode-select"); + const mode = modeEl ? modeEl.value : "disabled"; + const config = { + remote: rig, + mode, + grayline: null, + entries: [] + }; + if (mode === "grayline") { + const lat = parseFloat(document.getElementById("scheduler-gl-lat").value); + const lon = parseFloat(document.getElementById("scheduler-gl-lon").value); + const win = parseInt(document.getElementById("scheduler-gl-window").value, 10); + config.grayline = { + lat: isNaN(lat) ? 0 : lat, + lon: isNaN(lon) ? 0 : lon, + transition_window_min: isNaN(win) ? 20 : win, + dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, + day_bookmark_id: selectVal("scheduler-gl-day") || null, + dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, + night_bookmark_id: selectVal("scheduler-gl-night") || null + }; + } else if (mode === "time_span") { + config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; + const ilVal = parseInt(document.getElementById("scheduler-ts-interleave").value, 10); + config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; + } + config.satellites = collectSatelliteConfig(); + var missingBmErrors = []; + if (mode === "grayline" && config.grayline) { + var gl = config.grayline; + var glFields = [ + ["dawn_bookmark_id", "Grayline dawn"], + ["day_bookmark_id", "Grayline day"], + ["dusk_bookmark_id", "Grayline dusk"], + ["night_bookmark_id", "Grayline night"] + ]; + glFields.forEach(function(pair) { + if (!bookmarkExists(gl[pair[0]])) missingBmErrors.push(pair[1] + " (bookmark " + gl[pair[0]] + ")"); + }); + } + if (mode === "time_span" && Array.isArray(config.entries)) { + config.entries.forEach(function(entry, idx) { + var label = entry.label || "Entry #" + (idx + 1); + if (!bookmarkExists(entry.bookmark_id)) { + missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); + } + var extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + extras.forEach(function(id) { + if (!bookmarkExists(id)) { + missingBmErrors.push(label + " extra channel (" + id + ")"); + } + }); + }); + } + if (config.satellites && Array.isArray(config.satellites.entries)) { + config.satellites.entries.forEach(function(sat, idx) { + var satLabel = sat.name || "Satellite #" + (idx + 1); + if (!bookmarkExists(sat.bookmark_id)) { + missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); + } + }); + } + if (missingBmErrors.length > 0) { + showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); + return; + } + const btn = document.getElementById("scheduler-save-btn"); + if (btn) btn.disabled = true; + apiPutScheduler(rig, config).then(function(saved) { + currentConfig = saved; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler saved."); + }).catch(function(e) { + showSchedulerToast("Save failed: " + e.message, true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + function selectVal(id) { + const el = document.getElementById(id); + return el ? el.value : ""; + } + async function resetScheduler() { + const rig = currentRigId; + if (!rig) return; + if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; + apiDeleteScheduler(rig).then(function() { + currentConfig = { + remote: rig, + mode: "disabled", + grayline: null, + entries: [] + }; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler reset."); + }).catch(function(e) { + showSchedulerToast("Reset failed: " + e.message, true); + }); + } + function markSchedulerDirty() { + if (schedulerDirty) return; + schedulerDirty = true; + var btn = document.getElementById("scheduler-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearSchedulerDirty() { + schedulerDirty = false; + var btn = document.getElementById("scheduler-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showSchedulerToast(msg, isError) { + const el = document.getElementById("scheduler-toast"); + if (!el) return; + el.textContent = msg; + el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; + el.style.display = "block"; + setTimeout(function() { + el.style.display = "none"; + }, 3e3); + } + function wireSchedulerEvents() { + const modeEl = document.getElementById("scheduler-mode-select"); + if (modeEl) { + modeEl.addEventListener("change", function() { + if (!currentConfig) currentConfig = { remote: currentRigId, mode: modeEl.value, entries: [] }; + currentConfig.mode = modeEl.value; + renderScheduler(); + }); + } + const saveBtn = document.getElementById("scheduler-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveScheduler); + const resetBtn = document.getElementById("scheduler-reset-btn"); + if (resetBtn) resetBtn.addEventListener("click", resetScheduler); + const addBtn = document.getElementById("scheduler-ts-add-btn"); + if (addBtn) addBtn.addEventListener("click", function() { + schOpenEntryForm(null, null); + }); + const entryForm = document.getElementById("sch-entry-form"); + if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); + const cancelBtn = document.getElementById("sch-entry-form-cancel"); + if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); + const prevBtn = document.getElementById("scheduler-prev-btn"); + if (prevBtn) prevBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(-1); + }); + const nextBtn = document.getElementById("scheduler-next-btn"); + if (nextBtn) nextBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(1); + }); + var schPanel = document.getElementById("scheduler-panel"); + if (schPanel && !schPanel._dirtyWired) { + schPanel._dirtyWired = true; + schPanel.addEventListener("input", function(e) { + if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + schPanel.addEventListener("change", function(e) { + if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + } + var gridEl = document.getElementById("scheduler-gl-grid"); + if (gridEl) { + gridEl.addEventListener("input", function() { + var ll = gridToLatLon(gridEl.value); + if (ll) { + setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); + setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); + markSchedulerDirty(); + } + }); + } + var latEl = document.getElementById("scheduler-gl-lat"); + var lonEl = document.getElementById("scheduler-gl-lon"); + [latEl, lonEl].forEach(function(el) { + if (el) { + el.addEventListener("input", function() { + var la = parseFloat(document.getElementById("scheduler-gl-lat").value); + var lo = parseFloat(document.getElementById("scheduler-gl-lon").value); + var gEl = document.getElementById("scheduler-gl-grid"); + if (gEl && !isNaN(la) && !isNaN(lo)) { + gEl.value = latLonToGrid(la, lo); + } + }); + } + }); + wireExtraBmAdd(); + wireSatelliteEvents(); + } + function populateTsBookmarkSelect() { + const sel = document.getElementById("scheduler-ts-bookmark"); + const extraSel = document.getElementById("scheduler-ts-extra-bm-pick"); + [sel, extraSel].forEach(function(el) { + if (!el) return; + const prev = el.value; + el.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + el.appendChild(opt); + }); + if (prev) el.value = prev; + }); + } + let pendingExtraBmIds = []; + function renderExtraBmList() { + var container = document.getElementById("scheduler-ts-extra-bm-list"); + if (!container) return; + container.innerHTML = ""; + pendingExtraBmIds.forEach(function(id, idx) { + var bm = bookmarkList.find(function(b) { + return b.id === id; + }); + var chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + var rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "Γ—"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + pendingExtraBmIds.splice(idx, 1); + renderExtraBmList(); + }); + chip.appendChild(rmBtn); + var label = document.createTextNode(" " + (bm ? bm.name : id)); + chip.appendChild(label); + container.appendChild(chip); + }); + var pick = document.getElementById("scheduler-ts-extra-bm-pick"); + if (pick) { + Array.from(pick.options).forEach(function(opt) { + if (opt.value) { + opt.disabled = pendingExtraBmIds.includes(opt.value); + } + }); + } + } + function wireExtraBmAdd() { + const addBtn = document.getElementById("scheduler-ts-extra-bm-add"); + if (!addBtn || addBtn._wired) return; + addBtn._wired = true; + addBtn.addEventListener("click", function() { + const pick = document.getElementById("scheduler-ts-extra-bm-pick"); + if (!pick || !pick.value) return; + if (!pendingExtraBmIds.includes(pick.value)) { + pendingExtraBmIds.push(pick.value); + renderExtraBmList(); + } + pick.value = ""; + }); + } + function renderSatelliteSection() { + if (window.satScheduler) window.satScheduler.renderSection(); + } + function renderSatPassStatus() { + if (window.satScheduler) window.satScheduler.renderPassStatus(); + } + function collectSatelliteConfig() { + return window.satScheduler ? window.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; + } + function wireSatelliteEvents() { + window.schedulerBridge = { + getConfig: function() { + return currentConfig; + }, + getStatus: function() { + return currentSchedulerStatus; + }, + getBookmarks: function() { + return bookmarkList; + }, + markDirty: function() { + markSchedulerDirty(); + } + }; + if (window.satScheduler) window.satScheduler.wireEvents(); + } + function isInputFocused() { + var el = document.activeElement; + if (!el) return false; + var tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable; + } + document.addEventListener("keydown", function(e) { + if (isInputFocused()) return; + if (e.shiftKey && e.key === "R") { + e.preventDefault(); + var releaseBtn = document.getElementById("scheduler-release-btn"); + if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); + } else if (e.shiftKey && e.key === "N") { + e.preventDefault(); + schedulerSelectRelativeEntry(1); + } else if (e.shiftKey && e.key === "P") { + e.preventDefault(); + schedulerSelectRelativeEntry(-1); + } + }); + (function() { + var details = document.querySelector(".sch-ts-details"); + if (!details) return; + var key = "sch-details-open"; + var saved = localStorage.getItem(key); + if (saved !== null) details.open = saved === "1"; + details.addEventListener("toggle", function() { + localStorage.setItem(key, details.open ? "1" : "0"); + }); + })(); + window.initScheduler = initScheduler; + window.destroyScheduler = destroyScheduler; + window.wireSchedulerEvents = wireSchedulerEvents; + window.setSchedulerRig = setSchedulerRig; + if (typeof authRole !== "undefined" && authRole !== null) { + initScheduler(typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null, authRole); + wireSchedulerEvents(); + } +})(); 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 new file mode 100644 index 00000000..6a22e1f5 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/screenshot.js @@ -0,0 +1,236 @@ +"use strict"; +(function() { + "use strict"; + const T = window.trx; + function isVisibleForSnapshot(el) { + if (!el) return false; + const style = getComputedStyle(el); + if (style.display === "none" || style.visibility === "hidden") return false; + const opacity = Number(style.opacity); + if (Number.isFinite(opacity) && opacity <= 0) return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + function drawRoundedRectPath(ctx, x, y, w, h, r) { + const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2)); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + w - radius, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + radius); + ctx.lineTo(x + w, y + h - radius); + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); + ctx.lineTo(x + radius, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + } + function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) { + if (!isVisibleForSnapshot(el)) return null; + const rect = el.getBoundingClientRect(); + const style = getComputedStyle(el); + const x = rect.left - rootRect.left; + const y = rect.top - rootRect.top; + const w = rect.width; + const h = rect.height; + const radius = parseFloat(style.borderTopLeftRadius) || 0; + const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)"); + const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0); + const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)"); + const bgAlpha = Math.min(bg[3], maxAlpha); + if (bgAlpha > 0.01) { + drawRoundedRectPath(ctx, x, y, w, h, radius); + ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`; + ctx.fill(); + } + const borderAlpha = Math.min(border[3], maxAlpha); + if (borderWidth > 0 && borderAlpha > 0.01) { + drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5)); + ctx.lineWidth = borderWidth; + ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`; + ctx.stroke(); + } + return { x, y, w, h, style }; + } + function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { + const words = String(text || "").split(/\s+/).filter(Boolean); + if (!words.length) return; + let line = ""; + let lineIdx = 0; + for (let i = 0; i < words.length; i += 1) { + const candidate = line ? `${line} ${words[i]}` : words[i]; + if (ctx.measureText(candidate).width <= maxWidth || !line) { + line = candidate; + continue; + } + ctx.fillText(line, x, y + lineIdx * lineHeight); + lineIdx += 1; + if (lineIdx >= maxLines) return; + line = words[i]; + } + if (line && lineIdx < maxLines) { + ctx.fillText(line, x, y + lineIdx * lineHeight); + } + } + function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) { + const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha); + if (!chrome) return; + const text = (fallbackText == null ? el.innerText : fallbackText) || ""; + const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim(); + if (!clean) return; + const style = chrome.style; + const fontSize = parseFloat(style.fontSize) || 12; + const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25; + const padX = 6; + const padY = 4; + const maxWidth = Math.max(20, chrome.w - padX * 2); + const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight)); + ctx.fillStyle = style.color || "#ffffff"; + ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`; + ctx.textBaseline = "top"; + const lines = clean.split(/\n+/); + let lineCursor = 0; + for (const line of lines) { + if (lineCursor >= maxLines) break; + drawWrappedText( + ctx, + line, + chrome.x + padX, + chrome.y + padY + lineCursor * lineHeight, + maxWidth, + lineHeight, + maxLines - lineCursor + ); + lineCursor += 1; + } + } + function drawAxisLabels(ctx, axisEl, rootRect) { + if (!isVisibleForSnapshot(axisEl)) return; + for (const node of axisEl.children) { + if (!(node instanceof HTMLElement)) continue; + if (!(node.matches("span") || node.matches("button"))) continue; + if (!isVisibleForSnapshot(node)) continue; + const chrome = drawElementChrome(ctx, node, rootRect); + const text = (node.textContent || "").trim(); + if (!chrome || !text) continue; + const style = chrome.style; + ctx.fillStyle = style.color || "#ffffff"; + ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`; + ctx.textBaseline = "middle"; + ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2); + } + } + function buildSpectrumSnapshotCanvas() { + const rootEl = document.querySelector(".signal-visual-block"); + const spectrumPanelEl = document.getElementById("spectrum-panel"); + if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) { + return null; + } + for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) { + const gl = renderer?.gl; + if (!gl) continue; + try { + if (typeof gl.flush === "function") gl.flush(); + if (typeof gl.finish === "function") gl.finish(); + } catch (_) { + } + } + const rootRect = rootEl.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const out = document.createElement("canvas"); + out.width = Math.max(1, Math.round(rootRect.width * dpr)); + out.height = Math.max(1, Math.round(rootRect.height * dpr)); + const ctx = out.getContext("2d"); + if (!ctx) return null; + ctx.scale(dpr, dpr); + const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000"; + ctx.fillStyle = bg; + ctx.fillRect(0, 0, rootRect.width, rootRect.height); + const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); + const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas]; + for (const canvas of canvases) { + if (!canvas || !isVisibleForSnapshot(canvas)) continue; + const rect = canvas.getBoundingClientRect(); + ctx.drawImage( + canvas, + rect.left - rootRect.left, + rect.top - rootRect.top, + rect.width, + rect.height + ); + } + const decoderOverlayIds = [ + "ais-bar-overlay", + "vdes-bar-overlay", + "ft8-bar-overlay", + "aprs-bar-overlay", + "rds-ps-overlay" + ]; + for (const id of decoderOverlayIds) { + const overlayEl = document.getElementById(id); + if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue; + drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35); + } + const spectrumFreqAxis = document.getElementById("spectrum-freq-axis"); + const spectrumDbAxis = document.getElementById("spectrum-db-axis"); + drawAxisLabels(ctx, spectrumFreqAxis, rootRect); + drawAxisLabels(ctx, spectrumDbAxis, rootRect); + drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect); + drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect); + drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect); + return out; + } + function clickCanvasDownload(href, fileName) { + const a = document.createElement("a"); + a.href = href; + a.download = fileName; + a.rel = "noopener"; + a.style.display = "none"; + document.body.appendChild(a); + a.click(); + requestAnimationFrame(() => a.remove()); + } + function saveCanvasAsPng(canvas, fileName) { + if (!canvas) return Promise.resolve(false); + if (typeof canvas.toBlob === "function") { + return new Promise((resolve) => { + try { + canvas.toBlob((blob) => { + if (!blob) { + resolve(false); + return; + } + const url = URL.createObjectURL(blob); + clickCanvasDownload(url, fileName); + setTimeout(() => URL.revokeObjectURL(url), 1e3); + resolve(true); + }, "image/png"); + } catch (_) { + resolve(false); + } + }); + } + try { + clickCanvasDownload(canvas.toDataURL("image/png"), fileName); + return Promise.resolve(true); + } catch (_) { + return Promise.resolve(false); + } + } + async function captureSpectrumScreenshot() { + const snapshotCanvas = buildSpectrumSnapshotCanvas(); + if (!snapshotCanvas) { + T.showHint("Spectrum view not ready", 1300); + return false; + } + const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); + const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`); + T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800); + return saved; + } + window.trx.modules.screenshot = { + captureSpectrumScreenshot, + buildSpectrumSnapshotCanvas, + saveCanvasAsPng + }; +})(); 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 new file mode 100644 index 00000000..4f9acdea --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ui-core.js @@ -0,0 +1,329 @@ +"use strict"; +(function initUiCore() { + const api = window.trxUi = window.trxUi || {}; + function ensureLiveRegions() { + if (!document.getElementById("toast-region")) { + const region = document.createElement("div"); + region.id = "toast-region"; + region.className = "toast-region"; + region.setAttribute("aria-live", "polite"); + region.setAttribute("aria-atomic", "false"); + document.body.appendChild(region); + } + if (!document.getElementById("ui-confirm-dialog")) { + const dialog = document.createElement("dialog"); + dialog.id = "ui-confirm-dialog"; + dialog.className = "ui-dialog"; + dialog.innerHTML = ` +
+

Confirm action

+

+
+ + +
+
`; + document.body.appendChild(dialog); + } + } + api.notify = function notify(message, options = {}) { + ensureLiveRegions(); + const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options; + const toast = document.createElement("div"); + toast.className = `toast toast-${kind}`; + toast.setAttribute("role", kind === "error" ? "alert" : "status"); + const text = document.createElement("span"); + text.textContent = message; + toast.appendChild(text); + if (action && typeof action.run === "function") { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = action.label || "Retry"; + button.addEventListener("click", () => { + action.run(); + toast.remove(); + }); + toast.appendChild(button); + } + document.getElementById("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 = document.getElementById("ui-confirm-dialog"); + document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action"; + document.getElementById("ui-confirm-message").textContent = options.message || "Continue?"; + const confirmButton = dialog.querySelector('[value="confirm"]'); + 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 (bar._accessibleTabsPrepared) return; + bar._accessibleTabsPrepared = true; + 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; + 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 (!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]; + 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] === true; + } + 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 requestedLayout = layouts[name]; + const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact"; + const layout = layouts[permittedName]; + document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact"; + if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout); + 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 window.navigateToTab === "function") { + window.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"); + const savedLayout = savedLayoutName(); + actions.insertBefore(label, actions.firstChild); + select.value = savedLayout; + refreshLayoutOptions(); + if (savedLayout !== "broadcast" && layouts[savedLayout]) 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"); + ["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 window.navigateToTab === "function") window.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 (!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 === "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?.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/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js new file mode 100644 index 00000000..aed696e1 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -0,0 +1,453 @@ +"use strict"; +let vchanSessionId = null; +let vchanRigId = null; +let vchanChannels = []; +let vchanActiveId = null; +let schedulerReleaseState = null; +let schedulerReleasePollTimer = null; +function vchanFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "β€―GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "β€―MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "β€―kHz"; + return hz + "β€―Hz"; +} +function schedulerReleaseSummaryText(state) { + if (!state) return "Scheduler is controlling the rig."; + const connected = Number(state.connected_sessions) || 0; + const released = Number(state.released_sessions) || 0; + if (connected === 0) return "Scheduler can control the rig."; + if (state.all_released) { + return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; + } + if (!state.current_session_released) { + const othersReleased = Math.max(released, 0); + return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; + } + const blocking = Math.max(connected - released, 0); + return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; +} +function vchanRenderSchedulerRelease() { + const btn = document.getElementById("scheduler-release-btn"); + const status = document.getElementById("scheduler-release-status"); + if (!btn || !status) return; + const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); + btn.disabled = !vchanSessionId || currentReleased; + btn.classList.toggle("active", !currentReleased); + btn.textContent = "Release to Scheduler"; + status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); +} +async function vchanPollSchedulerRelease() { + if (!vchanSessionId) { + schedulerReleaseState = null; + vchanRenderSchedulerRelease(); + return; + } + try { + const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release status failed", e); + } +} +function vchanStartSchedulerReleasePolling() { + if (schedulerReleasePollTimer) { + clearInterval(schedulerReleasePollTimer); + } + schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 1e4); +} +async function vchanToggleSchedulerRelease() { + if (!vchanSessionId) return; + const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null); + try { + const resp = await fetch("/scheduler-control", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }) + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release toggle failed", e); + } +} +async function vchanTakeSchedulerControl() { + if (!vchanSessionId) return; + if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return; + try { + const resp = await fetch("/scheduler-control", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, released: false }) + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler control takeover failed", e); + } +} +window.vchanTakeSchedulerControl = vchanTakeSchedulerControl; +function vchanHandleSession(data) { + try { + const d = JSON.parse(data); + vchanSessionId = d.session_id || null; + vchanPollSchedulerRelease(); + } catch (e) { + console.warn("vchan: bad session event", e); + } +} +function vchanHandleChannels(data) { + try { + const d = JSON.parse(data); + vchanRigId = d.remote || null; + vchanChannels = d.channels || []; + const ids = new Set(vchanChannels.map((c) => c.id)); + if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) { + vchanAutoJoinPrimary(vchanChannels[0].id); + } else if (vchanActiveId && !ids.has(vchanActiveId)) { + vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null; + vchanReconnectAudio(); + } + vchanRender(); + vchanRenderSchedulerRelease(); + if (typeof renderRdsOverlays === "function") renderRdsOverlays(); + } catch (e) { + console.warn("vchan: bad channels event", e); + } +} +function vchanRender() { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.innerHTML = ""; + vchanChannels.forEach((ch) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} Β· ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`; + if (ch.id === vchanActiveId) btn.classList.add("active"); + const label = document.createElement("span"); + label.className = "vchan-label"; + label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`; + btn.appendChild(label); + if (!ch.permanent) { + const del = document.createElement("span"); + del.className = "vchan-del"; + del.textContent = "Γ—"; + del.title = "Delete channel"; + del.addEventListener("click", (e) => { + e.stopPropagation(); + vchanDelete(ch.id); + }); + btn.appendChild(del); + } + btn.addEventListener("click", () => { + if (ch.id !== vchanActiveId) vchanSubscribe(ch.id); + }); + picker.appendChild(btn); + }); + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "vchan-add"; + addBtn.textContent = "+"; + addBtn.title = "Allocate new virtual channel at current frequency"; + addBtn.addEventListener("click", vchanAllocate); + picker.appendChild(addBtn); + vchanSyncAccentUI(); + if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { + updateDocumentTitle(activeChannelRds()); + } + vchanRenderSchedulerRelease(); +} +async function vchanAllocate() { + if (!vchanSessionId || !vchanRigId) return; + const freqHz = typeof lastFreqHz === "number" && lastFreqHz > 0 ? lastFreqHz : 0; + const modeEl = document.getElementById("mode"); + const mode = modeEl ? modeEl.value || "USB" : "USB"; + try { + const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) + }); + if (!resp.ok) { + const msg = await resp.text().catch(() => String(resp.status)); + console.warn("vchan: allocate failed β€”", msg); + return; + } + const ch = await resp.json(); + vchanActiveId = ch.id; + vchanRender(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: allocate error", e); + } +} +async function vchanDelete(channelId) { + if (!vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, + { method: "DELETE" } + ); + if (!resp.ok) { + console.warn("vchan: delete failed", resp.status); + } + } catch (e) { + console.error("vchan: delete error", e); + } +} +async function vchanAutoJoinPrimary(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) + } + ); + if (!resp.ok) { + console.warn("vchan: auto-join primary failed", resp.status); + return; + } + vchanActiveId = channelId; + vchanRender(); + } catch (e) { + console.error("vchan: auto-join error", e); + } +} +async function vchanSubscribe(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) + } + ); + if (!resp.ok) { + console.warn("vchan: subscribe failed", resp.status); + return; + } + vchanActiveId = channelId; + vchanRender(); + vchanSyncModeDisplay(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: subscribe error", e); + } +} +function vchanReconnectAudio() { + const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; + if (typeof _audioChannelOverride !== "undefined") { + _audioChannelOverride = ch ? ch.id : null; + } + if (typeof rxActive === "undefined" || !rxActive) return; + if (typeof stopRxAudio === "function") stopRxAudio(); + setTimeout(() => { + if (typeof startRxAudio === "function") startRxAudio(); + }, 300); +} +function vchanApplyCapabilities(caps) { + const picker = document.getElementById("vchan-picker"); + if (!picker) return; + picker.style.display = caps && caps.filter_controls ? "" : "none"; + vchanRenderSchedulerRelease(); +} +function vchanIsOnVirtual() { + if (!vchanActiveId || vchanChannels.length === 0) return false; + return vchanActiveId !== vchanChannels[0].id; +} +function vchanActiveChannel() { + return vchanChannels.find((c) => c.id === vchanActiveId) || null; +} +function vchanUpdateFreqDisplay() { + const ch = vchanActiveChannel(); + if (!ch) return; + const el = document.getElementById("freq"); + if (!el) return; + if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") { + el.value = formatFreqForStep(ch.freq_hz, jogUnit); + } else { + el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, ""); + } +} +function vchanSyncModeDisplay() { + const modeEl = document.getElementById("mode"); + if (!modeEl) return; + if (vchanIsOnVirtual()) { + const ch = vchanActiveChannel(); + if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); + } + const modeUpper = (modeEl.value || "").toUpperCase(); + if (typeof lastModeName !== "undefined") { + if (modeUpper === "WFM" && lastModeName !== "WFM") { + if (typeof setJogDivisor === "function") setJogDivisor(10); + if (typeof resetRdsDisplay === "function") resetRdsDisplay(); + } else if (modeUpper !== "WFM" && lastModeName === "WFM") { + if (typeof resetRdsDisplay === "function") resetRdsDisplay(); + } + lastModeName = modeUpper; + } + if (typeof updateWfmControls === "function") updateWfmControls(); + if (typeof updateSdrSquelchControlVisibility === "function") { + updateSdrSquelchControlVisibility(); + } + if (typeof refreshRdsUi === "function") { + refreshRdsUi(); + } else if (typeof positionRdsPsOverlay === "function") { + positionRdsPsOverlay(); + } +} +function vchanSyncBwDisplay() { + if (!vchanIsOnVirtual()) return; + const ch = vchanActiveChannel(); + if (!ch) return; + const bwEl = document.getElementById("spectrum-bw-input"); + if (!bwEl) return; + let bwHz = ch.bandwidth_hz || 0; + if (bwHz === 0 && typeof mwDefaultsForMode === "function") { + bwHz = mwDefaultsForMode(ch.mode)[0] || 0; + } + if (bwHz > 0) { + bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, ""); + if (typeof currentBandwidthHz !== "undefined") { + currentBandwidthHz = bwHz; + window.currentBandwidthHz = bwHz; + } else { + window.currentBandwidthHz = bwHz; + } + } +} +function vchanSyncAccentUI() { + const onVirtual = vchanIsOnVirtual(); + const freqEl = document.getElementById("freq"); + const bwEl = document.getElementById("spectrum-bw-input"); + if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual); + if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual); + if (onVirtual) { + vchanUpdateFreqDisplay(); + vchanSyncModeDisplay(); + vchanSyncBwDisplay(); + } else if (typeof _origRefreshFreqDisplay === "function") { + _origRefreshFreqDisplay(); + } + if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") { + updateDocumentTitle(activeChannelRds()); + } +} +let _origRefreshFreqDisplay = null; +function vchanSetChannelFreq(freqHz) { + if (!vchanRigId || !vchanActiveId) return; + if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && lastSpectrumData.sample_rate > 0) { + const halfSpan = Number(lastSpectrumData.sample_rate) / 2; + const center = Number(lastSpectrumData.center_hz); + if (Math.abs(freqHz - center) > halfSpan) { + if (typeof showHint === "function") { + showHint( + `Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz Β±${(halfSpan / 1e3).toFixed(0)} kHz)`, + 3e3 + ); + } + return; + } + } + vchanTakeSchedulerControl(); + fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ freq_hz: Math.round(freqHz) }) + } + ).catch((e) => console.error("vchan: set freq error", e)); +} +async function vchanSetChannelBandwidth(bwHz) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) + } + ); + if (!resp.ok) console.warn("vchan: set bw failed", resp.status); + } catch (e) { + console.error("vchan: set bw error", e); + } +} +async function vchanSetChannelMode(mode) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode }) + } + ); + if (!resp.ok) console.warn("vchan: set mode failed", resp.status); + } catch (e) { + console.error("vchan: set mode error", e); + } +} +window.vchanInterceptMode = async function(mode) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelMode(mode); + return true; +}; +window.vchanInterceptBandwidth = async function(bwHz) { + if (!vchanIsOnVirtual()) return false; + await vchanSetChannelBandwidth(bwHz); + return true; +}; +(function() { + const _orig = window.setRigFrequency; + window.setRigFrequency = function(freqHz) { + if (vchanIsOnVirtual()) { + if (typeof applyLocalTunedFrequency === "function") { + if (typeof _freqOptimisticSeq !== "undefined") { + ++_freqOptimisticSeq; + _freqOptimisticHz = Math.round(freqHz); + } + applyLocalTunedFrequency(Math.round(freqHz)); + } + vchanSetChannelFreq(freqHz); + return; + } + vchanTakeSchedulerControl(); + if (typeof _orig === "function") _orig(freqHz); + }; +})(); +(function initSchedulerReleaseControl() { + const btn = document.getElementById("scheduler-release-btn"); + if (btn) { + btn.addEventListener("click", () => { + vchanToggleSchedulerRelease(); + }); + } + vchanStartSchedulerReleasePolling(); + vchanRenderSchedulerRelease(); +})(); +(function() { + _origRefreshFreqDisplay = window.refreshFreqDisplay; + window.refreshFreqDisplay = function() { + if (vchanIsOnVirtual()) { + vchanUpdateFreqDisplay(); + return; + } + if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay(); + }; +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js new file mode 100644 index 00000000..73b4eda3 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -0,0 +1,282 @@ +"use strict"; +const vdesStatus = document.getElementById("vdes-status"); +const vdesMessagesEl = document.getElementById("vdes-messages"); +const vdesFilterInput = document.getElementById("vdes-filter"); +const vdesBarOverlay = document.getElementById("vdes-bar-overlay"); +const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); +const vdesFrameCountEl = document.getElementById("vdes-frame-count"); +const vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); +const VDES_BAR_WINDOW_MS = 15 * 60 * 1e3; +let vdesFilterText = ""; +let vdesMessageHistory = []; +function currentVdesHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneVdesMessageHistory() { + const cutoffMs = Date.now() - currentVdesHistoryRetentionMs(); + vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); +} +function scheduleVdesUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function scheduleVdesHistoryRender() { + scheduleVdesUi("vdes-history", () => renderVdesHistory()); +} +function scheduleVdesBarUpdate() { + scheduleVdesUi("vdes-bar", () => updateVdesBar()); +} +function currentVdesCenterText() { + const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); + const hz = raw ? Number(raw) : 0; + if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency"; + return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`; +} +function vdesAgeText(tsMs) { + if (!Number.isFinite(tsMs)) return "just now"; + const deltaMs = Math.max(0, Date.now() - tsMs); + const seconds = Math.round(deltaMs / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + return `${hours}h ago`; +} +function vdesHexPreview(rawBytes) { + if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--"; + return rawBytes.slice(0, 20).map((value) => Number(value).toString(16).padStart(2, "0")).join(" ").toUpperCase(); +} +function updateVdesSummary() { + pruneVdesMessageHistory(); + if (vdesChannelSummaryEl) { + vdesChannelSummaryEl.textContent = currentVdesCenterText(); + } + if (vdesFrameCountEl) { + const count = vdesMessageHistory.length; + vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`; + } + if (vdesLatestSeenEl) { + const latest = vdesMessageHistory[0]; + vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet"; + } +} +function applyVdesFilterToRow(row) { + if (!vdesFilterText) { + row.style.display = ""; + return; + } + const text = row.dataset.filterText || ""; + row.style.display = text.includes(vdesFilterText) ? "" : "none"; +} +function applyVdesFilterToAll() { + if (!vdesMessagesEl) return; + vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row)); +} +function renderVdesRow(msg) { + const row = document.createElement("div"); + row.className = "vdes-message"; + const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + const title = msg.vessel_name || "VDES Burst"; + const label = msg.callsign || "VDES"; + const info = msg.destination || ""; + const labelText = msg.message_label || ""; + const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : ""; + const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : ""; + const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : ""; + const fecText = msg.fec_state || ""; + const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : ""; + const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : ""; + const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : ""; + const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : ""; + const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : ""; + const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : ""; + const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : ""; + const previewText = msg.payload_preview || ""; + const rawHex = vdesHexPreview(msg.raw_bytes); + row.dataset.filterText = [ + title, + label, + labelText, + info, + srcText, + dstText, + sessionText, + asmText, + countText, + ackText, + cqiText, + previewText, + linkText, + syncText, + phaseText, + fecText, + rawHex, + msg.message_type, + msg.bit_len + ].filter(Boolean).join(" ").toUpperCase(); + row.innerHTML = `
${ts}${escapeMapHtml(title)}${escapeMapHtml(label)}` + (labelText ? `${escapeMapHtml(labelText)}` : "") + (linkText ? `${escapeMapHtml(linkText)}` : "") + (srcText ? `${escapeMapHtml(srcText)}` : "") + (dstText ? `${escapeMapHtml(dstText)}` : "") + (syncText ? `${escapeMapHtml(syncText)}` : "") + (phaseText ? `${escapeMapHtml(phaseText)}` : "") + `T${escapeMapHtml(String(msg.message_type ?? "--"))}
${escapeMapHtml(currentVdesCenterText())}${escapeMapHtml(`${msg.bit_len || 0} bits`)}` + (sessionText ? `${escapeMapHtml(sessionText)}` : "") + (asmText ? `${escapeMapHtml(asmText)}` : "") + (countText ? `${escapeMapHtml(countText)}` : "") + (ackText ? `${escapeMapHtml(ackText)}` : "") + (cqiText ? `${escapeMapHtml(cqiText)}` : "") + (info ? `${escapeMapHtml(info)}` : "") + (fecText ? `${escapeMapHtml(fecText)}` : "") + `${escapeMapHtml(vdesAgeText(msg._tsMs))}
` + (previewText ? `${escapeMapHtml(previewText)}` : "") + (previewText ? `Β·` : "") + `${escapeMapHtml(rawHex)}
`; + applyVdesFilterToRow(row); + return row; +} +function updateVdesBar() { + if (!vdesBarOverlay) return; + updateVdesSummary(); + const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES"; + const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS; + const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6); + if (!isVdes || messages.length === 0) { + vdesBarOverlay.style.display = "none"; + vdesBarOverlay.innerHTML = ""; + return; + } + let html = `
VDESLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + const label = escapeMapHtml(msg.callsign || "VDES"); + const title = escapeMapHtml(msg.vessel_name || "Burst"); + const detail = [ + `${msg.bit_len || 0} bits`, + msg.message_label ? escapeMapHtml(msg.message_label) : null, + Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null, + Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null, + Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null, + Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null, + Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null, + Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null, + msg.destination ? escapeMapHtml(msg.destination) : null, + escapeMapHtml(vdesAgeText(msg._tsMs)) + ].filter(Boolean).join(" Β· "); + html += `
${ts}${title} ${label}: ${detail}
`; + } + vdesBarOverlay.innerHTML = html; + vdesBarOverlay.style.display = "flex"; +} +window.updateVdesBar = updateVdesBar; +window.clearVdesBar = function() { + window.resetVdesHistoryView(); +}; +window.resetVdesHistoryView = function() { + if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; + vdesMessageHistory = []; + updateVdesBar(); + renderVdesHistory(); +}; +function renderVdesHistory() { + pruneVdesMessageHistory(); + if (!vdesMessagesEl) { + updateVdesSummary(); + return; + } + const fragment = document.createDocumentFragment(); + for (let i = 0; i < vdesMessageHistory.length; i += 1) { + fragment.appendChild(renderVdesRow(vdesMessageHistory[i])); + } + vdesMessagesEl.replaceChildren(fragment); + updateVdesSummary(); +} +function addVdesMessage(msg) { + const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + vdesMessageHistory.unshift(msg); + pruneVdesMessageHistory(); + scheduleVdesBarUpdate(); + scheduleVdesHistoryRender(); +} +function normalizeServerVdesMessage(msg) { + return { + rig_id: msg.rig_id || null, + message_type: msg.message_type, + bit_len: msg.bit_len, + raw_bytes: msg.raw_bytes, + lat: msg.lat, + lon: msg.lon, + vessel_name: msg.vessel_name, + callsign: msg.callsign, + destination: msg.destination, + message_label: msg.message_label, + session_id: msg.session_id, + source_id: msg.source_id, + destination_id: msg.destination_id, + data_count: msg.data_count, + asm_identifier: msg.asm_identifier, + ack_nack_mask: msg.ack_nack_mask, + channel_quality: msg.channel_quality, + payload_preview: msg.payload_preview, + link_id: msg.link_id, + sync_score: msg.sync_score, + sync_errors: msg.sync_errors, + phase_rotation: msg.phase_rotation, + fec_state: msg.fec_state, + ts_ms: msg.ts_ms + }; +} +window.onServerVdesBatch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + if (vdesStatus) vdesStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerVdesMessage(msg); + const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); + next._tsMs = tsMs; + next._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { + window.vdesMapAddPoint(next); + } + normalized.push(next); + } + normalized.reverse(); + vdesMessageHistory = normalized.concat(vdesMessageHistory); + pruneVdesMessageHistory(); + scheduleVdesBarUpdate(); + scheduleVdesHistoryRender(); +}; +window.restoreVdesHistory = function(messages) { + window.onServerVdesBatch(messages); +}; +document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_vdes_decode"); + window.resetVdesHistoryView(); + } catch (e) { + console.error("VDES history clear failed", e); + } +}); +if (vdesFilterInput) { + vdesFilterInput.addEventListener("input", () => { + vdesFilterText = vdesFilterInput.value.trim().toUpperCase(); + renderVdesHistory(); + }); +} +window.onServerVdes = function(msg) { + if (vdesStatus) vdesStatus.textContent = "Receiving"; + const next = normalizeServerVdesMessage(msg); + addVdesMessage(next); + if (next.lat != null && next.lon != null && window.vdesMapAddPoint) { + window.vdesMapAddPoint(next); + } +}; +window.pruneVdesHistoryView = function() { + pruneVdesMessageHistory(); + updateVdesBar(); + renderVdesHistory(); +}; +updateVdesSummary(); +if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes"); 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 new file mode 100644 index 00000000..d05792b2 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/webgl-renderer.js @@ -0,0 +1,485 @@ +"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(String(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 = String(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 = String(value ?? ""); + if (cssColorCache.has(key)) return cssColorCache.get(key).slice(); + 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.slice()); + return parsed.slice(); + } + function hslToRgba(h, s, l, a = 1) { + const hue = ((Number(h) || 0) % 360 + 360) % 360 / 360; + const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100)); + const lig = Math.max(0, Math.min(1, (Number(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, Number(a)))]; + } + function normalizeColor(input, alphaMul = 1) { + let rgba; + if (Array.isArray(input)) { + const arr = input.map((v) => Number(v)); + if (arr.length >= 4) { + rgba = [arr[0], arr[1], arr[2], arr[3]]; + } else { + rgba = [0, 0, 0, 1]; + } + } else if (typeof input === "string") { + rgba = parseCssColor(input); + } else if (input && typeof input === "object") { + rgba = [ + Number(input.r) || 0, + Number(input.g) || 0, + Number(input.b) || 0, + Number(input.a ?? 1) + ]; + } else { + rgba = [0, 0, 0, 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); + 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 { + 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) || null; + this.ready = !!this.gl; + this.textures = /* @__PURE__ */ new Map(); + this._colorScratch = new Float32Array(4096 * 6); + this._colorGpuSize = 0; + this._texScratch = new Float32Array(6 * 4); + if (!this.ready) return; + const gl = this.gl; + 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.ready) 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.ready) 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) { + this._drawColorGeometry(vertices, this.gl.TRIANGLES); + } + drawTriangleStrip(vertices) { + this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP); + } + _drawColorGeometry(vertices, mode) { + if (!this.ready || !vertices || 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._drawColorGeometry(v, this.gl.TRIANGLES); + } + 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._drawColorGeometry(v, this.gl.TRIANGLES); + } + drawPolyline(points, color, width = 1) { + if (!Array.isArray(points) || points.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, Number(width) || 1) / 2; + const verts = []; + for (let i = 0; i < points.length - 2; i += 2) { + segmentToQuadVertices( + verts, + points[i], + points[i + 1], + points[i + 2], + points[i + 3], + halfW, + rgba + ); + } + this._drawColorGeometry(verts, this.gl.TRIANGLES); + } + drawSegments(segments, color, width = 1) { + if (!Array.isArray(segments) || segments.length < 4) return; + const rgba = normalizeColor(color); + const halfW = Math.max(0.5, Number(width) || 1) / 2; + const verts = []; + for (let i = 0; i < segments.length - 3; i += 4) { + segmentToQuadVertices( + verts, + segments[i], + segments[i + 1], + segments[i + 2], + segments[i + 3], + halfW, + rgba + ); + } + this._drawColorGeometry(verts, this.gl.TRIANGLES); + } + 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], baselineY, rgba); + pushColoredVertex(verts, points[i], points[i + 1], rgba); + } + this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP); + } + drawPoints(points, size, color) { + if (!Array.isArray(points) || points.length < 2) return; + const radius = Math.max(1, Number(size) || 1); + const rgba = normalizeColor(color); + const verts = []; + for (let i = 0; i < points.length; i += 2) { + const x = points[i] - radius; + const y = points[i + 1] - 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._drawColorGeometry(verts, this.gl.TRIANGLES); + } + drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) { + const dash = Math.max(1, Number(dashLen) || 1); + const gap = Math.max(1, Number(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.ready || !name || !data) 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.ready || !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, Number(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/generated/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js new file mode 100644 index 00000000..2816fffc --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js @@ -0,0 +1,304 @@ +"use strict"; +var wefaxDom = { + status: document.getElementById("wefax-status"), + liveView: document.getElementById("wefax-live-view"), + historyView: document.getElementById("wefax-history-view"), + liveContainer: document.getElementById("wefax-live-container"), + liveInfo: document.getElementById("wefax-live-info"), + liveCanvas: document.getElementById("wefax-live-canvas"), + liveLatest: document.getElementById("wefax-live-latest"), + historyList: document.getElementById("wefax-history-list"), + historyCount: document.getElementById("wefax-history-count"), + filterInput: document.getElementById("wefax-filter"), + sortSelect: document.getElementById("wefax-sort"), + toggleBtn: document.getElementById("wefax-decode-toggle-btn"), + clearBtn: document.getElementById("wefax-clear-btn"), + viewLiveBtn: document.getElementById("wefax-view-live"), + viewHistoryBtn: document.getElementById("wefax-view-history") +}; +var wefaxImageHistory = []; +var WEFAX_MAX_IMAGES = 100; +var wefaxLiveCtx = null; +var wefaxLiveLineCount = 0; +var wefaxLivePixelsPerLine = 1809; +var wefaxActiveView = "live"; +var wefaxFilterText = ""; +function currentWefaxHistoryRetentionMs() { + return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneWefaxHistory() { + var cutoff = Date.now() - currentWefaxHistoryRetentionMs(); + wefaxImageHistory = wefaxImageHistory.filter(function(m) { + return (m._tsMs || 0) > cutoff; + }); +} +function escapeHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} +function scheduleWefaxUi(key, job) { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob(key, job); + return; + } + job(); +} +function switchWefaxView(view) { + wefaxActiveView = view; + if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none"; + if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none"; + [wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) { + if (btn) btn.classList.remove("sat-view-active"); + }); + if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active"); + if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active"); + if (view === "history") renderWefaxHistoryTable(); +} +if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() { + switchWefaxView("live"); +}); +if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() { + switchWefaxView("history"); +}); +function resetLiveCanvas(pixelsPerLine) { + wefaxLivePixelsPerLine = pixelsPerLine; + wefaxLiveLineCount = 0; + wefaxDom.liveCanvas.width = pixelsPerLine; + wefaxDom.liveCanvas.height = 800; + wefaxLiveCtx = wefaxDom.liveCanvas.getContext("2d"); + wefaxLiveCtx.fillStyle = "#000"; + wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height); + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = ""; +} +function paintLine(lineBytes) { + if (!wefaxLiveCtx) return; + var y = wefaxLiveLineCount; + if (y >= wefaxDom.liveCanvas.height) { + var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height); + wefaxDom.liveCanvas.height *= 2; + wefaxLiveCtx.putImageData(old, 0, 0); + } + var w = wefaxLivePixelsPerLine; + var imgData = wefaxLiveCtx.createImageData(w, 1); + var d = imgData.data; + for (var x = 0; x < w; x++) { + var v = x < lineBytes.length ? lineBytes[x] : 0; + var i = x * 4; + d[i] = v; + d[i + 1] = v; + d[i + 2] = v; + d[i + 3] = 255; + } + wefaxLiveCtx.putImageData(imgData, 0, y); + wefaxLiveLineCount++; +} +function renderWefaxLatestCard() { + if (!wefaxDom.liveLatest) return; + if (wefaxImageHistory.length === 0) { + wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; + return; + } + var img = wefaxImageHistory[0]; + var ts = img._ts || "--"; + var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; + var meta = [ + img.ioc + " IOC", + img.lpm + " LPM", + img.line_count + " lines", + date + " " + ts + ].join(" Β· "); + var imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; + var html = '
'; + html += '
Latest decoded image
'; + html += '
' + escapeHtml(meta) + "
"; + if (imgSrc) { + html += 'View full image'; + } + html += "
"; + wefaxDom.liveLatest.innerHTML = html; +} +function getWefaxFilteredHistory() { + var items = wefaxImageHistory; + if (wefaxFilterText) { + items = items.filter(function(i) { + var haystack = [ + String(i.ioc || ""), + String(i.lpm || ""), + String(i.line_count || "") + ].join(" ").toUpperCase(); + return haystack.indexOf(wefaxFilterText) >= 0; + }); + } + var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest"; + if (sortVal === "oldest") items = items.slice().reverse(); + return items; +} +function renderWefaxHistoryRow(img) { + var row = document.createElement("div"); + row.className = "sat-history-row"; + var ts = img._ts || "--"; + var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; + var ioc = img.ioc || "--"; + var lpm = img.lpm || "--"; + var lines = img.line_count || 0; + var imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; + var link = imgSrc ? 'View' : "--"; + row.innerHTML = [ + "" + escapeHtml(date + " " + ts) + "", + "" + escapeHtml(String(ioc)) + "", + "" + escapeHtml(String(lpm)) + "", + "" + lines + "", + "" + link + "" + ].join(""); + return row; +} +function renderWefaxHistoryTable() { + if (!wefaxDom.historyList) return; + pruneWefaxHistory(); + var items = getWefaxFilteredHistory(); + var fragment = document.createDocumentFragment(); + for (var i = 0; i < items.length; i++) { + fragment.appendChild(renderWefaxHistoryRow(items[i])); + } + wefaxDom.historyList.replaceChildren(fragment); + if (wefaxDom.historyCount) { + var total = wefaxImageHistory.length; + var shown = items.length; + wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? total + " image" + (total === 1 ? "" : "s") : shown + " of " + total + " images"; + } +} +function addWefaxImage(msg) { + var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); + msg._tsMs = tsMs; + msg._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + if (wefaxLiveCtx && wefaxLiveLineCount > 0) { + var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount); + wefaxDom.liveCanvas.height = wefaxLiveLineCount; + wefaxLiveCtx.putImageData(trimmed, 0, 0); + try { + msg._dataUrl = wefaxDom.liveCanvas.toDataURL("image/png"); + } catch (e) { + } + } + wefaxImageHistory.unshift(msg); + if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) { + wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES); + } + scheduleWefaxUi("wefax-latest", renderWefaxLatestCard); + if (wefaxActiveView === "history") { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + } +} +window.onServerWefaxProgress = function(msg) { + if (msg.state && !msg.line_data) { + if (wefaxDom.status) { + wefaxDom.status.textContent = msg.state; + wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)"; + } + return; + } + if (msg.line_count <= 1 || !wefaxLiveCtx) { + resetLiveCanvas(msg.pixels_per_line || 1809); + } + if (msg.line_data) { + var binary = atob(msg.line_data); + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + paintLine(bytes); + } + if (wefaxDom.liveInfo) { + wefaxDom.liveInfo.textContent = "Line " + msg.line_count + " Β· " + msg.ioc + " IOC Β· " + msg.lpm + " LPM"; + } + if (wefaxDom.status) { + wefaxDom.status.textContent = "Receiving β€” line " + msg.line_count; + wefaxDom.status.style.color = "var(--text-accent)"; + } +}; +window.onServerWefax = function(msg) { + addWefaxImage(msg); + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; + if (wefaxDom.status) { + wefaxDom.status.textContent = "Complete β€” " + msg.line_count + " lines"; + wefaxDom.status.style.color = ""; + } +}; +window.restoreWefaxHistory = function(messages) { + if (!messages || !messages.length) return; + for (var i = 0; i < messages.length; i++) { + var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now(); + messages[i]._tsMs = tsMs; + messages[i]._ts = new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + wefaxImageHistory = messages.concat(wefaxImageHistory); + pruneWefaxHistory(); + scheduleWefaxUi("wefax-latest", renderWefaxLatestCard); + if (wefaxActiveView === "history") { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + } +}; +window.pruneWefaxHistoryView = function() { + pruneWefaxHistory(); + renderWefaxHistoryTable(); + renderWefaxLatestCard(); +}; +window.resetWefaxHistoryView = function() { + wefaxImageHistory = []; + if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = ""; + if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none"; + wefaxLiveCtx = null; + wefaxLiveLineCount = 0; + renderWefaxLatestCard(); + renderWefaxHistoryTable(); + if (wefaxDom.status) { + wefaxDom.status.textContent = "Idle"; + wefaxDom.status.style.color = ""; + } +}; +if (wefaxDom.filterInput) { + wefaxDom.filterInput.addEventListener("input", function() { + wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase(); + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + }); +} +if (wefaxDom.sortSelect) { + wefaxDom.sortSelect.addEventListener("change", function() { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); + }); +} +window.syncWefaxToggle = function(enabled) { + if (!wefaxDom.toggleBtn) return; + wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false"; + wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX"; + wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : ""; + wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : ""; +}; +if (wefaxDom.toggleBtn) { + wefaxDom.toggleBtn.addEventListener("click", async function() { + try { + if (window.takeSchedulerControlForDecoderDisable) { + await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn); + } + await postPath("/toggle_wefax_decode"); + } catch (e) { + console.error("WEFAX toggle failed", e); + } + }); +} +if (wefaxDom.clearBtn) { + wefaxDom.clearBtn.addEventListener("click", async function() { + try { + await postPath("/clear_wefax_decode"); + window.resetWefaxHistoryView(); + } catch (e) { + console.error("WEFAX clear failed", e); + } + }); +} +renderWefaxLatestCard(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js new file mode 100644 index 00000000..29b11658 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -0,0 +1,251 @@ +"use strict"; +const wsprStatus = document.getElementById("wspr-status"); +const wsprPeriodEl = document.getElementById("wspr-period"); +const wsprMessagesEl = document.getElementById("wspr-messages"); +const wsprFilterInput = document.getElementById("wspr-filter"); +const WSPR_PERIOD_SECONDS = 120; +let wsprFilterText = ""; +let wsprMessageHistory = []; +function currentWsprHistoryRetentionMs() { + return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneWsprMessageHistory() { + const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs); +} +function scheduleWsprHistoryRender() { + if (typeof window.trxScheduleUiFrameJob === "function") { + window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory()); + return; + } + renderWsprHistory(); +} +function fmtWsprTime(tsMs) { + if (!tsMs) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} +function updateWsprPeriodTimer() { + if (!wsprPeriodEl) return; + const nowSec = Math.floor(Date.now() / 1e3); + const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS; + const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); + const ss = String(remaining % 60).padStart(2, "0"); + wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; +} +updateWsprPeriodTimer(); +setInterval(updateWsprPeriodTimer, 500); +function renderWsprRow(msg) { + const row = document.createElement("div"); + row.className = "ft8-row"; + row.dataset.decoder = "wspr"; + const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--"; + const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--"; + const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null; + const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + msg.freq_hz : null; + const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--"; + const message = (msg.message || "").toString(); + row.dataset.message = message.toUpperCase(); + row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr}${dt}${freq}${renderWsprMessage(message)}`; + applyWsprFilterToRow(row); + return row; +} +function renderWsprHistory() { + pruneWsprMessageHistory(); + if (!wsprMessagesEl) return; + const fragment = document.createDocumentFragment(); + for (let i = 0; i < wsprMessageHistory.length; i += 1) { + fragment.appendChild(renderWsprRow(wsprMessageHistory[i])); + } + wsprMessagesEl.replaceChildren(fragment); +} +function addWsprMessage(msg) { + msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now(); + wsprMessageHistory.unshift(msg); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); +} +function normalizeServerWsprMessage(msg) { + const raw = (msg.message || "").toString(); + const grids = extractAllGrids(raw); + const station = extractLikelyCallsign(raw); + const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null; + const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + Number(msg.freq_hz) : Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null; + return { + raw, + grids, + station, + rfHz, + history: { + receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null, + ts_ms: msg.ts_ms, + snr_db: msg.snr_db, + dt_s: msg.dt_s, + freq_hz: msg.freq_hz, + message: raw + } + }; +} +window.onServerWsprBatch = function(messages) { + if (!Array.isArray(messages) || messages.length === 0) return; + wsprStatus.textContent = "Receiving"; + const normalized = []; + for (const msg of messages) { + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + freq_hz: next.rfHz + }); + } + next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now(); + normalized.push(next.history); + } + normalized.reverse(); + wsprMessageHistory = normalized.concat(wsprMessageHistory); + pruneWsprMessageHistory(); + scheduleWsprHistoryRender(); +}; +window.restoreWsprHistory = function(messages) { + window.onServerWsprBatch(messages); +}; +window.pruneWsprHistoryView = function() { + pruneWsprMessageHistory(); + renderWsprHistory(); +}; +function escapeWsprHtml(input) { + return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function renderWsprMessage(message) { + let out = ""; + let i = 0; + while (i < message.length) { + const ch = message[i]; + if (isAlphaNum(ch)) { + let j = i + 1; + while (j < message.length && isAlphaNum(message[j])) j++; + const token = message.slice(i, j); + const grid = token.toUpperCase(); + if (isMaidenheadGridToken(grid)) { + out += `${grid}`; + } else { + out += escapeWsprHtml(token); + } + i = j; + } else { + out += escapeWsprHtml(ch); + i += 1; + } + } + return out; +} +function extractAllGrids(message) { + const out = []; + const seen = /* @__PURE__ */ new Set(); + const parts = message.toUpperCase().split(/[^A-Z0-9]+/); + for (const token of parts) { + if (!token) continue; + if (isMaidenheadGridToken(token) && !seen.has(token)) { + seen.add(token); + out.push(token); + } + } + return out; +} +function extractLikelyCallsign(message) { + const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/); + for (const token of parts) { + if (!token) continue; + if (token.length < 3 || token.length > 12) continue; + if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue; + if (isMaidenheadGridToken(token)) continue; + if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token; + } + return null; +} +function isFtxFarewellToken(token) { + const normalized = String(token || "").trim().toUpperCase(); + return normalized === "RR73" || normalized === "73" || normalized === "RR"; +} +function isMaidenheadGridToken(token) { + const normalized = String(token || "").trim().toUpperCase(); + return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized); +} +function isAlphaNum(ch) { + return /[A-Za-z0-9]/.test(ch); +} +function activateWsprHistoryLocator(targetEl) { + const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]"); + if (!locatorEl) return false; + const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase(); + if (!grid) return false; + if (typeof window.navigateToMapLocator === "function") { + window.navigateToMapLocator(grid, "wspr"); + } + return true; +} +function applyWsprFilterToRow(row) { + if (!wsprFilterText) { + row.style.display = ""; + return; + } + const message = row.dataset.message || ""; + row.style.display = message.includes(wsprFilterText) ? "" : "none"; +} +function applyWsprFilterToAll() { + const rows = wsprMessagesEl.querySelectorAll(".ft8-row"); + rows.forEach((row) => applyWsprFilterToRow(row)); +} +window.resetWsprHistoryView = function() { + wsprMessagesEl.innerHTML = ""; + wsprMessageHistory = []; + renderWsprHistory(); + if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr"); +}; +if (wsprFilterInput) { + wsprFilterInput.addEventListener("input", () => { + wsprFilterText = wsprFilterInput.value.trim().toUpperCase(); + renderWsprHistory(); + }); +} +if (wsprMessagesEl) { + wsprMessagesEl.addEventListener("click", (event) => { + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); + wsprMessagesEl.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + if (!activateWsprHistoryLocator(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + }); +} +const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn"); +wsprDecodeToggleBtn?.addEventListener("click", async () => { + try { + await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn); + await postPath("/toggle_wspr_decode"); + } catch (e) { + console.error("WSPR toggle failed", e); + } +}); +document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => { + if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return; + try { + await postPath("/clear_wspr_decode"); + window.resetWsprHistoryView(); + } catch (e) { + console.error("WSPR history clear failed", e); + } +}); +window.onServerWspr = function(msg) { + wsprStatus.textContent = "Receiving"; + const next = normalizeServerWsprMessage(msg); + if (next.grids.length > 0 && window.mapAddLocator) { + window.mapAddLocator(next.raw, next.grids, "wspr", next.station, { + ...msg, + freq_hz: next.rfHz + }); + } + addWsprMessage(next.history); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs new file mode 100644 index 00000000..a686f502 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import { build } from "esbuild"; +import { rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const frontendDir = path.dirname(fileURLToPath(import.meta.url)); +const sourceDir = path.join(frontendDir, "src"); +const outputDir = path.join(frontendDir, "..", "assets", "web", "generated"); + +await rm(outputDir, { recursive: true, force: true }); + +await build({ + entryPoints: { + app: path.join(sourceDir, "app.js"), + "ui-core": path.join(sourceDir, "ui-core.js"), + "map-core": path.join(sourceDir, "map-core.js"), + screenshot: path.join(sourceDir, "screenshot.js"), + "webgl-renderer": path.join(sourceDir, "webgl-renderer.js"), + "decode-history-worker": path.join(sourceDir, "decode-history-worker.js"), + "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"), + ais: path.join(sourceDir, "plugins", "ais.js"), + aprs: path.join(sourceDir, "plugins", "aprs.js"), + "background-decode": path.join(sourceDir, "plugins", "background-decode.js"), + bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), + cw: path.join(sourceDir, "plugins", "cw.js"), + ft2: path.join(sourceDir, "plugins", "ft2.js"), + ft4: path.join(sourceDir, "plugins", "ft4.js"), + ft8: path.join(sourceDir, "plugins", "ft8.js"), + "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), + sat: path.join(sourceDir, "plugins", "sat.js"), + "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"), + scheduler: path.join(sourceDir, "plugins", "scheduler.js"), + vchan: path.join(sourceDir, "plugins", "vchan.js"), + vdes: path.join(sourceDir, "plugins", "vdes.js"), + wefax: path.join(sourceDir, "plugins", "wefax.js"), + wspr: path.join(sourceDir, "plugins", "wspr.js"), + }, + outdir: outputDir, + bundle: false, + target: "es2022", + sourcemap: false, + legalComments: "inline", + charset: "utf8", + logLevel: "info", +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js new file mode 100644 index 00000000..4b367188 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import eslint from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "../assets/web/generated/**", + // Removed file-by-file as the legacy sources are converted to TypeScript. + "src/**/*.js", + ], + }, + { + files: ["**/*.mjs"], + ...eslint.configs.recommended, + languageOptions: { + globals: globals.node, + }, + }, + ...tseslint.configs.strictTypeChecked.map((config) => ({ + ...config, + files: ["src/**/*.ts", "tests/**/*.ts"], + })), + { + files: ["src/**/*.ts", "tests/**/*.ts"], + languageOptions: { + globals: globals.browser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "no-undef": "off", + "@typescript-eslint/no-explicit-any": "error", + }, + }, +); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json new file mode 100644 index 00000000..730bd925 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package-lock.json @@ -0,0 +1,1971 @@ +{ + "name": "trx-frontend-http-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trx-frontend-http-web", + "version": "0.1.0", + "devDependencies": { + "@eslint/js": "9.39.2", + "esbuild": "0.25.12", + "eslint": "9.39.2", + "globals": "16.5.0", + "typescript": "5.9.3", + "typescript-eslint": "8.51.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.6.tgz", + "integrity": "sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.0", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.51.0.tgz", + "integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/type-utils": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.51.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.51.0.tgz", + "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.51.0.tgz", + "integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.51.0", + "@typescript-eslint/types": "^8.51.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.51.0.tgz", + "integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.51.0.tgz", + "integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.51.0.tgz", + "integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.51.0.tgz", + "integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.51.0.tgz", + "integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.51.0", + "@typescript-eslint/tsconfig-utils": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/visitor-keys": "8.51.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.51.0.tgz", + "integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.51.0", + "@typescript-eslint/types": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.51.0.tgz", + "integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.51.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.51.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.51.0.tgz", + "integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.51.0", + "@typescript-eslint/parser": "8.51.0", + "@typescript-eslint/typescript-estree": "8.51.0", + "@typescript-eslint/utils": "8.51.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json new file mode 100644 index 00000000..d91233dd --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "trx-frontend-http-web", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "node build.mjs", + "typecheck": "tsc --project tsconfig.json", + "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", + "test": "node --test tests/*.test.mjs", + "verify-generated": "npm run build && git diff --exit-code -- ../assets/web/generated" + }, + "devDependencies": { + "@eslint/js": "9.39.2", + "esbuild": "0.25.12", + "eslint": "9.39.2", + "globals": "16.5.0", + "typescript": "5.9.3", + "typescript-eslint": "8.51.0" + } +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md new file mode 100644 index 00000000..b6c76922 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/README.md @@ -0,0 +1,12 @@ + + +# Frontend source + +This directory is the source of the browser assets embedded by +`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory +after changing a source file. Cargo consumes the committed output under +`../assets/web/generated` and does not invoke Node.js. diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js new file mode 100644 index 00000000..b41fdb46 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js @@ -0,0 +1,8934 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// --- Decoder registry (fetched from /decoders on load) --- +/** @type {Array<{id:string,label:string,activation:string,active_modes:string[],background_decode:boolean,bookmark_selectable:boolean}>} */ +let decoderRegistry = []; +window.decoderRegistry = decoderRegistry; + +/** Callbacks invoked once the decoder registry is fetched. */ +const _decoderRegistryReadyCallbacks = []; +window.onDecoderRegistryReady = function (fn) { + if (decoderRegistry.length > 0) fn(); + else _decoderRegistryReadyCallbacks.push(fn); +}; + +(async function fetchDecoderRegistry() { + try { + const resp = await fetch("/decoders"); + if (resp.ok) { + decoderRegistry = await resp.json(); + window.decoderRegistry = decoderRegistry; + for (const fn of _decoderRegistryReadyCallbacks) fn(); + _decoderRegistryReadyCallbacks.length = 0; + hideUnsupportedDecoderTabs(); + refreshOperatorLayoutCapabilities(); + } + } catch (e) { + console.error("Failed to fetch decoder registry:", e); + } +})(); + +/** Hide decoder sub-tabs, panels, about rows, and settings buttons + * for decoders not present in the server's registry. */ +function hideUnsupportedDecoderTabs() { + const knownIds = new Set(decoderRegistry.map(function (d) { return d.id; })); + // Sub-tabs that are not decoders and should always be visible. + const alwaysShow = new Set(["overview", "rds", "sat"]); + document.querySelectorAll("#tab-digital-modes > .sub-tab-bar > .sub-tab[data-subtab]").forEach(function (btn) { + const id = btn.dataset.subtab; + if (alwaysShow.has(id) || knownIds.has(id)) return; + btn.style.display = "none"; + var panel = document.getElementById("subtab-" + id); + if (panel) panel.style.display = "none"; + }); + // About-tab decoder status rows: LABEL + document.querySelectorAll('[id^="about-dec-"]').forEach(function (el) { + var id = el.id.replace("about-dec-", ""); + if (!alwaysShow.has(id) && !knownIds.has(id)) { + var row = el.closest("tr"); + if (row) row.style.display = "none"; + } + }); + // Settings clear-history buttons + document.querySelectorAll('[id^="settings-clear-"][id$="-history"]').forEach(function (el) { + var m = el.id.match(/^settings-clear-(.+)-history$/); + if (m && !alwaysShow.has(m[1]) && !knownIds.has(m[1])) { + el.style.display = "none"; + } + }); + // Overview decoder descriptions + document.querySelectorAll("#subtab-overview .plugin-item[data-decoder]").forEach(function (el) { + var id = el.dataset.decoder; + if (!alwaysShow.has(id) && !knownIds.has(id)) { + el.style.display = "none"; + } + }); +} + +// --- Persistent settings (localStorage) --- +const STORAGE_PREFIX = "trx_"; +function saveSetting(key, value) { + try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch(e) {} +} +function loadSetting(key, fallback) { + try { + const v = localStorage.getItem(STORAGE_PREFIX + key); + return v !== null ? JSON.parse(v) : fallback; + } catch(e) { return fallback; } +} +function escapeMapHtml(input) { + return String(input) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + +// --- Authentication --- +let authRole = null; // null (not authenticated), "rx" (read-only), or "control" (full access) +let authEnabled = true; + +async function checkAuthStatus() { + try { + const resp = await fetch("/auth/session"); + if (resp.status === 404) { + // Auth API not exposed -> treat as auth-disabled mode. + return { authenticated: true, role: "control", auth_disabled: true }; + } + if (!resp.ok) return { authenticated: false }; + const data = await resp.json(); + return data; + } catch (e) { + console.error("Auth check failed:", e); + return { authenticated: false }; + } +} + +async function authLogin(passphrase) { + try { + const resp = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ passphrase }), + }); + if (resp.status === 404) { + return { authenticated: true, role: "control", auth_disabled: true }; + } + if (!resp.ok) { + const text = await resp.text(); + throw new Error(text || "Login failed"); + } + const data = await resp.json(); + return data; + } catch (e) { + throw e; + } +} + +async function authLogout() { + try { + const resp = await fetch("/auth/logout", { method: "POST" }); + if (resp.status !== 404 && !resp.ok) throw new Error("Logout failed"); + authRole = null; + // Disconnect and show auth gate without page reload + disconnect(); + setDecodeHistoryOverlayVisible(false); + document.getElementById("content").style.display = "none"; + document.getElementById("loading").style.display = "none"; + document.getElementById("auth-passphrase").value = ""; + updateAuthUI(); + + // Check if guest mode is available after logout + 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); + document.getElementById("loading").style.display = "none"; + document.getElementById("content").style.display = "none"; + const authGate = document.getElementById("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"; + } + + // Hide all tab panels + document.querySelectorAll(".tab-panel").forEach(panel => { + panel.style.display = "none"; + }); + + // Show guest button if guest mode is available + const guestBtn = document.getElementById("auth-guest-btn"); + if (guestBtn) { + guestBtn.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 = document.getElementById("auth-gate"); + authGate.style.display = "none"; + document.getElementById("loading").style.display = "block"; + const signalVisualBlock = document.querySelector(".signal-visual-block"); + if (signalVisualBlock) { + signalVisualBlock.style.display = ""; + } + + // Show the tab that matches the current route. + document.querySelectorAll(".tab-panel").forEach(panel => { + panel.style.display = "none"; + }); + document.querySelectorAll(".tab-bar .tab").forEach(btn => { + btn.classList.remove("active"); + }); + navigateToTab(tabFromPath(), { updateHistory: false, replaceHistory: true }); + syncTopBarAccess(); +} + +function showAuthError(msg) { + const el = document.getElementById("auth-error"); + el.textContent = msg; + el.style.display = "block"; + setTimeout(() => { + el.style.display = "none"; + }, 5000); +} + +function updateAuthUI() { + const badge = document.getElementById("auth-badge"); + const badgeRole = document.getElementById("auth-role-badge"); + const headerAuthBtn = document.getElementById("header-auth-btn"); + + if (!authEnabled) { + if (badge) badge.style.display = "none"; + if (headerAuthBtn) headerAuthBtn.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 (headerAuthBtn) { + headerAuthBtn.textContent = "Logout"; + headerAuthBtn.style.display = "block"; + } + } else { + if (badge) badge.style.display = "none"; + if (headerAuthBtn) { + headerAuthBtn.textContent = "Login"; + headerAuthBtn.style.display = "block"; + } + } + syncTopBarAccess(); +} + +function applyAuthRestrictions() { + if (!authRole) return; + + // Disable TX/PTT/frequency/mode/VFO controls for rx role + if (authRole === "rx") { + const pttBtn = document.getElementById("ptt-btn"); + const powerBtn = document.getElementById("power-btn"); + const lockBtn = document.getElementById("lock-btn"); + const freqInput = document.getElementById("freq"); + const centerFreqInput = document.getElementById("center-freq"); + const modeSelect = document.getElementById("mode"); + const txLimitInput = document.getElementById("tx-limit"); + const txLimitBtn = document.getElementById("tx-limit-btn"); + const txAudioBtn = document.getElementById("tx-audio-btn"); + const txLimitRow = document.getElementById("tx-limit-row"); + const vfoPicker = 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"); + + // Disable TX buttons + if (pttBtn) pttBtn.disabled = true; + if (powerBtn) powerBtn.disabled = true; + if (lockBtn) lockBtn.disabled = true; + if (txAudioBtn) txAudioBtn.disabled = true; + if (txLimitBtn) txLimitBtn.disabled = true; + + // Disable frequency/mode inputs + if (freqInput) freqInput.disabled = true; + if (centerFreqInput) centerFreqInput.disabled = true; + if (modeSelect) modeSelect.disabled = true; + if (txLimitInput) txLimitInput.disabled = true; + + // Disable VFO selector + vfoButtons.forEach(btn => btn.disabled = true); + + // Disable jog controls + const jogWheel = document.getElementById("jog-wheel"); + if (jogUp) jogUp.disabled = true; + if (jogDown) jogDown.disabled = true; + if (jogWheel) jogWheel.style.opacity = "0.5"; + jogButtons.forEach(btn => btn.disabled = true); + + // Disable plugin enable/disable buttons and decode history clear buttons + // Note: sig-clear-btn is allowed for RX (clears local measurements only) + 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 && btn.tagName === "BUTTON") { + btn.disabled = true; + } else if (btn && btn.type === "checkbox") { + btn.disabled = true; + } + }); + + // Hide TX-specific UI but keep controls visible (disabled) + if (txLimitRow) txLimitRow.style.opacity = "0.5"; + } +} + +function applyCapabilities(caps) { + if (!caps) return; + lastHasTx = !!caps.tx; + if (signalVisualBlockEl) signalVisualBlockEl.style.display = ""; + + // PTT / TX controls + const pttBtn = document.getElementById("ptt-btn"); + const txPowerCol = document.getElementById("tx-power-col"); + const txMetersRow = document.getElementById("tx-meters"); + const txAudioBtn = document.getElementById("tx-audio-btn"); + const txVolSlider = document.getElementById("tx-vol"); + const txVolControl = txVolSlider ? txVolSlider.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 (pttBtn) pttBtn.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 (txAudioBtn) txAudioBtn.style.display = caps.tx ? "" : "none"; + if (txVolControl) txVolControl.style.display = caps.tx ? "" : "none"; + if (!caps.tx && typeof stopTxAudio === "function" && txActive) { + stopTxAudio(); + } + + // TX limit row + const txLimitRow = document.getElementById("tx-limit-row"); + if (txLimitRow) txLimitRow.style.display = caps.tx_limit ? "" : "none"; + + // VFO row + const vfoRow = document.getElementById("vfo-row"); + if (vfoRow) vfoRow.style.display = caps.vfo_switch ? "" : "none"; + + // Signal meter row + 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"; + } + }); + + // Spectrum panel (SDR-only) + 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(); + if (typeof vchanApplyCapabilities === "function") vchanApplyCapabilities(caps); +} + +const freqEl = document.getElementById("freq"); +const centerFreqEl = document.getElementById("center-freq"); +const wavelengthEl = document.getElementById("wavelength"); +const sigStrengthEl = document.getElementById("sig-strength"); +const modeEl = document.getElementById("mode"); +const bandLabel = document.getElementById("band-label"); +const powerBtn = document.getElementById("power-btn"); +const powerHint = document.getElementById("power-hint"); +const vfoPicker = document.getElementById("vfo-picker"); +const signalBar = document.getElementById("signal-bar"); +const signalValue = document.getElementById("signal-value"); +const pttBtn = document.getElementById("ptt-btn"); +const txLimitInput = document.getElementById("tx-limit"); +const txLimitBtn = document.getElementById("tx-limit-btn"); +const txLimitRow = document.getElementById("tx-limit-row"); +const lockBtn = document.getElementById("lock-btn"); +const txMeters = document.getElementById("tx-meters"); +const pwrBar = document.getElementById("pwr-bar"); +const pwrValue = document.getElementById("pwr-value"); +const swrBar = document.getElementById("swr-bar"); +const swrValue = document.getElementById("swr-value"); +const loadingEl = document.getElementById("loading"); +const contentEl = document.getElementById("content"); +const serverSubtitle = document.getElementById("server-subtitle"); +const rigSubtitle = document.getElementById("rig-subtitle"); +const ownerSubtitle = document.getElementById("owner-subtitle"); +const locationSubtitle = document.getElementById("location-subtitle"); +const loadingTitle = document.getElementById("loading-title"); +const loadingSub = document.getElementById("loading-sub"); +const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay"); +const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title"); +const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub"); +const connLostOverlayEl = document.getElementById("conn-lost-overlay"); +const connLostOverlayTitleEl = document.getElementById("conn-lost-overlay-title"); +const connLostOverlaySubEl = document.getElementById("conn-lost-overlay-sub"); +const overviewCanvas = document.getElementById("overview-canvas"); +const signalOverlayCanvas = document.getElementById("signal-overlay-canvas"); +// Screenshots composite these live WebGL canvases into a PNG. +const spectrumSnapshotGlOptions = { alpha: true, preserveDrawingBuffer: true }; +const overviewGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(overviewCanvas, spectrumSnapshotGlOptions) + : null; +const signalOverlayGl = typeof createTrxWebGlRenderer === "function" + ? createTrxWebGlRenderer(signalOverlayCanvas, spectrumSnapshotGlOptions) + : null; +const signalVisualBlockEl = document.querySelector(".signal-visual-block"); +const signalSplitControlEl = document.getElementById("signal-split-control"); +const signalSplitSliderEl = document.getElementById("signal-split-slider"); +const signalSplitValueEl = document.getElementById("signal-split-value"); +const overviewPeakHoldEl = document.getElementById("overview-peak-hold"); +const themeToggleBtn = document.getElementById("theme-toggle"); +const headerRigSwitchSelect = document.getElementById("header-rig-switch-select"); +const headerRigSummary = document.getElementById("header-rig-summary"); +const headerStylePickSelect = document.getElementById("header-style-pick-select"); +const rdsPsOverlay = document.getElementById("rds-ps-overlay"); +const tabMainEl = document.getElementById("tab-main"); +// About-tab elements β€” resolved lazily after the about template is cloned. +let aboutServerVerEl = null; +let aboutServerBuildDateEl = null; +let aboutServerAddrEl = null; +let aboutServerCallEl = null; +let aboutServerLocationEl = null; +let aboutRigInfoEl = null; +let aboutRigAccessEl = null; +let aboutModesEl = null; +let aboutVfosEl = null; +let aboutActiveRigEl = null; +let aboutAudioCodecEl = null; +let aboutAudioSamplerateEl = null; +let aboutAudioChannelsEl = null; +let aboutAudioBitrateEl = null; +let aboutAudioFrameEl = null; +let aboutAudioRxEl = null; +let aboutAudioStreamsEl = null; +let aboutPskreporterEl = null; +let aboutAprsIsEl = null; +let aboutRigctlClientsEl = null; +let aboutRigctlEndpointEl = null; +let aboutClientsEl = null; +let _aboutElsResolved = false; +function _resolveAboutEls() { + if (_aboutElsResolved) return; + aboutServerVerEl = document.getElementById("about-server-ver"); + if (!aboutServerVerEl) return; // template not cloned yet + _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"); +} +// Cached CW elements (avoid getElementById on every SSE render) +const cwAutoEl = document.getElementById("cw-auto"); +const cwWpmEl = document.getElementById("cw-wpm"); +const cwToneEl = document.getElementById("cw-tone"); +let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); +let decodeHistoryRetentionMin = 24 * 60; + +// Cached decoder toggle buttons β€” built from the registry, keyed by status +// field name (e.g. "ft8_decode_enabled"). Lazily populated on first SSE. +const _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" : ""; +} + +// About-tab decoder status elements β€” resolved lazily after template clone. +const _aboutDecIds = [ + "about-dec-ft8", "about-dec-ft4", "about-dec-ft2", "about-dec-wspr", + "about-dec-cw", "about-dec-aprs", "about-dec-lrpt", +]; +let _aboutDecEls = _aboutDecIds.map(() => ({ el: null, last: null })); +function _resolveAboutDecEls() { + if (_aboutDecEls[0].el) return; + for (let i = 0; i < _aboutDecIds.length; i++) { + _aboutDecEls[i].el = document.getElementById(_aboutDecIds[i]); + } +} + +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"; +} + +let primaryRds = null; +let vchanRdsById = new Map(); +let vchanSignalDbById = new Map(); +let rdsOverlayEntries = []; + +function currentDecodeHistoryRetentionMs() { + const minutes = Math.max(1, Math.round(Number(decodeHistoryRetentionMin) || (24 * 60))); + return minutes * 60 * 1000; +} + +window.getDecodeHistoryRetentionMs = currentDecodeHistoryRetentionMs; + +window.applyDecodeHistoryRetention = function() { + if (typeof window.pruneAprsHistoryView === "function") window.pruneAprsHistoryView(); + if (typeof window.pruneHfAprsHistoryView === "function") window.pruneHfAprsHistoryView(); + if (typeof window.pruneAisHistoryView === "function") window.pruneAisHistoryView(); + if (typeof window.pruneVdesHistoryView === "function") window.pruneVdesHistoryView(); + if (typeof window.pruneFt8HistoryView === "function") window.pruneFt8HistoryView(); + if (typeof window.pruneWsprHistoryView === "function") window.pruneWsprHistoryView(); +}; + +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; + } +} + +let 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\u2026", 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); +} +const decodeHistoryTextDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null; +let decodeHistoryReplayActive = false; +let decodeMapSyncPending = false; + +// --- Pending decode data buffers --- +// Map-data plugins (ais.js, aprs.js, vdes.js, hf-aprs.js) are loaded eagerly +// but dynamically-inserted scripts have no guaranteed execution order. If +// decode history or live SSE messages arrive before the plugin handlers are +// registered, buffer them here and let each plugin drain on init. +const _pendingDecodeHistory = {}; +const _pendingDecodeLive = {}; + +window._trxDrainPendingDecode = function(kind) { + const historyKey = { + ais: "restoreAisHistory", + vdes: "restoreVdesHistory", + aprs: "restoreAprsHistory", + hf_aprs: "restoreHfAprsHistory", + }[kind]; + if (historyKey && _pendingDecodeHistory[kind] && window[historyKey]) { + const msgs = _pendingDecodeHistory[kind]; + delete _pendingDecodeHistory[kind]; + window[historyKey](msgs); + } + const liveKey = { + ais: "onServerAis", + vdes: "onServerVdes", + aprs: "onServerAprs", + hf_aprs: "onServerHfAprs", + }[kind]; + if (liveKey && _pendingDecodeLive[kind] && window[liveKey]) { + const msgs = _pendingDecodeLive[kind]; + delete _pendingDecodeLive[kind]; + for (const msg of msgs) { + try { window[liveKey](msg); } catch (_) {} + } + } +}; + +function markDecodeMapSyncPending() { + decodeMapSyncPending = true; +} + +function flushDeferredDecodeMapSync() { + if (!decodeMapSyncPending || decodeHistoryReplayActive || !window.trx?.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?.map?.aprsMap; +} + +function decodeCborUint(view, bytes, state, additional) { + const offset = state.offset; + if (additional < 24) return additional; + if (additional === 24) { + if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 1; + return bytes[offset]; + } + if (additional === 25) { + if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 2; + return view.getUint16(offset); + } + if (additional === 26) { + if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + state.offset += 4; + return view.getUint32(offset); + } + if (additional === 27) { + if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getBigUint64(offset); + state.offset += 8; + const numeric = Number(value); + if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range"); + return numeric; + } + throw new Error("Unsupported CBOR additional info"); +} + +function decodeCborFloat16(bits) { + const sign = (bits & 0x8000) ? -1 : 1; + const exponent = (bits >> 10) & 0x1f; + const fraction = bits & 0x03ff; + if (exponent === 0) { + return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024); + } + if (exponent === 0x1f) { + return fraction === 0 ? sign * Infinity : Number.NaN; + } + return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024)); +} + +function decodeCborItem(view, bytes, state) { + if (state.offset >= bytes.length) throw new Error("CBOR payload truncated"); + const initial = bytes[state.offset++]; + const major = initial >> 5; + const additional = initial & 0x1f; + if (major === 0) return decodeCborUint(view, bytes, state, additional); + if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional); + if (major === 2) { + const length = decodeCborUint(view, bytes, state, additional); + if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated"); + const chunk = bytes.slice(state.offset, state.offset + length); + state.offset += length; + return Array.from(chunk); + } + if (major === 3) { + const length = decodeCborUint(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; + return decodeHistoryTextDecoder ? decodeHistoryTextDecoder.decode(chunk) : String.fromCharCode(...chunk); + } + if (major === 4) { + const length = decodeCborUint(view, bytes, state, additional); + const items = new Array(length); + for (let i = 0; i < length; i += 1) { + items[i] = decodeCborItem(view, bytes, state); + } + return items; + } + if (major === 5) { + const length = decodeCborUint(view, bytes, state, additional); + const value = {}; + for (let i = 0; i < length; i += 1) { + const key = decodeCborItem(view, bytes, state); + value[String(key)] = decodeCborItem(view, bytes, state); + } + return value; + } + if (major === 6) { + decodeCborUint(view, bytes, state, additional); + return decodeCborItem(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 undefined; + if (additional === 25) { + if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated"); + const bits = view.getUint16(state.offset); + state.offset += 2; + return decodeCborFloat16(bits); + } + if (additional === 26) { + if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat32(state.offset); + state.offset += 4; + return value; + } + if (additional === 27) { + if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated"); + const value = view.getFloat64(state.offset); + state.offset += 8; + return value; + } + } + throw new Error("Unsupported CBOR major type"); +} + +function decodeCborPayload(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 = decodeCborItem(view, bytes, state); + if (state.offset !== bytes.length) { + throw new Error("Unexpected trailing bytes in CBOR payload"); + } + return value; +} + +let lastSpectrumData = null; +window.lastSpectrumData = null; +let lastControl; +let lastTxEn = null; +let lastHasTx = true; +let lastRendered = null; +let prevRenderData = {}; +let hintTimer = null; +let sigMeasuring = false; +let sigLastSUnits = null; +let sigLastDbm = null; +const SIG_STRENGTH_UNITS = ["dBFS", "dBf", "dBm", "S"]; +let sigStrengthUnitIdx = loadSetting("sigStrengthUnit", 0); + +function sigUnit(u) { return `${u}`; } + +function formatSigStrength(dbm) { + if (!Number.isFinite(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") { + // dBf = dBm + 107 (referenced to 1 femtowatt across 50 Ξ©) + const dbf = dbm + 107; + return `${dbf.toFixed(1)} ${sigUnit("dBf")}`; + } + // dBFS: map receiver range to a full-scale reference + // Typical receiver: -140 dBm (noise floor) to 0 dBm (full scale) + 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(); + }); +} + +let sigMeasureTimer = null; +let sigMeasureLastTickMs = 0; +let sigMeasureAccumMs = 0; +let sigMeasureWeighted = 0; +let sigMeasurePeak = null; +let lastFreqHz = null; +window.lastFreqHz = null; +let centerFreqDirty = false; +let jogUnit = loadSetting("jogUnit", 1000); // base unit: 1, 1000, 1000000 +let jogMult = loadSetting("jogMult", 1); // divisor: 1, 10, 100 +let jogStep = Math.max(Math.round(jogUnit / jogMult), 1); +let minFreqStepHz = 1; +let lastModeName = ""; +let lastWfmCci = 0; +let lastWfmAci = 0; +const VFO_COLORS = ["var(--accent-green)", "var(--accent-yellow)"]; +function vfoColor(idx) { + if (idx < VFO_COLORS.length) return VFO_COLORS[idx]; + // Deterministic pseudo-random hue for extra VFOs + const hue = ((idx * 137) % 360); + return `hsl(${hue}, 70%, 55%)`; +} +let jogAngle = 0; +let lastClientCount = null; +let lastLocked = false; +let sdrSquelchSupported = false; +// ── Previous-state tracking for "B" hotkey ──────────────────────────────────── +let previousTuneState = null; // { freqHz, bandwidthHz, mode, centerHz } + +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(); // save current as previous so B toggles back + if (saved.mode && modeEl && modeEl.value !== saved.mode) { + modeEl.value = saved.mode; + await postPath(`/set_mode?mode=${encodeURIComponent(saved.mode)}`); + updateWfmControls(); + } + if (Number.isFinite(saved.bandwidthHz) && saved.bandwidthHz !== currentBandwidthHz) { + currentBandwidthHz = saved.bandwidthHz; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(currentBandwidthHz); + await postPath(`/set_bandwidth?hz=${saved.bandwidthHz}`); + } + if (Number.isFinite(saved.freqHz)) { + setRigFrequency(saved.freqHz); + } + if (Number.isFinite(saved.centerHz)) { + await postPath(`/set_center_freq?hz=${saved.centerHz}`); + } + showHint("Restored previous", 1500); +} + +let lastRigIds = []; +let lastRigDisplayNames = {}; +let lastActiveRigId = null; +let rigSwitchInProgress = false; +let lastCityLabel = ""; +let sseSessionId = null; +const originalTitle = document.title; +const savedTheme = loadSetting("theme", null); + +function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; +} + +function updateDocumentTitle(rds = null) { + const freqHz = activeChannelFreqHz(); + if (!Number.isFinite(freqHz)) { + document.title = originalTitle; + return; + } + const parts = [formatFreq(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(); +} + +// Recolour bookmark chips after any palette/theme change (setTheme or setStyle). +function invalidateBookmarkColors() { + if (typeof bmOverlayRevision === "undefined") return; + bmOverlayRevision++; + // Force the browser to recalculate styles so getComputedStyle reads new values. + void getComputedStyle(document.documentElement).getPropertyValue("--bg"); + const colorMap = bmCategoryColorMap(); + const ref = typeof bmOverlayList !== "undefined" ? bmOverlayList : []; + 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)); + }); + // Clear cached DOM keys so the next spectrum draw rebuilds chips fresh. + 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 (_) {} +} + +// ── Style / palette system ──────────────────────────────────────────────────── +const 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.80], + }, + }, + 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.30, 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.30, 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.80], + }, + }, + "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() { + return document.documentElement.getAttribute("data-style") || "original"; +} + +function canvasPalette() { + const s = currentStyle(); + const t = currentTheme(); + return (CANVAS_PALETTE[s] ?? CANVAS_PALETTE.original)[t]; +} + +function setStyle(style) { + const remapped = + style === "nord" ? "arctic" + : style === "monokai" ? "lime" + : style === "blood" ? "fire" + : style; + const valid = ["original", "arctic", "lime", "contrast", "neon-disco", "golden-rain", "amber", "fire", "phosphor"]; + const next = valid.includes(remapped) ? 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 (!Number.isFinite(overviewPeakHoldMs) || overviewPeakHoldMs < 0) { + overviewPeakHoldMs = 2000; + } + 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"); +} + +const savedStyle = loadSetting("style", "original"); +setStyle(savedStyle); + +if (themeToggleBtn) { + themeToggleBtn.addEventListener("click", () => { + setTheme(currentTheme() === "dark" ? "light" : "dark"); + updateMapBaseLayerForTheme(currentTheme()); + syncLocatorMarkerStyles(); + refreshAisMarkerColors(); + scheduleOverviewDraw(); + if (typeof scheduleSpectrumDraw === "function" && lastSpectrumData) scheduleSpectrumDraw(); + }); +} + +if (headerStylePickSelect) { + headerStylePickSelect.addEventListener("change", () => { + setStyle(headerStylePickSelect.value); + updateMapBaseLayerForTheme(currentTheme()); + syncLocatorMarkerStyles(); + refreshAisMarkerColors(); + }); +} + +function readyText() { + return lastClientCount !== null ? `Ready \u00b7 ${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); + // Detect whether the rig list or active rig actually changed so we can + // skip expensive bookmark re-fetches on every SSE state update. + 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) { + // Only adopt the server's active rig when this tab has no selection yet + // (first load). Otherwise keep the per-tab choice so other tabs' switches + // do not override ours. + 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) { + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); + if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); + if (typeof bmFetch === "function") bmFetch(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 && 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) { + // Non-fatal: SSE/status path still drives main UI. + } +} + +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" }); + } +} +let supportedModes = []; +let supportedBands = []; +let lastUnsupportedFreqPopupAt = 0; +let freqDirty = false; +let initialized = false; +let lastEventAt = Date.now(); +let aboutUptimeStart = null; +let es; +let esHeartbeat; + +function formatUptime(ms) { + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const parts = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0 || d > 0) parts.push(`${h}h`); + parts.push(`${m}m`); + parts.push(`${sec}s`); + return parts.join(" "); +} +setInterval(() => { + if (!aboutUptimeStart) return; + const el = document.getElementById("about-uptime"); + if (el) el.textContent = formatUptime(Date.now() - aboutUptimeStart); +}, 1000); +let reconnectTimer = null; +let overviewSignalSamples = []; +let overviewSignalTimer = null; +let overviewWaterfallRows = []; +let overviewWaterfallPushCount = 0; // monotonically increments on every push +const HEADER_SIG_WINDOW_MS = 10_000; +const OVERVIEW_WF_TEX_MAX_W = 512; +let overviewWfTexData = null; +let overviewWfTexWidth = 0; +let overviewWfTexHeight = 0; +let overviewWfTexPushCount = 0; +let overviewWfTexPalKey = ""; +let 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], + parsed[1], + parsed[2], + Math.max(0, Math.min(1, parsed[3] * alphaMul)), + ]; +} + +function rgbaWithAlpha(color, alphaMul = 1) { + return cssColorToRgba(color, alphaMul); +} + +const 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.30], + stroke: [240 / 255, 173 / 255, 78 / 255, 0.70], + hard: [240 / 255, 173 / 255, 78 / 255, 0.38], +}; + +const 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; + // Include the bandplan strip height when it sits above the overview. + 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 = typeof bmOverlayList !== "undefined" ? bmOverlayList : null; + if (Array.isArray(bmRef) && bmRef.length > 0) { + const colorMap = bmCategoryColorMap(); + const grouped = new Map(); + for (const bm of bmRef) { + const f = Number(bm?.freq_hz); + if (!Number.isFinite(f) || f < range.visLoHz || f > range.visHiHz) continue; + if (Number.isFinite(lastFreqHz) && Math.abs(f - lastFreqHz) <= Math.max(minFreqStepHz, 5)) continue; + const x = hzToX(f); + if (!Number.isFinite(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)); + } + } + } + + // Virtual channel markers (sky-blue dashed lines, active one is solid). + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels)) { + vchanChannels.forEach(ch => { + if (!Number.isFinite(ch.freq_hz) || ch.freq_hz <= 0) return; + const xc = hzToX(ch.freq_hz); + if (xc < 0 || xc > W) return; + const isActive = ch.id === vchanActiveId; + const color = cssColorToRgba("#38bdf8"); + if (isActive) { + signalOverlayGl.drawSegments([xc, 0, xc, H], color, Math.max(1.5, dpr * 1.5)); + } else { + signalOverlayGl.drawDashedVerticalLine( + xc, 0, H, + Math.max(2, Math.round(4 * dpr)), + Math.max(3, Math.round(6 * dpr)), + color, + Math.max(1, dpr), + ); + } + }); + } + + if (lastFreqHz != null) { + const xf = hzToX(lastFreqHz); + if (xf >= 0 && xf <= W) { + signalOverlayGl.drawDashedVerticalLine( + xf, + 0, + H, + Math.max(2, Math.round(4 * dpr)), + Math.max(2, Math.round(4 * dpr)), + cssColorToRgba("#ff1744"), + Math.max(1, dpr), + ); + } + } +} + +function scheduleOverviewDraw() { + if (!overviewCanvas || overviewDrawPending) return; + overviewDrawPending = true; + requestAnimationFrame(() => { + overviewDrawPending = false; + drawHeaderSignalGraph(); + }); +} + +function pushHeaderSignalSample(sUnits) { + if (!overviewCanvas) return; + const now = Date.now(); + const sample = Number.isFinite(sUnits) ? Math.max(0, Math.min(20, sUnits)) : 0; + overviewSignalSamples.push({ t: now, v: sample }); + while (overviewSignalSamples.length && now - overviewSignalSamples[0].t > HEADER_SIG_WINDOW_MS) { + overviewSignalSamples.shift(); + } + scheduleOverviewDraw(); +} + +function trimOverviewWaterfallRows() { + if (!overviewCanvas) return; + const maxRows = Math.max(1, Math.floor(overviewCanvas.height / _cachedDpr)); + if (overviewWaterfallRows.length > maxRows) { + overviewWaterfallRows.splice(0, overviewWaterfallRows.length - maxRows); + } +} + +function overviewVisibleBinWindow(data, binCount) { + if (!data || !Number.isFinite(data.sample_rate) || binCount <= 1) { + return { startIdx: 0, endIdx: Math.max(0, binCount - 1) }; + } + const range = spectrumVisibleRange(data); + const fullLoHz = data.center_hz - data.sample_rate / 2; + const startFrac = (range.visLoHz - fullLoHz) / data.sample_rate; + const endFrac = (range.visHiHz - fullLoHz) / data.sample_rate; + const maxIdx = binCount - 1; + const startIdx = Math.max(0, Math.min(maxIdx, Math.floor(startFrac * maxIdx))); + const endIdx = Math.max(startIdx, Math.min(maxIdx, Math.ceil(endFrac * maxIdx))); + return { startIdx, endIdx }; +} + +function pushOverviewWaterfallFrame(data) { + if (!overviewCanvas || !data || !isBinsArray(data.bins) || data.bins.length === 0) return; + overviewWaterfallRows.push(data.bins.slice()); + overviewWaterfallPushCount++; + trimOverviewWaterfallRows(); + scheduleOverviewDraw(); +} + +function startHeaderSignalSampling() { + if (!overviewCanvas || overviewSignalTimer) return; + overviewSignalTimer = setInterval(() => { + pushHeaderSignalSample(Number.isFinite(sigLastSUnits) ? sigLastSUnits : 0); + }, 120); +} + +function drawHeaderSignalGraph() { + if (!ensureOverviewCanvasBackingStore()) return; + if (!overviewGl || !overviewGl.ready) return; + const pal = canvasPalette(); + const W = overviewCanvas.width; + const H = overviewCanvas.height; + if (W <= 0 || H <= 0) return; + + overviewGl.clear(cssColorToRgba(pal.bg)); + if (lastSpectrumData && overviewWaterfallRows.length > 0) { + drawOverviewWaterfall(W, H, pal); + } else { + drawOverviewSignalHistory(W, H, pal); + } + positionRdsPsOverlay(); + drawSignalOverlay(); + updateBandplanStrip(bandplanComputeRange()); +} + +function drawOverviewWaterfall(W, H, pal) { + if (!overviewGl || !overviewGl.ready) return; + const maxVisible = Math.max(1, Math.floor(H)); + const rows = overviewWaterfallRows.slice(-maxVisible); + if (rows.length === 0) return; + + const iW = Math.max(96, Math.min(OVERVIEW_WF_TEX_MAX_W, Math.ceil(W / 2))); + const iH = Math.max(1, rows.length); + const minDb = Number.isFinite(spectrumFloor) ? spectrumFloor : -115; + const maxDb = minDb + Math.max(20, Number.isFinite(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); + } + overviewWfTexWidth = iW; + overviewWfTexHeight = iH; + + ensureWaterfallLut(pal, minDb, maxDb); + + function renderRow(dstY, srcBins) { + if (!isBinsArray(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)); + waterfallLutWrite(overviewWfTexData, rowBase + x * 4, srcBins[binIdx]); + } + } + + if (needsFull) { + for (let y = 0; y < iH; y++) { + renderRow(y, rows[y]); + } + 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++) renderRow(y, rows[y]); + } else { + const shiftBytes = newCount * rowStride; + overviewWfTexData.copyWithin(0, shiftBytes); + const startRow = iH - newCount; + for (let y = startRow; y < iH; y++) { + renderRow(y, rows[y]); + } + } + overviewWfTexPushCount = overviewWaterfallPushCount; + overviewWfTexPalKey = palKey; + texUpdated = true; + } + + if (texUpdated || !overviewWfTexReady) { + overviewGl.uploadRgbaTexture("overview-waterfall", iW, iH, overviewWfTexData, "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]) { + linePoints.push(x, y); + } + }); + overviewGl.drawPolyline(linePoints, cssColorToRgba(pal.waveformLine), 1.6); + + const holdMs = Math.max(0, Number.isFinite(overviewPeakHoldMs) ? overviewPeakHoldMs : 0); + if (holdMs > 0) { + const holdPoints = []; + for (let i = 0; i < samples.length; i++) { + let peak = samples[i].v; + for (let j = i; j >= 0; j--) { + if (samples[i].t - samples[j].t > holdMs) break; + if (samples[j].v > peak) peak = samples[j].v; + } + const x = toX(samples[i].t); + const y = toY(peak); + if (i === 0 || x >= holdPoints[holdPoints.length - 2]) { + holdPoints.push(x, y); + } + } + overviewGl.drawPolyline(holdPoints, cssColorToRgba(pal.waveformPeak), 1); + } +} + +function waterfallColorRgba(db, pal, minDb, maxDb) { + const lo = Number.isFinite(minDb) ? minDb : (Number.isFinite(spectrumFloor) ? spectrumFloor : -115); + const hi = Number.isFinite(maxDb) ? maxDb : (lo + Math.max(20, Number.isFinite(spectrumRange) ? spectrumRange : 90)); + const safeDb = Number.isFinite(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.0 ? 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})`); +} + +// 256-entry waterfall color lookup table (bins are i8 = 256 possible values). +// Eliminates per-pixel HSLβ†’RGBA computation in the waterfall rendering hot path. +let _wfLutKey = ""; +const _wfLut = new Uint8Array(256 * 4); // [r,g,b,a] Γ— 256 entries, 0-255 range + +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++) { + // i8 range: -128 to 127 (dB values in the spectrum) + 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; + } +} + +// Fast waterfall pixel write using LUT. `db` is the raw i8 bin value. +function waterfallLutWrite(texData, offset, db) { + // Convert signed i8 to 0-255 LUT index + const idx = ((db | 0) + 256) & 0xFF; + const p = idx * 4; + texData[offset] = _wfLut[p]; + texData[offset + 1] = _wfLut[p + 1]; + texData[offset + 2] = _wfLut[p + 2]; + texData[offset + 3] = _wfLut[p + 3]; +} + +function formatFreq(hz) { + if (!Number.isFinite(hz)) return "--"; + if (hz >= 1_000_000_000) { + return `${(hz / 1_000_000_000).toFixed(3)} GHz`; + } + if (hz >= 10_000_000) { + return `${(hz / 1_000_000).toFixed(3)} MHz`; + } + return `${(hz / 1_000).toFixed(1)} kHz`; +} + +function formatFreqForStep(hz, step) { + if (!Number.isFinite(hz)) return "--"; + if (step >= 1_000_000) return (hz / 1_000_000).toFixed(6); + if (step >= 1_000) return (hz / 1_000).toFixed(3); + if (step >= 1) return String(Math.round(hz)); + return formatFreq(hz); +} + +function formatWavelength(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + const meters = 299_792_458 / hz; + if (meters >= 1) return `${Math.round(meters)} m`; + return `${Math.round(meters * 100)} cm`; +} + +function refreshWavelengthDisplay(hz) { + if (!wavelengthEl) return; + wavelengthEl.textContent = formatWavelength(hz); +} + +function refreshFreqDisplay() { + if (lastFreqHz == null || freqDirty) return; + freqEl.value = formatFreqForStep(lastFreqHz, jogUnit); + refreshWavelengthDisplay(lastFreqHz); +} + +function activeRdsChannelId() { + if (typeof vchanActiveId !== "undefined" && vchanActiveId) return vchanActiveId; + return null; +} + +function activeChannelRds() { + if (!activeChannelIsWfm()) return null; + const activeId = activeRdsChannelId(); + if (activeId) { + const rds = vchanRdsById.get(activeId); + if (rds) return rds; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + if (vchanChannels[0].id === activeId) return primaryRds; + } + } + return primaryRds; +} + +function activeChannelIsWfm() { + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + const activeId = activeRdsChannelId(); + const active = vchanChannels.find((ch) => ch.id === activeId) || vchanChannels[0]; + return String(active?.mode || "").toUpperCase() === "WFM"; + } + return lastModeName === "WFM"; +} + +function activeChannelFreqHz() { + if (typeof vchanActiveChannel === "function") { + const ch = vchanActiveChannel(); + if (Number.isFinite(ch?.freq_hz)) return ch.freq_hz; + } + return lastFreqHz; +} + +function activeBandwidthCenterHz() { + const freqHz = activeChannelFreqHz(); + return Number.isFinite(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) : escapeMapHtml(mainText)}` + + `` + + `${escapeMapHtml(metaText)}` + + `${trafficFlags}` + + `` + ); +} + +function collectRdsOverlayEntries() { + const entries = []; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + for (const ch of vchanChannels) { + if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; + if (!Number.isFinite(ch?.freq_hz)) continue; + const rds = vchanRdsById.get(ch.id) || (vchanChannels[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 && Number.isFinite(lastFreqHz)) { + entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); + } + return entries; +} + +function renderRdsOverlays() { + if (!rdsPsOverlay) return; + if (!lastSpectrumData || !overviewCanvas) { + rdsOverlayEntries = []; + rdsPsOverlay.style.display = "none"; + return; + } + const entries = collectRdsOverlayEntries(); + rdsOverlayEntries = []; + rdsPsOverlay.replaceChildren(); + if (entries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + entries.forEach((entry) => { + const html = buildRdsOverlayHtml(entry.rds); + if (!html) return; + const el = document.createElement("div"); + el.className = "rds-ps-overlay-item"; + el.dataset.freqHz = String(entry.freq_hz); + el.innerHTML = html; + el.addEventListener("click", (evt) => { + evt.stopPropagation(); + 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 (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) return; + // Assign z-indices: sort by frequency ascending so higher-frequency layers + // sit on top of lower-frequency ones in the default (non-hover) state. + 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 (!Number.isFinite(entry.freq_hz)) { + el.style.display = "none"; + return; + } + el.style.display = ""; + const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; + const clamped = Math.max(0.06, Math.min(0.94, rel)); + el.style.left = `${clamped * width}px`; + el.style.top = "50%"; + const z = String(freqZMap.get(entry.id) ?? (idx + 1)); + el.style.zIndex = z; + el.dataset.defaultZ = z; + }); +} + +function positionRdsPsOverlay() { + positionRdsOverlays(); +} + +function resetRdsDisplay() { + updateRdsPsOverlay(primaryRds); +} + +function resetDecoderStateOnRigSwitch() { + // RDS + primaryRds = null; + vchanRdsById = new Map(); + resetRdsDisplay(); + resetWfmStereoIndicator(); + resetIntfBars(); + + // Spectrum β€” clear stale data from previous rig's SDR + lastSpectrumData = null; + window.lastSpectrumData = null; + lastSpectrumRenderData = null; + + // Decoder status indicators + 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); +} + +// ── Fast CSS-based frequency/BW marker positioning ────────────────────────── +// These lightweight DOM elements reposition via `transform: translateX()` +// which is GPU-composited β€” zero layout/paint cost. The full WebGL overlay +// (drawSignalOverlay) catches up on the next rAF. +const _fastFreqMarker = document.getElementById("fast-freq-marker"); +const _fastBwLeft = document.getElementById("fast-bw-left"); +const _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 && Number.isFinite(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 && Number.isFinite(freqHz) && Number.isFinite(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); + // Left side of BW + 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"; + } + // Right side of BW + if (rFrac > cFrac && rFrac >= 0 && cFrac <= 1) { + const x = Math.max(0, cFrac) * cssW; + const w = (Math.min(1, rFrac) - Math.max(0, cFrac)) * cssW; + _fastBwRight.style.display = ""; + _fastBwRight.style.transform = `translateX(${x}px)`; + _fastBwRight.style.width = `${w}px`; + } else { + _fastBwRight.style.display = "none"; + } + } +} + +function applyLocalTunedFrequency(hz, forceDisplay = false) { + if (!Number.isFinite(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(); + } + // Instant CSS marker repositioning (GPU-composited, no WebGL). + positionFastOverlay(lastFreqHz, currentBandwidthHz); + if (freqChanged && lastSpectrumData) { + scheduleSpectrumDraw(); + } + if (freqChanged && !lastSpectrumData) { + updateBandplanStrip(bandplanComputeRange()); + } + positionRdsPsOverlay(); +} + +function coverageGuardBandwidthHz(mode = modeEl ? modeEl.value : "") { + const [, , maxBw] = mwDefaultsForMode(mode); + return Math.max(0, Number.isFinite(maxBw) ? maxBw : currentBandwidthHz); +} + +function isAisMode(mode = modeEl ? modeEl.value : "") { + return String(mode || "").toUpperCase() === "AIS"; +} + +function isVdesMode(mode = modeEl ? modeEl.value : "") { + return String(mode || "").toUpperCase() === "VDES"; +} + +function visibleBandwidthSpecs(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { + if (!Number.isFinite(freqHz)) return []; + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "AIS") { + return [ + { centerHz: freqHz, widthHz: currentBandwidthHz }, + { centerHz: freqHz + 50_000, widthHz: currentBandwidthHz }, + ]; + } + return [{ centerHz: freqHz, widthHz: currentBandwidthHz }]; +} + +function sidebandDirectionForMode(mode = modeEl ? modeEl.value : "") { + const modeUpper = String(mode || "").toUpperCase(); + if (modeUpper === "LSB" || modeUpper === "CWR") return -1; + if (modeUpper === "USB" || modeUpper === "CW" || modeUpper === "DIG") return 1; + return 0; +} + +function displaySpanForBandwidthSpec(spec, mode = modeEl ? modeEl.value : "") { + const centerHz = Number(spec?.centerHz); + const widthHz = Math.max(0, Number.isFinite(spec?.widthHz) ? Number(spec.widthHz) : 0); + const side = sidebandDirectionForMode(mode); + if (side < 0) { + return { loHz: centerHz - widthHz, hiHz: centerHz, side }; + } + if (side > 0) { + return { loHz: centerHz, hiHz: centerHz + widthHz, side }; + } + const halfBw = widthHz / 2; + return { loHz: centerHz - halfBw, hiHz: centerHz + halfBw, side }; +} + +function coverageSpanForMode(freqHz, bandwidthHz = coverageGuardBandwidthHz(), mode = modeEl ? modeEl.value : "") { + if (!Number.isFinite(freqHz)) return null; + const specs = visibleBandwidthSpecs(freqHz, mode).map((spec) => { + const widthHz = Math.max( + 0, + Number.isFinite(spec.widthHz) ? spec.widthHz : Math.max(0, Number.isFinite(bandwidthHz) ? bandwidthHz : 0), + ); + return displaySpanForBandwidthSpec({ centerHz: spec.centerHz, widthHz }, mode); + }); + if (specs.length === 0) return null; + let loHz = specs[0].loHz; + let hiHz = specs[0].hiHz; + for (const spec of specs.slice(1)) { + loHz = Math.min(loHz, spec.loHz); + hiHz = Math.max(hiHz, spec.hiHz); + } + return { loHz, hiHz }; +} + +function visibleBandwidthCenters(freqHz = lastFreqHz, mode = modeEl ? modeEl.value : "") { + return visibleBandwidthSpecs(freqHz, mode).map((spec) => spec.centerHz); +} + +function effectiveSpectrumCoverageSpanHz(sampleRateHz) { + const sampleRate = Number(sampleRateHz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0) return 0; + // Keep a guard band at the spectrum edges; practical usable span is slightly smaller. + const ratio = Number.isFinite(spectrumUsableSpanRatio) ? spectrumUsableSpanRatio : 0.92; + return sampleRate * Math.max(0.01, Math.min(1.0, ratio)); +} + +function sweetSpotMinimumOffsetHz(bandwidthHz) { + if (!Number.isFinite(bandwidthHz) || bandwidthHz <= 0) return 0; + return bandwidthHz / 2; +} + +function sweetSpotCenterHasRequiredOffset(centerHz, freqHz, bandwidthHz) { + if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz)) return false; + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) return true; + return Math.abs(centerHz - freqHz) >= minOffsetHz - 1; +} + +function chooseSweetSpotCenterOutsideOffsetRange(freqHz, bandwidthHz, minCenterHz, maxCenterHz, preferredCenterHz = null) { + if (!Number.isFinite(freqHz) || !Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + return null; + } + + const minOffsetHz = sweetSpotMinimumOffsetHz(bandwidthHz); + if (!Number.isFinite(minOffsetHz) || minOffsetHz <= 0) { + const fallbackCenterHz = Number.isFinite(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 (Number.isFinite(preferredCenterHz)) { + let bestCenterHz = targetCentersHz[0]; + 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 || !Number.isFinite(freqHz)) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(data.sample_rate); + const currentCenterHz = Number(data.center_hz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(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 (!Number.isFinite(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 = formatFreqForStep(nextCenterHz, jogUnit); + } +} + +// Guard: while a set_freq is in flight, SSE state updates must not overwrite +// the optimistic local frequency with the stale server value. +let _freqOptimisticHz = null; +let _freqOptimisticSeq = 0; + +function setRigFrequency(freqHz) { + const targetHz = Math.round(freqHz); + if (!freqAllowed(targetHz)) { + showUnsupportedFreqPopup(targetHz); + throw new Error(`Unsupported frequency: ${targetHz}`); + } + // Optimistic local update β€” visual is instant via CSS overlay + guard. + const prevFreqHz = lastFreqHz; + const seq = ++_freqOptimisticSeq; + _freqOptimisticHz = targetHz; + applyLocalTunedFrequency(targetHz); + // Fire-and-forget: network calls run in background. The SSE stream will + // push the confirmed frequency; the optimistic guard prevents snap-back. + Promise.all([ + postPath(`/set_freq?hz=${targetHz}`), + ensureTunedBandwidthCoverage(targetHz), + ]).catch((err) => { + // Roll back only if no newer optimistic call has superseded this one. + 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 || !isBinsArray(data.bins) || data.bins.length < 2 || !Number.isFinite(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 = Number.isFinite(db) ? db : -160; + const clamped = Math.max(-160, Math.min(40, value)); + return 10 ** (clamped / 10); +} + +function sweetSpotCandidateForFrame(data, freqHz, bandwidthHz) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 16) { + return null; + } + if (!Number.isFinite(freqHz) || !Number.isFinite(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 (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(usableSpanHz) || usableSpanHz <= 0 || !Number.isFinite(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 (!Number.isFinite(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 (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + evalMinCenterHz, + evalMaxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), + ); + if (!Number.isFinite(fallbackCenterHz)) return null; + return { centerHz: fallbackCenterHz, score: Number.POSITIVE_INFINITY }; + } + + const maxIdx = bins.length - 1; + const usableBins = Math.max(4, Math.min(maxIdx, Math.round((usableSpanHz / sampleRate) * maxIdx))); + const fullLoHz = currentCenterHz - fullHalfSpanHz; + const startMinIdx = Math.max( + 0, + Math.min(maxIdx - usableBins, Math.round((((minCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)), + ); + const startMaxIdx = Math.max( + startMinIdx, + Math.min(maxIdx - usableBins, Math.round((((maxCenterHz - halfUsableSpanHz) - fullLoHz) / sampleRate) * maxIdx)), + ); + + let bestStartIdx = null; + let bestScore = Number.POSITIVE_INFINITY; + const signalLoHz = span.loHz; + const signalHiHz = span.hiHz; + + for (let startIdx = startMinIdx; startIdx <= startMaxIdx; startIdx += 1) { + const endIdx = Math.min(maxIdx, startIdx + usableBins); + const windowLoHz = fullLoHz + (startIdx / maxIdx) * sampleRate; + const candidateCenterHz = windowLoHz + halfUsableSpanHz; + if (!sweetSpotCenterHasRequiredOffset(candidateCenterHz, freqHz, bandwidthHz)) { + continue; + } + const signalLoIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalLoHz))); + const signalHiIdx = Math.max(startIdx, Math.min(endIdx, spectrumBinIndexForHz(data, signalHiHz))); + + let score = 0; + for (let i = startIdx; i <= endIdx; i++) { + if (i >= signalLoIdx && i <= signalHiIdx) continue; + score += spectrumPowerScore(bins[i]); + } + + // Keep a very small bias toward a reasonably centered passband when scores are close. + 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 (!Number.isFinite(bestScore) || bestStartIdx == null) { + const fallbackCenterHz = chooseSweetSpotCenterOutsideOffsetRange( + freqHz, + bandwidthHz, + minCenterHz, + maxCenterHz, + requiredCenterFreqForCoverageInFrame(data, freqHz, bandwidthHz), + ); + if (!Number.isFinite(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) { + const candidate = sweetSpotCandidateForFrame(lastSpectrumData, freqHz, bandwidthHz); + return candidate && Number.isFinite(candidate.centerHz) ? candidate.centerHz : null; +} + +function sweetSpotProbeCenters(data, freqHz, bandwidthHz) { + if (!data || !Number.isFinite(freqHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) { + return []; + } + + const sampleRate = Number(data.sample_rate); + const usableSpanHz = effectiveSpectrumCoverageSpanHz(sampleRate); + if (!Number.isFinite(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 (!Number.isFinite(minCenterHz) || !Number.isFinite(maxCenterHz) || minCenterHz > maxCenterHz) { + return []; + } + + const points = 5; + const centers = []; + for (let i = 0; i < points; i++) { + const frac = points === 1 ? 0.5 : 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 (Number.isFinite(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 (!Number.isFinite(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 || !Number.isFinite(bestCandidate.centerHz))) { + showHint("Sweet-spot unavailable", 1100); + return; + } + + sweetSpotScanInFlight = true; + try { + showHint("Scanning sweet spot...", 1400); + + for (const probeCenterHz of probeCentersHz) { + if (!Number.isFinite(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 || !Number.isFinite(candidate.centerHz)) continue; + if (!bestCandidate || candidate.score < bestCandidate.score) { + bestCandidate = candidate; + } + } + + const targetCenterHz = bestCandidate && Number.isFinite(bestCandidate.centerHz) + ? bestCandidate.centerHz + : sweetSpotCenterFreq(lastFreqHz, currentBandwidthHz); + if (!Number.isFinite(targetCenterHz)) { + if (Number.isFinite(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 = formatFreqForStep(targetCenterHz, jogUnit); + } + if (Number.isFinite(originalCenterHz) && Math.abs(targetCenterHz - originalCenterHz) < 1) { + showHint("Already at sweet spot", 900); + } else { + showHint("Sweet-spot set", 1200); + } + } finally { + sweetSpotScanInFlight = false; + } +} + +function tunedFrequencyForCenterCoverage(centerHz, freqHz = lastFreqHz, bandwidthHz = coverageGuardBandwidthHz()) { + if (!Number.isFinite(centerHz) || !Number.isFinite(freqHz) || !lastSpectrumData) return null; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + if (!Number.isFinite(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)); +} + +// Optimistic center freq: updated immediately on each arrow click so that +// rapid clicks accumulate rather than all starting from the same stale frame. +let spectrumCenterPendingHz = null; + +async function shiftSpectrumCenter(direction) { + if (!lastSpectrumData || !Number.isFinite(direction) || direction === 0) return; + const sampleRate = effectiveSpectrumCoverageSpanHz(lastSpectrumData.sample_rate); + const currentCenterHz = spectrumCenterPendingHz ?? Number(lastSpectrumData.center_hz); + if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(currentCenterHz)) return; + + const stepHz = Math.max(50_000, 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 = formatFreqForStep(nextCenterHz, jogUnit); + } + + const nextFreqHz = tunedFrequencyForCenterCoverage(nextCenterHz); + if (Number.isFinite(nextFreqHz) && Math.abs(nextFreqHz - Number(lastFreqHz)) >= 1) { + await postPath(`/set_freq?hz=${nextFreqHz}`); + applyLocalTunedFrequency(nextFreqHz); + } +} + +function refreshCenterFreqDisplay() { + if (!centerFreqEl || !lastSpectrumData || centerFreqDirty) return; + centerFreqEl.value = formatFreqForStep(lastSpectrumData.center_hz, jogUnit); +} + +function parseFreqInput(val, defaultStep) { + if (!val) return null; + const trimmed = val.trim().toLowerCase(); + const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/); + if (!match) return null; + const rawNumber = match[1]; + let num = parseFloat(rawNumber.replace(",", ".")); + const unit = match[2] || ""; + if (Number.isNaN(num)) return null; + if (unit.startsWith("gh") || unit === "g") { + num *= 1_000_000_000; + } else if (unit.startsWith("mh") || unit === "m") { + num *= 1_000_000; + } else if (unit.startsWith("kh") || unit === "k") { + num *= 1_000; + } else if (!unit) { + const mode = (modeEl?.value || "").toUpperCase(); + const hasDecimalSeparator = rawNumber.includes(".") || rawNumber.includes(","); + if (mode === "WFM") { + if (hasDecimalSeparator && num >= 50 && num < 200) { + num *= 1_000_000; + return Math.round(num); + } + if (!hasDecimalSeparator && num >= 875 && num <= 1080) { + num = (num / 10) * 1_000_000; + return Math.round(num); + } + } + // Use currently selected input unit when user omits suffix. + if (defaultStep >= 1_000_000) { + num *= 1_000_000; + } else if (defaultStep >= 1_000) { + num *= 1_000; + } else if (defaultStep >= 1) { + // already Hz + } else { + // Fallback heuristic. + if (num >= 1_000_000) { + // Assume already Hz. + } else if (num >= 1_000) { + num *= 1_000; + } else { + num *= 1_000_000; + } + } + } + return Math.round(num); +} + +function normalizeMinFreqStep(cap) { + const val = Number(cap && cap.min_freq_step_hz); + if (!Number.isFinite(val) || val < 1) return 1; + return Math.round(val); +} + +function alignFreqToRigStep(hz) { + if (!Number.isFinite(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 (Number.isFinite(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) => Number.isFinite(s) && s > 0); + if (steps.length === 0) return; + + const current = Number(jogUnit); + const desired = + Number.isFinite(current) && current >= minFreqStepHz ? current : Math.max(steps[0], minFreqStepHz); + + jogUnit = steps.reduce((best, s) => (Math.abs(s - desired) < Math.abs(best - desired) ? s : best), steps[0]); + 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 [variant, value] = entries[0]; + if (variant === "Other" && typeof value === "string") return value; + return variant; + } + } + return ""; +} + +function updateSupportedBands(cap) { + if (cap && Array.isArray(cap.supported_bands)) { + supportedBands = cap.supported_bands + .filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number") + .map((b) => ({ low: b.low_hz, high: b.high_hz })); + } else { + supportedBands = []; + } +} + +function freqAllowed(hz) { + if (!Number.isFinite(hz)) return false; + if (supportedBands.length === 0) return true; // if unknown, don't block + 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) => `${formatFreqForHumans(b.low)} to ${formatFreqForHumans(b.high)}`); + return `Supported ranges: ${ranges.join(", ")}`; +} + +function formatFreqForHumans(hz) { + if (!Number.isFinite(hz)) return "--"; + if (hz >= 1_000_000_000) return `${(hz / 1_000_000_000).toFixed(3)} GHz`; + if (hz >= 1_000_000) return `${(hz / 1_000_000).toFixed(3)} MHz`; + if (hz >= 1_000) return `${(hz / 1_000).toFixed(3)} kHz`; + return `${Math.round(hz)} Hz`; +} + +function showUnsupportedFreqPopup(hz) { + const message = `Unsupported frequency: ${formatFreqForHumans(hz)}.\n\n${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: 7000 }); +} + +// Convert dBm (wire format) to S-units (S1=-121dBm, S9=-73dBm, 6dB/S-unit). +// Above S9, returns 9 + (overshoot in S-unit-equivalent, i.e. dB/10). +function dbmToSUnits(dbm) { + if (!Number.isFinite(dbm)) return 0; + // Guard against bogus backend values to keep display in a realistic range. + 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 (!Number.isFinite(sUnits) || sUnits <= 0) return `${sigUnit("S")}0`; + if (sUnits <= 9) return `${sigUnit("S")}${Math.round(sUnits)}`; + // S9+xdB: round to nearest 10 dB step, cap at +60. + 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; + }); +} + +let serverVersion = null; +let serverBuildDate = null; +let serverCallsign = null; +let ownerCallsign = null; +let ownerWebsiteUrl = null; +let ownerWebsiteName = null; +let aisVesselUrlBase = null; +let serverRigs = []; +let serverActiveRigId = null; +let serverLat = null; +let serverLon = null; +let initialMapZoom = 10; +let spectrumCoverageMarginHz = 50_000; +let spectrumUsableSpanRatio = 0.92; +const DEFAULT_OVERVIEW_PLOT_HEIGHT_PX = 160; +const DEFAULT_SPECTRUM_PLOT_HEIGHT_PX = 160; +const MIN_OVERVIEW_PLOT_HEIGHT_PX = 90; +const MIN_SPECTRUM_PLOT_HEIGHT_PX = 130; +const DEFAULT_SIGNAL_SPLIT_PERCENT = 50; +const MIN_SIGNAL_SPLIT_PERCENT = 20; +const MAX_SIGNAL_SPLIT_PERCENT = 80; +let spectrumLayoutPending = false; +let spectrumManualTotalPlotHeightPx = null; +let spectrumResizeState = null; +let 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 = Number.isFinite(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(tabMainEl, contentEl, overviewCanvasEl, spectrumCanvasEl) { + const currentOverviewHeight = currentOverviewHeightPx(overviewCanvasEl); + const currentSpectrumHeight = currentSpectrumHeightPx(spectrumCanvasEl); + const currentTotalHeight = currentOverviewHeight + currentSpectrumHeight; + const tabBottom = tabMainEl.getBoundingClientRect().bottom; + const contentBottom = contentEl.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`); + // Refresh cached canvas sizes after layout change. + 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"); +} + +const 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 = + `${escapeMapHtml(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 || !Number.isFinite(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 && Number.isFinite(serverLat) && Number.isFinite(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" && Number.isFinite(update.initial_map_zoom)) { + initialMapZoom = Math.max(1, Math.round(update.initial_map_zoom)); + } + if ( + typeof update.spectrum_coverage_margin_hz === "number" && + Number.isFinite(update.spectrum_coverage_margin_hz) + ) { + spectrumCoverageMarginHz = Math.max(1, Math.round(update.spectrum_coverage_margin_hz)); + } + if ( + typeof update.spectrum_usable_span_ratio === "number" && + Number.isFinite(update.spectrum_usable_span_ratio) + ) { + spectrumUsableSpanRatio = Math.max(0.01, Math.min(1.0, Number(update.spectrum_usable_span_ratio))); + } + if ( + typeof update.decode_history_retention_min === "number" && + Number.isFinite(update.decode_history_retention_min) && + update.decode_history_retention_min > 0 + ) { + const nextRetentionMin = Math.max(1, Math.round(Number(update.decode_history_retention_min))); + if (nextRetentionMin !== decodeHistoryRetentionMin) { + decodeHistoryRetentionMin = nextRetentionMin; + if (typeof window.applyDecodeHistoryRetention === "function") { + window.applyDecodeHistoryRetention(); + } + } + } + scheduleSpectrumLayout(); + updateTitle(); + + initialized = !!update.initialized; + const hasUsableSnapshot = + !!update.info && + !!update.status && + !!update.status.freq && + typeof update.status.freq.hz === "number"; + if (!initialized) { + const fallbackRigName = originalTitle || "Rig"; + const manu = (update.info && update.info.manufacturer) || fallbackRigName; + const model = (update.info && update.info.model) || fallbackRigName; + const rev = (update.info && update.info.revision) || ""; + const parts = [manu, model, rev].filter(Boolean).join(" "); + if (!hasUsableSnapshot) { + loadingTitle.textContent = `Initializing ${parts}…`; + loadingSub.textContent = ""; + console.info("Rig initializing:", { manufacturer: manu, model, revision: rev }); + loadingEl.style.display = ""; + if (contentEl) contentEl.style.display = "none"; + powerHint.textContent = "Initializing rig…"; + setDisabled(true); + return; + } + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + powerHint.textContent = "Rig not fully initialized yet"; + } else { + loadingEl.style.display = "none"; + if (contentEl) contentEl.style.display = ""; + } + // Server subtitle: keep the static "trx-client vX.Y.Z" and append callsign if available. + if (serverSubtitle && update.server_callsign) { + const base = serverSubtitle.textContent.split(" hosted by")[0]; + const safeCallsign = escapeMapHtml(update.server_callsign); + const encodedCallsign = encodeURIComponent(update.server_callsign); + serverSubtitle.innerHTML = + `${escapeMapHtml(base)} hosted by ${safeCallsign}`; + } + // Note: rig switch decoder reset is now handled in switchRigFromSelect() + // so that other tabs' switches don't reset our state. + updateRigSubtitle(lastActiveRigId); + if (ownerSubtitle) { + if (ownerCallsign) { + const safeOwner = escapeMapHtml(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); + } + // Sync filter state (SDR backends only) + if (update.filter && typeof update.filter.bandwidth_hz === "number") { + currentBandwidthHz = update.filter.bandwidth_hz; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(currentBandwidthHz); + // Reposition BW overlay immediately so freq+bw render together. + 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, + hasSdrSquelchThreshold ? 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; + } + if (hasSdrNbThreshold && sdrNbThresholdEl && document.activeElement !== sdrNbThresholdEl) { + 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"; + } + // Apply server-configured bandplan defaults once, only when the user has not + // previously overridden the setting via the UI (localStorage). + if (!_bandplanServerDefaultApplied && typeof update.bandplan_enabled === "boolean" + && typeof update.bandplan_region === "string") { + _bandplanServerDefaultApplied = true; + const hasUserOverride = localStorage.getItem(STORAGE_PREFIX + "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; + // While an optimistic set_freq is in flight, suppress SSE updates that + // would snap the marker back to the stale server frequency. + if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) > 1) { + // stale β€” skip + } else { + if (_freqOptimisticHz != null && Math.abs(sseHz - _freqOptimisticHz) <= 1) { + _freqOptimisticHz = null; // server confirmed β€” clear guard early + } + 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 modeUpper = mode ? mode.toUpperCase() : ""; + const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + // When subscribed to a virtual channel the mode picker must reflect + // that channel's mode, not the primary rig mode. Skip the update here; + // vchan.js will apply the correct mode via vchanSyncModeDisplay(). + if (!onVirtual) { + modeEl.value = modeUpper; + if (modeUpper === "WFM" && lastModeName !== "WFM") { + setJogDivisor(10); + resetRdsDisplay(); + } else if (modeUpper !== "WFM" && lastModeName === "WFM") { + resetRdsDisplay(); + } + lastModeName = modeUpper; + // When filter panel is active (SDR backend), update the BW slider range + // to match the new mode β€” but only if the server hasn't already sent a + // filter state that overrides it. + // When SDR backend is active (spectrum visible), apply BW default for new + // mode β€” but only if the server hasn't already pushed a filter_state. + if (lastSpectrumData && !update.filter) { + applyBwDefaultForMode(mode, false); + } + } + updateWfmControls(); + updateSdrSquelchControlVisibility(); + } + const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; + // Mode-bound decoder status (driven by registry). + 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(); + // Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong. + 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 = ""; + } + } + // Decoder toggle buttons: only write DOM when the enabled flag actually changes. + _ensureDecoderToggles(); + for (const [key, entry] of Object.entries(_decoderToggles)) { + syncDecoderToggle(entry, !!update[key], entry.label); + } + // WEFAX toggle sync (plugin-owned, belt-and-suspenders alongside _decoderToggles). + if (typeof update.wefax_decode_enabled === "boolean" && window.syncWefaxToggle) { + window.syncWefaxToggle(update.wefax_decode_enabled); + } + // Recorder state sync. + if (typeof update.recorder_enabled === "boolean" && window._syncRecorderState) { + window._syncRecorderState(update.recorder_enabled); + } + if (window.updateSatLiveState) window.updateSatLiveState(update); + // cwAutoEl, cwWpmEl, cwToneEl are cached at module level + if (cwWpmEl && typeof update.cw_wpm === "number") { + cwWpmEl.value = update.cw_wpm; + } + if (cwToneEl && typeof update.cw_tone_hz === "number") { + cwToneEl.value = update.cw_tone_hz; + } + if (typeof update.cw_auto === "boolean") { + if (typeof window.applyCwAutoUiFromServer === "function") { + // cw.js is loaded: use the guarded path that respects in-flight user + // changes, preventing a concurrent SSE poll from re-enabling auto just + // after the user disabled it. + 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)}: ${formatFreq(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", 2000); + 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 = update.status.tx.limit; + txLimitRow.style.display = ""; + } else { + txLimitInput.value = ""; + txLimitRow.style.display = "none"; + } + + if (typeof update.clients === "number") lastClientCount = update.clients; + // Populate About tab β€” only update DOM when the about tab is visible + if (_activeTab === "about") { + _resolveAboutEls(); + _resolveAboutDecEls(); + // About β€” Server card + 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 (Number.isFinite(serverLat) && Number.isFinite(serverLon) && aboutServerLocationEl) { + const grid = latLonToMaidenhead(serverLat, serverLon); + aboutServerLocationEl.textContent = `${grid} (${serverLat.toFixed(4)}, ${serverLon.toFixed(4)})`; + } + + // About β€” Radio card + 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 (access.Serial) { + const serialPath = access.Serial.path || access.Serial.port || "?"; + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `Serial (${serialPath}, ${access.Serial.baud || "?"} baud)`; + } else if (access.Tcp) { + if (aboutRigAccessEl) aboutRigAccessEl.textContent = `TCP (${access.Tcp.host || "?"}:${access.Tcp.port || "?"})`; + } 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 = cap.num_vfos; + } + } + } + if (lastActiveRigId && aboutActiveRigEl) { + aboutActiveRigEl.textContent = lastActiveRigId; + } + + // About β€” Audio card + if (streamInfo) { + if (aboutAudioCodecEl) aboutAudioCodecEl.textContent = "Opus"; + if (aboutAudioSamplerateEl) aboutAudioSamplerateEl.textContent = `${(streamInfo.sample_rate || 48000).toLocaleString()} Hz`; + if (aboutAudioChannelsEl) aboutAudioChannelsEl.textContent = (streamInfo.channels || 1) === 1 ? "Mono" : "Stereo"; + if (streamInfo.bitrate_bps && aboutAudioBitrateEl) { + const kbps = (streamInfo.bitrate_bps / 1000).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 = update.audio_clients; + } + + // About β€” Decoders card (only update when values change) + 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); + + // About β€” Integrations card + 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 = update.rigctl_clients; + } + if (typeof update.rigctl_addr === "string" && update.rigctl_addr.length > 0 && aboutRigctlEndpointEl) { + aboutRigctlEndpointEl.textContent = update.rigctl_addr; + } + + // About β€” Clients card + if (typeof update.clients === "number" && aboutClientsEl) { + aboutClientsEl.textContent = update.clients; + } + } // end _activeTab === "about" + if (Array.isArray(update.remotes)) { + applyRigList(update.active_remote, update.remotes); + } + powerHint.textContent = readyText(); + lastLocked = update.status && 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 = 1000) { + 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) { + // Ignore network errors; connect() retry loop handles reconnection. + } +} + +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); + lastEventAt = Date.now(); + es.onopen = () => { + setConnLostOverlay(false); + if (tabMainEl) tabMainEl.classList.remove("server-disconnected"); + if (!aboutUptimeStart) aboutUptimeStart = Date.now(); + pollFreshSnapshot(); + refreshRigList(); + }; + es.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); + } + }; + es.addEventListener("ping", () => { + lastEventAt = Date.now(); + }); + es.addEventListener("session", evt => { + try { + const d = JSON.parse(evt.data); + sseSessionId = d.session_id || null; + } catch (_) {} + if (typeof vchanHandleSession === "function") vchanHandleSession(evt.data); + }); + es.addEventListener("channels", evt => { + if (typeof vchanHandleChannels === "function") vchanHandleChannels(evt.data); + }); + es.onerror = () => { + // Check if this is an auth error by looking at readyState + if (es.readyState === EventSource.CLOSED) { + powerHint.textContent = "trx-client connection lost, retrying\u2026"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); + es.close(); + pollFreshSnapshot(); + scheduleReconnect(1000); + } + }; + + esHeartbeat = setInterval(() => { + const now = Date.now(); + if (now - lastEventAt > 15000) { + powerHint.textContent = "trx-client connection lost, retrying\u2026"; + setConnLostOverlay(true, "trx-client connection lost", "Retrying\u2026", true); + es.close(); + pollFreshSnapshot(); + scheduleReconnect(250); + } + }, 5000); +} + +function disconnect() { + // Close event sources + if (es) { + es.close(); + es = null; + } + if (decodeSource) { + decodeSource.close(); + decodeSource = null; + } + stopSpectrumStreaming(); + stopMeterStreaming(); + // Clear timers + if (esHeartbeat) { + clearInterval(esHeartbeat); + esHeartbeat = null; + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + setDecodeHistoryOverlayVisible(false); + setConnLostOverlay(false); +} + +// Yield the main thread so the browser can paint before heavy async work. +// Uses scheduler.yield() (Chrome 115+) with a setTimeout fallback. +function yieldToMain() { + if (typeof scheduler !== "undefined" && typeof scheduler.yield === "function") { + return scheduler.yield(); + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const uiFrameJobs = new Map(); +let 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 === undefined ? lastActiveRigId : options.remote; + // Auto-append remote so each tab targets its own rig. + // Skip when the caller already included remote (e.g. /select_rig). + 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) { + // Not authenticated - return to login + authRole = null; + if (es) es.close(); + showAuthGate(); + throw new Error("Authentication required"); + } + if (resp.status === 403) { + // Authenticated but insufficient permissions - don't redirect + 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; + if (typeof window.vchanTakeSchedulerControl === "function") { + await window.vchanTakeSchedulerControl(); + } +} +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); + if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); + if (typeof bmFetch === "function") bmFetch(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", 2000); + 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", 2000); + console.error(err); + } finally { + setControlPending(pttBtn, false); + } +}); + +function applyFreqFromInput() { + const parsedRaw = parseFreqInput(freqEl.value, jogUnit); + const parsed = alignFreqToRigStep(parsedRaw); + if (parsed === null) { + showHint("Freq missing", 1500); + return; + } + if (!freqAllowed(parsed)) { + showUnsupportedFreqPopup(parsed); + return; + } + freqDirty = false; + // setRigFrequency is fire-and-forget; visual update is instant. + setRigFrequency(parsed); +} + +async function applyCenterFreqFromInput() { + if (!centerFreqEl) return; + const parsedRaw = parseFreqInput(centerFreqEl.value, jogUnit); + const parsed = alignFreqToRigStep(parsedRaw); + if (parsed === null) { + showHint("Central freq missing", 1500); + return; + } + 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", 2000); + 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(); + applyCenterFreqFromInput(); + } else if (e.key === "Escape") { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + centerFreqEl.blur(); + } + }); + centerFreqEl.addEventListener("blur", () => { + if (centerFreqDirty) { + centerFreqDirty = false; + refreshCenterFreqDisplay(); + } + }); + centerFreqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); + }, { passive: false }); +} +freqEl.addEventListener("wheel", (e) => { + e.preventDefault(); + const direction = e.deltaY < 0 ? 1 : -1; + jogFreq(direction); +}, { passive: false }); + +// --- Jog wheel --- +const jogWheel = document.getElementById("jog-wheel"); +const jogIndicator = document.getElementById("jog-indicator"); +const jogDownBtn = document.getElementById("jog-down"); +const jogUpBtn = document.getElementById("jog-up"); +const jogStepEl = document.getElementById("jog-step"); +const jogMultEl = document.getElementById("jog-mult"); +const VALID_JOG_DIVISORS = 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 is fire-and-forget; visual update is instant. + 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 }); + +// Touch drag on jog wheel +let jogTouchY = null; +jogWheel.addEventListener("touchstart", (e) => { + e.preventDefault(); + jogTouchY = e.touches[0].clientY; +}, { passive: false }); +jogWheel.addEventListener("touchmove", (e) => { + e.preventDefault(); + if (jogTouchY === null) return; + const dy = jogTouchY - e.touches[0].clientY; + if (Math.abs(dy) > 12) { + jogFreq(dy > 0 ? 1 : -1); + jogTouchY = e.touches[0].clientY; + } +}, { passive: false }); +jogWheel.addEventListener("touchend", () => { jogTouchY = null; }); + +// Mouse drag on jog wheel +let 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"; +}); + +// Step unit selector +jogStepEl.addEventListener("click", (e) => { + const btn = e.target.closest("button[data-step]"); + if (!btn) return; + jogUnit = parseInt(btn.dataset.step, 10); + jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active")); + btn.classList.add("active"); + applyJogStep(); +}); + +// Step multiplier selector +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.closest("button[data-mult]"); + if (!btn) return; + setJogDivisor(parseInt(btn.dataset.mult, 10)); + }); +} + +// Restore active jog step buttons from saved settings +{ + 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) === 1000) || + unitBtns[0]; + if (activeUnit) { + jogUnit = parseInt(activeUnit.dataset.step, 10); + unitBtns.forEach((b) => b.classList.toggle("active", b === activeUnit)); + } + if (jogMultEl) { + const multBtns = Array.from(jogMultEl.querySelectorAll("button[data-mult]")); + const activeMult = + multBtns.find((b) => parseInt(b.dataset.mult, 10) === jogMult && VALID_JOG_DIVISORS.has(jogMult)) || + multBtns.find((b) => parseInt(b.dataset.mult, 10) === 1) || + multBtns[0]; + if (activeMult) { + jogMult = VALID_JOG_DIVISORS.has(parseInt(activeMult.dataset.mult, 10)) + ? parseInt(activeMult.dataset.mult, 10) + : 1; + multBtns.forEach((b) => b.classList.toggle("active", b === activeMult)); + } else { + jogMult = 1; + } + } + jogStep = Math.max(Math.round(jogUnit / jogMult), minFreqStepHz); +} + +async function applyModeFromPicker() { + const mode = modeEl.value || ""; + if (!mode) { + showHint("Mode missing", 1500); + return; + } + updateWfmControls(); + setControlPending(modeEl, true); + showHint("Setting mode…"); + try { + if (typeof vchanInterceptMode === "function" && await vchanInterceptMode(mode)) { + showHint("Channel mode set", 1500); + return; + } + await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`); + showHint("Mode set", 1500); + if (mode.toUpperCase() === "WFM") { + setJogDivisor(10); + } + // Apply sensible default bandwidth for the new mode and push to server. + await applyBwDefaultForMode(mode, true); + } catch (err) { + showHint("Set mode failed", 2000); + 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", 2000); + 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", 2000); + console.error(err); + } finally { + setControlPending(lockBtn, false); + } +}); + +// --- Filter controls --- + +// Per-mode defaults: [default bandwidth Hz, min Hz, max Hz, step Hz] +const MODE_BW_DEFAULTS = { + CW: [500, 100, 9_000, 50], + CWR: [500, 100, 9_000, 50], + LSB: [2_700, 300, 6_000, 100], + USB: [2_700, 300, 6_000, 100], + AM: [9_000, 500, 20_000, 500], + SAM: [9_000, 500, 20_000, 500], + FM: [12_500, 2_500, 25_000, 500], + AIS: [25_000, 12_500, 50_000, 500], + VDES: [100_000, 25_000, 200_000, 1_000], + WFM: [180_000, 60_000,300_000,5_000], + DIG: [3_000, 300, 6_000, 100], + PKT: [25_000, 300, 50_000, 500], +}; +const MODE_BW_FALLBACK = [3_000, 300, 500_000, 100]; + +function mwDefaultsForMode(mode) { + return MODE_BW_DEFAULTS[(mode || "").toUpperCase()] || MODE_BW_FALLBACK; +} + +function formatBwLabel(hz) { + if (hz >= 1000) return (hz / 1000).toFixed(hz % 1000 === 0 ? 0 : 1) + " kHz"; + return hz + " Hz"; +} + +// Current receive bandwidth (Hz) β€” updated by server sync and BW drag. +let currentBandwidthHz = 3_000; +window.currentBandwidthHz = currentBandwidthHz; +const spectrumBwInput = document.getElementById("spectrum-bw-input"); +const spectrumBwSetBtn = document.getElementById("spectrum-bw-set-btn"); +const spectrumBwAutoBtn = document.getElementById("spectrum-bw-auto-btn"); +const spectrumBwSweetBtn = document.getElementById("spectrum-bw-sweet-btn"); + +function formatBandwidthInputKhz(hz) { + const khz = hz / 1000; + if (Math.abs(Math.round(khz) - khz) < 0.0001) return String(Math.round(khz)); + if (Math.abs(Math.round(khz * 10) - khz * 10) < 0.0001) return khz.toFixed(1); + return khz.toFixed(2); +} + +function syncBandwidthInput(hz) { + if (!spectrumBwInput || !Number.isFinite(hz) || hz <= 0) return; + const [, minBw, maxBw, stepBw] = mwDefaultsForMode(modeEl ? modeEl.value : "USB"); + spectrumBwInput.min = String(minBw / 1000); + spectrumBwInput.max = String(maxBw / 1000); + spectrumBwInput.step = String(stepBw / 1000); + spectrumBwInput.value = formatBandwidthInputKhz(hz); +} + +// Apply mode-specific BW default and optionally push to server. +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 * 1000); + if (!Number.isFinite(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 (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(clamped)) return; + await postPath(`/set_bandwidth?hz=${clamped}`); + if (Number.isFinite(lastFreqHz)) { + await ensureTunedBandwidthCoverage(lastFreqHz); + } + } catch (error) { + window.trxUi?.notify("Bandwidth could not be changed", { kind: "error", action: { label: "Retry", run: applyBandwidthFromInput } }); + } +} + +function estimateOccupiedBandwidth(data, centerHz, interference = {}) { + if (!data || !isBinsArray(data.bins) || data.bins.length < 3 || !Number.isFinite(centerHz)) { + return null; + } + + const bins = data.bins; + 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 mode = (modeEl ? modeEl.value : "USB").toUpperCase(); + const [defaultBw, minBw, maxBw, stepBw] = mwDefaultsForMode(mode); + const oneSided = mode === "USB" || mode === "DIG" || mode === "CW" + ? 1 + : mode === "LSB" || mode === "CWR" ? -1 : 0; + const isWfm = mode === "WFM"; + + // Reduce single-bin peaks and holes before finding occupied-channel edges. + // WFM needs a wider smoothing window because its energy is noise-like and + // spread across the entire channel rather than concentrated at a carrier. + const smoothRadius = isWfm ? 3 : 1; + const smoothed = bins.map((_, i) => { + let sum = 0; + let count = 0; + for (let j = Math.max(0, i - smoothRadius); j <= Math.min(maxIdx, i + smoothRadius); j++) { + sum += bins[j]; + count += 1; + } + return sum / count; + }); + const sorted = [...bins].sort((a, b) => a - b); + const noise = sorted[Math.floor(sorted.length * 0.2)]; + 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 i = searchLo; i <= searchHi; i++) peak = Math.max(peak, smoothed[i]); + const snr = peak - noise; + if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw; + + // A threshold relative to the noise floor finds occupied bandwidth much + // more reliably than one relative to the peak. The latter fails for WFM, + // whose multiplex spectrum has peaks, notches, and no narrow centre carrier. + 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 ? 12_000 : stepBw) / hzPerBin)); + + function occupiedExtent(direction, limitBins) { + let lastOccupied = centerIdx; + let gap = 0; + for (let n = 0; n <= limitBins; n++) { + const i = centerIdx + direction * n; + if (i <= 0 || i >= maxIdx) break; + if (smoothed[i] >= threshold) { + lastOccupied = i; + gap = 0; + } else if (++gap > allowedGap) { + break; + } + } + return Math.abs(lastOccupied - centerIdx) * hzPerBin; + } + + let rawBw; + if (oneSided !== 0) { + rawBw = occupiedExtent(oneSided, maxSpanBins); + } else { + const leftHz = occupiedExtent(-1, searchHalfBins); + const rightHz = occupiedExtent(1, searchHalfBins); + // A symmetric RF filter must contain the larger of the two sidebands. + rawBw = 2 * Math.max(leftHz, rightHz); + } + + // Add a transition-band margin. Weak WFM deliberately falls back to the + // 60 kHz mode floor above: a narrower filter trades stereo/RDS content for + // a useful improvement in intelligibility when the signal is very poor. + rawBw *= isWfm ? 1.08 : 1.12; + if (isWfm) { + const aci = Math.max(0, Math.min(100, Number(interference.aci) || 0)) / 100; + const cci = Math.max(0, Math.min(100, Number(interference.cci) || 0)) / 100; + // Adjacent-channel energy is outside the wanted modulation, so ACI can + // safely drive the cap all the way from the 300 kHz ceiling to 60 kHz. + const aciCap = maxBw - (maxBw - minBw) * aci; + // CCI overlaps the wanted station and cannot be removed by an RF filter. + // Only distrust the widest edge estimates, retaining at least 65% of the + // useful range between the weak-signal floor and nominal WFM bandwidth. + 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); +} + +async function applyAutoBandwidth() { + if (!lastSpectrumData || lastFreqHz == null) return; + // WFM interference telemetry belongs to the primary DSP channel. Do not + // apply it to a virtual channel, where it would describe the wrong signal. + const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); + const interference = onVirtual ? {} : { cci: lastWfmCci, aci: lastWfmAci }; + const estimated = estimateOccupiedBandwidth(lastSpectrumData, lastFreqHz, interference); + if (!Number.isFinite(estimated) || estimated <= 0) { + syncBandwidthInput(currentBandwidthHz); + return; + } + currentBandwidthHz = estimated; + window.currentBandwidthHz = currentBandwidthHz; + syncBandwidthInput(estimated); + positionFastOverlay(lastFreqHz, estimated); + if (lastSpectrumData) { + scheduleSpectrumDraw(); + } + const mode = (modeEl?.value || "").toUpperCase(); + let reason = "measured occupied spectrum"; + if (mode === "WFM") { + if (estimated === 60_000 && lastWfmAci >= 20) reason = `high adjacent-channel interference (${Math.round(lastWfmAci)}% ACI)`; + else if (estimated === 60_000) 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: 5000 }); + try { + if (typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(estimated)) return; + await postPath(`/set_bandwidth?hz=${estimated}`); + if (Number.isFinite(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(() => {}); }); +} + +// --- Tab navigation --- +let _activeTab = "main"; // tracked for render-path tab awareness +const TAB_ORDER = ["main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about"]; +const TAB_PATHS = { + main: "/", + bookmarks: "/bookmarks", + "digital-modes": "/digital-modes", + map: "/map", + recorder: "/recorder", + settings: "/settings", + about: "/about", +}; + +function normalizeTabPath(pathname) { + const raw = typeof pathname === "string" && pathname.length > 0 ? pathname : "/"; + if (raw === "/") return "/"; + return raw.replace(/\/+$/, "") || "/"; +} + +function tabFromPath(pathname = window.location.pathname) { + const normalized = normalizeTabPath(pathname); + for (const [tabName, tabPath] of Object.entries(TAB_PATHS)) { + if (normalized === tabPath) return tabName; + } + return "main"; +} + +function updateTabHistory(name, replaceHistory = false) { + const targetPath = TAB_PATHS[name] || "/"; + if (normalizeTabPath(window.location.pathname) === targetPath) return; + const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; + const method = replaceHistory ? "replaceState" : "pushState"; + window.history[method]({}, "", nextUrl); +} + +// Initialise the Leaflet map, waiting for both Leaflet (L) and map-core.js +// (window.trx.modules.map) if they haven't loaded yet. +let _mapInitTimer = null; +function _initMapWhenReady() { + const loadingEl = document.getElementById("map-loading"); + if (window.trx.modules.map && typeof L !== "undefined") { + if (_mapInitTimer) { clearInterval(_mapInitTimer); _mapInitTimer = null; } + if (loadingEl) loadingEl.classList.add("is-hidden"); + window.trx.modules.map.initAprsMap(); + window.trx.modules.map.sizeAprsMapToViewport(); + // The map panel was just made visible (display:none β†’ ""); the browser + // may not have laid it out yet, so getBoundingClientRect() can return + // stale/zero dimensions. Double-rAF ensures a full layout pass has + // completed before we re-measure and tell Leaflet about its real size. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + window.trx.modules.map.sizeAprsMapToViewport(); + if (window.trx.modules.map.aprsMap) window.trx.modules.map.aprsMap.invalidateSize(); + }); + }); + return; + } + // Not ready yet β€” show overlay and poll until both are available. + if (loadingEl) loadingEl.classList.remove("is-hidden"); + if (!_mapInitTimer) { + _mapInitTimer = setInterval(() => { + if (_activeTab !== "map") { 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}`); + panel.style.display = ""; + // Clone deferred