[feat](trx-rs): show all satellites in predictions with filter bar

Iterate all TLE store entries (weather + amateur) for pass predictions instead of a hardcoded list. Add name/elevation filter bar to the predictions UI. Fix pre-existing missing fields in remote_client test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-28 14:07:51 +01:00
parent aab344b729
commit a0c92df86f
4 changed files with 93 additions and 23 deletions
+2
View File
@@ -1111,6 +1111,8 @@ mod tests {
cw_auto: true, cw_auto: true,
cw_wpm: 15, cw_wpm: 15,
cw_tone_hz: 700, cw_tone_hz: 700,
wxsat_decode_enabled: false,
lrpt_decode_enabled: false,
filter: None, filter: None,
spectrum: None, spectrum: None,
vchan_rds: None, vchan_rds: None,
@@ -864,6 +864,15 @@
</div> </div>
<!-- Predictions view --> <!-- Predictions view -->
<div id="sat-predictions-view" style="display:none;"> <div id="sat-predictions-view" style="display:none;">
<div class="ft8-controls">
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
<select id="sat-pred-min-el" class="sat-sort-select">
<option value="0">All passes</option>
<option value="10">Min 10°</option>
<option value="20">Min 20°</option>
<option value="45">Min 45°</option>
</select>
</div>
<div class="sat-pred-header"> <div class="sat-pred-header">
<span class="sat-pred-col-time">AOS (UTC)</span> <span class="sat-pred-col-time">AOS (UTC)</span>
<span class="sat-pred-col-sat">Satellite</span> <span class="sat-pred-col-sat">Satellite</span>
@@ -289,6 +289,32 @@ document
}); });
// ── Predictions view ──────────────────────────────────────────────── // ── Predictions view ────────────────────────────────────────────────
let satPredData = [];
let satPredFilterText = "";
let satPredMinEl = 0;
const satPredFilterInput = document.getElementById("sat-pred-filter");
const satPredMinElSelect = document.getElementById("sat-pred-min-el");
function getFilteredPredictions() {
let items = satPredData;
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;
}
satPredFilterInput?.addEventListener("input", () => {
satPredFilterText = satPredFilterInput.value.trim().toUpperCase();
renderSatPredictions(getFilteredPredictions());
});
satPredMinElSelect?.addEventListener("change", () => {
satPredMinEl = parseInt(satPredMinElSelect.value, 10) || 0;
renderSatPredictions(getFilteredPredictions());
});
function azToCardinal(deg) { function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
@@ -362,9 +388,11 @@ async function loadSatPredictions() {
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const data = await resp.json();
if (data.error) { if (data.error) {
satPredData = [];
renderSatPredictions([], data.error); renderSatPredictions([], data.error);
} else { } else {
renderSatPredictions(data.passes || []); satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions());
} }
} catch (e) { } catch (e) {
renderSatPredictions([], `Failed to load predictions: ${e.message}`); renderSatPredictions([], `Failed to load predictions: ${e.message}`);
+53 -22
View File
@@ -31,10 +31,18 @@ const CELESTRAK_HAM_URL: &str =
/// How often to refresh TLEs after the initial fetch (24 hours). /// How often to refresh TLEs after the initial fetch (24 hours).
const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); const TLE_REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
/// 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,
}
/// Global store for dynamically-fetched TLE data. /// Global store for dynamically-fetched TLE data.
/// ///
/// Keys are NORAD catalog numbers; values are `(line1, line2)` strings. /// Keys are NORAD catalog numbers; values contain the satellite name and TLE lines.
static TLE_STORE: RwLock<Option<HashMap<u32, (String, String)>>> = RwLock::new(None); static TLE_STORE: RwLock<Option<HashMap<u32, TleEntry>>> = RwLock::new(None);
/// Geographic bounds for a satellite image overlay: `[south, west, north, east]`. /// Geographic bounds for a satellite image overlay: `[south, west, north, east]`.
pub type GeoBounds = [f64; 4]; pub type GeoBounds = [f64; 4];
@@ -158,8 +166,8 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> {
// Try dynamic store first. // Try dynamic store first.
if let Ok(guard) = TLE_STORE.read() { if let Ok(guard) = TLE_STORE.read() {
if let Some(store) = guard.as_ref() { if let Some(store) = guard.as_ref() {
if let Some((l1, l2)) = store.get(&norad_id) { if let Some(entry) = store.get(&norad_id) {
return Some((l1.clone(), l2.clone())); return Some((entry.line1.clone(), entry.line2.clone()));
} }
} }
} }
@@ -172,19 +180,27 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> {
// CelesTrak TLE refresh // CelesTrak TLE refresh
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → (line1, line2). /// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → TleEntry.
fn parse_tle_response(body: &str) -> HashMap<u32, (String, String)> { fn parse_tle_response(body: &str) -> HashMap<u32, TleEntry> {
let mut result = HashMap::new(); let mut result = HashMap::new();
let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect(); let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect();
let mut i = 0; let mut i = 0;
while i + 2 < lines.len() { while i + 2 < lines.len() {
let name_line = lines[i].trim();
let line1 = lines[i + 1]; let line1 = lines[i + 1];
let line2 = lines[i + 2]; let line2 = lines[i + 2];
// Validate TLE line markers // Validate TLE line markers
if line1.starts_with("1 ") && line2.starts_with("2 ") { if line1.starts_with("1 ") && line2.starts_with("2 ") {
// Extract NORAD catalog number from line 1 columns 2-6 // Extract NORAD catalog number from line 1 columns 2-6
if let Ok(norad_id) = line1[2..7].trim().parse::<u32>() { if let Ok(norad_id) = line1[2..7].trim().parse::<u32>() {
result.insert(norad_id, (line1.to_string(), line2.to_string())); result.insert(
norad_id,
TleEntry {
name: name_line.to_string(),
line1: line1.to_string(),
line2: line2.to_string(),
},
);
} }
} }
i += 3; i += 3;
@@ -446,10 +462,11 @@ fn find_passes_for_sat(
passes passes
} }
/// Compute upcoming passes for all known amateur satellites over the next /// Compute upcoming passes for all satellites in the TLE store over the next
/// `window_ms` milliseconds, starting from `start_ms`. /// `window_ms` milliseconds, starting from `start_ms`.
/// ///
/// Satellites without TLE data in the store are silently skipped. /// Iterates over every satellite fetched from CelesTrak (weather + amateur).
/// Falls back to hardcoded weather-satellite TLEs when the store is empty.
/// Results are sorted by AOS time. /// Results are sorted by AOS time.
pub fn compute_upcoming_passes( pub fn compute_upcoming_passes(
station_lat: f64, station_lat: f64,
@@ -464,19 +481,14 @@ pub fn compute_upcoming_passes(
let mut all_passes = Vec::new(); let mut all_passes = Vec::new();
for &(name, norad_id) in PREDICTION_SATS { if let Some(store) = guard.as_ref() {
let tle = guard // Use all satellites in the dynamic store.
.as_ref() for (&norad_id, entry) in store {
.and_then(|s| s.get(&norad_id))
.cloned()
.or_else(|| hardcoded_tle(norad_id).map(|(l1, l2)| (l1.to_string(), l2.to_string())));
if let Some((line1, line2)) = tle {
let passes = find_passes_for_sat( let passes = find_passes_for_sat(
name, &entry.name,
norad_id, norad_id,
&line1, &entry.line1,
&line2, &entry.line2,
station_lat, station_lat,
station_lon, station_lon,
start_ms, start_ms,
@@ -484,6 +496,23 @@ pub fn compute_upcoming_passes(
); );
all_passes.extend(passes); all_passes.extend(passes);
} }
} else {
// Fallback: hardcoded weather satellite TLEs only.
for &(name, norad_id) in PREDICTION_SATS {
if let Some((l1, l2)) = hardcoded_tle(norad_id) {
let passes = find_passes_for_sat(
name,
norad_id,
l1,
l2,
station_lat,
station_lon,
start_ms,
window_ms,
);
all_passes.extend(passes);
}
}
} }
all_passes.sort_by_key(|p| p.aos_ms); all_passes.sort_by_key(|p| p.aos_ms);
@@ -747,8 +776,10 @@ NOAA 19
assert_eq!(tles.len(), 2); assert_eq!(tles.len(), 2);
assert!(tles.contains_key(&25338)); assert!(tles.contains_key(&25338));
assert!(tles.contains_key(&33591)); assert!(tles.contains_key(&33591));
assert!(tles[&25338].0.starts_with("1 25338")); assert_eq!(tles[&25338].name, "NOAA 15");
assert!(tles[&33591].1.starts_with("2 33591")); assert!(tles[&25338].line1.starts_with("1 25338"));
assert_eq!(tles[&33591].name, "NOAA 19");
assert!(tles[&33591].line2.starts_with("2 33591"));
} }
#[test] #[test]