[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:
@@ -1111,6 +1111,8 @@ mod tests {
|
||||
cw_auto: true,
|
||||
cw_wpm: 15,
|
||||
cw_tone_hz: 700,
|
||||
wxsat_decode_enabled: false,
|
||||
lrpt_decode_enabled: false,
|
||||
filter: None,
|
||||
spectrum: None,
|
||||
vchan_rds: None,
|
||||
|
||||
@@ -864,6 +864,15 @@
|
||||
</div>
|
||||
<!-- Predictions view -->
|
||||
<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">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
|
||||
@@ -289,6 +289,32 @@ document
|
||||
});
|
||||
|
||||
// ── 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) {
|
||||
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}`);
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
satPredData = [];
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
renderSatPredictions(data.passes || []);
|
||||
satPredData = data.passes || [];
|
||||
renderSatPredictions(getFilteredPredictions());
|
||||
}
|
||||
} catch (e) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||
|
||||
+53
-22
@@ -31,10 +31,18 @@ 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);
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Keys are NORAD catalog numbers; values are `(line1, line2)` strings.
|
||||
static TLE_STORE: RwLock<Option<HashMap<u32, (String, String)>>> = RwLock::new(None);
|
||||
/// Keys are NORAD catalog numbers; values contain the satellite name and TLE lines.
|
||||
static TLE_STORE: RwLock<Option<HashMap<u32, TleEntry>>> = RwLock::new(None);
|
||||
|
||||
/// Geographic bounds for a satellite image overlay: `[south, west, north, east]`.
|
||||
pub type GeoBounds = [f64; 4];
|
||||
@@ -158,8 +166,8 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> {
|
||||
// Try dynamic store first.
|
||||
if let Ok(guard) = TLE_STORE.read() {
|
||||
if let Some(store) = guard.as_ref() {
|
||||
if let Some((l1, l2)) = store.get(&norad_id) {
|
||||
return Some((l1.clone(), l2.clone()));
|
||||
if let Some(entry) = store.get(&norad_id) {
|
||||
return Some((entry.line1.clone(), entry.line2.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,19 +180,27 @@ fn tle_for_satellite(name: &str) -> Option<(String, String)> {
|
||||
// CelesTrak TLE refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → (line1, line2).
|
||||
fn parse_tle_response(body: &str) -> HashMap<u32, (String, String)> {
|
||||
/// Parse a CelesTrak 3-line TLE response into a map of NORAD ID → TleEntry.
|
||||
fn parse_tle_response(body: &str) -> HashMap<u32, TleEntry> {
|
||||
let mut result = HashMap::new();
|
||||
let lines: Vec<&str> = body.lines().map(|l| l.trim_end()).collect();
|
||||
let mut i = 0;
|
||||
while i + 2 < lines.len() {
|
||||
let name_line = lines[i].trim();
|
||||
let line1 = lines[i + 1];
|
||||
let line2 = lines[i + 2];
|
||||
// Validate TLE line markers
|
||||
if line1.starts_with("1 ") && line2.starts_with("2 ") {
|
||||
// Extract NORAD catalog number from line 1 columns 2-6
|
||||
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;
|
||||
@@ -446,10 +462,11 @@ fn find_passes_for_sat(
|
||||
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`.
|
||||
///
|
||||
/// 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.
|
||||
pub fn compute_upcoming_passes(
|
||||
station_lat: f64,
|
||||
@@ -464,19 +481,14 @@ pub fn compute_upcoming_passes(
|
||||
|
||||
let mut all_passes = Vec::new();
|
||||
|
||||
for &(name, norad_id) in PREDICTION_SATS {
|
||||
let tle = guard
|
||||
.as_ref()
|
||||
.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 {
|
||||
if let Some(store) = guard.as_ref() {
|
||||
// Use all satellites in the dynamic store.
|
||||
for (&norad_id, entry) in store {
|
||||
let passes = find_passes_for_sat(
|
||||
name,
|
||||
&entry.name,
|
||||
norad_id,
|
||||
&line1,
|
||||
&line2,
|
||||
&entry.line1,
|
||||
&entry.line2,
|
||||
station_lat,
|
||||
station_lon,
|
||||
start_ms,
|
||||
@@ -484,6 +496,23 @@ pub fn compute_upcoming_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);
|
||||
@@ -747,8 +776,10 @@ NOAA 19
|
||||
assert_eq!(tles.len(), 2);
|
||||
assert!(tles.contains_key(&25338));
|
||||
assert!(tles.contains_key(&33591));
|
||||
assert!(tles[&25338].0.starts_with("1 25338"));
|
||||
assert!(tles[&33591].1.starts_with("2 33591"));
|
||||
assert_eq!(tles[&25338].name, "NOAA 15");
|
||||
assert!(tles[&25338].line1.starts_with("1 25338"));
|
||||
assert_eq!(tles[&33591].name, "NOAA 19");
|
||||
assert!(tles[&33591].line2.starts_with("2 33591"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user