Pass predictions were a third view inside the Weather Satellite Decoder card, under Digital modes — a planning tool filed behind a decoder toggle, beside the FT8 and WEFAX panels it has nothing to do with. Nothing about knowing when a bird comes over belongs there. Move them to /satellites, reached from Tools alongside Statistics, Recorder, Settings and About: occasional destinations that live behind that menu rather than taking a slot in the operating strip. Adding a sixth strip button wrapped the phone nav onto two rows and cost the desktop strip its labels at 1280px, so the tab is hidden from the strip exactly the way its four peers already are — the nav is byte-for-byte what it was. The prediction code moves out of sat.ts into its own plugin that loads with the page, so the decoder card no longer carries it. Countdowns stop when the page is hidden and each visit reloads, since passes go stale while it is closed. The server grows a /satellites index route so a deep link or a refresh serves the SPA shell rather than a 404. Closes #47 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
229 lines
8.6 KiB
JavaScript
229 lines
8.6 KiB
JavaScript
// src/plugins/satellite-predictions.ts
|
|
var predWindow = window;
|
|
var dom = {
|
|
page: document.getElementById("tab-satellites"),
|
|
filter: document.getElementById("sat-pred-filter"),
|
|
minElevation: document.getElementById("sat-pred-min-el"),
|
|
category: document.getElementById("sat-pred-category"),
|
|
currentList: document.getElementById("sat-pred-current-list"),
|
|
upcomingList: document.getElementById("sat-pred-list"),
|
|
currentSection: document.getElementById("sat-pred-current-section"),
|
|
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
|
status: document.getElementById("sat-pred-status")
|
|
};
|
|
var PAGE_SIZE = 50;
|
|
var predShowAll = false;
|
|
var predData = [];
|
|
var predFilterText = "";
|
|
var predMinEl = 0;
|
|
var predCategory = "all";
|
|
var predSatCount = 0;
|
|
var predCountdownTimer = null;
|
|
function isPageVisible() {
|
|
return !!dom.page && dom.page.style.display !== "none";
|
|
}
|
|
function azToCardinal(deg) {
|
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
}
|
|
function formatPredTime(ms) {
|
|
const d = new Date(ms);
|
|
const now = /* @__PURE__ */ 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 formatCountdown(ms) {
|
|
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
|
const m = Math.floor(totalSec / 60);
|
|
const s = totalSec % 60;
|
|
return `${m}:${String(s).padStart(2, "0")}`;
|
|
}
|
|
function elevationClass(deg) {
|
|
if (deg >= 45) return "sat-pred-el-high";
|
|
if (deg >= 10) return "sat-pred-el-mid";
|
|
return "sat-pred-el-low";
|
|
}
|
|
function stopCountdownTimer() {
|
|
if (predCountdownTimer) {
|
|
clearInterval(predCountdownTimer);
|
|
predCountdownTimer = null;
|
|
}
|
|
}
|
|
function startCountdownTimer(container) {
|
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
|
if (countdownEls.length === 0) return;
|
|
predCountdownTimer = setInterval(() => {
|
|
if (!isPageVisible()) {
|
|
stopCountdownTimer();
|
|
return;
|
|
}
|
|
const n = Date.now();
|
|
let anyActive = false;
|
|
for (const el of countdownEls) {
|
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
const rem = los - n;
|
|
if (rem > 0) {
|
|
el.textContent = formatCountdown(rem);
|
|
anyActive = true;
|
|
} else {
|
|
el.textContent = "0:00";
|
|
}
|
|
}
|
|
if (!anyActive) {
|
|
stopCountdownTimer();
|
|
render(filtered());
|
|
}
|
|
}, 1e3);
|
|
}
|
|
function buildCurrentPassRow(pass, now) {
|
|
const row = document.createElement("div");
|
|
row.className = "sat-pred-row-current";
|
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
const remaining = Math.max(0, pass.los_ms - now);
|
|
row.innerHTML = [
|
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
].join("");
|
|
return row;
|
|
}
|
|
function buildUpcomingPassRow(pass) {
|
|
const row = document.createElement("div");
|
|
row.className = "sat-pred-row";
|
|
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 ${elevationClass(pass.max_elevation_deg)}">${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("");
|
|
return row;
|
|
}
|
|
function filtered() {
|
|
let items = predData;
|
|
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
|
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
|
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
|
return items;
|
|
}
|
|
function applyFilters() {
|
|
render(filtered());
|
|
}
|
|
var filterInput = dom.filter;
|
|
filterInput?.addEventListener("input", () => {
|
|
predFilterText = filterInput.value.trim().toUpperCase();
|
|
applyFilters();
|
|
});
|
|
var minElevationSelect = dom.minElevation;
|
|
minElevationSelect?.addEventListener("change", () => {
|
|
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
|
applyFilters();
|
|
});
|
|
var categorySelect = dom.category;
|
|
categorySelect?.addEventListener("change", () => {
|
|
predCategory = categorySelect.value;
|
|
applyFilters();
|
|
});
|
|
function render(passes, error) {
|
|
stopCountdownTimer();
|
|
if (error) {
|
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
if (dom.status) dom.status.textContent = error;
|
|
return;
|
|
}
|
|
if (!Array.isArray(passes) || passes.length === 0) {
|
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
|
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
|
return;
|
|
}
|
|
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);
|
|
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
|
if (dom.currentList) {
|
|
if (current.length === 0) {
|
|
dom.currentList.innerHTML = "";
|
|
} else {
|
|
const frag = document.createDocumentFragment();
|
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
dom.currentList.replaceChildren(frag);
|
|
}
|
|
}
|
|
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
|
if (dom.upcomingList) {
|
|
const frag = document.createDocumentFragment();
|
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
if (hiddenCount > 0) {
|
|
const moreRow = document.createElement("div");
|
|
moreRow.className = "sat-pred-row";
|
|
moreRow.style.cursor = "pointer";
|
|
moreRow.style.textAlign = "center";
|
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
moreRow.addEventListener("click", () => {
|
|
predShowAll = true;
|
|
render(filtered());
|
|
});
|
|
frag.appendChild(moreRow);
|
|
}
|
|
dom.upcomingList.replaceChildren(frag);
|
|
}
|
|
if (dom.status) {
|
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
|
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
|
|
dom.status.textContent = text;
|
|
}
|
|
if (current.length > 0 && isPageVisible()) {
|
|
startCountdownTimer(dom.currentList);
|
|
}
|
|
}
|
|
async function load() {
|
|
if (dom.status) dom.status.textContent = "Loading predictions…";
|
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
try {
|
|
const resp = await fetch("/sat_passes");
|
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
const data = await resp.json();
|
|
predSatCount = data.satellite_count || 0;
|
|
if (data.error) {
|
|
predData = [];
|
|
render([], data.error);
|
|
} else {
|
|
predData = data.passes || [];
|
|
render(filtered());
|
|
}
|
|
} catch (error) {
|
|
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
function clearPredictionDom() {
|
|
stopCountdownTimer();
|
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
}
|
|
predWindow.clearSatPredictionDom = clearPredictionDom;
|
|
predWindow.refreshSatPredictions = function() {
|
|
predShowAll = false;
|
|
void load();
|
|
};
|