diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index b1c7eee..c09ad8a 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -3,14 +3,15 @@ * NOAA APT and Meteor LRPT decoder interface with auto-scheduler, * polar plot, styled real-world map, countdown, and timeline. */ - -const WeatherSat = (function() { - // State - let isRunning = false; - let eventSource = null; - let images = []; - let passes = []; - let selectedPassIndex = -1; + +const WeatherSat = (function() { + // 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; @@ -21,22 +22,22 @@ const WeatherSat = (function() { let satCrosshairMarker = null; let observerMarker = null; let consoleEntries = []; - let consoleCollapsed = false; - let currentPhase = 'idle'; + let consoleCollapsed = false; + let currentPhase = 'idle'; let consoleAutoHideTimer = null; let currentModalFilename = null; let locationListenersAttached = false; - - /** - * Initialize the Weather Satellite mode - */ + + /** + * Initialize the Weather Satellite mode + */ function init() { checkStatus(); loadImages(); loadLocationInputs(); loadPasses(); - startCountdownTimer(); - checkSchedulerStatus(); + startCountdownTimer(); + checkSchedulerStatus(); initGroundMap(); } @@ -78,42 +79,44 @@ const WeatherSat = (function() { */ 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); - 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 }); + 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', applyPassFilter); + 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()); @@ -122,420 +125,421 @@ const WeatherSat = (function() { 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); - } - + + /** + * 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...'); - - try { - const response = await fetch('/weather-sat/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - satellite, - device, - gain, - bias_t: biasT, - }) - }); - - 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); - updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', 'Connection error'); - } - } - - /** - * Start capture for a specific pass - */ - function startPass(satellite) { - const satSelect = document.getElementById('weatherSatSelect'); - if (satSelect) { - satSelect.value = satellite; - } - start(); - } - - /** - * Stop capture - */ - async function stop() { - try { - await fetch('/weather-sat/stop', { method: 'POST' }); - isRunning = false; - stopStream(); - updateStatusUI('idle', 'Stopped'); - showNotification('Weather Sat', 'Capture stopped'); - } catch (err) { - console.error('Failed to stop weather sat:', 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); - updateStatusUI('idle', 'Error'); - showNotification('Weather Sat', 'Connection 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 = () => { - 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); - } - } - - /** - * 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) - */ + (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...'); + + try { + const response = await fetch('/weather-sat/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + satellite, + device, + gain, + bias_t: biasT, + }) + }); + + 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); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', 'Connection error'); + } + } + + /** + * Start capture for a specific pass + */ + function startPass(satellite) { + const satSelect = document.getElementById('weatherSatSelect'); + if (satSelect) { + satSelect.value = satellite; + } + start(); + } + + /** + * Stop capture + */ + async function stop() { + try { + await fetch('/weather-sat/stop', { method: 'POST' }); + isRunning = false; + stopStream(); + updateStatusUI('idle', 'Stopped'); + showNotification('Weather Sat', 'Capture stopped'); + } catch (err) { + console.error('Failed to stop weather sat:', 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); + updateStatusUI('idle', 'Error'); + showNotification('Weather Sat', 'Connection 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 = () => { + 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); + } + } + + /** + * 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'); - } - + 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([]); @@ -544,247 +548,260 @@ const WeatherSat = (function() { updateGroundTrack(null); return; } - - try { - const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; - const response = await fetch(url); - const data = await response.json(); - + + try { + const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`; + const response = await fetch(url); + const data = await response.json(); + if (data.status === 'ok') { - passes = data.passes || []; - selectedPassIndex = -1; - renderPasses(passes); - renderTimeline(passes); - updateCountdownFromPasses(); - // Always select the first upcoming pass so the polar plot - // and ground track reflect the current list after every refresh. - if (passes.length > 0) { - selectPass(0); - } else { - updateGroundTrack(null); - } + allPasses = data.passes || []; + applyPassFilter(); } } catch (err) { console.error('Failed to load passes:', err); } - } - - /** - * Select a pass to display in polar plot and map - */ - function selectPass(index) { - if (index < 0 || index >= passes.length) return; - selectedPassIndex = index; - const pass = passes[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`; - } - - /** - * 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; - container.innerHTML = ` - - `; - return; - } - - container.innerHTML = passList.map((pass, idx) => { - const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; - const timeStr = pass.startTime || '--'; - const now = new Date(); - const passStart = parsePassDate(pass.startTimeISO); - const diffMs = passStart ? passStart - now : NaN; - const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; - const isSelected = idx === selectedPassIndex; - - let countdown = '--'; - if (!Number.isFinite(diffMs)) { - countdown = '--'; - } else if (diffMs < 0) { - countdown = 'NOW'; - } else if (diffMins < 60) { - countdown = `in ${diffMins}m`; - } else { - const hrs = Math.floor(diffMins / 60); - const mins = diffMins % 60; - countdown = `in ${hrs}h${mins}m`; - } - - return ` -
-
- ${escapeHtml(pass.name)} - ${escapeHtml(pass.mode)} -
-
- Time - ${escapeHtml(timeStr)} - Max El - ${pass.maxEl}° - Duration - ${pass.duration} min - Freq - ${pass.frequency} MHz -
-
- ${pass.quality} - ${countdown} -
-
- -
-
- `; - }).join(''); - } - - // ======================== - // Polar Plot - // ======================== - - /** - * Draw polar plot for a pass trajectory - */ - function drawPolarPlot(pass) { - const canvas = document.getElementById('wxsatPolarCanvas'); - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - const w = canvas.width; - const h = canvas.height; - const cx = w / 2; - const cy = h / 2; - const r = Math.min(cx, cy) - 20; - - ctx.clearRect(0, 0, w, h); - - // Background - ctx.fillStyle = '#0d1117'; - ctx.fillRect(0, 0, w, h); - - // Grid circles (30, 60, 90 deg elevation) - ctx.strokeStyle = '#2a3040'; - ctx.lineWidth = 0.5; - [90, 60, 30].forEach((el, i) => { - const gr = r * (1 - el / 90); - ctx.beginPath(); - ctx.arc(cx, cy, gr, 0, Math.PI * 2); - ctx.stroke(); - // Label - ctx.fillStyle = '#555'; - ctx.font = '9px Roboto Condensed, monospace'; - ctx.textAlign = 'left'; - ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); - }); - - // Horizon circle - ctx.strokeStyle = '#3a4050'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.arc(cx, cy, r, 0, Math.PI * 2); - ctx.stroke(); - - // Cardinal directions - ctx.fillStyle = '#666'; - ctx.font = '10px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('N', cx, cy - r - 10); - ctx.fillText('S', cx, cy + r + 10); - ctx.fillText('E', cx + r + 10, cy); - ctx.fillText('W', cx - r - 10, cy); - - // Cross hairs - ctx.strokeStyle = '#2a3040'; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(cx, cy - r); - ctx.lineTo(cx, cy + r); - ctx.moveTo(cx - r, cy); - ctx.lineTo(cx + r, cy); - ctx.stroke(); - - // Trajectory - const trajectory = pass.trajectory; - if (!trajectory || trajectory.length === 0) return; - - const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; - - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = 2; - - trajectory.forEach((pt, i) => { - const elRad = (90 - pt.el) / 90; - const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up - const px = cx + r * elRad * Math.cos(azRad); - const py = cy + r * elRad * Math.sin(azRad); - - if (i === 0) ctx.moveTo(px, py); - else ctx.lineTo(px, py); - }); - ctx.stroke(); - - // Start point (green dot) - const start = trajectory[0]; - const startR = (90 - start.el) / 90; - const startAz = (start.az - 90) * Math.PI / 180; - ctx.fillStyle = '#00ff88'; - ctx.beginPath(); - ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2); - ctx.fill(); - - // End point (red dot) - const end = trajectory[trajectory.length - 1]; - const endR = (90 - end.el) / 90; - const endAz = (end.az - 90) * Math.PI / 180; - ctx.fillStyle = '#ff4444'; - ctx.beginPath(); - ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2); - ctx.fill(); - - // Max elevation marker - let maxEl = 0; - let maxPt = trajectory[0]; - trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } }); - const maxR = (90 - maxPt.el) / 90; - const maxAz = (maxPt.az - 90) * Math.PI / 180; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = color; - ctx.font = '9px Roboto Condensed, monospace'; - ctx.textAlign = 'center'; - ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8); - } - + } + + /** + * 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); + updateCountdownFromPasses(); + if (passes.length > 0) { + selectPass(0); + } else { + updateGroundTrack(null); + drawPolarPlot(null); + } + } + + /** + * Select a pass to display in polar plot and map + */ + function selectPass(index) { + if (index < 0 || index >= passes.length) return; + selectedPassIndex = index; + const pass = passes[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`; + } + + /** + * 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; + container.innerHTML = ` + + `; + return; + } + + container.innerHTML = passList.map((pass, idx) => { + const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt'; + const timeStr = pass.startTime || '--'; + const now = new Date(); + const passStart = parsePassDate(pass.startTimeISO); + const diffMs = passStart ? passStart - now : NaN; + const diffMins = Number.isFinite(diffMs) ? Math.floor(diffMs / 60000) : NaN; + const isSelected = idx === selectedPassIndex; + + let countdown = '--'; + if (!Number.isFinite(diffMs)) { + countdown = '--'; + } else if (diffMs < 0) { + countdown = 'NOW'; + } else if (diffMins < 60) { + countdown = `in ${diffMins}m`; + } else { + const hrs = Math.floor(diffMins / 60); + const mins = diffMins % 60; + countdown = `in ${hrs}h${mins}m`; + } + + return ` +
+
+ ${escapeHtml(pass.name)} + ${escapeHtml(pass.mode)} +
+
+ Time + ${escapeHtml(timeStr)} + Max El + ${pass.maxEl}° + Duration + ${pass.duration} min + Freq + ${pass.frequency} MHz +
+
+ ${pass.quality} + ${countdown} +
+
+ +
+
+ `; + }).join(''); + } + + // ======================== + // Polar Plot + // ======================== + + /** + * Draw polar plot for a pass trajectory + */ + function drawPolarPlot(pass) { + const canvas = document.getElementById('wxsatPolarCanvas'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const w = canvas.width; + const h = canvas.height; + const cx = w / 2; + const cy = h / 2; + const r = Math.min(cx, cy) - 20; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#0d1117'; + ctx.fillRect(0, 0, w, h); + + // Grid circles (30, 60, 90 deg elevation) + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + [90, 60, 30].forEach((el, i) => { + const gr = r * (1 - el / 90); + ctx.beginPath(); + ctx.arc(cx, cy, gr, 0, Math.PI * 2); + ctx.stroke(); + // Label + ctx.fillStyle = '#555'; + ctx.font = '9px Roboto Condensed, monospace'; + ctx.textAlign = 'left'; + ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2); + }); + + // Horizon circle + ctx.strokeStyle = '#3a4050'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.stroke(); + + // Cardinal directions + ctx.fillStyle = '#666'; + ctx.font = '10px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('N', cx, cy - r - 10); + ctx.fillText('S', cx, cy + r + 10); + ctx.fillText('E', cx + r + 10, cy); + ctx.fillText('W', cx - r - 10, cy); + + // Cross hairs + ctx.strokeStyle = '#2a3040'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx, cy + r); + ctx.moveTo(cx - r, cy); + ctx.lineTo(cx + r, cy); + ctx.stroke(); + + // Trajectory + const trajectory = pass?.trajectory; + if (!trajectory || trajectory.length === 0) return; + + const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff'; + + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + + trajectory.forEach((pt, i) => { + const elRad = (90 - pt.el) / 90; + const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up + const px = cx + r * elRad * Math.cos(azRad); + const py = cy + r * elRad * Math.sin(azRad); + + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); + }); + ctx.stroke(); + + // Start point (green dot) + const start = trajectory[0]; + const startR = (90 - start.el) / 90; + const startAz = (start.az - 90) * Math.PI / 180; + ctx.fillStyle = '#00ff88'; + ctx.beginPath(); + ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // End point (red dot) + const end = trajectory[trajectory.length - 1]; + const endR = (90 - end.el) / 90; + const endAz = (end.az - 90) * Math.PI / 180; + ctx.fillStyle = '#ff4444'; + ctx.beginPath(); + ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2); + ctx.fill(); + + // Max elevation marker + let maxEl = 0; + let maxPt = trajectory[0]; + trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } }); + const maxR = (90 - maxPt.el) / 90; + const maxAz = (maxPt.az - 90) * Math.PI / 180; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = color; + ctx.font = '9px Roboto Condensed, monospace'; + ctx.textAlign = 'center'; + ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8); + } + // ======================== // Ground Track Map // ======================== @@ -1121,230 +1138,230 @@ const WeatherSat = (function() { satCrosshairMarker.setTooltipContent(infoText); } } - - // ======================== - // Countdown - // ======================== - - /** - * Start the countdown interval timer - */ - function startCountdownTimer() { - if (countdownInterval) clearInterval(countdownInterval); - countdownInterval = setInterval(updateCountdownFromPasses, 1000); - } - - /** - * Update countdown display from passes array - */ - function updateCountdownFromPasses() { - const now = new Date(); - let nextPass = null; - let isActive = false; - - for (const pass of passes) { - const start = parsePassDate(pass.startTimeISO); - const end = parsePassDate(pass.endTimeISO); - if (!start || !end) { - continue; - } - if (end > now) { - nextPass = pass; - isActive = start <= now; - break; - } - } - - const daysEl = document.getElementById('wxsatCdDays'); - const hoursEl = document.getElementById('wxsatCdHours'); - const minsEl = document.getElementById('wxsatCdMins'); - const secsEl = document.getElementById('wxsatCdSecs'); - const satEl = document.getElementById('wxsatCountdownSat'); - const detailEl = document.getElementById('wxsatCountdownDetail'); - const boxes = document.getElementById('wxsatCountdownBoxes'); - - if (!nextPass) { - if (daysEl) daysEl.textContent = '--'; - if (hoursEl) hoursEl.textContent = '--'; - if (minsEl) minsEl.textContent = '--'; - if (secsEl) secsEl.textContent = '--'; - if (satEl) satEl.textContent = '--'; - if (detailEl) detailEl.textContent = 'No passes predicted'; - if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.remove('imminent', 'active'); - }); - return; - } - - const target = parsePassDate(nextPass.startTimeISO); - if (!target) { - if (daysEl) daysEl.textContent = '--'; - if (hoursEl) hoursEl.textContent = '--'; - if (minsEl) minsEl.textContent = '--'; - if (secsEl) secsEl.textContent = '--'; - if (satEl) satEl.textContent = '--'; - if (detailEl) detailEl.textContent = 'Invalid pass time'; - if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.remove('imminent', 'active'); - }); - return; - } - let diffMs = target - now; - - if (isActive) { - diffMs = 0; - } - - const totalSec = Math.max(0, Math.floor(diffMs / 1000)); - const d = Math.floor(totalSec / 86400); - const h = Math.floor((totalSec % 86400) / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - - if (daysEl) daysEl.textContent = d.toString().padStart(2, '0'); - if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); - if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); - if (secsEl) secsEl.textContent = s.toString().padStart(2, '0'); - if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`; - if (detailEl) { - if (isActive) { - detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; - } else { - detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`; - } - } - - // Countdown box states - if (boxes) { - const isImminent = totalSec < 600 && totalSec > 0; // < 10 min - boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { - b.classList.toggle('imminent', isImminent); - b.classList.toggle('active', isActive); - }); - } - + + // ======================== + // Countdown + // ======================== + + /** + * Start the countdown interval timer + */ + function startCountdownTimer() { + if (countdownInterval) clearInterval(countdownInterval); + countdownInterval = setInterval(updateCountdownFromPasses, 1000); + } + + /** + * Update countdown display from passes array + */ + function updateCountdownFromPasses() { + const now = new Date(); + let nextPass = null; + let isActive = false; + + for (const pass of passes) { + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) { + continue; + } + if (end > now) { + nextPass = pass; + isActive = start <= now; + break; + } + } + + const daysEl = document.getElementById('wxsatCdDays'); + const hoursEl = document.getElementById('wxsatCdHours'); + const minsEl = document.getElementById('wxsatCdMins'); + const secsEl = document.getElementById('wxsatCdSecs'); + const satEl = document.getElementById('wxsatCountdownSat'); + const detailEl = document.getElementById('wxsatCountdownDetail'); + const boxes = document.getElementById('wxsatCountdownBoxes'); + + if (!nextPass) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'No passes predicted'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } + + const target = parsePassDate(nextPass.startTimeISO); + if (!target) { + if (daysEl) daysEl.textContent = '--'; + if (hoursEl) hoursEl.textContent = '--'; + if (minsEl) minsEl.textContent = '--'; + if (secsEl) secsEl.textContent = '--'; + if (satEl) satEl.textContent = '--'; + if (detailEl) detailEl.textContent = 'Invalid pass time'; + if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.remove('imminent', 'active'); + }); + return; + } + let diffMs = target - now; + + if (isActive) { + diffMs = 0; + } + + const totalSec = Math.max(0, Math.floor(diffMs / 1000)); + const d = Math.floor(totalSec / 86400); + const h = Math.floor((totalSec % 86400) / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + if (daysEl) daysEl.textContent = d.toString().padStart(2, '0'); + if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0'); + if (minsEl) minsEl.textContent = m.toString().padStart(2, '0'); + if (secsEl) secsEl.textContent = s.toString().padStart(2, '0'); + if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`; + if (detailEl) { + if (isActive) { + detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`; + } else { + detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`; + } + } + + // Countdown box states + if (boxes) { + const isImminent = totalSec < 600 && totalSec > 0; // < 10 min + boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => { + b.classList.toggle('imminent', isImminent); + b.classList.toggle('active', isActive); + }); + } + // Keep timeline cursor in sync updateTimelineCursor(); // Keep selected satellite marker synchronized with time progression. updateSatelliteCrosshair(getSelectedPass()); } - - // ======================== - // Timeline - // ======================== - - /** - * Render 24h timeline with pass markers - */ - function renderTimeline(passList) { - const track = document.getElementById('wxsatTimelineTrack'); - const cursor = document.getElementById('wxsatTimelineCursor'); - if (!track) return; - - // Clear existing pass markers - track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove()); - - const now = new Date(); - const dayStart = new Date(now); - dayStart.setHours(0, 0, 0, 0); - const dayMs = 24 * 60 * 60 * 1000; - - passList.forEach((pass, idx) => { - const start = parsePassDate(pass.startTimeISO); - const end = parsePassDate(pass.endTimeISO); - if (!start || !end) return; - - const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); - const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); - const widthPct = Math.max(0.5, endPct - startPct); - - const marker = document.createElement('div'); - marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`; - marker.style.left = startPct + '%'; - marker.style.width = widthPct + '%'; - marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`; - marker.onclick = () => selectPass(idx); - track.appendChild(marker); - }); - - // Update cursor position - updateTimelineCursor(); - } - - /** - * Update timeline cursor to current time - */ - function updateTimelineCursor() { - const cursor = document.getElementById('wxsatTimelineCursor'); - if (!cursor) return; - - const now = new Date(); - const dayStart = new Date(now); - dayStart.setHours(0, 0, 0, 0); - const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100; - cursor.style.left = pct + '%'; - } - - // ======================== - // Auto-Scheduler - // ======================== - - /** - * Toggle auto-scheduler - */ - async function toggleScheduler(source) { - const checked = source?.checked ?? false; - - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - - // Sync both checkboxes to the source of truth - if (stripCheckbox) stripCheckbox.checked = checked; - if (sidebarCheckbox) sidebarCheckbox.checked = checked; - - if (checked) { - await enableScheduler(); - } else { - await disableScheduler(); - } - } - - /** - * Enable auto-scheduler - */ + + // ======================== + // Timeline + // ======================== + + /** + * Render 24h timeline with pass markers + */ + function renderTimeline(passList) { + const track = document.getElementById('wxsatTimelineTrack'); + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!track) return; + + // Clear existing pass markers + track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove()); + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const dayMs = 24 * 60 * 60 * 1000; + + passList.forEach((pass, idx) => { + const start = parsePassDate(pass.startTimeISO); + const end = parsePassDate(pass.endTimeISO); + if (!start || !end) return; + + const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100)); + const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100)); + const widthPct = Math.max(0.5, endPct - startPct); + + const marker = document.createElement('div'); + marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`; + marker.style.left = startPct + '%'; + marker.style.width = widthPct + '%'; + marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`; + marker.onclick = () => selectPass(idx); + track.appendChild(marker); + }); + + // Update cursor position + updateTimelineCursor(); + } + + /** + * Update timeline cursor to current time + */ + function updateTimelineCursor() { + const cursor = document.getElementById('wxsatTimelineCursor'); + if (!cursor) return; + + const now = new Date(); + const dayStart = new Date(now); + dayStart.setHours(0, 0, 0, 0); + const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100; + cursor.style.left = pct + '%'; + } + + // ======================== + // Auto-Scheduler + // ======================== + + /** + * Toggle auto-scheduler + */ + async function toggleScheduler(source) { + const checked = source?.checked ?? false; + + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + + // Sync both checkboxes to the source of truth + if (stripCheckbox) stripCheckbox.checked = checked; + if (sidebarCheckbox) sidebarCheckbox.checked = checked; + + if (checked) { + await enableScheduler(); + } else { + await disableScheduler(); + } + } + + /** + * Enable auto-scheduler + */ async function enableScheduler() { - let lat, lon; - if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { - const shared = ObserverLocation.getShared(); - lat = shared?.lat; - lon = shared?.lon; - } else { - lat = parseFloat(localStorage.getItem('observerLat')); - lon = parseFloat(localStorage.getItem('observerLon')); - } - - if (isNaN(lat) || isNaN(lon)) { - showNotification('Weather Sat', 'Set observer location first'); - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - if (stripCheckbox) stripCheckbox.checked = false; - if (sidebarCheckbox) sidebarCheckbox.checked = false; - return; - } - - const deviceSelect = document.getElementById('deviceSelect'); - const gainInput = document.getElementById('weatherSatGain'); - const biasTInput = document.getElementById('weatherSatBiasT'); - + let lat, lon; + if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) { + const shared = ObserverLocation.getShared(); + lat = shared?.lat; + lon = shared?.lon; + } else { + lat = parseFloat(localStorage.getItem('observerLat')); + lon = parseFloat(localStorage.getItem('observerLon')); + } + + if (isNaN(lat) || isNaN(lon)) { + showNotification('Weather Sat', 'Set observer location first'); + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + if (stripCheckbox) stripCheckbox.checked = false; + if (sidebarCheckbox) sidebarCheckbox.checked = false; + return; + } + + const deviceSelect = document.getElementById('deviceSelect'); + const gainInput = document.getElementById('weatherSatGain'); + const biasTInput = document.getElementById('weatherSatBiasT'); + try { const response = await fetch('/weather-sat/schedule/enable', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - latitude: lat, - longitude: lon, - device: parseInt(deviceSelect?.value || '0', 10), - gain: parseFloat(gainInput?.value || '40'), + latitude: lat, + longitude: lon, + device: parseInt(deviceSelect?.value || '0', 10), + gain: parseFloat(gainInput?.value || '40'), bias_t: biasTInput?.checked || false, }), }); @@ -1374,10 +1391,10 @@ const WeatherSat = (function() { showNotification('Weather Sat', 'Failed to enable auto-scheduler'); } } - - /** - * Disable auto-scheduler - */ + + /** + * Disable auto-scheduler + */ async function disableScheduler() { try { const response = await fetch('/weather-sat/schedule/disable', { method: 'POST' }); @@ -1390,13 +1407,13 @@ const WeatherSat = (function() { if (!isRunning) stopStream(); showNotification('Weather Sat', 'Auto-scheduler disabled'); } catch (err) { - console.error('Failed to disable scheduler:', err); - } - } - - /** - * Check current scheduler status - */ + console.error('Failed to disable scheduler:', err); + } + } + + /** + * Check current scheduler status + */ async function checkSchedulerStatus() { try { const response = await fetch('/weather-sat/schedule/status'); @@ -1406,249 +1423,249 @@ const WeatherSat = (function() { updateSchedulerUI(data); if (schedulerEnabled) startStream(); } catch (err) { - // Scheduler endpoint may not exist yet - } - } - - /** - * Update scheduler UI elements - */ - function updateSchedulerUI(data) { - const stripCheckbox = document.getElementById('wxsatAutoSchedule'); - const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - const statusEl = document.getElementById('wxsatSchedulerStatus'); - - if (stripCheckbox) stripCheckbox.checked = data.enabled; - if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled; - if (statusEl) { - if (data.enabled) { - statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`; - statusEl.style.color = '#00ff88'; - } else { - statusEl.textContent = 'Disabled'; - statusEl.style.color = ''; - } - } - } - - // ======================== - // Images - // ======================== - - /** - * Load decoded images - */ - async function loadImages() { - try { - const response = await fetch('/weather-sat/images'); - const data = await response.json(); - - if (data.status === 'ok') { - images = data.images || []; - updateImageCount(images.length); - renderGallery(); - } - } catch (err) { - console.error('Failed to load weather sat images:', err); - } - } - - /** - * Update image count - */ - function updateImageCount(count) { - const countEl = document.getElementById('wxsatImageCount'); - const stripCount = document.getElementById('wxsatStripImageCount'); - if (countEl) countEl.textContent = count; - if (stripCount) stripCount.textContent = count; - } - - /** - * Render image gallery grouped by date - */ - function renderGallery() { - const gallery = document.getElementById('wxsatGallery'); - if (!gallery) return; - - if (images.length === 0) { - gallery.innerHTML = ` - - `; - return; - } - - // Sort by timestamp descending - const sorted = [...images].sort((a, b) => { - return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); - }); - - // Group by date - const groups = {}; - sorted.forEach(img => { - const dateKey = img.timestamp - ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) - : 'Unknown Date'; - if (!groups[dateKey]) groups[dateKey] = []; - groups[dateKey].push(img); - }); - - let html = ''; - for (const [date, imgs] of Object.entries(groups)) { - html += `
${escapeHtml(date)}
`; - html += imgs.map(img => { - const fn = escapeHtml(img.filename || img.url.split('/').pop()); - return ` -
-
- ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} -
-
${escapeHtml(img.satellite)}
-
${escapeHtml(img.product || img.mode)}
-
${formatTimestamp(img.timestamp)}
-
-
-
- -
-
`; - }).join(''); - } - - gallery.innerHTML = html; - } - - /** - * Show full-size image - */ - function showImage(url, satellite, product, filename) { - currentModalFilename = filename || null; - - let modal = document.getElementById('wxsatImageModal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'wxsatImageModal'; - modal.className = 'wxsat-image-modal'; - modal.innerHTML = ` -
- -
- - Weather Satellite Image -
- `; - modal.addEventListener('click', (e) => { - if (e.target === modal) closeImage(); - }); - document.body.appendChild(modal); - } - - modal.querySelector('img').src = url; - const info = modal.querySelector('.wxsat-modal-info'); - if (info) { - info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`; - } - modal.classList.add('show'); - } - - /** - * Close image modal - */ - function closeImage() { - const modal = document.getElementById('wxsatImageModal'); - if (modal) modal.classList.remove('show'); - } - - /** - * Delete a single image - */ - async function deleteImage(filename) { - if (!filename) return; - if (!confirm(`Delete this image?`)) return; - - try { - const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); - const data = await response.json(); - - if (data.status === 'deleted') { - images = images.filter(img => { - const imgFn = img.filename || img.url.split('/').pop(); - return imgFn !== filename; - }); - updateImageCount(images.length); - renderGallery(); - closeImage(); - } else { - showNotification('Weather Sat', data.message || 'Failed to delete image'); - } - } catch (err) { - console.error('Failed to delete image:', err); - showNotification('Weather Sat', 'Failed to delete image'); - } - } - - /** - * Delete all images - */ - async function deleteAllImages() { - if (images.length === 0) return; - if (!confirm(`Delete all ${images.length} decoded images?`)) return; - - try { - const response = await fetch('/weather-sat/images', { method: 'DELETE' }); - const data = await response.json(); - - if (data.status === 'ok') { - images = []; - updateImageCount(0); - renderGallery(); - showNotification('Weather Sat', `Deleted ${data.deleted} images`); - } else { - showNotification('Weather Sat', 'Failed to delete images'); - } - } catch (err) { - console.error('Failed to delete all images:', err); - showNotification('Weather Sat', 'Failed to delete images'); - } - } - - /** - * Format timestamp - */ - function formatTimestamp(isoString) { - if (!isoString) return '--'; - try { - return new Date(isoString).toLocaleString(); - } catch { - return isoString; - } - } - - /** - * Escape HTML - */ - function escapeHtml(text) { - if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - + // Scheduler endpoint may not exist yet + } + } + + /** + * Update scheduler UI elements + */ + function updateSchedulerUI(data) { + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); + const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); + const statusEl = document.getElementById('wxsatSchedulerStatus'); + + if (stripCheckbox) stripCheckbox.checked = data.enabled; + if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled; + if (statusEl) { + if (data.enabled) { + statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`; + statusEl.style.color = '#00ff88'; + } else { + statusEl.textContent = 'Disabled'; + statusEl.style.color = ''; + } + } + } + + // ======================== + // Images + // ======================== + + /** + * Load decoded images + */ + async function loadImages() { + try { + const response = await fetch('/weather-sat/images'); + const data = await response.json(); + + if (data.status === 'ok') { + images = data.images || []; + updateImageCount(images.length); + renderGallery(); + } + } catch (err) { + console.error('Failed to load weather sat images:', err); + } + } + + /** + * Update image count + */ + function updateImageCount(count) { + const countEl = document.getElementById('wxsatImageCount'); + const stripCount = document.getElementById('wxsatStripImageCount'); + if (countEl) countEl.textContent = count; + if (stripCount) stripCount.textContent = count; + } + + /** + * Render image gallery grouped by date + */ + function renderGallery() { + const gallery = document.getElementById('wxsatGallery'); + if (!gallery) return; + + if (images.length === 0) { + gallery.innerHTML = ` + + `; + return; + } + + // Sort by timestamp descending + const sorted = [...images].sort((a, b) => { + return new Date(b.timestamp || 0) - new Date(a.timestamp || 0); + }); + + // Group by date + const groups = {}; + sorted.forEach(img => { + const dateKey = img.timestamp + ? new Date(img.timestamp).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + : 'Unknown Date'; + if (!groups[dateKey]) groups[dateKey] = []; + groups[dateKey].push(img); + }); + + let html = ''; + for (const [date, imgs] of Object.entries(groups)) { + html += `
${escapeHtml(date)}
`; + html += imgs.map(img => { + const fn = escapeHtml(img.filename || img.url.split('/').pop()); + return ` +
+
+ ${escapeHtml(img.satellite)} ${escapeHtml(img.product)} +
+
${escapeHtml(img.satellite)}
+
${escapeHtml(img.product || img.mode)}
+
${formatTimestamp(img.timestamp)}
+
+
+
+ +
+
`; + }).join(''); + } + + gallery.innerHTML = html; + } + + /** + * Show full-size image + */ + function showImage(url, satellite, product, filename) { + currentModalFilename = filename || null; + + let modal = document.getElementById('wxsatImageModal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wxsatImageModal'; + modal.className = 'wxsat-image-modal'; + modal.innerHTML = ` +
+ +
+ + Weather Satellite Image +
+ `; + modal.addEventListener('click', (e) => { + if (e.target === modal) closeImage(); + }); + document.body.appendChild(modal); + } + + modal.querySelector('img').src = url; + const info = modal.querySelector('.wxsat-modal-info'); + if (info) { + info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`; + } + modal.classList.add('show'); + } + + /** + * Close image modal + */ + function closeImage() { + const modal = document.getElementById('wxsatImageModal'); + if (modal) modal.classList.remove('show'); + } + + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!filename) return; + if (!confirm(`Delete this image?`)) return; + + try { + const response = await fetch(`/weather-sat/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'deleted') { + images = images.filter(img => { + const imgFn = img.filename || img.url.split('/').pop(); + return imgFn !== filename; + }); + updateImageCount(images.length); + renderGallery(); + closeImage(); + } else { + showNotification('Weather Sat', data.message || 'Failed to delete image'); + } + } catch (err) { + console.error('Failed to delete image:', err); + showNotification('Weather Sat', 'Failed to delete image'); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (images.length === 0) return; + if (!confirm(`Delete all ${images.length} decoded images?`)) return; + + try { + const response = await fetch('/weather-sat/images', { method: 'DELETE' }); + const data = await response.json(); + + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('Weather Sat', `Deleted ${data.deleted} images`); + } else { + showNotification('Weather Sat', 'Failed to delete images'); + } + } catch (err) { + console.error('Failed to delete all images:', err); + showNotification('Weather Sat', 'Failed to delete images'); + } + } + + /** + * Format timestamp + */ + function formatTimestamp(isoString) { + if (!isoString) return '--'; + try { + return new Date(isoString).toLocaleString(); + } catch { + return isoString; + } + } + + /** + * Escape HTML + */ + function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + /** * Invalidate ground map size (call after container becomes visible) */ @@ -1662,151 +1679,151 @@ const WeatherSat = (function() { updateGroundTrack(getSelectedPass()); }, 100); } - - // ======================== - // Decoder Console - // ======================== - - /** - * Add an entry to the decoder console log - */ - function addConsoleEntry(message, logType) { - const log = document.getElementById('wxsatConsoleLog'); - if (!log) return; - - const entry = document.createElement('div'); - entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`; - entry.textContent = message; - log.appendChild(entry); - - consoleEntries.push(entry); - - // Cap at 200 entries - while (consoleEntries.length > 200) { - const old = consoleEntries.shift(); - if (old.parentNode) old.parentNode.removeChild(old); - } - - // Auto-scroll to bottom - log.scrollTop = log.scrollHeight; - } - - /** - * Update the phase indicator steps - */ - function updatePhaseIndicator(phase) { - if (!phase || phase === currentPhase) return; - currentPhase = phase; - - const phases = ['tuning', 'listening', 'signal_detected', 'decoding', 'complete']; - const phaseIndex = phases.indexOf(phase); - const isError = phase === 'error'; - - document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { - const stepPhase = step.dataset.phase; - const stepIndex = phases.indexOf(stepPhase); - - step.classList.remove('active', 'completed', 'error'); - - if (isError) { - if (stepPhase === currentPhase || stepIndex === phaseIndex) { - step.classList.add('error'); - } - } else if (stepIndex === phaseIndex) { - step.classList.add('active'); - } else if (stepIndex < phaseIndex && phaseIndex >= 0) { - step.classList.add('completed'); - } - }); - } - - /** - * Show or hide the decoder console - */ - function showConsole(visible) { - const el = document.getElementById('wxsatSignalConsole'); - if (el) el.classList.toggle('active', visible); - - if (consoleAutoHideTimer) { - clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = null; - } - } - - /** - * Toggle console body collapsed state - */ - function toggleConsole() { - const body = document.getElementById('wxsatConsoleBody'); - const btn = document.getElementById('wxsatConsoleToggle'); - if (!body) return; - - consoleCollapsed = !consoleCollapsed; - body.classList.toggle('collapsed', consoleCollapsed); - if (btn) btn.classList.toggle('collapsed', consoleCollapsed); - } - - /** - * Clear console entries and reset phase indicator - */ - function clearConsole() { - const log = document.getElementById('wxsatConsoleLog'); - if (log) log.innerHTML = ''; - consoleEntries = []; - currentPhase = 'idle'; - - document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { - step.classList.remove('active', 'completed', 'error'); - }); - - if (consoleAutoHideTimer) { - clearTimeout(consoleAutoHideTimer); - consoleAutoHideTimer = null; - } - } - - /** - * Suspend background activity when leaving the mode. - * Closes the SSE stream and stops the countdown interval so they don't - * keep running while another mode is active. The stream is re-opened - * by init() or startStream() when the mode is next entered. - */ - function suspend() { - if (countdownInterval) { - clearInterval(countdownInterval); - countdownInterval = null; - } - // Only close the stream if nothing is actively capturing/scheduling — - // if a capture or scheduler is running we want it to continue on the - // server and the stream will reconnect on next init(). - if (!isRunning && !schedulerEnabled) { - stopStream(); - } - } - - // Public API - return { - init, - suspend, - start, - stop, - startPass, - selectPass, - testDecode, - loadImages, - loadPasses, - showImage, - closeImage, - deleteImage, - deleteAllImages, - useGPS, - toggleScheduler, - invalidateMap, - toggleConsole, - _getModalFilename: () => currentModalFilename, - }; -})(); - -document.addEventListener('DOMContentLoaded', function() { - // Initialization happens via selectMode when weather-satellite mode is activated -}); + + // ======================== + // Decoder Console + // ======================== + + /** + * Add an entry to the decoder console log + */ + function addConsoleEntry(message, logType) { + const log = document.getElementById('wxsatConsoleLog'); + if (!log) return; + + const entry = document.createElement('div'); + entry.className = `wxsat-console-entry wxsat-log-${logType || 'info'}`; + entry.textContent = message; + log.appendChild(entry); + + consoleEntries.push(entry); + + // Cap at 200 entries + while (consoleEntries.length > 200) { + const old = consoleEntries.shift(); + if (old.parentNode) old.parentNode.removeChild(old); + } + + // Auto-scroll to bottom + log.scrollTop = log.scrollHeight; + } + + /** + * Update the phase indicator steps + */ + function updatePhaseIndicator(phase) { + if (!phase || phase === currentPhase) return; + currentPhase = phase; + + const phases = ['tuning', 'listening', 'signal_detected', 'decoding', 'complete']; + const phaseIndex = phases.indexOf(phase); + const isError = phase === 'error'; + + document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { + const stepPhase = step.dataset.phase; + const stepIndex = phases.indexOf(stepPhase); + + step.classList.remove('active', 'completed', 'error'); + + if (isError) { + if (stepPhase === currentPhase || stepIndex === phaseIndex) { + step.classList.add('error'); + } + } else if (stepIndex === phaseIndex) { + step.classList.add('active'); + } else if (stepIndex < phaseIndex && phaseIndex >= 0) { + step.classList.add('completed'); + } + }); + } + + /** + * Show or hide the decoder console + */ + function showConsole(visible) { + const el = document.getElementById('wxsatSignalConsole'); + if (el) el.classList.toggle('active', visible); + + if (consoleAutoHideTimer) { + clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = null; + } + } + + /** + * Toggle console body collapsed state + */ + function toggleConsole() { + const body = document.getElementById('wxsatConsoleBody'); + const btn = document.getElementById('wxsatConsoleToggle'); + if (!body) return; + + consoleCollapsed = !consoleCollapsed; + body.classList.toggle('collapsed', consoleCollapsed); + if (btn) btn.classList.toggle('collapsed', consoleCollapsed); + } + + /** + * Clear console entries and reset phase indicator + */ + function clearConsole() { + const log = document.getElementById('wxsatConsoleLog'); + if (log) log.innerHTML = ''; + consoleEntries = []; + currentPhase = 'idle'; + + document.querySelectorAll('#wxsatPhaseIndicator .wxsat-phase-step').forEach(step => { + step.classList.remove('active', 'completed', 'error'); + }); + + if (consoleAutoHideTimer) { + clearTimeout(consoleAutoHideTimer); + consoleAutoHideTimer = null; + } + } + + /** + * Suspend background activity when leaving the mode. + * Closes the SSE stream and stops the countdown interval so they don't + * keep running while another mode is active. The stream is re-opened + * by init() or startStream() when the mode is next entered. + */ + function suspend() { + if (countdownInterval) { + clearInterval(countdownInterval); + countdownInterval = null; + } + // Only close the stream if nothing is actively capturing/scheduling — + // if a capture or scheduler is running we want it to continue on the + // server and the stream will reconnect on next init(). + if (!isRunning && !schedulerEnabled) { + stopStream(); + } + } + + // Public API + return { + init, + suspend, + start, + stop, + startPass, + selectPass, + testDecode, + loadImages, + loadPasses, + showImage, + closeImage, + deleteImage, + deleteAllImages, + useGPS, + toggleScheduler, + invalidateMap, + toggleConsole, + _getModalFilename: () => currentModalFilename, + }; +})(); + +document.addEventListener('DOMContentLoaded', function() { + // Initialization happens via selectMode when weather-satellite mode is activated +});