/**
* Weather Satellite Mode
* Meteor LRPT decoder interface with auto-scheduler,
* polar plot, styled real-world map, countdown, and timeline.
*/
const WeatherSat = (function() {
const METEOR_NORAD_IDS = {
'METEOR-M2-3': 57166,
'METEOR-M2-4': 59051,
};
// State
let isRunning = false;
let eventSource = null;
let images = [];
let allPasses = [];
let passes = [];
let selectedPassIndex = -1;
let currentSatellite = null;
let countdownInterval = null;
let schedulerEnabled = false;
let groundMap = null;
let groundTrackLayer = null;
let groundOverlayLayer = null;
let groundGridLayer = null;
let satCrosshairMarker = null;
let observerMarker = null;
let consoleEntries = [];
let consoleCollapsed = false;
let currentPhase = 'idle';
let consoleAutoHideTimer = null;
let currentModalFilename = null;
let locationListenersAttached = false;
let initialized = false;
let imageRefreshInterval = null;
let lastDecodeJobSignature = null;
let lastDecodeSatellite = null;
let consoleFilter = 'all';
// Timezone support
const TZ_MAP = {
'UTC': 'UTC',
'local': null, // browser default
'US/Eastern': 'America/New_York',
'US/Central': 'America/Chicago',
'US/Mountain': 'America/Denver',
'US/Pacific': 'America/Los_Angeles',
};
let selectedTimezone = localStorage.getItem('wxsatTimezone') || 'UTC';
/**
* Format an ISO date string for the selected timezone.
* @param {string} isoString - ISO 8601 date string
* @param {object} [opts] - Additional Intl.DateTimeFormat options
* @returns {string} Formatted date/time string
*/
function formatTimeForTZ(isoString, opts = {}) {
if (!isoString) return '--';
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
const tz = TZ_MAP[selectedTimezone];
const defaults = { hour: '2-digit', minute: '2-digit', hour12: false };
const options = { ...defaults, ...opts };
if (tz) options.timeZone = tz;
return date.toLocaleString(undefined, options);
} catch {
return isoString;
}
}
/**
* Format a short time (HH:MM) for the selected timezone.
*/
function formatShortTime(isoString) {
return formatTimeForTZ(isoString, { hour: '2-digit', minute: '2-digit', hour12: false });
}
/**
* Format date + time for the selected timezone.
*/
function formatDateTime(isoString) {
return formatTimeForTZ(isoString, {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', hour12: false
});
}
/**
* Get a short timezone label for display.
*/
function getTZLabel() {
if (selectedTimezone === 'local') return '';
if (selectedTimezone === 'UTC') return ' UTC';
const labels = { 'US/Eastern': ' ET', 'US/Central': ' CT', 'US/Mountain': ' MT', 'US/Pacific': ' PT' };
return labels[selectedTimezone] || '';
}
/**
* Set timezone and refresh all displays.
*/
function setTimezone(tz) {
selectedTimezone = tz;
localStorage.setItem('wxsatTimezone', tz);
const sel = document.getElementById('wxsatTimezone');
if (sel && sel.value !== tz) sel.value = tz;
// Refresh all time-dependent displays
applyPassFilter();
renderGallery();
updateTimelineLabels();
}
/**
* Convert an azimuth angle (0-360) to a cardinal direction label.
*/
function azToDir(az) {
if (typeof az !== 'number' || isNaN(az)) return '?';
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return dirs[Math.round(az / 22.5) % 16];
}
/**
* Find the best upcoming pass (highest max elevation).
*/
function findBestPass(passList) {
const now = new Date();
const upcoming = passList.filter(p => {
const end = parsePassDate(p.endTimeISO);
return end && end > now;
});
if (upcoming.length === 0) return null;
return upcoming.reduce((best, p) => (p.maxEl > best.maxEl) ? p : best, upcoming[0]);
}
/**
* Initialize the Weather Satellite mode
*/
function init() {
// Restore timezone selector
const tzSel = document.getElementById('wxsatTimezone');
if (tzSel) tzSel.value = selectedTimezone;
if (initialized) {
checkStatus();
loadImages();
loadLocationInputs();
loadPasses();
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
loadLatestDecodeJob();
return;
}
initialized = true;
checkStatus();
loadImages();
loadLocationInputs();
loadPasses();
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
ensureImageRefresh();
loadLatestDecodeJob();
}
/**
* Get passes filtered by the currently selected satellite.
*/
function getFilteredPasses() {
const satSelect = document.getElementById('weatherSatSelect');
const selected = satSelect?.value;
if (!selected) return passes;
return passes.filter(p => p.satellite === selected);
}
/**
* Re-render passes, timeline, countdown and polar plot using filtered list.
*/
function applyPassFilter() {
const filtered = getFilteredPasses();
selectedPassIndex = -1;
renderPasses(filtered);
renderTimeline(filtered);
updateCountdownFromPasses();
if (filtered.length > 0) {
selectPass(0);
} else {
updateGroundTrack(null);
}
}
/**
* Get observer coordinates from shared location or local storage.
*/
function getObserverCoords() {
let lat;
let lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
lat = Number(shared?.lat);
lon = Number(shared?.lon);
} else {
lat = Number(localStorage.getItem('observerLat'));
lon = Number(localStorage.getItem('observerLon'));
}
if (!isFinite(lat) || !isFinite(lon)) return null;
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
return { lat, lon };
}
/**
* Center the ground map on current observer coordinates when available.
*/
function centerGroundMapOnObserver(zoom = 1) {
if (!groundMap) return;
const observer = getObserverCoords();
if (!observer) return;
const lat = Math.max(-85, Math.min(85, observer.lat));
const lon = normalizeLon(observer.lon);
groundMap.setView([lat, lon], zoom, { animate: false });
}
/**
* Load observer location into input fields
*/
function loadLocationInputs() {
const latInput = document.getElementById('wxsatObsLat');
const lonInput = document.getElementById('wxsatObsLon');
let storedLat = localStorage.getItem('observerLat');
let storedLon = localStorage.getItem('observerLon');
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared.lat.toString();
storedLon = shared.lon.toString();
}
if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon;
// Only attach listeners once — re-calling init() on mode switch must not
// accumulate duplicate listeners that fire loadPasses() multiple times.
if (!locationListenersAttached) {
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.addEventListener('change', () => {
resetDecodeJobDisplay();
applyPassFilter();
loadImages();
loadLatestDecodeJob();
});
}
locationListenersAttached = true;
}
}
/**
* Save location from inputs and refresh passes
*/
function saveLocationFromInputs() {
const latInput = document.getElementById('wxsatObsLat');
const lonInput = document.getElementById('wxsatObsLon');
const lat = parseFloat(latInput?.value);
const lon = parseFloat(lonInput?.value);
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
!isNaN(lon) && lon >= -180 && lon <= 180) {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
} else {
localStorage.setItem('observerLat', lat.toString());
localStorage.setItem('observerLon', lon.toString());
}
loadPasses();
centerGroundMapOnObserver(1);
}
}
/**
* Use GPS for location
*/
function useGPS(btn) {
if (!navigator.geolocation) {
showNotification('Weather Sat', 'GPS not available in this browser');
return;
}
const originalText = btn.innerHTML;
btn.innerHTML = '...';
btn.disabled = true;
navigator.geolocation.getCurrentPosition(
(pos) => {
const latInput = document.getElementById('wxsatObsLat');
const lonInput = document.getElementById('wxsatObsLon');
const lat = pos.coords.latitude.toFixed(4);
const lon = pos.coords.longitude.toFixed(4);
if (latInput) latInput.value = lat;
if (lonInput) lonInput.value = lon;
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
} else {
localStorage.setItem('observerLat', lat);
localStorage.setItem('observerLon', lon);
}
btn.innerHTML = originalText;
btn.disabled = false;
showNotification('Weather Sat', 'Location updated');
loadPasses();
centerGroundMapOnObserver(1);
},
(err) => {
btn.innerHTML = originalText;
btn.disabled = false;
showNotification('Weather Sat', 'Failed to get location');
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
/**
* Check decoder status
*/
async function checkStatus() {
try {
const response = await fetch('/weather-sat/status');
const data = await response.json();
if (!data.available) {
updateStatusUI('unavailable', 'SatDump not installed');
return;
}
if (data.running) {
isRunning = true;
currentSatellite = data.satellite;
updateStatusUI('capturing', `Capturing ${data.satellite}...`);
startStream();
} else {
updateStatusUI('idle', 'Idle');
}
} catch (err) {
console.error('Failed to check weather sat status:', err);
}
}
/**
* Start capture
*/
async function start() {
const satSelect = document.getElementById('weatherSatSelect');
const gainInput = document.getElementById('weatherSatGain');
const biasTInput = document.getElementById('weatherSatBiasT');
const deviceSelect = document.getElementById('deviceSelect');
const satellite = satSelect?.value || 'METEOR-M2-3';
const gain = parseFloat(gainInput?.value || '40');
const biasT = biasTInput?.checked || false;
const device = parseInt(deviceSelect?.value || '0', 10);
clearConsole();
showConsole(true);
updatePhaseIndicator('tuning');
addConsoleEntry('Starting capture...', 'info');
updateStatusUI('connecting', 'Starting...');
const startBtn = document.getElementById('weatherSatStartBtn');
if (startBtn) startBtn.classList.add('btn-loading');
try {
const config = {
satellite,
device,
gain,
bias_t: biasT,
sdr_type: typeof getSelectedSDRType === 'function' ? getSelectedSDRType() : 'rtlsdr',
};
// Add rtl_tcp params if using remote SDR
if (typeof getRemoteSDRConfig === 'function') {
var remoteConfig = getRemoteSDRConfig();
if (remoteConfig) {
config.rtl_tcp_host = remoteConfig.host;
config.rtl_tcp_port = remoteConfig.port;
}
}
const response = await fetch('/weather-sat/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
currentSatellite = data.satellite || satellite;
updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`);
updateFreqDisplay(data.frequency, data.mode);
startStream();
showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`);
} else {
updateStatusUI('idle', 'Start failed');
showNotification('Weather Sat', data.message || 'Failed to start');
}
} catch (err) {
console.error('Failed to start weather sat:', err);
reportActionableError('Start Weather Satellite', err, {
onRetry: () => start()
});
updateStatusUI('idle', 'Error');
} finally {
if (startBtn) startBtn.classList.remove('btn-loading');
}
}
/**
* Pre-select a satellite without starting capture.
* Used by the satellite dashboard handoff so the user can review
* settings before hitting Start.
*/
function preSelect(satellite) {
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.value = satellite;
satSelect.dispatchEvent(new Event('change'));
}
}
/**
* Start capture for a specific pass
*/
function startPass(satellite) {
const satSelect = document.getElementById('weatherSatSelect');
if (satSelect) {
satSelect.value = satellite;
satSelect.dispatchEvent(new Event('change'));
}
start();
}
/**
* Stop capture
*/
async function stop() {
// Optimistically update UI immediately so stop feels responsive,
// even if the server takes time to terminate the process.
isRunning = false;
stopStream();
updateStatusUI('idle', 'Stopping...');
try {
await fetch('/weather-sat/stop', { method: 'POST' });
updateStatusUI('idle', 'Stopped');
showNotification('Weather Sat', 'Capture stopped');
} catch (err) {
console.error('Failed to stop weather sat:', err);
reportActionableError('Stop Weather Satellite', err);
}
}
/**
* Start test decode from a pre-recorded file
*/
async function testDecode() {
const satSelect = document.getElementById('wxsatTestSatSelect');
const fileInput = document.getElementById('wxsatTestFilePath');
const rateSelect = document.getElementById('wxsatTestSampleRate');
const satellite = satSelect?.value || 'METEOR-M2-3';
const inputFile = (fileInput?.value || '').trim();
const sampleRate = parseInt(rateSelect?.value || '1000000', 10);
if (!inputFile) {
showNotification('Weather Sat', 'Enter a file path');
return;
}
clearConsole();
showConsole(true);
updatePhaseIndicator('decoding');
addConsoleEntry(`Test decode: ${inputFile}`, 'info');
updateStatusUI('connecting', 'Starting file decode...');
try {
const response = await fetch('/weather-sat/test-decode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
satellite,
input_file: inputFile,
sample_rate: sampleRate,
})
});
const data = await response.json();
if (data.status === 'started' || data.status === 'already_running') {
isRunning = true;
currentSatellite = data.satellite || satellite;
updateStatusUI('decoding', `Decoding ${data.satellite} from file`);
updateFreqDisplay(data.frequency, data.mode);
startStream();
showNotification('Weather Sat', `Decoding ${data.satellite} from file`);
} else {
updateStatusUI('idle', 'Decode failed');
showNotification('Weather Sat', data.message || 'Failed to start decode');
addConsoleEntry(data.message || 'Failed to start decode', 'error');
}
} catch (err) {
console.error('Failed to start test decode:', err);
reportActionableError('Start Test Decode', err, {
onRetry: () => testDecode()
});
updateStatusUI('idle', 'Error');
}
}
/**
* Update status UI
*/
function updateStatusUI(status, text) {
const dot = document.getElementById('wxsatStripDot');
const statusText = document.getElementById('wxsatStripStatus');
const startBtn = document.getElementById('wxsatStartBtn');
const stopBtn = document.getElementById('wxsatStopBtn');
if (dot) {
dot.className = 'wxsat-strip-dot';
if (status === 'capturing') dot.classList.add('capturing');
else if (status === 'decoding') dot.classList.add('decoding');
}
if (statusText) statusText.textContent = text || status;
if (startBtn && stopBtn) {
if (status === 'capturing' || status === 'decoding') {
startBtn.style.display = 'none';
stopBtn.style.display = 'inline-block';
} else {
startBtn.style.display = 'inline-block';
stopBtn.style.display = 'none';
}
}
}
/**
* Update frequency display in strip
*/
function updateFreqDisplay(freq, mode) {
const freqEl = document.getElementById('wxsatStripFreq');
const modeEl = document.getElementById('wxsatStripMode');
if (freqEl) freqEl.textContent = freq || '--';
if (modeEl) modeEl.textContent = mode || '--';
}
/**
* Start SSE stream
*/
function startStream() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/weather-sat/stream');
eventSource.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'weather_sat_progress') {
handleProgress(data);
} else if (data.type && data.type.startsWith('schedule_')) {
handleSchedulerSSE(data);
}
} catch (err) {
console.error('Failed to parse SSE:', err);
}
};
eventSource.onerror = () => {
// Close the failed connection first to avoid leaking it
stopStream();
setTimeout(() => {
if (isRunning || schedulerEnabled) startStream();
}, 3000);
};
}
/**
* Stop SSE stream
*/
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
/**
* Handle progress update
*/
function handleProgress(data) {
const captureStatus = document.getElementById('wxsatCaptureStatus');
const captureMsg = document.getElementById('wxsatCaptureMsg');
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
const progressBar = document.getElementById('wxsatProgressFill');
if (data.status === 'capturing' || data.status === 'decoding') {
updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`);
if (captureStatus) captureStatus.classList.add('active');
if (captureMsg) captureMsg.textContent = data.message || '';
if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0);
if (progressBar) progressBar.style.width = (data.progress || 0) + '%';
// Console updates
showConsole(true);
if (data.message) addConsoleEntry(data.message, data.log_type || 'info');
if (data.capture_phase) updatePhaseIndicator(data.capture_phase);
} else if (data.status === 'complete') {
if (data.image) {
images.unshift(data.image);
updateImageCount(images.length);
renderGallery();
showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`);
}
if (!data.image) {
// Capture ended
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Capture complete');
if (captureStatus) captureStatus.classList.remove('active');
addConsoleEntry('Capture complete', 'signal');
updatePhaseIndicator('complete');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
}
} else if (data.status === 'error') {
isRunning = false;
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Error');
showNotification('Weather Sat', data.message || 'Capture error');
if (captureStatus) captureStatus.classList.remove('active');
if (data.message) addConsoleEntry(data.message, 'error');
updatePhaseIndicator('error');
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
loadImages();
}
}
/**
* Handle scheduler SSE events
*/
function handleSchedulerSSE(data) {
if (data.type === 'schedule_capture_start') {
isRunning = true;
const p = data.pass || {};
currentSatellite = p.satellite;
updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
} else if (data.type === 'schedule_capture_complete') {
const p = data.pass || {};
showNotification('Weather Sat', `Auto-capture complete: ${p.name || ''}`);
// Reset UI — the decoder's stop() doesn't emit a progress complete event
// when called internally by the scheduler, so we handle it here.
isRunning = false;
updateStatusUI('idle', 'Auto-capture complete');
const captureStatus = document.getElementById('wxsatCaptureStatus');
if (captureStatus) captureStatus.classList.remove('active');
updatePhaseIndicator('complete');
loadImages();
loadPasses();
} else if (data.type === 'schedule_capture_skipped') {
const reason = data.reason || 'unknown';
const p = data.pass || {};
showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`);
}
}
/**
* Format elapsed seconds
*/
function formatElapsed(seconds) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/**
* Parse pass timestamps, accepting legacy malformed UTC strings (+00:00Z).
*/
function parsePassDate(value) {
if (!value || typeof value !== 'string') return null;
let parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
// Backward-compatible cleanup for accidentally double-suffixed UTC timestamps.
parsed = new Date(value.replace(/\+00:00Z$/, 'Z'));
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
return null;
}
/**
* Load pass predictions (with trajectory + ground track)
*/
async function loadPasses() {
let storedLat, storedLon;
// Use ObserverLocation if available, otherwise fall back to localStorage
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
storedLat = shared?.lat?.toString();
storedLon = shared?.lon?.toString();
} else {
storedLat = localStorage.getItem('observerLat');
storedLon = localStorage.getItem('observerLon');
}
if (!storedLat || !storedLon) {
allPasses = [];
passes = [];
selectedPassIndex = -1;
renderPasses([]);
renderTimeline([]);
updateCountdownFromPasses();
updatePassAnalysis([]);
updateGroundTrack(null);
const passCountEl = document.getElementById('wxsatStripPassCount');
if (passCountEl) passCountEl.textContent = '0';
return;
}
try {
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=48&min_elevation=5&trajectory=true&ground_track=true`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'ok') {
allPasses = data.passes || [];
applyPassFilter();
}
} catch (err) {
console.error('Failed to load passes:', err);
}
}
/**
* Filter displayed passes by the currently selected satellite dropdown value.
* Updates the module-level `passes` from `allPasses` so selectPass/countdown work.
*/
function applyPassFilter() {
const satSelect = document.getElementById('weatherSatSelect');
const selected = satSelect?.value;
passes = selected
? allPasses.filter(p => p.satellite === selected)
: allPasses.slice();
selectedPassIndex = -1;
renderPasses(passes);
renderTimeline(passes);
updateTimelineLabels();
updateCountdownFromPasses();
updatePassAnalysis(passes);
// Update strip pass count
const passCountEl = document.getElementById('wxsatStripPassCount');
if (passCountEl) passCountEl.textContent = passes.length;
if (passes.length > 0) {
selectPass(0);
} else {
updateGroundTrack(null);
drawPolarPlot(null);
}
}
/**
* Select a pass to display in polar plot and map
*/
function selectPass(index) {
const filtered = getFilteredPasses();
if (index < 0 || index >= filtered.length) return;
selectedPassIndex = index;
const pass = filtered[index];
// Highlight active card
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
card.classList.toggle('selected', i === index);
});
// Update polar plot
drawPolarPlot(pass);
// Update ground track
updateGroundTrack(pass);
// Update polar panel subtitle
const polarSat = document.getElementById('wxsatPolarSat');
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
// Update pass geometry detail panel
updatePassGeometry(pass);
}
/**
* Update the AOS/TCA/LOS pass geometry detail panel.
*/
function updatePassGeometry(pass) {
const panel = document.getElementById('wxsatPassGeometry');
if (!panel) return;
if (!pass) {
panel.style.display = 'none';
return;
}
panel.style.display = 'flex';
const aosTime = document.getElementById('wxsatGeomAosTime');
const aosAz = document.getElementById('wxsatGeomAosAz');
const tcaEl = document.getElementById('wxsatGeomTcaEl');
const tcaAz = document.getElementById('wxsatGeomTcaAz');
const losTime = document.getElementById('wxsatGeomLosTime');
const losAz = document.getElementById('wxsatGeomLosAz');
const meta = document.getElementById('wxsatGeomMeta');
const tzLabel = getTZLabel();
if (aosTime) aosTime.textContent = formatShortTime(pass.startTimeISO) + tzLabel;
if (aosAz) aosAz.textContent = `${Math.round(pass.riseAz || 0)}\u00b0 ${azToDir(pass.riseAz)}`;
if (tcaEl) tcaEl.textContent = `${pass.maxEl}\u00b0 el`;
if (tcaAz) tcaAz.textContent = `${Math.round(pass.maxElAz || pass.tcaAz || 0)}\u00b0 ${azToDir(pass.maxElAz || pass.tcaAz)}`;
if (losTime) losTime.textContent = formatShortTime(pass.endTimeISO) + tzLabel;
if (losAz) losAz.textContent = `${Math.round(pass.setAz || 0)}\u00b0 ${azToDir(pass.setAz)}`;
const durMin = Math.round((pass.duration || 0) / 60);
if (meta) meta.textContent = `${durMin} min / ${pass.quality}`;
}
/**
* Render pass predictions list
*/
function renderPasses(passList) {
const container = document.getElementById('wxsatPassesList');
const countEl = document.getElementById('wxsatPassesCount');
if (countEl) countEl.textContent = passList.length;
if (!container) return;
if (passList.length === 0) {
const hasLocation = localStorage.getItem('observerLat') !== null ||
(window.ObserverLocation && ObserverLocation.isSharedEnabled() && ObserverLocation.getShared()?.lat);
container.innerHTML = `
${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
${hasLocation
? 'All Meteor passes may be below the minimum elevation. Try again later.'
: 'Enter lat/lon in the strip bar above or click GPS to load pass predictions'}