Files
intercept/templates/adsb_dashboard.html
Smittix 9d0e417f2a Simplify GPS to gpsd-only and streamline UI controls
Remove direct serial GPS dongle support in favor of gpsd daemon connectivity.
The UI now auto-connects to gpsd on page load and shows a GPS indicator when connected.
Simplify ADS-B dashboard controls bar for a cleaner, more compact layout.
Add setup-dev.sh for streamlined development environment setup.

- Remove GPSReader class and NMEA parsing (utils/gps.py)
- Consolidate to GPSDClient only with auto-connect endpoint
- Add GPS indicator with pulsing dot animation
- Compact controls bar with smaller fonts and tighter spacing
- Add aircraft database download banner/functionality

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:49:58 +00:00

2013 lines
87 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
AIRCRAFT RADAR
<span>// INTERCEPT</span>
</div>
<div class="stats-badges">
<div class="stat-badge">
<span class="value" id="statTotal">0</span>
<span class="label">aircraft</span>
</div>
<div class="stat-badge">
<span class="value" id="statMaxRange">0</span>
<span class="label">nm max</span>
</div>
<div class="stat-badge">
<span class="value" id="statMsgRate">0</span>
<span class="label">msg/s</span>
</div>
</div>
<div class="status-bar">
<div class="status-item">
<div class="status-dot inactive" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="datetime" id="utcTime">--:--:-- UTC</div>
<a href="/?mode=aircraft" class="back-link">Main Dashboard</a>
</div>
</header>
<main class="dashboard">
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
<div id="radarMap"></div>
<div id="radarScope">
<canvas id="radarCanvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" id="mapViewBtn" onclick="setView('map')">MAP</button>
<button class="view-btn" id="radarViewBtn" onclick="setView('radar')">RADAR</button>
</div>
<!-- Selected Aircraft -->
<div class="panel selected-aircraft">
<div class="panel-header">
<span>SELECTED TARGET</span>
<div class="panel-indicator"></div>
</div>
<div class="selected-info" id="selectedInfo">
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>
</div>
</div>
<!-- Aircraft List -->
<div class="panel aircraft-list">
<div class="panel-header">
<span>TRACKED AIRCRAFT</span>
<div class="panel-indicator"></div>
</div>
<div class="aircraft-list-content" id="aircraftList">
<div class="no-aircraft">
<div>No aircraft detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<label title="Show aircraft trails"><input type="checkbox" id="showTrails" onchange="toggleTrails()"> Trails</label>
<label title="Show range rings"><input type="checkbox" id="showRangeRings" checked onchange="drawRangeRings()"> Rings</label>
<label title="Audio alerts"><input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()"> Alerts</label>
<select id="aircraftFilter" onchange="applyFilter()" title="Filter aircraft">
<option value="all">All</option>
<option value="military">Military</option>
<option value="civil">Civil</option>
<option value="emergency">Emergency</option>
</select>
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
<option value="50">50nm</option>
<option value="100">100nm</option>
<option value="200" selected>200nm</option>
<option value="300">300nm</option>
</select>
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 65px;" title="Latitude">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 65px;" title="Longitude">
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
<span class="remote-dump1090-controls" style="display: none;">
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 70px;">
<input type="number" id="remoteSbsPort" value="30003" style="width: 50px;">
</span>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
<div class="airband-divider"></div>
<select id="airbandFreqSelect" onchange="updateAirbandFreq()" class="airband-controls" title="Airband frequency">
<option value="121.5">121.5 Guard</option>
<option value="118.0">118.0</option>
<option value="119.1">119.1</option>
<option value="120.5">120.5</option>
<option value="123.45">123.45 Air</option>
<option value="127.85">127.85</option>
<option value="128.825">128.825</option>
<option value="132.0">132.0</option>
<option value="134.725">134.725</option>
<option value="custom">Custom</option>
</select>
<input type="number" id="airbandCustomFreq" step="0.005" placeholder="MHz" class="airband-controls" style="width: 60px; display: none;">
<select id="airbandDeviceSelect" class="airband-controls" style="width: 90px;" title="SDR for airband (use different device than tracking)">
<option value="0">Loading...</option>
</select>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" class="airband-controls" style="width: 50px;" title="Squelch">
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">▶ LISTEN</button>
<span id="airbandStatus" class="airband-controls" style="color: var(--text-muted); font-size: 9px;">OFF</span>
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
<div class="airband-visualizer" id="airbandVisualizerContainer" style="display: none;">
<div class="signal-meter">
<div class="meter-bar">
<div class="meter-fill" id="airbandSignalMeter"></div>
<div class="meter-peak" id="airbandSignalPeak"></div>
</div>
</div>
<canvas id="airbandSpectrumCanvas" width="100" height="25"></canvas>
</div>
</div>
</main>
<script>
// ============================================
// STATE
// ============================================
let radarMap = null;
let aircraft = {};
let markers = {};
let selectedIcao = null;
let eventSource = null;
let isTracking = false;
let currentFilter = 'all';
let alertedAircraft = {};
let alertsEnabled = true;
let currentView = 'map'; // 'map' or 'radar'
// Aircraft trails
let aircraftTrails = {}; // ICAO -> [{lat, lon, alt, time}, ...]
let trailLines = {}; // ICAO -> L.polyline (array of segments)
let showTrails = false;
const MAX_TRAIL_POINTS = 100;
// Radar scope
let radarScope = null;
let radarAnimationId = null;
let maxRange = 200; // nautical miles
// Statistics
let stats = {
totalAircraftSeen: new Set(),
maxRange: 0,
messagesPerSecond: 0,
messageTimestamps: []
};
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
// GPS state
let gpsConnected = false;
let gpsEventSource = null;
// ============================================
// AUDIO ALERTS
// ============================================
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function playAlertSound(type) {
if (!alertsEnabled) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'emergency') {
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
} else if (type === 'military') {
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
}
}
function showAlertBanner(message, color) {
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px; border-radius: 6px;
font-weight: bold; font-size: 13px; z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: slideDown 0.3s ease-out;
`;
banner.textContent = message;
document.body.appendChild(banner);
setTimeout(() => {
banner.style.opacity = '0';
banner.style.transition = 'opacity 0.3s';
setTimeout(() => banner.remove(), 300);
}, 5000);
}
function toggleAlerts() {
alertsEnabled = document.getElementById('alertToggle').checked;
}
// ============================================
// MILITARY/EMERGENCY DETECTION
// ============================================
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' },
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' },
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' },
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' },
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' },
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' },
];
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
const SQUAWK_CODES = {
'7500': { type: 'hijack', name: 'HIJACK' },
'7600': { type: 'radio', name: 'RADIO FAILURE' },
'7700': { type: 'mayday', name: 'EMERGENCY' }
};
function isMilitaryAircraft(icao, callsign) {
const icaoNum = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (icaoNum >= range.start && icaoNum <= range.end) {
return { military: true, country: range.country };
}
}
if (callsign) {
const upper = callsign.toUpperCase();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) {
return { military: true, type: 'callsign' };
}
}
}
return { military: false };
}
function checkSquawkCode(aircraft) {
if (aircraft.squawk && SQUAWK_CODES[aircraft.squawk]) {
return SQUAWK_CODES[aircraft.squawk];
}
return null;
}
// ============================================
// DISTANCE/BEARING CALCULATIONS
// ============================================
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065;
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;
}
function calculateBearing(lat1, lon1, lat2, lon2) {
const dLon = (lon2 - lon1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
}
// ============================================
// 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;
}
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() {
document.getElementById('statMaxRange').textContent = stats.maxRange.toFixed(0);
document.getElementById('statMsgRate').textContent = stats.messagesPerSecond.toFixed(1);
document.getElementById('statTotal').textContent = Object.keys(aircraft).length;
}
// ============================================
// AIRCRAFT TRAILS
// ============================================
function toggleTrails() {
showTrails = document.getElementById('showTrails').checked;
if (!showTrails) {
// Remove all trail lines from map
Object.keys(trailLines).forEach(icao => {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
});
} else {
// Draw existing trails
Object.keys(aircraftTrails).forEach(icao => {
updateTrailLine(icao);
});
}
}
function recordTrailPoint(icao, lat, lon, alt) {
if (!aircraftTrails[icao]) aircraftTrails[icao] = [];
const trail = aircraftTrails[icao];
// Only add if moved significantly
if (trail.length === 0 ||
Math.abs(trail[trail.length-1].lat - lat) > 0.0005 ||
Math.abs(trail[trail.length-1].lon - lon) > 0.0005) {
trail.push({ lat, lon, alt: alt || 0, time: Date.now() });
if (trail.length > MAX_TRAIL_POINTS) trail.shift();
}
}
function getAltitudeColor(alt) {
if (!alt || alt <= 0) return '#888888';
if (alt < 10000) return '#00ff88'; // Green - low
if (alt < 25000) return '#00d4ff'; // Cyan - medium
if (alt < 35000) return '#ffcc00'; // Yellow - high
return '#ff9500'; // Orange - very high
}
function updateTrailLine(icao) {
if (!showTrails || !radarMap) return;
const trail = aircraftTrails[icao];
if (!trail || trail.length < 2) return;
// Remove old trail lines
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
}
trailLines[icao] = [];
// Create gradient segments
const now = Date.now();
for (let i = 1; i < trail.length; i++) {
const p1 = trail[i-1];
const p2 = trail[i];
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);
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], {
color: color,
weight: 2,
opacity: opacity
}).addTo(radarMap);
trailLines[icao].push(line);
}
}
function cleanupTrail(icao) {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
delete aircraftTrails[icao];
}
// ============================================
// RADAR SCOPE (PPI)
// ============================================
class RadarScope {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.sweepAngle = 0;
this.blips = []; // Aircraft blips with afterglow
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const container = this.canvas.parentElement;
const size = Math.min(container.clientWidth, container.clientHeight) - 40;
this.canvas.width = size;
this.canvas.height = size;
this.centerX = size / 2;
this.centerY = size / 2;
this.radius = (size / 2) - 30;
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
// Clear
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
// Draw range rings
this.drawRangeRings();
// Draw compass rose
this.drawCompassRose();
// Draw aircraft blips
this.drawBlips();
// Draw sweep line
this.drawSweep();
// Draw center point (observer)
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ffff00';
ctx.fill();
}
drawRangeRings() {
const ctx = this.ctx;
const rings = [0.25, 0.5, 0.75, 1.0];
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
ctx.lineWidth = 1;
rings.forEach((ratio, i) => {
const r = this.radius * ratio;
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, r, 0, Math.PI * 2);
ctx.stroke();
// Range label
const rangeNm = Math.round(maxRange * ratio);
ctx.fillStyle = 'rgba(0, 255, 255, 0.5)';
ctx.font = '10px JetBrains Mono';
ctx.fillText(`${rangeNm}`, this.centerX + r + 5, this.centerY + 4);
});
}
drawCompassRose() {
const ctx = this.ctx;
const directions = [
{ angle: 0, label: 'N' },
{ angle: 90, label: 'E' },
{ angle: 180, label: 'S' },
{ angle: 270, label: 'W' }
];
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 12px Orbitron';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
directions.forEach(d => {
const rad = (d.angle - 90) * Math.PI / 180;
const x = this.centerX + (this.radius + 15) * Math.cos(rad);
const y = this.centerY + (this.radius + 15) * Math.sin(rad);
ctx.fillText(d.label, x, y);
});
// Draw tick marks every 30 degrees
ctx.strokeStyle = 'rgba(0, 255, 255, 0.3)';
ctx.lineWidth = 1;
for (let angle = 0; angle < 360; angle += 30) {
const rad = (angle - 90) * Math.PI / 180;
const inner = this.radius - 5;
const outer = this.radius + 2;
ctx.beginPath();
ctx.moveTo(
this.centerX + inner * Math.cos(rad),
this.centerY + inner * Math.sin(rad)
);
ctx.lineTo(
this.centerX + outer * Math.cos(rad),
this.centerY + outer * Math.sin(rad)
);
ctx.stroke();
}
}
drawSweep() {
const ctx = this.ctx;
const rad = (this.sweepAngle - 90) * Math.PI / 180;
// Sweep line with gradient
const gradient = ctx.createLinearGradient(
this.centerX, this.centerY,
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
gradient.addColorStop(0, 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0.1)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.lineTo(
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.stroke();
// Sweep arc (afterglow)
const startAngle = (this.sweepAngle - 90 - 30) * Math.PI / 180;
const endAngle = (this.sweepAngle - 90) * Math.PI / 180;
const arcGradient = ctx.createConicGradient(startAngle, this.centerX, this.centerY);
arcGradient.addColorStop(0, 'rgba(0, 255, 255, 0)');
arcGradient.addColorStop(1, 'rgba(0, 255, 255, 0.15)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = arcGradient;
ctx.fill();
// Update sweep angle
this.sweepAngle = (this.sweepAngle + 2) % 360;
}
drawBlips() {
const ctx = this.ctx;
const now = Date.now();
// Update blips from aircraft data
this.blips = [];
Object.entries(aircraft).forEach(([icao, ac]) => {
if (!ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) return;
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > maxRange) return;
const bearing = calculateBearing(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
const ratio = distance / maxRange;
const rad = (bearing - 90) * Math.PI / 180;
const x = this.centerX + (this.radius * ratio) * Math.cos(rad);
const y = this.centerY + (this.radius * ratio) * Math.sin(rad);
this.blips.push({
x, y,
icao,
callsign: ac.callsign,
altitude: ac.altitude,
selected: icao === selectedIcao
});
});
// Draw blips
this.blips.forEach(blip => {
// Blip glow
const gradient = ctx.createRadialGradient(blip.x, blip.y, 0, blip.x, blip.y, 12);
gradient.addColorStop(0, blip.selected ? 'rgba(255, 255, 0, 0.8)' : 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(blip.x - 12, blip.y - 12, 24, 24);
// Blip dot
ctx.beginPath();
ctx.arc(blip.x, blip.y, blip.selected ? 5 : 3, 0, Math.PI * 2);
ctx.fillStyle = blip.selected ? '#ffff00' : '#00ffff';
ctx.fill();
// Label
if (blip.callsign || blip.selected) {
ctx.fillStyle = '#00ffff';
ctx.font = '9px JetBrains Mono';
ctx.textAlign = 'left';
ctx.fillText(blip.callsign || blip.icao, blip.x + 8, blip.y - 5);
if (blip.altitude) {
ctx.fillText(`${Math.round(blip.altitude/100)}`, blip.x + 8, blip.y + 7);
}
}
});
}
handleClick(event) {
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Find clicked blip
for (const blip of this.blips) {
const dx = x - blip.x;
const dy = y - blip.y;
if (dx * dx + dy * dy < 100) { // 10px radius
selectAircraft(blip.icao);
return;
}
}
}
}
function startRadarAnimation() {
if (radarAnimationId) return;
function animate() {
if (currentView === 'radar' && radarScope) {
radarScope.draw();
}
radarAnimationId = requestAnimationFrame(animate);
}
animate();
}
function stopRadarAnimation() {
if (radarAnimationId) {
cancelAnimationFrame(radarAnimationId);
radarAnimationId = null;
}
}
// ============================================
// VIEW TOGGLE
// ============================================
function setView(view) {
currentView = view;
const mapEl = document.getElementById('radarMap');
const scopeEl = document.getElementById('radarScope');
const mapBtn = document.getElementById('mapViewBtn');
const radarBtn = document.getElementById('radarViewBtn');
if (view === 'map') {
mapEl.style.display = 'block';
scopeEl.classList.remove('active');
mapBtn.classList.add('active');
radarBtn.classList.remove('active');
stopRadarAnimation();
// Invalidate map size after showing
setTimeout(() => radarMap && radarMap.invalidateSize(), 100);
} else {
mapEl.style.display = 'none';
scopeEl.classList.add('active');
mapBtn.classList.remove('active');
radarBtn.classList.add('active');
if (!radarScope) {
radarScope = new RadarScope('radarCanvas');
document.getElementById('radarCanvas').addEventListener('click', (e) => radarScope.handleClick(e));
}
radarScope.resize();
startRadarAnimation();
}
}
function updateRange() {
maxRange = parseInt(document.getElementById('rangeSelect').value);
drawRangeRings();
}
// ============================================
// RANGE RINGS (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 = [maxRange * 0.25, maxRange * 0.5, maxRange * 0.75, maxRange];
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;">${Math.round(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);
}
function updateObserverLoc() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat;
observerLocation.lon = lon;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
if (radarMap) {
radarMap.setView([lat, lon], radarMap.getZoom());
}
drawRangeRings();
}
}
function getGeolocation() {
if (!navigator.geolocation) {
alert('Geolocation not supported');
return;
}
if (!window.isSecureContext) {
alert('GPS requires HTTPS. Enter coordinates manually.');
return;
}
const btn = document.getElementById('geolocateBtn');
btn.textContent = '...';
navigator.geolocation.getCurrentPosition(
(position) => {
observerLocation.lat = position.coords.latitude;
observerLocation.lon = position.coords.longitude;
// Save to localStorage for persistence
localStorage.setItem('observerLocation', JSON.stringify(observerLocation));
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
if (radarMap) {
radarMap.setView([observerLocation.lat, observerLocation.lon], 8);
}
drawRangeRings();
btn.textContent = 'Locate';
},
(error) => {
alert('Location error: ' + error.message);
btn.textContent = 'Locate';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// ============================================
// GPS FUNCTIONS (gpsd auto-connect)
// ============================================
async function autoConnectGps() {
try {
const response = await fetch('/gps/auto-connect', { method: 'POST' });
const data = await response.json();
if (data.status === 'connected') {
gpsConnected = true;
startGpsStream();
showGpsIndicator(true);
console.log('GPS: Auto-connected to gpsd');
if (data.position) {
updateLocationFromGps(data.position);
}
} else {
console.log('GPS: gpsd not available -', data.message);
}
} catch (e) {
console.log('GPS: Auto-connect failed -', e.message);
}
}
let gpsReconnectTimeout = null;
function startGpsStream() {
if (gpsEventSource) {
gpsEventSource.close();
}
if (gpsReconnectTimeout) {
clearTimeout(gpsReconnectTimeout);
gpsReconnectTimeout = null;
}
gpsEventSource = new EventSource('/gps/stream');
gpsEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'position' && data.latitude && data.longitude) {
updateLocationFromGps(data);
}
} catch (e) {
console.error('GPS parse error:', e);
}
};
gpsEventSource.onerror = (e) => {
// Don't log every error - connection suspends are normal
if (gpsEventSource) {
gpsEventSource.close();
gpsEventSource = null;
}
// Auto-reconnect after 5 seconds if still connected
if (gpsConnected && !gpsReconnectTimeout) {
gpsReconnectTimeout = setTimeout(() => {
gpsReconnectTimeout = null;
if (gpsConnected) {
startGpsStream();
}
}, 5000);
}
};
}
// Reconnect GPS stream when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (!document.hidden && gpsConnected && !gpsEventSource) {
startGpsStream();
}
});
function updateLocationFromGps(position) {
observerLocation.lat = position.latitude;
observerLocation.lon = position.longitude;
document.getElementById('obsLat').value = position.latitude.toFixed(4);
document.getElementById('obsLon').value = position.longitude.toFixed(4);
// Center map on GPS location (on first fix)
if (radarMap && !radarMap._gpsInitialized) {
radarMap.setView([position.latitude, position.longitude], radarMap.getZoom());
radarMap._gpsInitialized = true;
// Draw range rings immediately after centering
drawRangeRings();
} else {
drawRangeRings();
}
}
function showGpsIndicator(show) {
const indicator = document.getElementById('gpsIndicator');
if (indicator) {
indicator.style.display = show ? 'inline-flex' : 'none';
}
}
// ============================================
// FILTERING
// ============================================
function applyFilter() {
currentFilter = document.getElementById('aircraftFilter').value;
// Clear markers and redraw
Object.keys(markers).forEach(icao => {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
});
Object.keys(markerState).forEach(icao => delete markerState[icao]);
pendingMarkerUpdates.clear();
Object.keys(aircraft).forEach(icao => {
if (aircraft[icao].lat && aircraft[icao].lon) {
pendingMarkerUpdates.add(icao);
}
});
scheduleUIUpdate();
}
function passesFilter(icao, ac) {
if (currentFilter === 'all') return true;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (currentFilter === 'military') return militaryInfo.military;
if (currentFilter === 'civil') return !militaryInfo.military;
if (currentFilter === 'emergency') return !!squawkInfo;
return true;
}
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', () => {
// Initialize observer location input fields from saved location
const obsLatInput = document.getElementById('obsLat');
const obsLonInput = document.getElementById('obsLon');
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
initMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
checkAdsbTools();
checkAircraftDatabase();
// Auto-connect to gpsd if available
autoConnectGps();
});
function checkAdsbTools() {
fetch('/adsb/tools')
.then(r => r.json())
.then(data => {
if (data.needs_readsb) {
showReadsbWarning(data.soapy_types);
}
})
.catch(() => {});
}
// ============================================
// AIRCRAFT DATABASE
// ============================================
let aircraftDbStatus = { installed: false };
function checkAircraftDatabase() {
fetch('/adsb/aircraft-db/status')
.then(r => r.json())
.then(status => {
aircraftDbStatus = status;
if (!status.installed) {
showAircraftDbBanner('not_installed');
} else {
// Check for updates in background
fetch('/adsb/aircraft-db/check-updates')
.then(r => r.json())
.then(data => {
if (data.update_available) {
showAircraftDbBanner('update_available', data.latest_version);
}
})
.catch(() => {});
}
})
.catch(() => {});
}
function showAircraftDbBanner(type, version) {
// Remove any existing banner
const existing = document.getElementById('aircraftDbBanner');
if (existing) existing.remove();
const banner = document.createElement('div');
banner.id = 'aircraftDbBanner';
banner.style.cssText = `
position: fixed;
top: 70px;
right: 20px;
background: ${type === 'not_installed' ? 'rgba(59, 130, 246, 0.95)' : 'rgba(34, 197, 94, 0.95)'};
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
max-width: 320px;
font-family: 'Inter', sans-serif;
`;
if (type === 'not_installed') {
banner.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">Aircraft Database Not Installed</div>
<div style="margin-bottom: 10px; font-size: 11px; opacity: 0.9;">Download to see aircraft types, registrations, and model info.</div>
<button onclick="downloadAircraftDb()" style="background: white; color: #3b82f6; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px;">Download Database</button>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
} else {
banner.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">Database Update Available</div>
<div style="margin-bottom: 10px; font-size: 11px; opacity: 0.9;">New version: ${version || 'latest'}</div>
<button onclick="downloadAircraftDb()" style="background: white; color: #22c55e; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-weight: 500; font-size: 11px;">Update Now</button>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
document.body.appendChild(banner);
}
function downloadAircraftDb() {
const banner = document.getElementById('aircraftDbBanner');
if (banner) {
banner.innerHTML = `
<div style="font-weight: bold;">Downloading...</div>
<div style="font-size: 11px; opacity: 0.9;">This may take a moment</div>
`;
}
fetch('/adsb/aircraft-db/download', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.success) {
if (banner) {
banner.style.background = 'rgba(34, 197, 94, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Database Installed</div>
<div style="font-size: 11px; opacity: 0.9;">${data.message}</div>
`;
setTimeout(() => banner.remove(), 3000);
}
aircraftDbStatus.installed = true;
} else {
if (banner) {
banner.style.background = 'rgba(239, 68, 68, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Download Failed</div>
<div style="font-size: 11px;">${data.error || 'Unknown error'}</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
}
})
.catch(err => {
if (banner) {
banner.style.background = 'rgba(239, 68, 68, 0.95)';
banner.innerHTML = `
<div style="font-weight: bold;">Download Failed</div>
<div style="font-size: 11px;">${err.message}</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 6px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
}
});
}
function showReadsbWarning(sdrTypes) {
const typeList = sdrTypes.join(', ') || 'SoapySDR device';
const warning = document.createElement('div');
warning.id = 'readsbWarning';
warning.style.cssText = `
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: rgba(245, 158, 11, 0.95);
color: #000;
padding: 15px 25px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 500px;
text-align: left;
font-family: 'Inter', sans-serif;
`;
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${typeList} Detected - readsb Required</div>
<div style="margin-bottom: 10px;">ADS-B tracking with ${typeList} requires <strong>readsb</strong> compiled with SoapySDR support.</div>
<details style="font-size: 11px;">
<summary style="cursor: pointer; margin-bottom: 8px;">Installation Instructions</summary>
<div style="background: rgba(0,0,0,0.1); padding: 10px; border-radius: 4px; margin-top: 5px;">
<code style="display: block; white-space: pre-wrap; font-family: 'JetBrains Mono', monospace; font-size: 10px;">sudo apt install build-essential libsoapysdr-dev librtlsdr-dev
git clone https://github.com/wiedehopf/readsb.git
cd readsb
make HAVE_SOAPYSDR=1
sudo make install</code>
</div>
</details>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 8px; right: 12px; background: none; border: none; color: #000; cursor: pointer; font-size: 16px; font-weight: bold;">×</button>
`;
document.body.appendChild(warning);
}
function updateClock() {
const now = new Date();
document.getElementById('utcTime').textContent =
now.toISOString().substring(11, 19) + ' UTC';
}
function initMap() {
radarMap = L.map('radarMap', {
center: [observerLocation.lat, observerLocation.lon],
zoom: 7,
minZoom: 3,
maxZoom: 15
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(radarMap);
// Draw range rings after map is ready
setTimeout(() => drawRangeRings(), 100);
}
// ============================================
// TRACKING CONTROL
// ============================================
function toggleRemoteDump1090() {
const useRemote = document.getElementById('useRemoteDump1090').checked;
const controls = document.querySelector('.remote-dump1090-controls');
controls.style.display = useRemote ? 'flex' : 'none';
}
function getRemoteDump1090Config() {
const useRemote = document.getElementById('useRemoteDump1090').checked;
if (!useRemote) return null;
const host = document.getElementById('remoteSbsHost').value.trim();
const port = parseInt(document.getElementById('remoteSbsPort').value) || 30003;
if (!host) {
alert('Please enter remote dump1090 host address');
return false;
}
return { host, port };
}
async function toggleTracking() {
const btn = document.getElementById('startBtn');
if (!isTracking) {
// Check for remote dump1090 config
const remoteConfig = getRemoteDump1090Config();
if (remoteConfig === false) return;
const requestBody = {};
if (remoteConfig) {
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
const response = await fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
alert('Invalid response: ' + text);
return;
}
if (data.status === 'success' || data.status === 'started' || data.status === 'already_running') {
startEventStream();
drawRangeRings();
isTracking = true;
btn.textContent = 'STOP';
btn.classList.add('active');
document.getElementById('trackingDot').classList.remove('inactive');
document.getElementById('trackingStatus').textContent = 'TRACKING';
} else {
alert('Failed to start: ' + (data.message || JSON.stringify(data)));
}
} catch (err) {
alert('Error: ' + err.message);
}
} else {
try {
await fetch('/adsb/stop', { method: 'POST' });
} catch (err) {}
stopEventStream();
isTracking = false;
btn.textContent = 'START';
btn.classList.remove('active');
document.getElementById('trackingDot').classList.add('inactive');
document.getElementById('trackingStatus').textContent = 'STANDBY';
}
}
function startEventStream() {
if (eventSource) eventSource.close();
console.log('Starting ADS-B event stream...');
eventSource = new EventSource('/adsb/stream');
eventSource.onopen = () => {
console.log('ADS-B stream connected');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'aircraft') {
updateAircraft(data);
} else if (data.type === 'status') {
console.log('ADS-B status:', data.message);
} else if (data.type === 'keepalive') {
// Keepalive received
} else {
console.log('ADS-B data:', data);
}
} catch (err) {
console.error('ADS-B parse error:', err, event.data);
}
};
eventSource.onerror = (e) => {
console.error('ADS-B stream error:', e);
if (eventSource.readyState === EventSource.CLOSED) {
console.log('ADS-B stream closed, will not auto-reconnect');
}
};
}
function stopEventStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
// ============================================
// AIRCRAFT UPDATES
// ============================================
let pendingUIUpdate = false;
let pendingMarkerUpdates = new Set();
const MAX_MARKER_UPDATES_PER_FRAME = 20;
function scheduleUIUpdate() {
if (pendingUIUpdate) return;
pendingUIUpdate = true;
requestAnimationFrame(() => {
updateStatsDisplay();
renderAircraftList();
let updateCount = 0;
const toProcess = [];
for (const icao of pendingMarkerUpdates) {
if (updateCount < MAX_MARKER_UPDATES_PER_FRAME) {
updateMarkerImmediate(icao);
toProcess.push(icao);
updateCount++;
}
}
toProcess.forEach(icao => pendingMarkerUpdates.delete(icao));
if (pendingMarkerUpdates.size > 0) {
pendingUIUpdate = false;
scheduleUIUpdate();
return;
}
if (selectedIcao && aircraft[selectedIcao]) {
showAircraftDetails(selectedIcao);
}
pendingUIUpdate = false;
});
}
function updateAircraft(data) {
const icao = data.icao;
if (!icao) return;
aircraft[icao] = {
...aircraft[icao],
...data,
lastSeen: Date.now()
};
checkAndAlertAircraft(icao, aircraft[icao]);
updateStatistics(icao, aircraft[icao]);
// Record trail point
if (data.lat && data.lon) {
recordTrailPoint(icao, data.lat, data.lon, data.altitude);
if (showTrails) {
updateTrailLine(icao);
}
pendingMarkerUpdates.add(icao);
}
scheduleUIUpdate();
}
const markerState = {};
function updateMarkerImmediate(icao) {
const ac = aircraft[icao];
if (!ac || !ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
delete markerState[icao];
}
return;
}
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const callsign = ac.callsign || icao;
const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A';
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color;
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt;
if (markers[icao]) {
markers[icao].setLatLng([ac.lat, ac.lon]);
if (iconChanged) {
markers[icao].setIcon(createMarkerIcon(rotation, color));
}
if (tooltipChanged) {
markers[icao].unbindTooltip();
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
} else {
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
markerState[icao] = { rotation, color, callsign, alt };
}
function createMarkerIcon(rotation, color) {
return L.divIcon({
className: 'aircraft-marker',
html: `<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color}; filter: drop-shadow(0 0 5px ${color});">
<path fill="currentColor" d="M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
// ============================================
// AIRCRAFT LIST
// ============================================
let renderedAircraftOrder = [];
let lastFullRebuild = 0;
const MAX_AIRCRAFT_DISPLAY = 50;
const MIN_REBUILD_INTERVAL = 2000;
function renderAircraftList() {
const container = document.getElementById('aircraftList');
const sortedAircraft = Object.entries(aircraft)
.filter(([icao, ac]) => passesFilter(icao, ac))
.map(([icao, ac]) => ({ ...ac, icao }))
.sort((a, b) => (b.altitude || 0) - (a.altitude || 0))
.slice(0, MAX_AIRCRAFT_DISPLAY);
if (sortedAircraft.length === 0) {
if (container.querySelector('.no-aircraft')) return;
container.innerHTML = `<div class="no-aircraft"><div>No aircraft detected</div></div>`;
renderedAircraftOrder = [];
return;
}
const newOrder = sortedAircraft.map(ac => ac.icao);
const orderChanged = newOrder.length !== renderedAircraftOrder.length ||
newOrder.some((icao, i) => icao !== renderedAircraftOrder[i]);
const now = Date.now();
const canRebuild = now - lastFullRebuild > MIN_REBUILD_INTERVAL;
if (orderChanged && canRebuild) {
lastFullRebuild = now;
const fragment = document.createDocumentFragment();
sortedAircraft.forEach(ac => {
const div = document.createElement('div');
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
div.setAttribute('data-icao', ac.icao);
div.onclick = () => selectAircraft(ac.icao);
div.innerHTML = buildAircraftItemHTML(ac);
fragment.appendChild(div);
});
container.innerHTML = '';
container.appendChild(fragment);
renderedAircraftOrder = newOrder;
} else {
const existingItems = {};
container.querySelectorAll('[data-icao]').forEach(el => {
existingItems[el.getAttribute('data-icao')] = el;
});
sortedAircraft.forEach(ac => {
const existingItem = existingItems[ac.icao];
if (existingItem) {
existingItem.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
existingItem.innerHTML = buildAircraftItemHTML(ac);
}
});
}
}
function buildAircraftItemHTML(ac) {
const callsign = ac.callsign || '------';
const alt = ac.altitude ? ac.altitude.toLocaleString() : '---';
const speed = ac.speed || '---';
const heading = ac.heading ? ac.heading + '°' : '---';
const typeCode = ac.type_code || '';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<span style="background:#556b2f;color:#fff;padding:1px 4px;border-radius:2px;font-size:8px;margin-left:4px;">MIL</span>` : '';
// Vertical rate indicator: arrow up (climbing), arrow down (descending), or dash (level)
let vsIndicator = '-';
let vsColor = '';
if (ac.vertical_rate !== undefined) {
if (ac.vertical_rate > 300) { vsIndicator = '↑'; vsColor = 'color:#00ff88;'; }
else if (ac.vertical_rate < -300) { vsIndicator = '↓'; vsColor = 'color:#ff6b6b;'; }
}
return `
<div class="aircraft-header">
<span class="aircraft-callsign">${callsign}${badge}</span>
<span class="aircraft-icao">${typeCode ? typeCode + ' • ' : ''}${ac.icao}</span>
</div>
<div class="aircraft-details">
<div class="aircraft-detail">
<div class="aircraft-detail-value">${alt}</div>
<div class="aircraft-detail-label">ALT</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value">${speed}</div>
<div class="aircraft-detail-label">SPD</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value">${heading}</div>
<div class="aircraft-detail-label">HDG</div>
</div>
<div class="aircraft-detail">
<div class="aircraft-detail-value" style="${vsColor}">${vsIndicator}</div>
<div class="aircraft-detail-label">V/S</div>
</div>
</div>
`;
}
function selectAircraft(icao) {
selectedIcao = icao;
renderAircraftList();
showAircraftDetails(icao);
const ac = aircraft[icao];
if (ac && ac.lat && ac.lon && currentView === 'map') {
radarMap.setView([ac.lat, ac.lon], 10);
}
}
function showAircraftDetails(icao) {
const ac = aircraft[icao];
const container = document.getElementById('selectedInfo');
if (!ac) {
container.innerHTML = `
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>`;
return;
}
const callsign = ac.callsign || ac.icao;
const lat = ac.lat ? ac.lat.toFixed(4) + '°' : 'N/A';
const lon = ac.lon ? ac.lon.toFixed(4) + '°' : 'N/A';
const alt = ac.altitude ? ac.altitude.toLocaleString() + ' ft' : 'N/A';
const speed = ac.speed ? ac.speed + ' kts' : 'N/A';
const heading = ac.heading ? ac.heading + '°' : 'N/A';
const squawk = ac.squawk || 'N/A';
const vrate = ac.vertical_rate !== undefined ? (ac.vertical_rate >= 0 ? '+' : '') + ac.vertical_rate.toLocaleString() + ' ft/min' : 'N/A';
const registration = ac.registration || '';
const typeCode = ac.type_code || '';
const typeDesc = ac.type_desc || '';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<div style="background:#556b2f;color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;text-align:center;margin-bottom:8px;">MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>` : '';
// Aircraft type info line (shown if available from database)
const typeInfo = (typeCode || typeDesc) ?
`<div style="color:#00d4ff;font-size:11px;margin-bottom:6px;">${typeDesc || typeCode}${registration ? ' • ' + registration : ''}</div>` : '';
container.innerHTML = `
<div class="selected-callsign">${callsign}</div>
${typeInfo}
${badge}
<div class="telemetry-grid">
<div class="telemetry-item">
<div class="telemetry-label">ICAO</div>
<div class="telemetry-value">${ac.icao}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Squawk</div>
<div class="telemetry-value">${squawk}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lat</div>
<div class="telemetry-value">${lat}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lon</div>
<div class="telemetry-value">${lon}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Altitude</div>
<div class="telemetry-value">${alt}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Speed</div>
<div class="telemetry-value">${speed}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Heading</div>
<div class="telemetry-value">${heading}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">V/S</div>
<div class="telemetry-value" style="${ac.vertical_rate > 0 ? 'color: #00ff88;' : ac.vertical_rate < 0 ? 'color: #ff6b6b;' : ''}">${vrate}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
</div>
</div>`;
}
function cleanupOldAircraft() {
const now = Date.now();
const timeout = 60000;
let needsUpdate = false;
Object.keys(aircraft).forEach(icao => {
if (now - aircraft[icao].lastSeen > timeout) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
}
cleanupTrail(icao);
delete aircraft[icao];
delete alertedAircraft[icao];
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
}
}
});
if (needsUpdate) {
scheduleUIUpdate();
}
}
// ============================================
// AIRBAND AUDIO
// ============================================
let isAirbandPlaying = false;
// Web Audio API for airband visualization
let airbandAudioContext = null;
let airbandAnalyser = null;
let airbandSource = null;
let airbandVisualizerId = null;
let airbandPeakLevel = 0;
function initAirbandVisualizer() {
const audioPlayer = document.getElementById('airbandPlayer');
if (!airbandAudioContext) {
airbandAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (airbandAudioContext.state === 'suspended') {
airbandAudioContext.resume();
}
if (!airbandSource) {
try {
airbandSource = airbandAudioContext.createMediaElementSource(audioPlayer);
airbandAnalyser = airbandAudioContext.createAnalyser();
airbandAnalyser.fftSize = 128;
airbandAnalyser.smoothingTimeConstant = 0.7;
airbandSource.connect(airbandAnalyser);
airbandAnalyser.connect(airbandAudioContext.destination);
} catch (e) {
console.warn('Could not create airband audio source:', e);
return;
}
}
document.getElementById('airbandVisualizerContainer').style.display = 'flex';
drawAirbandVisualizer();
}
function drawAirbandVisualizer() {
if (!airbandAnalyser) return;
const canvas = document.getElementById('airbandSpectrumCanvas');
const ctx = canvas.getContext('2d');
const bufferLength = airbandAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
airbandVisualizerId = requestAnimationFrame(draw);
airbandAnalyser.getByteFrequencyData(dataArray);
// Signal meter
let sum = 0;
for (let i = 0; i < bufferLength; i++) sum += dataArray[i];
const average = sum / bufferLength;
const levelPercent = (average / 255) * 100;
if (levelPercent > airbandPeakLevel) {
airbandPeakLevel = levelPercent;
} else {
airbandPeakLevel *= 0.95;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = levelPercent + '%';
if (meterPeak) meterPeak.style.left = Math.min(airbandPeakLevel, 100) + '%';
// Draw spectrum
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = canvas.width / bufferLength * 2;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
const hue = 200 - (i / bufferLength) * 60;
ctx.fillStyle = `hsl(${hue}, 80%, ${40 + (dataArray[i] / 255) * 30}%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
}
draw();
}
function stopAirbandVisualizer() {
if (airbandVisualizerId) {
cancelAnimationFrame(airbandVisualizerId);
airbandVisualizerId = null;
}
const meterFill = document.getElementById('airbandSignalMeter');
const meterPeak = document.getElementById('airbandSignalPeak');
if (meterFill) meterFill.style.width = '0%';
if (meterPeak) meterPeak.style.left = '0%';
airbandPeakLevel = 0;
const container = document.getElementById('airbandVisualizerContainer');
if (container) container.style.display = 'none';
}
function initAirband() {
// Populate device selector with available SDRs
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR found</option>';
select.disabled = true;
} else if (devices.length === 1) {
// Only one device - warn user they need two
const dev = devices[0];
const name = dev.name || dev.type || `RTL-SDR`;
const opt = document.createElement('option');
opt.value = dev.index || 0;
opt.textContent = `${dev.index || 0}: ${name}`;
select.appendChild(opt);
// Show warning about needing second SDR
document.getElementById('airbandStatus').textContent = '1 SDR (need 2)';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
} else {
// Multiple devices - let user choose which for airband
devices.forEach((dev, i) => {
const opt = document.createElement('option');
const idx = dev.index !== undefined ? dev.index : i;
const name = dev.name || dev.type || `RTL-SDR`;
opt.value = idx;
opt.textContent = `${idx}: ${name}`;
select.appendChild(opt);
});
// Default to second device (first is likely used for ADS-B)
if (devices.length > 1) {
select.value = devices[1].index !== undefined ? devices[1].index : 1;
}
}
})
.catch(() => {
const select = document.getElementById('airbandDeviceSelect');
select.innerHTML = '<option value="0">No SDR</option>';
});
// Check if audio tools are available
fetch('/listening/tools')
.then(r => r.json())
.then(data => {
const missingTools = [];
if (!data.rtl_fm) missingTools.push('rtl_fm');
if (!data.ffmpeg) missingTools.push('ffmpeg (audio encoder)');
if (missingTools.length > 0) {
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
// Show warning banner
showAirbandWarning(missingTools);
}
})
.catch(() => {
// Endpoint not available, disable airband
document.getElementById('airbandBtn').disabled = true;
document.getElementById('airbandBtn').style.opacity = '0.5';
document.getElementById('airbandStatus').textContent = 'UNAVAILABLE';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function showAirbandWarning(missingTools) {
const warning = document.createElement('div');
warning.id = 'airbandWarning';
warning.style.cssText = `
position: fixed;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 400px;
text-align: center;
`;
const toolList = missingTools.join(', ');
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 6px;">⚠️ Airband Listen Unavailable</div>
<div>Missing required tools: <strong>${toolList}</strong></div>
<div style="margin-top: 8px; font-size: 10px; opacity: 0.9;">
Install with: <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">sudo apt install rtl-sdr ffmpeg</code> (Debian) or <code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 3px;">brew install librtlsdr ffmpeg</code> (macOS)
</div>
<button onclick="this.parentElement.remove()" style="position: absolute; top: 5px; right: 8px; background: none; border: none; color: white; cursor: pointer; font-size: 14px;">×</button>
`;
document.body.appendChild(warning);
// Auto-dismiss after 15 seconds
setTimeout(() => {
if (warning.parentElement) {
warning.style.opacity = '0';
warning.style.transition = 'opacity 0.3s';
setTimeout(() => warning.remove(), 300);
}
}, 15000);
}
function updateAirbandFreq() {
const select = document.getElementById('airbandFreqSelect');
const customInput = document.getElementById('airbandCustomFreq');
if (select.value === 'custom') {
customInput.style.display = 'inline-block';
} else {
customInput.style.display = 'none';
// If audio is playing, restart on new frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
}
}
// Handle custom frequency input changes
document.addEventListener('DOMContentLoaded', () => {
const customInput = document.getElementById('airbandCustomFreq');
if (customInput) {
customInput.addEventListener('change', () => {
// If audio is playing, restart on new custom frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
});
}
});
function getAirbandFrequency() {
const select = document.getElementById('airbandFreqSelect');
if (select.value === 'custom') {
return parseFloat(document.getElementById('airbandCustomFreq').value) || 121.5;
}
return parseFloat(select.value);
}
function toggleAirband() {
if (isAirbandPlaying) {
stopAirband();
} else {
startAirband();
}
}
function startAirband() {
const frequency = getAirbandFrequency();
const device = parseInt(document.getElementById('airbandDeviceSelect').value);
const squelch = parseInt(document.getElementById('airbandSquelch').value);
// Check if ADS-B tracking is using this device (ADS-B uses device 0 by default)
if (isTracking && device === 0) {
const useAnyway = confirm(
'Warning: ADS-B tracking is using SDR device 0.\n\n' +
'Using the same device for airband will stop ADS-B tracking.\n\n' +
'Select a different SDR device for airband listening, or click OK to stop tracking and listen.'
);
if (!useAnyway) {
return;
}
}
document.getElementById('airbandStatus').textContent = 'STARTING...';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
fetch('/listening/audio/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
frequency: frequency,
modulation: 'am', // Airband uses AM
squelch: squelch,
gain: 40,
device: device
})
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAirbandPlaying = true;
// Start browser audio playback
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.src = '/listening/audio/stream?' + Date.now();
// Initialize visualizer before playing
initAirbandVisualizer();
audioPlayer.play().catch(e => {
console.warn('Audio autoplay blocked:', e);
});
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">⏹</span> STOP';
document.getElementById('airbandBtn').classList.add('active');
document.getElementById('airbandStatus').textContent = frequency.toFixed(3) + ' MHz';
document.getElementById('airbandStatus').style.color = 'var(--accent-green)';
} else {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
alert('Airband Error: ' + (data.message || 'Failed to start'));
}
})
.catch(err => {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
});
}
function stopAirband() {
// Stop visualizer
stopAirbandVisualizer();
// Stop browser audio
const audioPlayer = document.getElementById('airbandPlayer');
audioPlayer.pause();
audioPlayer.src = '';
fetch('/listening/audio/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAirbandPlaying = false;
document.getElementById('airbandBtn').innerHTML = '<span class="airband-icon">▶</span> LISTEN';
document.getElementById('airbandBtn').classList.remove('active');
document.getElementById('airbandStatus').textContent = 'OFF';
document.getElementById('airbandStatus').style.color = 'var(--text-muted)';
})
.catch(() => {});
}
// Initialize airband on page load
document.addEventListener('DOMContentLoaded', initAirband);
</script>
</body>
</html>