Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4727233d8e |
@@ -885,7 +885,7 @@ function elementById(id) {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
@@ -1529,7 +1529,6 @@ var TAB_ORDER = [
|
|||||||
"bookmarks",
|
"bookmarks",
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
"satellites",
|
|
||||||
"statistics",
|
"statistics",
|
||||||
"recorder",
|
"recorder",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1540,7 +1539,6 @@ var TAB_PATHS = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
satellites: "/satellites",
|
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
@@ -1758,7 +1756,6 @@ var pluginGroups = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
satellites: ["/satellite-predictions.js"],
|
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
@@ -5847,7 +5844,6 @@ function _initMapWhenReady() {
|
|||||||
}
|
}
|
||||||
function navigateToTab(name, options = {}) {
|
function navigateToTab(name, options = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -5877,10 +5873,7 @@ function navigateToTab(name, options = {}) {
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
void loadPluginsForTab(name).catch((error) => {
|
||||||
void loadPluginsForTab(name).then(() => {
|
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
@@ -6327,6 +6320,9 @@ function _wireSubTabBar(bar) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||||
|
window.clearSatPredictionDom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ var satDom = {
|
|||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -16,12 +17,29 @@ var satDom = {
|
|||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history")
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
|
predFilter: document.getElementById("sat-pred-filter"),
|
||||||
|
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||||
|
predCategory: document.getElementById("sat-pred-category"),
|
||||||
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
|
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
predStatus: document.getElementById("sat-pred-status")
|
||||||
};
|
};
|
||||||
var satImageHistory = [];
|
var satImageHistory = [];
|
||||||
var SAT_MAX_IMAGES = 100;
|
var SAT_MAX_IMAGES = 100;
|
||||||
|
var SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
var satPredShowAll = false;
|
||||||
var satFilterText = "";
|
var satFilterText = "";
|
||||||
var satActiveView = "live";
|
var satActiveView = "live";
|
||||||
|
var satPredData = [];
|
||||||
|
var satPredFilterText = "";
|
||||||
|
var satPredMinEl = 0;
|
||||||
|
var satPredCategory = "all";
|
||||||
|
var satPredSatCount = 0;
|
||||||
|
var satPredCountdownTimer = null;
|
||||||
function scheduleSatUi(key, job) {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
@@ -30,19 +48,37 @@ function scheduleSatUi(key, job) {
|
|||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
function switchSatView(view) {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (view === "history") renderSatHistoryTable();
|
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||||
|
if (leavingPredictions) clearPredictionDom();
|
||||||
|
if (view === "history") {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
} else if (view === "predictions") {
|
||||||
|
satPredShowAll = false;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
switchSatView("live");
|
switchSatView("live");
|
||||||
});
|
});
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
switchSatView("history");
|
switchSatView("history");
|
||||||
});
|
});
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("predictions");
|
||||||
|
});
|
||||||
var lastSatLrptOn = null;
|
var lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function(update) {
|
satWindow.updateSatLiveState = function(update) {
|
||||||
if (!satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
@@ -239,6 +275,200 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
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 (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startCountdownTimer(container) {
|
||||||
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
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();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 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 getFilteredPredictions() {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
var satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
function renderSatPredictions(passes, error) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.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 (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
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", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadSatPredictions() {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
|||||||
-228
@@ -1,228 +0,0 @@
|
|||||||
// 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();
|
|
||||||
};
|
|
||||||
@@ -24,7 +24,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
|
||||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||||
@@ -64,10 +63,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" data-tab="satellites">
|
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
|
||||||
<span class="tab-label">Satellites</span>
|
|
||||||
</button>
|
|
||||||
<button class="tab" data-tab="statistics">
|
<button class="tab" data-tab="statistics">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||||
<span class="tab-label">Statistics</span>
|
<span class="tab-label">Statistics</span>
|
||||||
@@ -923,6 +918,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="sat-view-bar">
|
<div class="sat-view-bar">
|
||||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
|
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-live-view">
|
<div id="sat-live-view">
|
||||||
@@ -964,6 +960,50 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="sat-history-list"></div>
|
<div id="sat-history-list"></div>
|
||||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||||
</div>
|
</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-category" class="sat-sort-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="weather">Weather</option>
|
||||||
|
<option value="amateur">Ham Radio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
<!-- Current passes -->
|
||||||
|
<div id="sat-pred-current-section">
|
||||||
|
<div class="sat-pred-section-title">Currently receivable</div>
|
||||||
|
<div class="sat-pred-header sat-pred-header-current">
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-time">AOS Start</span>
|
||||||
|
<span class="sat-pred-col-time">AOS End</span>
|
||||||
|
<span class="sat-pred-col-countdown">Time left</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-current-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming passes -->
|
||||||
|
<div id="sat-pred-upcoming-section">
|
||||||
|
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
@@ -1077,51 +1117,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
|
||||||
<h2 class="section-heading">Satellites</h2>
|
|
||||||
<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-category" class="sat-sort-select">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="weather">Weather</option>
|
|
||||||
<option value="amateur">Ham Radio</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
<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>
|
|
||||||
<!-- Current passes -->
|
|
||||||
<div id="sat-pred-current-section">
|
|
||||||
<div class="sat-pred-section-title">Currently receivable</div>
|
|
||||||
<div class="sat-pred-header sat-pred-header-current">
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-time">AOS Start</span>
|
|
||||||
<span class="sat-pred-col-time">AOS End</span>
|
|
||||||
<span class="sat-pred-col-countdown">Time left</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-current-list"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Upcoming passes -->
|
|
||||||
<div id="sat-pred-upcoming-section">
|
|
||||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
|
|||||||
@@ -3816,12 +3816,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
/* One navigation model at every width. Statistics, Recorder, Settings and
|
||||||
* Settings and About are occasional destinations: they live behind More rather
|
* About are occasional destinations: they live behind More rather than
|
||||||
* than competing with the operating tabs for the row and then scrolling out of
|
* competing with the operating tabs for the row and then scrolling out of
|
||||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||||
.tab-bar-nav .tab[data-tab="satellites"],
|
|
||||||
.tab-bar-nav .tab[data-tab="statistics"],
|
.tab-bar-nav .tab[data-tab="statistics"],
|
||||||
.tab-bar-nav .tab[data-tab="recorder"],
|
.tab-bar-nav .tab[data-tab="recorder"],
|
||||||
.tab-bar-nav .tab[data-tab="settings"],
|
.tab-bar-nav .tab[data-tab="settings"],
|
||||||
@@ -4031,9 +4030,8 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||||
}
|
}
|
||||||
.tab-icon, .tab-more-icon { display: block; }
|
.tab-icon, .tab-more-icon { display: block; }
|
||||||
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
||||||
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
||||||
.tab[data-tab="about"] { display: none; }
|
|
||||||
.mobile-more-btn { display: flex; }
|
.mobile-more-btn { display: flex; }
|
||||||
.mobile-more-menu {
|
.mobile-more-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ await build({
|
|||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||||
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
|
||||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -385,7 +385,6 @@ declare global {
|
|||||||
refreshCwTonePicker?(): void;
|
refreshCwTonePicker?(): void;
|
||||||
updateFt8RfDisplay?(): void;
|
updateFt8RfDisplay?(): void;
|
||||||
clearSatPredictionDom?(): void;
|
clearSatPredictionDom?(): void;
|
||||||
refreshSatPredictions?(): void;
|
|
||||||
syncWefaxToggle?(enabled: boolean): void;
|
syncWefaxToggle?(enabled: boolean): void;
|
||||||
syncSstvToggle?(enabled: boolean): void;
|
syncSstvToggle?(enabled: boolean): void;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
@@ -4850,7 +4849,6 @@ function _initMapWhenReady() {
|
|||||||
|
|
||||||
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -4891,13 +4889,7 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
// Its countdowns tick every second, so the page stops when it is not on screen.
|
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
||||||
if (leavingSatellites) window.clearSatPredictionDom?.();
|
|
||||||
void loadPluginsForTab(name).then(() => {
|
|
||||||
// Passes go stale while the page is closed, so each visit reloads them.
|
|
||||||
// The first visit is what imports the module, which renders on its own.
|
|
||||||
if (name === "satellites") window.refreshSatPredictions?.();
|
|
||||||
}).catch((error: unknown) => { console.error(error); });
|
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_initMapWhenReady();
|
||||||
}
|
}
|
||||||
@@ -5249,6 +5241,10 @@ function _wireSubTabBar(bar: WiredElement) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
|
||||||
|
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
||||||
|
window.clearSatPredictionDom();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
|
|||||||
+1
-2
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
export const TAB_ORDER = [
|
||||||
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -13,7 +13,6 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
satellites: "/satellites",
|
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
type PluginGroup =
|
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
||||||
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
|
||||||
| "bookmarks" | "recorder" | "settings";
|
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
@@ -17,7 +15,6 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
satellites: ["/satellite-predictions.js"],
|
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
type SatelliteView = "live" | "history";
|
type SatelliteView = "live" | "history" | "predictions";
|
||||||
interface SatelliteBridge {
|
interface SatelliteBridge {
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
clearSatPredictionDom?: () => void;
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||||
clearSatMapOverlays?: () => void;
|
clearSatMapOverlays?: () => void;
|
||||||
@@ -24,14 +25,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// History view: filterable table of all decoded images
|
||||||
//
|
// Predictions view: next 24 h passes for ham satellites
|
||||||
// Pass predictions live on their own page; see satellite-predictions.ts.
|
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const satDom = {
|
const satDom = {
|
||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -41,13 +42,30 @@ const satDom = {
|
|||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
|
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||||
|
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||||
|
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||||
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
|
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
predStatus: document.getElementById("sat-pred-status"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory: SatelliteImage[] = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
|
const SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
let satPredShowAll = false;
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
let satActiveView: SatelliteView = "live";
|
||||||
|
let satPredData: SatellitePass[] = [];
|
||||||
|
let satPredFilterText = "";
|
||||||
|
let satPredMinEl = 0;
|
||||||
|
let satPredCategory = "all";
|
||||||
|
let satPredSatCount = 0;
|
||||||
|
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key: string, job: () => void): void {
|
||||||
@@ -60,16 +78,33 @@ function scheduleSatUi(key: string, job: () => void): void {
|
|||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view: SatelliteView): void {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (view === "history") renderSatHistoryTable();
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
||||||
|
if (leavingPredictions) clearPredictionDom();
|
||||||
|
if (view === "history") {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
} else if (view === "predictions") {
|
||||||
|
satPredShowAll = false;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
let lastSatLrptOn: boolean | null = null;
|
||||||
@@ -300,6 +335,234 @@ document
|
|||||||
}
|
}
|
||||||
})(); });
|
})(); });
|
||||||
|
|
||||||
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
|
function azToCardinal(deg: number): string {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredTime(ms: number): string {
|
||||||
|
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: number): string {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevationClass(deg: number): string {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: countdown timer management ─────────────────────────
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdownTimer(container: HTMLElement | null): void {
|
||||||
|
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
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();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: row builders ───────────────────────────────────────
|
||||||
|
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${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)}\u00B0</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: SatellitePass): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${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)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: filter state ───────────────────────────────────────
|
||||||
|
function getFilteredPredictions(): SatellitePass[] {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
|
||||||
|
const satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
|
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.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);
|
||||||
|
|
||||||
|
// ── Current passes ──
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upcoming passes ──
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
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\u2026</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Countdown timer ──
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: data loading ───────────────────────────────────────
|
||||||
|
async function loadSatPredictions(): Promise<void> {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json() as SatellitePassResponse;
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
|||||||
-290
@@ -1,290 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
|
|
||||||
|
|
||||||
// --- Satellite pass predictions ---
|
|
||||||
//
|
|
||||||
// Its own page rather than a third view inside the weather-satellite decoder:
|
|
||||||
// when a bird comes over is a planning question, and it was buried under
|
|
||||||
// Digital modes beside decoders it has nothing to do with.
|
|
||||||
|
|
||||||
interface PredictionsBridge {
|
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
refreshSatPredictions?: () => void;
|
|
||||||
}
|
|
||||||
const predWindow = window as unknown as PredictionsBridge;
|
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
|
||||||
const dom = {
|
|
||||||
page: document.getElementById("tab-satellites"),
|
|
||||||
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
|
||||||
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
|
||||||
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
|
||||||
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"),
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
|
||||||
const PAGE_SIZE = 50;
|
|
||||||
let predShowAll = false;
|
|
||||||
let predData: SatellitePass[] = [];
|
|
||||||
let predFilterText = "";
|
|
||||||
let predMinEl = 0;
|
|
||||||
let predCategory = "all";
|
|
||||||
let predSatCount = 0;
|
|
||||||
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
/** The countdowns tick once a second, so they stop when nobody is looking. */
|
|
||||||
function isPageVisible(): boolean {
|
|
||||||
return !!dom.page && dom.page.style.display !== "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
|
||||||
function azToCardinal(deg: number): string {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredTime(ms: number): string {
|
|
||||||
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: number): string {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountdown(ms: number): string {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function elevationClass(deg: number): string {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: countdown timer management ─────────────────────────
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (predCountdownTimer) {
|
|
||||||
clearInterval(predCountdownTimer);
|
|
||||||
predCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdownTimer(container: HTMLElement | null): void {
|
|
||||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".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());
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: row builders ───────────────────────────────────────
|
|
||||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${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)}\u00B0</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: SatellitePass): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${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)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: filter state ───────────────────────────────────────
|
|
||||||
function filtered(): SatellitePass[] {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterInput = dom.filter;
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
predFilterText = filterInput.value.trim().toUpperCase();
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const minElevationSelect = dom.minElevation;
|
|
||||||
minElevationSelect?.addEventListener("change", () => {
|
|
||||||
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const categorySelect = dom.category;
|
|
||||||
categorySelect?.addEventListener("change", () => {
|
|
||||||
predCategory = categorySelect.value;
|
|
||||||
applyFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
|
||||||
function render(passes: SatellitePass[], error?: string): void {
|
|
||||||
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);
|
|
||||||
|
|
||||||
// ── Current passes ──
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upcoming passes ──
|
|
||||||
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\u2026</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
predShowAll = true;
|
|
||||||
render(filtered());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
dom.upcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ──
|
|
||||||
if (dom.status) {
|
|
||||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
|
||||||
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
|
|
||||||
dom.status.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Countdown timer ──
|
|
||||||
if (current.length > 0 && isPageVisible()) {
|
|
||||||
startCountdownTimer(dom.currentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: data loading ───────────────────────────────────────
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
|
|
||||||
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() as SatellitePassResponse;
|
|
||||||
predSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
predData = [];
|
|
||||||
render([], data.error);
|
|
||||||
} else {
|
|
||||||
predData = data.passes || [];
|
|
||||||
render(filtered());
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Page lifecycle ──────────────────────────────────────────────────
|
|
||||||
function clearPredictionDom(): void {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (dom.currentList) dom.currentList.innerHTML = "";
|
|
||||||
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
predWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the router each time the Satellites page is opened, including the
|
|
||||||
* visit that imports this module — so loading is driven from one place only.
|
|
||||||
*/
|
|
||||||
predWindow.refreshSatPredictions = function () {
|
|
||||||
predShowAll = false;
|
|
||||||
void load();
|
|
||||||
};
|
|
||||||
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
|
|||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
||||||
//
|
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
||||||
|
|
||||||
// Pass predictions used to be a third view inside the weather-satellite
|
|
||||||
// decoder, under Digital modes — a planning tool filed with the decoders it has
|
|
||||||
// nothing to do with. It is its own page now, so what this guards is that the
|
|
||||||
// page exists at its own address, renders passes, and that the decoder card no
|
|
||||||
// longer offers the view it gave up.
|
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { chromium } from "playwright-core";
|
|
||||||
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
|
||||||
|
|
||||||
/* global document, window, getComputedStyle */
|
|
||||||
|
|
||||||
const HOUR = 3_600_000;
|
|
||||||
const now = Date.now();
|
|
||||||
const satPasses = {
|
|
||||||
satellite_count: 2,
|
|
||||||
passes: [
|
|
||||||
// In progress right now, so the page has a countdown to run.
|
|
||||||
{
|
|
||||||
satellite: "NOAA 19", category: "weather",
|
|
||||||
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
|
|
||||||
max_elevation_deg: 62.4, duration_s: 600,
|
|
||||||
azimuth_aos_deg: 10, azimuth_los_deg: 190,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
satellite: "ISS", category: "amateur",
|
|
||||||
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
|
|
||||||
max_elevation_deg: 18.2, duration_s: 480,
|
|
||||||
azimuth_aos_deg: 200, azimuth_los_deg: 20,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const fixture = await startWebFixture({ satPasses });
|
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await page.setViewportSize({ width: 1500, height: 950 });
|
|
||||||
|
|
||||||
// The page is reachable at its own address, not through a decoder sub-view.
|
|
||||||
// #content is the radio panel on the main tab, so the readiness signal for a
|
|
||||||
// deep link is the destination panel itself.
|
|
||||||
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-satellites").waitFor({ state: "visible" });
|
|
||||||
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
|
||||||
null,
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const rendered = await page.evaluate(() => ({
|
|
||||||
current: document.getElementById("sat-pred-current-list").textContent,
|
|
||||||
upcoming: document.getElementById("sat-pred-list").textContent,
|
|
||||||
status: document.getElementById("sat-pred-status").textContent,
|
|
||||||
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
|
|
||||||
}));
|
|
||||||
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
|
|
||||||
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
|
|
||||||
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
|
|
||||||
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
|
|
||||||
|
|
||||||
// A pass in progress counts down, so the seconds have to move on their own.
|
|
||||||
const firstTick = await page.evaluate(
|
|
||||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
|
||||||
await page.waitForTimeout(1600);
|
|
||||||
const secondTick = await page.evaluate(
|
|
||||||
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
|
||||||
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
|
|
||||||
|
|
||||||
// Leaving stops the countdown rather than leaving a timer running behind a
|
|
||||||
// hidden page.
|
|
||||||
await page.evaluate(() => { window.navigateToTab("main"); });
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const afterLeaving = await page.evaluate(() => ({
|
|
||||||
visible: document.getElementById("tab-satellites").style.display,
|
|
||||||
rows: document.getElementById("sat-pred-current-list").childElementCount,
|
|
||||||
}));
|
|
||||||
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
|
|
||||||
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
|
|
||||||
|
|
||||||
// Coming back reloads rather than showing whatever was there before.
|
|
||||||
await page.evaluate(() => { window.navigateToTab("satellites"); });
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
|
||||||
null,
|
|
||||||
{ timeout: 5000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
// The page is an occasional destination, so it lives behind Tools rather than
|
|
||||||
// taking a slot in the operating strip — the same treatment Statistics,
|
|
||||||
// Recorder, Settings and About get.
|
|
||||||
const nav = await page.evaluate(() => ({
|
|
||||||
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
|
|
||||||
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
|
|
||||||
}));
|
|
||||||
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
|
|
||||||
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
|
|
||||||
|
|
||||||
// The decoder card keeps Live and History, and no longer offers Predictions.
|
|
||||||
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
|
||||||
const satCard = await page.evaluate(() => ({
|
|
||||||
predictionsButton: !!document.getElementById("sat-view-predictions"),
|
|
||||||
predictionsView: !!document.getElementById("sat-predictions-view"),
|
|
||||||
live: !!document.getElementById("sat-view-live"),
|
|
||||||
history: !!document.getElementById("sat-view-history"),
|
|
||||||
}));
|
|
||||||
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
|
|
||||||
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
|
|
||||||
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
|
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
|
|
||||||
console.log("satellite predictions page tests passed");
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
await fixture.close();
|
|
||||||
}
|
|
||||||
@@ -135,7 +135,6 @@ export async function startWebFixture({
|
|||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
satPasses = null,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
remote,
|
remote,
|
||||||
@@ -231,7 +230,6 @@ export async function startWebFixture({
|
|||||||
["/bandplan.json", bandplan],
|
["/bandplan.json", bandplan],
|
||||||
["/api/recorder/status", []],
|
["/api/recorder/status", []],
|
||||||
["/api/recorder/files", []],
|
["/api/recorder/files", []],
|
||||||
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
||||||
|
|||||||
@@ -101,12 +101,6 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
|||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/satellites")]
|
|
||||||
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
|
||||||
let c = gz_index_html();
|
|
||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/bookmarks")]
|
#[get("/bookmarks")]
|
||||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
|
|||||||
@@ -639,7 +639,6 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::recorder_index)
|
.service(assets::recorder_index)
|
||||||
.service(assets::settings_index)
|
.service(assets::settings_index)
|
||||||
.service(assets::about_index)
|
.service(assets::about_index)
|
||||||
.service(assets::satellites_index)
|
|
||||||
.service(assets::statistics_index)
|
.service(assets::statistics_index)
|
||||||
.service(assets::bookmarks_index)
|
.service(assets::bookmarks_index)
|
||||||
.service(assets::favicon)
|
.service(assets::favicon)
|
||||||
|
|||||||
Reference in New Issue
Block a user