[feat](trx-rs): add ham sat pass predictions; rename SAT tab
- Rename "Weather Satellites" sub-tab to "SAT" - Add "Predictions" view: next 24 h flyby table for 13 ham sats (ISS, AO-91, AO-92, SO-50, AO-73, JO-97, PO-101, LilacSat-2, CAS-4B, EO-88, RS-44, SALSAT, GREENCUBE) - trx-core/geo: add PassPrediction, HAM_SATS, compute_upcoming_passes(), find_passes_for_sat(), compute_az_el() helpers; spawn_tle_refresh_task now also fetches CelesTrak amateur group on startup and every 24 h - trx-frontend-http: add GET /sat_passes endpoint - app.js: locator tooltips now accumulate all receivers per station via remotes Set; _detailPassesRigFilter checks the Set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -7002,10 +7002,16 @@ function buildDecodeLocatorTooltipHtml(grid, entry, type) {
|
||||
Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null,
|
||||
escapeMapHtml(freq),
|
||||
].filter(Boolean).join(" · ");
|
||||
const rxLabel = _receiverLabel(detail?.remote);
|
||||
const rxHtml = rxLabel
|
||||
? `<div class="decode-locator-tip-rx">${escapeMapHtml(rxLabel)}</div>`
|
||||
: "";
|
||||
const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0
|
||||
? Array.from(detail.remotes)
|
||||
: (detail?.remote ? [detail.remote] : []);
|
||||
const rxHtml = remoteIds
|
||||
.map(rid => {
|
||||
const label = _receiverLabel(rid);
|
||||
return label ? `<div class="decode-locator-tip-rx">${escapeMapHtml(label)}</div>` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
const message = detail?.message
|
||||
? `<div class="decode-locator-tip-note">${escapeMapHtml(String(detail.message))}</div>`
|
||||
: "";
|
||||
@@ -7113,6 +7119,7 @@ function _locatorEntryVisibleOnMap(entry) {
|
||||
|
||||
function _detailPassesRigFilter(detail) {
|
||||
if (!mapRigFilter) return true;
|
||||
if (detail?.remotes instanceof Set) return detail.remotes.has(mapRigFilter);
|
||||
return detail?.remote === mapRigFilter;
|
||||
}
|
||||
|
||||
@@ -7603,6 +7610,7 @@ window.mapAddLocator = function(message, grids, type = "ft8", station = null, de
|
||||
freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null,
|
||||
message: String(details?.message || message || "").trim() || null,
|
||||
remote: msgRigId || null,
|
||||
remotes: new Set(msgRigId ? [msgRigId] : []),
|
||||
};
|
||||
const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
|
||||
const key = `${markerType}:${grid}`;
|
||||
@@ -7614,7 +7622,10 @@ window.mapAddLocator = function(message, grids, type = "ft8", station = null, de
|
||||
? new Map(existing.stationDetails)
|
||||
: new Map();
|
||||
}
|
||||
existing.allStationDetails.set(detailKey, { ...detailEntry });
|
||||
const prevDetail = existing.allStationDetails.get(detailKey);
|
||||
const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : new Set();
|
||||
if (msgRigId) mergedRemotes.add(msgRigId);
|
||||
existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes });
|
||||
existing.sourceType = markerType;
|
||||
if (msgRigId) {
|
||||
if (!existing.rigIds) existing.rigIds = new Set();
|
||||
|
||||
@@ -515,7 +515,7 @@
|
||||
<button class="sub-tab" data-subtab="ft2">FT2</button>
|
||||
<button class="sub-tab" data-subtab="wspr">WSPR</button>
|
||||
<button class="sub-tab" data-subtab="rds">RDS</button>
|
||||
<button class="sub-tab" data-subtab="wxsat">Weather Satellites</button>
|
||||
<button class="sub-tab" data-subtab="wxsat">SAT</button>
|
||||
</div>
|
||||
<div id="subtab-overview" class="sub-tab-panel">
|
||||
<div class="plugin-item">
|
||||
@@ -811,6 +811,7 @@
|
||||
<div class="wxsat-view-bar">
|
||||
<button id="wxsat-view-live" class="wxsat-view-btn wxsat-view-active" type="button">Live</button>
|
||||
<button id="wxsat-view-history" class="wxsat-view-btn" type="button">History</button>
|
||||
<button id="wxsat-view-predictions" class="wxsat-view-btn" type="button">Predictions</button>
|
||||
</div>
|
||||
<!-- Live view -->
|
||||
<div id="wxsat-live-view">
|
||||
@@ -861,6 +862,18 @@
|
||||
<div id="wxsat-history-list"></div>
|
||||
<small id="wxsat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||
</div>
|
||||
<!-- Predictions view -->
|
||||
<div id="wxsat-predictions-view" style="display:none;">
|
||||
<div class="sat-pred-header">
|
||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||
<span class="sat-pred-col-sat">Satellite</span>
|
||||
<span class="sat-pred-col-el">Max El</span>
|
||||
<span class="sat-pred-col-dur">Duration</span>
|
||||
<span class="sat-pred-col-dir">Direction</span>
|
||||
</div>
|
||||
<div id="sat-pred-list"></div>
|
||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tab-map" class="tab-panel" style="display:none;">
|
||||
@@ -1109,7 +1122,7 @@
|
||||
<button id="settings-clear-ft4-history" class="sch-write sch-reset-btn" type="button">Clear full FT4 history</button>
|
||||
<button id="settings-clear-ft2-history" class="sch-write sch-reset-btn" type="button">Clear full FT2 history</button>
|
||||
<button id="settings-clear-wspr-history" class="sch-write sch-reset-btn" type="button">Clear full WSPR history</button>
|
||||
<button id="settings-clear-wxsat-history" class="sch-write sch-reset-btn" type="button">Clear full Weather Sat history</button>
|
||||
<button id="settings-clear-wxsat-history" class="sch-write sch-reset-btn" type="button">Clear full Sat history</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// --- Weather Satellite Decoder Plugin ---
|
||||
// --- 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
|
||||
|
||||
// ── DOM references ──────────────────────────────────────────────────
|
||||
const wxsatStatus = document.getElementById("wxsat-status");
|
||||
const wxsatLiveView = document.getElementById("wxsat-live-view");
|
||||
const wxsatHistoryView = document.getElementById("wxsat-history-view");
|
||||
const wxsatPredictionsView = document.getElementById("wxsat-predictions-view");
|
||||
const wxsatLiveLatest = document.getElementById("wxsat-live-latest");
|
||||
const wxsatHistoryList = document.getElementById("wxsat-history-list");
|
||||
const wxsatHistoryCount = document.getElementById("wxsat-history-count");
|
||||
@@ -19,7 +21,7 @@ const wxsatLrptState = document.getElementById("wxsat-lrpt-state");
|
||||
let wxsatImageHistory = [];
|
||||
const WXSAT_MAX_IMAGES = 100;
|
||||
let wxsatFilterText = "";
|
||||
let wxsatActiveView = "live"; // "live" | "history"
|
||||
let wxsatActiveView = "live"; // "live" | "history" | "predictions"
|
||||
|
||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||
function scheduleWxsatUi(key, job) {
|
||||
@@ -33,24 +35,26 @@ function scheduleWxsatUi(key, job) {
|
||||
// ── View switching ──────────────────────────────────────────────────
|
||||
const wxsatViewLiveBtn = document.getElementById("wxsat-view-live");
|
||||
const wxsatViewHistoryBtn = document.getElementById("wxsat-view-history");
|
||||
const wxsatViewPredictionsBtn = document.getElementById("wxsat-view-predictions");
|
||||
|
||||
function switchWxsatView(view) {
|
||||
wxsatActiveView = view;
|
||||
if (wxsatLiveView) wxsatLiveView.style.display = view === "live" ? "" : "none";
|
||||
if (wxsatHistoryView) wxsatHistoryView.style.display = view === "history" ? "" : "none";
|
||||
if (wxsatViewLiveBtn) {
|
||||
wxsatViewLiveBtn.classList.toggle("wxsat-view-active", view === "live");
|
||||
}
|
||||
if (wxsatViewHistoryBtn) {
|
||||
wxsatViewHistoryBtn.classList.toggle("wxsat-view-active", view === "history");
|
||||
}
|
||||
if (wxsatPredictionsView) wxsatPredictionsView.style.display = view === "predictions" ? "" : "none";
|
||||
if (wxsatViewLiveBtn) wxsatViewLiveBtn.classList.toggle("wxsat-view-active", view === "live");
|
||||
if (wxsatViewHistoryBtn) wxsatViewHistoryBtn.classList.toggle("wxsat-view-active", view === "history");
|
||||
if (wxsatViewPredictionsBtn) wxsatViewPredictionsBtn.classList.toggle("wxsat-view-active", view === "predictions");
|
||||
if (view === "history") {
|
||||
renderWxsatHistoryTable();
|
||||
} else if (view === "predictions") {
|
||||
loadSatPredictions();
|
||||
}
|
||||
}
|
||||
|
||||
wxsatViewLiveBtn?.addEventListener("click", () => switchWxsatView("live"));
|
||||
wxsatViewHistoryBtn?.addEventListener("click", () => switchWxsatView("history"));
|
||||
wxsatViewPredictionsBtn?.addEventListener("click", () => switchWxsatView("predictions"));
|
||||
|
||||
// ── Live view: decoder state ────────────────────────────────────────
|
||||
// Updated from app.js render() via window.updateWxsatLiveState
|
||||
@@ -284,6 +288,89 @@ document
|
||||
}
|
||||
});
|
||||
|
||||
// ── Predictions view ────────────────────────────────────────────────
|
||||
|
||||
function azToCardinal(deg) {
|
||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||
return dirs[Math.round(deg / 45) % 8];
|
||||
}
|
||||
|
||||
function formatPredTime(ms) {
|
||||
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) {
|
||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
function renderSatPredictions(passes, error) {
|
||||
const list = document.getElementById("sat-pred-list");
|
||||
const status = document.getElementById("sat-pred-status");
|
||||
if (!list) return;
|
||||
|
||||
if (error) {
|
||||
list.innerHTML = "";
|
||||
if (status) status.textContent = error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(passes) || passes.length === 0) {
|
||||
list.innerHTML = "";
|
||||
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 = [
|
||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||
`<span class="sat-pred-col-el ${elClass}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||
].join("");
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
list.replaceChildren(fragment);
|
||||
if (status) status.textContent = `${passes.length} pass${passes.length === 1 ? "" : "es"} in the next 24 h · times in UTC`;
|
||||
}
|
||||
|
||||
async function loadSatPredictions() {
|
||||
const status = document.getElementById("sat-pred-status");
|
||||
const list = document.getElementById("sat-pred-list");
|
||||
if (status) status.textContent = "Loading predictions\u2026";
|
||||
if (list) list.innerHTML = "";
|
||||
try {
|
||||
const resp = await fetch("/sat_passes");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
renderSatPredictions([], data.error);
|
||||
} else {
|
||||
renderSatPredictions(data.passes || []);
|
||||
}
|
||||
} catch (e) {
|
||||
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||
window.wxsatShowOnMap = function (south, west, north, east) {
|
||||
// Enable wxsat filter if not active
|
||||
|
||||
@@ -4538,7 +4538,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* ── Weather Satellite panel ────────────────────────────────────────── */
|
||||
/* ── SAT panel ──────────────────────────────────────────────────────── */
|
||||
.wxsat-view-bar { display: flex; gap: 0; margin-bottom: 0.75rem; border-bottom: 1px solid var(--border); }
|
||||
.wxsat-view-btn { flex-shrink: 0; background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.3rem 0.9rem; color: var(--text-muted); cursor: pointer; font-size: 0.82rem; }
|
||||
.wxsat-view-active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
|
||||
@@ -4561,7 +4561,20 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.wxsat-latest-card { background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 0.4rem; padding: 0.6rem 0.75rem; }
|
||||
.wxsat-latest-card .wxsat-latest-title { font-size: 0.82rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.wxsat-latest-card .wxsat-latest-meta { font-size: 0.78rem; color: var(--text-muted); }
|
||||
.sat-pred-header { display: grid; grid-template-columns: 6rem 1fr 4.5rem 5rem 6rem; gap: 0.25rem; padding: 0.25rem 0.4rem; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 1px solid var(--border); }
|
||||
.sat-pred-row { display: grid; grid-template-columns: 6rem 1fr 4.5rem 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:hover { background: var(--bg-hover, rgba(255,255,255,0.02)); }
|
||||
.sat-pred-col-time { font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||||
.sat-pred-col-sat { font-weight: 500; }
|
||||
.sat-pred-col-el { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.sat-pred-col-dur { font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||||
.sat-pred-col-dir { color: var(--text-muted); }
|
||||
.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); }
|
||||
@media (max-width: 600px) {
|
||||
.wxsat-live-grid { grid-template-columns: 1fr; }
|
||||
.wxsat-history-header, .wxsat-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-col-dir { display: none; }
|
||||
}
|
||||
|
||||
@@ -1371,6 +1371,43 @@ pub async fn clear_lrpt_decode(
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SatPassesResponse {
|
||||
passes: Vec<trx_core::geo::PassPrediction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Return predicted passes for all known amateur satellites over the next 24 h.
|
||||
///
|
||||
/// Requires the server station location to be configured. Returns an empty
|
||||
/// `passes` array with an `error` field if the location is missing.
|
||||
#[get("/sat_passes")]
|
||||
pub async fn sat_passes(state: web::Data<watch::Receiver<RigState>>) -> impl Responder {
|
||||
let rig_state = state.get_ref().borrow().clone();
|
||||
let lat = rig_state.server_latitude;
|
||||
let lon = rig_state.server_longitude;
|
||||
|
||||
let (Some(lat), Some(lon)) = (lat, lon) else {
|
||||
return web::Json(SatPassesResponse {
|
||||
passes: vec![],
|
||||
error: Some("No station location configured".to_string()),
|
||||
});
|
||||
};
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64;
|
||||
let window_ms = 24 * 60 * 60 * 1000_i64;
|
||||
|
||||
let passes = trx_core::geo::compute_upcoming_passes(lat, lon, now_ms, window_ms);
|
||||
web::Json(SatPassesResponse {
|
||||
passes,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[post("/clear_ft8_decode")]
|
||||
pub async fn clear_ft8_decode(
|
||||
query: web::Query<RemoteQuery>,
|
||||
@@ -2087,6 +2124,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
.service(toggle_wspr_decode)
|
||||
.service(toggle_wxsat_decode)
|
||||
.service(toggle_lrpt_decode)
|
||||
.service(sat_passes)
|
||||
.service(clear_ais_decode)
|
||||
.service(clear_vdes_decode)
|
||||
.service(clear_aprs_decode)
|
||||
|
||||
Reference in New Issue
Block a user