Files
intercept/templates/adsb_dashboard.html
Smittix dc4434db84 Add mobile responsive design overhaul
- Add responsive.css with shared utilities (hamburger menu, touch targets, responsive typography)
- Add hamburger menu and mobile drawer navigation to main app
- Add horizontal scrolling mobile nav bar for mode switching
- Refactor index.css with mobile-first breakpoints
- Update adsb_dashboard.css for mobile layouts
- Update satellite_dashboard.css for mobile layouts
- Add mobile nav controller to app.js with drawer toggle
- Hide stats/taglines on small screens
- Unified breakpoints: 480px (phone), 768px (tablet), 1024px (desktop)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:30:15 +00:00

2866 lines
124 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 - See the Invisible</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/responsive.css') }}">
<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 - See the Invisible</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">
<!-- ACARS Panel (left of map) - Collapsible -->
<div class="acars-sidebar" id="acarsSidebar">
<div class="acars-sidebar-content" id="acarsSidebarContent">
<div class="panel acars-panel">
<div class="panel-header">
<span>ACARS MESSAGES</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="acarsCount" style="font-size: 10px; color: var(--accent-cyan);">0</span>
<div class="panel-indicator" id="acarsPanelIndicator"></div>
</div>
</div>
<div id="acarsPanelContent">
<div class="acars-info" style="font-size: 9px; color: var(--text-muted); padding: 5px 8px; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--accent-yellow);"></span> Requires separate SDR (VHF ~131 MHz)
</div>
<div class="acars-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select id="acarsDeviceSelect" style="flex: 1; font-size: 10px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="acarsRegionSelect" onchange="setAcarsFreqs()" style="flex: 1; font-size: 10px;">
<option value="na">N. America</option>
<option value="eu">Europe</option>
<option value="ap">Asia-Pac</option>
</select>
</div>
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
▶ START ACARS
</button>
</div>
<div class="acars-messages" id="acarsMessages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
<div style="font-size: 10px; color: var(--text-muted);">No ACARS messages</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start ACARS to receive aircraft datalink messages</div>
</div>
</div>
</div>
</div>
</div>
<button class="acars-collapse-btn" id="acarsCollapseBtn" onclick="toggleAcarsSidebar()" title="Toggle ACARS Panel">
<span id="acarsCollapseIcon"></span>
<span class="acars-collapse-label">ACARS</span>
</button>
</div>
<!-- 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>
<option value="watchlist">Watchlist</option>
</select>
<button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist"></button>
<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'
// Watchlist - persisted to localStorage
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
// 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);
const onWatchlist = isOnWatchlist(ac);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (onWatchlist) {
alertedAircraft[icao] = 'watchlist';
playAlertSound('military'); // Use military sound for watchlist
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
} 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;
}
// ============================================
// WATCHLIST FUNCTIONS
// ============================================
function saveWatchlist() {
localStorage.setItem('adsb_watchlist', JSON.stringify(watchlist));
}
function isOnWatchlist(aircraft) {
const icao = aircraft.icao?.toUpperCase();
const callsign = aircraft.callsign?.toUpperCase()?.trim();
const registration = aircraft.registration?.toUpperCase()?.trim();
return watchlist.some(entry => {
const val = entry.value.toUpperCase();
if (entry.type === 'icao' && icao === val) return true;
if (entry.type === 'callsign' && callsign && callsign.includes(val)) return true;
if (entry.type === 'registration' && registration === val) return true;
if (entry.type === 'any') {
if (icao === val) return true;
if (callsign && callsign.includes(val)) return true;
if (registration === val) return true;
}
return false;
});
}
function addToWatchlist(value, type = 'any', note = '') {
value = value.trim().toUpperCase();
if (!value) return false;
// Check for duplicates
const exists = watchlist.some(e => e.value.toUpperCase() === value && e.type === type);
if (exists) return false;
watchlist.push({ value, type, note, added: Date.now() });
saveWatchlist();
renderWatchlist();
return true;
}
function removeFromWatchlist(index) {
watchlist.splice(index, 1);
saveWatchlist();
renderWatchlist();
}
function showWatchlistModal() {
renderWatchlist();
document.getElementById('watchlistModal').classList.add('active');
document.getElementById('watchlistInput').focus();
}
function closeWatchlistModal() {
document.getElementById('watchlistModal').classList.remove('active');
}
function renderWatchlist() {
const container = document.getElementById('watchlistEntries');
document.getElementById('watchlistCount').textContent = watchlist.length;
if (watchlist.length === 0) {
container.innerHTML = '<div class="watchlist-empty">No entries. Add callsigns, registrations, or ICAO codes to watch.</div>';
return;
}
container.innerHTML = watchlist.map((entry, i) => `
<div class="watchlist-entry">
<div class="watchlist-entry-info">
<span class="watchlist-value">${entry.value}</span>
<span class="watchlist-type">${entry.type}</span>
${entry.note ? `<span class="watchlist-note">${entry.note}</span>` : ''}
</div>
<button class="watchlist-remove" onclick="removeFromWatchlist(${i})" title="Remove">&times;</button>
</div>
`).join('');
}
// Close watchlist modal on overlay click or Escape key
document.getElementById('watchlistModal')?.addEventListener('click', (e) => {
if (e.target.id === 'watchlistModal') closeWatchlistModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeWatchlistModal();
closeSquawkModal();
}
});
function handleWatchlistAdd() {
const input = document.getElementById('watchlistInput');
const type = document.getElementById('watchlistType').value;
const note = document.getElementById('watchlistNote').value.trim();
if (addToWatchlist(input.value, type, note)) {
input.value = '';
document.getElementById('watchlistNote').value = '';
}
}
function addCurrentAircraftToWatchlist() {
if (!selectedIcao || !aircraft[selectedIcao]) return;
const ac = aircraft[selectedIcao];
const value = ac.callsign || ac.registration || ac.icao;
const type = ac.callsign ? 'callsign' : (ac.registration ? 'registration' : 'icao');
addToWatchlist(value, type);
showAlertBanner(`Added ${value} to watchlist`, '#00d4ff');
}
// ============================================
// 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 = {
// Emergency codes
'7500': { type: 'emergency', name: 'HIJACK', desc: 'Aircraft is being hijacked', color: '#ff0000' },
'7600': { type: 'emergency', name: 'RADIO FAILURE', desc: 'Lost communication with ATC', color: '#ff6600' },
'7700': { type: 'emergency', name: 'EMERGENCY', desc: 'General emergency (mayday)', color: '#ff0000' },
// Special codes
'7777': { type: 'special', name: 'MILITARY INTERCEPT', desc: 'Military interceptor operations', color: '#ff00ff' },
'7000': { type: 'vfr', name: 'VFR (EU)', desc: 'Visual Flight Rules - Europe', color: '#00d4ff' },
'1200': { type: 'vfr', name: 'VFR (US/CA)', desc: 'Visual Flight Rules - North America', color: '#00d4ff' },
'2000': { type: 'standard', name: 'UNASSIGNED', desc: 'No assigned code / entering controlled airspace', color: '#888888' },
'1000': { type: 'standard', name: 'IFR (EU)', desc: 'Instrument Flight Rules with no assigned code', color: '#00ff88' },
'0000': { type: 'special', name: 'DISCRETE', desc: 'Military/special operations', color: '#ff00ff' },
'4000': { type: 'special', name: 'FERRY/DELIVERY', desc: 'Aircraft ferry/delivery flight', color: '#ffaa00' },
'5000': { type: 'special', name: 'MILITARY (UK)', desc: 'UK military operations', color: '#556b2f' },
'0033': { type: 'special', name: 'PARACHUTE OPS', desc: 'Parachute dropping in progress', color: '#ffaa00' },
'7001': { type: 'special', name: 'VFR INTRUSION', desc: 'VFR aircraft entering controlled airspace', color: '#ffaa00' },
'7004': { type: 'special', name: 'AEROBATIC', desc: 'Aerobatic flight display', color: '#00d4ff' },
'7010': { type: 'special', name: 'RADIO EQUIPPED', desc: 'IFR flight (UK zones)', color: '#00ff88' }
};
const SQUAWK_REFERENCE = [
{ code: '7500', name: 'Hijack', desc: 'Aircraft is being hijacked - do not acknowledge' },
{ code: '7600', name: 'Radio Failure', desc: 'Two-way radio communication failure' },
{ code: '7700', name: 'Emergency', desc: 'General emergency (mayday/pan-pan)' },
{ code: '7777', name: 'Military Intercept', desc: 'Active military intercept operations' },
{ code: '---', name: '---', desc: '---' },
{ code: '1200', name: 'VFR (US/Canada)', desc: 'Visual flight rules - North America' },
{ code: '7000', name: 'VFR (Europe)', desc: 'Visual flight rules - ICAO/Europe' },
{ code: '2000', name: 'Conspicuity', desc: 'Entering airspace, no code assigned' },
{ code: '1000', name: 'IFR (Europe)', desc: 'Instrument flight rules, no code assigned' },
{ code: '---', name: '---', desc: '---' },
{ code: '0000', name: 'Discrete', desc: 'Military/special operations' },
{ code: '0033', name: 'Parachute Ops', desc: 'Parachute dropping operations' },
{ code: '4000', name: 'Ferry Flight', desc: 'Aircraft delivery/repositioning' },
{ code: '5000', name: 'Military (UK)', desc: 'UK military low-level operations' },
{ code: '7001', name: 'VFR Intrusion', desc: 'VFR aircraft entering controlled space' },
{ code: '7004', name: 'Aerobatic', desc: 'Aerobatic display flight' }
];
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;
if (currentFilter === 'watchlist') return isOnWatchlist(ac);
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' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
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' : ''} ${isOnWatchlist(ac) ? 'watched' : ''}`;
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 id="aircraftPhotoContainer" style="display:none;margin-bottom:10px;">
<a id="aircraftPhotoLink" href="#" target="_blank" rel="noopener">
<img id="aircraftPhoto" src="" alt="Aircraft photo" style="width:100%;border-radius:6px;border:1px solid #333;">
</a>
<div id="aircraftPhotoCredit" style="font-size:9px;color:#666;margin-top:2px;text-align:right;"></div>
</div>
<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-clickable" onclick="showSquawkInfo('${squawk}')" title="Click for squawk info">${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>`;
// Fetch aircraft photo if registration is available
if (registration) {
fetchAircraftPhoto(registration);
}
}
// Cache for aircraft photos to avoid repeated API calls
const photoCache = {};
async function fetchAircraftPhoto(registration) {
const container = document.getElementById('aircraftPhotoContainer');
const img = document.getElementById('aircraftPhoto');
const link = document.getElementById('aircraftPhotoLink');
const credit = document.getElementById('aircraftPhotoCredit');
if (!container || !img) return;
// Check cache first
if (photoCache[registration]) {
const cached = photoCache[registration];
if (cached.thumbnail) {
img.src = cached.thumbnail;
link.href = cached.link || '#';
credit.textContent = cached.photographer ? `Photo: ${cached.photographer}` : '';
container.style.display = 'block';
}
return;
}
try {
const response = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
const data = await response.json();
// Cache the result
photoCache[registration] = data;
if (data.success && data.thumbnail) {
img.src = data.thumbnail;
link.href = data.link || '#';
credit.textContent = data.photographer ? `Photo: ${data.photographer}` : '';
container.style.display = 'block';
} else {
container.style.display = 'none';
}
} catch (err) {
console.debug('Failed to fetch aircraft photo:', err);
container.style.display = 'none';
}
}
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);
// ============================================
// ACARS Functions
// ============================================
let acarsEventSource = null;
let isAcarsRunning = false;
let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') === 'true';
let acarsFrequencies = {
'na': ['129.125', '130.025', '130.450', '131.550'],
'eu': ['131.525', '131.725', '131.550'],
'ap': ['131.550', '131.450']
};
function toggleAcarsSidebar() {
const sidebar = document.getElementById('acarsSidebar');
acarsSidebarCollapsed = !acarsSidebarCollapsed;
sidebar.classList.toggle('collapsed', acarsSidebarCollapsed);
localStorage.setItem('acarsSidebarCollapsed', acarsSidebarCollapsed);
}
// Initialize ACARS sidebar state
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('acarsSidebar');
if (sidebar && acarsSidebarCollapsed) {
sidebar.classList.add('collapsed');
}
});
function setAcarsFreqs() {
// Just updates the region selection - frequencies are sent on start
}
function getAcarsRegionFreqs() {
const region = document.getElementById('acarsRegionSelect').value;
return acarsFrequencies[region] || acarsFrequencies['na'];
}
function toggleAcars() {
if (isAcarsRunning) {
stopAcars();
} else {
startAcars();
}
}
function startAcars() {
const device = document.getElementById('acarsDeviceSelect').value;
const frequencies = getAcarsRegionFreqs();
// Warn if using same device as ADS-B
if (isTracking && device === '0') {
const useAnyway = confirm(
'Warning: ADS-B tracking may be using SDR device 0.\n\n' +
'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' +
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
'Click OK to start ACARS on device ' + device + ' anyway.'
);
if (!useAnyway) return;
}
fetch('/acars/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40' })
})
.then(r => r.json())
.then(data => {
if (data.status === 'started') {
isAcarsRunning = true;
acarsMessageCount = 0;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream();
} else {
alert('ACARS Error: ' + data.message);
}
})
.catch(err => alert('ACARS Error: ' + err));
}
function stopAcars() {
fetch('/acars/stop', { method: 'POST' })
.then(r => r.json())
.then(() => {
isAcarsRunning = false;
document.getElementById('acarsToggleBtn').textContent = '▶ START ACARS';
document.getElementById('acarsToggleBtn').classList.remove('active');
document.getElementById('acarsPanelIndicator').classList.remove('active');
if (acarsEventSource) {
acarsEventSource.close();
acarsEventSource = null;
}
});
}
function startAcarsStream() {
if (acarsEventSource) acarsEventSource.close();
acarsEventSource = new EventSource('/acars/stream');
acarsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (data.type === 'acars') {
acarsMessageCount++;
document.getElementById('acarsCount').textContent = acarsMessageCount;
addAcarsMessage(data);
}
};
acarsEventSource.onerror = function() {
console.error('ACARS stream error');
};
}
function addAcarsMessage(data) {
const container = document.getElementById('acarsMessages');
// Remove "no messages" placeholder if present
const placeholder = container.querySelector('.no-aircraft');
if (placeholder) placeholder.remove();
const msg = document.createElement('div');
msg.className = 'acars-message-item';
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;';
const flight = data.flight || 'UNKNOWN';
const reg = data.reg || '';
const label = data.label || '';
const text = data.text || data.msg || '';
const time = new Date().toLocaleTimeString();
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
${label ? `<div style="color: var(--accent-green);">Label: ${label}</div>` : ''}
${text ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${text}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
// Keep max 50 messages
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
// Populate ACARS device selector
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('acarsDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
if (devices.length > 1) {
select.value = '1';
}
}
});
});
// ============================================
// SQUAWK CODE REFERENCE
// ============================================
function showSquawkInfo(code) {
const modal = document.getElementById('squawkModal');
const codeInfo = document.getElementById('squawkCodeInfo');
const refTable = document.getElementById('squawkRefTable');
// Show info for the clicked code
let infoHtml = '';
if (code && code !== 'N/A' && SQUAWK_CODES[code]) {
const info = SQUAWK_CODES[code];
infoHtml = `
<div class="squawk-current">
<div class="squawk-current-code" style="color: ${info.color}">${code}</div>
<div class="squawk-current-name">${info.name}</div>
<div class="squawk-current-desc">${info.desc}</div>
</div>
`;
} else if (code && code !== 'N/A') {
infoHtml = `
<div class="squawk-current">
<div class="squawk-current-code">${code}</div>
<div class="squawk-current-name">ASSIGNED CODE</div>
<div class="squawk-current-desc">Discrete code assigned by ATC for identification</div>
</div>
`;
}
codeInfo.innerHTML = infoHtml;
// Build reference table
let tableHtml = '';
SQUAWK_REFERENCE.forEach(item => {
if (item.code === '---') {
tableHtml += `<tr class="squawk-divider"><td colspan="3"></td></tr>`;
} else {
const isEmergency = ['7500', '7600', '7700'].includes(item.code);
tableHtml += `
<tr class="${isEmergency ? 'emergency' : ''}">
<td class="squawk-code">${item.code}</td>
<td class="squawk-name">${item.name}</td>
<td class="squawk-desc">${item.desc}</td>
</tr>
`;
}
});
refTable.innerHTML = tableHtml;
modal.classList.add('active');
}
function closeSquawkModal() {
document.getElementById('squawkModal').classList.remove('active');
}
// Close modal on overlay click
document.getElementById('squawkModal')?.addEventListener('click', (e) => {
if (e.target.id === 'squawkModal') closeSquawkModal();
});
</script>
<!-- Squawk Code Reference Modal -->
<div id="squawkModal" class="squawk-modal">
<div class="squawk-modal-content">
<div class="squawk-modal-header">
<span>SQUAWK CODE REFERENCE</span>
<button class="squawk-modal-close" onclick="closeSquawkModal()">&times;</button>
</div>
<div id="squawkCodeInfo"></div>
<div class="squawk-ref-container">
<table class="squawk-ref-table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody id="squawkRefTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Watchlist Modal -->
<div id="watchlistModal" class="watchlist-modal">
<div class="watchlist-modal-content">
<div class="watchlist-modal-header">
<span>★ WATCHLIST</span>
<button class="watchlist-modal-close" onclick="closeWatchlistModal()">&times;</button>
</div>
<div class="watchlist-add-form">
<input type="text" id="watchlistInput" placeholder="Callsign, registration, or ICAO..." onkeypress="if(event.key==='Enter')handleWatchlistAdd()">
<select id="watchlistType">
<option value="any">Any match</option>
<option value="callsign">Callsign</option>
<option value="registration">Registration</option>
<option value="icao">ICAO Hex</option>
</select>
<input type="text" id="watchlistNote" placeholder="Note (optional)" style="flex:1;">
<button onclick="handleWatchlistAdd()">ADD</button>
</div>
<div class="watchlist-entries" id="watchlistEntries">
<div class="watchlist-empty">No entries. Add callsigns, registrations, or ICAO codes to watch.</div>
</div>
<div class="watchlist-footer">
<span class="watchlist-count"><span id="watchlistCount">0</span> entries</span>
<span class="watchlist-hint">Alerts trigger when watched aircraft appear</span>
</div>
</div>
</div>
<style>
.squawk-clickable {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.squawk-clickable:hover {
color: var(--accent-cyan);
}
.squawk-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.squawk-modal.active {
display: flex;
}
.squawk-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.squawk-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.1em;
}
.squawk-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
}
.squawk-modal-close:hover {
color: #fff;
}
.squawk-current {
padding: 16px;
text-align: center;
border-bottom: 1px solid var(--border-color, #333);
background: var(--bg-tertiary, #252525);
}
.squawk-current-code {
font-family: 'JetBrains Mono', monospace;
font-size: 32px;
font-weight: bold;
}
.squawk-current-name {
font-size: 14px;
font-weight: 600;
margin-top: 4px;
color: var(--text-primary, #fff);
}
.squawk-current-desc {
font-size: 11px;
color: var(--text-secondary, #888);
margin-top: 4px;
}
.squawk-ref-container {
overflow-y: auto;
flex: 1;
}
.squawk-ref-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.squawk-ref-table th {
background: var(--bg-tertiary, #252525);
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text-secondary, #888);
position: sticky;
top: 0;
}
.squawk-ref-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #222);
}
.squawk-ref-table tr:hover {
background: var(--bg-tertiary, #252525);
}
.squawk-ref-table tr.emergency {
background: rgba(255, 0, 0, 0.1);
}
.squawk-ref-table tr.emergency:hover {
background: rgba(255, 0, 0, 0.2);
}
.squawk-ref-table .squawk-code {
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
color: var(--accent-cyan, #00d4ff);
}
.squawk-ref-table tr.emergency .squawk-code {
color: #ff4444;
}
.squawk-ref-table .squawk-name {
font-weight: 500;
color: var(--text-primary, #fff);
}
.squawk-ref-table .squawk-desc {
color: var(--text-secondary, #888);
}
.squawk-ref-table tr.squawk-divider td {
padding: 4px;
border-bottom: 1px solid var(--border-color, #444);
}
/* Watchlist Button */
.watchlist-btn {
background: transparent;
border: 1px solid var(--border-color, #444);
color: var(--accent-cyan, #00d4ff);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.watchlist-btn:hover {
background: var(--accent-cyan, #00d4ff);
color: #000;
}
/* Watchlist Modal */
.watchlist-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.watchlist-modal.active {
display: flex;
}
.watchlist-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.watchlist-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00d4ff);
}
.watchlist-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
}
.watchlist-modal-close:hover {
color: #fff;
}
.watchlist-add-form {
display: flex;
gap: 8px;
padding: 12px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
flex-wrap: wrap;
}
.watchlist-add-form input,
.watchlist-add-form select {
padding: 6px 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--bg-primary, #0d0d0d);
color: var(--text-primary, #fff);
font-size: 12px;
}
.watchlist-add-form input:first-child {
flex: 2;
min-width: 150px;
}
.watchlist-add-form button {
padding: 6px 16px;
background: var(--accent-cyan, #00d4ff);
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 11px;
}
.watchlist-add-form button:hover {
opacity: 0.9;
}
.watchlist-entries {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.watchlist-empty {
text-align: center;
color: var(--text-secondary, #666);
padding: 30px;
font-size: 12px;
}
.watchlist-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-tertiary, #252525);
border-radius: 4px;
margin-bottom: 6px;
}
.watchlist-entry:hover {
background: var(--bg-primary, #1a1a1a);
}
.watchlist-entry-info {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.watchlist-value {
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
color: var(--accent-cyan, #00d4ff);
font-size: 13px;
}
.watchlist-type {
font-size: 9px;
padding: 2px 6px;
background: var(--bg-primary, #0d0d0d);
border-radius: 3px;
color: var(--text-secondary, #888);
text-transform: uppercase;
}
.watchlist-note {
font-size: 11px;
color: var(--text-secondary, #888);
}
.watchlist-remove {
background: none;
border: none;
color: var(--text-secondary, #666);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
}
.watchlist-remove:hover {
color: #ff4444;
}
.watchlist-footer {
display: flex;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-tertiary, #252525);
border-top: 1px solid var(--border-color, #333);
font-size: 10px;
color: var(--text-secondary, #888);
}
.watchlist-hint {
opacity: 0.7;
}
/* Watched aircraft highlight in list */
.aircraft-item.watched {
border-left: 3px solid var(--accent-cyan, #00d4ff);
}
.aircraft-item.watched::before {
content: '★';
position: absolute;
right: 8px;
top: 8px;
color: var(--accent-cyan, #00d4ff);
font-size: 10px;
}
</style>
</body>
</html>