Files
intercept/templates/adsb_dashboard.html
Smittix 71e5803695 Add stats strip with tracking features to ADS-B dashboard
- Add slim statistics bar with live stats (aircraft count, max range,
  highest altitude, fastest speed, closest aircraft, countries, ACARS)
- Add session timer and report generation with JSON export
- Add signal quality indicator with visual dots
- Add squawk code reference modal
- Add flight lookup button (FlightAware integration)
- Add aircraft type icons (jet, helicopter, prop, military, glider)
- Move status indicator and UTC time from header to stats strip
- Reorganize controls bar into logical groups
- Add ICAO country allocation data for nationality detection

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

3584 lines
161 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
AIRCRAFT RADAR
<span>// INTERCEPT - See the Invisible</span>
</div>
<div class="status-bar">
<a href="/?mode=aircraft" class="back-link">Main Dashboard</a>
</div>
</header>
<!-- Slim Statistics Bar -->
<div class="stats-strip">
<div class="stats-strip-inner">
<div class="strip-stat">
<span class="strip-value" id="stripAircraftNow">0</span>
<span class="strip-label">NOW</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripTotalSeen">0</span>
<span class="strip-label">SEEN</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripMaxRange">0</span>
<span class="strip-label">MAX NM</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripHighest">-</span>
<span class="strip-label">HIGH FL</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripFastest">-</span>
<span class="strip-label">FAST KT</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripClosest">-</span>
<span class="strip-label">NEAR NM</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripCountries">0</span>
<span class="strip-label">COUNTRIES</span>
</div>
<div class="strip-stat">
<span class="strip-value" id="stripAcars">0</span>
<span class="strip-label">ACARS</span>
</div>
<div class="strip-stat signal-stat" title="Signal quality (messages/errors)">
<span class="strip-value" id="stripSignal">--</span>
<span class="strip-label">SIGNAL</span>
</div>
<div class="strip-stat session-stat">
<span class="strip-value" id="stripSession">00:00:00</span>
<span class="strip-label">SESSION</span>
</div>
<div class="strip-divider"></div>
<button class="strip-btn" onclick="showSquawkReference()" title="Squawk Code Reference">
📟 Squawk
</button>
<button class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
🔗 Lookup
</button>
<button class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
📊 Report
</button>
<div class="strip-divider"></div>
<div class="strip-status">
<div class="status-dot inactive" id="trackingDot"></div>
<span id="trackingStatus">STANDBY</span>
</div>
<div class="strip-time" id="utcTime">--:--:-- UTC</div>
</div>
</div>
<main class="dashboard">
<!-- ACARS Panel (left of map) - Collapsible -->
<div class="acars-sidebar" id="acarsSidebar">
<div class="acars-sidebar-content" id="acarsSidebarContent">
<div class="panel acars-panel">
<div class="panel-header">
<span>ACARS MESSAGES</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="acarsCount" style="font-size: 10px; color: var(--accent-cyan);">0</span>
<div class="panel-indicator" id="acarsPanelIndicator"></div>
</div>
</div>
<div id="acarsPanelContent">
<div class="acars-info" style="font-size: 9px; color: var(--text-muted); padding: 5px 8px; border-bottom: 1px solid var(--border-color);">
<span style="color: var(--accent-yellow);"></span> Requires separate SDR (VHF ~131 MHz)
</div>
<div class="acars-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select id="acarsDeviceSelect" style="flex: 1; font-size: 10px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="acarsRegionSelect" onchange="setAcarsFreqs()" style="flex: 1; font-size: 10px;">
<option value="na">N. America</option>
<option value="eu">Europe</option>
<option value="ap">Asia-Pac</option>
</select>
</div>
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
▶ START ACARS
</button>
</div>
<div class="acars-messages" id="acarsMessages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
<div style="font-size: 10px; color: var(--text-muted);">No ACARS messages</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start ACARS to receive aircraft datalink messages</div>
</div>
</div>
</div>
</div>
</div>
<button class="acars-collapse-btn" id="acarsCollapseBtn" onclick="toggleAcarsSidebar()" title="Toggle ACARS Panel">
<span id="acarsCollapseIcon"></span>
<span class="acars-collapse-label">ACARS</span>
</button>
</div>
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
<div id="radarMap"></div>
<div id="radarScope">
<canvas id="radarCanvas"></canvas>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- View Toggle -->
<div class="view-toggle">
<button class="view-btn active" id="mapViewBtn" onclick="setView('map')">MAP</button>
<button class="view-btn" id="radarViewBtn" onclick="setView('radar')">RADAR</button>
</div>
<!-- Selected Aircraft -->
<div class="panel selected-aircraft">
<div class="panel-header">
<span>SELECTED TARGET</span>
<div class="panel-indicator"></div>
</div>
<div class="selected-info" id="selectedInfo">
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>
</div>
</div>
<!-- Aircraft List -->
<div class="panel aircraft-list">
<div class="panel-header">
<span>TRACKED AIRCRAFT</span>
<div class="panel-indicator"></div>
</div>
<div class="aircraft-list-content" id="aircraftList">
<div class="no-aircraft">
<div>No aircraft detected</div>
<div style="font-size: 10px; margin-top: 5px;">Start tracking to begin</div>
</div>
</div>
</div>
</div>
<!-- Controls Bar - Reorganized -->
<div class="controls-bar">
<!-- Display Options Group -->
<div class="control-group">
<span class="control-group-label">DISPLAY</span>
<div class="control-group-items">
<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 Aircraft</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>
</div>
</div>
<!-- Location Group -->
<div class="control-group">
<span class="control-group-label">LOCATION</span>
<div class="control-group-items">
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
</div>
</div>
<!-- ADS-B Tracking Group -->
<div class="control-group tracking-group">
<span class="control-group-label">ADS-B TRACKING</span>
<div class="control-group-items">
<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: 55px;">
</span>
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option>
</select>
<button class="start-btn" id="startBtn" onclick="toggleTracking()">START</button>
</div>
</div>
<!-- Airband Listening Group -->
<div class="control-group airband-group">
<span class="control-group-label">AIRBAND</span>
<div class="control-group-items">
<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-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: 65px; display: none;">
<select id="airbandDeviceSelect" class="airband-controls" title="SDR for airband">
<option value="0">SDR 0</option>
</select>
<div class="airband-sliders">
<span title="Squelch">SQ</span>
<input type="range" id="airbandSquelch" min="0" max="100" value="20" class="airband-controls" title="Squelch">
<span title="Volume">VOL</span>
<input type="range" id="airbandVolume" min="0" max="100" value="80" class="airband-controls" oninput="updateAirbandVolume()" title="Volume">
</div>
<button class="airband-btn" id="airbandBtn" onclick="toggleAirband()">▶ LISTEN</button>
<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="80" height="20"></canvas>
</div>
</div>
</div>
<audio id="airbandPlayer" style="display: none;" crossorigin="anonymous"></audio>
</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: [],
countriesSeen: new Set(),
highestAltitude: 0,
fastestSpeed: 0,
closestDistance: Infinity,
sessionStart: null,
acarsMessages: 0
};
// Session log for report generation
let sessionLog = {
startTime: null,
endTime: null,
aircraftLog: [], // Array of all aircraft seen with details
highlights: [], // Notable events (military, emergency, etc)
maxConcurrent: 0,
peakMsgRate: 0
};
// ICAO Country Allocations (first hex digit ranges)
const ICAO_COUNTRY_RANGES = [
{ start: 0x000000, end: 0x003FFF, country: 'Zimbabwe' },
{ start: 0x004000, end: 0x0043FF, country: 'Mozambique' },
{ start: 0x006000, end: 0x006FFF, country: 'South Africa' },
{ start: 0x008000, end: 0x00FFFF, country: 'South Africa' },
{ start: 0x010000, end: 0x017FFF, country: 'Egypt' },
{ start: 0x018000, end: 0x01FFFF, country: 'Libya' },
{ start: 0x020000, end: 0x027FFF, country: 'Morocco' },
{ start: 0x028000, end: 0x02FFFF, country: 'Tunisia' },
{ start: 0x030000, end: 0x0303FF, country: 'Botswana' },
{ start: 0x032000, end: 0x032FFF, country: 'Burundi' },
{ start: 0x034000, end: 0x034FFF, country: 'Cameroon' },
{ start: 0x038000, end: 0x038FFF, country: 'Congo' },
{ start: 0x03E000, end: 0x03EFFF, country: 'Gabon' },
{ start: 0x040000, end: 0x040FFF, country: 'Ethiopia' },
{ start: 0x042000, end: 0x042FFF, country: 'Equatorial Guinea' },
{ start: 0x044000, end: 0x044FFF, country: 'Ghana' },
{ start: 0x048000, end: 0x0483FF, country: 'Tanzania' },
{ start: 0x050000, end: 0x050FFF, country: 'Kenya' },
{ start: 0x054000, end: 0x054FFF, country: 'Zambia' },
{ start: 0x058000, end: 0x058FFF, country: 'Seychelles' },
{ start: 0x060000, end: 0x061FFF, country: 'Algeria' },
{ start: 0x068000, end: 0x068FFF, country: 'Angola' },
{ start: 0x070000, end: 0x070FFF, country: 'Ivory Coast' },
{ start: 0x078000, end: 0x078FFF, country: 'Mauritius' },
{ start: 0x080000, end: 0x080FFF, country: 'Nigeria' },
{ start: 0x088000, end: 0x088FFF, country: 'Uganda' },
{ start: 0x090000, end: 0x090FFF, country: 'Qatar' },
{ start: 0x0A0000, end: 0x0A7FFF, country: 'India' },
{ start: 0x0C0000, end: 0x0C4FFF, country: 'Australia' },
{ start: 0x100000, end: 0x1FFFFF, country: 'Russia' },
{ start: 0x200000, end: 0x27FFFF, country: 'USA' },
{ start: 0x280000, end: 0x28FFFF, country: 'USA' },
{ start: 0x300000, end: 0x33FFFF, country: 'Italy' },
{ start: 0x340000, end: 0x37FFFF, country: 'Spain' },
{ start: 0x380000, end: 0x3BFFFF, country: 'France' },
{ start: 0x3C0000, end: 0x3FFFFF, country: 'Germany' },
{ start: 0x400000, end: 0x43FFFF, country: 'UK' },
{ start: 0x440000, end: 0x447FFF, country: 'Austria' },
{ start: 0x448000, end: 0x44FFFF, country: 'Belgium' },
{ start: 0x450000, end: 0x457FFF, country: 'Bulgaria' },
{ start: 0x458000, end: 0x45FFFF, country: 'Denmark' },
{ start: 0x460000, end: 0x467FFF, country: 'Finland' },
{ start: 0x468000, end: 0x46FFFF, country: 'Greece' },
{ start: 0x470000, end: 0x477FFF, country: 'Hungary' },
{ start: 0x478000, end: 0x47FFFF, country: 'Norway' },
{ start: 0x480000, end: 0x487FFF, country: 'Netherlands' },
{ start: 0x488000, end: 0x48FFFF, country: 'Poland' },
{ start: 0x490000, end: 0x497FFF, country: 'Portugal' },
{ start: 0x498000, end: 0x49FFFF, country: 'Czech Republic' },
{ start: 0x4A0000, end: 0x4A7FFF, country: 'Romania' },
{ start: 0x4A8000, end: 0x4AFFFF, country: 'Sweden' },
{ start: 0x4B0000, end: 0x4B7FFF, country: 'Switzerland' },
{ start: 0x4B8000, end: 0x4BFFFF, country: 'Turkey' },
{ start: 0x4C0000, end: 0x4C7FFF, country: 'Serbia' },
{ start: 0x4CA000, end: 0x4CAFFF, country: 'Ireland' },
{ start: 0x4D0000, end: 0x4D03FF, country: 'Iceland' },
{ start: 0x500000, end: 0x5003FF, country: 'Luxembourg' },
{ start: 0x501000, end: 0x5013FF, country: 'Monaco' },
{ start: 0x502000, end: 0x502FFF, country: 'Malta' },
{ start: 0x503000, end: 0x5033FF, country: 'San Marino' },
{ start: 0x505000, end: 0x5057FF, country: 'Latvia' },
{ start: 0x506000, end: 0x5067FF, country: 'Lithuania' },
{ start: 0x507000, end: 0x5077FF, country: 'Moldova' },
{ start: 0x508000, end: 0x50FFFF, country: 'Slovakia' },
{ start: 0x510000, end: 0x5107FF, country: 'Slovenia' },
{ start: 0x511000, end: 0x5117FF, country: 'Uzbekistan' },
{ start: 0x512000, end: 0x5127FF, country: 'Ukraine' },
{ start: 0x513000, end: 0x5137FF, country: 'Belarus' },
{ start: 0x514000, end: 0x5147FF, country: 'Estonia' },
{ start: 0x515000, end: 0x5157FF, country: 'Macedonia' },
{ start: 0x516000, end: 0x5167FF, country: 'Bosnia' },
{ start: 0x517000, end: 0x5177FF, country: 'Georgia' },
{ start: 0x518000, end: 0x5187FF, country: 'Tajikistan' },
{ start: 0x600000, end: 0x6003FF, country: 'Armenia' },
{ start: 0x680000, end: 0x6803FF, country: 'Kyrgyzstan' },
{ start: 0x681000, end: 0x6813FF, country: 'Turkmenistan' },
{ start: 0x682000, end: 0x6823FF, country: 'Azerbaijan' },
{ start: 0x683000, end: 0x6833FF, country: 'Kazakhstan' },
{ start: 0x700000, end: 0x700FFF, country: 'Afghanistan' },
{ start: 0x702000, end: 0x702FFF, country: 'Bangladesh' },
{ start: 0x704000, end: 0x704FFF, country: 'Maldives' },
{ start: 0x706000, end: 0x706FFF, country: 'Nepal' },
{ start: 0x708000, end: 0x708FFF, country: 'Pakistan' },
{ start: 0x70A000, end: 0x70AFFF, country: 'Sri Lanka' },
{ start: 0x70C000, end: 0x70C3FF, country: 'Myanmar' },
{ start: 0x710000, end: 0x717FFF, country: 'Japan' },
{ start: 0x718000, end: 0x71FFFF, country: 'Japan' },
{ start: 0x720000, end: 0x727FFF, country: 'Laos' },
{ start: 0x728000, end: 0x72FFFF, country: 'Mongolia' },
{ start: 0x730000, end: 0x737FFF, country: 'Nepal' },
{ start: 0x738000, end: 0x73FFFF, country: 'South Korea' },
{ start: 0x740000, end: 0x747FFF, country: 'Indonesia' },
{ start: 0x748000, end: 0x74FFFF, country: 'Malaysia' },
{ start: 0x750000, end: 0x757FFF, country: 'Philippines' },
{ start: 0x758000, end: 0x75FFFF, country: 'Singapore' },
{ start: 0x760000, end: 0x767FFF, country: 'Thailand' },
{ start: 0x768000, end: 0x76FFFF, country: 'Vietnam' },
{ start: 0x780000, end: 0x7BFFFF, country: 'China' },
{ start: 0x7C0000, end: 0x7FFFFF, country: 'Australia' },
{ start: 0x800000, end: 0x83FFFF, country: 'India' },
{ start: 0x840000, end: 0x87FFFF, country: 'Japan' },
{ start: 0x880000, end: 0x887FFF, country: 'Pakistan' },
{ start: 0x890000, end: 0x890FFF, country: 'Hong Kong' },
{ start: 0x894000, end: 0x894FFF, country: 'Taiwan' },
{ start: 0x895000, end: 0x8953FF, country: 'North Korea' },
{ start: 0x896000, end: 0x896FFF, country: 'Jordan' },
{ start: 0x897000, end: 0x897FFF, country: 'Lebanon' },
{ start: 0x898000, end: 0x898FFF, country: 'Kuwait' },
{ start: 0x899000, end: 0x8993FF, country: 'Saudi Arabia' },
{ start: 0x8A0000, end: 0x8A7FFF, country: 'Saudi Arabia' },
{ start: 0x900000, end: 0x9003FF, country: 'Kuwait' },
{ start: 0x901000, end: 0x9013FF, country: 'Bahrain' },
{ start: 0x902000, end: 0x9023FF, country: 'Yemen' },
{ start: 0x903000, end: 0x9033FF, country: 'Syria' },
{ start: 0xA00000, end: 0xAFFFFF, country: 'USA' },
{ start: 0xC00000, end: 0xC3FFFF, country: 'Canada' },
{ start: 0xC80000, end: 0xC87FFF, country: 'New Zealand' },
{ start: 0xE00000, end: 0xE3FFFF, country: 'Argentina' },
{ start: 0xE40000, end: 0xE7FFFF, country: 'Brazil' },
{ start: 0xE80000, end: 0xE80FFF, country: 'Chile' },
{ start: 0xE84000, end: 0xE84FFF, country: 'Colombia' },
{ start: 0xE88000, end: 0xE88FFF, country: 'Peru' },
{ start: 0xE8C000, end: 0xE8CFFF, country: 'Venezuela' },
{ start: 0xF00000, end: 0xF07FFF, country: 'ICAO (special)' }
];
function getCountryFromIcao(icao) {
const icaoNum = parseInt(icao, 16);
for (const range of ICAO_COUNTRY_RANGES) {
if (icaoNum >= range.start && icaoNum <= range.end) {
return range.country;
}
}
return 'Unknown';
}
// Observer location and range rings (load from localStorage or default to London)
let observerLocation = (function() {
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat && parsed.lon) return parsed;
} catch (e) {}
}
return { lat: 51.5074, lon: -0.1278 };
})();
let rangeRingsLayer = null;
let observerMarker = null;
// GPS state
let gpsConnected = false;
let gpsEventSource = null;
// ============================================
// AUDIO ALERTS
// ============================================
let audioContext = null;
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function playAlertSound(type) {
if (!alertsEnabled) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
if (type === 'emergency') {
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.5);
} else if (type === 'military') {
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.3);
}
} catch (e) {
console.warn('Audio alert failed:', e);
}
}
function checkAndAlertAircraft(icao, ac) {
if (alertedAircraft[icao]) return;
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
const onWatchlist = isOnWatchlist(ac);
if (squawkInfo) {
alertedAircraft[icao] = 'emergency';
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
} else if (onWatchlist) {
alertedAircraft[icao] = 'watchlist';
playAlertSound('military'); // Use military sound for watchlist
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
} else if (militaryInfo.military) {
alertedAircraft[icao] = 'military';
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
}
}
function showAlertBanner(message, color) {
const banner = document.createElement('div');
banner.style.cssText = `
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
background: ${color}; color: white; padding: 10px 20px; border-radius: 6px;
font-weight: bold; font-size: 13px; z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: slideDown 0.3s ease-out;
`;
banner.textContent = message;
document.body.appendChild(banner);
setTimeout(() => {
banner.style.opacity = '0';
banner.style.transition = 'opacity 0.3s';
setTimeout(() => banner.remove(), 300);
}, 5000);
}
function toggleAlerts() {
alertsEnabled = document.getElementById('alertToggle').checked;
}
// ============================================
// WATCHLIST FUNCTIONS
// ============================================
function saveWatchlist() {
localStorage.setItem('adsb_watchlist', JSON.stringify(watchlist));
}
function isOnWatchlist(aircraft) {
const icao = aircraft.icao?.toUpperCase();
const callsign = aircraft.callsign?.toUpperCase()?.trim();
const registration = aircraft.registration?.toUpperCase()?.trim();
return watchlist.some(entry => {
const val = entry.value.toUpperCase();
if (entry.type === 'icao' && icao === val) return true;
if (entry.type === 'callsign' && callsign && callsign.includes(val)) return true;
if (entry.type === 'registration' && registration === val) return true;
if (entry.type === 'any') {
if (icao === val) return true;
if (callsign && callsign.includes(val)) return true;
if (registration === val) return true;
}
return false;
});
}
function addToWatchlist(value, type = 'any', note = '') {
value = value.trim().toUpperCase();
if (!value) return false;
// Check for duplicates
const exists = watchlist.some(e => e.value.toUpperCase() === value && e.type === type);
if (exists) return false;
watchlist.push({ value, type, note, added: Date.now() });
saveWatchlist();
renderWatchlist();
return true;
}
function removeFromWatchlist(index) {
watchlist.splice(index, 1);
saveWatchlist();
renderWatchlist();
}
function showWatchlistModal() {
renderWatchlist();
document.getElementById('watchlistModal').classList.add('active');
document.getElementById('watchlistInput').focus();
}
function closeWatchlistModal() {
document.getElementById('watchlistModal').classList.remove('active');
}
function renderWatchlist() {
const container = document.getElementById('watchlistEntries');
document.getElementById('watchlistCount').textContent = watchlist.length;
if (watchlist.length === 0) {
container.innerHTML = '<div class="watchlist-empty">No entries. Add callsigns, registrations, or ICAO codes to watch.</div>';
return;
}
container.innerHTML = watchlist.map((entry, i) => `
<div class="watchlist-entry">
<div class="watchlist-entry-info">
<span class="watchlist-value">${entry.value}</span>
<span class="watchlist-type">${entry.type}</span>
${entry.note ? `<span class="watchlist-note">${entry.note}</span>` : ''}
</div>
<button class="watchlist-remove" onclick="removeFromWatchlist(${i})" title="Remove">&times;</button>
</div>
`).join('');
}
// Close watchlist modal on overlay click or Escape key
document.getElementById('watchlistModal')?.addEventListener('click', (e) => {
if (e.target.id === 'watchlistModal') closeWatchlistModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeWatchlistModal();
closeSquawkModal();
}
});
function handleWatchlistAdd() {
const input = document.getElementById('watchlistInput');
const type = document.getElementById('watchlistType').value;
const note = document.getElementById('watchlistNote').value.trim();
if (addToWatchlist(input.value, type, note)) {
input.value = '';
document.getElementById('watchlistNote').value = '';
}
}
function addCurrentAircraftToWatchlist() {
if (!selectedIcao || !aircraft[selectedIcao]) return;
const ac = aircraft[selectedIcao];
const value = ac.callsign || ac.registration || ac.icao;
const type = ac.callsign ? 'callsign' : (ac.registration ? 'registration' : 'icao');
addToWatchlist(value, type);
showAlertBanner(`Added ${value} to watchlist`, '#00d4ff');
}
// ============================================
// MILITARY/EMERGENCY DETECTION
// ============================================
const MILITARY_RANGES = [
{ start: 0xADF7C0, end: 0xADFFFF, country: 'US' },
{ start: 0xAE0000, end: 0xAEFFFF, country: 'US' },
{ start: 0x3F4000, end: 0x3F7FFF, country: 'FR' },
{ start: 0x43C000, end: 0x43CFFF, country: 'UK' },
{ start: 0x3D0000, end: 0x3DFFFF, country: 'DE' },
{ start: 0x501C00, end: 0x501FFF, country: 'NATO' },
];
const MILITARY_PREFIXES = [
'REACH', 'JAKE', 'DOOM', 'IRON', 'HAWK', 'VIPER', 'COBRA', 'THUNDER',
'SHADOW', 'NIGHT', 'STEEL', 'GRIM', 'REAPER', 'BLADE', 'STRIKE',
'RCH', 'CNV', 'MCH', 'EVAC', 'TOPCAT', 'ASCOT', 'RRR', 'HRK',
'NAVY', 'ARMY', 'USAF', 'RAF', 'RCAF', 'RAAF', 'IAF', 'PAF'
];
const SQUAWK_CODES = {
// Emergency codes
'7500': { type: 'emergency', name: 'HIJACK', desc: 'Aircraft is being hijacked', color: '#ff0000' },
'7600': { type: 'emergency', name: 'RADIO FAILURE', desc: 'Lost communication with ATC', color: '#ff6600' },
'7700': { type: 'emergency', name: 'EMERGENCY', desc: 'General emergency (mayday)', color: '#ff0000' },
// Special codes
'7777': { type: 'special', name: 'MILITARY INTERCEPT', desc: 'Military interceptor operations', color: '#ff00ff' },
'7000': { type: 'vfr', name: 'VFR (EU)', desc: 'Visual Flight Rules - Europe', color: '#00d4ff' },
'1200': { type: 'vfr', name: 'VFR (US/CA)', desc: 'Visual Flight Rules - North America', color: '#00d4ff' },
'2000': { type: 'standard', name: 'UNASSIGNED', desc: 'No assigned code / entering controlled airspace', color: '#888888' },
'1000': { type: 'standard', name: 'IFR (EU)', desc: 'Instrument Flight Rules with no assigned code', color: '#00ff88' },
'0000': { type: 'special', name: 'DISCRETE', desc: 'Military/special operations', color: '#ff00ff' },
'4000': { type: 'special', name: 'FERRY/DELIVERY', desc: 'Aircraft ferry/delivery flight', color: '#ffaa00' },
'5000': { type: 'special', name: 'MILITARY (UK)', desc: 'UK military operations', color: '#556b2f' },
'0033': { type: 'special', name: 'PARACHUTE OPS', desc: 'Parachute dropping in progress', color: '#ffaa00' },
'7001': { type: 'special', name: 'VFR INTRUSION', desc: 'VFR aircraft entering controlled airspace', color: '#ffaa00' },
'7004': { type: 'special', name: 'AEROBATIC', desc: 'Aerobatic flight display', color: '#00d4ff' },
'7010': { type: 'special', name: 'RADIO EQUIPPED', desc: 'IFR flight (UK zones)', color: '#00ff88' }
};
const SQUAWK_REFERENCE = [
{ code: '7500', name: 'Hijack', desc: 'Aircraft is being hijacked - do not acknowledge' },
{ code: '7600', name: 'Radio Failure', desc: 'Two-way radio communication failure' },
{ code: '7700', name: 'Emergency', desc: 'General emergency (mayday/pan-pan)' },
{ code: '7777', name: 'Military Intercept', desc: 'Active military intercept operations' },
{ code: '---', name: '---', desc: '---' },
{ code: '1200', name: 'VFR (US/Canada)', desc: 'Visual flight rules - North America' },
{ code: '7000', name: 'VFR (Europe)', desc: 'Visual flight rules - ICAO/Europe' },
{ code: '2000', name: 'Conspicuity', desc: 'Entering airspace, no code assigned' },
{ code: '1000', name: 'IFR (Europe)', desc: 'Instrument flight rules, no code assigned' },
{ code: '---', name: '---', desc: '---' },
{ code: '0000', name: 'Discrete', desc: 'Military/special operations' },
{ code: '0033', name: 'Parachute Ops', desc: 'Parachute dropping operations' },
{ code: '4000', name: 'Ferry Flight', desc: 'Aircraft delivery/repositioning' },
{ code: '5000', name: 'Military (UK)', desc: 'UK military low-level operations' },
{ code: '7001', name: 'VFR Intrusion', desc: 'VFR aircraft entering controlled space' },
{ code: '7004', name: 'Aerobatic', desc: 'Aerobatic display flight' }
];
function isMilitaryAircraft(icao, callsign) {
const icaoNum = parseInt(icao, 16);
for (const range of MILITARY_RANGES) {
if (icaoNum >= range.start && icaoNum <= range.end) {
return { military: true, country: range.country };
}
}
if (callsign) {
const upper = callsign.toUpperCase();
for (const prefix of MILITARY_PREFIXES) {
if (upper.startsWith(prefix)) {
return { military: true, type: 'callsign' };
}
}
}
return { military: false };
}
function checkSquawkCode(aircraft) {
if (aircraft.squawk && SQUAWK_CODES[aircraft.squawk]) {
return SQUAWK_CODES[aircraft.squawk];
}
return null;
}
// ============================================
// DISTANCE/BEARING CALCULATIONS
// ============================================
function calculateDistanceNm(lat1, lon1, lat2, lon2) {
const R = 3440.065;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function calculateBearing(lat1, lon1, lat2, lon2) {
const dLon = (lon2 - lon1) * Math.PI / 180;
const lat1Rad = lat1 * Math.PI / 180;
const lat2Rad = lat2 * Math.PI / 180;
const y = Math.sin(dLon) * Math.cos(lat2Rad);
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon);
let bearing = Math.atan2(y, x) * 180 / Math.PI;
return (bearing + 360) % 360;
}
// ============================================
// STATISTICS
// ============================================
function updateStatistics(icao, ac) {
const isNew = !stats.totalAircraftSeen.has(icao);
stats.totalAircraftSeen.add(icao);
// Track country
const country = getCountryFromIcao(icao);
if (country !== 'Unknown') {
stats.countriesSeen.add(country);
}
// Log new aircraft
if (isNew) {
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
sessionLog.aircraftLog.push({
icao,
callsign: ac.callsign || '',
registration: ac.registration || '',
country,
military: militaryInfo.military,
firstSeen: new Date().toISOString(),
altitude: ac.altitude,
speed: ac.speed
});
// Log highlights
if (militaryInfo.military) {
sessionLog.highlights.push({
time: new Date().toISOString(),
type: 'military',
icao,
callsign: ac.callsign || icao,
country: militaryInfo.country || country
});
}
if (squawkInfo && squawkInfo.type === 'emergency') {
sessionLog.highlights.push({
time: new Date().toISOString(),
type: 'emergency',
icao,
callsign: ac.callsign || icao,
squawk: ac.squawk,
name: squawkInfo.name
});
}
}
// Distance calculation
if (ac.lat && ac.lon) {
const distance = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (distance > stats.maxRange) {
stats.maxRange = distance;
}
}
// Message rate
const now = Date.now();
stats.messageTimestamps.push(now);
stats.messageTimestamps = stats.messageTimestamps.filter(t => now - t < 5000);
stats.messagesPerSecond = stats.messageTimestamps.length / 5;
// Track peak message rate
if (stats.messagesPerSecond > sessionLog.peakMsgRate) {
sessionLog.peakMsgRate = stats.messagesPerSecond;
}
updateStatsDisplay();
}
// Signal quality tracking
let signalStats = {
goodMessages: 0,
errorMessages: 0
};
function updateStatsDisplay() {
const aircraftCount = Object.keys(aircraft).length;
// Track max concurrent
if (aircraftCount > sessionLog.maxConcurrent) {
sessionLog.maxConcurrent = aircraftCount;
}
// Calculate live stats from current aircraft
let highest = 0, fastest = 0, closest = Infinity;
let highestIcao = '', fastestIcao = '', closestIcao = '';
Object.entries(aircraft).forEach(([icao, ac]) => {
if (ac.altitude && ac.altitude > highest) {
highest = ac.altitude;
highestIcao = icao;
}
if (ac.speed && ac.speed > fastest) {
fastest = ac.speed;
fastestIcao = icao;
}
if (ac.lat && ac.lon) {
const dist = calculateDistanceNm(
observerLocation.lat, observerLocation.lon,
ac.lat, ac.lon
);
if (dist < closest) {
closest = dist;
closestIcao = icao;
}
}
});
// Update strip stats
document.getElementById('stripAircraftNow').textContent = aircraftCount;
document.getElementById('stripTotalSeen').textContent = stats.totalAircraftSeen.size;
document.getElementById('stripMaxRange').textContent = stats.maxRange.toFixed(0);
document.getElementById('stripHighest').textContent = highest > 0 ? Math.round(highest / 100) : '-';
document.getElementById('stripFastest').textContent = fastest > 0 ? Math.round(fastest) : '-';
document.getElementById('stripClosest').textContent = closest < Infinity ? closest.toFixed(1) : '-';
document.getElementById('stripCountries').textContent = stats.countriesSeen.size;
document.getElementById('stripAcars').textContent = stats.acarsMessages;
// Update signal quality
updateSignalQuality();
}
// Session timer
let sessionTimerInterval = null;
function startSessionTimer() {
if (!stats.sessionStart) {
stats.sessionStart = Date.now();
sessionLog.startTime = new Date().toISOString();
}
if (sessionTimerInterval) clearInterval(sessionTimerInterval);
sessionTimerInterval = setInterval(updateSessionTimer, 1000);
}
function stopSessionTimer() {
sessionLog.endTime = new Date().toISOString();
}
function updateSessionTimer() {
if (!stats.sessionStart) return;
const elapsed = Date.now() - stats.sessionStart;
const hours = Math.floor(elapsed / 3600000);
const mins = Math.floor((elapsed % 3600000) / 60000);
const secs = Math.floor((elapsed % 60000) / 1000);
document.getElementById('stripSession').textContent =
`${hours.toString().padStart(2,'0')}:${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
}
// Report generation
function generateReport() {
stopSessionTimer();
const report = {
title: 'ADS-B Session Report',
generated: new Date().toISOString(),
session: {
start: sessionLog.startTime,
end: sessionLog.endTime || new Date().toISOString(),
duration: stats.sessionStart ? formatDuration(Date.now() - stats.sessionStart) : 'N/A'
},
location: {
lat: observerLocation.lat,
lon: observerLocation.lon
},
statistics: {
totalAircraftSeen: stats.totalAircraftSeen.size,
maxConcurrent: sessionLog.maxConcurrent,
maxRange: stats.maxRange.toFixed(1) + ' nm',
peakMessageRate: sessionLog.peakMsgRate.toFixed(1) + ' msg/s',
countriesSeen: Array.from(stats.countriesSeen).sort(),
acarsMessages: stats.acarsMessages
},
highlights: sessionLog.highlights,
aircraftLog: sessionLog.aircraftLog
};
// Show report modal
showReportModal(report);
}
function formatDuration(ms) {
const hours = Math.floor(ms / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
const secs = Math.floor((ms % 60000) / 1000);
return `${hours}h ${mins}m ${secs}s`;
}
function showReportModal(report) {
const modal = document.createElement('div');
modal.className = 'report-modal';
modal.innerHTML = `
<div class="report-content">
<div class="report-header">
<h2>📊 Session Report</h2>
<button class="report-close" onclick="this.closest('.report-modal').remove()">×</button>
</div>
<div class="report-body">
<div class="report-section">
<h3>Session Info</h3>
<div class="report-grid">
<span>Duration:</span><span>${report.session.duration}</span>
<span>Location:</span><span>${report.location.lat.toFixed(4)}, ${report.location.lon.toFixed(4)}</span>
</div>
</div>
<div class="report-section">
<h3>Statistics</h3>
<div class="report-grid">
<span>Total Aircraft:</span><span>${report.statistics.totalAircraftSeen}</span>
<span>Max Concurrent:</span><span>${report.statistics.maxConcurrent}</span>
<span>Max Range:</span><span>${report.statistics.maxRange}</span>
<span>Peak Msg Rate:</span><span>${report.statistics.peakMessageRate}</span>
<span>Countries:</span><span>${report.statistics.countriesSeen.length} (${report.statistics.countriesSeen.slice(0,5).join(', ')}${report.statistics.countriesSeen.length > 5 ? '...' : ''})</span>
<span>ACARS Messages:</span><span>${report.statistics.acarsMessages}</span>
</div>
</div>
${report.highlights.length > 0 ? `
<div class="report-section">
<h3>Highlights</h3>
<div class="report-highlights">
${report.highlights.slice(0, 10).map(h => `
<div class="highlight-item ${h.type}">
<span class="highlight-type">${h.type.toUpperCase()}</span>
<span class="highlight-detail">${h.callsign}${h.country ? ' (' + h.country + ')' : ''}${h.name ? ' - ' + h.name : ''}</span>
</div>
`).join('')}
${report.highlights.length > 10 ? `<div class="highlight-more">+${report.highlights.length - 10} more...</div>` : ''}
</div>
</div>
` : ''}
<div class="report-section">
<h3>Aircraft Log (${report.aircraftLog.length})</h3>
<div class="report-table-wrap">
<table class="report-table">
<thead>
<tr><th>ICAO</th><th>Callsign</th><th>Country</th><th>Type</th></tr>
</thead>
<tbody>
${report.aircraftLog.slice(0, 50).map(ac => `
<tr class="${ac.military ? 'military' : ''}">
<td>${ac.icao}</td>
<td>${ac.callsign || '-'}</td>
<td>${ac.country}</td>
<td>${ac.military ? '🎖️ MIL' : 'CIV'}</td>
</tr>
`).join('')}
</tbody>
</table>
${report.aircraftLog.length > 50 ? `<div class="report-more">Showing 50 of ${report.aircraftLog.length} aircraft</div>` : ''}
</div>
</div>
</div>
<div class="report-footer">
<button class="report-btn" onclick="downloadReport()">💾 Download JSON</button>
<button class="report-btn" onclick="copyReportToClipboard()">📋 Copy Summary</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Store report for download
window._currentReport = report;
}
function downloadReport() {
if (!window._currentReport) return;
const blob = new Blob([JSON.stringify(window._currentReport, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `adsb-report-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
function copyReportToClipboard() {
if (!window._currentReport) return;
const r = window._currentReport;
const summary = `ADS-B Session Report
Duration: ${r.session.duration}
Aircraft Seen: ${r.statistics.totalAircraftSeen}
Max Concurrent: ${r.statistics.maxConcurrent}
Max Range: ${r.statistics.maxRange}
Countries: ${r.statistics.countriesSeen.join(', ')}
Highlights: ${r.highlights.length} events
ACARS: ${r.statistics.acarsMessages} messages`;
navigator.clipboard.writeText(summary).then(() => {
alert('Summary copied to clipboard');
});
}
// ============================================
// SIGNAL QUALITY
// ============================================
function updateSignalQuality() {
const msgRate = stats.messagesPerSecond;
const el = document.getElementById('stripSignal');
const stat = el.closest('.strip-stat');
if (!isTracking || msgRate === 0) {
el.textContent = '--';
stat.classList.remove('good', 'warning', 'poor');
return;
}
// Signal quality based on message rate
// Good: >10 msg/s, Warning: 2-10, Poor: <2
if (msgRate >= 10) {
el.textContent = '●●●';
stat.classList.remove('warning', 'poor');
stat.classList.add('good');
} else if (msgRate >= 2) {
el.textContent = '●●○';
stat.classList.remove('good', 'poor');
stat.classList.add('warning');
} else {
el.textContent = '●○○';
stat.classList.remove('good', 'warning');
stat.classList.add('poor');
}
}
// ============================================
// SQUAWK CODE REFERENCE
// ============================================
function showSquawkReference() {
const modal = document.createElement('div');
modal.className = 'squawk-modal';
modal.innerHTML = `
<div class="squawk-content">
<div class="squawk-header">
<h2>📟 Squawk Code Reference</h2>
<button class="squawk-close" onclick="this.closest('.squawk-modal').remove()">×</button>
</div>
<div class="squawk-body">
<div class="squawk-section emergency">
<h3>🚨 Emergency Codes</h3>
<div class="squawk-grid">
<div class="squawk-item"><span class="squawk-code">7500</span><span class="squawk-name">HIJACK</span><span class="squawk-desc">Aircraft being hijacked - do not acknowledge</span></div>
<div class="squawk-item"><span class="squawk-code">7600</span><span class="squawk-name">RADIO FAIL</span><span class="squawk-desc">Two-way radio communication failure</span></div>
<div class="squawk-item"><span class="squawk-code">7700</span><span class="squawk-name">EMERGENCY</span><span class="squawk-desc">General emergency (mayday/pan-pan)</span></div>
</div>
</div>
<div class="squawk-section special">
<h3>⚠️ Special Codes</h3>
<div class="squawk-grid">
<div class="squawk-item"><span class="squawk-code">7777</span><span class="squawk-name">MIL INTERCEPT</span><span class="squawk-desc">Active military intercept operations</span></div>
<div class="squawk-item"><span class="squawk-code">0000</span><span class="squawk-name">DISCRETE</span><span class="squawk-desc">Military/special operations</span></div>
<div class="squawk-item"><span class="squawk-code">5000</span><span class="squawk-name">MILITARY UK</span><span class="squawk-desc">UK military low-level operations</span></div>
<div class="squawk-item"><span class="squawk-code">0033</span><span class="squawk-name">PARA OPS</span><span class="squawk-desc">Parachute dropping operations</span></div>
</div>
</div>
<div class="squawk-section standard">
<h3>✈️ Standard VFR/IFR</h3>
<div class="squawk-grid">
<div class="squawk-item"><span class="squawk-code">1200</span><span class="squawk-name">VFR (US/CA)</span><span class="squawk-desc">Visual flight rules - North America</span></div>
<div class="squawk-item"><span class="squawk-code">7000</span><span class="squawk-name">VFR (EU)</span><span class="squawk-desc">Visual flight rules - ICAO/Europe</span></div>
<div class="squawk-item"><span class="squawk-code">2000</span><span class="squawk-name">CONSPICUITY</span><span class="squawk-desc">Entering airspace, no code assigned</span></div>
<div class="squawk-item"><span class="squawk-code">1000</span><span class="squawk-name">IFR (EU)</span><span class="squawk-desc">Instrument flight rules, no assigned code</span></div>
</div>
</div>
<div class="squawk-section other">
<h3>📋 Other Codes</h3>
<div class="squawk-grid">
<div class="squawk-item"><span class="squawk-code">4000</span><span class="squawk-name">FERRY</span><span class="squawk-desc">Aircraft delivery/repositioning</span></div>
<div class="squawk-item"><span class="squawk-code">7001</span><span class="squawk-name">VFR INTRUSION</span><span class="squawk-desc">VFR aircraft entering controlled space</span></div>
<div class="squawk-item"><span class="squawk-code">7004</span><span class="squawk-name">AEROBATIC</span><span class="squawk-desc">Aerobatic display flight</span></div>
<div class="squawk-item"><span class="squawk-code">7010</span><span class="squawk-name">RADIO EQUIPPED</span><span class="squawk-desc">IFR flight (UK zones)</span></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
}
// ============================================
// FLIGHT LOOKUP
// ============================================
function lookupSelectedFlight() {
if (!selectedIcao || !aircraft[selectedIcao]) return;
const ac = aircraft[selectedIcao];
const callsign = ac.callsign?.trim();
const reg = ac.registration?.trim();
// Prefer callsign, then registration, then ICAO
let searchTerm = callsign || reg || selectedIcao;
// Open FlightAware search
const url = `https://flightaware.com/live/flight/${searchTerm}`;
window.open(url, '_blank');
}
function updateFlightLookupBtn() {
const btn = document.getElementById('flightLookupBtn');
if (selectedIcao && aircraft[selectedIcao]) {
btn.disabled = false;
const ac = aircraft[selectedIcao];
const label = ac.callsign || ac.registration || selectedIcao;
btn.title = `Lookup ${label} on FlightAware`;
} else {
btn.disabled = true;
btn.title = 'Select an aircraft first';
}
}
// ============================================
// 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();
initDeviceSelectors();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
checkAdsbTools();
checkAircraftDatabase();
// Auto-connect to gpsd if available
autoConnectGps();
});
// Track which device is being used for ADS-B tracking
let adsbActiveDevice = null;
function initDeviceSelectors() {
// Populate both ADS-B and airband device selectors
fetch('/devices')
.then(r => r.json())
.then(devices => {
const adsbSelect = document.getElementById('adsbDeviceSelect');
const airbandSelect = document.getElementById('airbandDeviceSelect');
// Clear loading state
adsbSelect.innerHTML = '';
airbandSelect.innerHTML = '';
if (devices.length === 0) {
adsbSelect.innerHTML = '<option value="0">No SDR found</option>';
airbandSelect.innerHTML = '<option value="0">No SDR found</option>';
airbandSelect.disabled = true;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
const name = dev.name || dev.type || 'RTL-SDR';
const shortName = `SDR ${idx}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = idx;
adsbOpt.textContent = shortName;
adsbOpt.title = name;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = idx;
airbandOpt.textContent = shortName;
airbandOpt.title = name;
airbandSelect.appendChild(airbandOpt);
});
// Default: ADS-B uses first device, Airband uses second (if available)
adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0;
if (devices.length > 1) {
airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1;
}
// Show warning if only one device
if (devices.length === 1) {
document.getElementById('airbandStatus').textContent = '1 SDR only';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
}
}
})
.catch(() => {
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="0">Error</option>';
});
}
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;
// Get selected ADS-B device
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0;
const requestBody = {
device: adsbDevice
};
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();
startSessionTimer();
isTracking = true;
adsbActiveDevice = adsbDevice; // Track which device is being used
btn.textContent = 'STOP';
btn.classList.add('active');
document.getElementById('trackingDot').classList.remove('inactive');
document.getElementById('trackingStatus').textContent = 'TRACKING';
// Disable ADS-B device selector while tracking
document.getElementById('adsbDeviceSelect').disabled = true;
} 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;
adsbActiveDevice = null;
btn.textContent = 'START';
btn.classList.remove('active');
document.getElementById('trackingDot').classList.add('inactive');
document.getElementById('trackingStatus').textContent = 'STANDBY';
// Re-enable ADS-B device selector
document.getElementById('adsbDeviceSelect').disabled = false;
}
}
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 iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color || prevState.iconType !== iconType;
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, iconType));
}
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, iconType) })
.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, iconType };
}
// Aircraft type icon SVG paths
const AIRCRAFT_ICONS = {
jet: 'M12 2L8 10H4v2l8 4 8-4v-2h-4L12 2zm0 14l-6 3v1h12v-1l-6-3z',
helicopter: 'M12 4L10 6H8V8h1l3 8 3-8h1V6h-2L12 4zm-1 14v2H9v1h6v-1h-2v-2h-2zm7-7h-2v2h2v-2zM4 11h2v2H4v-2z',
prop: 'M12 3L9 8H5v2l7 6 7-6v-2h-4L12 3zm0 12l-4 2v1h8v-1l-4-2z',
military: 'M12 2L7 9H3l1 3 8 6 8-6 1-3h-4L12 2zm0 14l-5 2.5V20h10v-1.5L12 16z',
glider: 'M12 4L10 8H4v1.5l8 4 8-4V8h-6L12 4zm0 10l-6 2v1h12v-1l-6-2z'
};
// Determine aircraft type from type_code
function getAircraftIconType(typeCode, isMilitary) {
if (isMilitary) return 'military';
if (!typeCode) return 'jet';
const code = typeCode.toUpperCase();
// Helicopters
if (code.startsWith('H') || code.includes('HELI') ||
['R22', 'R44', 'R66', 'EC35', 'EC45', 'AS50', 'AS55', 'AS65', 'B06', 'B212', 'B412', 'S76', 'A109', 'AW139', 'AW169'].some(h => code.includes(h))) {
return 'helicopter';
}
// Gliders
if (code.startsWith('G') || code.includes('GLID')) {
return 'glider';
}
// Light props (common GA aircraft)
if (['C150', 'C152', 'C172', 'C182', 'C206', 'C208', 'C210', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'SR20', 'SR22', 'DA40', 'DA42', 'TB20', 'M20', 'BE35', 'BE36', 'BE58'].some(p => code.includes(p))) {
return 'prop';
}
// Turboprops
if (['ATR', 'DH8', 'DHC', 'SF34', 'J328', 'B190', 'PC12', 'TBM'].some(t => code.includes(t))) {
return 'prop';
}
return 'jet';
}
function createMarkerIcon(rotation, color, iconType = 'jet') {
const path = AIRCRAFT_ICONS[iconType] || AIRCRAFT_ICONS.jet;
const size = iconType === 'helicopter' ? 22 : 24;
return L.divIcon({
className: `aircraft-marker aircraft-${iconType}`,
html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color}; filter: drop-shadow(0 0 5px ${color});">
<path fill="currentColor" d="${path}"/>
</svg>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
}
// ============================================
// 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);
updateFlightLookupBtn();
const ac = aircraft[icao];
if (ac && ac.lat && ac.lon && currentView === 'map') {
radarMap.setView([ac.lat, ac.lon], 10);
}
}
function showAircraftDetails(icao) {
const ac = aircraft[icao];
const container = document.getElementById('selectedInfo');
if (!ac) {
container.innerHTML = `
<div class="no-aircraft">
<div class="no-aircraft-icon">&#9992;</div>
<div>Select an aircraft</div>
</div>`;
return;
}
const callsign = ac.callsign || ac.icao;
const lat = ac.lat ? ac.lat.toFixed(4) + '°' : 'N/A';
const lon = ac.lon ? ac.lon.toFixed(4) + '°' : 'N/A';
const alt = ac.altitude ? ac.altitude.toLocaleString() + ' ft' : 'N/A';
const speed = ac.speed ? ac.speed + ' kts' : 'N/A';
const heading = ac.heading ? ac.heading + '°' : 'N/A';
const squawk = ac.squawk || 'N/A';
const vrate = ac.vertical_rate !== undefined ? (ac.vertical_rate >= 0 ? '+' : '') + ac.vertical_rate.toLocaleString() + ' ft/min' : 'N/A';
const registration = ac.registration || '';
const typeCode = ac.type_code || '';
const typeDesc = ac.type_desc || '';
const militaryInfo = isMilitaryAircraft(ac.icao, ac.callsign);
const badge = militaryInfo.military ?
`<div style="background:#556b2f;color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;text-align:center;margin-bottom:8px;">MILITARY${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}</div>` : '';
// Aircraft type info line (shown if available from database)
const typeInfo = (typeCode || typeDesc) ?
`<div style="color:#00d4ff;font-size:11px;margin-bottom:6px;">${typeDesc || typeCode}${registration ? ' • ' + registration : ''}</div>` : '';
container.innerHTML = `
<div id="aircraftPhotoContainer" style="display:none;margin-bottom:10px;">
<a id="aircraftPhotoLink" href="#" target="_blank" rel="noopener">
<img id="aircraftPhoto" src="" alt="Aircraft photo" style="width:100%;border-radius:6px;border:1px solid #333;">
</a>
<div id="aircraftPhotoCredit" style="font-size:9px;color:#666;margin-top:2px;text-align:right;"></div>
</div>
<div class="selected-callsign">${callsign}</div>
${typeInfo}
${badge}
<div class="telemetry-grid">
<div class="telemetry-item">
<div class="telemetry-label">ICAO</div>
<div class="telemetry-value">${ac.icao}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Squawk</div>
<div class="telemetry-value squawk-clickable" onclick="showSquawkInfo('${squawk}')" title="Click for squawk info">${squawk}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lat</div>
<div class="telemetry-value">${lat}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Lon</div>
<div class="telemetry-value">${lon}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Altitude</div>
<div class="telemetry-value">${alt}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Speed</div>
<div class="telemetry-value">${speed}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Heading</div>
<div class="telemetry-value">${heading}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">V/S</div>
<div class="telemetry-value" style="${ac.vertical_rate > 0 ? 'color: #00ff88;' : ac.vertical_rate < 0 ? 'color: #ff6b6b;' : ''}">${vrate}</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Range</div>
<div class="telemetry-value">${ac.lat ? calculateDistanceNm(observerLocation.lat, observerLocation.lon, ac.lat, ac.lon).toFixed(1) + ' nm' : 'N/A'}</div>
</div>
</div>`;
// Fetch aircraft photo if registration is available
if (registration) {
fetchAircraftPhoto(registration);
}
}
// Cache for aircraft photos to avoid repeated API calls
const photoCache = {};
async function fetchAircraftPhoto(registration) {
const container = document.getElementById('aircraftPhotoContainer');
const img = document.getElementById('aircraftPhoto');
const link = document.getElementById('aircraftPhotoLink');
const credit = document.getElementById('aircraftPhotoCredit');
if (!container || !img) return;
// Check cache first
if (photoCache[registration]) {
const cached = photoCache[registration];
if (cached.thumbnail) {
img.src = cached.thumbnail;
link.href = cached.link || '#';
credit.textContent = cached.photographer ? `Photo: ${cached.photographer}` : '';
container.style.display = 'block';
}
return;
}
try {
const response = await fetch(`/adsb/aircraft-photo/${encodeURIComponent(registration)}`);
const data = await response.json();
// Cache the result
photoCache[registration] = data;
if (data.success && data.thumbnail) {
img.src = data.thumbnail;
link.href = data.link || '#';
credit.textContent = data.photographer ? `Photo: ${data.photographer}` : '';
container.style.display = 'block';
} else {
container.style.display = 'none';
}
} catch (err) {
console.debug('Failed to fetch aircraft photo:', err);
container.style.display = 'none';
}
}
function cleanupOldAircraft() {
const now = Date.now();
const timeout = 60000;
let needsUpdate = false;
Object.keys(aircraft).forEach(icao => {
if (now - aircraft[icao].lastSeen > timeout) {
if (markers[icao]) {
radarMap.removeLayer(markers[icao]);
delete markers[icao];
}
cleanupTrail(icao);
delete aircraft[icao];
delete alertedAircraft[icao];
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
updateFlightLookupBtn();
}
}
});
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() {
// 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
if (isTracking && adsbActiveDevice !== null && device === adsbActiveDevice) {
const useAnyway = confirm(
`Warning: ADS-B tracking is using SDR ${adsbActiveDevice}.\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();
// Apply current volume setting
const volume = parseInt(document.getElementById('airbandVolume').value) || 80;
audioPlayer.volume = volume / 100;
// 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(() => {});
}
function updateAirbandVolume() {
const audioPlayer = document.getElementById('airbandPlayer');
const volume = parseInt(document.getElementById('airbandVolume').value) || 80;
if (audioPlayer) {
audioPlayer.volume = volume / 100;
}
}
// 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++;
stats.acarsMessages++;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('stripAcars').textContent = stats.acarsMessages;
addAcarsMessage(data);
}
};
acarsEventSource.onerror = function() {
console.error('ACARS stream error');
};
}
function addAcarsMessage(data) {
const container = document.getElementById('acarsMessages');
// Remove "no messages" placeholder if present
const placeholder = container.querySelector('.no-aircraft');
if (placeholder) placeholder.remove();
const msg = document.createElement('div');
msg.className = 'acars-message-item';
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;';
const flight = data.flight || 'UNKNOWN';
const reg = data.reg || '';
const label = data.label || '';
const text = data.text || data.msg || '';
const time = new Date().toLocaleTimeString();
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${flight}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${reg}</div>` : ''}
${label ? `<div style="color: var(--accent-green);">Label: ${label}</div>` : ''}
${text ? `<div style="color: var(--text-primary); margin-top: 3px; word-break: break-word;">${text}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
// Keep max 50 messages
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
// Populate ACARS device selector
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('acarsDeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
opt.value = d.index || i;
opt.textContent = `Device ${d.index || i}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
// Default to device 1 if available (device 0 likely used for ADS-B)
if (devices.length > 1) {
select.value = '1';
}
}
});
});
// ============================================
// SQUAWK CODE REFERENCE
// ============================================
function showSquawkInfo(code) {
const modal = document.getElementById('squawkModal');
const codeInfo = document.getElementById('squawkCodeInfo');
const refTable = document.getElementById('squawkRefTable');
// Show info for the clicked code
let infoHtml = '';
if (code && code !== 'N/A' && SQUAWK_CODES[code]) {
const info = SQUAWK_CODES[code];
infoHtml = `
<div class="squawk-current">
<div class="squawk-current-code" style="color: ${info.color}">${code}</div>
<div class="squawk-current-name">${info.name}</div>
<div class="squawk-current-desc">${info.desc}</div>
</div>
`;
} else if (code && code !== 'N/A') {
infoHtml = `
<div class="squawk-current">
<div class="squawk-current-code">${code}</div>
<div class="squawk-current-name">ASSIGNED CODE</div>
<div class="squawk-current-desc">Discrete code assigned by ATC for identification</div>
</div>
`;
}
codeInfo.innerHTML = infoHtml;
// Build reference table
let tableHtml = '';
SQUAWK_REFERENCE.forEach(item => {
if (item.code === '---') {
tableHtml += `<tr class="squawk-divider"><td colspan="3"></td></tr>`;
} else {
const isEmergency = ['7500', '7600', '7700'].includes(item.code);
tableHtml += `
<tr class="${isEmergency ? 'emergency' : ''}">
<td class="squawk-code">${item.code}</td>
<td class="squawk-name">${item.name}</td>
<td class="squawk-desc">${item.desc}</td>
</tr>
`;
}
});
refTable.innerHTML = tableHtml;
modal.classList.add('active');
}
function closeSquawkModal() {
document.getElementById('squawkModal').classList.remove('active');
}
// Close modal on overlay click
document.getElementById('squawkModal')?.addEventListener('click', (e) => {
if (e.target.id === 'squawkModal') closeSquawkModal();
});
</script>
<!-- Squawk Code Reference Modal -->
<div id="squawkModal" class="squawk-modal">
<div class="squawk-modal-content">
<div class="squawk-modal-header">
<span>SQUAWK CODE REFERENCE</span>
<button class="squawk-modal-close" onclick="closeSquawkModal()">&times;</button>
</div>
<div id="squawkCodeInfo"></div>
<div class="squawk-ref-container">
<table class="squawk-ref-table">
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody id="squawkRefTable"></tbody>
</table>
</div>
</div>
</div>
<!-- Watchlist Modal -->
<div id="watchlistModal" class="watchlist-modal">
<div class="watchlist-modal-content">
<div class="watchlist-modal-header">
<span>★ WATCHLIST</span>
<button class="watchlist-modal-close" onclick="closeWatchlistModal()">&times;</button>
</div>
<div class="watchlist-add-form">
<input type="text" id="watchlistInput" placeholder="Callsign, registration, or ICAO..." onkeypress="if(event.key==='Enter')handleWatchlistAdd()">
<select id="watchlistType">
<option value="any">Any match</option>
<option value="callsign">Callsign</option>
<option value="registration">Registration</option>
<option value="icao">ICAO Hex</option>
</select>
<input type="text" id="watchlistNote" placeholder="Note (optional)" style="flex:1;">
<button onclick="handleWatchlistAdd()">ADD</button>
</div>
<div class="watchlist-entries" id="watchlistEntries">
<div class="watchlist-empty">No entries. Add callsigns, registrations, or ICAO codes to watch.</div>
</div>
<div class="watchlist-footer">
<span class="watchlist-count"><span id="watchlistCount">0</span> entries</span>
<span class="watchlist-hint">Alerts trigger when watched aircraft appear</span>
</div>
</div>
</div>
<style>
.squawk-clickable {
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}
.squawk-clickable:hover {
color: var(--accent-cyan);
}
.squawk-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.squawk-modal.active {
display: flex;
}
.squawk-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.squawk-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.1em;
}
.squawk-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
}
.squawk-modal-close:hover {
color: #fff;
}
.squawk-current {
padding: 16px;
text-align: center;
border-bottom: 1px solid var(--border-color, #333);
background: var(--bg-tertiary, #252525);
}
.squawk-current-code {
font-family: 'JetBrains Mono', monospace;
font-size: 32px;
font-weight: bold;
}
.squawk-current-name {
font-size: 14px;
font-weight: 600;
margin-top: 4px;
color: var(--text-primary, #fff);
}
.squawk-current-desc {
font-size: 11px;
color: var(--text-secondary, #888);
margin-top: 4px;
}
.squawk-ref-container {
overflow-y: auto;
flex: 1;
}
.squawk-ref-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.squawk-ref-table th {
background: var(--bg-tertiary, #252525);
padding: 8px 12px;
text-align: left;
font-weight: 600;
color: var(--text-secondary, #888);
position: sticky;
top: 0;
}
.squawk-ref-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #222);
}
.squawk-ref-table tr:hover {
background: var(--bg-tertiary, #252525);
}
.squawk-ref-table tr.emergency {
background: rgba(255, 0, 0, 0.1);
}
.squawk-ref-table tr.emergency:hover {
background: rgba(255, 0, 0, 0.2);
}
.squawk-ref-table .squawk-code {
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
color: var(--accent-cyan, #00d4ff);
}
.squawk-ref-table tr.emergency .squawk-code {
color: #ff4444;
}
.squawk-ref-table .squawk-name {
font-weight: 500;
color: var(--text-primary, #fff);
}
.squawk-ref-table .squawk-desc {
color: var(--text-secondary, #888);
}
.squawk-ref-table tr.squawk-divider td {
padding: 4px;
border-bottom: 1px solid var(--border-color, #444);
}
/* Watchlist Button */
.watchlist-btn {
background: transparent;
border: 1px solid var(--border-color, #444);
color: var(--accent-cyan, #00d4ff);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.watchlist-btn:hover {
background: var(--accent-cyan, #00d4ff);
color: #000;
}
/* Watchlist Modal */
.watchlist-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
}
.watchlist-modal.active {
display: flex;
}
.watchlist-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 500px;
max-height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.watchlist-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
font-weight: 600;
font-size: 12px;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00d4ff);
}
.watchlist-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
}
.watchlist-modal-close:hover {
color: #fff;
}
.watchlist-add-form {
display: flex;
gap: 8px;
padding: 12px;
background: var(--bg-tertiary, #252525);
border-bottom: 1px solid var(--border-color, #333);
flex-wrap: wrap;
}
.watchlist-add-form input,
.watchlist-add-form select {
padding: 6px 10px;
border: 1px solid var(--border-color, #444);
border-radius: 4px;
background: var(--bg-primary, #0d0d0d);
color: var(--text-primary, #fff);
font-size: 12px;
}
.watchlist-add-form input:first-child {
flex: 2;
min-width: 150px;
}
.watchlist-add-form button {
padding: 6px 16px;
background: var(--accent-cyan, #00d4ff);
color: #000;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 600;
font-size: 11px;
}
.watchlist-add-form button:hover {
opacity: 0.9;
}
.watchlist-entries {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.watchlist-empty {
text-align: center;
color: var(--text-secondary, #666);
padding: 30px;
font-size: 12px;
}
.watchlist-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-tertiary, #252525);
border-radius: 4px;
margin-bottom: 6px;
}
.watchlist-entry:hover {
background: var(--bg-primary, #1a1a1a);
}
.watchlist-entry-info {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.watchlist-value {
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
color: var(--accent-cyan, #00d4ff);
font-size: 13px;
}
.watchlist-type {
font-size: 9px;
padding: 2px 6px;
background: var(--bg-primary, #0d0d0d);
border-radius: 3px;
color: var(--text-secondary, #888);
text-transform: uppercase;
}
.watchlist-note {
font-size: 11px;
color: var(--text-secondary, #888);
}
.watchlist-remove {
background: none;
border: none;
color: var(--text-secondary, #666);
font-size: 18px;
cursor: pointer;
padding: 0 4px;
}
.watchlist-remove:hover {
color: #ff4444;
}
.watchlist-footer {
display: flex;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-tertiary, #252525);
border-top: 1px solid var(--border-color, #333);
font-size: 10px;
color: var(--text-secondary, #888);
}
.watchlist-hint {
opacity: 0.7;
}
/* Watched aircraft highlight in list */
.aircraft-item.watched {
border-left: 3px solid var(--accent-cyan, #00d4ff);
}
.aircraft-item.watched::before {
content: '★';
position: absolute;
right: 8px;
top: 8px;
color: var(--accent-cyan, #00d4ff);
font-size: 10px;
}
</style>
</body>
</html>