diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index c9dd4208..6f891962 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -885,7 +885,7 @@ function elementById(id) { if (restoreFocus) more.focus(); }; api.closeMobileOverlays = closeMore; - ["statistics", "recorder", "settings", "about"].forEach((tabName) => { + ["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => { const source = nav.querySelector(`[data-tab="${tabName}"]`); if (!source) return; const item = document.createElement("button"); @@ -1529,6 +1529,7 @@ var TAB_ORDER = [ "bookmarks", "digital-modes", "map", + "satellites", "statistics", "recorder", "settings", @@ -1539,6 +1540,7 @@ var TAB_PATHS = { bookmarks: "/bookmarks", "digital-modes": "/digital-modes", map: "/map", + satellites: "/satellites", statistics: "/statistics", recorder: "/recorder", settings: "/settings", @@ -1756,6 +1758,7 @@ var pluginGroups = { ], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], + satellites: ["/satellite-predictions.js"], statistics: ["/map-core.js"], bookmarks: ["/bookmarks.js"], recorder: [], @@ -5844,6 +5847,7 @@ function _initMapWhenReady() { } function navigateToTab(name, options = {}) { window.trxUi?.closeMobileOverlays?.(); + const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { showAuthGate(false); @@ -5873,7 +5877,10 @@ function navigateToTab(name, options = {}) { updateTabHistory(name, replaceHistory); } scheduleSpectrumLayout(); - void loadPluginsForTab(name).catch((error) => { + if (leavingSatellites) window.clearSatPredictionDom?.(); + void loadPluginsForTab(name).then(() => { + if (name === "satellites") window.refreshSatPredictions?.(); + }).catch((error) => { console.error(error); }); if (name === "map") { @@ -6320,9 +6327,6 @@ function _wireSubTabBar(bar) { if (window.refreshCwTonePicker) window.refreshCwTonePicker(); }); } - if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { - window.clearSatPredictionDom(); - } }); } document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); 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 index 44bd3940..0c9ca257 100644 --- 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 @@ -8,7 +8,6 @@ var 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"), @@ -17,29 +16,12 @@ var satDom = { 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") + viewHistoryBtn: document.getElementById("sat-view-history") }; var satImageHistory = []; var SAT_MAX_IMAGES = 100; -var SAT_PRED_PAGE_SIZE = 50; -var satPredShowAll = false; var satFilterText = ""; var satActiveView = "live"; -var satPredData = []; -var satPredFilterText = ""; -var satPredMinEl = 0; -var satPredCategory = "all"; -var satPredSatCount = 0; -var satPredCountdownTimer = null; function scheduleSatUi(key, job) { if (typeof satWindow.trxScheduleUiFrameJob === "function") { satWindow.trxScheduleUiFrameJob(key, job); @@ -48,37 +30,19 @@ function scheduleSatUi(key, job) { 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; - void loadSatPredictions(); - } + if (view === "history") renderSatHistoryTable(); } -function clearPredictionDom() { - stopCountdownTimer(); - if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; - if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; -} -satWindow.clearSatPredictionDom = clearPredictionDom; satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); }); satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); }); -satDom.viewPredBtn?.addEventListener("click", () => { - switchSatView("predictions"); -}); var lastSatLrptOn = null; satWindow.updateSatLiveState = function(update) { if (!satDom.lrptState) return; @@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click", } })(); }); -function azToCardinal(deg) { - const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; - return dirs[Math.round(deg / 45) % 8] ?? "N"; -} -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?.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 = Number.parseInt(el.dataset.los ?? "0", 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()); -} -var satPredictionFilter = satDom.predFilter; -satPredictionFilter?.addEventListener("input", () => { - satPredFilterText = satPredictionFilter.value.trim().toUpperCase(); - applyPredFilters(); -}); -var satPredictionMinElevation = satDom.predMinEl; -satPredictionMinElevation?.addEventListener("change", () => { - satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0; - applyPredFilters(); -}); -var satPredictionCategory = satDom.predCategory; -satPredictionCategory?.addEventListener("change", () => { - satPredCategory = satPredictionCategory.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 (error) { - renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`); - } -} satWindow.satShowOnMap = function(south, west, north, east) { if (typeof satWindow.enableMapSourceFilter === "function") { satWindow.enableMapSourceFilter("sat"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/satellite-predictions.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/satellite-predictions.js new file mode 100644 index 00000000..0c497d88 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/satellite-predictions.js @@ -0,0 +1,228 @@ +// src/plugins/satellite-predictions.ts +var predWindow = window; +var dom = { + page: document.getElementById("tab-satellites"), + filter: document.getElementById("sat-pred-filter"), + minElevation: document.getElementById("sat-pred-min-el"), + category: document.getElementById("sat-pred-category"), + currentList: document.getElementById("sat-pred-current-list"), + upcomingList: document.getElementById("sat-pred-list"), + currentSection: document.getElementById("sat-pred-current-section"), + upcomingSection: document.getElementById("sat-pred-upcoming-section"), + status: document.getElementById("sat-pred-status") +}; +var PAGE_SIZE = 50; +var predShowAll = false; +var predData = []; +var predFilterText = ""; +var predMinEl = 0; +var predCategory = "all"; +var predSatCount = 0; +var predCountdownTimer = null; +function isPageVisible() { + return !!dom.page && dom.page.style.display !== "none"; +} +function azToCardinal(deg) { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(deg / 45) % 8] ?? "N"; +} +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 (predCountdownTimer) { + clearInterval(predCountdownTimer); + predCountdownTimer = null; + } +} +function startCountdownTimer(container) { + const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? []; + if (countdownEls.length === 0) return; + predCountdownTimer = setInterval(() => { + if (!isPageVisible()) { + stopCountdownTimer(); + return; + } + const n = Date.now(); + let anyActive = false; + for (const el of countdownEls) { + const los = Number.parseInt(el.dataset.los ?? "0", 10); + const rem = los - n; + if (rem > 0) { + el.textContent = formatCountdown(rem); + anyActive = true; + } else { + el.textContent = "0:00"; + } + } + if (!anyActive) { + stopCountdownTimer(); + render(filtered()); + } + }, 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 filtered() { + let items = predData; + if (predCategory !== "all") items = items.filter((p) => p.category === predCategory); + if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl); + if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText)); + return items; +} +function applyFilters() { + render(filtered()); +} +var filterInput = dom.filter; +filterInput?.addEventListener("input", () => { + predFilterText = filterInput.value.trim().toUpperCase(); + applyFilters(); +}); +var minElevationSelect = dom.minElevation; +minElevationSelect?.addEventListener("change", () => { + predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0; + applyFilters(); +}); +var categorySelect = dom.category; +categorySelect?.addEventListener("change", () => { + predCategory = categorySelect.value; + applyFilters(); +}); +function render(passes, error) { + stopCountdownTimer(); + if (error) { + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + if (dom.currentSection) dom.currentSection.style.display = "none"; + if (dom.upcomingSection) dom.upcomingSection.style.display = "none"; + if (dom.status) dom.status.textContent = error; + return; + } + if (!Array.isArray(passes) || passes.length === 0) { + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + if (dom.currentSection) dom.currentSection.style.display = "none"; + if (dom.upcomingSection) dom.upcomingSection.style.display = "none"; + if (dom.status) dom.status.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 (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none"; + if (dom.currentList) { + if (current.length === 0) { + dom.currentList.innerHTML = ""; + } else { + const frag = document.createDocumentFragment(); + for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now)); + dom.currentList.replaceChildren(frag); + } + } + const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE; + const visibleUpcoming = upcoming.slice(0, upcomingLimit); + const hiddenCount = upcoming.length - visibleUpcoming.length; + if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none"; + if (dom.upcomingList) { + 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", () => { + predShowAll = true; + render(filtered()); + }); + frag.appendChild(moreRow); + } + dom.upcomingList.replaceChildren(frag); + } + if (dom.status) { + let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`; + if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`; + dom.status.textContent = text; + } + if (current.length > 0 && isPageVisible()) { + startCountdownTimer(dom.currentList); + } +} +async function load() { + if (dom.status) dom.status.textContent = "Loading predictions…"; + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + try { + const resp = await fetch("/sat_passes"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + predSatCount = data.satellite_count || 0; + if (data.error) { + predData = []; + render([], data.error); + } else { + predData = data.passes || []; + render(filtered()); + } + } catch (error) { + render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`); + } +} +function clearPredictionDom() { + stopCountdownTimer(); + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; +} +predWindow.clearSatPredictionDom = clearPredictionDom; +predWindow.refreshSatPredictions = function() { + predShowAll = false; + void load(); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 45bc6bc1..ec936b08 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + @@ -63,6 +64,10 @@ SPDX-License-Identifier: GPL-2.0-or-later Map + + + Satellites + Statistics @@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later Live History - Predictions @@ -960,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later No images yet - - - - - - All - Weather - Ham Radio - Other - - - All passes - Min 10° - Min 20° - Min 45° - - - - - Currently receivable - - Satellite - Max El - AOS Start - AOS End - Time left - Direction - - - - - - Upcoming passes - - AOS (UTC) - Satellite - Max El - Duration - Direction - - - - Loading predictions… - @@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later + + Satellites + + + + All + Weather + Ham Radio + Other + + + All passes + Min 10° + Min 20° + Min 45° + + + + + Currently receivable + + Satellite + Max El + AOS Start + AOS End + Time left + Direction + + + + + + Upcoming passes + + AOS (UTC) + Satellite + Max El + Duration + Direction + + + + Loading predictions… + + Statistics diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index f0e0311f..8d7afb5b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { @media (max-width: 760px) { body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; } } -/* One navigation model at every width. Statistics, Recorder, Settings and - * About are occasional destinations: they live behind More rather than - * competing with the operating tabs for the row and then scrolling out of +/* One navigation model at every width. Satellites, Statistics, Recorder, + * Settings and About are occasional destinations: they live behind More rather + * than competing with the operating tabs for the row and then scrolling out of * reach. The mobile layout already grouped them this way; the desktop strip * now matches it, which is why the tab strip no longer needs to scroll. */ +.tab-bar-nav .tab[data-tab="satellites"], .tab-bar-nav .tab[data-tab="statistics"], .tab-bar-nav .tab[data-tab="recorder"], .tab-bar-nav .tab[data-tab="settings"], @@ -4030,8 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent); } .tab-icon, .tab-more-icon { display: block; } - .tab[data-tab="statistics"], .tab[data-tab="recorder"], - .tab[data-tab="settings"], .tab[data-tab="about"] { display: none; } + .tab[data-tab="satellites"], .tab[data-tab="statistics"], + .tab[data-tab="recorder"], .tab[data-tab="settings"], + .tab[data-tab="about"] { display: none; } .mobile-more-btn { display: flex; } .mobile-more-menu { position: fixed; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 11a6767c..4bec9f24 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -30,6 +30,7 @@ await build({ "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"), sat: path.join(sourceDir, "plugins", "sat.ts"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), + "satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"), vchan: path.join(sourceDir, "plugins", "vchan.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), 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 index 429e0f2f..edfdea2a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "test": "node --test tests/*.test.mjs", - "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs", + "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs", "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" }, "devDependencies": { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 9acfca0b..4a0c707e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -385,6 +385,7 @@ declare global { refreshCwTonePicker?(): void; updateFt8RfDisplay?(): void; clearSatPredictionDom?(): void; + refreshSatPredictions?(): void; syncWefaxToggle?(enabled: boolean): void; syncSstvToggle?(enabled: boolean): void; updateAisBar?(value?: number): void; @@ -4849,6 +4850,7 @@ function _initMapWhenReady() { function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) { window.trxUi?.closeMobileOverlays?.(); + const leavingSatellites = _activeTab === "satellites" && name !== "satellites"; const { updateHistory = true, replaceHistory = false } = options; if (authEnabled && !authRole && name !== "main") { showAuthGate(false); @@ -4889,7 +4891,13 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac updateTabHistory(name, replaceHistory); } scheduleSpectrumLayout(); - void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); }); + // Its countdowns tick every second, so the page stops when it is not on screen. + if (leavingSatellites) window.clearSatPredictionDom?.(); + void loadPluginsForTab(name).then(() => { + // Passes go stale while the page is closed, so each visit reloads them. + // The first visit is what imports the module, which renders on its own. + if (name === "satellites") window.refreshSatPredictions?.(); + }).catch((error: unknown) => { console.error(error); }); if (name === "map") { _initMapWhenReady(); } @@ -5241,10 +5249,6 @@ function _wireSubTabBar(bar: WiredElement) { if (window.refreshCwTonePicker) window.refreshCwTonePicker(); }); } - // Clear SAT prediction DOM when leaving the SAT tab to reduce node count. - if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") { - window.clearSatPredictionDom(); - } }); } document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts index 497413c5..0990aa17 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/features/navigation/routes.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later export const TAB_ORDER = [ - "main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about", + "main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about", ] as const; export type TabName = typeof TAB_ORDER[number]; @@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly> = { bookmarks: "/bookmarks", "digital-modes": "/digital-modes", map: "/map", + satellites: "/satellites", statistics: "/statistics", recorder: "/recorder", settings: "/settings", diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index f1fa8dc1..0169a071 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -2,7 +2,9 @@ // // SPDX-License-Identifier: GPL-2.0-or-later -type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings"; +type PluginGroup = + | "digital-modes" | "map-data" | "map" | "satellites" | "statistics" + | "bookmarks" | "recorder" | "settings"; const pluginGroups: Readonly> = { // AIS, VDES and the two APRS decoders have panels on this tab, so they load @@ -15,6 +17,7 @@ const pluginGroups: Readonly> = { ], "map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"], map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"], + satellites: ["/satellite-predictions.js"], statistics: ["/map-core.js"], bookmarks: ["/bookmarks.js"], recorder: [], diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts index fac01e79..8823ee0a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat.ts @@ -4,13 +4,12 @@ import { hostCore } from "./host.js"; -import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types"; +import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types"; import type { PluginRuntimeWindow } from "./runtime-contract.js"; -type SatelliteView = "live" | "history" | "predictions"; +type SatelliteView = "live" | "history"; interface SatelliteBridge { trxScheduleUiFrameJob?: (key: string, job: () => void) => void; - clearSatPredictionDom?: () => void; updateSatLiveState?: (update: SatelliteLiveUpdate) => void; addSatMapOverlay?: (image: SatelliteImage) => void; clearSatMapOverlays?: () => void; @@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow; // --- SAT Plugin --- // Live view: decoder state, latest image card // History view: filterable table of all decoded images -// Predictions view: next 24 h passes for ham satellites +// +// Pass predictions live on their own page; see satellite-predictions.ts. // ── DOM references (cached once) ─────────────────────────────────── 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"), @@ -42,30 +41,13 @@ const satDom = { 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") as HTMLInputElement | null, - predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null, - predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null, - 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"), }; // ── State ─────────────────────────────────────────────────────────── let satImageHistory: SatelliteImage[] = []; const SAT_MAX_IMAGES = 100; -const SAT_PRED_PAGE_SIZE = 50; -let satPredShowAll = false; let satFilterText = ""; let satActiveView: SatelliteView = "live"; -let satPredData: SatellitePass[] = []; -let satPredFilterText = ""; -let satPredMinEl = 0; -let satPredCategory = "all"; -let satPredSatCount = 0; -let satPredCountdownTimer: ReturnType | null = null; // ── UI scheduler helper ───────────────────────────────────────────── function scheduleSatUi(key: string, job: () => void): void { @@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void { // ── View switching ────────────────────────────────────────────────── function switchSatView(view: SatelliteView): void { - 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; - void loadSatPredictions(); - } + if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; + if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "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 (view === "history") renderSatHistoryTable(); } -function clearPredictionDom() { - stopCountdownTimer(); - if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; - if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; -} -satWindow.clearSatPredictionDom = clearPredictionDom; - satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); }); satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); }); -satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); }); // ── Live view: decoder state ──────────────────────────────────────── let lastSatLrptOn: boolean | null = null; @@ -335,234 +300,6 @@ document } })(); }); -// ── Predictions: helpers ──────────────────────────────────────────── -function azToCardinal(deg: number): string { - const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; - return dirs[Math.round(deg / 45) % 8] ?? "N"; -} - -function formatPredTime(ms: number): string { - const d = new Date(ms); - const now = 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: number): string { - if (s >= 60) return `${Math.round(s / 60)} min`; - return `${s}s`; -} - -function formatCountdown(ms: number): string { - const totalSec = Math.max(0, Math.floor(ms / 1000)); - const m = Math.floor(totalSec / 60); - const s = totalSec % 60; - return `${m}:${String(s).padStart(2, "0")}`; -} - -function elevationClass(deg: number): string { - if (deg >= 45) return "sat-pred-el-high"; - if (deg >= 10) return "sat-pred-el-mid"; - return "sat-pred-el-low"; -} - -// ── Predictions: countdown timer management ───────────────────────── -function stopCountdownTimer() { - if (satPredCountdownTimer) { - clearInterval(satPredCountdownTimer); - satPredCountdownTimer = null; - } -} - -function startCountdownTimer(container: HTMLElement | null): void { - const countdownEls = 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 = Number.parseInt(el.dataset.los ?? "0", 10); - const rem = los - n; - if (rem > 0) { - el.textContent = formatCountdown(rem); - anyActive = true; - } else { - el.textContent = "0:00"; - } - } - if (!anyActive) { - stopCountdownTimer(); - renderSatPredictions(getFilteredPredictions()); - } - }, 1000); -} - -// ── Predictions: row builders ─────────────────────────────────────── -function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement { - const row = document.createElement("div"); - row.className = "sat-pred-row-current"; - const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; - const remaining = Math.max(0, pass.los_ms - now); - row.innerHTML = [ - `${pass.satellite}`, - `${pass.max_elevation_deg.toFixed(1)}\u00B0`, - `${formatPredTime(pass.aos_ms)}`, - `${formatPredTime(pass.los_ms)}`, - `${formatCountdown(remaining)}`, - `${dir}`, - ].join(""); - return row; -} - -function buildUpcomingPassRow(pass: SatellitePass): HTMLElement { - const row = document.createElement("div"); - row.className = "sat-pred-row"; - const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; - row.innerHTML = [ - `${formatPredTime(pass.aos_ms)}`, - `${pass.satellite}`, - `${pass.max_elevation_deg.toFixed(1)}\u00B0`, - `${formatPredDuration(pass.duration_s)}`, - `${dir}`, - ].join(""); - return row; -} - -// ── Predictions: filter state ─────────────────────────────────────── -function getFilteredPredictions(): SatellitePass[] { - 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()); -} - -const satPredictionFilter = satDom.predFilter; -satPredictionFilter?.addEventListener("input", () => { - satPredFilterText = satPredictionFilter.value.trim().toUpperCase(); - applyPredFilters(); -}); - -const satPredictionMinElevation = satDom.predMinEl; -satPredictionMinElevation?.addEventListener("change", () => { - satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0; - applyPredFilters(); -}); - -const satPredictionCategory = satDom.predCategory; -satPredictionCategory?.addEventListener("change", () => { - satPredCategory = satPredictionCategory.value; - applyPredFilters(); -}); - -// ── Predictions: main render ──────────────────────────────────────── -function renderSatPredictions(passes: SatellitePass[], error?: string): void { - 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); - - // ── Current passes ── - 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); - } - } - - // ── Upcoming passes ── - 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\u2026`; - moreRow.addEventListener("click", () => { - satPredShowAll = true; - renderSatPredictions(getFilteredPredictions()); - }); - frag.appendChild(moreRow); - } - satDom.predUpcomingList.replaceChildren(frag); - } - - // ── Status ── - if (satDom.predStatus) { - let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`; - if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`; - satDom.predStatus.textContent = text; - } - - // ── Countdown timer ── - if (current.length > 0 && satActiveView === "predictions") { - startCountdownTimer(satDom.predCurrentList); - } -} - -// ── Predictions: data loading ─────────────────────────────────────── -async function loadSatPredictions(): Promise { - if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026"; - 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() as SatellitePassResponse; - satPredSatCount = data.satellite_count || 0; - if (data.error) { - satPredData = []; - renderSatPredictions([], data.error); - } else { - satPredData = data.passes || []; - renderSatPredictions(getFilteredPredictions()); - } - } catch (error: unknown) { - renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`); - } -} - // ── Navigate to map centered on satellite image bounds ────────────── satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) { if (typeof satWindow.enableMapSourceFilter === "function") { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/satellite-predictions.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/satellite-predictions.ts new file mode 100644 index 00000000..e8883696 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/satellite-predictions.ts @@ -0,0 +1,290 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { SatellitePass, SatellitePassResponse } from "./satellite-types"; + +// --- Satellite pass predictions --- +// +// Its own page rather than a third view inside the weather-satellite decoder: +// when a bird comes over is a planning question, and it was buried under +// Digital modes beside decoders it has nothing to do with. + +interface PredictionsBridge { + clearSatPredictionDom?: () => void; + refreshSatPredictions?: () => void; +} +const predWindow = window as unknown as PredictionsBridge; + +// ── DOM references (cached once) ─────────────────────────────────── +const dom = { + page: document.getElementById("tab-satellites"), + filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null, + minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null, + category: document.getElementById("sat-pred-category") as HTMLSelectElement | null, + currentList: document.getElementById("sat-pred-current-list"), + upcomingList: document.getElementById("sat-pred-list"), + currentSection: document.getElementById("sat-pred-current-section"), + upcomingSection: document.getElementById("sat-pred-upcoming-section"), + status: document.getElementById("sat-pred-status"), +}; + +// ── State ─────────────────────────────────────────────────────────── +const PAGE_SIZE = 50; +let predShowAll = false; +let predData: SatellitePass[] = []; +let predFilterText = ""; +let predMinEl = 0; +let predCategory = "all"; +let predSatCount = 0; +let predCountdownTimer: ReturnType | null = null; + +/** The countdowns tick once a second, so they stop when nobody is looking. */ +function isPageVisible(): boolean { + return !!dom.page && dom.page.style.display !== "none"; +} + +// ── Predictions: helpers ──────────────────────────────────────────── +function azToCardinal(deg: number): string { + const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; + return dirs[Math.round(deg / 45) % 8] ?? "N"; +} + +function formatPredTime(ms: number): string { + const d = new Date(ms); + const now = 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: number): string { + if (s >= 60) return `${Math.round(s / 60)} min`; + return `${s}s`; +} + +function formatCountdown(ms: number): string { + const totalSec = Math.max(0, Math.floor(ms / 1000)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +function elevationClass(deg: number): string { + if (deg >= 45) return "sat-pred-el-high"; + if (deg >= 10) return "sat-pred-el-mid"; + return "sat-pred-el-low"; +} + +// ── Predictions: countdown timer management ───────────────────────── +function stopCountdownTimer() { + if (predCountdownTimer) { + clearInterval(predCountdownTimer); + predCountdownTimer = null; + } +} + +function startCountdownTimer(container: HTMLElement | null): void { + const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? []; + if (countdownEls.length === 0) return; + + predCountdownTimer = setInterval(() => { + if (!isPageVisible()) { + stopCountdownTimer(); + return; + } + const n = Date.now(); + let anyActive = false; + for (const el of countdownEls) { + const los = Number.parseInt(el.dataset.los ?? "0", 10); + const rem = los - n; + if (rem > 0) { + el.textContent = formatCountdown(rem); + anyActive = true; + } else { + el.textContent = "0:00"; + } + } + if (!anyActive) { + stopCountdownTimer(); + render(filtered()); + } + }, 1000); +} + +// ── Predictions: row builders ─────────────────────────────────────── +function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement { + const row = document.createElement("div"); + row.className = "sat-pred-row-current"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; + const remaining = Math.max(0, pass.los_ms - now); + row.innerHTML = [ + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}\u00B0`, + `${formatPredTime(pass.aos_ms)}`, + `${formatPredTime(pass.los_ms)}`, + `${formatCountdown(remaining)}`, + `${dir}`, + ].join(""); + return row; +} + +function buildUpcomingPassRow(pass: SatellitePass): HTMLElement { + const row = document.createElement("div"); + row.className = "sat-pred-row"; + const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; + row.innerHTML = [ + `${formatPredTime(pass.aos_ms)}`, + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}\u00B0`, + `${formatPredDuration(pass.duration_s)}`, + `${dir}`, + ].join(""); + return row; +} + +// ── Predictions: filter state ─────────────────────────────────────── +function filtered(): SatellitePass[] { + let items = predData; + if (predCategory !== "all") items = items.filter((p) => p.category === predCategory); + if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl); + if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText)); + return items; +} + +function applyFilters() { + render(filtered()); +} + +const filterInput = dom.filter; +filterInput?.addEventListener("input", () => { + predFilterText = filterInput.value.trim().toUpperCase(); + applyFilters(); +}); + +const minElevationSelect = dom.minElevation; +minElevationSelect?.addEventListener("change", () => { + predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0; + applyFilters(); +}); + +const categorySelect = dom.category; +categorySelect?.addEventListener("change", () => { + predCategory = categorySelect.value; + applyFilters(); +}); + +// ── Predictions: main render ──────────────────────────────────────── +function render(passes: SatellitePass[], error?: string): void { + stopCountdownTimer(); + + if (error) { + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + if (dom.currentSection) dom.currentSection.style.display = "none"; + if (dom.upcomingSection) dom.upcomingSection.style.display = "none"; + if (dom.status) dom.status.textContent = error; + return; + } + + if (!Array.isArray(passes) || passes.length === 0) { + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + if (dom.currentSection) dom.currentSection.style.display = "none"; + if (dom.upcomingSection) dom.upcomingSection.style.display = "none"; + if (dom.status) dom.status.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); + + // ── Current passes ── + if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none"; + if (dom.currentList) { + if (current.length === 0) { + dom.currentList.innerHTML = ""; + } else { + const frag = document.createDocumentFragment(); + for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now)); + dom.currentList.replaceChildren(frag); + } + } + + // ── Upcoming passes ── + const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE; + const visibleUpcoming = upcoming.slice(0, upcomingLimit); + const hiddenCount = upcoming.length - visibleUpcoming.length; + if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none"; + if (dom.upcomingList) { + 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\u2026`; + moreRow.addEventListener("click", () => { + predShowAll = true; + render(filtered()); + }); + frag.appendChild(moreRow); + } + dom.upcomingList.replaceChildren(frag); + } + + // ── Status ── + if (dom.status) { + let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`; + if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`; + dom.status.textContent = text; + } + + // ── Countdown timer ── + if (current.length > 0 && isPageVisible()) { + startCountdownTimer(dom.currentList); + } +} + +// ── Predictions: data loading ─────────────────────────────────────── +async function load(): Promise { + if (dom.status) dom.status.textContent = "Loading predictions\u2026"; + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; + try { + const resp = await fetch("/sat_passes"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json() as SatellitePassResponse; + predSatCount = data.satellite_count || 0; + if (data.error) { + predData = []; + render([], data.error); + } else { + predData = data.passes || []; + render(filtered()); + } + } catch (error: unknown) { + render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`); + } +} + +// ── Page lifecycle ────────────────────────────────────────────────── +function clearPredictionDom(): void { + stopCountdownTimer(); + if (dom.currentList) dom.currentList.innerHTML = ""; + if (dom.upcomingList) dom.upcomingList.innerHTML = ""; +} +predWindow.clearSatPredictionDom = clearPredictionDom; + +/** + * Called by the router each time the Satellites page is opened, including the + * visit that imports this module — so loading is driven from one place only. + */ +predWindow.refreshSatPredictions = function () { + predShowAll = false; + void load(); +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts index 9caf222d..0d9f3941 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/ui-core.ts @@ -499,7 +499,7 @@ function elementById(id: string): T { if (restoreFocus) more.focus(); }; api.closeMobileOverlays = closeMore; - ["statistics", "recorder", "settings", "about"].forEach((tabName) => { + ["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => { const source = nav.querySelector(`[data-tab="${tabName}"]`); if (!source) return; const item = document.createElement("button"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/satellite-predictions.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/satellite-predictions.mjs new file mode 100644 index 00000000..3585bcf5 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/satellite-predictions.mjs @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Pass predictions used to be a third view inside the weather-satellite +// decoder, under Digital modes — a planning tool filed with the decoders it has +// nothing to do with. It is its own page now, so what this guards is that the +// page exists at its own address, renders passes, and that the decoder card no +// longer offers the view it gave up. + +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; +import { startBrowser, startWebFixture } from "./web-fixture.mjs"; + +/* global document, window, getComputedStyle */ + +const HOUR = 3_600_000; +const now = Date.now(); +const satPasses = { + satellite_count: 2, + passes: [ + // In progress right now, so the page has a countdown to run. + { + satellite: "NOAA 19", category: "weather", + aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000, + max_elevation_deg: 62.4, duration_s: 600, + azimuth_aos_deg: 10, azimuth_los_deg: 190, + }, + { + satellite: "ISS", category: "amateur", + aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000, + max_elevation_deg: 18.2, duration_s: 480, + azimuth_aos_deg: 200, azimuth_los_deg: 20, + }, + ], +}; + +const fixture = await startWebFixture({ satPasses }); +const { browser, page, runtimeErrors } = await startBrowser(chromium); + +try { + await page.setViewportSize({ width: 1500, height: 950 }); + + // The page is reachable at its own address, not through a decoder sub-view. + // #content is the radio panel on the main tab, so the readiness signal for a + // deep link is the destination panel itself. + await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" }); + await page.locator("#tab-satellites").waitFor({ state: "visible" }); + + await page.waitForFunction( + () => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0, + null, + { timeout: 5000 }, + ); + + const rendered = await page.evaluate(() => ({ + current: document.getElementById("sat-pred-current-list").textContent, + upcoming: document.getElementById("sat-pred-list").textContent, + status: document.getElementById("sat-pred-status").textContent, + activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab, + })); + assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`); + assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`); + assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`); + assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one"); + + // A pass in progress counts down, so the seconds have to move on their own. + const firstTick = await page.evaluate( + () => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent); + await page.waitForTimeout(1600); + const secondTick = await page.evaluate( + () => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent); + assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`); + + // Leaving stops the countdown rather than leaving a timer running behind a + // hidden page. + await page.evaluate(() => { window.navigateToTab("main"); }); + await page.waitForTimeout(300); + const afterLeaving = await page.evaluate(() => ({ + visible: document.getElementById("tab-satellites").style.display, + rows: document.getElementById("sat-pred-current-list").childElementCount, + })); + assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away"); + assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page"); + + // Coming back reloads rather than showing whatever was there before. + await page.evaluate(() => { window.navigateToTab("satellites"); }); + await page.waitForFunction( + () => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0, + null, + { timeout: 5000 }, + ); + + // The page is an occasional destination, so it lives behind Tools rather than + // taking a slot in the operating strip — the same treatment Statistics, + // Recorder, Settings and About get. + const nav = await page.evaluate(() => ({ + inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display, + inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'), + })); + assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip"); + assert.ok(nav.inTools, "Satellites is not reachable from Tools"); + + // The decoder card keeps Live and History, and no longer offers Predictions. + await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); + await page.locator("#tab-digital-modes").waitFor({ state: "visible" }); + const satCard = await page.evaluate(() => ({ + predictionsButton: !!document.getElementById("sat-view-predictions"), + predictionsView: !!document.getElementById("sat-predictions-view"), + live: !!document.getElementById("sat-view-live"), + history: !!document.getElementById("sat-view-history"), + })); + assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions"); + assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page"); + assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History"); + + assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions"); + console.log("satellite predictions page tests passed"); +} finally { + await browser.close(); + await fixture.close(); +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs index 6cd62ff4..1e2682f2 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/web-fixture.mjs @@ -135,6 +135,7 @@ export async function startWebFixture({ bandplan = {}, bandplanEnabled = false, bandplanUnauthorizedFirst = false, + satPasses = null, } = {}) { const rigItems = ["rig-a", "rig-b"].map((remote) => ({ remote, @@ -230,6 +231,7 @@ export async function startWebFixture({ ["/bandplan.json", bandplan], ["/api/recorder/status", []], ["/api/recorder/files", []], + ["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }], ]); // Flat i8 bins: the shape does not matter, only that frames arrive so the diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs index a53da89c..8b25e898 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/assets.rs @@ -101,6 +101,12 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder { static_asset_response(&req, "text/html; charset=utf-8", c) } +#[get("/satellites")] +pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder { + let c = gz_index_html(); + static_asset_response(&req, "text/html; charset=utf-8", c) +} + #[get("/bookmarks")] pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder { let c = gz_index_html(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs index ebc80605..47bb9c03 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api/mod.rs @@ -639,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .service(assets::recorder_index) .service(assets::settings_index) .service(assets::about_index) + .service(assets::satellites_index) .service(assets::statistics_index) .service(assets::bookmarks_index) .service(assets::favicon)