refactor: convert satellite view to TypeScript

This commit is contained in:
sjg
2026-08-01 12:51:22 +02:00
parent 021d31d780
commit 4fb65971e4
7 changed files with 661 additions and 519 deletions
@@ -10,7 +10,7 @@ const pluginGroups = {
}; };
const loaded = /* @__PURE__ */ new Set(); const loaded = /* @__PURE__ */ new Set();
const loading = /* @__PURE__ */ new Map(); const loading = /* @__PURE__ */ new Map();
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js"]); const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js"]);
function loadLegacyScript(path) { function loadLegacyScript(path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement("script"); const script = document.createElement("script");
@@ -1,447 +1,472 @@
"use strict"; "use strict";
const satDom = { (() => {
status: document.getElementById("sat-status"), // src/plugins/sat.ts
liveView: document.getElementById("sat-live-view"), var satWindow = window;
historyView: document.getElementById("sat-history-view"), var satDom = {
predictionsView: document.getElementById("sat-predictions-view"), status: document.getElementById("sat-status"),
liveLatest: document.getElementById("sat-live-latest"), liveView: document.getElementById("sat-live-view"),
historyList: document.getElementById("sat-history-list"), historyView: document.getElementById("sat-history-view"),
historyCount: document.getElementById("sat-history-count"), predictionsView: document.getElementById("sat-predictions-view"),
filterInput: document.getElementById("sat-filter"), liveLatest: document.getElementById("sat-live-latest"),
sortSelect: document.getElementById("sat-sort"), historyList: document.getElementById("sat-history-list"),
typeFilter: document.getElementById("sat-type-filter"), historyCount: document.getElementById("sat-history-count"),
lrptState: document.getElementById("sat-lrpt-state"), filterInput: document.getElementById("sat-filter"),
viewLiveBtn: document.getElementById("sat-view-live"), sortSelect: document.getElementById("sat-sort"),
viewHistoryBtn: document.getElementById("sat-view-history"), typeFilter: document.getElementById("sat-type-filter"),
viewPredBtn: document.getElementById("sat-view-predictions"), lrptState: document.getElementById("sat-lrpt-state"),
predFilter: document.getElementById("sat-pred-filter"), viewLiveBtn: document.getElementById("sat-view-live"),
predMinEl: document.getElementById("sat-pred-min-el"), viewHistoryBtn: document.getElementById("sat-view-history"),
predCategory: document.getElementById("sat-pred-category"), viewPredBtn: document.getElementById("sat-view-predictions"),
predCurrentList: document.getElementById("sat-pred-current-list"), predFilter: document.getElementById("sat-pred-filter"),
predUpcomingList: document.getElementById("sat-pred-list"), predMinEl: document.getElementById("sat-pred-min-el"),
predCurrentSec: document.getElementById("sat-pred-current-section"), predCategory: document.getElementById("sat-pred-category"),
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"), predCurrentList: document.getElementById("sat-pred-current-list"),
predStatus: document.getElementById("sat-pred-status") predUpcomingList: document.getElementById("sat-pred-list"),
}; predCurrentSec: document.getElementById("sat-pred-current-section"),
let satImageHistory = []; predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
const SAT_MAX_IMAGES = 100; predStatus: document.getElementById("sat-pred-status")
const SAT_PRED_PAGE_SIZE = 50; };
let satPredShowAll = false; var satImageHistory = [];
let satFilterText = ""; var SAT_MAX_IMAGES = 100;
let satActiveView = "live"; var SAT_PRED_PAGE_SIZE = 50;
let satPredData = []; var satPredShowAll = false;
let satPredFilterText = ""; var satFilterText = "";
let satPredMinEl = 0; var satActiveView = "live";
let satPredCategory = "all"; var satPredData = [];
let satPredSatCount = 0; var satPredFilterText = "";
let satPredCountdownTimer = null; var satPredMinEl = 0;
function scheduleSatUi(key, job) { var satPredCategory = "all";
if (typeof window.trxScheduleUiFrameJob === "function") { var satPredSatCount = 0;
window.trxScheduleUiFrameJob(key, job); var satPredCountdownTimer = null;
return; function scheduleSatUi(key, job) {
} if (typeof satWindow.trxScheduleUiFrameJob === "function") {
job(); satWindow.trxScheduleUiFrameJob(key, job);
}
function switchSatView(view) {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
satActiveView = view;
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.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;
loadSatPredictions();
}
}
function clearPredictionDom() {
stopCountdownTimer();
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
}
window.clearSatPredictionDom = clearPredictionDom;
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
let _lastSatLrptOn = null;
window.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
const lrptOn = !!update.lrpt_decode_enabled;
if (lrptOn !== _lastSatLrptOn) {
_lastSatLrptOn = lrptOn;
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
if (satDom.status) {
if (lrptOn) {
satDom.status.textContent = "Decoder active — waiting for signal";
} else {
satDom.status.textContent = "Decoder idle";
}
}
}
};
function renderSatLatestCard() {
if (!satDom.liveLatest) return;
if (satImageHistory.length === 0) {
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
return;
}
const img = satImageHistory[0];
const decoder = img._decoder || "unknown";
const typeName = "Meteor LRPT";
const satellite = img.satellite || "";
const channels = img.channels || img.channel_a || "";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU rows";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
let meta = [typeName];
if (satellite) meta.push(satellite);
if (channels) meta.push(channels);
meta.push(`${lines} ${unit}`);
meta.push(`${date} ${ts}`);
let html = `<div class="sat-latest-card">`;
html += `<div class="sat-latest-title">Latest decoded image</div>`;
html += `<div class="sat-latest-meta">${meta.join(" &middot; ")}</div>`;
if (img.path) {
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
}
if (img.geo_bounds) {
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
}
html += `</div>`;
satDom.liveLatest.innerHTML = html;
}
function getSatFilteredHistory() {
let items = satImageHistory;
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
if (satFilterText) {
items = items.filter((i) => {
const haystack = [
"meteor lrpt",
i.satellite || "",
i.channels || "",
i.channel_a || "",
i.channel_b || ""
].join(" ").toUpperCase();
return haystack.includes(satFilterText);
});
}
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
if (sortVal === "oldest") items = items.slice().reverse();
return items;
}
function renderSatHistoryRow(img) {
const row = document.createElement("div");
row.className = "sat-history-row";
const decoder = img._decoder || "unknown";
const typeName = "Meteor LRPT";
const typeClass = "sat-type-lrpt";
const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
const satellite = img.satellite || "--";
const channels = img.channels || "--";
const lines = img.mcu_count || img.line_count || 0;
const unit = "MCU";
let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
if (img.geo_bounds) {
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
}
row.innerHTML = [
`<span>${date} ${ts}</span>`,
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
`<span>${satellite}</span>`,
`<span>${channels}</span>`,
`<span>${lines} ${unit}</span>`,
`<span>${link}</span>`
].join("");
return row;
}
function renderSatHistoryTable() {
if (!satDom.historyList) return;
const items = getSatFilteredHistory();
const fragment = document.createDocumentFragment();
for (let i = 0; i < items.length; i += 1) {
fragment.appendChild(renderSatHistoryRow(items[i]));
}
satDom.historyList.replaceChildren(fragment);
if (satDom.historyCount) {
const total = satImageHistory.length;
const shown = items.length;
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
}
}
function addSatImage(img, decoder) {
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
img._tsMs = tsMs;
img._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
img._decoder = decoder;
satImageHistory.unshift(img);
if (satImageHistory.length > SAT_MAX_IMAGES) {
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
}
scheduleSatUi("sat-latest", () => renderSatLatestCard());
if (satActiveView === "history") {
scheduleSatUi("sat-history", () => renderSatHistoryTable());
}
}
window.onServerLrptProgress = function(msg) {
if (satDom.status && msg.mcu_count > 0) {
satDom.status.textContent = "Receiving — " + msg.mcu_count + " MCU rows decoded";
}
};
window.onServerLrptImage = function(msg) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
window.addSatMapOverlay(msg);
}
};
window.resetSatHistoryView = function() {
satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard();
renderSatHistoryTable();
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
};
window.pruneSatHistoryView = function() {
renderSatHistoryTable();
renderSatLatestCard();
};
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", async () => {
try {
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await postPath("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
});
satDom.filterInput?.addEventListener("input", () => {
satFilterText = satDom.filterInput.value.trim().toUpperCase();
renderSatHistoryTable();
});
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
document.getElementById("settings-clear-sat-history")?.addEventListener("click", async () => {
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await postPath("/clear_lrpt_decode");
window.resetSatHistoryView();
} catch (e) {
console.error("Weather satellite history clear failed", e);
}
});
function azToCardinal(deg) {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8];
}
function formatPredTime(ms) {
const d = new Date(ms);
const now = /* @__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 ? container.querySelectorAll(".sat-pred-col-countdown") : [];
if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => {
if (satActiveView !== "predictions") {
stopCountdownTimer();
return; return;
} }
const n = Date.now(); job();
let anyActive = false; }
for (const el of countdownEls) { function switchSatView(view) {
const los = parseInt(el.dataset.los, 10); const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
const rem = los - n; satActiveView = view;
if (rem > 0) { if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
el.textContent = formatCountdown(rem); if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
anyActive = true; if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
} else { if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
el.textContent = "0:00"; 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.viewHistoryBtn?.addEventListener("click", () => {
switchSatView("history");
});
satDom.viewPredBtn?.addEventListener("click", () => {
switchSatView("predictions");
});
var lastSatLrptOn = null;
satWindow.updateSatLiveState = function(update) {
if (!satDom.lrptState) return;
const lrptOn = !!update.lrpt_decode_enabled;
if (lrptOn !== lastSatLrptOn) {
lastSatLrptOn = lrptOn;
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
if (satDom.status) {
if (lrptOn) {
satDom.status.textContent = "Decoder active — waiting for signal";
} else {
satDom.status.textContent = "Decoder idle";
}
} }
} }
if (!anyActive) { };
stopCountdownTimer(); function renderSatLatestCard() {
renderSatPredictions(getFilteredPredictions()); if (!satDom.liveLatest) return;
if (satImageHistory.length === 0) {
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
return;
} }
}, 1e3); const img = satImageHistory[0];
} if (!img) return;
function buildCurrentPassRow(pass, now) { const typeName = "Meteor LRPT";
const row = document.createElement("div"); const satellite = img.satellite || "";
row.className = "sat-pred-row-current"; const channels = img.channels || img.channel_a || "";
const dir = `${azToCardinal(pass.azimuth_aos_deg)}${azToCardinal(pass.azimuth_los_deg)}`; const lines = img.mcu_count || img.line_count || 0;
const remaining = Math.max(0, pass.los_ms - now); const unit = "MCU rows";
row.innerHTML = [ const ts = img._ts || "--";
`<span class="sat-pred-col-sat">${pass.satellite}</span>`, const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`, const meta = [typeName];
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`, if (satellite) meta.push(satellite);
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`, if (channels) meta.push(channels);
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`, meta.push(`${lines} ${unit}`);
`<span class="sat-pred-col-dir">${dir}</span>` meta.push(`${date} ${ts}`);
].join(""); let html = `<div class="sat-latest-card">`;
return row; html += `<div class="sat-latest-title">Latest decoded image</div>`;
} html += `<div class="sat-latest-meta">${meta.join(" &middot; ")}</div>`;
function buildUpcomingPassRow(pass) { if (img.path) {
const row = document.createElement("div"); html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
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());
}
satDom.predFilter?.addEventListener("input", () => {
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
applyPredFilters();
});
satDom.predMinEl?.addEventListener("change", () => {
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
applyPredFilters();
});
satDom.predCategory?.addEventListener("change", () => {
satPredCategory = satDom.predCategory.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);
} }
if (img.geo_bounds) {
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
}
html += `</div>`;
satDom.liveLatest.innerHTML = html;
} }
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE; function getSatFilteredHistory() {
const visibleUpcoming = upcoming.slice(0, upcomingLimit); let items = satImageHistory;
const hiddenCount = upcoming.length - visibleUpcoming.length; const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none"; if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
if (satDom.predUpcomingList) { if (satFilterText) {
const frag = document.createDocumentFragment(); items = items.filter((i) => {
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass)); const haystack = [
if (hiddenCount > 0) { "meteor lrpt",
const moreRow = document.createElement("div"); i.satellite || "",
moreRow.className = "sat-pred-row"; i.channels || "",
moreRow.style.cursor = "pointer"; i.channel_a || "",
moreRow.style.textAlign = "center"; i.channel_b || ""
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`; ].join(" ").toUpperCase();
moreRow.addEventListener("click", () => { return haystack.includes(satFilterText);
satPredShowAll = true;
renderSatPredictions(getFilteredPredictions());
}); });
frag.appendChild(moreRow);
} }
satDom.predUpcomingList.replaceChildren(frag); const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
if (sortVal === "oldest") items = items.slice().reverse();
return items;
} }
if (satDom.predStatus) { function renderSatHistoryRow(img) {
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`; const row = document.createElement("div");
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`; row.className = "sat-history-row";
satDom.predStatus.textContent = text; const typeName = "Meteor LRPT";
} const typeClass = "sat-type-lrpt";
if (current.length > 0 && satActiveView === "predictions") { const ts = img._ts || "--";
startCountdownTimer(satDom.predCurrentList); const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
} const satellite = img.satellite || "--";
} const channels = img.channels || "--";
async function loadSatPredictions() { const lines = img.mcu_count || img.line_count || 0;
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…"; const unit = "MCU";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; if (img.geo_bounds) {
try { link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
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 (e) { row.innerHTML = [
renderSatPredictions([], `Failed to load predictions: ${e.message}`); `<span>${date} ${ts}</span>`,
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
`<span>${satellite}</span>`,
`<span>${channels}</span>`,
`<span>${lines} ${unit}</span>`,
`<span>${link}</span>`
].join("");
return row;
} }
} function renderSatHistoryTable() {
window.satShowOnMap = function(south, west, north, east) { if (!satDom.historyList) return;
if (typeof window.enableMapSourceFilter === "function") { const items = getSatFilteredHistory();
window.enableMapSourceFilter("sat"); const fragment = document.createDocumentFragment();
for (const image of items) {
fragment.appendChild(renderSatHistoryRow(image));
}
satDom.historyList.replaceChildren(fragment);
if (satDom.historyCount) {
const total = satImageHistory.length;
const shown = items.length;
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
}
} }
const lat = (south + north) / 2; function addSatImage(img, decoder) {
const lon = (west + east) / 2; const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
if (window.navigateToAprsMap) { img._tsMs = tsMs;
window.navigateToAprsMap(lat, lon); img._ts = new Date(tsMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
img._decoder = decoder;
satImageHistory.unshift(img);
if (satImageHistory.length > SAT_MAX_IMAGES) {
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
}
scheduleSatUi("sat-latest", () => {
renderSatLatestCard();
});
if (satActiveView === "history") {
scheduleSatUi("sat-history", () => {
renderSatHistoryTable();
});
}
} }
}; satWindow.onServerLrptProgress = function(msg) {
renderSatLatestCard(); if (satDom.status && (msg.mcu_count ?? 0) > 0) {
renderSatHistoryTable(); satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
}
};
satWindow.onServerLrptImage = function(msg) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
satWindow.addSatMapOverlay(msg);
}
};
satWindow.resetSatHistoryView = function() {
satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard();
renderSatHistoryTable();
satWindow.clearSatMapOverlays?.();
};
satWindow.pruneSatHistoryView = function() {
renderSatHistoryTable();
renderSatLatestCard();
};
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", () => {
void (async () => {
try {
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await satWindow.postPath?.("/toggle_lrpt_decode");
} catch (e) {
console.error("LRPT toggle failed", e);
}
})();
});
var satFilterInput = satDom.filterInput;
satFilterInput?.addEventListener("input", () => {
satFilterText = satFilterInput.value.trim().toUpperCase();
renderSatHistoryTable();
});
satDom.sortSelect?.addEventListener("change", () => {
renderSatHistoryTable();
});
satDom.typeFilter?.addEventListener("change", () => {
renderSatHistoryTable();
});
document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => {
void (async () => {
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try {
await satWindow.postPath?.("/clear_lrpt_decode");
satWindow.resetSatHistoryView?.();
} catch (e) {
console.error("Weather satellite history clear failed", e);
}
})();
});
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) {
if (typeof satWindow.enableMapSourceFilter === "function") {
satWindow.enableMapSourceFilter("sat");
}
const lat = (south + north) / 2;
const lon = (west + east) / 2;
if (satWindow.navigateToAprsMap) {
satWindow.navigateToAprsMap(lat, lon);
}
};
renderSatLatestCard();
renderSatHistoryTable();
})();
@@ -23,7 +23,6 @@ await build({
screenshot: path.join(sourceDir, "screenshot.ts"), screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
sat: path.join(sourceDir, "plugins", "sat.js"),
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"), "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"),
scheduler: path.join(sourceDir, "plugins", "scheduler.js"), scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
vchan: path.join(sourceDir, "plugins", "vchan.js"), vchan: path.join(sourceDir, "plugins", "vchan.js"),
@@ -51,6 +50,7 @@ await build({
ais: path.join(sourceDir, "plugins", "ais.ts"), ais: path.join(sourceDir, "plugins", "ais.ts"),
aprs: path.join(sourceDir, "plugins", "aprs.ts"), aprs: path.join(sourceDir, "plugins", "aprs.ts"),
"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"),
}, },
outdir: outputDir, outdir: outputDir,
bundle: true, bundle: true,
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
const loaded = new Set<string>(); const loaded = new Set<string>();
const loading = new Map<string, Promise<void>>(); const loading = new Map<string, Promise<void>>();
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js"]); const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js"]);
function loadLegacyScript(path: string): Promise<void> { function loadLegacyScript(path: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -2,6 +2,28 @@
// //
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
type SatelliteView = "live" | "history" | "predictions";
interface SatelliteBridge {
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
clearSatPredictionDom?: () => void;
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
onServerLrptProgress?: (message: LrptProgress) => void;
onServerLrptImage?: (message: SatelliteImage) => void;
addSatMapOverlay?: (image: SatelliteImage) => void;
resetSatHistoryView?: () => void;
pruneSatHistoryView?: () => void;
clearSatMapOverlays?: () => void;
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
postPath?: (path: string) => Promise<unknown>;
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
satShowOnMap?: (south: number, west: number, north: number, east: number) => void;
enableMapSourceFilter?: (source: string) => void;
navigateToAprsMap?: (lat: number, lon: number) => void;
}
const satWindow = window as unknown as SatelliteBridge;
// --- 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
@@ -16,16 +38,16 @@ const satDom = {
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"),
filterInput: document.getElementById("sat-filter"), filterInput: document.getElementById("sat-filter") as HTMLInputElement | null,
sortSelect: document.getElementById("sat-sort"), sortSelect: document.getElementById("sat-sort") as HTMLSelectElement | null,
typeFilter: document.getElementById("sat-type-filter"), typeFilter: document.getElementById("sat-type-filter") as HTMLSelectElement | null,
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"), viewPredBtn: document.getElementById("sat-view-predictions"),
predFilter: document.getElementById("sat-pred-filter"), predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
predMinEl: document.getElementById("sat-pred-min-el"), predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
predCategory: document.getElementById("sat-pred-category"), predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
predCurrentList: document.getElementById("sat-pred-current-list"), predCurrentList: document.getElementById("sat-pred-current-list"),
predUpcomingList: document.getElementById("sat-pred-list"), predUpcomingList: document.getElementById("sat-pred-list"),
predCurrentSec: document.getElementById("sat-pred-current-section"), predCurrentSec: document.getElementById("sat-pred-current-section"),
@@ -34,30 +56,30 @@ const satDom = {
}; };
// ── State ─────────────────────────────────────────────────────────── // ── State ───────────────────────────────────────────────────────────
let satImageHistory = []; let satImageHistory: SatelliteImage[] = [];
const SAT_MAX_IMAGES = 100; const SAT_MAX_IMAGES = 100;
const SAT_PRED_PAGE_SIZE = 50; const SAT_PRED_PAGE_SIZE = 50;
let satPredShowAll = false; let satPredShowAll = false;
let satFilterText = ""; let satFilterText = "";
let satActiveView = "live"; // "live" | "history" | "predictions" let satActiveView: SatelliteView = "live";
let satPredData = []; let satPredData: SatellitePass[] = [];
let satPredFilterText = ""; let satPredFilterText = "";
let satPredMinEl = 0; let satPredMinEl = 0;
let satPredCategory = "all"; let satPredCategory = "all";
let satPredSatCount = 0; let satPredSatCount = 0;
let satPredCountdownTimer = null; let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
// ── UI scheduler helper ───────────────────────────────────────────── // ── UI scheduler helper ─────────────────────────────────────────────
function scheduleSatUi(key, job) { function scheduleSatUi(key: string, job: () => void): void {
if (typeof window.trxScheduleUiFrameJob === "function") { if (typeof satWindow.trxScheduleUiFrameJob === "function") {
window.trxScheduleUiFrameJob(key, job); satWindow.trxScheduleUiFrameJob(key, job);
return; return;
} }
job(); job();
} }
// ── View switching ────────────────────────────────────────────────── // ── View switching ──────────────────────────────────────────────────
function switchSatView(view) { function switchSatView(view: SatelliteView): void {
const leavingPredictions = satActiveView === "predictions" && view !== "predictions"; 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";
@@ -71,7 +93,7 @@ function switchSatView(view) {
renderSatHistoryTable(); renderSatHistoryTable();
} else if (view === "predictions") { } else if (view === "predictions") {
satPredShowAll = false; satPredShowAll = false;
loadSatPredictions(); void loadSatPredictions();
} }
} }
@@ -80,19 +102,19 @@ function clearPredictionDom() {
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
} }
window.clearSatPredictionDom = clearPredictionDom; 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")); satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
// ── Live view: decoder state ──────────────────────────────────────── // ── Live view: decoder state ────────────────────────────────────────
let _lastSatLrptOn = null; let lastSatLrptOn: boolean | null = null;
window.updateSatLiveState = function (update) { satWindow.updateSatLiveState = function (update: SatelliteLiveUpdate) {
if (!satDom.lrptState) return; if (!satDom.lrptState) return;
const lrptOn = !!update.lrpt_decode_enabled; const lrptOn = !!update.lrpt_decode_enabled;
if (lrptOn !== _lastSatLrptOn) { if (lrptOn !== lastSatLrptOn) {
_lastSatLrptOn = lrptOn; lastSatLrptOn = lrptOn;
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle"; satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle"); satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
if (satDom.status) { if (satDom.status) {
@@ -114,7 +136,7 @@ function renderSatLatestCard() {
} }
const img = satImageHistory[0]; const img = satImageHistory[0];
const decoder = img._decoder || "unknown"; if (!img) return;
const typeName = "Meteor LRPT"; const typeName = "Meteor LRPT";
const satellite = img.satellite || ""; const satellite = img.satellite || "";
const channels = img.channels || img.channel_a || ""; const channels = img.channels || img.channel_a || "";
@@ -123,7 +145,7 @@ function renderSatLatestCard() {
const ts = img._ts || "--"; const ts = img._ts || "--";
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : ""; const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
let meta = [typeName]; const meta = [typeName];
if (satellite) meta.push(satellite); if (satellite) meta.push(satellite);
if (channels) meta.push(channels); if (channels) meta.push(channels);
meta.push(`${lines} ${unit}`); meta.push(`${lines} ${unit}`);
@@ -143,7 +165,7 @@ function renderSatLatestCard() {
} }
// ── History view: table ───────────────────────────────────────────── // ── History view: table ─────────────────────────────────────────────
function getSatFilteredHistory() { function getSatFilteredHistory(): SatelliteImage[] {
let items = satImageHistory; let items = satImageHistory;
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all"; const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
@@ -168,11 +190,10 @@ function getSatFilteredHistory() {
return items; return items;
} }
function renderSatHistoryRow(img) { function renderSatHistoryRow(img: SatelliteImage): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sat-history-row"; row.className = "sat-history-row";
const decoder = img._decoder || "unknown";
const typeName = "Meteor LRPT"; const typeName = "Meteor LRPT";
const typeClass = "sat-type-lrpt"; const typeClass = "sat-type-lrpt";
const ts = img._ts || "--"; const ts = img._ts || "--";
@@ -204,8 +225,8 @@ function renderSatHistoryTable() {
if (!satDom.historyList) return; if (!satDom.historyList) return;
const items = getSatFilteredHistory(); const items = getSatFilteredHistory();
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
for (let i = 0; i < items.length; i += 1) { for (const image of items) {
fragment.appendChild(renderSatHistoryRow(items[i])); fragment.appendChild(renderSatHistoryRow(image));
} }
satDom.historyList.replaceChildren(fragment); satDom.historyList.replaceChildren(fragment);
@@ -222,7 +243,7 @@ function renderSatHistoryTable() {
} }
// ── Add image to history ──────────────────────────────────────────── // ── Add image to history ────────────────────────────────────────────
function addSatImage(img, decoder) { function addSatImage(img: SatelliteImage, decoder: "lrpt"): void {
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now(); const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
img._tsMs = tsMs; img._tsMs = tsMs;
img._ts = new Date(tsMs).toLocaleTimeString([], { img._ts = new Date(tsMs).toLocaleTimeString([], {
@@ -237,102 +258,103 @@ function addSatImage(img, decoder) {
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES); satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
} }
scheduleSatUi("sat-latest", () => renderSatLatestCard()); scheduleSatUi("sat-latest", () => { renderSatLatestCard(); });
if (satActiveView === "history") { if (satActiveView === "history") {
scheduleSatUi("sat-history", () => renderSatHistoryTable()); scheduleSatUi("sat-history", () => { renderSatHistoryTable(); });
} }
} }
// ── Server callbacks ──────────────────────────────────────────────── // ── Server callbacks ────────────────────────────────────────────────
window.onServerLrptProgress = function (msg) { satWindow.onServerLrptProgress = function (msg: LrptProgress) {
if (satDom.status && msg.mcu_count > 0) { if (satDom.status && (msg.mcu_count ?? 0) > 0) {
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded"; satDom.status.textContent = `Receiving \u2014 ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
} }
}; };
window.onServerLrptImage = function (msg) { satWindow.onServerLrptImage = function (msg: SatelliteImage) {
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)"; if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
addSatImage(msg, "lrpt"); addSatImage(msg, "lrpt");
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) { if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
window.addSatMapOverlay(msg); satWindow.addSatMapOverlay(msg);
} }
}; };
window.resetSatHistoryView = function () { satWindow.resetSatHistoryView = function () {
satImageHistory = []; satImageHistory = [];
if (satDom.historyList) satDom.historyList.innerHTML = ""; if (satDom.historyList) satDom.historyList.innerHTML = "";
renderSatLatestCard(); renderSatLatestCard();
renderSatHistoryTable(); renderSatHistoryTable();
if (window.clearSatMapOverlays) window.clearSatMapOverlays(); satWindow.clearSatMapOverlays?.();
}; };
window.pruneSatHistoryView = function () { satWindow.pruneSatHistoryView = function () {
renderSatHistoryTable(); renderSatHistoryTable();
renderSatLatestCard(); renderSatLatestCard();
}; };
// ── Toggle buttons ────────────────────────────────────────────────── // ── Toggle buttons ──────────────────────────────────────────────────
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn"); const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
lrptDecodeToggleBtn?.addEventListener("click", async () => { lrptDecodeToggleBtn?.addEventListener("click", () => { void (async () => {
try { try {
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn); await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
await postPath("/toggle_lrpt_decode"); await satWindow.postPath?.("/toggle_lrpt_decode");
} catch (e) { } catch (e) {
console.error("LRPT toggle failed", e); console.error("LRPT toggle failed", e);
} }
}); })(); });
// ── Filter / sort event listeners ─────────────────────────────────── // ── Filter / sort event listeners ───────────────────────────────────
satDom.filterInput?.addEventListener("input", () => { const satFilterInput = satDom.filterInput;
satFilterText = satDom.filterInput.value.trim().toUpperCase(); satFilterInput?.addEventListener("input", () => {
satFilterText = satFilterInput.value.trim().toUpperCase();
renderSatHistoryTable(); renderSatHistoryTable();
}); });
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable()); satDom.sortSelect?.addEventListener("change", () => { renderSatHistoryTable(); });
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable()); satDom.typeFilter?.addEventListener("change", () => { renderSatHistoryTable(); });
// ── Settings: clear history ───────────────────────────────────────── // ── Settings: clear history ─────────────────────────────────────────
document document
.getElementById("settings-clear-sat-history") .getElementById("settings-clear-sat-history")
?.addEventListener("click", async () => { ?.addEventListener("click", () => { void (async () => {
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return; if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
try { try {
await postPath("/clear_lrpt_decode"); await satWindow.postPath?.("/clear_lrpt_decode");
window.resetSatHistoryView(); satWindow.resetSatHistoryView?.();
} catch (e) { } catch (e) {
console.error("Weather satellite history clear failed", e); console.error("Weather satellite history clear failed", e);
} }
}); })(); });
// ── Predictions: helpers ──────────────────────────────────────────── // ── Predictions: helpers ────────────────────────────────────────────
function azToCardinal(deg) { function azToCardinal(deg: number): string {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]; const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8]; return dirs[Math.round(deg / 45) % 8] ?? "N";
} }
function formatPredTime(ms) { function formatPredTime(ms: number): string {
const d = new Date(ms); const d = new Date(ms);
const now = new Date(); const now = new Date();
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const day = d.getUTCDay() !== now.getUTCDay() ? dayNames[d.getUTCDay()] + " " : ""; const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
const hh = String(d.getUTCHours()).padStart(2, "0"); const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(d.getUTCMinutes()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0");
return `${day}${hh}:${mm}`; return `${day}${hh}:${mm}`;
} }
function formatPredDuration(s) { function formatPredDuration(s: number): string {
if (s >= 60) return `${Math.round(s / 60)} min`; if (s >= 60) return `${Math.round(s / 60)} min`;
return `${s}s`; return `${s}s`;
} }
function formatCountdown(ms) { function formatCountdown(ms: number): string {
const totalSec = Math.max(0, Math.floor(ms / 1000)); const totalSec = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSec / 60); const m = Math.floor(totalSec / 60);
const s = totalSec % 60; const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`;
} }
function elevationClass(deg) { function elevationClass(deg: number): string {
if (deg >= 45) return "sat-pred-el-high"; if (deg >= 45) return "sat-pred-el-high";
if (deg >= 10) return "sat-pred-el-mid"; if (deg >= 10) return "sat-pred-el-mid";
return "sat-pred-el-low"; return "sat-pred-el-low";
@@ -346,8 +368,8 @@ function stopCountdownTimer() {
} }
} }
function startCountdownTimer(container) { function startCountdownTimer(container: HTMLElement | null): void {
const countdownEls = container ? container.querySelectorAll(".sat-pred-col-countdown") : []; const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
if (countdownEls.length === 0) return; if (countdownEls.length === 0) return;
satPredCountdownTimer = setInterval(() => { satPredCountdownTimer = setInterval(() => {
@@ -358,7 +380,7 @@ function startCountdownTimer(container) {
const n = Date.now(); const n = Date.now();
let anyActive = false; let anyActive = false;
for (const el of countdownEls) { for (const el of countdownEls) {
const los = parseInt(el.dataset.los, 10); const los = Number.parseInt(el.dataset.los ?? "0", 10);
const rem = los - n; const rem = los - n;
if (rem > 0) { if (rem > 0) {
el.textContent = formatCountdown(rem); el.textContent = formatCountdown(rem);
@@ -375,7 +397,7 @@ function startCountdownTimer(container) {
} }
// ── Predictions: row builders ─────────────────────────────────────── // ── Predictions: row builders ───────────────────────────────────────
function buildCurrentPassRow(pass, now) { function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sat-pred-row-current"; row.className = "sat-pred-row-current";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
@@ -391,7 +413,7 @@ function buildCurrentPassRow(pass, now) {
return row; return row;
} }
function buildUpcomingPassRow(pass) { function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "sat-pred-row"; row.className = "sat-pred-row";
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`; const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
@@ -406,7 +428,7 @@ function buildUpcomingPassRow(pass) {
} }
// ── Predictions: filter state ─────────────────────────────────────── // ── Predictions: filter state ───────────────────────────────────────
function getFilteredPredictions() { function getFilteredPredictions(): SatellitePass[] {
let items = satPredData; let items = satPredData;
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory); if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl); if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
@@ -418,23 +440,26 @@ function applyPredFilters() {
renderSatPredictions(getFilteredPredictions()); renderSatPredictions(getFilteredPredictions());
} }
satDom.predFilter?.addEventListener("input", () => { const satPredictionFilter = satDom.predFilter;
satPredFilterText = satDom.predFilter.value.trim().toUpperCase(); satPredictionFilter?.addEventListener("input", () => {
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
applyPredFilters(); applyPredFilters();
}); });
satDom.predMinEl?.addEventListener("change", () => { const satPredictionMinElevation = satDom.predMinEl;
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0; satPredictionMinElevation?.addEventListener("change", () => {
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
applyPredFilters(); applyPredFilters();
}); });
satDom.predCategory?.addEventListener("change", () => { const satPredictionCategory = satDom.predCategory;
satPredCategory = satDom.predCategory.value; satPredictionCategory?.addEventListener("change", () => {
satPredCategory = satPredictionCategory.value;
applyPredFilters(); applyPredFilters();
}); });
// ── Predictions: main render ──────────────────────────────────────── // ── Predictions: main render ────────────────────────────────────────
function renderSatPredictions(passes, error) { function renderSatPredictions(passes: SatellitePass[], error?: string): void {
stopCountdownTimer(); stopCountdownTimer();
if (error) { if (error) {
@@ -508,14 +533,14 @@ function renderSatPredictions(passes, error) {
} }
// ── Predictions: data loading ─────────────────────────────────────── // ── Predictions: data loading ───────────────────────────────────────
async function loadSatPredictions() { async function loadSatPredictions(): Promise<void> {
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026"; if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = ""; if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = ""; if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
try { try {
const resp = await fetch("/sat_passes"); const resp = await fetch("/sat_passes");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const data = await resp.json() as SatellitePassResponse;
satPredSatCount = data.satellite_count || 0; satPredSatCount = data.satellite_count || 0;
if (data.error) { if (data.error) {
satPredData = []; satPredData = [];
@@ -524,20 +549,20 @@ async function loadSatPredictions() {
satPredData = data.passes || []; satPredData = data.passes || [];
renderSatPredictions(getFilteredPredictions()); renderSatPredictions(getFilteredPredictions());
} }
} catch (e) { } catch (error: unknown) {
renderSatPredictions([], `Failed to load predictions: ${e.message}`); 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 ──────────────
window.satShowOnMap = function (south, west, north, east) { satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
if (typeof window.enableMapSourceFilter === "function") { if (typeof satWindow.enableMapSourceFilter === "function") {
window.enableMapSourceFilter("sat"); satWindow.enableMapSourceFilter("sat");
} }
const lat = (south + north) / 2; const lat = (south + north) / 2;
const lon = (west + east) / 2; const lon = (west + east) / 2;
if (window.navigateToAprsMap) { if (satWindow.navigateToAprsMap) {
window.navigateToAprsMap(lat, lon); satWindow.navigateToAprsMap(lat, lon);
} }
}; };
@@ -0,0 +1,55 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
export interface SatelliteImage {
ts_ms?: number | null;
satellite?: string;
channels?: string;
channel_a?: string;
channel_b?: string;
mcu_count?: number;
line_count?: number;
path?: string;
geo_bounds?: [number, number, number, number];
_tsMs?: number;
_ts?: string;
_decoder?: "lrpt";
}
export interface LrptProgress { mcu_count?: number }
export interface SatelliteLiveUpdate { lrpt_decode_enabled?: boolean }
export interface SatellitePass {
satellite: string;
category: string;
aos_ms: number;
los_ms: number;
duration_s: number;
azimuth_aos_deg: number;
azimuth_los_deg: number;
max_elevation_deg: number;
}
export interface SatellitePassResponse {
satellite_count?: number;
error?: string | null;
passes?: SatellitePass[];
}
export interface SatelliteScheduleEntry {
id: string;
satellite: string;
norad_id: number;
bookmark_id: string;
min_elevation_deg: number;
priority: number;
center_hz: number | null;
bookmark_ids: string[];
}
export interface SatelliteScheduleConfig {
enabled: boolean;
pretune_secs: number;
entries: SatelliteScheduleEntry[];
}
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import vm from "node:vm";
test("satellite entry registers lifecycle callbacks and forwards georeferenced images", async () => {
const overlays = [];
const window = {
trxUi: { confirm: async () => true },
addSatMapOverlay: (image) => { overlays.push(image); },
};
const context = vm.createContext({
window,
document: { getElementById: () => null, createDocumentFragment: () => ({ appendChild() {} }) },
fetch: async () => ({ ok: true, json: async () => ({ satellite_count: 0, passes: [] }) }),
setInterval: () => 1,
clearInterval() {},
Date,
Number,
String,
Array,
Error,
console,
});
const source = await readFile(new URL("../../assets/web/generated/sat.js", import.meta.url), "utf8");
new vm.Script(source).runInContext(context);
window.onServerLrptImage({ path: "/images/lrpt.png", geo_bounds: [50, 10, 55, 20], mcu_count: 100 });
assert.equal(overlays.length, 1);
assert.deepEqual(overlays[0].geo_bounds, [50, 10, 55, 20]);
assert.equal(typeof window.updateSatLiveState, "function");
assert.equal(typeof window.resetSatHistoryView, "function");
});