mirror of
https://github.com/smittix/intercept.git
synced 2026-07-03 23:33:38 -07:00
90b455aa6c
Reusable SVG bar waveform (SignalWaveform.Live) that animates in response to incoming SSE data — idle breathing when stopped, active oscillation proportional to telemetry update frequency, smooth decay on signal loss. Integrated into radiosonde Status section with ping() on each balloon message and stop() on tracking stop. Also hardens the fetch error path to show a readable message instead of a JSON parse error when the server returns HTML. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
485 lines
23 KiB
HTML
485 lines
23 KiB
HTML
<!-- RADIOSONDE WEATHER BALLOON TRACKING MODE -->
|
|
<div id="radiosondeMode" class="mode-content">
|
|
<div class="section">
|
|
<h3>Radiosonde Decoder</h3>
|
|
<div class="info-text" style="margin-bottom: 15px;">
|
|
Track weather balloons via radiosonde telemetry on 400–406 MHz. Decodes position, altitude, temperature, humidity, and pressure.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Settings</h3>
|
|
<div class="form-group">
|
|
<label>Region / Frequency Band</label>
|
|
<select id="radiosondeRegionSelect" onchange="updateRadiosondeFreqRange()">
|
|
<option value="global" selected>Global (400–406 MHz)</option>
|
|
<option value="eu">Europe (400–403 MHz)</option>
|
|
<option value="us">US (400–406 MHz)</option>
|
|
<option value="au">Australia (400–403 MHz)</option>
|
|
<option value="custom">Custom…</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" id="radiosondeCustomFreqGroup" style="display: none;">
|
|
<label>Frequency Range (MHz)</label>
|
|
<div style="display: flex; gap: 8px; align-items: center;">
|
|
<input type="number" id="radiosondeFreqMin" value="400.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Min">
|
|
<span style="color: var(--text-dim);">–</span>
|
|
<input type="number" id="radiosondeFreqMax" value="406.0" min="380" max="410" step="0.1" style="width: 50%;" placeholder="Max">
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Gain (dB, 0 = auto)</label>
|
|
<input type="number" id="radiosondeGainInput" value="40" min="0" max="50" placeholder="0-50">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h3>Status</h3>
|
|
<div id="radiosondeWaveformContainer" style="margin-bottom: 8px;"></div>
|
|
<div id="radiosondeStatusDisplay" class="info-text">
|
|
<p>Status: <span id="radiosondeStatusText" style="color: var(--accent-yellow);">Standby</span></p>
|
|
<p>Balloons: <span id="radiosondeBalloonCount">0</span></p>
|
|
<p>Last update: <span id="radiosondeLastUpdate">—</span></p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Antenna Guide -->
|
|
<div class="section">
|
|
<h3>Antenna Guide</h3>
|
|
<div style="font-size: 11px; color: var(--text-dim); line-height: 1.5;">
|
|
<p style="margin-bottom: 8px; color: var(--accent-cyan); font-weight: 600;">
|
|
400 MHz meteorological band — stock SDR antenna may work for nearby launches
|
|
</p>
|
|
|
|
<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;">Simple Quarter-Wave</strong>
|
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
<li><strong style="color: var(--text-primary);">Element length:</strong> ~18.7 cm (quarter-wave at 400 MHz)</li>
|
|
<li><strong style="color: var(--text-primary);">Material:</strong> Wire or copper rod</li>
|
|
<li><strong style="color: var(--text-primary);">Orientation:</strong> Vertical</li>
|
|
<li><strong style="color: var(--text-primary);">Placement:</strong> Outdoors, as high as possible with clear sky view</li>
|
|
</ul>
|
|
</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;">Tips</strong>
|
|
<ul style="margin: 6px 0 0 14px; padding: 0;">
|
|
<li><strong style="color: var(--text-primary);">Range:</strong> 200+ km with LNA and good antenna placement</li>
|
|
<li><strong style="color: var(--text-primary);">LNA:</strong> Recommended — mount near antenna for best results</li>
|
|
<li><strong style="color: var(--text-primary);">Launches:</strong> Typically 2×/day at 00Z and 12Z from weather stations</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;">Quick Reference</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);">Frequency band</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">400–406 MHz</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Quarter-wave length</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">18.7 cm</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Common types</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RS41, RS92, DFM, M10</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Max altitude</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~35 km (115,000 ft)</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 3px 4px; color: var(--text-dim);">Flight duration</td>
|
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~90 min ascent</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="run-btn" id="startRadiosondeBtn" onclick="startRadiosondeTracking()">
|
|
Start Radiosonde Tracking
|
|
</button>
|
|
<button class="stop-btn" id="stopRadiosondeBtn" onclick="stopRadiosondeTracking()" style="display: none;">
|
|
Stop Radiosonde Tracking
|
|
</button>
|
|
</div>
|
|
|
|
<script>
|
|
let radiosondeEventSource = null;
|
|
let radiosondeBalloons = {};
|
|
let radiosondeWaveform = null;
|
|
|
|
// Initialise signal waveform once the component script has loaded
|
|
function initRadiosondeWaveform() {
|
|
if (radiosondeWaveform) return;
|
|
if (typeof SignalWaveform === 'undefined') return;
|
|
const el = document.getElementById('radiosondeWaveformContainer');
|
|
if (el) {
|
|
radiosondeWaveform = new SignalWaveform.Live(el, {
|
|
width: 200,
|
|
height: 40,
|
|
barCount: 24,
|
|
color: '#00e5ff',
|
|
decayMs: 3000,
|
|
idleAmplitude: 0.05,
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateRadiosondeFreqRange() {
|
|
const region = document.getElementById('radiosondeRegionSelect').value;
|
|
const customGroup = document.getElementById('radiosondeCustomFreqGroup');
|
|
const minInput = document.getElementById('radiosondeFreqMin');
|
|
const maxInput = document.getElementById('radiosondeFreqMax');
|
|
|
|
const presets = {
|
|
global: [400.0, 406.0],
|
|
eu: [400.0, 403.0],
|
|
us: [400.0, 406.0],
|
|
au: [400.0, 403.0],
|
|
};
|
|
|
|
if (region === 'custom') {
|
|
customGroup.style.display = 'block';
|
|
} else {
|
|
customGroup.style.display = 'none';
|
|
if (presets[region]) {
|
|
minInput.value = presets[region][0];
|
|
maxInput.value = presets[region][1];
|
|
}
|
|
}
|
|
}
|
|
|
|
function startRadiosondeTracking() {
|
|
const gain = document.getElementById('radiosondeGainInput').value || '40';
|
|
const device = document.getElementById('deviceSelect')?.value || '0';
|
|
const freqMin = parseFloat(document.getElementById('radiosondeFreqMin').value) || 400.0;
|
|
const freqMax = parseFloat(document.getElementById('radiosondeFreqMax').value) || 406.0;
|
|
|
|
fetch('/radiosonde/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
device,
|
|
gain,
|
|
freq_min: freqMin,
|
|
freq_max: freqMax,
|
|
bias_t: typeof getBiasTEnabled === 'function' ? getBiasTEnabled() : false,
|
|
latitude: radiosondeStationLocation.lat,
|
|
longitude: radiosondeStationLocation.lon,
|
|
})
|
|
})
|
|
.then(r => {
|
|
if (!r.ok && r.headers.get('content-type')?.indexOf('application/json') === -1) {
|
|
throw new Error(`Server error (${r.status}). Check that the backend is running.`);
|
|
}
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'started' || data.status === 'already_running') {
|
|
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
|
startRadiosondeSSE();
|
|
} else {
|
|
alert(data.message || 'Failed to start radiosonde tracking');
|
|
}
|
|
})
|
|
.catch(err => alert('Error: ' + err.message));
|
|
}
|
|
|
|
function stopRadiosondeTracking() {
|
|
// Update UI immediately so the user sees feedback
|
|
document.getElementById('startRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Stopping...';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-yellow)';
|
|
|
|
if (radiosondeEventSource) {
|
|
radiosondeEventSource.close();
|
|
radiosondeEventSource = null;
|
|
}
|
|
if (radiosondeWaveform) radiosondeWaveform.stop();
|
|
|
|
fetch('/radiosonde/stop', { method: 'POST' })
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
|
document.getElementById('radiosondeBalloonCount').textContent = '0';
|
|
document.getElementById('radiosondeLastUpdate').textContent = '\u2014';
|
|
radiosondeBalloons = {};
|
|
// Clear map markers
|
|
if (typeof radiosondeMap !== 'undefined' && radiosondeMap) {
|
|
radiosondeMarkers.forEach(m => radiosondeMap.removeLayer(m));
|
|
radiosondeMarkers.clear();
|
|
radiosondeTracks.forEach(t => radiosondeMap.removeLayer(t));
|
|
radiosondeTracks.clear();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
document.getElementById('radiosondeStatusText').textContent = 'Standby';
|
|
});
|
|
}
|
|
|
|
function startRadiosondeSSE() {
|
|
if (radiosondeEventSource) radiosondeEventSource.close();
|
|
|
|
radiosondeEventSource = new EventSource('/radiosonde/stream');
|
|
radiosondeEventSource.onmessage = function(e) {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.type === 'balloon') {
|
|
if (radiosondeWaveform) radiosondeWaveform.ping();
|
|
radiosondeBalloons[data.id] = data;
|
|
document.getElementById('radiosondeBalloonCount').textContent = Object.keys(radiosondeBalloons).length;
|
|
const now = new Date();
|
|
document.getElementById('radiosondeLastUpdate').textContent =
|
|
now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
updateRadiosondeMap(data);
|
|
updateRadiosondeCards();
|
|
}
|
|
} catch (err) {}
|
|
};
|
|
|
|
radiosondeEventSource.onerror = function() {
|
|
setTimeout(() => {
|
|
const panel = document.getElementById('radiosondeMode');
|
|
if (panel && panel.classList.contains('active') &&
|
|
document.getElementById('stopRadiosondeBtn').style.display === 'block') {
|
|
startRadiosondeSSE();
|
|
}
|
|
}, 2000);
|
|
};
|
|
}
|
|
|
|
// Map management
|
|
let radiosondeMap = null;
|
|
let radiosondeMarkers = new Map();
|
|
let radiosondeTracks = new Map();
|
|
let radiosondeTrackPoints = new Map();
|
|
let radiosondeStationLocation = { lat: 0, lon: 0 };
|
|
let radiosondeStationMarker = null;
|
|
|
|
function initRadiosondeMap() {
|
|
if (radiosondeMap) return;
|
|
const container = document.getElementById('radiosondeMapContainer');
|
|
if (!container) return;
|
|
|
|
// Resolve observer location
|
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
|
radiosondeStationLocation = ObserverLocation.getForModule('radiosonde_observerLocation');
|
|
}
|
|
const hasLocation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
|
|
|
radiosondeMap = L.map('radiosondeMapContainer', {
|
|
center: hasLocation ? [radiosondeStationLocation.lat, radiosondeStationLocation.lon] : [40, -95],
|
|
zoom: hasLocation ? 7 : 4,
|
|
zoomControl: true,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
|
attribution: '© OpenStreetMap © CARTO',
|
|
maxZoom: 18,
|
|
}).addTo(radiosondeMap);
|
|
|
|
// Add station marker if we have a location
|
|
if (hasLocation) {
|
|
radiosondeStationMarker = L.circleMarker(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], {
|
|
radius: 8,
|
|
fillColor: '#00e5ff',
|
|
color: '#00e5ff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.5,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeStationMarker.bindTooltip('Station', { permanent: false, direction: 'top' });
|
|
}
|
|
|
|
// Try GPS for live position updates
|
|
fetch('/gps/position')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'ok' && data.position && data.position.latitude != null) {
|
|
radiosondeStationLocation = { lat: data.position.latitude, lon: data.position.longitude };
|
|
const ll = [data.position.latitude, data.position.longitude];
|
|
if (radiosondeStationMarker) {
|
|
radiosondeStationMarker.setLatLng(ll);
|
|
} else {
|
|
radiosondeStationMarker = L.circleMarker(ll, {
|
|
radius: 8,
|
|
fillColor: '#00e5ff',
|
|
color: '#00e5ff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.5,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeStationMarker.bindTooltip('Station (GPS)', { permanent: false, direction: 'top' });
|
|
}
|
|
if (!radiosondeMap._gpsInitialized) {
|
|
radiosondeMap.setView(ll, 7);
|
|
radiosondeMap._gpsInitialized = true;
|
|
}
|
|
// Re-render cards with updated distances
|
|
updateRadiosondeCards();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function updateRadiosondeMap(balloon) {
|
|
if (!radiosondeMap || !balloon.lat || !balloon.lon) return;
|
|
|
|
const id = balloon.id;
|
|
const latlng = [balloon.lat, balloon.lon];
|
|
|
|
// Altitude-based colour coding
|
|
const alt = balloon.alt || 0;
|
|
let colour;
|
|
if (alt < 5000) colour = '#00ff88';
|
|
else if (alt < 15000) colour = '#00ccff';
|
|
else if (alt < 25000) colour = '#ff9900';
|
|
else colour = '#ff3366';
|
|
|
|
// Update or create marker
|
|
if (radiosondeMarkers.has(id)) {
|
|
radiosondeMarkers.get(id).setLatLng(latlng);
|
|
} else {
|
|
const marker = L.circleMarker(latlng, {
|
|
radius: 7,
|
|
color: colour,
|
|
fillColor: colour,
|
|
fillOpacity: 0.8,
|
|
weight: 2,
|
|
}).addTo(radiosondeMap);
|
|
radiosondeMarkers.set(id, marker);
|
|
}
|
|
|
|
// Update marker colour based on altitude
|
|
radiosondeMarkers.get(id).setStyle({ color: colour, fillColor: colour });
|
|
|
|
// Build popup content
|
|
const altStr = alt ? `${Math.round(alt).toLocaleString()} m` : '--';
|
|
const tempStr = balloon.temp != null ? `${balloon.temp.toFixed(1)} °C` : '--';
|
|
const humStr = balloon.humidity != null ? `${balloon.humidity.toFixed(0)}%` : '--';
|
|
const velStr = balloon.vel_v != null ? `${balloon.vel_v.toFixed(1)} m/s` : '--';
|
|
let distStr = '';
|
|
if ((radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0) && balloon.lat && balloon.lon) {
|
|
const distM = radiosondeMap.distance(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon], latlng);
|
|
distStr = `Dist: ${(distM / 1000).toFixed(1)} km<br>`;
|
|
}
|
|
radiosondeMarkers.get(id).bindPopup(
|
|
`<strong>${id}</strong><br>` +
|
|
`Type: ${balloon.sonde_type || '--'}<br>` +
|
|
`Alt: ${altStr}<br>` +
|
|
`Temp: ${tempStr} | Hum: ${humStr}<br>` +
|
|
`Vert: ${velStr}<br>` +
|
|
distStr +
|
|
(balloon.freq ? `Freq: ${balloon.freq.toFixed(3)} MHz` : '')
|
|
);
|
|
|
|
// Track polyline
|
|
if (!radiosondeTrackPoints.has(id)) {
|
|
radiosondeTrackPoints.set(id, []);
|
|
}
|
|
radiosondeTrackPoints.get(id).push(latlng);
|
|
|
|
if (radiosondeTracks.has(id)) {
|
|
radiosondeTracks.get(id).setLatLngs(radiosondeTrackPoints.get(id));
|
|
} else {
|
|
const track = L.polyline(radiosondeTrackPoints.get(id), {
|
|
color: colour,
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
dashArray: '4 4',
|
|
}).addTo(radiosondeMap);
|
|
radiosondeTracks.set(id, track);
|
|
}
|
|
|
|
// Auto-centre on first balloon
|
|
if (radiosondeMarkers.size === 1) {
|
|
radiosondeMap.setView(latlng, 8);
|
|
}
|
|
}
|
|
|
|
function updateRadiosondeCards() {
|
|
const container = document.getElementById('radiosondeCardContainer');
|
|
if (!container) return;
|
|
|
|
const sorted = Object.values(radiosondeBalloons).sort((a, b) => (b.alt || 0) - (a.alt || 0));
|
|
const hasStation = radiosondeStationLocation.lat !== 0 || radiosondeStationLocation.lon !== 0;
|
|
container.innerHTML = sorted.map(b => {
|
|
const alt = b.alt ? `${Math.round(b.alt).toLocaleString()} m` : '--';
|
|
const temp = b.temp != null ? `${b.temp.toFixed(1)}°C` : '--';
|
|
const hum = b.humidity != null ? `${b.humidity.toFixed(0)}%` : '--';
|
|
const press = b.pressure != null ? `${b.pressure.toFixed(1)} hPa` : '--';
|
|
const vel = b.vel_v != null ? `${b.vel_v > 0 ? '+' : ''}${b.vel_v.toFixed(1)} m/s` : '--';
|
|
const freq = b.freq ? `${b.freq.toFixed(3)} MHz` : '--';
|
|
let dist = '--';
|
|
if (hasStation && b.lat && b.lon && radiosondeMap) {
|
|
const distM = radiosondeMap.distance(
|
|
[radiosondeStationLocation.lat, radiosondeStationLocation.lon],
|
|
[b.lat, b.lon]);
|
|
dist = `${(distM / 1000).toFixed(1)} km`;
|
|
}
|
|
return `
|
|
<div class="radiosonde-card" onclick="radiosondeMap && radiosondeMap.setView([${b.lat || 0}, ${b.lon || 0}], 10)">
|
|
<div class="radiosonde-card-header">
|
|
<span class="radiosonde-serial">${b.id}</span>
|
|
<span class="radiosonde-type">${b.sonde_type || '??'}</span>
|
|
</div>
|
|
<div class="radiosonde-stats">
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${alt}</span>
|
|
<span class="radiosonde-stat-label">ALT</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${temp}</span>
|
|
<span class="radiosonde-stat-label">TEMP</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${hum}</span>
|
|
<span class="radiosonde-stat-label">HUM</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${press}</span>
|
|
<span class="radiosonde-stat-label">PRESS</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${vel}</span>
|
|
<span class="radiosonde-stat-label">VERT</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${freq}</span>
|
|
<span class="radiosonde-stat-label">FREQ</span>
|
|
</div>
|
|
<div class="radiosonde-stat">
|
|
<span class="radiosonde-stat-value">${dist}</span>
|
|
<span class="radiosonde-stat-label">DIST</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// Check initial status on load
|
|
fetch('/radiosonde/status')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.tracking_active) {
|
|
document.getElementById('startRadiosondeBtn').style.display = 'none';
|
|
document.getElementById('stopRadiosondeBtn').style.display = 'block';
|
|
document.getElementById('radiosondeStatusText').textContent = 'Tracking';
|
|
document.getElementById('radiosondeStatusText').style.color = 'var(--accent-green)';
|
|
document.getElementById('radiosondeBalloonCount').textContent = data.balloon_count || 0;
|
|
startRadiosondeSSE();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
</script>
|