From f4ade209f9b428a74881b3bce3295dce1924980b Mon Sep 17 00:00:00 2001 From: Smittix Date: Wed, 18 Feb 2026 11:43:47 +0000 Subject: [PATCH] feat: Weather satellite and ADS-B trail rendering improvements Weather Satellite: - Fix duplicate event listeners on mode re-entry via locationListenersAttached guard - Add suspend() to stop countdown/SSE stream when switching away from the mode - Call WeatherSat.suspend() in switchMode() when leaving weathersat - Fix toggleScheduler() to take the checkbox element as source of truth, preventing both checkboxes fighting each other - Reset isRunning/UI state after auto-capture completes (scheduler path) - Always re-select first pass and reset selectedPassIndex after loadPasses() - Keep timeline cursor in sync inside selectPass() - Add seconds to pass ID format to avoid collisions on concurrent passes - Improve predict_passes() comment clarity; fix trajectory comment ADS-B dashboard: - Batch altitude-colour trail segments into runs of same-colour polylines, reducing Leaflet layer count from O(trail length) to O(colour changes) for significantly better rendering performance with many aircraft Co-Authored-By: Claude Sonnet 4.6 --- static/js/modes/weather-satellite.js | 57 ++++++++++++++++--- templates/adsb_dashboard.html | 46 ++++++++++----- templates/index.html | 7 ++- .../partials/modes/weather-satellite.html | 24 ++++---- utils/weather_sat_predict.py | 8 +-- 5 files changed, 104 insertions(+), 38 deletions(-) diff --git a/static/js/modes/weather-satellite.js b/static/js/modes/weather-satellite.js index 6dab834..0896690 100644 --- a/static/js/modes/weather-satellite.js +++ b/static/js/modes/weather-satellite.js @@ -22,6 +22,7 @@ const WeatherSat = (function() { let currentPhase = 'idle'; let consoleAutoHideTimer = null; let currentModalFilename = null; + let locationListenersAttached = false; /** * Initialize the Weather Satellite mode @@ -54,8 +55,13 @@ const WeatherSat = (function() { if (latInput && storedLat) latInput.value = storedLat; if (lonInput && storedLon) lonInput.value = storedLon; - if (latInput) latInput.addEventListener('change', saveLocationFromInputs); - if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); + // 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; + } } /** @@ -428,8 +434,17 @@ const WeatherSat = (function() { 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') { - showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`); + 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 || {}; @@ -474,11 +489,13 @@ const WeatherSat = (function() { if (data.status === 'ok') { passes = data.passes || []; + selectedPassIndex = -1; renderPasses(passes); renderTimeline(passes); updateCountdownFromPasses(); - // Auto-select first pass - if (passes.length > 0 && selectedPassIndex < 0) { + // 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); } } @@ -875,6 +892,9 @@ const WeatherSat = (function() { b.classList.toggle('active', isActive); }); } + + // Keep timeline cursor in sync + updateTimelineCursor(); } // ======================== @@ -939,12 +959,13 @@ const WeatherSat = (function() { /** * Toggle auto-scheduler */ - async function toggleScheduler() { + async function toggleScheduler(source) { + const checked = source?.checked ?? false; + const stripCheckbox = document.getElementById('wxsatAutoSchedule'); const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); - const checked = stripCheckbox?.checked || sidebarCheckbox?.checked; - // Sync both checkboxes + // Sync both checkboxes to the source of truth if (stripCheckbox) stripCheckbox.checked = checked; if (sidebarCheckbox) sidebarCheckbox.checked = checked; @@ -1386,9 +1407,29 @@ const WeatherSat = (function() { } } + /** + * 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, diff --git a/templates/adsb_dashboard.html b/templates/adsb_dashboard.html index 05553cc..0867193 100644 --- a/templates/adsb_dashboard.html +++ b/templates/adsb_dashboard.html @@ -1345,21 +1345,41 @@ ACARS: ${r.statistics.acarsMessages} messages`; } trailLines[icao] = []; - // Create gradient segments + // Group consecutive same-altitude-color points into single polylines. + // This reduces layer count from O(trail length) to O(color band changes), + // which is typically 1-2 polylines per aircraft instead of up to 99. const now = Date.now(); - for (let i = 1; i < trail.length; i++) { - const p1 = trail[i-1]; - const p2 = trail[i]; - const age = (now - p2.time) / 1000; // seconds - const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes + let runColor = getAltitudeColor(trail[0].alt); + let runPoints = [[trail[0].lat, trail[0].lon]]; + let runEndTime = trail[0].time; - const color = getAltitudeColor(p2.alt); - const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], { - color: color, - weight: 2, - opacity: opacity - }).addTo(radarMap); - trailLines[icao].push(line); + for (let i = 1; i < trail.length; i++) { + const p = trail[i]; + const color = getAltitudeColor(p.alt); + + if (color !== runColor) { + // Flush the current color run as one polyline + if (runPoints.length >= 2) { + const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120)); + trailLines[icao].push( + L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap) + ); + } + // Start a new run, sharing the junction point for visual continuity + runColor = color; + runPoints = [[trail[i-1].lat, trail[i-1].lon], [p.lat, p.lon]]; + } else { + runPoints.push([p.lat, p.lon]); + } + runEndTime = p.time; + } + + // Flush the final run + if (runPoints.length >= 2) { + const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120)); + trailLines[icao].push( + L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap) + ); } } diff --git a/templates/index.html b/templates/index.html index 4262cac..9fce59a 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2696,7 +2696,7 @@
@@ -4036,6 +4036,11 @@ if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy(); } + // Suspend Weather Satellite background timers/streams when leaving the mode + if (mode !== 'weathersat') { + if (typeof WeatherSat !== 'undefined' && WeatherSat.suspend) WeatherSat.suspend(); + } + // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm) const reconBtn = document.getElementById('reconBtn'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); diff --git a/templates/partials/modes/weather-satellite.html b/templates/partials/modes/weather-satellite.html index 0f0f5d2..c43f671 100644 --- a/templates/partials/modes/weather-satellite.html +++ b/templates/partials/modes/weather-satellite.html @@ -1,15 +1,15 @@ -
-
-

Weather Satellite Decoder

-
- ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. -
-

- Receive and decode weather images from NOAA and Meteor satellites. - Uses SatDump for live SDR capture and image processing. -

-
+
+
+

Weather Satellite Decoder

+
+ ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. +
+

+ Receive and decode weather images from NOAA and Meteor satellites. + Uses SatDump for live SDR capture and image processing. +

+

Satellite

@@ -226,7 +226,7 @@

diff --git a/utils/weather_sat_predict.py b/utils/weather_sat_predict.py index 8d6432f..88dfd32 100644 --- a/utils/weather_sat_predict.py +++ b/utils/weather_sat_predict.py @@ -105,7 +105,7 @@ def predict_passes( ).total_seconds() duration_minutes = round(duration_seconds / 60, 1) - # Calculate max elevation and trajectory + # Calculate max elevation (always) and trajectory points (only if requested) max_el = 0.0 max_el_az = 0.0 trajectory: list[dict[str, float]] = [] @@ -141,14 +141,14 @@ def predict_passes( _, set_az, _ = set_topo.altaz() pass_data: dict[str, Any] = { - 'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}", + 'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M%S')}", 'satellite': sat_key, 'name': sat_info['name'], 'frequency': sat_info['frequency'], 'mode': sat_info['mode'], 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), - 'startTimeISO': rise_time.utc_datetime().isoformat(), - 'endTimeISO': set_time.utc_datetime().isoformat(), + 'startTimeISO': rise_time.utc_datetime().isoformat() + 'Z', + 'endTimeISO': set_time.utc_datetime().isoformat() + 'Z', 'maxEl': round(max_el, 1), 'maxElAz': round(max_el_az, 1), 'riseAz': round(rise_az.degrees, 1),