Files
intercept/templates/adsb_dashboard.html
James Smith 11c65448f5 Add aircraft trails, radar scope, and UI overhaul to ADS-B dashboard
- Aircraft Trails: altitude-based color gradient with fading opacity
- Virtual Radar Scope (PPI): canvas-based with sweep animation, range rings, compass rose
- UI Overhaul: controls bar at bottom, compact stats in header, MAP/RADAR view toggle
- Range selector syncs with both map rings and radar scope
- Click-to-select aircraft works in both map and radar views

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 21:14:40 +00:00

1918 lines
70 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</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&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>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00ff88;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-green: #00ff88;
--accent-cyan: #00d4ff;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--accent-yellow: #ffcc00;
--grid-line: rgba(0, 255, 136, 0.1);
--radar-cyan: #00ffff;
--radar-bg: #1a1a2e;
}
body {
font-family: 'Rajdhani', sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Animated radar sweep background */
.radar-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
pointer-events: none;
z-index: 0;
}
/* Scan line effect */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
animation: scan 4s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.5;
}
@keyframes scan {
0% { top: -4px; }
100% { top: 100vh; }
}
/* Header */
.header {
position: relative;
z-index: 10;
padding: 12px 20px;
background: linear-gradient(180deg, rgba(0,255,136,0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0,255,136,0.3);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-green);
text-shadow: 0 0 20px var(--accent-green), 0 0 40px var(--accent-green);
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 14px;
margin-left: 15px;
letter-spacing: 2px;
}
.status-bar {
display: flex;
gap: 20px;
align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.status-item {
display: flex;
align-items: center;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.inactive {
background: var(--accent-red);
box-shadow: 0 0 10px var(--accent-red);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Stats badges in header */
.stats-badges {
display: flex;
gap: 12px;
}
.stat-badge {
background: rgba(0,255,136,0.1);
border: 1px solid rgba(0,255,136,0.3);
border-radius: 4px;
padding: 4px 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.stat-badge .value {
color: var(--accent-green);
font-weight: 600;
}
.stat-badge .label {
color: var(--text-secondary);
margin-left: 4px;
}
.datetime {
font-family: 'Orbitron', monospace;
font-size: 12px;
color: var(--accent-green);
}
.back-link {
color: var(--accent-green);
text-decoration: none;
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--accent-green);
border-radius: 4px;
}
/* Main dashboard grid */
.dashboard {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 1fr 340px;
grid-template-rows: 1fr auto;
gap: 0;
height: calc(100vh - 60px);
min-height: 500px;
}
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(0,255,136,0.2);
overflow: hidden;
position: relative;
}
.panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-green), transparent);
}
.panel-header {
padding: 10px 15px;
background: rgba(0,255,136,0.05);
border-bottom: 1px solid rgba(0,255,136,0.1);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-green);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-indicator {
width: 6px;
height: 6px;
background: var(--accent-green);
border-radius: 50%;
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@keyframes slideDown {
from { opacity: 0; transform: translateX(-50%) translateY(-20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* Main display container (map + radar scope) */
.main-display {
grid-column: 1;
grid-row: 1;
position: relative;
}
.display-container {
position: relative;
width: 100%;
height: 100%;
}
#radarMap {
width: 100%;
height: 100%;
display: block;
}
#radarScope {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
background: var(--radar-bg);
}
#radarScope.active {
display: flex;
justify-content: center;
align-items: center;
}
#radarCanvas {
max-width: 100%;
max-height: 100%;
}
/* Right sidebar */
.sidebar {
grid-column: 2;
grid-row: 1;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(0,255,136,0.2);
overflow: hidden;
}
/* View toggle */
.view-toggle {
display: flex;
padding: 10px;
gap: 8px;
background: var(--bg-panel);
border-bottom: 1px solid rgba(0,255,136,0.2);
}
.view-btn {
flex: 1;
padding: 10px;
border: 1px solid rgba(0,255,136,0.3);
background: transparent;
color: var(--text-secondary);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.view-btn:hover {
border-color: var(--accent-green);
color: var(--accent-green);
}
.view-btn.active {
background: var(--accent-green);
border-color: var(--accent-green);
color: var(--bg-dark);
}
/* Selected aircraft panel */
.selected-aircraft {
flex-shrink: 0;
max-height: 280px;
overflow-y: auto;
}
.selected-info {
padding: 12px;
}
.selected-callsign {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-weight: 700;
color: var(--accent-green);
text-shadow: 0 0 15px var(--accent-green);
text-align: center;
margin-bottom: 12px;
}
.telemetry-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.telemetry-item {
background: rgba(0,0,0,0.3);
border-radius: 4px;
padding: 8px;
border-left: 2px solid var(--accent-green);
}
.telemetry-label {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-bottom: 2px;
}
.telemetry-value {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--accent-cyan);
}
/* Aircraft list */
.aircraft-list {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.aircraft-list-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.aircraft-item {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,136,0.15);
border-radius: 4px;
padding: 8px 10px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.aircraft-item:hover {
border-color: var(--accent-green);
background: rgba(0,255,136,0.05);
}
.aircraft-item.selected {
border-color: var(--accent-green);
box-shadow: 0 0 15px rgba(0,255,136,0.2);
background: rgba(0,255,136,0.1);
}
.aircraft-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.aircraft-callsign {
font-family: 'Orbitron', monospace;
font-size: 12px;
font-weight: 600;
color: var(--accent-green);
}
.aircraft-icao {
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
color: var(--text-secondary);
background: rgba(0,255,136,0.1);
padding: 2px 5px;
border-radius: 3px;
}
.aircraft-details {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
font-size: 10px;
}
.aircraft-detail {
text-align: center;
}
.aircraft-detail-value {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-cyan);
font-size: 11px;
}
.aircraft-detail-label {
color: var(--text-secondary);
font-size: 8px;
text-transform: uppercase;
}
/* Bottom controls bar */
.controls-bar {
grid-column: 1 / -1;
grid-row: 2;
display: flex;
align-items: center;
gap: 20px;
padding: 10px 20px;
background: var(--bg-panel);
border-top: 1px solid rgba(0,255,136,0.3);
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
}
.control-group label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: var(--text-primary);
}
.control-group input[type="checkbox"] {
accent-color: var(--accent-green);
}
.control-group select {
padding: 6px 10px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,136,0.3);
border-radius: 4px;
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.control-group input[type="text"] {
width: 80px;
padding: 6px 8px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,255,136,0.3);
border-radius: 4px;
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.control-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
/* Start/stop button */
.start-btn {
padding: 8px 20px;
border: 1px solid var(--accent-green);
background: rgba(0,255,136,0.1);
color: var(--accent-green);
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 2px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
margin-left: auto;
}
.start-btn:hover {
background: var(--accent-green);
color: var(--bg-dark);
box-shadow: 0 0 20px rgba(0,255,136,0.3);
}
.start-btn.active {
background: var(--accent-red);
border-color: var(--accent-red);
color: #fff;
}
.start-btn.active:hover {
box-shadow: 0 0 20px rgba(255,68,68,0.3);
}
/* GPS button */
.gps-btn {
padding: 6px 10px;
background: rgba(0,255,136,0.2);
border: 1px solid rgba(0,255,136,0.3);
border-radius: 4px;
color: var(--accent-green);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
cursor: pointer;
}
/* Leaflet overrides */
.leaflet-container {
background: var(--bg-dark) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-green) !important;
border-color: rgba(0,255,136,0.3) !important;
}
.leaflet-control-attribution {
background: rgba(0,0,0,0.7) !important;
color: var(--text-secondary) !important;
font-size: 9px !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--accent-green);
border-radius: 3px;
}
/* No aircraft message */
.no-aircraft {
text-align: center;
padding: 30px 15px;
color: var(--text-secondary);
}
.no-aircraft-icon {
font-size: 36px;
margin-bottom: 10px;
opacity: 0.5;
}
/* Responsive */
@media (max-width: 1000px) {
.dashboard {
grid-template-columns: 1fr;
grid-template-rows: 1fr auto auto;
}
.main-display {
min-height: 400px;
}
.sidebar {
grid-column: 1;
grid-row: 2;
border-left: none;
border-top: 1px solid rgba(0,255,136,0.2);
max-height: 300px;
}
.controls-bar {
grid-row: 3;
flex-wrap: wrap;
}
}
</style>
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
AIRCRAFT RADAR
<span>// INTERCEPT</span>
</div>
<div class="stats-badges">
<div class="stat-badge">
<span class="value" id="statTotal">0</span>
<span class="label">aircraft</span>
</div>
<div class="stat-badge">
<span class="value" id="statMaxRange">0</span>
<span class="label">nm max</span>
</div>
<div class="stat-badge">
<span class="value" id="statMsgRate">0</span>
<span class="label">msg/s</span>
</div>
</div>
<div class="status-bar">
<div class="status-item">
<div class="status-dot inactive" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="datetime" id="utcTime">--:--:-- UTC</div>
<a href="/?mode=aircraft" class="back-link">Main Dashboard</a>
</div>
</header>
<main class="dashboard">
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
<div id="radarMap"></div>
<div id="radarScope">
<canvas id="radarCanvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" id="mapViewBtn" onclick="setView('map')">MAP</button>
<button class="view-btn" id="radarViewBtn" onclick="setView('radar')">RADAR</button>
</div>
<!-- Selected Aircraft -->
<div class="panel selected-aircraft">
<div class="panel-header">
<span>SELECTED TARGET</span>
<div class="panel-indicator"></div>
</div>
<div class="selected-info" id="selectedInfo">
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>
</div>
</div>
<!-- Aircraft List -->
<div class="panel aircraft-list">
<div class="panel-header">
<span>TRACKED AIRCRAFT</span>
<div class="panel-indicator"></div>
</div>
<div class="aircraft-list-content" id="aircraftList">
<div class="no-aircraft">
<div>No aircraft detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<div class="control-group">
<label>
<input type="checkbox" id="showTrails" onchange="toggleTrails()">
Trails
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="showRangeRings" onchange="drawRangeRings()">
Range Rings
</label>
</div>
<div class="control-group">
<label>
<input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()">
Alerts
</label>
</div>
<div class="control-group">
<span class="control-label">Filter:</span>
<select id="aircraftFilter" onchange="applyFilter()">
<option value="all">All</option>
<option value="military">Military</option>
<option value="civil">Civil</option>
<option value="emergency">Emergency</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Range:</span>
<select id="rangeSelect" onchange="updateRange()">
<option value="50">50 nm</option>
<option value="100">100 nm</option>
<option value="200" selected>200 nm</option>
<option value="300">300 nm</option>
</select>
</div>
<div class="control-group">
<span class="control-label">Lat:</span>
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<span class="control-label">Lon:</span>
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()">
</div>
<div class="control-group">
<button class="gps-btn" id="geolocateBtn" onclick="getGeolocation()">GPS</button>
</div>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</main>
<script>
// ============================================
// STATE
// ============================================
let radarMap = null;
let aircraft = {};
let markers = {};
let selectedIcao = null;
let eventSource = null;
let isTracking = false;
let currentFilter = 'all';
let alertedAircraft = {};
let alertsEnabled = true;
let currentView = 'map'; // 'map' or 'radar'
// Aircraft trails
let aircraftTrails = {}; // ICAO -> [{lat, lon, alt, time}, ...]
let trailLines = {}; // ICAO -> L.polyline (array of segments)
let showTrails = false;
const MAX_TRAIL_POINTS = 100;
// Radar scope
let radarScope = null;
let radarAnimationId = null;
let maxRange = 200; // nautical miles
// Statistics
let stats = {
totalAircraftSeen: new Set(),
maxRange: 0,
messagesPerSecond: 0,
messageTimestamps: []
};
// Observer location and range rings
let observerLocation = { lat: 51.5074, lon: -0.1278 };
let rangeRingsLayer = null;
let observerMarker = null;
// ============================================
// AUDIO ALERTS
// ============================================
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function playAlertSound(type) {
if (!alertsEnabled) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'emergency') {
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
} else if (type === 'military') {
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
}
}
function showAlertBanner(message, color) {
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px; border-radius: 6px;
font-weight: bold; font-size: 13px; z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: slideDown 0.3s ease-out;
`;
banner.textContent = message;
document.body.appendChild(banner);
setTimeout(() => {
banner.style.opacity = '0';
banner.style.transition = 'opacity 0.3s';
setTimeout(() => banner.remove(), 300);
}, 5000);
}
function toggleAlerts() {
alertsEnabled = document.getElementById('alertToggle').checked;
}
// ============================================
// MILITARY/EMERGENCY DETECTION
// ============================================
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' },
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' },
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' },
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' },
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' },
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' },
];
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
const SQUAWK_CODES = {
'7500': { type: 'hijack', name: 'HIJACK' },
'7600': { type: 'radio', name: 'RADIO FAILURE' },
'7700': { type: 'mayday', name: 'EMERGENCY' }
};
function isMilitaryAircraft(icao, callsign) {
const icaoNum = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (icaoNum >= range.start && icaoNum <= range.end) {
return { military: true, country: range.country };
}
}
if (callsign) {
const upper = callsign.toUpperCase();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) {
return { military: true, type: 'callsign' };
}
}
}
return { military: false };
}
function checkSquawkCode(aircraft) {
if (aircraft.squawk && SQUAWK_CODES[aircraft.squawk]) {
return SQUAWK_CODES[aircraft.squawk];
}
return null;
}
// ============================================
// DISTANCE/BEARING CALCULATIONS
// ============================================
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function calculateBearing(lat1, lon1, lat2, lon2) {
const dLon = (lon2 - lon1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
}
// ============================================
// STATISTICS
// ============================================
function updateStatistics(icao, ac) {
if (!ac.lat || !ac.lon) return;
stats.totalAircraftSeen.add(icao);
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > stats.maxRange) {
stats.maxRange = distance;
}
const now = Date.now();
stats.messageTimestamps.push(now);
stats.messageTimestamps = stats.messageTimestamps.filter(t => now - t < 5000);
stats.messagesPerSecond = stats.messageTimestamps.length / 5;
updateStatsDisplay();
}
function updateStatsDisplay() {
document.getElementById('statMaxRange').textContent = stats.maxRange.toFixed(0);
document.getElementById('statMsgRate').textContent = stats.messagesPerSecond.toFixed(1);
document.getElementById('statTotal').textContent = Object.keys(aircraft).length;
}
// ============================================
// AIRCRAFT TRAILS
// ============================================
function toggleTrails() {
showTrails = document.getElementById('showTrails').checked;
if (!showTrails) {
// Remove all trail lines from map
Object.keys(trailLines).forEach(icao => {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
});
} else {
// Draw existing trails
Object.keys(aircraftTrails).forEach(icao => {
updateTrailLine(icao);
});
}
}
function recordTrailPoint(icao, lat, lon, alt) {
if (!aircraftTrails[icao]) aircraftTrails[icao] = [];
const trail = aircraftTrails[icao];
// Only add if moved significantly
if (trail.length === 0 ||
Math.abs(trail[trail.length-1].lat - lat) > 0.0005 ||
Math.abs(trail[trail.length-1].lon - lon) > 0.0005) {
trail.push({ lat, lon, alt: alt || 0, time: Date.now() });
if (trail.length > MAX_TRAIL_POINTS) trail.shift();
}
}
function getAltitudeColor(alt) {
if (!alt || alt <= 0) return '#888888';
if (alt < 10000) return '#00ff88'; // Green - low
if (alt < 25000) return '#00d4ff'; // Cyan - medium
if (alt < 35000) return '#ffcc00'; // Yellow - high
return '#ff9500'; // Orange - very high
}
function updateTrailLine(icao) {
if (!showTrails || !radarMap) return;
const trail = aircraftTrails[icao];
if (!trail || trail.length < 2) return;
// Remove old trail lines
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
}
trailLines[icao] = [];
// Create gradient segments
const now = Date.now();
for (let i = 1; i < trail.length; i++) {
const p1 = trail[i-1];
const p2 = trail[i];
const age = (now - p2.time) / 1000; // seconds
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
const color = getAltitudeColor(p2.alt);
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], {
color: color,
weight: 2,
opacity: opacity
}).addTo(radarMap);
trailLines[icao].push(line);
}
}
function cleanupTrail(icao) {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
delete aircraftTrails[icao];
}
// ============================================
// RADAR SCOPE (PPI)
// ============================================
class RadarScope {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.sweepAngle = 0;
this.blips = []; // Aircraft blips with afterglow
this.resize();
window.addEventListener('resize', () => this.resize());
}
resize() {
const container = this.canvas.parentElement;
const size = Math.min(container.clientWidth, container.clientHeight) - 40;
this.canvas.width = size;
this.canvas.height = size;
this.centerX = size / 2;
this.centerY = size / 2;
this.radius = (size / 2) - 30;
}
draw() {
const ctx = this.ctx;
const w = this.canvas.width;
const h = this.canvas.height;
// Clear
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
// Draw range rings
this.drawRangeRings();
// Draw compass rose
this.drawCompassRose();
// Draw aircraft blips
this.drawBlips();
// Draw sweep line
this.drawSweep();
// Draw center point (observer)
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, 4, 0, Math.PI * 2);
ctx.fillStyle = '#ffff00';
ctx.fill();
}
drawRangeRings() {
const ctx = this.ctx;
const rings = [0.25, 0.5, 0.75, 1.0];
ctx.strokeStyle = 'rgba(0, 255, 255, 0.2)';
ctx.lineWidth = 1;
rings.forEach((ratio, i) => {
const r = this.radius * ratio;
ctx.beginPath();
ctx.arc(this.centerX, this.centerY, r, 0, Math.PI * 2);
ctx.stroke();
// Range label
const rangeNm = Math.round(maxRange * ratio);
ctx.fillStyle = 'rgba(0, 255, 255, 0.5)';
ctx.font = '10px JetBrains Mono';
ctx.fillText(`${rangeNm}`, this.centerX + r + 5, this.centerY + 4);
});
}
drawCompassRose() {
const ctx = this.ctx;
const directions = [
{ angle: 0, label: 'N' },
{ angle: 90, label: 'E' },
{ angle: 180, label: 'S' },
{ angle: 270, label: 'W' }
];
ctx.fillStyle = '#00ffff';
ctx.font = 'bold 12px Orbitron';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
directions.forEach(d => {
const rad = (d.angle - 90) * Math.PI / 180;
const x = this.centerX + (this.radius + 15) * Math.cos(rad);
const y = this.centerY + (this.radius + 15) * Math.sin(rad);
ctx.fillText(d.label, x, y);
});
// Draw tick marks every 30 degrees
ctx.strokeStyle = 'rgba(0, 255, 255, 0.3)';
ctx.lineWidth = 1;
for (let angle = 0; angle < 360; angle += 30) {
const rad = (angle - 90) * Math.PI / 180;
const inner = this.radius - 5;
const outer = this.radius + 2;
ctx.beginPath();
ctx.moveTo(
this.centerX + inner * Math.cos(rad),
this.centerY + inner * Math.sin(rad)
);
ctx.lineTo(
this.centerX + outer * Math.cos(rad),
this.centerY + outer * Math.sin(rad)
);
ctx.stroke();
}
}
drawSweep() {
const ctx = this.ctx;
const rad = (this.sweepAngle - 90) * Math.PI / 180;
// Sweep line with gradient
const gradient = ctx.createLinearGradient(
this.centerX, this.centerY,
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
gradient.addColorStop(0, 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0.1)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.lineTo(
this.centerX + this.radius * Math.cos(rad),
this.centerY + this.radius * Math.sin(rad)
);
ctx.strokeStyle = gradient;
ctx.lineWidth = 2;
ctx.stroke();
// Sweep arc (afterglow)
const startAngle = (this.sweepAngle - 90 - 30) * Math.PI / 180;
const endAngle = (this.sweepAngle - 90) * Math.PI / 180;
const arcGradient = ctx.createConicGradient(startAngle, this.centerX, this.centerY);
arcGradient.addColorStop(0, 'rgba(0, 255, 255, 0)');
arcGradient.addColorStop(1, 'rgba(0, 255, 255, 0.15)');
ctx.beginPath();
ctx.moveTo(this.centerX, this.centerY);
ctx.arc(this.centerX, this.centerY, this.radius, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = arcGradient;
ctx.fill();
// Update sweep angle
this.sweepAngle = (this.sweepAngle + 2) % 360;
}
drawBlips() {
const ctx = this.ctx;
const now = Date.now();
// Update blips from aircraft data
this.blips = [];
Object.entries(aircraft).forEach(([icao, ac]) => {
if (!ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) return;
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > maxRange) return;
const bearing = calculateBearing(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
const ratio = distance / maxRange;
const rad = (bearing - 90) * Math.PI / 180;
const x = this.centerX + (this.radius * ratio) * Math.cos(rad);
const y = this.centerY + (this.radius * ratio) * Math.sin(rad);
this.blips.push({
x, y,
icao,
callsign: ac.callsign,
altitude: ac.altitude,
selected: icao === selectedIcao
});
});
// Draw blips
this.blips.forEach(blip => {
// Blip glow
const gradient = ctx.createRadialGradient(blip.x, blip.y, 0, blip.x, blip.y, 12);
gradient.addColorStop(0, blip.selected ? 'rgba(255, 255, 0, 0.8)' : 'rgba(0, 255, 255, 0.8)');
gradient.addColorStop(1, 'rgba(0, 255, 255, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(blip.x - 12, blip.y - 12, 24, 24);
// Blip dot
ctx.beginPath();
ctx.arc(blip.x, blip.y, blip.selected ? 5 : 3, 0, Math.PI * 2);
ctx.fillStyle = blip.selected ? '#ffff00' : '#00ffff';
ctx.fill();
// Label
if (blip.callsign || blip.selected) {
ctx.fillStyle = '#00ffff';
ctx.font = '9px JetBrains Mono';
ctx.textAlign = 'left';
ctx.fillText(blip.callsign || blip.icao, blip.x + 8, blip.y - 5);
if (blip.altitude) {
ctx.fillText(`${Math.round(blip.altitude/100)}`, blip.x + 8, blip.y + 7);
}
}
});
}
handleClick(event) {
const rect = this.canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Find clicked blip
for (const blip of this.blips) {
const dx = x - blip.x;
const dy = y - blip.y;
if (dx * dx + dy * dy < 100) { // 10px radius
selectAircraft(blip.icao);
return;
}
}
}
}
function startRadarAnimation() {
if (radarAnimationId) return;
function animate() {
if (currentView === 'radar' && radarScope) {
radarScope.draw();
}
radarAnimationId = requestAnimationFrame(animate);
}
animate();
}
function stopRadarAnimation() {
if (radarAnimationId) {
cancelAnimationFrame(radarAnimationId);
radarAnimationId = null;
}
}
// ============================================
// VIEW TOGGLE
// ============================================
function setView(view) {
currentView = view;
const mapEl = document.getElementById('radarMap');
const scopeEl = document.getElementById('radarScope');
const mapBtn = document.getElementById('mapViewBtn');
const radarBtn = document.getElementById('radarViewBtn');
if (view === 'map') {
mapEl.style.display = 'block';
scopeEl.classList.remove('active');
mapBtn.classList.add('active');
radarBtn.classList.remove('active');
stopRadarAnimation();
// Invalidate map size after showing
setTimeout(() => radarMap && radarMap.invalidateSize(), 100);
} else {
mapEl.style.display = 'none';
scopeEl.classList.add('active');
mapBtn.classList.remove('active');
radarBtn.classList.add('active');
if (!radarScope) {
radarScope = new RadarScope('radarCanvas');
document.getElementById('radarCanvas').addEventListener('click', (e) => radarScope.handleClick(e));
}
radarScope.resize();
startRadarAnimation();
}
}
function updateRange() {
maxRange = parseInt(document.getElementById('rangeSelect').value);
drawRangeRings();
}
// ============================================
// RANGE RINGS (MAP)
// ============================================
function drawRangeRings() {
if (!radarMap) return;
if (rangeRingsLayer) {
radarMap.removeLayer(rangeRingsLayer);
rangeRingsLayer = null;
}
const showRings = document.getElementById('showRangeRings')?.checked;
if (!showRings) return;
rangeRingsLayer = L.layerGroup();
const distances = [maxRange * 0.25, maxRange * 0.5, maxRange * 0.75, maxRange];
distances.forEach(nm => {
const meters = nm * 1852;
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
radius: meters,
color: '#00ff88',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.4,
dashArray: '5, 5'
});
const labelLat = observerLocation.lat + (nm * 0.0166);
const label = L.marker([labelLat, observerLocation.lon], {
icon: L.divIcon({
className: 'range-label',
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
iconSize: [40, 12],
iconAnchor: [20, 6]
})
});
rangeRingsLayer.addLayer(circle);
rangeRingsLayer.addLayer(label);
});
// Observer marker
if (observerMarker) radarMap.removeLayer(observerMarker);
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
icon: L.divIcon({
className: 'observer-marker',
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
iconSize: [12, 12],
iconAnchor: [6, 6]
})
}).bindPopup('Your Location').addTo(radarMap);
rangeRingsLayer.addTo(radarMap);
}
function updateObserverLoc() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
observerLocation.lat = lat;
observerLocation.lon = lon;
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;
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 = 'GPS';
},
(error) => {
alert('Location error: ' + error.message);
btn.textContent = 'GPS';
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
// ============================================
// FILTERING
// ============================================
function applyFilter() {
currentFilter = document.getElementById('aircraftFilter').value;
// Clear markers and redraw
Object.keys(markers).forEach(icao => {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
});
Object.keys(markerState).forEach(icao => delete markerState[icao]);
pendingMarkerUpdates.clear();
Object.keys(aircraft).forEach(icao => {
if (aircraft[icao].lat && aircraft[icao].lon) {
pendingMarkerUpdates.add(icao);
}
});
scheduleUIUpdate();
}
function passesFilter(icao, ac) {
if (currentFilter === 'all') return true;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
if (currentFilter === 'military') return militaryInfo.military;
if (currentFilter === 'civil') return !militaryInfo.military;
if (currentFilter === 'emergency') return !!squawkInfo;
return true;
}
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', () => {
initMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
});
function updateClock() {
const now = new Date();
document.getElementById('utcTime').textContent =
now.toISOString().substring(11, 19) + ' UTC';
}
function initMap() {
radarMap = L.map('radarMap', {
center: [51.5, -0.1],
zoom: 7,
minZoom: 3,
maxZoom: 15
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap, ©CartoDB'
}).addTo(radarMap);
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
radarMap.setView([pos.coords.latitude, pos.coords.longitude], 8);
observerLocation.lat = pos.coords.latitude;
observerLocation.lon = pos.coords.longitude;
document.getElementById('obsLat').value = observerLocation.lat.toFixed(4);
document.getElementById('obsLon').value = observerLocation.lon.toFixed(4);
}, () => {}, { timeout: 5000 });
}
}
// ============================================
// TRACKING CONTROL
// ============================================
async function toggleTracking() {
const btn = document.getElementById('startBtn');
if (!isTracking) {
try {
const response = await fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
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();
eventSource = new EventSource('/adsb/stream');
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'aircraft') {
updateAircraft(data);
}
} catch (err) {}
};
eventSource.onerror = () => {};
}
function stopEventStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
}
// ============================================
// AIRCRAFT UPDATES
// ============================================
let pendingUIUpdate = false;
let pendingMarkerUpdates = new Set();
const MAX_MARKER_UPDATES_PER_FRAME = 20;
function scheduleUIUpdate() {
if (pendingUIUpdate) return;
pendingUIUpdate = true;
requestAnimationFrame(() => {
updateStatsDisplay();
renderAircraftList();
let updateCount = 0;
const toProcess = [];
for (const icao of pendingMarkerUpdates) {
if (updateCount < MAX_MARKER_UPDATES_PER_FRAME) {
updateMarkerImmediate(icao);
toProcess.push(icao);
updateCount++;
}
}
toProcess.forEach(icao => pendingMarkerUpdates.delete(icao));
if (pendingMarkerUpdates.size > 0) {
pendingUIUpdate = false;
scheduleUIUpdate();
return;
}
if (selectedIcao && aircraft[selectedIcao]) {
showAircraftDetails(selectedIcao);
}
pendingUIUpdate = false;
});
}
function updateAircraft(data) {
const icao = data.icao;
if (!icao) return;
aircraft[icao] = {
...aircraft[icao],
...data,
lastSeen: Date.now()
};
checkAndAlertAircraft(icao, aircraft[icao]);
updateStatistics(icao, aircraft[icao]);
// Record trail point
if (data.lat && data.lon) {
recordTrailPoint(icao, data.lat, data.lon, data.altitude);
if (showTrails) {
updateTrailLine(icao);
}
pendingMarkerUpdates.add(icao);
}
scheduleUIUpdate();
}
const markerState = {};
function updateMarkerImmediate(icao) {
const ac = aircraft[icao];
if (!ac || !ac.lat || !ac.lon) return;
if (!passesFilter(icao, ac)) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
delete markerState[icao];
}
return;
}
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const callsign = ac.callsign || icao;
const alt = ac.altitude ? ac.altitude + ' ft' : 'N/A';
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color;
const tooltipChanged = prevState.callsign !== callsign || prevState.alt !== alt;
if (markers[icao]) {
markers[icao].setLatLng([ac.lat, ac.lon]);
if (iconChanged) {
markers[icao].setIcon(createMarkerIcon(rotation, color));
}
if (tooltipChanged) {
markers[icao].unbindTooltip();
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
} else {
markers[icao] = L.marker([ac.lat, ac.lon], { icon: createMarkerIcon(rotation, color) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
markerState[icao] = { rotation, color, callsign, alt };
}
function createMarkerIcon(rotation, color) {
return L.divIcon({
className: 'aircraft-marker',
html: `<svg width="24" height="24" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color}; filter: drop-shadow(0 0 5px ${color});">
<path fill="currentColor" d="M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z"/>
</svg>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
});
}
// ============================================
// AIRCRAFT LIST
// ============================================
let renderedAircraftOrder = [];
let lastFullRebuild = 0;
const MAX_AIRCRAFT_DISPLAY = 50;
const MIN_REBUILD_INTERVAL = 2000;
function renderAircraftList() {
const container = document.getElementById('aircraftList');
const sortedAircraft = Object.entries(aircraft)
.filter(([icao, ac]) => passesFilter(icao, ac))
.map(([icao, ac]) => ({ ...ac, icao }))
.sort((a, b) => (b.altitude || 0) - (a.altitude || 0))
.slice(0, MAX_AIRCRAFT_DISPLAY);
if (sortedAircraft.length === 0) {
if (container.querySelector('.no-aircraft')) return;
container.innerHTML = `<div class="no-aircraft"><div>No aircraft detected</div></div>`;
renderedAircraftOrder = [];
return;
}
const newOrder = sortedAircraft.map(ac => ac.icao);
const orderChanged = newOrder.length !== renderedAircraftOrder.length ||
newOrder.some((icao, i) => icao !== renderedAircraftOrder[i]);
const now = Date.now();
const canRebuild = now - lastFullRebuild > MIN_REBUILD_INTERVAL;
if (orderChanged && canRebuild) {
lastFullRebuild = now;
const fragment = document.createDocumentFragment();
sortedAircraft.forEach(ac => {
const div = document.createElement('div');
div.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
div.setAttribute('data-icao', ac.icao);
div.onclick = () => selectAircraft(ac.icao);
div.innerHTML = buildAircraftItemHTML(ac);
fragment.appendChild(div);
});
container.innerHTML = '';
container.appendChild(fragment);
renderedAircraftOrder = newOrder;
} else {
const existingItems = {};
container.querySelectorAll('[data-icao]').forEach(el => {
existingItems[el.getAttribute('data-icao')] = el;
});
sortedAircraft.forEach(ac => {
const existingItem = existingItems[ac.icao];
if (existingItem) {
existingItem.className = `aircraft-item ${selectedIcao === ac.icao ? 'selected' : ''}`;
existingItem.innerHTML = buildAircraftItemHTML(ac);
}
});
}
}
function buildAircraftItemHTML(ac) {
const callsign = ac.callsign || '------';
const alt = ac.altitude ? ac.altitude.toLocaleString() : '---';
const speed = ac.speed || '---';
const heading = ac.heading ? ac.heading + '°' : '---';
const 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>` : '';
return `
<div class="aircraft-header">
<span class="aircraft-callsign">${callsign}${badge}</span>
<span class="aircraft-icao">${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>
`;
}
function selectAircraft(icao) {
selectedIcao = icao;
renderAircraftList();
showAircraftDetails(icao);
const ac = aircraft[icao];
if (ac && ac.lat && ac.lon && currentView === 'map') {
radarMap.setView([ac.lat, ac.lon], 10);
}
}
function showAircraftDetails(icao) {
const ac = aircraft[icao];
const container = document.getElementById('selectedInfo');
if (!ac) {
container.innerHTML = `
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>`;
return;
}
const callsign = ac.callsign || ac.icao;
const lat = ac.lat ? ac.lat.toFixed(4) + '°' : 'N/A';
const lon = ac.lon ? ac.lon.toFixed(4) + '°' : 'N/A';
const alt = ac.altitude ? ac.altitude.toLocaleString() + ' ft' : 'N/A';
const speed = ac.speed ? ac.speed + ' kts' : 'N/A';
const heading = ac.heading ? ac.heading + '°' : 'N/A';
const squawk = ac.squawk || 'N/A';
const 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>` : '';
container.innerHTML = `
<div class="selected-callsign">${callsign}</div>
${badge}
<div class="telemetry-grid">
<div class="telemetry-item">
<div class="telemetry-label">ICAO</div>
<div class="telemetry-value">${ac.icao}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Squawk</div>
<div class="telemetry-value">${squawk}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lat</div>
<div class="telemetry-value">${lat}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lon</div>
<div class="telemetry-value">${lon}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Altitude</div>
<div class="telemetry-value">${alt}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Speed</div>
<div class="telemetry-value">${speed}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Heading</div>
<div class="telemetry-value">${heading}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
</div>
</div>`;
}
function cleanupOldAircraft() {
const now = Date.now();
const timeout = 60000;
let needsUpdate = false;
Object.keys(aircraft).forEach(icao => {
if (now - aircraft[icao].lastSeen > timeout) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
}
cleanupTrail(icao);
delete aircraft[icao];
delete alertedAircraft[icao];
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
}
}
});
if (needsUpdate) {
scheduleUIUpdate();
}
}
</script>
</body>
</html>