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 <noreply@anthropic.com>
This commit is contained in:
Smittix
2026-02-18 11:43:47 +00:00
parent b0652595fa
commit f4ade209f9
5 changed files with 104 additions and 38 deletions

View File

@@ -22,6 +22,7 @@ const WeatherSat = (function() {
let currentPhase = 'idle'; let currentPhase = 'idle';
let consoleAutoHideTimer = null; let consoleAutoHideTimer = null;
let currentModalFilename = null; let currentModalFilename = null;
let locationListenersAttached = false;
/** /**
* Initialize the Weather Satellite mode * Initialize the Weather Satellite mode
@@ -54,8 +55,13 @@ const WeatherSat = (function() {
if (latInput && storedLat) latInput.value = storedLat; if (latInput && storedLat) latInput.value = storedLat;
if (lonInput && storedLon) lonInput.value = storedLon; if (lonInput && storedLon) lonInput.value = storedLon;
if (latInput) latInput.addEventListener('change', saveLocationFromInputs); // Only attach listeners once — re-calling init() on mode switch must not
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs); // 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`); updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`); showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
} else if (data.type === 'schedule_capture_complete') { } 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(); loadImages();
loadPasses();
} else if (data.type === 'schedule_capture_skipped') { } else if (data.type === 'schedule_capture_skipped') {
const reason = data.reason || 'unknown'; const reason = data.reason || 'unknown';
const p = data.pass || {}; const p = data.pass || {};
@@ -474,11 +489,13 @@ const WeatherSat = (function() {
if (data.status === 'ok') { if (data.status === 'ok') {
passes = data.passes || []; passes = data.passes || [];
selectedPassIndex = -1;
renderPasses(passes); renderPasses(passes);
renderTimeline(passes); renderTimeline(passes);
updateCountdownFromPasses(); updateCountdownFromPasses();
// Auto-select first pass // Always select the first upcoming pass so the polar plot
if (passes.length > 0 && selectedPassIndex < 0) { // and ground track reflect the current list after every refresh.
if (passes.length > 0) {
selectPass(0); selectPass(0);
} }
} }
@@ -875,6 +892,9 @@ const WeatherSat = (function() {
b.classList.toggle('active', isActive); b.classList.toggle('active', isActive);
}); });
} }
// Keep timeline cursor in sync
updateTimelineCursor();
} }
// ======================== // ========================
@@ -939,12 +959,13 @@ const WeatherSat = (function() {
/** /**
* Toggle auto-scheduler * Toggle auto-scheduler
*/ */
async function toggleScheduler() { async function toggleScheduler(source) {
const checked = source?.checked ?? false;
const stripCheckbox = document.getElementById('wxsatAutoSchedule'); const stripCheckbox = document.getElementById('wxsatAutoSchedule');
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule'); 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 (stripCheckbox) stripCheckbox.checked = checked;
if (sidebarCheckbox) sidebarCheckbox.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 // Public API
return { return {
init, init,
suspend,
start, start,
stop, stop,
startPass, startPass,

View File

@@ -1345,21 +1345,41 @@ ACARS: ${r.statistics.acarsMessages} messages`;
} }
trailLines[icao] = []; 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(); const now = Date.now();
for (let i = 1; i < trail.length; i++) { let runColor = getAltitudeColor(trail[0].alt);
const p1 = trail[i-1]; let runPoints = [[trail[0].lat, trail[0].lon]];
const p2 = trail[i]; let runEndTime = trail[0].time;
const age = (now - p2.time) / 1000; // seconds
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
const color = getAltitudeColor(p2.alt); for (let i = 1; i < trail.length; i++) {
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], { const p = trail[i];
color: color, const color = getAltitudeColor(p.alt);
weight: 2,
opacity: opacity if (color !== runColor) {
}).addTo(radarMap); // Flush the current color run as one polyline
trailLines[icao].push(line); 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)
);
} }
} }

View File

@@ -2696,7 +2696,7 @@
<div class="wxsat-strip-divider"></div> <div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group"> <div class="wxsat-strip-group">
<label class="wxsat-schedule-toggle" title="Auto-capture passes"> <label class="wxsat-schedule-toggle" title="Auto-capture passes">
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler()"> <input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler(this)">
<span class="wxsat-toggle-label">AUTO</span> <span class="wxsat-toggle-label">AUTO</span>
</label> </label>
</div> </div>
@@ -4036,6 +4036,11 @@
if (typeof SpaceWeather !== 'undefined' && SpaceWeather.destroy) SpaceWeather.destroy(); 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) // Show/hide Device Intelligence for modes that use it (not for satellite/aircraft/tscm)
const reconBtn = document.getElementById('reconBtn'); const reconBtn = document.getElementById('reconBtn');
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]'); const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');

View File

@@ -1,15 +1,15 @@
<!-- WEATHER SATELLITE MODE --> <!-- WEATHER SATELLITE MODE -->
<div id="weatherSatMode" class="mode-content"> <div id="weatherSatMode" class="mode-content">
<div class="section"> <div class="section">
<h3>Weather Satellite Decoder</h3> <h3>Weather Satellite Decoder</h3>
<div class="alpha-mode-notice"> <div class="alpha-mode-notice">
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions. ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
</div> </div>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;"> <p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
Receive and decode weather images from NOAA and Meteor satellites. Receive and decode weather images from NOAA and Meteor satellites.
Uses SatDump for live SDR capture and image processing. Uses SatDump for live SDR capture and image processing.
</p> </p>
</div> </div>
<div class="section"> <div class="section">
<h3>Satellite</h3> <h3>Satellite</h3>
@@ -226,7 +226,7 @@
</p> </p>
<div class="form-group"> <div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;"> <label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;"> <input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler(this)" style="width: auto;">
Enable Auto-Capture Enable Auto-Capture
</label> </label>
</div> </div>

View File

@@ -105,7 +105,7 @@ def predict_passes(
).total_seconds() ).total_seconds()
duration_minutes = round(duration_seconds / 60, 1) 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 = 0.0
max_el_az = 0.0 max_el_az = 0.0
trajectory: list[dict[str, float]] = [] trajectory: list[dict[str, float]] = []
@@ -141,14 +141,14 @@ def predict_passes(
_, set_az, _ = set_topo.altaz() _, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = { 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, 'satellite': sat_key,
'name': sat_info['name'], 'name': sat_info['name'],
'frequency': sat_info['frequency'], 'frequency': sat_info['frequency'],
'mode': sat_info['mode'], 'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'), 'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(), 'startTimeISO': rise_time.utc_datetime().isoformat() + 'Z',
'endTimeISO': set_time.utc_datetime().isoformat(), 'endTimeISO': set_time.utc_datetime().isoformat() + 'Z',
'maxEl': round(max_el, 1), 'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1), 'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1), 'riseAz': round(rise_az.degrees, 1),