[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_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
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user