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;
// 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 (latInput) latInput.addEventListener('change', saveLocationFromInputs);
if (lonInput) lonInput.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

@@ -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),