Add ADS-B range rings, statistics, geolocation, and reception stats

New features for both ADS-B tab and dashboard:

1. Range Rings - Concentric circles at 25, 50, 100, 150, 200nm showing
   distance from observer location with dashed styling and labels

2. Statistics Panel - Tracks:
   - Max range achieved (with aircraft that achieved it)
   - Total unique aircraft seen this session
   - Messages per second rate
   - Busiest hour of tracking

3. Geolocation Button - Gets user's actual GPS location to:
   - Center the map on their position
   - Calculate accurate distances for range statistics
   - Position range rings correctly

4. Reception Statistics - Real-time msg/sec counter to monitor
   receiver performance

All features work on both the ADS-B tab and full-screen dashboard.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
James Smith
2025-12-30 18:58:10 +00:00
parent bde977c73d
commit 2736c0c107
2 changed files with 466 additions and 0 deletions
+212
View File
@@ -599,6 +599,32 @@
<div class="stat-value" id="statAvgAlt">0</div>
<div class="stat-label">Avg Alt (ft)</div>
</div>
<div class="stat-box">
<div class="stat-value"><span id="statMaxRange">0.0</span> nm</div>
<div class="stat-label">Max Range</div>
</div>
<div class="stat-box">
<div class="stat-value" id="statTotalSeen">0</div>
<div class="stat-label">Total Seen</div>
</div>
<div class="stat-box">
<div class="stat-value"><span id="statMsgRate">0.0</span>/s</div>
<div class="stat-label">Msg Rate</div>
</div>
<div class="stat-box">
<div class="stat-value" id="statBusiestHour">--</div>
<div class="stat-label">Busiest Hour</div>
</div>
</div>
<div style="padding: 0 15px 10px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12px; color: var(--text-primary); margin-bottom: 8px;">
<input type="checkbox" id="showRangeRings" onchange="drawRangeRings()" style="accent-color: var(--accent-green);">
Show Range Rings
</label>
<button id="geolocateBtn" onclick="getGeolocation()" style="width: 100%; padding: 8px; background: rgba(0,255,136,0.2); border: 1px solid rgba(0,255,136,0.3); border-radius: 4px; color: var(--accent-green); font-family: 'JetBrains Mono', monospace; font-size: 11px; cursor: pointer; margin-bottom: 5px;">
📍 My Location
</button>
<div style="text-align: center; font-size: 10px; color: var(--text-secondary);" id="observerLoc">51.5074, -0.1278</div>
</div>
<div style="padding: 0 15px 10px;">
<label style="font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text-secondary); display: block; margin-bottom: 5px;">Aircraft Filter</label>
@@ -664,6 +690,22 @@
let alertedAircraft = {}; // Track aircraft that have already triggered alerts
let alertsEnabled = true; // Toggle for audio alerts
// Statistics tracking
let stats = {
totalAircraftSeen: new Set(),
maxRange: 0,
maxRangeAircraft: null,
hourlyCount: {},
messagesPerSecond: 0,
messageTimestamps: [],
sessionStart: null
};
// Observer location and range rings
let observerLocation = { lat: 51.5074, lon: -0.1278 };
let rangeRingsLayer = null;
let observerMarker = null;
// Audio alert system using Web Audio API
let audioContext = null;
function getAudioContext() {
@@ -755,6 +797,171 @@
alertsEnabled = document.getElementById('alertToggle').checked;
}
// Calculate distance between two points in nautical miles
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065; // Earth radius in nautical miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Update statistics
function updateStatistics(icao, ac) {
if (!ac.lat || !ac.lon) return;
stats.totalAircraftSeen.add(icao);
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > stats.maxRange) {
stats.maxRange = distance;
stats.maxRangeAircraft = ac.callsign || icao;
}
const hour = new Date().getHours();
if (!stats.hourlyCount[hour]) {
stats.hourlyCount[hour] = new Set();
}
stats.hourlyCount[hour].add(icao);
const now = Date.now();
stats.messageTimestamps.push(now);
stats.messageTimestamps = stats.messageTimestamps.filter(t => now - t < 5000);
stats.messagesPerSecond = stats.messageTimestamps.length / 5;
updateStatsDisplay();
}
function updateStatsDisplay() {
const maxRangeEl = document.getElementById('statMaxRange');
const totalSeenEl = document.getElementById('statTotalSeen');
const msgRateEl = document.getElementById('statMsgRate');
const busiestEl = document.getElementById('statBusiestHour');
if (maxRangeEl) maxRangeEl.textContent = stats.maxRange.toFixed(1);
if (totalSeenEl) totalSeenEl.textContent = stats.totalAircraftSeen.size;
if (msgRateEl) msgRateEl.textContent = stats.messagesPerSecond.toFixed(1);
if (busiestEl) {
let busiestHour = '--';
let maxCount = 0;
Object.entries(stats.hourlyCount).forEach(([hour, set]) => {
if (set.size > maxCount) {
maxCount = set.size;
busiestHour = `${hour}:00`;
}
});
busiestEl.textContent = busiestHour;
}
}
function resetStats() {
stats = {
totalAircraftSeen: new Set(),
maxRange: 0,
maxRangeAircraft: null,
hourlyCount: {},
messagesPerSecond: 0,
messageTimestamps: [],
sessionStart: Date.now()
};
updateStatsDisplay();
}
// Draw range rings on the map
function drawRangeRings() {
if (!radarMap) return;
if (rangeRingsLayer) {
radarMap.removeLayer(rangeRingsLayer);
rangeRingsLayer = null;
}
const showRings = document.getElementById('showRangeRings')?.checked;
if (!showRings) return;
rangeRingsLayer = L.layerGroup();
const distances = [25, 50, 100, 150, 200];
distances.forEach(nm => {
const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters,
color: '#00ff88',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.4,
dashArray: '5, 5'
});
const labelLat = observerLocation.lat + (nm * 0.0166);
const label = L.marker([labelLat, observerLocation.lon], {
icon: L.divIcon({
className: 'range-label',
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${nm} nm</span>`,
iconSize: [40, 12],
iconAnchor: [20, 6]
})
});
rangeRingsLayer.addLayer(circle);
rangeRingsLayer.addLayer(label);
});
// Observer marker
if (observerMarker) radarMap.removeLayer(observerMarker);
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(radarMap);
rangeRingsLayer.addTo(radarMap);
}
// Get user geolocation
function getGeolocation() {
if (!navigator.geolocation) {
alert('Geolocation not supported');
return;
}
const btn = document.getElementById('geolocateBtn');
if (btn) btn.textContent = '📍 Locating...';
navigator.geolocation.getCurrentPosition(
(position) => {
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
const locEl = document.getElementById('observerLoc');
if (locEl) locEl.textContent = `${observerLocation.lat.toFixed(4)}, ${observerLocation.lon.toFixed(4)}`;
if (radarMap) {
radarMap.setView([observerLocation.lat, observerLocation.lon], 8);
}
drawRangeRings();
if (btn) btn.textContent = '📍 My Location';
},
(error) => {
alert('Location error: ' + error.message);
if (btn) btn.textContent = '📍 My Location';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// Military aircraft detection (specific military-only sub-ranges)
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' }, // US Military
@@ -890,7 +1097,9 @@
}
if (data.status === 'success' || data.status === 'started' || data.status === 'already_running' || data.status === 'error' && data.message && data.message.includes('already')) {
resetStats(); // Reset statistics for new session
startEventStream();
drawRangeRings(); // Draw range rings if enabled
isTracking = true;
btn.textContent = 'STOP TRACKING';
btn.classList.add('active');
@@ -1005,6 +1214,9 @@
// Check for military/emergency aircraft and alert
checkAndAlertAircraft(icao, aircraft[icao]);
// Update statistics
updateStatistics(icao, aircraft[icao]);
// Queue marker update
if (data.lat && data.lon) {
pendingMarkerUpdates.add(icao);
+254
View File
@@ -3202,6 +3202,16 @@
<input type="checkbox" id="adsbEnableClustering" onchange="toggleAircraftClustering()">
Cluster Markers
</label>
<label>
<input type="checkbox" id="adsbShowRangeRings" onchange="drawRangeRings()">
Show Range Rings
</label>
</div>
<button class="preset-btn" id="adsbGeolocateBtn" onclick="getAdsbGeolocation()" style="width: 100%; margin-top: 10px;">
📍 My Location
</button>
<div class="info-text" style="margin-top: 5px; text-align: center;">
<span id="adsbObserverLoc">51.5074, -0.1278</span>
</div>
<div class="form-group" style="margin-top: 10px;">
<label>Aircraft Filter</label>
@@ -3220,6 +3230,28 @@
</div>
</div>
<div class="section">
<h3>Reception Statistics</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 11px;">
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Max Range</div>
<div id="adsbMaxRange" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0 nm</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Total Seen</div>
<div id="adsbTotalSeen" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Msg Rate</div>
<div id="adsbMsgRate" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">0.0/s</div>
</div>
<div style="background: rgba(0,212,255,0.1); padding: 8px; border-radius: 4px; text-align: center;">
<div style="color: var(--text-secondary); font-size: 9px; text-transform: uppercase;">Busiest Hour</div>
<div id="adsbBusiestHour" style="color: var(--accent-cyan); font-size: 14px; font-weight: bold;">--</div>
</div>
</div>
</div>
<div class="info-text" style="margin-top: 8px; display: grid; grid-template-columns: auto auto; gap: 4px 8px; align-items: center;" id="adsbToolStatus">
<span>dump1090:</span><span class="tool-status" id="dump1090Status">Checking...</span>
<span>rtl_adsb:</span><span class="tool-status" id="rtlAdsbStatus">Checking...</span>
@@ -3832,6 +3864,22 @@
let alertedAircraft = {}; // Track aircraft that have already triggered alerts
let adsbAlertsEnabled = true; // Toggle for audio alerts
// ADS-B Statistics tracking
let adsbStats = {
totalAircraftSeen: new Set(), // Unique ICAO codes seen
maxRange: 0, // Max distance in nm
maxRangeAircraft: null, // Aircraft that achieved max range
hourlyCount: {}, // Hour -> count of aircraft
messagesPerSecond: 0, // Current msg/sec rate
messageTimestamps: [], // Recent message timestamps for rate calc
sessionStart: null // When tracking started
};
// Observer location for distance calculations
let observerLocation = { lat: 51.5074, lon: -0.1278 }; // Default London
let rangeRingsLayer = null;
let observerMarkerAdsb = null;
// Audio alert system using Web Audio API (uses shared audioContext declared later)
function getAdsbAudioContext() {
if (!window.adsbAudioCtx) {
@@ -8063,6 +8111,207 @@
}
}
// Calculate distance between two points in nautical miles
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065; // Earth radius in nautical miles
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
// Update ADS-B statistics
function updateAdsbStatistics(icao, aircraft) {
if (!aircraft.lat || !aircraft.lon) return;
// Track unique aircraft
adsbStats.totalAircraftSeen.add(icao);
// Calculate distance from observer
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
aircraft.lat, aircraft.lon
);
// Update max range if this is further
if (distance > adsbStats.maxRange) {
adsbStats.maxRange = distance;
adsbStats.maxRangeAircraft = aircraft.callsign || icao;
}
// Track hourly aircraft count
const hour = new Date().getHours();
if (!adsbStats.hourlyCount[hour]) {
adsbStats.hourlyCount[hour] = new Set();
}
adsbStats.hourlyCount[hour].add(icao);
// Update messages per second calculation
const now = Date.now();
adsbStats.messageTimestamps.push(now);
// Keep only last 5 seconds of timestamps
adsbStats.messageTimestamps = adsbStats.messageTimestamps.filter(t => now - t < 5000);
adsbStats.messagesPerSecond = adsbStats.messageTimestamps.length / 5;
// Update stats display
updateStatsDisplay();
}
// Update the statistics display
function updateStatsDisplay() {
const maxRangeEl = document.getElementById('adsbMaxRange');
const totalSeenEl = document.getElementById('adsbTotalSeen');
const msgRateEl = document.getElementById('adsbMsgRate');
const busiestHourEl = document.getElementById('adsbBusiestHour');
if (maxRangeEl) {
maxRangeEl.textContent = `${adsbStats.maxRange.toFixed(1)} nm`;
if (adsbStats.maxRangeAircraft) {
maxRangeEl.title = `Aircraft: ${adsbStats.maxRangeAircraft}`;
}
}
if (totalSeenEl) {
totalSeenEl.textContent = adsbStats.totalAircraftSeen.size;
}
if (msgRateEl) {
msgRateEl.textContent = `${adsbStats.messagesPerSecond.toFixed(1)}/s`;
}
if (busiestHourEl) {
let busiestHour = 0;
let maxCount = 0;
Object.entries(adsbStats.hourlyCount).forEach(([hour, aircraftSet]) => {
if (aircraftSet.size > maxCount) {
maxCount = aircraftSet.size;
busiestHour = hour;
}
});
busiestHourEl.textContent = maxCount > 0 ? `${busiestHour}:00 (${maxCount})` : '--';
}
}
// Draw range rings on the map
function drawRangeRings() {
if (!aircraftMap) return;
// Remove existing rings
if (rangeRingsLayer) {
aircraftMap.removeLayer(rangeRingsLayer);
}
const showRings = document.getElementById('adsbShowRangeRings')?.checked;
if (!showRings) return;
rangeRingsLayer = L.layerGroup();
// Range ring distances in nautical miles
const distances = [25, 50, 100, 150, 200];
distances.forEach(nm => {
// Convert nm to meters for Leaflet circle
const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters,
color: '#00d4ff',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.4,
dashArray: '5, 5'
});
// Add label
const labelLatLng = L.latLng(
observerLocation.lat + (nm * 0.0166), // Approx degrees per nm
observerLocation.lon
);
const label = L.marker(labelLatLng, {
icon: L.divIcon({
className: 'range-ring-label',
html: `<span style="color: #00d4ff; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${nm} nm</span>`,
iconSize: [40, 12],
iconAnchor: [20, 6]
})
});
rangeRingsLayer.addLayer(circle);
rangeRingsLayer.addLayer(label);
});
// Add observer marker
if (observerMarkerAdsb) {
aircraftMap.removeLayer(observerMarkerAdsb);
}
observerMarkerAdsb = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(aircraftMap);
rangeRingsLayer.addTo(aircraftMap);
}
// Get user's geolocation
function getAdsbGeolocation() {
if (!navigator.geolocation) {
alert('Geolocation is not supported by your browser');
return;
}
const btn = document.getElementById('adsbGeolocateBtn');
if (btn) btn.textContent = '📍 Locating...';
navigator.geolocation.getCurrentPosition(
(position) => {
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Update display
const locDisplay = document.getElementById('adsbObserverLoc');
if (locDisplay) {
locDisplay.textContent = `${observerLocation.lat.toFixed(4)}, ${observerLocation.lon.toFixed(4)}`;
}
// Center map on location
if (aircraftMap) {
aircraftMap.setView([observerLocation.lat, observerLocation.lon], 8);
aircraftMap._userInteracted = true; // Prevent auto-fit
}
// Redraw range rings
drawRangeRings();
if (btn) btn.textContent = '📍 My Location';
showInfo(`Location set: ${observerLocation.lat.toFixed(4)}, ${observerLocation.lon.toFixed(4)}`);
},
(error) => {
if (btn) btn.textContent = '📍 My Location';
alert('Unable to get your location: ' + error.message);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// Reset ADS-B statistics
function resetAdsbStats() {
adsbStats = {
totalAircraftSeen: new Set(),
maxRange: 0,
maxRangeAircraft: null,
hourlyCount: {},
messagesPerSecond: 0,
messageTimestamps: [],
sessionStart: Date.now()
};
updateStatsDisplay();
}
function startAdsbScan() {
const gain = document.getElementById('adsbGain').value;
const device = getSelectedDevice();
@@ -8080,7 +8329,10 @@
document.getElementById('stopAdsbBtn').style.display = 'block';
document.getElementById('statusDot').className = 'status-dot active';
document.getElementById('statusText').textContent = 'ADS-B Tracking';
resetAdsbStats(); // Reset statistics for new session
adsbStats.sessionStart = Date.now();
startAdsbStream();
drawRangeRings(); // Draw range rings if enabled
} else {
alert('Error: ' + data.message);
}
@@ -8137,6 +8389,8 @@
pendingAircraftData.push(data);
// Check for military/emergency aircraft and alert
checkAndAlertAircraft(data.icao, adsbAircraft[data.icao]);
// Update statistics
updateAdsbStatistics(data.icao, adsbAircraft[data.icao]);
// Use batched update instead of immediate
scheduleAircraftUIUpdate();
}