Enhance weather satellite UX with pass geometry, guides, and wider predictions

Pass prediction improvements:
- Widen prediction window to 48h at 5° min elevation (was 24h/15°)
- Add AOS/TCA/LOS pass geometry detail panel with times and bearings
- Fix duration display (was showing seconds labeled as minutes)
- Enhanced pass cards with AOS/LOS times, bearings, and directions
- Add REFRESH button in passes panel header
- Better empty state with clear "set your location" prompt and icon

Countdown and visual:
- Pulse animation on countdown when pass is imminent or active
- Countdown numbers scale up and change color for urgency

Sidebar getting started guide:
- New "Getting Started" section explaining what Meteor satellites are,
  polar orbits, 4-8 passes/day, step-by-step workflow
- "When to look" tips (elevation, day vs night, pass direction)
- "What you need" equipment table with costs
- Collapsed antenna guide by default to reduce initial overwhelm
- Improved offline decode section with clear instructions on where
  to get IQ recordings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mitchross
2026-03-25 01:13:37 -04:00
parent 43fb735e4e
commit 1e5bc0054d
4 changed files with 268 additions and 24 deletions
+81
View File
@@ -408,6 +408,87 @@
margin: 0 2px;
}
/* ===== Pass Geometry Detail ===== */
.wxsat-pass-geometry {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
background: var(--bg-primary, #0d1117);
border-bottom: 1px solid var(--border-color, #2a3040);
font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif;
}
.wxsat-geom-event {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 60px;
}
.wxsat-geom-event.wxsat-geom-tca {
color: var(--neon-green);
}
.wxsat-geom-label {
font-size: 9px;
font-weight: 600;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wxsat-geom-tca .wxsat-geom-label {
color: var(--neon-green);
}
.wxsat-geom-time {
font-size: 12px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
}
.wxsat-geom-tca .wxsat-geom-time {
color: var(--neon-green);
}
.wxsat-geom-az {
font-size: 10px;
color: var(--text-dim, #666);
}
.wxsat-geom-arrow {
font-size: 14px;
color: var(--text-dim, #444);
}
.wxsat-geom-meta {
font-size: 10px;
color: var(--text-dim, #666);
margin-left: 8px;
padding-left: 8px;
border-left: 1px solid var(--border-color, #2a3040);
white-space: nowrap;
}
/* ===== Countdown Pulse Animation ===== */
.wxsat-countdown-box.imminent .wxsat-cd-value {
animation: wxsat-count-pulse 1s ease-in-out infinite;
color: var(--accent-yellow);
}
.wxsat-countdown-box.active .wxsat-cd-value {
animation: wxsat-count-pulse 1s ease-in-out infinite;
color: var(--neon-green);
}
@keyframes wxsat-count-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.15); opacity: 0.8; }
}
/* ===== Pass Predictions Panel ===== */
.wxsat-passes-panel {
flex: 0 0 280px;
+72 -14
View File
@@ -751,7 +751,7 @@ const WeatherSat = (function() {
}
try {
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=48&min_elevation=5&trajectory=true&ground_track=true`;
const response = await fetch(url);
const data = await response.json();
@@ -815,6 +815,42 @@ const WeatherSat = (function() {
// Update polar panel subtitle
const polarSat = document.getElementById('wxsatPolarSat');
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
// Update pass geometry detail panel
updatePassGeometry(pass);
}
/**
* Update the AOS/TCA/LOS pass geometry detail panel.
*/
function updatePassGeometry(pass) {
const panel = document.getElementById('wxsatPassGeometry');
if (!panel) return;
if (!pass) {
panel.style.display = 'none';
return;
}
panel.style.display = 'flex';
const aosTime = document.getElementById('wxsatGeomAosTime');
const aosAz = document.getElementById('wxsatGeomAosAz');
const tcaEl = document.getElementById('wxsatGeomTcaEl');
const tcaAz = document.getElementById('wxsatGeomTcaAz');
const losTime = document.getElementById('wxsatGeomLosTime');
const losAz = document.getElementById('wxsatGeomLosAz');
const meta = document.getElementById('wxsatGeomMeta');
const tzLabel = getTZLabel();
if (aosTime) aosTime.textContent = formatShortTime(pass.startTimeISO) + tzLabel;
if (aosAz) aosAz.textContent = `${Math.round(pass.riseAz || 0)}\u00b0 ${azToDir(pass.riseAz)}`;
if (tcaEl) tcaEl.textContent = `${pass.maxEl}\u00b0 el`;
if (tcaAz) tcaAz.textContent = `${Math.round(pass.maxElAz || pass.tcaAz || 0)}\u00b0 ${azToDir(pass.maxElAz || pass.tcaAz)}`;
if (losTime) losTime.textContent = formatShortTime(pass.endTimeISO) + tzLabel;
if (losAz) losAz.textContent = `${Math.round(pass.setAz || 0)}\u00b0 ${azToDir(pass.setAz)}`;
const durMin = Math.round((pass.duration || 0) / 60);
if (meta) meta.textContent = `${durMin} min / ${pass.quality}`;
}
/**
@@ -829,12 +865,28 @@ const WeatherSat = (function() {
if (!container) return;
if (passList.length === 0) {
const hasLocation = localStorage.getItem('observerLat') !== null;
const hasLocation = localStorage.getItem('observerLat') !== null ||
(window.ObserverLocation && ObserverLocation.isSharedEnabled() && ObserverLocation.getShared()?.lat);
container.innerHTML = `
<div class="wxsat-gallery-empty">
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
${hasLocation
? '<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>'
: '<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>'}
</svg>
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">
${hasLocation ? 'No passes in next 24 hours' : 'Set your location'}
</p>
<p style="font-size: 11px; margin-top: 4px;">
${hasLocation
? 'All Meteor passes may be below the minimum elevation. Try again later.'
: 'Enter lat/lon in the strip bar above or click GPS to load pass predictions'}
</p>
</div>
`;
// Hide geometry panel when no passes
const geom = document.getElementById('wxsatPassGeometry');
if (geom) geom.style.display = 'none';
return;
}
@@ -866,6 +918,10 @@ const WeatherSat = (function() {
const riseDir = azToDir(pass.riseAz);
const setDir = azToDir(pass.setAz);
const bestBadge = isBest ? '<span class="wxsat-pass-best-badge">BEST</span>' : '';
const durMin = Math.round((pass.duration || 0) / 60);
const aosStr = formatShortTime(pass.startTimeISO);
const losStr = formatShortTime(pass.endTimeISO);
const tzLabel = getTZLabel();
return `
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
@@ -874,13 +930,13 @@ const WeatherSat = (function() {
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
</div>
<div class="wxsat-pass-details">
<span class="wxsat-pass-detail-label">Time</span>
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
<span class="wxsat-pass-detail-label">Max El</span>
<span class="wxsat-pass-detail-value">${pass.maxEl}&deg;</span>
<span class="wxsat-pass-detail-label">Duration</span>
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
<span class="wxsat-pass-detail-label">Direction</span>
<span class="wxsat-pass-detail-label">AOS</span>
<span class="wxsat-pass-detail-value">${escapeHtml(aosStr)}${escapeHtml(tzLabel)} &middot; ${Math.round(pass.riseAz || 0)}&deg; ${riseDir}</span>
<span class="wxsat-pass-detail-label">LOS</span>
<span class="wxsat-pass-detail-value">${escapeHtml(losStr)}${escapeHtml(tzLabel)} &middot; ${Math.round(pass.setAz || 0)}&deg; ${setDir}</span>
<span class="wxsat-pass-detail-label">Peak</span>
<span class="wxsat-pass-detail-value">${pass.maxEl}&deg; el &middot; ${durMin} min</span>
<span class="wxsat-pass-detail-label">Track</span>
<span class="wxsat-pass-detail-value">${riseDir} <span class="wxsat-dir-arrow">&rarr;</span> ${setDir}</span>
</div>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
@@ -1456,10 +1512,11 @@ const WeatherSat = (function() {
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
} else {
const bestPass = findBestPass(filtered);
const durMin = Math.round((nextPass.duration || 0) / 60);
const bestNote = bestPass && bestPass.startTimeISO !== nextPass.startTimeISO
? ` | Best: ${bestPass.name} ${formatShortTime(bestPass.startTimeISO)}${getTZLabel()} (${bestPass.maxEl}\u00b0)`
: '';
detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min${bestNote}`;
detailEl.textContent = `${passTimeStr} / ${nextPass.maxEl}\u00b0 max el / ${durMin} min${bestNote}`;
}
}
@@ -1592,7 +1649,8 @@ const WeatherSat = (function() {
if (bestEl) {
if (best) {
const t = formatShortTime(best.startTimeISO) + getTZLabel();
bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${best.duration} min)`;
const bestDurMin = Math.round((best.duration || 0) / 60);
bestEl.textContent = `Best: ${best.name} at ${t} (${best.maxEl}\u00b0 el, ${bestDurMin} min)`;
} else {
bestEl.textContent = 'No upcoming passes';
}
@@ -2343,13 +2401,13 @@ const WeatherSat = (function() {
const offsets = [25, 95, 200, 340, 510, 720, 880, 1020];
const elevations = [72, 45, 28, 63, 18, 55, 82, 35];
const durations = [14, 12, 8, 13, 6, 11, 15, 10];
const durations = [840, 720, 480, 780, 360, 660, 900, 600]; // seconds
const riseAzs = [350, 15, 200, 310, 170, 40, 280, 90];
const setAzs = [170, 195, 20, 130, 350, 220, 100, 270];
offsets.forEach((offset, i) => {
const start = new Date(now.getTime() + offset * 60000);
const end = new Date(start.getTime() + durations[i] * 60000);
const end = new Date(start.getTime() + durations[i] * 1000);
const sat = demoSats[i % 2];
const el = elevations[i];
const quality = el >= 60 ? 'excellent' : el >= 30 ? 'good' : 'fair';
+27 -1
View File
@@ -2612,10 +2612,36 @@
<div class="wxsat-passes-header">
<span class="wxsat-passes-title">Upcoming Passes</span>
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
<button class="wxsat-strip-btn" onclick="WeatherSat.loadPasses()" title="Refresh pass predictions" style="margin-left: auto; font-size: 9px; padding: 2px 6px;">REFRESH</button>
</div>
<!-- Pass geometry detail (shown when pass selected) -->
<div class="wxsat-pass-geometry" id="wxsatPassGeometry" style="display: none;">
<div class="wxsat-geom-event">
<span class="wxsat-geom-label">AOS</span>
<span class="wxsat-geom-time" id="wxsatGeomAosTime">--</span>
<span class="wxsat-geom-az" id="wxsatGeomAosAz">--</span>
</div>
<div class="wxsat-geom-arrow">&rarr;</div>
<div class="wxsat-geom-event wxsat-geom-tca">
<span class="wxsat-geom-label">TCA</span>
<span class="wxsat-geom-time" id="wxsatGeomTcaEl">--</span>
<span class="wxsat-geom-az" id="wxsatGeomTcaAz">--</span>
</div>
<div class="wxsat-geom-arrow">&rarr;</div>
<div class="wxsat-geom-event">
<span class="wxsat-geom-label">LOS</span>
<span class="wxsat-geom-time" id="wxsatGeomLosTime">--</span>
<span class="wxsat-geom-az" id="wxsatGeomLosAz">--</span>
</div>
<div class="wxsat-geom-meta" id="wxsatGeomMeta">--</div>
</div>
<div class="wxsat-passes-list" id="wxsatPassesList">
<div class="wxsat-gallery-empty">
<p>Set location to see pass predictions</p>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="width: 32px; height: 32px; margin-bottom: 8px; opacity: 0.3;">
<circle cx="12" cy="12" r="10"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/>
</svg>
<p style="font-size: 12px; font-weight: 600; color: var(--text-secondary);">Set your location</p>
<p style="font-size: 11px; margin-top: 4px;">Enter lat/lon in the strip bar above or click the GPS button to load pass predictions</p>
</div>
</div>
</div>
@@ -11,6 +11,74 @@
</p>
</div>
<!-- Getting Started Guide -->
<div class="section">
<h3>Getting Started</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.6;">
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">What are Meteor satellites?</strong>
<p style="margin-top: 6px;">
Russia's <strong style="color: var(--text-primary);">Meteor-M2-3</strong> and <strong style="color: var(--text-primary);">Meteor-M2-4</strong>
are polar-orbiting weather satellites that continuously transmit real-time color imagery (clouds, land, sea) at <strong style="color: var(--text-primary);">137.900 MHz</strong>
using the LRPT digital format. Unlike old analog NOAA APT, LRPT produces sharp, full-color images.
</p>
<p style="margin-top: 6px;">
They orbit ~830 km high, circling the Earth every ~100 minutes in a near-polar sun-synchronous orbit.
From any location, you'll typically get <strong style="color: var(--text-primary);">4&ndash;8 usable passes per day</strong>,
each lasting 8&ndash;15 minutes as the satellite crosses your sky.
</p>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">Step-by-step</strong>
<ol style="margin: 6px 0 0 16px; padding: 0;">
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Set your location</strong> &mdash; Enter your lat/lon in the strip bar above (or click GPS). This is required for pass predictions.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Check upcoming passes</strong> &mdash; The pass list shows when each satellite will be overhead. Higher max elevation = better signal. Passes above 30&deg; are "good", above 60&deg; are "excellent".</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Prepare your antenna</strong> &mdash; You need a 137 MHz antenna outdoors with clear sky (see Antenna Guide below). A $5 V-dipole works for high passes.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Click Capture</strong> on a pass card when it's about to start, or enable <strong style="color: var(--text-primary);">AUTO</strong> to let the scheduler capture automatically.</li>
<li style="margin-bottom: 4px;"><strong style="color: var(--text-primary);">Wait for images</strong> &mdash; SatDump will tune, lock the signal, and decode. Decoded images appear in the gallery after the pass completes.</li>
</ol>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px; margin-bottom: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">When to look</strong>
<ul style="margin: 6px 0 0 14px; padding: 0;">
<li><strong style="color: var(--text-primary);">Best passes:</strong> When the satellite is high overhead (&gt;30&deg; elevation). The countdown timer shows the next one.</li>
<li><strong style="color: var(--text-primary);">Day vs night:</strong> Daytime passes produce visible-light imagery. Night passes still work but only produce infrared/thermal images.</li>
<li><strong style="color: var(--text-primary);">Both satellites share 137.9 MHz</strong> so they won't transmit at the same time. You'll see separate pass predictions for each.</li>
<li><strong style="color: var(--text-primary);">Pass direction:</strong> Meteor satellites travel roughly north&rarr;south or south&rarr;north. The pass cards show the exact rise/set direction.</li>
</ul>
</div>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 10px;">
<strong style="color: var(--accent-cyan); font-size: 12px;">What you need</strong>
<table style="width: 100%; margin-top: 6px; font-size: 10px; border-collapse: collapse;">
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">SDR receiver</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RTL-SDR V3/V4 ($25-35)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Antenna</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz V-dipole ($5 DIY) or QFH ($20-30)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">LNA (optional)</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">137 MHz filtered, at antenna ($15-25)</td>
</tr>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 3px 4px; color: var(--text-dim);">Location</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Outdoors, clear sky view</td>
</tr>
<tr>
<td style="padding: 3px 4px; color: var(--text-dim);">No hardware?</td>
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">Use <em>Load Demo Data</em> below to explore the UI</td>
</tr>
</table>
</div>
</div>
</div>
<div class="section">
<h3>Satellite</h3>
<div class="form-group">
@@ -33,10 +101,13 @@
</div>
</div>
<!-- Antenna Guide - detailed -->
<!-- Antenna Guide - detailed (collapsed by default) -->
<div class="section">
<h3>Antenna Guide</h3>
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<h3 onclick="this.parentElement.querySelector('.wxsat-antenna-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Antenna Guide
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-antenna-body wxsat-test-decode-body collapsed" style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
<p style="margin-bottom: 10px; color: var(--accent-cyan); font-weight: 600;">
137 MHz band &mdash; your stock SDR antenna will NOT work.
@@ -174,14 +245,22 @@
<div class="section">
<h3 onclick="this.parentElement.querySelector('.wxsat-test-decode-body').classList.toggle('collapsed'); this.querySelector('.wxsat-collapse-icon').classList.toggle('collapsed')" style="cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
Test Decode (File)
Offline Decode (IQ File)
<span class="wxsat-collapse-icon collapsed" style="font-size: 10px; transition: transform 0.2s; display: inline-block;">&#9660;</span>
</h3>
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Decode a pre-recorded Meteor IQ file without SDR hardware.
Shared ground-station recordings are also accepted by the backend.
Decode a pre-recorded Meteor IQ baseband file without SDR hardware.
You need an actual <code>.raw</code>, <code>.sigmf-data</code>, or <code>.wav</code> recording of a Meteor pass.
</p>
<div style="background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; margin-bottom: 10px; font-size: 10px; color: var(--text-dim); line-height: 1.5;">
<strong style="color: var(--text-secondary);">Where to get a test file:</strong>
<ul style="margin: 4px 0 0 14px; padding: 0;">
<li>Record one yourself with <code>rtl_sdr -f 137900000 -s 2400000 meteor.raw</code> during a pass</li>
<li>Download samples from <a href="https://www.sigidwiki.com/wiki/Meteor-M_LRPT" target="_blank" rel="noopener" style="color: var(--accent-cyan);">SigID Wiki</a> or <a href="https://www.sondehub.org/" target="_blank" rel="noopener" style="color: var(--accent-cyan);">community forums</a></li>
<li>Place the file in <code>data/weather_sat/</code> on the server</li>
</ul>
</div>
<div class="form-group">
<label>Satellite</label>
<select id="wxsatTestSatSelect" class="mode-select">
@@ -191,8 +270,8 @@
</select>
</div>
<div class="form-group">
<label>File Path (server-side)</label>
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
<label>File Path (server-side, relative to app root)</label>
<input type="text" id="wxsatTestFilePath" placeholder="data/weather_sat/my_recording.raw" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
</div>
<div class="form-group">
<label>Sample Rate</label>
@@ -203,7 +282,7 @@
</select>
</div>
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
Test Decode
Decode File
</button>
</div>
</div>