From e831dff85d7e911638b7ba7f3ad6f0b5847dcdfa Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Mar 2026 15:38:27 +0100 Subject: [PATCH] [feat](trx-rs): rework satellite predictions with category filter and live countdown Add category selector (All/Weather/Ham Radio/Other) to predictions panel. Split predictions into currently receivable passes with live countdown timer and upcoming passes table. Add SatCategory enum to geo types for CelesTrak group classification. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/index.html | 37 +++- .../assets/web/plugins/sat.js | 158 ++++++++++++++---- .../trx-frontend-http/assets/web/style.css | 7 + src/trx-core/src/geo.rs | 58 +++++-- 4 files changed, 210 insertions(+), 50 deletions(-) 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 4f3c3b7..a11cca1 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 @@ -866,6 +866,12 @@ diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/sat.js index 77fde64..0039984 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/sat.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/sat.js @@ -292,12 +292,18 @@ document let satPredData = []; let satPredFilterText = ""; let satPredMinEl = 0; +let satPredCategory = "all"; let satPredSatCount = 0; +let satPredCountdownTimer = null; const satPredFilterInput = document.getElementById("sat-pred-filter"); const satPredMinElSelect = document.getElementById("sat-pred-min-el"); +const satPredCategorySelect = document.getElementById("sat-pred-category"); 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); } @@ -307,14 +313,23 @@ function getFilteredPredictions() { return items; } +function applyPredFilters() { + renderSatPredictions(getFilteredPredictions()); +} + satPredFilterInput?.addEventListener("input", () => { satPredFilterText = satPredFilterInput.value.trim().toUpperCase(); - renderSatPredictions(getFilteredPredictions()); + applyPredFilters(); }); satPredMinElSelect?.addEventListener("change", () => { satPredMinEl = parseInt(satPredMinElSelect.value, 10) || 0; - renderSatPredictions(getFilteredPredictions()); + applyPredFilters(); +}); + +satPredCategorySelect?.addEventListener("change", () => { + satPredCategory = satPredCategorySelect.value; + applyPredFilters(); }); function azToCardinal(deg) { @@ -339,55 +354,142 @@ function formatPredDuration(s) { return `${s}s`; } +function formatCountdown(ms) { + 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 renderSatPredictions(passes, error) { - const list = document.getElementById("sat-pred-list"); + const currentList = document.getElementById("sat-pred-current-list"); + const upcomingList = document.getElementById("sat-pred-list"); + const currentSection = document.getElementById("sat-pred-current-section"); + const upcomingSection = document.getElementById("sat-pred-upcoming-section"); const status = document.getElementById("sat-pred-status"); - if (!list) return; + + // Stop any previous countdown timer + if (satPredCountdownTimer) { clearInterval(satPredCountdownTimer); satPredCountdownTimer = null; } if (error) { - list.innerHTML = ""; + if (currentList) currentList.innerHTML = ""; + if (upcomingList) upcomingList.innerHTML = ""; + if (currentSection) currentSection.style.display = "none"; + if (upcomingSection) upcomingSection.style.display = "none"; if (status) status.textContent = error; return; } if (!Array.isArray(passes) || passes.length === 0) { - list.innerHTML = ""; + if (currentList) currentList.innerHTML = ""; + if (upcomingList) upcomingList.innerHTML = ""; + if (currentSection) currentSection.style.display = "none"; + if (upcomingSection) upcomingSection.style.display = "none"; if (status) status.textContent = "No passes found in the next 24 hours."; return; } - const fragment = document.createDocumentFragment(); - for (const pass of passes) { - const row = document.createElement("div"); - row.className = "sat-pred-row"; - const elClass = pass.max_elevation_deg >= 45 - ? "sat-pred-el-high" - : pass.max_elevation_deg >= 10 - ? "sat-pred-el-mid" - : "sat-pred-el-low"; - 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(""); - fragment.appendChild(row); + 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 (currentSection) currentSection.style.display = current.length > 0 ? "" : "none"; + if (currentList) { + if (current.length === 0) { + currentList.innerHTML = ""; + } else { + const frag = document.createDocumentFragment(); + for (const pass of current) { + const row = document.createElement("div"); + row.className = "sat-pred-row-current"; + const elClass = pass.max_elevation_deg >= 45 + ? "sat-pred-el-high" + : pass.max_elevation_deg >= 10 + ? "sat-pred-el-mid" + : "sat-pred-el-low"; + 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(""); + frag.appendChild(row); + } + currentList.replaceChildren(frag); + } } - list.replaceChildren(fragment); + + // ── Upcoming passes ── + if (upcomingSection) upcomingSection.style.display = upcoming.length > 0 ? "" : "none"; + if (upcomingList) { + const frag = document.createDocumentFragment(); + for (const pass of upcoming) { + const row = document.createElement("div"); + row.className = "sat-pred-row"; + const elClass = pass.max_elevation_deg >= 45 + ? "sat-pred-el-high" + : pass.max_elevation_deg >= 10 + ? "sat-pred-el-mid" + : "sat-pred-el-low"; + 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(""); + frag.appendChild(row); + } + upcomingList.replaceChildren(frag); + } + + // ── Status ── if (status) { - let text = `${passes.length} pass${passes.length === 1 ? "" : "es"} in the next 24 h · times in UTC`; + const totalAll = getFilteredPredictions().length; + let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`; if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`; status.textContent = text; } + + // ── Countdown timer: update "time left" every second ── + if (current.length > 0) { + satPredCountdownTimer = setInterval(() => { + const n = Date.now(); + const els = document.querySelectorAll("#sat-pred-current-list .sat-pred-col-countdown"); + let anyActive = false; + for (const el of els) { + 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) { + // All current passes ended — re-render to move them out + clearInterval(satPredCountdownTimer); + satPredCountdownTimer = null; + renderSatPredictions(getFilteredPredictions()); + } + }, 1000); + } } async function loadSatPredictions() { const status = document.getElementById("sat-pred-status"); - const list = document.getElementById("sat-pred-list"); + const currentList = document.getElementById("sat-pred-current-list"); + const upcomingList = document.getElementById("sat-pred-list"); if (status) status.textContent = "Loading predictions\u2026"; - if (list) list.innerHTML = ""; + if (currentList) currentList.innerHTML = ""; + if (upcomingList) upcomingList.innerHTML = ""; try { const resp = await fetch("/sat_passes"); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); 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 5c6ae8d..a396a25 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 @@ -4572,9 +4572,16 @@ button:focus-visible, input:focus-visible, select:focus-visible { .sat-pred-el-high { color: var(--accent-green); font-weight: 600; } .sat-pred-el-mid { color: #f0a020; } .sat-pred-el-low { color: var(--text-muted); } +.sat-pred-section-title { font-size: 0.75rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding: 0.5rem 0.4rem 0.15rem; } +.sat-pred-header-current { grid-template-columns: 1fr 4.5rem 6rem 6rem 5.5rem 6rem; } +.sat-pred-row-current { display: grid; grid-template-columns: 1fr 4.5rem 6rem 6rem 5.5rem 6rem; gap: 0.25rem; padding: 0.35rem 0.4rem; font-size: 0.82rem; border-bottom: 1px solid var(--border-faint, rgba(255,255,255,0.04)); } +.sat-pred-row-current:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); } +.sat-pred-col-countdown { font-variant-numeric: tabular-nums; font-weight: 600; color: var(--accent-green); } +.sat-pred-current-empty { padding: 0.5rem 0.4rem; font-size: 0.8rem; color: var(--text-muted); } @media (max-width: 600px) { .sat-live-grid { grid-template-columns: 1fr; } .sat-history-header, .sat-history-row { grid-template-columns: 5rem 4rem 6rem 4rem 3.5rem 1fr; font-size: 0.75rem; } .sat-pred-header, .sat-pred-row { grid-template-columns: 5.5rem 1fr 4rem 4.5rem; font-size: 0.75rem; } + .sat-pred-header-current, .sat-pred-row-current { grid-template-columns: 1fr 4rem 5rem 5rem 4.5rem; font-size: 0.75rem; } .sat-pred-col-dir { display: none; } } diff --git a/src/trx-core/src/geo.rs b/src/trx-core/src/geo.rs index 4e27095..591c058 100644 --- a/src/trx-core/src/geo.rs +++ b/src/trx-core/src/geo.rs @@ -15,7 +15,7 @@ use std::sync::RwLock; use std::time::Duration; /// Result of computing upcoming passes, including metadata about TLE source. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PassPredictionResult { /// Predicted passes sorted by AOS time. pub passes: Vec, @@ -26,7 +26,7 @@ pub struct PassPredictionResult { } /// Indicates the origin of the TLE data used for predictions. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] pub enum TleSource { /// Live TLE data fetched from CelesTrak. @@ -52,12 +52,22 @@ const CELESTRAK_HAM_URL: &str = /// How often to refresh TLEs after the initial fetch (24 hours). const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +/// Satellite category based on TLE source group. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SatCategory { + Weather, + Amateur, + Other, +} + /// A single TLE entry: satellite name + two-line element set. #[derive(Debug, Clone)] pub struct TleEntry { pub name: String, pub line1: String, pub line2: String, + pub category: SatCategory, } /// Global store for dynamically-fetched TLE data. @@ -81,12 +91,14 @@ pub struct PassGeo { } /// A predicted satellite pass over the observer's location. -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct PassPrediction { /// Satellite display name. pub satellite: String, /// NORAD catalog number. pub norad_id: u32, + /// Satellite category (weather, amateur, other). + pub category: SatCategory, /// Acquisition of Signal: UTC timestamp in milliseconds. pub aos_ms: i64, /// Loss of Signal: UTC timestamp in milliseconds. @@ -178,7 +190,7 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> { // --------------------------------------------------------------------------- /// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → TleEntry. -fn parse_tle_response(body: &str) -> HashMap { +fn parse_tle_response(body: &str, category: SatCategory) -> HashMap { let mut result = HashMap::new(); let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect(); let mut i = 0; @@ -196,6 +208,7 @@ fn parse_tle_response(body: &str) -> HashMap { name: name_line.to_string(), line1: line1.to_string(), line2: line2.to_string(), + category, }, ); } @@ -206,7 +219,7 @@ fn parse_tle_response(body: &str) -> HashMap { } /// Fetch TLEs from a CelesTrak URL and merge them into the global store. -async fn fetch_and_merge_tles(url: &str) -> Result { +async fn fetch_and_merge_tles(url: &str, category: SatCategory) -> Result { let response = reqwest::Client::builder() .timeout(Duration::from_secs(30)) .build() @@ -225,7 +238,7 @@ async fn fetch_and_merge_tles(url: &str) -> Result { .await .map_err(|e| format!("Failed to read CelesTrak response: {e}"))?; - let tles = parse_tle_response(&body); + let tles = parse_tle_response(&body, category); let count = tles.len(); if count == 0 { @@ -257,7 +270,7 @@ async fn fetch_and_merge_tles(url: &str) -> Result { /// /// Returns the number of TLEs loaded, or an error description. pub async fn refresh_tles_from_celestrak() -> Result { - fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await + fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await } /// Spawn a background task that fetches TLEs from CelesTrak on start and @@ -268,7 +281,7 @@ pub async fn refresh_tles_from_celestrak() -> Result { pub fn spawn_tle_refresh_task() { tokio::spawn(async { // Initial fetch at startup: weather + amateur satellites. - match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await { + match fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await { Ok(n) => { tracing::info!("TLE refresh: loaded {n} weather satellite TLEs from CelesTrak") } @@ -276,7 +289,7 @@ pub fn spawn_tle_refresh_task() { tracing::warn!("TLE refresh: weather fetch failed ({e}), using hardcoded TLEs") } } - match fetch_and_merge_tles(CELESTRAK_HAM_URL).await { + match fetch_and_merge_tles(CELESTRAK_HAM_URL, SatCategory::Amateur).await { Ok(n) => { tracing::info!("TLE refresh: loaded {n} amateur satellite TLEs from CelesTrak") } @@ -290,7 +303,7 @@ pub fn spawn_tle_refresh_task() { loop { interval.tick().await; - match fetch_and_merge_tles(CELESTRAK_WEATHER_URL).await { + match fetch_and_merge_tles(CELESTRAK_WEATHER_URL, SatCategory::Weather).await { Ok(n) => { tracing::info!("TLE refresh: updated {n} weather satellite TLEs from CelesTrak") } @@ -298,7 +311,7 @@ pub fn spawn_tle_refresh_task() { tracing::warn!("TLE refresh: weather fetch failed ({e}), keeping previous TLEs") } } - match fetch_and_merge_tles(CELESTRAK_HAM_URL).await { + match fetch_and_merge_tles(CELESTRAK_HAM_URL, SatCategory::Amateur).await { Ok(n) => { tracing::info!("TLE refresh: updated {n} amateur satellite TLEs from CelesTrak") } @@ -372,6 +385,7 @@ fn compute_az_el( fn find_passes_for_sat( name: &str, norad_id: u32, + category: SatCategory, line1: &str, line2: &str, obs_lat: f64, @@ -432,6 +446,7 @@ fn find_passes_for_sat( passes.push(PassPrediction { satellite: name.to_string(), norad_id, + category, aos_ms, los_ms: t_ms, max_elevation_deg: (max_el * 10.0).round() / 10.0, @@ -450,6 +465,7 @@ fn find_passes_for_sat( passes.push(PassPrediction { satellite: name.to_string(), norad_id, + category, aos_ms, los_ms: start_ms + window_ms, max_elevation_deg: (max_el * 10.0).round() / 10.0, @@ -488,6 +504,7 @@ pub fn compute_upcoming_passes( let passes = find_passes_for_sat( &entry.name, norad_id, + entry.category, &entry.line1, &entry.line2, station_lat, @@ -768,20 +785,21 @@ NOAA 19 1 33591U 09005A 26085.50000000 .00000028 00000-0 20000-4 0 9997 2 33591 99.1700 050.5000 0014000 100.0000 260.0000 14.12300000 8003 "; - let tles = parse_tle_response(body); + let tles = parse_tle_response(body, SatCategory::Weather); assert_eq!(tles.len(), 2); assert!(tles.contains_key(&25338)); assert!(tles.contains_key(&33591)); assert_eq!(tles[&25338].name, "NOAA 15"); assert!(tles[&25338].line1.starts_with("1 25338")); + assert_eq!(tles[&25338].category, SatCategory::Weather); assert_eq!(tles[&33591].name, "NOAA 19"); assert!(tles[&33591].line2.starts_with("2 33591")); } #[test] fn test_parse_tle_response_empty() { - assert!(parse_tle_response("").is_empty()); - assert!(parse_tle_response("not a tle\n").is_empty()); + assert!(parse_tle_response("", SatCategory::Other).is_empty()); + assert!(parse_tle_response("not a tle\n", SatCategory::Other).is_empty()); } #[test] @@ -850,7 +868,17 @@ NOAA 19 let start = 1774800000000_i64; // 2026-03-28 let window = 24 * 60 * 60 * 1000_i64; let (l1, l2) = hardcoded_tle(33591).unwrap(); - let passes = find_passes_for_sat("NOAA 19", 33591, l1, l2, 48.0, 11.0, start, window); + let passes = find_passes_for_sat( + "NOAA 19", + 33591, + SatCategory::Weather, + l1, + l2, + 48.0, + 11.0, + start, + window, + ); assert!( passes.len() >= 2 && passes.len() <= 10, "Expected 2-10 passes for NOAA-19 in 24h, got {}",