mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
- Add mobile CSS for dashboard to allow scrolling and proper stacking - Set explicit height for map container on mobile (50vh min 300px) - Remove sidebar max-height restriction on mobile - Add map invalidateSize() on init, resize, and orientation change - Fix controls bar wrapping and touch-friendly zoom controls - Simplify header layout on mobile Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2883 lines
124 KiB
HTML
2883 lines
124 KiB
HTML
<!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">✈</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">×</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);
|
||
|
||
// Fix map size on mobile after initialization
|
||
setTimeout(() => {
|
||
if (radarMap) radarMap.invalidateSize();
|
||
}, 200);
|
||
}
|
||
|
||
// Handle window resize for map (especially important on mobile)
|
||
window.addEventListener('resize', function() {
|
||
if (radarMap) radarMap.invalidateSize();
|
||
});
|
||
|
||
// Handle orientation changes for mobile devices
|
||
window.addEventListener('orientationchange', function() {
|
||
setTimeout(() => {
|
||
if (radarMap) radarMap.invalidateSize();
|
||
}, 200);
|
||
});
|
||
|
||
// ============================================
|
||
// 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">✈</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()">×</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()">×</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>
|