Files
intercept/templates/adsb_dashboard.html
2026-03-18 21:33:32 +00:00

5976 lines
274 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" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
<!-- Dedicated dashboards always use bundled assets so navigation is not
blocked by external CDN reachability. -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<!-- Core CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/adsb_dashboard.css') }}">
<!-- Deferred scripts -->
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
</script>
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<div class="radar-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
AIRCRAFT RADAR
<span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT - See the Invisible</span>
</div>
<div class="status-bar">
<!-- Agent Selector -->
<div class="agent-selector-compact" id="agentSection">
<select id="agentSelect" class="agent-select-sm" title="Select signal source">
<option value="local">Local</option>
</select>
<span class="agent-status-dot online" id="agentStatusDot"></span>
<label class="show-all-label" title="Show aircraft from all agents on map">
<input type="checkbox" id="showAllAgents" onchange="toggleShowAllAgents()"> All
</label>
</div>
</div>
</header>
{% if not embedded %}
{% set active_mode = 'adsb' %}
{% include 'partials/nav.html' with context %}
{% endif %}
<!-- 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">
<span class="strip-value" id="stripVdl2">0</span>
<span class="strip-label">VDL2</span>
</div>
<div class="strip-stat source-stat" title="Data source (Local or Agent name)">
<span class="strip-value" id="stripSource">Local</span>
<span class="strip-label">SOURCE</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 type="button" class="strip-btn" onclick="showSquawkInfo(null)" title="Squawk Code Reference">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><line x1="6" y1="10" x2="6" y2="14"/><line x1="10" y1="10" x2="10" y2="14"/><line x1="14" y1="10" x2="14" y2="14"/><line x1="18" y1="10" x2="18" y2="14"/></svg>
Squawk
</button>
<button type="button" class="strip-btn" onclick="lookupSelectedFlight()" title="Lookup selected aircraft on FlightAware" id="flightLookupBtn" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Lookup
</button>
<button type="button" class="strip-btn primary" onclick="generateReport()" title="Generate Session Report">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
Report
</button>
<a class="strip-btn" href="/adsb/history" title="Open History Reporting">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
History
</a>
<button type="button" class="strip-btn" onclick="toggleAntennaGuide()" title="1090 MHz Antenna Guide">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12 L12 2 L22 12"/><line x1="12" y1="2" x2="12" y2="22"/><path d="M4.93 4.93 L12 12"/><path d="M19.07 4.93 L12 12"/></svg>
Antenna
</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">
<!-- Left Sidebars (ACARS + VDL2) -->
<div class="left-sidebars">
<!-- 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="updateAcarsFreqCheckboxes()" 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>
<div id="acarsFreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
<!-- Frequency checkboxes populated by JS -->
</div>
<div style="display: flex; gap: 5px;">
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="flex: 1;">
▶ START ACARS
</button>
<button class="acars-btn" id="acarsClearBtn" onclick="clearAcarsMessages()" title="Clear messages" style="padding: 4px 8px; font-size: 9px; opacity: 0.7;">
CLEAR
</button>
</div>
</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>
<!-- VDL2 Panel (left of map, after ACARS) - Collapsible -->
<div class="vdl2-sidebar" id="vdl2Sidebar">
<div class="vdl2-sidebar-content" id="vdl2SidebarContent">
<div class="panel vdl2-panel">
<div class="panel-header">
<span>VDL2 MESSAGES</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span id="vdl2Count" style="font-size: 10px; color: var(--accent-cyan);">0</span>
<button onclick="exportVdl2Csv()" title="Export VDL2 to CSV" style="background: none; border: 1px solid var(--border-color); color: var(--text-muted); cursor: pointer; font-size: 9px; padding: 2px 6px; border-radius: 3px; font-family: var(--font-mono);">CSV</button>
<div class="panel-indicator" id="vdl2PanelIndicator"></div>
</div>
</div>
<div id="vdl2PanelContent">
<div class="vdl2-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);">&#9888;</span> Requires separate SDR (VHF ~137 MHz)
</div>
<div class="vdl2-controls" style="padding: 8px; border-bottom: 1px solid var(--border-color);">
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
<select id="vdl2DeviceSelect" style="flex: 1; font-size: 10px;">
<option value="0">SDR 0</option>
<option value="1">SDR 1</option>
</select>
<select id="vdl2RegionDashSelect" onchange="updateVdl2FreqCheckboxes()" 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>
<div id="vdl2FreqSelector" style="display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; font-size: 9px;">
<!-- Frequency checkboxes populated by JS -->
</div>
<div style="display: flex; gap: 5px;">
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="flex: 1;">
&#9654; START VDL2
</button>
<button class="vdl2-btn" id="vdl2ClearBtn" onclick="clearVdl2Messages()" title="Clear messages" style="padding: 4px 8px; font-size: 9px; opacity: 0.7;">
CLEAR
</button>
</div>
</div>
<div class="vdl2-messages" id="vdl2Messages">
<div class="no-aircraft" style="padding: 20px; text-align: center;">
<div style="font-size: 10px; color: var(--text-muted);">No VDL2 messages</div>
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start VDL2 to receive digital datalink messages</div>
</div>
</div>
</div>
</div>
</div>
<button class="vdl2-collapse-btn" id="vdl2CollapseBtn" onclick="toggleVdl2Sidebar()" title="Toggle VDL2 Panel">
<span id="vdl2CollapseIcon">&#9664;</span>
<span class="vdl2-collapse-label">VDL2</span>
</button>
</div>
</div><!-- /left-sidebars -->
<!-- Main Display (Map or Radar Scope) -->
<div class="main-display">
<div class="display-container">
<div id="radarMap">
</div>
<div id="mapCrosshairOverlay" class="map-crosshair-overlay" aria-hidden="true">
<div class="map-crosshair-line map-crosshair-vertical"></div>
<div class="map-crosshair-line map-crosshair-horizontal"></div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- 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" checked onchange="toggleTrails()"> Trails</label>
<label title="Show range rings"><input type="checkbox" id="showRangeRings" checked onchange="drawRangeRings()"> Rings</label>
<label title="Audio alerts for military/emergency"><input type="checkbox" id="alertToggle" checked onchange="toggleAlerts()"> Alerts</label>
<label title="Sound when new aircraft detected"><input type="checkbox" id="detectionSoundToggle" onchange="toggleDetectionSound()"> Ping</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" selected>50nm</option>
<option value="100">100nm</option>
<option value="200">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="{{ default_latitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
<input type="text" id="obsLon" value="{{ default_longitude }}" 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" min="1" max="65535" style="width: 70px;">
</span>
<select id="adsbDeviceSelect" title="SDR device for ADS-B (1090 MHz)">
<option value="0">SDR 0</option>
</select>
<label class="bias-t-label" title="Enable Bias-T power for external LNA/preamp"><input type="checkbox" id="adsbBiasT" onchange="saveAdsbBiasTSetting()"> Bias-T</label>
<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.025" placeholder="MHz" class="airband-controls" style="width: 65px; display: none;">
<select id="airbandSpacing" class="airband-controls" title="Channel spacing" style="width: 55px; display: none;" onchange="updateAirbandSpacing()">
<option value="25">25 kHz</option>
<option value="8.33">8.33 kHz</option>
</select>
<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="0" class="airband-controls" title="Squelch" onchange="updateAirbandSquelch()">
<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>
<span id="airbandStatus" class="airband-status">OFF</span>
<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;"></audio>
</div>
</main>
<script>
// ============================================
// BIAS-T HELPER
// ============================================
function getBiasTEnabled() {
return document.getElementById('adsbBiasT')?.checked || false;
}
function saveAdsbBiasTSetting() {
const enabled = document.getElementById('adsbBiasT')?.checked || false;
localStorage.setItem('adsbBiasTEnabled', enabled);
}
function loadAdsbBiasTSetting() {
const saved = localStorage.getItem('adsbBiasTEnabled');
if (saved === 'true') {
const checkbox = document.getElementById('adsbBiasT');
if (checkbox) checkbox.checked = true;
}
}
// ============================================
// STATE
// ============================================
let radarMap = null;
let aircraft = {};
let markers = {};
let selectedIcao = null;
let eventSource = null;
let agentPollTimer = null; // Polling fallback for agent mode
let isTracking = false;
let currentFilter = 'all';
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
let alertedAircraft = {};
let alertsEnabled = true;
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
let soundedAircraft = {}; // Track aircraft we've played detection sound for
const MAP_CROSSHAIR_DURATION_MS = 1500;
const PANEL_SELECTION_BASE_ZOOM = 10;
const PANEL_SELECTION_MAX_ZOOM = 12;
const PANEL_SELECTION_ZOOM_INCREMENT = 1.4;
const PANEL_SELECTION_STAGE1_DURATION_SEC = 1.05;
const PANEL_SELECTION_STAGE2_DURATION_SEC = 1.15;
const PANEL_SELECTION_STAGE_GAP_MS = 180;
let mapCrosshairResetTimer = null;
let panelSelectionFallbackTimer = null;
let panelSelectionStageTimer = null;
let mapCrosshairRequestId = 0;
// 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 = true;
const MAX_TRAIL_POINTS = 100;
let maxRange = 50; // 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() {
if (window.ObserverLocation && ObserverLocation.getForModule) {
return ObserverLocation.getForModule('observerLocation');
}
const saved = localStorage.getItem('observerLocation');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
} catch (e) {}
}
const defaultLat = window.INTERCEPT_DEFAULT_LAT || 51.5074;
const defaultLon = window.INTERCEPT_DEFAULT_LON || -0.1278;
return { lat: defaultLat, lon: defaultLon };
})();
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 speakAircraftAlert(kind, icao, ac, detail) {
if (typeof VoiceAlerts === 'undefined' || typeof VoiceAlerts.speak !== 'function') return;
const cfg = (typeof VoiceAlerts.getConfig === 'function')
? VoiceAlerts.getConfig()
: { streams: {} };
const streams = cfg && cfg.streams ? cfg.streams : {};
const callsign = (ac && ac.callsign ? String(ac.callsign).trim() : '') || icao;
if (kind === 'emergency') {
if (streams.squawks === false) return;
const squawk = detail && detail.squawk ? ` squawk ${detail.squawk}.` : '.';
const meaning = detail && detail.name ? ` ${detail.name}.` : '';
VoiceAlerts.speak(`Aircraft emergency: ${callsign}.${squawk}${meaning}`, VoiceAlerts.PRIORITY.HIGH);
return;
}
if (kind === 'military') {
if (streams.adsb_military === false) return;
const country = detail && detail.country ? ` ${detail.country}.` : '';
VoiceAlerts.speak(`Military aircraft detected: ${callsign}.${country}`, VoiceAlerts.PRIORITY.HIGH);
}
}
function checkAndAlertAircraft(icao, ac) {
if (!alertedAircraft[icao]) {
alertedAircraft[icao] = { emergency: false, watchlist: false, military: false };
}
const alertState = alertedAircraft[icao];
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
const squawkInfo = checkSquawkCode(ac);
const onWatchlist = isOnWatchlist(ac);
if (squawkInfo && squawkInfo.type === 'emergency') {
if (!alertState.emergency) {
alertState.emergency = true;
playAlertSound('emergency');
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
speakAircraftAlert('emergency', icao, ac, {
squawk: ac.squawk,
name: squawkInfo.name,
});
}
return;
}
if (onWatchlist && !alertState.watchlist) {
alertState.watchlist = true;
playAlertSound('military'); // Use military sound for watchlist
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
} else if (militaryInfo.military && !alertState.military) {
alertState.military = true;
playAlertSound('military');
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
speakAircraftAlert('military', icao, ac, {
country: militaryInfo.country || null,
});
}
}
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;
}
function toggleDetectionSound() {
detectionSoundEnabled = document.getElementById('detectionSoundToggle').checked;
localStorage.setItem('adsb_detectionSound', detectionSoundEnabled);
}
function playDetectionSound() {
if (!detectionSoundEnabled) return;
try {
const ctx = getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
// Soft, quick ping - 1000Hz for 100ms
oscillator.frequency.setValueAtTime(1000, ctx.currentTime);
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.1, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + 0.1);
} catch (e) {
console.warn('Detection sound failed:', e);
}
}
// ============================================
// 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 !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
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 !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
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');
}
}
// ============================================
// 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] = [];
// Group consecutive same-altitude-color points into single polylines.
// This reduces layer count from O(trail length) to O(color band changes),
// which is typically 1-2 polylines per aircraft instead of up to 99.
const now = Date.now();
let runColor = getAltitudeColor(trail[0].alt);
let runPoints = [[trail[0].lat, trail[0].lon]];
let runEndTime = trail[0].time;
for (let i = 1; i < trail.length; i++) {
const p = trail[i];
const color = getAltitudeColor(p.alt);
if (color !== runColor) {
// Flush the current color run as one polyline
if (runPoints.length >= 2) {
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
trailLines[icao].push(
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
);
}
// Start a new run, sharing the junction point for visual continuity
runColor = color;
runPoints = [[trail[i-1].lat, trail[i-1].lon], [p.lat, p.lon]];
} else {
runPoints.push([p.lat, p.lon]);
}
runEndTime = p.time;
}
// Flush the final run
if (runPoints.length >= 2) {
const opacity = Math.max(0.2, 1 - ((now - runEndTime) / 1000 / 120));
trailLines[icao].push(
L.polyline(runPoints, { color: runColor, weight: 2, opacity }).addTo(radarMap)
);
}
}
function cleanupTrail(icao) {
if (trailLines[icao]) {
trailLines[icao].forEach(line => radarMap.removeLayer(line));
delete trailLines[icao];
}
delete aircraftTrails[icao];
}
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: '#4a9eff',
fillColor: 'transparent',
fillOpacity: 0,
weight: 1,
opacity: 0.3,
dashArray: '4 4'
});
const labelLat = observerLocation.lat + (nm * 0.0166);
const label = L.marker([labelLat, observerLocation.lon], {
icon: L.divIcon({
className: 'range-label',
html: `<span style="color: #4a9eff; 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
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
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
if (window.ObserverLocation) {
ObserverLocation.setForModule('observerLocation', observerLocation);
} else {
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 !== undefined && data.latitude !== null && data.longitude !== undefined && data.longitude !== null) {
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);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
}
// 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 !== undefined && aircraft[icao].lat !== null && aircraft[icao].lon !== undefined && aircraft[icao].lon !== null) {
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 && squawkInfo.type === 'emergency';
if (currentFilter === 'watchlist') return isOnWatchlist(ac);
return true;
}
// ============================================
// INITIALIZATION
// ============================================
// Clean up SSE connections on page unload to prevent orphaned streams
window.addEventListener('pagehide', function() {
if (eventSource) { eventSource.close(); eventSource = null; }
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
});
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);
// Initialize detection sound toggle from localStorage
const detectionToggle = document.getElementById('detectionSoundToggle');
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
// Load Bias-T setting from localStorage
loadAdsbBiasTSetting();
initMap();
initDeviceSelectors();
updateClock();
setInterval(updateClock, 1000);
setInterval(cleanupOldAircraft, 10000);
checkAdsbTools();
checkAircraftDatabase();
checkDvbDriverConflict();
// Auto-connect to gpsd if available
autoConnectGps();
// Sync tracking state if ADS-B already running
syncTrackingStatus();
});
// 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="rtlsdr:0">No SDR found</option>';
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
airbandSelect.disabled = true;
} else {
devices.forEach((dev, i) => {
const idx = dev.index !== undefined ? dev.index : i;
const sdrType = dev.sdr_type || 'rtlsdr';
const compositeVal = `${sdrType}:${idx}`;
const displayName = `SDR ${idx}: ${dev.name}`;
// Add to ADS-B selector
const adsbOpt = document.createElement('option');
adsbOpt.value = compositeVal;
adsbOpt.dataset.sdrType = sdrType;
adsbOpt.dataset.index = idx;
adsbOpt.textContent = displayName;
adsbSelect.appendChild(adsbOpt);
// Add to Airband selector
const airbandOpt = document.createElement('option');
airbandOpt.value = compositeVal;
airbandOpt.dataset.sdrType = sdrType;
airbandOpt.dataset.index = idx;
airbandOpt.textContent = displayName;
airbandSelect.appendChild(airbandOpt);
});
// Default: ADS-B uses first device, Airband uses second (if available)
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
if (devices.length > 1) {
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
}
// 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="rtlsdr:0">Error</option>';
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
});
}
function checkDvbDriverConflict() {
fetch('/settings/rtlsdr/driver-status')
.then(r => r.json())
.then(data => {
if (data.issue_detected) {
showDvbDriverWarning(data.loaded_modules);
}
})
.catch(() => {});
}
function showDvbDriverWarning(loadedModules) {
// Don't show if already dismissed this session
if (sessionStorage.getItem('dvb_warning_dismissed')) return;
const warning = document.createElement('div');
warning.id = 'dvbDriverWarning';
warning.style.cssText = `
position: fixed;
top: 70px;
left: 50%;
transform: translateX(-50%);
background: rgba(239, 68, 68, 0.95);
color: white;
padding: 15px 20px;
border-radius: 8px;
font-size: 13px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
max-width: 500px;
text-align: center;
`;
warning.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ DVB Driver Conflict Detected</div>
<div style="margin-bottom: 10px;">
Kernel DVB drivers are claiming your RTL-SDR devices, preventing them from working properly.
<br><small>Loaded modules: ${loadedModules.join(', ')}</small>
</div>
<div style="display: flex; gap: 10px; justify-content: center;">
<button onclick="fixDvbDrivers()" style="background: white; color: #dc2626; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold;">
Fix Now
</button>
<button onclick="dismissDvbWarning()" style="background: rgba(255,255,255,0.2); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
Dismiss
</button>
</div>
`;
document.body.appendChild(warning);
}
function fixDvbDrivers() {
const warning = document.getElementById('dvbDriverWarning');
if (warning) {
warning.innerHTML = '<div>Applying fix...</div>';
}
fetch('/settings/rtlsdr/blacklist-drivers', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.status === 'success' || data.status === 'partial') {
if (warning) {
warning.style.background = 'rgba(34, 197, 94, 0.95)';
warning.innerHTML = `
<div style="font-weight: bold;">✓ DVB Drivers Disabled</div>
<div style="margin-top: 8px;">${data.message}</div>
<button onclick="this.parentElement.remove()" style="margin-top: 10px; background: white; color: #16a34a; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
OK
</button>
`;
}
} else {
if (warning) {
warning.innerHTML = `
<div style="font-weight: bold;">Manual Fix Required</div>
<div style="margin-top: 8px;">${data.message}</div>
<button onclick="this.parentElement.remove()" style="margin-top: 10px; background: rgba(255,255,255,0.2); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
Close
</button>
`;
}
}
})
.catch(err => {
if (warning) {
warning.innerHTML = `
<div>Error: ${err.message}</div>
<button onclick="this.parentElement.remove()" style="margin-top: 10px; background: rgba(255,255,255,0.2); color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
Close
</button>
`;
}
});
}
function dismissDvbWarning() {
sessionStorage.setItem('dvb_warning_dismissed', 'true');
const warning = document.getElementById('dvbDriverWarning');
if (warning) warning.remove();
}
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: var(--font-sans);
`;
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: var(--font-sans);
`;
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: var(--font-mono); 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 createFallbackGridLayer() {
const layer = L.gridLayer({
tileSize: 256,
updateWhenIdle: true,
attribution: 'Local fallback grid'
});
layer.createTile = function(coords) {
const tile = document.createElement('canvas');
tile.width = 256;
tile.height = 256;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#08121c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.strokeStyle = 'rgba(0, 212, 255, 0.08)';
ctx.beginPath();
ctx.moveTo(128, 0);
ctx.lineTo(128, 256);
ctx.moveTo(0, 128);
ctx.lineTo(256, 128);
ctx.stroke();
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
return tile;
};
return layer;
}
async function initMap() {
// Guard against double initialization (e.g. bfcache restore)
const container = document.getElementById('radarMap');
if (!container || container._leaflet_id) return;
radarMap = L.map('radarMap', {
center: [observerLocation.lat, observerLocation.lon],
zoom: 7,
minZoom: 3,
maxZoom: 15
});
// Use settings manager for tile layer (allows runtime changes)
window.radarMap = radarMap;
// Use a zero-network fallback so dashboard navigation stays fast even
// when internet map providers are slow or unreachable.
const fallbackTiles = createFallbackGridLayer().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);
// Additional invalidateSize to ensure all tiles load
setTimeout(() => {
if (radarMap) radarMap.invalidateSize();
}, 500);
// Upgrade tiles via Settings in the background (non-blocking)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
radarMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(radarMap);
Settings.registerMap(radarMap);
} catch (e) {
console.warn('Settings init failed/timed out, using fallback tiles:', e);
}
}
}
// 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');
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (!isTracking) {
// Check for remote dump1090 config (only for local mode)
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
if (remoteConfig === false) return;
// Check for agent SDR conflicts
if (useAgent && typeof checkAgentModeConflict === 'function') {
if (!await checkAgentModeConflict('adsb')) {
return; // User cancelled or conflict not resolved
}
}
// Get selected ADS-B device (composite value "sdr_type:index")
const adsbSelectVal = document.getElementById('adsbDeviceSelect').value || 'rtlsdr:0';
const [adsbSdrType, adsbDeviceIdx] = adsbSelectVal.includes(':') ? adsbSelectVal.split(':') : ['rtlsdr', adsbSelectVal];
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
// Pre-flight: check if another mode is using this device and auto-stop it
if (!useAgent) {
try {
const devResp = await fetch('/devices/status');
if (devResp.ok) {
const devices = await devResp.json();
const target = devices.find(d => d.sdr_type === adsbSdrType && d.index === adsbDevice);
if (target && target.in_use && target.used_by && target.used_by !== 'adsb') {
const stopEndpoints = {
pager: '/stop',
sensor: '/stop_sensor',
morse: '/morse/stop',
adsb: '/adsb/stop',
acars: '/acars/stop',
vdl2: '/vdl2/stop',
ais: '/ais/stop',
dsc: '/dsc/stop',
weathersat: '/weather-sat/stop',
sstv: '/sstv/stop',
radiosonde: '/radiosonde/stop',
rtlamr: '/rtlamr/stop_rtlamr',
aprs: '/aprs/stop',
tscm: '/tscm/sweep/stop',
wefax: '/wefax/stop',
sstv_general: '/sstv-general/stop',
};
const mode = target.used_by;
const stopUrl = stopEndpoints[mode];
if (stopUrl) {
console.log(`Device ${adsbSdrType}:${adsbDevice} in use by ${mode}, stopping...`);
btn.textContent = `Stopping ${mode}...`;
btn.disabled = true;
try {
await fetch(stopUrl, { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'adsb_conflict' })
});
} catch (e) {
console.warn(`Failed to stop ${mode}:`, e);
}
// Wait for USB device to be released
await new Promise(resolve => setTimeout(resolve, 1500));
btn.textContent = 'Starting...';
btn.disabled = false;
}
}
}
} catch (e) {
console.warn('Device conflict check failed:', e);
}
}
const requestBody = {
device: adsbDevice,
sdr_type: adsbSdrType,
bias_t: getBiasTEnabled()
};
if (remoteConfig) {
requestBody.remote_sbs_host = remoteConfig.host;
requestBody.remote_sbs_port = remoteConfig.port;
}
try {
// Route through agent proxy if using remote agent
const url = useAgent
? `/controller/agents/${adsbCurrentAgent}/adsb/start`
: '/adsb/start';
const response = await fetch(url, {
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
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
btn.textContent = 'STOP';
btn.classList.add('active');
document.getElementById('trackingDot').classList.remove('inactive');
updateTrackingStatusDisplay();
// Disable ADS-B device selector while tracking
document.getElementById('adsbDeviceSelect').disabled = true;
// Disable agent selector while tracking
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) agentSelect.disabled = true;
// Update agent running modes tracking
if (useAgent && typeof agentRunningModes !== 'undefined') {
if (!agentRunningModes.includes('adsb')) {
agentRunningModes.push('adsb');
}
}
} else {
alert('Failed to start: ' + (data.message || JSON.stringify(data)));
}
} catch (err) {
alert('Error: ' + err.message);
}
} else {
try {
// Route stop through the source that actually started tracking.
const stopSource = adsbTrackingSource || (useAgent ? adsbCurrentAgent : 'local');
const stopViaAgent = stopSource !== null && stopSource !== undefined && stopSource !== 'local';
const url = stopViaAgent
? `/controller/agents/${stopSource}/adsb/stop`
: '/adsb/stop';
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = stopViaAgent && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
// Update agent running modes tracking
if (stopViaAgent && typeof agentRunningModes !== 'undefined') {
agentRunningModes = agentRunningModes.filter(m => m !== 'adsb');
}
} catch (err) {
alert('Failed to stop ADS-B: ' + err.message);
return;
}
stopEventStream();
isTracking = false;
adsbActiveDevice = null;
adsbTrackingSource = null; // Reset tracking source
btn.textContent = 'START';
btn.classList.remove('active');
document.getElementById('trackingDot').classList.add('inactive');
updateTrackingStatusDisplay();
// Re-enable ADS-B device selector
document.getElementById('adsbDeviceSelect').disabled = false;
// Re-enable agent selector
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) agentSelect.disabled = false;
}
}
async function syncTrackingStatus() {
// This function checks LOCAL tracking status on page load
// For local mode: auto-start if session is already running OR SDR is available
// For agent mode: don't auto-start (user controls agent tracking)
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
if (useAgent) {
console.log('[ADS-B] Agent mode on page load - not auto-starting local');
return;
}
try {
const response = await fetch('/adsb/session');
if (!response.ok) {
// No session info - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No session found, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No session found; auto-start disabled');
}
return;
}
const data = await response.json();
if (data.tracking_active) {
// Session is running - auto-connect to stream
console.log('[ADS-B] Local session already active - auto-connecting to stream');
// Get session info
const session = data.session || {};
const startTime = session.started_at ? Date.parse(session.started_at) : null;
if (startTime) {
stats.sessionStart = startTime;
}
const sessionDevice = session.device_index;
const sessionSdrType = session.sdr_type || 'rtlsdr';
if (sessionDevice !== null && sessionDevice !== undefined) {
adsbActiveDevice = sessionDevice;
const adsbSelect = document.getElementById('adsbDeviceSelect');
if (adsbSelect) {
// Use composite value to select the correct device+type
adsbSelect.value = `${sessionSdrType}:${sessionDevice}`;
}
}
// Auto-connect to the running session
isTracking = true;
adsbTrackingSource = 'local';
startEventStream();
drawRangeRings();
startSessionTimer();
const btn = document.getElementById('startBtn');
if (btn) {
btn.textContent = 'STOP';
btn.classList.add('active');
}
document.getElementById('trackingDot').classList.remove('inactive');
document.getElementById('trackingDot').classList.add('active');
const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'TRACKING';
} else {
// Session not active - only auto-start if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
console.log('[ADS-B] No active session, attempting auto-start...');
await tryAutoStartLocal();
} else {
console.log('[ADS-B] No active session; auto-start disabled');
}
}
} catch (err) {
console.warn('[ADS-B] Failed to sync tracking status:', err);
// Try auto-start only if enabled
if (window.INTERCEPT_ADSB_AUTO_START) {
await tryAutoStartLocal();
}
}
}
async function tryAutoStartLocal() {
// Try to auto-start local ADS-B tracking if SDR is available
try {
// Check if any SDR devices are available
const devResponse = await fetch('/devices');
if (!devResponse.ok) return;
const devices = await devResponse.json();
if (!devices || devices.length === 0) {
console.log('[ADS-B] No SDR devices found - cannot auto-start');
return;
}
// Try to start tracking on first available device
const device = devices[0].index !== undefined ? devices[0].index : 0;
console.log(`[ADS-B] Auto-starting local tracking on device ${device}...`);
const startResponse = await fetch('/adsb/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device: device, bias_t: getBiasTEnabled() })
});
const result = await startResponse.json();
if (result.status === 'success' || result.status === 'started' || result.status === 'already_running') {
console.log('[ADS-B] Auto-start successful');
isTracking = true;
adsbActiveDevice = device;
adsbTrackingSource = 'local';
startEventStream();
drawRangeRings();
startSessionTimer();
const btn = document.getElementById('startBtn');
if (btn) {
btn.textContent = 'STOP';
btn.classList.add('active');
}
document.getElementById('trackingDot').classList.remove('inactive');
document.getElementById('trackingDot').classList.add('active');
const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'TRACKING';
} else {
// SDR might be in use - don't show error, just don't auto-start
console.log('[ADS-B] Auto-start failed (SDR may be in use):', result.error || result.message);
}
} catch (err) {
console.log('[ADS-B] Auto-start error (SDR may be in use):', err.message);
}
}
function startEventStream() {
if (eventSource) eventSource.close();
const activeSource = (isTracking && adsbTrackingSource) ? adsbTrackingSource : adsbCurrentAgent;
const useAgent = typeof activeSource !== 'undefined' && activeSource !== null && activeSource !== 'local';
const streamUrl = useAgent ? '/controller/stream/all' : '/adsb/stream';
console.log(`[ADS-B] startEventStream called - activeSource=${activeSource}, useAgent=${useAgent}, streamUrl=${streamUrl}`);
eventSource = new EventSource(streamUrl);
// Get agent name for filtering multi-agent stream
let targetAgentName = null;
if (useAgent && typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == activeSource);
targetAgentName = agent ? agent.name : null;
}
eventSource.onopen = () => {
console.log('ADS-B stream connected');
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (useAgent) {
// Agent mode - handle multi-agent stream format
// Skip keepalive messages
if (data.type === 'keepalive') return;
// Filter to only our selected agent
if (targetAgentName && data.agent_name && data.agent_name !== targetAgentName) {
return;
}
// Extract aircraft data from push payload
if (data.scan_type === 'adsb' && data.payload) {
const payload = data.payload;
if (payload.aircraft) {
// Handle array or object of aircraft
const aircraftList = Array.isArray(payload.aircraft)
? payload.aircraft
: Object.values(payload.aircraft);
aircraftList.forEach(ac => {
ac._agent = data.agent_name;
updateAircraft({ type: 'aircraft', ...ac });
});
}
}
} else {
// Local mode - original stream format
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);
}
};
// Start polling as fallback when in agent mode (in case push isn't enabled)
if (useAgent) {
startAgentPolling();
}
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;
}
if (agentPollTimer) {
clearInterval(agentPollTimer);
agentPollTimer = null;
}
}
/**
* Perform a single poll of agent ADS-B data.
*/
async function doAgentPoll() {
try {
const pollUrl = `/controller/agents/${adsbCurrentAgent}/adsb/data`;
console.log(`[ADS-B Poll] Fetching: ${pollUrl}`);
const response = await fetch(pollUrl);
if (!response.ok) {
console.warn(`[ADS-B Poll] Response not OK: ${response.status}`);
return;
}
const result = await response.json();
console.log('[ADS-B Poll] Raw response keys:', Object.keys(result));
// Handle double-nested response: result.data.data contains aircraft array
// Structure: { agent_id, agent_name, data: { data: [aircraft], agent_gps, ... } }
let aircraftData = null;
if (result.data && result.data.data) {
// Double nested (controller proxy format)
aircraftData = result.data.data;
console.log('[ADS-B Poll] Found double-nested data, count:', aircraftData.length);
} else if (result.data && Array.isArray(result.data)) {
// Single nested array
aircraftData = result.data;
console.log('[ADS-B Poll] Found single-nested array');
} else if (Array.isArray(result)) {
// Direct array
aircraftData = result;
console.log('[ADS-B Poll] Found direct array');
} else {
console.warn('[ADS-B Poll] Unknown data format:', Object.keys(result));
}
// Get agent name
let agentName = result.agent_name || 'Agent';
if (!result.agent_name && typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == adsbCurrentAgent);
if (agent) agentName = agent.name;
}
// Process aircraft from polling response
if (aircraftData && Array.isArray(aircraftData)) {
console.log(`[ADS-B Poll] Processing ${aircraftData.length} aircraft from ${agentName}`);
aircraftData.forEach(ac => {
if (ac.icao) { // Only process valid aircraft
ac._agent = agentName;
updateAircraft({ type: 'aircraft', ...ac });
} else {
console.warn('[ADS-B Poll] Aircraft missing icao:', ac);
}
});
} else if (aircraftData) {
console.warn('[ADS-B Poll] aircraftData is not an array:', typeof aircraftData);
} else {
console.log('[ADS-B Poll] No aircraft data in response');
}
} catch (err) {
console.error('[ADS-B Poll] Error:', err);
}
}
/**
* Start polling agent data as fallback when push isn't enabled.
*/
function startAgentPolling() {
if (agentPollTimer) return;
const pollInterval = 2000; // 2 seconds for ADS-B
console.log(`[ADS-B Poll] Starting agent polling for agent ${adsbCurrentAgent}...`);
// Do an immediate poll first
doAgentPoll();
// Then set up the interval for continuous polling
agentPollTimer = setInterval(() => {
if (!isTracking) {
console.log('[ADS-B Poll] Stopping - isTracking is false');
clearInterval(agentPollTimer);
agentPollTimer = null;
return;
}
doAgentPoll();
}, pollInterval);
}
// ============================================
// 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;
// Check if this is a new aircraft we haven't seen this session
const isNewAircraft = !soundedAircraft[icao];
if (isNewAircraft) {
soundedAircraft[icao] = true;
playDetectionSound();
}
aircraft[icao] = {
...aircraft[icao],
...data,
lastSeen: Date.now()
};
checkAndAlertAircraft(icao, aircraft[icao]);
updateStatistics(icao, aircraft[icao]);
// Record trail point
if (data.lat !== undefined && data.lat !== null && data.lon !== undefined && data.lon !== null) {
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 === undefined || ac.lat === null || ac.lon === undefined || ac.lon === null) 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 isSelected = icao === selectedIcao;
const prevState = markerState[icao] || {};
const iconChanged = prevState.rotation !== rotation || prevState.color !== color || prevState.iconType !== iconType || prevState.isSelected !== isSelected;
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, isSelected));
}
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, isSelected) })
.addTo(radarMap)
.on('click', () => selectAircraft(icao, 'map'));
markers[icao].bindTooltip(`${callsign}<br>${alt}`, {
permanent: false, direction: 'top', className: 'aircraft-tooltip'
});
}
markerState[icao] = { rotation, color, callsign, alt, iconType, isSelected };
}
// 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', isSelected = false) {
const path = AIRCRAFT_ICONS[iconType] || AIRCRAFT_ICONS.jet;
const size = iconType === 'helicopter' ? 22 : 24;
const glowColor = isSelected ? 'rgba(255,255,255,0.9)' : color;
const glowSize = isSelected ? '10px' : '5px';
const trackingRing = isSelected ?
'<div class="tracking-ring"></div><div class="tracking-ring-inner"></div>' : '';
return L.divIcon({
className: `aircraft-marker aircraft-${iconType}${isSelected ? ' selected' : ''}`,
html: `${trackingRing}<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); color: ${color}; filter: drop-shadow(0 0 ${glowSize} ${glowColor});">
<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, 'panel');
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>` : '';
// Agent badge if aircraft came from remote agent
const agentBadge = ac._agent ?
`<span class="agent-badge">${ac._agent}</span>` : '';
// ACARS indicator if this aircraft has datalink messages
const acarsIndicator = (typeof acarsAircraftIcaos !== 'undefined' && acarsAircraftIcaos.has(ac.icao)) ?
`<span style="background:var(--accent-cyan);color:#000;padding:1px 4px;border-radius:2px;font-size:7px;font-weight:700;margin-left:4px;" title="Has ACARS messages">DLK</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">${escapeHtml(callsign)}${badge}${acarsIndicator}${agentBadge}</span>
<span class="aircraft-icao">${typeCode ? escapeHtml(typeCode) + ' • ' : ''}${escapeHtml(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 triggerMapCrosshairAnimation(lat, lon, durationMs = MAP_CROSSHAIR_DURATION_MS, lockToMapCenter = false) {
if (!radarMap) return;
const overlay = document.getElementById('mapCrosshairOverlay');
if (!overlay) return;
const size = radarMap.getSize();
let targetX;
let targetY;
if (lockToMapCenter) {
targetX = size.x / 2;
targetY = size.y / 2;
} else {
const point = radarMap.latLngToContainerPoint([lat, lon]);
targetX = Math.max(0, Math.min(size.x, point.x));
targetY = Math.max(0, Math.min(size.y, point.y));
}
const startX = size.x + 8;
const startY = size.y + 8;
overlay.style.setProperty('--crosshair-x-start', `${startX}px`);
overlay.style.setProperty('--crosshair-y-start', `${startY}px`);
overlay.style.setProperty('--crosshair-x-end', `${targetX}px`);
overlay.style.setProperty('--crosshair-y-end', `${targetY}px`);
overlay.style.setProperty('--crosshair-duration', `${durationMs}ms`);
overlay.classList.remove('active');
void overlay.offsetWidth;
overlay.classList.add('active');
if (mapCrosshairResetTimer) {
clearTimeout(mapCrosshairResetTimer);
}
mapCrosshairResetTimer = setTimeout(() => {
overlay.classList.remove('active');
mapCrosshairResetTimer = null;
}, durationMs + 100);
}
function getPanelSelectionFinalZoom() {
if (!radarMap) return PANEL_SELECTION_BASE_ZOOM;
const currentZoom = radarMap.getZoom();
const maxZoom = typeof radarMap.getMaxZoom === 'function' ? radarMap.getMaxZoom() : PANEL_SELECTION_MAX_ZOOM;
return Math.min(
PANEL_SELECTION_MAX_ZOOM,
maxZoom,
Math.max(PANEL_SELECTION_BASE_ZOOM, currentZoom + PANEL_SELECTION_ZOOM_INCREMENT)
);
}
function getPanelSelectionIntermediateZoom(finalZoom) {
if (!radarMap) return finalZoom;
const currentZoom = radarMap.getZoom();
if (finalZoom - currentZoom < 0.8) {
return finalZoom;
}
const midpointZoom = currentZoom + ((finalZoom - currentZoom) * 0.55);
return Math.min(finalZoom - 0.45, midpointZoom);
}
function runPanelSelectionAnimation(lat, lon, requestId) {
if (!radarMap) return;
const finalZoom = getPanelSelectionFinalZoom();
const intermediateZoom = getPanelSelectionIntermediateZoom(finalZoom);
const sequenceDurationMs = Math.round(
((PANEL_SELECTION_STAGE1_DURATION_SEC + PANEL_SELECTION_STAGE2_DURATION_SEC) * 1000) +
PANEL_SELECTION_STAGE_GAP_MS + 260
);
const startSecondStage = () => {
if (requestId !== mapCrosshairRequestId) return;
radarMap.flyTo([lat, lon], finalZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
easeLinearity: 0.2
});
};
triggerMapCrosshairAnimation(
lat,
lon,
Math.max(MAP_CROSSHAIR_DURATION_MS, sequenceDurationMs),
true
);
if (intermediateZoom >= finalZoom - 0.1) {
radarMap.flyTo([lat, lon], finalZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE2_DURATION_SEC,
easeLinearity: 0.2
});
return;
}
let stage1Handled = false;
const finishStage1 = () => {
if (stage1Handled || requestId !== mapCrosshairRequestId) return;
stage1Handled = true;
if (panelSelectionFallbackTimer) {
clearTimeout(panelSelectionFallbackTimer);
panelSelectionFallbackTimer = null;
}
panelSelectionStageTimer = setTimeout(() => {
panelSelectionStageTimer = null;
startSecondStage();
}, PANEL_SELECTION_STAGE_GAP_MS);
};
radarMap.once('moveend', finishStage1);
panelSelectionFallbackTimer = setTimeout(
finishStage1,
Math.round(PANEL_SELECTION_STAGE1_DURATION_SEC * 1000) + 160
);
radarMap.flyTo([lat, lon], intermediateZoom, {
animate: true,
duration: PANEL_SELECTION_STAGE1_DURATION_SEC,
easeLinearity: 0.2
});
}
function selectAircraft(icao, source = 'map') {
const prevSelected = selectedIcao;
selectedIcao = icao;
mapCrosshairRequestId += 1;
if (panelSelectionFallbackTimer) {
clearTimeout(panelSelectionFallbackTimer);
panelSelectionFallbackTimer = null;
}
if (panelSelectionStageTimer) {
clearTimeout(panelSelectionStageTimer);
panelSelectionStageTimer = null;
}
// Update marker icons for both previous and new selection
[prevSelected, icao].forEach(targetIcao => {
if (targetIcao && markers[targetIcao] && aircraft[targetIcao]) {
const ac = aircraft[targetIcao];
const militaryInfo = isMilitaryAircraft(targetIcao, ac.callsign);
const rotation = Math.round((ac.heading || 0) / 5) * 5;
const color = militaryInfo.military ? '#556b2f' : getAltitudeColor(ac.altitude);
const iconType = getAircraftIconType(ac.type_code, militaryInfo.military);
const isSelected = targetIcao === icao;
markers[targetIcao].setIcon(createMarkerIcon(rotation, color, iconType, isSelected));
// Update marker state to reflect selection
if (markerState[targetIcao]) {
markerState[targetIcao].isSelected = isSelected;
}
}
});
renderAircraftList();
showAircraftDetails(icao);
updateFlightLookupBtn();
highlightSidebarMessages(icao);
const ac = aircraft[icao];
if (ac && ac.lat !== undefined && ac.lat !== null && ac.lon !== undefined && ac.lon !== null) {
const targetLat = ac.lat;
const targetLon = ac.lon;
if (source === 'panel' && radarMap) {
runPanelSelectionAnimation(targetLat, targetLon, mapCrosshairRequestId);
return;
}
radarMap.setView([targetLat, targetLon], 10);
}
}
function highlightSidebarMessages(icao) {
// Highlight ACARS/VDL2 sidebar messages matching the selected aircraft
const containers = ['acarsMessages', 'vdl2Messages'];
containers.forEach(containerId => {
const container = document.getElementById(containerId);
if (!container) return;
for (const item of container.children) {
if (item.dataset.icao === icao) {
item.style.borderLeft = '3px solid var(--accent-cyan)';
item.style.background = 'rgba(0, 212, 255, 0.08)';
} else {
item.style.borderLeft = '';
item.style.background = '';
}
}
});
}
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>
<div id="aircraftAcarsSection" style="margin-top:12px;">
<div style="display:flex;justify-content:space-between;align-items:center;font-size:10px;font-weight:600;color:var(--accent-cyan);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px;border-top:1px solid var(--border-color);padding-top:8px;">
<span>Datalink Messages</span>
<button onclick="clearAircraftMessages()" title="Clear datalink messages" style="background:none;border:1px solid var(--border-color);color:var(--text-muted);cursor:pointer;font-size:9px;padding:1px 5px;border-radius:3px;font-family:var(--font-mono);line-height:1;">&#10005;</button>
</div>
<div id="aircraftAcarsMessages" style="font-size:10px;color:var(--text-dim);transition:opacity 0.15s ease;">Loading...</div>
</div>`;
// Fetch aircraft photo if registration is available
if (registration) {
fetchAircraftPhoto(registration);
}
// Fetch ACARS messages for this aircraft
fetchAircraftMessages(icao);
}
// ACARS message refresh timer for selected aircraft
let acarsMessageTimer = null;
function fetchAircraftMessages(icao) {
// Clear previous timer
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
function doFetch() {
fetch('/adsb/aircraft/' + icao + '/messages')
.then(r => r.json())
.then(data => {
const container = document.getElementById('aircraftAcarsMessages');
if (!container) return;
const msgs = (data.acars || []).concat(data.vdl2 || []);
let newHtml;
if (msgs.length === 0) {
newHtml = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
} else {
msgs.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
newHtml = msgs.slice(0, 20).map(renderAcarsCard).join('');
}
// Only update DOM if content actually changed
if (container.innerHTML !== newHtml) {
container.style.opacity = '0.7';
setTimeout(() => {
container.innerHTML = newHtml;
container.style.opacity = '1';
}, 150);
}
})
.catch(() => { /* keep existing content on error */ });
}
doFetch();
acarsMessageTimer = setInterval(doFetch, 10000);
}
function getAcarsTypeBadge(type) {
const colors = {
position: '#00ff88',
engine_data: '#ff9500',
weather: '#00d4ff',
ats: '#ffdd00',
cpdlc: '#b388ff',
oooi: '#4fc3f7',
squawk: '#ff6b6b',
link_test: '#666',
handshake: '#555',
other: '#888',
};
const labels = {
position: 'POS',
engine_data: 'ENG',
weather: 'WX',
ats: 'ATS',
cpdlc: 'CPDLC',
oooi: 'OOOI',
squawk: 'SQK',
link_test: 'LINK',
handshake: 'HSHK',
other: 'MSG',
};
const color = colors[type] || '#888';
const lbl = labels[type] || 'MSG';
return '<span style="display:inline-block;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:700;color:#000;background:' + color + ';">' + lbl + '</span>';
}
// TODO: Similar to renderAcarsMainCard in partials/modes/acars.html — consider unifying
function renderAcarsCard(msg) {
const type = msg.message_type || 'other';
const badge = getAcarsTypeBadge(type);
const desc = escapeHtml(msg.label_description || ('Label ' + (msg.label || '?')));
const text = msg.text || msg.msg || '';
const truncText = escapeHtml(text.length > 120 ? text.substring(0, 120) + '...' : text);
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : '';
const flight = escapeHtml(msg.flight || '');
let parsedHtml = '';
if (msg.parsed) {
const p = msg.parsed;
if (type === 'position' && p.lat !== undefined) {
parsedHtml = '<div style="color:var(--accent-green);margin-top:2px;">' +
p.lat.toFixed(4) + ', ' + p.lon.toFixed(4) +
(p.flight_level ? ' • ' + escapeHtml(String(p.flight_level)) : '') +
(p.destination ? ' → ' + escapeHtml(String(p.destination)) : '') + '</div>';
} else if (type === 'engine_data') {
const parts = [];
Object.keys(p).forEach(k => {
parts.push(escapeHtml(k) + ': ' + escapeHtml(String(p[k].value)));
});
if (parts.length) {
parsedHtml = '<div style="color:var(--accent-orange,#ff9500);margin-top:2px;">' + parts.slice(0, 4).join(' | ') + '</div>';
}
} else if (type === 'oooi' && p.origin) {
parsedHtml = '<div style="color:var(--accent-cyan);margin-top:2px;">' +
escapeHtml(String(p.origin)) + ' → ' + escapeHtml(String(p.destination)) +
(p.out ? ' | OUT ' + escapeHtml(String(p.out)) : '') +
(p.off ? ' OFF ' + escapeHtml(String(p.off)) : '') +
(p.on ? ' ON ' + escapeHtml(String(p.on)) : '') +
(p['in'] ? ' IN ' + escapeHtml(String(p['in'])) : '') + '</div>';
}
}
return '<div style="padding:5px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:2px;">' +
'<span>' + badge + ' <span style="color:var(--text-primary);">' + desc + '</span></span>' +
'<span style="color:var(--text-muted);font-size:9px;">' + time + '</span></div>' +
(flight ? '<div style="color:var(--accent-cyan);font-size:9px;">' + flight + '</div>' : '') +
parsedHtml +
(truncText && type !== 'link_test' && type !== 'handshake' ?
'<div style="color:var(--text-dim);font-family:var(--font-mono);font-size:9px;margin-top:2px;word-break:break-all;">' + truncText + '</div>' : '') +
'</div>';
}
// 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];
if (typeof acarsAircraftIcaos !== 'undefined') acarsAircraftIcaos.delete(icao);
needsUpdate = true;
if (selectedIcao === icao) {
selectedIcao = null;
showAircraftDetails(null);
updateFlightLookupBtn();
highlightSidebarMessages(null);
clearAircraftMessages();
}
}
});
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('/receiver/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');
const spacingSelect = document.getElementById('airbandSpacing');
if (select.value === 'custom') {
customInput.style.display = 'inline-block';
spacingSelect.style.display = 'inline-block';
} else {
customInput.style.display = 'none';
spacingSelect.style.display = 'none';
// If audio is playing, restart on new frequency
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
}
}
function updateAirbandSpacing() {
const spacing = document.getElementById('airbandSpacing').value;
const customInput = document.getElementById('airbandCustomFreq');
if (spacing === '8.33') {
// 8.33 kHz = 0.00833 MHz
customInput.step = '0.00833';
} else {
// 25 kHz = 0.025 MHz
customInput.step = '0.025';
}
}
// 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();
}
}
async function startAirband() {
const frequency = getAirbandFrequency();
const compositeVal = document.getElementById('airbandDeviceSelect').value || 'rtlsdr:0';
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = parseInt(deviceIdx) || 0;
const squelch = parseInt(document.getElementById('airbandSquelch').value) || 0;
console.log('[AIRBAND] Starting with device:', device, 'sdr_type:', sdr_type, 'freq:', frequency, 'squelch:', squelch);
// Check if ADS-B tracking is using this device
if (isTracking && adsbActiveDevice !== null && device === adsbActiveDevice) {
const useAnyway = await AppFeedback.confirmAction({
title: 'SDR Device Conflict',
message: `ADS-B tracking is using SDR ${adsbActiveDevice}. Using the same device for airband will stop ADS-B tracking. Select a different SDR device for airband listening, or continue to stop tracking and listen.`,
confirmLabel: 'Continue',
confirmClass: 'btn-danger'
});
if (!useAnyway) {
return;
}
}
document.getElementById('airbandStatus').textContent = 'STARTING...';
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
try {
// Start audio on backend
const response = await fetch('/receiver/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,
sdr_type: sdr_type
})
});
const data = await response.json();
console.log('[AIRBAND] Backend response:', data.status);
if (data.status !== 'started') {
document.getElementById('airbandStatus').textContent = 'ERROR';
document.getElementById('airbandStatus').style.color = 'var(--accent-red)';
alert('Airband Error: ' + (data.message || 'Failed to start'));
return;
}
isAirbandPlaying = true;
// Wait for stream to be ready (backend needs time to start rtl_fm)
await new Promise(r => setTimeout(r, 300));
// Setup audio player
const audioPlayer = document.getElementById('airbandPlayer');
// Fully reset audio element
audioPlayer.oncanplay = null;
try { audioPlayer.pause(); } catch (e) {}
audioPlayer.removeAttribute('src');
audioPlayer.load();
// Connect to stream
const streamUrl = `/receiver/audio/stream?t=${Date.now()}`;
console.log('[AIRBAND] Connecting to stream:', streamUrl);
audioPlayer.src = streamUrl;
// Apply current volume setting
const volume = parseInt(document.getElementById('airbandVolume').value) || 80;
audioPlayer.volume = volume / 100;
// Initialize visualizer
initAirbandVisualizer();
// Wait for audio to be ready then play
audioPlayer.oncanplay = () => {
console.log('[AIRBAND] Audio can play');
audioPlayer.play().catch(e => console.warn('[AIRBAND] Autoplay blocked:', e));
};
// Also try to play immediately (some browsers need this)
audioPlayer.play().catch(e => {
console.log('[AIRBAND] Initial play blocked, waiting for canplay');
});
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)';
document.getElementById('airbandVisualizerContainer').style.display = 'flex';
} catch (err) {
console.error('[AIRBAND] Error:', 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('/receiver/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)';
document.getElementById('airbandVisualizerContainer').style.display = 'none';
})
.catch(() => {});
}
function updateAirbandVolume() {
const audioPlayer = document.getElementById('airbandPlayer');
const volume = parseInt(document.getElementById('airbandVolume').value) || 80;
if (audioPlayer) {
audioPlayer.volume = volume / 100;
}
}
function updateAirbandSquelch() {
// If airband is playing, restart with new squelch value
if (isAirbandPlaying) {
stopAirband();
setTimeout(() => startAirband(), 300);
}
}
// Initialize airband on page load
document.addEventListener('DOMContentLoaded', initAirband);
// ============================================
// ACARS Functions
// ============================================
let acarsEventSource = null;
let isAcarsRunning = false;
let acarsCurrentAgent = null;
let acarsPollTimer = null;
let acarsMessageCount = 0;
let acarsSidebarCollapsed = localStorage.getItem('acarsSidebarCollapsed') !== 'false';
let acarsFrequencies = {
'na': ['131.550', '130.025', '129.125'],
'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);
// Collapse VDL2 when expanding ACARS
if (!acarsSidebarCollapsed && !vdl2SidebarCollapsed) {
const vdl2 = document.getElementById('vdl2Sidebar');
vdl2SidebarCollapsed = true;
vdl2.classList.add('collapsed');
localStorage.setItem('vdl2SidebarCollapsed', vdl2SidebarCollapsed);
}
setTimeout(() => { if (typeof radarMap !== 'undefined' && radarMap) radarMap.invalidateSize(); }, 350);
}
// Initialize ACARS sidebar state and frequency checkboxes
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('acarsSidebar');
if (sidebar && acarsSidebarCollapsed) {
sidebar.classList.add('collapsed');
}
updateAcarsFreqCheckboxes();
// Check if ACARS is already running (e.g. after page reload)
fetch('/acars/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isAcarsRunning = true;
acarsMessageCount = data.message_count || 0;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream(false);
// Reload message history from backend
fetch('/acars/messages?limit=50')
.then(r => r.json())
.then(msgs => {
if (!msgs || !msgs.length) return;
// Add oldest first so newest ends up on top
for (let i = msgs.length - 1; i >= 0; i--) {
addAcarsMessage(msgs[i]);
}
})
.catch(() => {});
}
})
.catch(() => {});
});
function updateAcarsFreqCheckboxes() {
const region = document.getElementById('acarsRegionSelect').value;
const freqs = acarsFrequencies[region] || acarsFrequencies['na'];
const container = document.getElementById('acarsFreqSelector');
// Save currently checked frequencies
const previouslyChecked = new Set();
container.querySelectorAll('input:checked').forEach(cb => {
previouslyChecked.add(cb.value);
});
container.innerHTML = freqs.map((freq, i) => {
// On initial load, check all frequencies; otherwise preserve state
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
return `
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
<input type="checkbox" class="acars-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
<span>${freq}</span>
</label>
`;
}).join('');
}
function getAcarsRegionFreqs() {
const checkboxes = document.querySelectorAll('.acars-freq-cb:checked');
const selectedFreqs = Array.from(checkboxes).map(cb => cb.value);
// If none selected, return all for the region as fallback
if (selectedFreqs.length === 0) {
const region = document.getElementById('acarsRegionSelect').value;
return acarsFrequencies[region] || acarsFrequencies['na'];
}
return selectedFreqs;
}
function toggleAcars() {
if (isAcarsRunning) {
stopAcars();
} else {
startAcars();
}
}
async function startAcars() {
const acarsSelect = document.getElementById('acarsDeviceSelect');
const compositeVal = acarsSelect.value || 'rtlsdr:0';
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getAcarsRegionFreqs();
// Check if using agent mode
const isAgentMode = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
acarsCurrentAgent = isAgentMode ? adsbCurrentAgent : null;
// Warn if using same device as ADS-B (only for local mode)
if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) {
const useAnyway = await AppFeedback.confirmAction({
title: 'SDR Device Conflict',
message: `ADS-B tracking is using SDR device ${adsbActiveDevice}. ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz. You need TWO separate SDR devices to receive both simultaneously. Continue to start ACARS on device ${device} anyway.`,
confirmLabel: 'Continue',
confirmClass: 'btn-danger'
});
if (!useAnyway) return;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${adsbCurrentAgent}/acars/start`
: '/acars/start';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const scanResult = isAgentMode && data.result ? data.result : data;
if (scanResult.status === 'started' || scanResult.status === 'success') {
isAcarsRunning = true;
acarsMessageCount = 0;
document.getElementById('acarsToggleBtn').textContent = '■ STOP ACARS';
document.getElementById('acarsToggleBtn').classList.add('active');
document.getElementById('acarsPanelIndicator').classList.add('active');
startAcarsStream(isAgentMode);
} else {
alert('ACARS Error: ' + (scanResult.message || scanResult.error || 'Failed to start'));
}
})
.catch(err => alert('ACARS Error: ' + err));
}
function stopAcars() {
const isAgentMode = acarsCurrentAgent !== null;
const endpoint = isAgentMode
? `/controller/agents/${acarsCurrentAgent}/acars/stop`
: '/acars/stop';
fetch(endpoint, { method: 'POST' })
.then(r => r.json())
.then(() => {
isAcarsRunning = false;
acarsCurrentAgent = null;
document.getElementById('acarsToggleBtn').textContent = '▶ START ACARS';
document.getElementById('acarsToggleBtn').classList.remove('active');
document.getElementById('acarsPanelIndicator').classList.remove('active');
if (acarsEventSource) {
acarsEventSource.close();
acarsEventSource = null;
}
// Clear polling timer
if (acarsPollTimer) {
clearInterval(acarsPollTimer);
acarsPollTimer = null;
}
});
}
// Sync ACARS UI state (called by syncModeUI in agents.js)
function setAcarsRunning(running, agentId = null) {
isAcarsRunning = running;
const btn = document.getElementById('acarsToggleBtn');
const indicator = document.getElementById('acarsPanelIndicator');
if (running) {
acarsCurrentAgent = agentId;
btn.textContent = '■ STOP ACARS';
btn.classList.add('active');
if (indicator) indicator.classList.add('active');
// Start stream if not already running
if (!acarsEventSource && !acarsPollTimer) {
startAcarsStream(agentId !== null);
}
} else {
btn.textContent = '▶ START ACARS';
btn.classList.remove('active');
if (indicator) indicator.classList.remove('active');
}
}
// Expose to global scope for syncModeUI
window.setAcarsRunning = setAcarsRunning;
function startAcarsStream(isAgentMode = false) {
if (acarsEventSource) acarsEventSource.close();
// Use different stream endpoint for agent mode
const streamUrl = isAgentMode ? '/controller/stream/all' : '/acars/stream';
acarsEventSource = new EventSource(streamUrl);
acarsEventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
if (isAgentMode) {
// Handle multi-agent stream format
if (data.scan_type === 'acars' && data.payload) {
const payload = data.payload;
if (payload.type === 'acars') {
acarsMessageCount++;
stats.acarsMessages++;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('stripAcars').textContent = stats.acarsMessages;
payload.agent_name = data.agent_name;
addAcarsMessage(payload);
}
}
} else {
// Local stream format
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');
setTimeout(() => {
if (isAcarsRunning) {
startAcarsStream(acarsCurrentAgent !== null);
}
}, 2000);
};
// Start polling fallback for agent mode
if (isAgentMode) {
startAcarsPolling();
}
}
// Track last ACARS message count for polling
let lastAcarsMessageCount = 0;
function startAcarsPolling() {
if (acarsPollTimer) return;
lastAcarsMessageCount = 0;
const pollInterval = 2000;
acarsPollTimer = setInterval(async () => {
if (!isAcarsRunning || !acarsCurrentAgent) {
clearInterval(acarsPollTimer);
acarsPollTimer = null;
return;
}
try {
const response = await fetch(`/controller/agents/${acarsCurrentAgent}/acars/data`);
if (!response.ok) return;
const data = await response.json();
const result = data.result || data;
const messages = result.data || [];
// Process new messages
if (messages.length > lastAcarsMessageCount) {
const newMessages = messages.slice(lastAcarsMessageCount);
newMessages.forEach(msg => {
acarsMessageCount++;
stats.acarsMessages++;
document.getElementById('acarsCount').textContent = acarsMessageCount;
document.getElementById('stripAcars').textContent = stats.acarsMessages;
msg.agent_name = result.agent_name || 'Remote Agent';
addAcarsMessage(msg);
});
lastAcarsMessageCount = messages.length;
}
} catch (err) {
console.error('ACARS polling error:', err);
}
}, pollInterval);
}
// Track which aircraft have ACARS messages (by ICAO)
const acarsAircraftIcaos = new Set();
// IATA (2-letter) → ICAO (3-letter) airline code mapping
// NOTE: Duplicated from utils/airline_codes.py — keep both in sync
const IATA_TO_ICAO = {
'AA':'AAL','DL':'DAL','UA':'UAL','WN':'SWA','B6':'JBU','AS':'ASA',
'NK':'NKS','F9':'FFT','G4':'AAY','HA':'HAL','SY':'SCX','WS':'WJA',
'AC':'ACA','WG':'WGN','TS':'TSC','PD':'POE','MX':'MXA','QX':'QXE',
'OH':'COM','OO':'SKW','YX':'RPA','9E':'FLG','PT':'SWQ','MQ':'ENY',
'YV':'ASH','AX':'LOF','ZW':'AWI','G7':'GJS','EV':'ASQ',
'AM':'AMX','VB':'VIV','4O':'AIJ','Y4':'VOI',
'5X':'UPS','FX':'FDX',
'BA':'BAW','LH':'DLH','AF':'AFR','KL':'KLM','IB':'IBE','AZ':'ITY',
'SK':'SAS','AY':'FIN','OS':'AUA','LX':'SWR','SN':'BEL','TP':'TAP',
'EI':'EIN','U2':'EZY','FR':'RYR','W6':'WZZ','VY':'VLG','PC':'PGT',
'TK':'THY','LO':'LOT','BT':'BTI','DY':'NAX','VS':'VIR','EW':'EWG',
'SQ':'SIA','CX':'CPA','QF':'QFA','JL':'JAL','NH':'ANA','KE':'KAL',
'OZ':'AAR','CI':'CAL','BR':'EVA','CZ':'CSN','MU':'CES','CA':'CCA',
'AI':'AIC','GA':'GIA','TG':'THA','MH':'MAS','PR':'PAL','VN':'HVN',
'NZ':'ANZ','3K':'JSA','JQ':'JST','AK':'AXM','TR':'TGW','5J':'CEB',
'EK':'UAE','QR':'QTR','EY':'ETD','GF':'GFA','SV':'SVA',
'ET':'ETH','MS':'MSR','SA':'SAA','RJ':'RJA','WY':'OMA',
'LA':'LAN','G3':'GLO','AD':'AZU','AV':'AVA','CM':'CMP','AR':'ARG'
};
const ICAO_TO_IATA = Object.fromEntries(Object.entries(IATA_TO_ICAO).map(([k,v]) => [v,k]));
function translateFlight(flight) {
if (!flight) return [];
const m = flight.match(/^([A-Z0-9]{2,3})(\d+[A-Z]?)$/);
if (!m) return [];
const [, prefix, num] = m;
const results = [];
if (IATA_TO_ICAO[prefix]) results.push(IATA_TO_ICAO[prefix] + num);
if (ICAO_TO_IATA[prefix]) results.push(ICAO_TO_IATA[prefix] + num);
return results;
}
function findAircraftIcaoByFlight(flight) {
if (!flight || flight === 'UNKNOWN') return null;
const upper = flight.trim().toUpperCase();
// Build candidate list: original + translated variants
const candidates = [upper, ...translateFlight(upper)];
for (const candidate of candidates) {
for (const [icao, ac] of Object.entries(aircraft)) {
const cs = (ac.callsign || '').trim().toUpperCase();
if (cs === candidate) return icao;
// Also match by registration (tail number)
const reg = (ac.registration || '').trim().toUpperCase();
if (reg && reg === candidate) return icao;
}
// Also check ICAO hex directly
if (aircraft[candidate]) return candidate;
}
return null;
}
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';
const flight = data.flight || 'UNKNOWN';
const reg = data.reg || '';
const label = data.label || '';
const labelDesc = data.label_description || '';
const msgType = data.message_type || 'other';
const text = data.text || data.msg || '';
const time = new Date().toLocaleTimeString();
// Escape user-controlled strings for safe innerHTML insertion
const eFlight = escapeHtml(flight);
const eReg = escapeHtml(reg);
const eLabelDesc = escapeHtml(labelDesc || (label ? 'Label: ' + label : ''));
const eText = escapeHtml(text.length > 80 ? text.substring(0, 80) + '...' : text);
// Try to find matching tracked aircraft
const matchedIcao = findAircraftIcaoByFlight(flight) ||
findAircraftIcaoByFlight(data.tail) ||
findAircraftIcaoByFlight(data.reg) ||
(data.icao && aircraft[data.icao.toUpperCase()] ? data.icao.toUpperCase() : null);
if (matchedIcao) acarsAircraftIcaos.add(matchedIcao);
// Tag message with matched ICAO for cross-highlighting
if (matchedIcao) msg.dataset.icao = matchedIcao;
// Make clickable if we have a matching aircraft
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' +
(matchedIcao ? ' cursor: pointer;' : '');
if (matchedIcao) {
msg.onclick = () => selectAircraft(matchedIcao);
msg.title = 'Click to locate ' + flight + ' on map';
}
const typeBadge = typeof getAcarsTypeBadge === 'function' ? getAcarsTypeBadge(msgType) : '';
const linkIcon = matchedIcao ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${eFlight}${linkIcon}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${reg ? `<div style="color: var(--text-muted); font-size: 9px;">Reg: ${eReg}</div>` : ''}
<div style="margin-top: 2px;">${typeBadge} <span style="color: var(--text-primary);">${eLabelDesc}</span></div>
${text && msgType !== 'link_test' && msgType !== 'handshake' ? `<div style="color: var(--text-dim); margin-top: 3px; word-break: break-word; font-size: 9px;">${eText}</div>` : ''}
`;
container.insertBefore(msg, container.firstChild);
// Keep max 50 messages
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
function clearAcarsMessages() {
fetch('/acars/clear', { method: 'POST' }).catch(() => {});
const container = document.getElementById('acarsMessages');
container.innerHTML = '<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>';
acarsMessageCount = 0;
document.getElementById('acarsCount').textContent = '0';
}
function clearVdl2Messages() {
fetch('/vdl2/clear', { method: 'POST' }).catch(() => {});
const container = document.getElementById('vdl2Messages');
container.innerHTML = '<div class="no-aircraft" style="padding: 20px; text-align: center;"><div style="font-size: 10px; color: var(--text-muted);">No VDL2 messages</div><div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start VDL2 to receive digital datalink messages</div></div>';
vdl2MessageCount = 0;
document.getElementById('vdl2Count').textContent = '0';
}
function clearAircraftMessages() {
const container = document.getElementById('aircraftAcarsMessages');
if (container) {
container.innerHTML = '<span style="color:var(--text-muted);font-style:italic;">No messages yet</span>';
}
if (acarsMessageTimer) {
clearInterval(acarsMessageTimer);
acarsMessageTimer = null;
}
}
// 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="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
});
// ============================================
// VDL2 DATALINK PANEL
// ============================================
let vdl2EventSource = null;
let isVdl2Running = false;
let vdl2CurrentAgent = null;
let vdl2PollTimer = null;
let vdl2MessageCount = 0;
let vdl2CollectedData = [];
let vdl2SidebarCollapsed = localStorage.getItem('vdl2SidebarCollapsed') !== 'false';
let vdl2Frequencies = {
'na': ['136975000', '136100000', '136650000', '136700000', '136800000'],
'eu': ['136975000', '136675000', '136725000', '136775000', '136825000'],
'ap': ['136975000', '136900000']
};
let vdl2FreqLabels = {
'136975000': '136.975', '136100000': '136.100', '136650000': '136.650',
'136700000': '136.700', '136800000': '136.800', '136675000': '136.675',
'136725000': '136.725', '136775000': '136.775', '136825000': '136.825',
'136900000': '136.900'
};
function toggleVdl2Sidebar() {
const sidebar = document.getElementById('vdl2Sidebar');
vdl2SidebarCollapsed = !vdl2SidebarCollapsed;
sidebar.classList.toggle('collapsed', vdl2SidebarCollapsed);
localStorage.setItem('vdl2SidebarCollapsed', vdl2SidebarCollapsed);
// Collapse ACARS when expanding VDL2
if (!vdl2SidebarCollapsed && !acarsSidebarCollapsed) {
const acars = document.getElementById('acarsSidebar');
acarsSidebarCollapsed = true;
acars.classList.add('collapsed');
localStorage.setItem('acarsSidebarCollapsed', acarsSidebarCollapsed);
}
setTimeout(() => { if (typeof radarMap !== 'undefined' && radarMap) radarMap.invalidateSize(); }, 350);
}
document.addEventListener('DOMContentLoaded', () => {
const sidebar = document.getElementById('vdl2Sidebar');
if (sidebar && vdl2SidebarCollapsed) {
sidebar.classList.add('collapsed');
}
updateVdl2FreqCheckboxes();
});
function updateVdl2FreqCheckboxes() {
const region = document.getElementById('vdl2RegionDashSelect').value;
const freqs = vdl2Frequencies[region] || vdl2Frequencies['na'];
const container = document.getElementById('vdl2FreqSelector');
const previouslyChecked = new Set();
container.querySelectorAll('input:checked').forEach(cb => previouslyChecked.add(cb.value));
container.innerHTML = freqs.map(freq => {
const checked = previouslyChecked.size === 0 || previouslyChecked.has(freq) ? 'checked' : '';
const label = vdl2FreqLabels[freq] || freq;
return `
<label style="display: flex; align-items: center; gap: 3px; padding: 2px 6px; background: var(--bg-secondary); border-radius: 3px; cursor: pointer;">
<input type="checkbox" class="vdl2-freq-cb" value="${freq}" ${checked} style="margin: 0; cursor: pointer;">
<span>${label}</span>
</label>
`;
}).join('');
}
function getVdl2RegionFreqs() {
const checkboxes = document.querySelectorAll('.vdl2-freq-cb:checked');
const selectedFreqs = Array.from(checkboxes).map(cb => cb.value);
if (selectedFreqs.length === 0) {
const region = document.getElementById('vdl2RegionDashSelect').value;
return vdl2Frequencies[region] || vdl2Frequencies['na'];
}
return selectedFreqs;
}
function toggleVdl2() {
if (isVdl2Running) {
stopVdl2();
} else {
startVdl2();
}
}
async function startVdl2() {
const vdl2Select = document.getElementById('vdl2DeviceSelect');
const compositeVal = vdl2Select.value || 'rtlsdr:0';
const [sdr_type, deviceIdx] = compositeVal.includes(':') ? compositeVal.split(':') : ['rtlsdr', compositeVal];
const device = deviceIdx;
const frequencies = getVdl2RegionFreqs();
// Check if using agent mode
const isAgentMode = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
vdl2CurrentAgent = isAgentMode ? adsbCurrentAgent : null;
// Warn if using same device as ADS-B (only for local mode)
if (!isAgentMode && isTracking && adsbActiveDevice !== null && device === String(adsbActiveDevice)) {
const useAnyway = await AppFeedback.confirmAction({
title: 'SDR Device Conflict',
message: `ADS-B tracking is using SDR device ${adsbActiveDevice}. VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz. You need TWO separate SDR devices to receive both simultaneously. Continue to start VDL2 on device ${device} anyway.`,
confirmLabel: 'Continue',
confirmClass: 'btn-danger'
});
if (!useAnyway) return;
}
// Determine endpoint based on agent mode
const endpoint = isAgentMode
? `/controller/agents/${adsbCurrentAgent}/vdl2/start`
: '/vdl2/start';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device, frequencies, gain: '40', sdr_type })
})
.then(r => r.json())
.then(data => {
// Handle controller proxy response format
const vdl2Result = isAgentMode && data.result ? data.result : data;
if (vdl2Result.status === 'started' || vdl2Result.status === 'success') {
isVdl2Running = true;
vdl2MessageCount = 0;
vdl2CollectedData = [];
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9632; STOP VDL2';
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream(isAgentMode);
} else {
alert('VDL2 Error: ' + (vdl2Result.message || vdl2Result.error || 'Failed to start'));
}
})
.catch(err => alert('VDL2 Error: ' + err));
}
async function stopVdl2() {
const sourceAgentId = vdl2CurrentAgent;
const isAgentMode = sourceAgentId !== null && sourceAgentId !== undefined;
const endpoint = isAgentMode
? `/controller/agents/${sourceAgentId}/vdl2/stop`
: '/vdl2/stop';
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'adsb_dashboard' })
});
const text = await response.text();
let data = {};
if (text) {
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Invalid response: ${text}`);
}
}
const result = isAgentMode && data.result ? data.result : data;
const stopped = response.ok && (
result.status === 'stopped' ||
result.status === 'success' ||
data.status === 'success'
);
if (!stopped) {
throw new Error(result.message || data.message || `HTTP ${response.status}`);
}
isVdl2Running = false;
vdl2CurrentAgent = null;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9654; START VDL2';
document.getElementById('vdl2ToggleBtn').classList.remove('active');
document.getElementById('vdl2PanelIndicator').classList.remove('active');
if (vdl2EventSource) {
vdl2EventSource.close();
vdl2EventSource = null;
}
// Clear polling timer
if (vdl2PollTimer) {
clearInterval(vdl2PollTimer);
vdl2PollTimer = null;
}
} catch (err) {
alert('Failed to stop VDL2: ' + err.message);
}
}
// Sync VDL2 UI state (called by syncModeUI in agents.js)
function setVdl2Running(running, agentId = null) {
isVdl2Running = running;
const btn = document.getElementById('vdl2ToggleBtn');
const indicator = document.getElementById('vdl2PanelIndicator');
if (running) {
vdl2CurrentAgent = agentId;
btn.innerHTML = '&#9632; STOP VDL2';
btn.classList.add('active');
if (indicator) indicator.classList.add('active');
if (!vdl2EventSource && !vdl2PollTimer) {
startVdl2Stream(agentId !== null);
}
} else {
vdl2CurrentAgent = null;
btn.innerHTML = '&#9654; START VDL2';
btn.classList.remove('active');
if (indicator) indicator.classList.remove('active');
}
}
// Expose to global scope for syncModeUI
window.setVdl2Running = setVdl2Running;
function startVdl2Stream(isAgentMode = false) {
if (vdl2EventSource) vdl2EventSource.close();
// For remote agent mode, stream directly from the selected agent via controller proxy.
// This does not depend on push ingestion being enabled.
const streamUrl = isAgentMode && vdl2CurrentAgent !== null
? `/controller/agents/${vdl2CurrentAgent}/vdl2/stream`
: '/vdl2/stream';
vdl2EventSource = new EventSource(streamUrl);
vdl2EventSource.onmessage = function(e) {
const data = JSON.parse(e.data);
let message = null;
// Backward compatibility: handle wrapped multi-agent payloads if encountered.
if (data.scan_type === 'vdl2' && data.payload && data.payload.type === 'vdl2') {
message = data.payload;
if (isAgentMode) {
message.agent_name = data.agent_name || message.agent_name || 'Remote Agent';
}
} else if (data.type === 'vdl2') {
message = data;
if (isAgentMode && !message.agent_name) {
message.agent_name = 'Remote Agent';
}
}
if (message) {
vdl2MessageCount++;
if (typeof stats !== 'undefined') stats.vdl2Messages = (stats.vdl2Messages || 0) + 1;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('stripVdl2').textContent = vdl2MessageCount;
addVdl2Message(message);
}
};
vdl2EventSource.onerror = function() {
console.error('VDL2 stream error');
setTimeout(() => {
if (isVdl2Running) {
startVdl2Stream(vdl2CurrentAgent !== null);
}
}, 2000);
};
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Unwrap dumpvdl2 JSON: data may have fields at top level or nested under data.vdl2
function unwrapVdl2(data) {
if (data.vdl2 && typeof data.vdl2 === 'object') return data.vdl2;
return data;
}
function showVdl2Modal(data, timeStr) {
// Remove any existing modal
const existing = document.querySelector('.vdl2-modal-overlay');
if (existing) existing.remove();
const inner = unwrapVdl2(data);
const avlc = inner.avlc || {};
const src = avlc.src || {};
const dst = avlc.dst || {};
const acars = avlc.acars || {};
const xid = avlc.xid || {};
const flight = acars.flight || '';
const freq = inner.freq ? (inner.freq / 1000000).toFixed(3) + ' MHz' : '';
const title = flight || src.addr || 'VDL2 Message';
// Build section helper
function buildSection(titleText, fields) {
if (!fields.length) return '';
return `<div class="vdl2-modal-section">
<div class="vdl2-modal-section-title">${titleText}</div>
<div class="vdl2-modal-grid">${fields.map(([l, v]) =>
`<div class="vdl2-modal-field"><span class="vdl2-modal-field-label">${escapeHtml(l)}</span><span class="vdl2-modal-field-value">${escapeHtml(String(v))}</span></div>`
).join('')}</div>
</div>`;
}
// Radio / signal fields
const radioFields = [];
if (freq) radioFields.push(['Frequency', freq]);
if (inner.sig_level != null) radioFields.push(['Signal', inner.sig_level.toFixed(1) + ' dB']);
if (inner.noise_level != null) radioFields.push(['Noise', inner.noise_level.toFixed(1) + ' dB']);
if (inner.sig_level != null && inner.noise_level != null) radioFields.push(['SNR', (inner.sig_level - inner.noise_level).toFixed(1) + ' dB']);
if (inner.freq_skew != null) radioFields.push(['Freq Skew', inner.freq_skew.toFixed(2) + ' Hz']);
if (inner.burst_len_octets != null) radioFields.push(['Burst Length', inner.burst_len_octets + ' octets']);
if (inner.octets_corrected_by_fec != null) radioFields.push(['FEC Corrections', String(inner.octets_corrected_by_fec)]);
if (inner.hdr_bits_fixed != null) radioFields.push(['Header Bits Fixed', String(inner.hdr_bits_fixed)]);
if (inner.idx != null) radioFields.push(['SDR Index', String(inner.idx)]);
if (inner.station) radioFields.push(['Station', String(inner.station)]);
if (data.agent_name) radioFields.push(['Agent', data.agent_name]);
const radioHtml = buildSection('Radio', radioFields);
// Timestamp from dumpvdl2
let tsStr = '';
if (inner.t) {
const d = new Date(inner.t.sec * 1000);
tsStr = d.toISOString().replace('T', ' ').replace('Z', '') + ' UTC';
}
// AVLC frame fields
const frameFields = [];
if (src.addr) frameFields.push(['Source', String(src.addr) + (src.type ? ' (' + src.type + ')' : '')]);
if (dst.addr) frameFields.push(['Destination', String(dst.addr) + (dst.type ? ' (' + dst.type + ')' : '')]);
if (avlc.cr) frameFields.push(['Cmd/Response', avlc.cr]);
if (avlc.frame_type) frameFields.push(['Frame Type', avlc.frame_type]);
if (avlc.rseq != null) frameFields.push(['R-Seq', String(avlc.rseq)]);
if (avlc.sseq != null) frameFields.push(['S-Seq', String(avlc.sseq)]);
if (avlc.poll != null) frameFields.push(['Poll/Final', avlc.poll ? 'Yes' : 'No']);
const frameHtml = buildSection('AVLC Frame', frameFields);
// ACARS fields
const acarsFields = [];
if (acars.reg) acarsFields.push(['Registration', acars.reg]);
if (acars.flight) acarsFields.push(['Flight', acars.flight]);
if (acars.mode) acarsFields.push(['Mode', acars.mode]);
if (acars.label) acarsFields.push(['Label', acars.label + (acars.label_description ? ' — ' + acars.label_description : '')]);
if (acars.sublabel) acarsFields.push(['Sublabel', acars.sublabel]);
if (acars.blk_id) acarsFields.push(['Block ID', acars.blk_id]);
if (acars.ack) acarsFields.push(['ACK', acars.ack === '!' ? 'NAK' : acars.ack]);
if (acars.msg_num) acarsFields.push(['Msg Number', acars.msg_num]);
if (acars.msg_num_seq) acarsFields.push(['Msg Seq', acars.msg_num_seq]);
if (acars.more != null) acarsFields.push(['More Follows', acars.more ? 'Yes' : 'No']);
if (acars.message_type && acars.message_type !== 'other') acarsFields.push(['Message Type', acars.message_type.replace(/_/g, ' ')]);
const acarsHtml = buildSection('ACARS', acarsFields);
// XID fields
const xidFields = [];
if (xid.type_descr) xidFields.push(['Type', xid.type_descr]);
if (xid.vdl_params) {
const params = xid.vdl_params;
if (params.ac_location) {
const loc = params.ac_location;
if (loc.lat != null) xidFields.push(['Latitude', loc.lat.toFixed(4)]);
if (loc.lon != null) xidFields.push(['Longitude', loc.lon.toFixed(4)]);
if (loc.alt != null) xidFields.push(['Altitude', loc.alt + ' ft']);
}
if (params.dst_airport) xidFields.push(['Destination', params.dst_airport]);
if (params.modulation_support) xidFields.push(['Modulation', params.modulation_support]);
}
const xidHtml = buildSection('XID', xidFields);
// Message body
const msgText = acars.msg_text || '';
let bodyHtml = '';
if (msgText) {
bodyHtml = `<div class="vdl2-modal-section">
<div class="vdl2-modal-section-title">Message</div>
<div class="vdl2-modal-msg-body">${escapeHtml(msgText)}</div>
</div>`;
}
// Raw JSON
const rawJson = JSON.stringify(data, null, 2);
const rawId = 'vdl2modal_raw_' + Date.now();
const rawHtml = `<div class="vdl2-modal-section">
<span class="vdl2-modal-raw-toggle" id="${rawId}_toggle">&#9656; Show raw JSON</span>
<div class="vdl2-modal-raw-json" id="${rawId}">${escapeHtml(rawJson)}</div>
</div>`;
const overlay = document.createElement('div');
overlay.className = 'vdl2-modal-overlay';
overlay.innerHTML = `
<div class="vdl2-modal">
<div class="vdl2-modal-header">
<div>
<div class="vdl2-modal-title">${escapeHtml(title)}</div>
<div class="vdl2-modal-time">${escapeHtml(tsStr || timeStr)}${freq ? ' · ' + escapeHtml(freq) : ''}</div>
</div>
<button class="vdl2-modal-close" title="Close">&times;</button>
</div>
<div class="vdl2-modal-body">
${radioHtml}
${frameHtml}
${acarsHtml}
${xidHtml}
${bodyHtml}
${rawHtml}
</div>
</div>
`;
// Close handlers
overlay.querySelector('.vdl2-modal-close').addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
// Raw JSON toggle
const rawToggle = overlay.querySelector(`#${rawId}_toggle`);
const rawEl = overlay.querySelector(`#${rawId}`);
rawToggle.addEventListener('click', () => {
const show = rawEl.style.display !== 'block';
rawEl.style.display = show ? 'block' : 'none';
rawToggle.innerHTML = show ? '&#9662; Hide raw JSON' : '&#9656; Show raw JSON';
});
// Escape key
const onKey = (e) => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onKey); } };
document.addEventListener('keydown', onKey);
document.body.appendChild(overlay);
}
function addVdl2Message(data) {
const container = document.getElementById('vdl2Messages');
const placeholder = container.querySelector('.no-aircraft');
if (placeholder) placeholder.remove();
const msg = document.createElement('div');
msg.className = 'vdl2-message-item';
const inner = unwrapVdl2(data);
const avlc = inner.avlc || {};
const src = avlc.src?.addr || '';
const acars = avlc.acars || {};
const flight = acars.flight || '';
const msgText = acars.msg_text || '';
const time = new Date().toLocaleTimeString();
const freq = inner.freq ? (inner.freq / 1000000).toFixed(3) : '';
// Store for CSV export
vdl2CollectedData.push({
timestamp: new Date().toISOString(),
frequency: freq ? freq + ' MHz' : '',
source: avlc.src?.addr || '',
source_type: avlc.src?.type || '',
destination: avlc.dst?.addr || '',
destination_type: avlc.dst?.type || '',
frame_type: avlc.frame_type || '',
flight: flight,
registration: acars.reg || '',
label: acars.label || '',
sublabel: acars.sublabel || '',
block_id: acars.blk_id || '',
msg_number: acars.msg_num || '',
message: msgText.replace(/\n/g, ' '),
signal_level: inner.sig_level != null ? inner.sig_level.toFixed(1) : '',
noise_level: inner.noise_level != null ? inner.noise_level.toFixed(1) : '',
agent: data.agent_name || ''
});
const label = flight || src || (avlc.frame_type || 'VDL2');
// Try to find matching tracked aircraft (same logic as ACARS)
const matchedIcao = findAircraftIcaoByFlight(flight) ||
findAircraftIcaoByFlight(acars.reg) ||
(src && aircraft[src.toUpperCase()] ? src.toUpperCase() : null);
if (matchedIcao) {
acarsAircraftIcaos.add(matchedIcao);
msg.dataset.icao = matchedIcao;
}
msg.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); font-size: 10px;' +
(matchedIcao ? ' cursor: pointer;' : '');
const linkIcon = matchedIcao ? '<span style="color:var(--accent-green);font-size:9px;margin-left:3px;" title="Tracked on map">&#9992;</span>' : '';
const acarsLabelDesc = acars.label_description || '';
const acarsMsgType = acars.message_type || 'other';
const vdl2TypeBadge = typeof getAcarsTypeBadge === 'function' && acars.label ? getAcarsTypeBadge(acarsMsgType) : '';
msg.innerHTML = `
<div style="display: flex; justify-content: space-between; margin-bottom: 2px;">
<span style="color: var(--accent-cyan); font-weight: bold;">${escapeHtml(label)}${linkIcon}</span>
<span style="color: var(--text-muted);">${time}</span>
</div>
${acarsLabelDesc ? `<div style="margin-top: 2px;">${vdl2TypeBadge} <span style="color: var(--text-primary);">${escapeHtml(acarsLabelDesc)}</span></div>` : ''}
<div style="color: var(--text-dim); font-size: 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
${freq ? freq + ' MHz' : ''}${freq && acars.label ? ' · ' : ''}${acars.label ? 'Label: ' + escapeHtml(acars.label) : ''}${msgText ? ' · ' + escapeHtml(msgText.substring(0, 50)) + (msgText.length > 50 ? '…' : '') : ''}
</div>
`;
if (matchedIcao) {
msg.addEventListener('click', () => selectAircraft(matchedIcao));
msg.addEventListener('dblclick', () => showVdl2Modal(data, time));
msg.title = 'Click to locate on map, double-click for details';
} else {
msg.addEventListener('click', () => showVdl2Modal(data, time));
}
container.insertBefore(msg, container.firstChild);
while (container.children.length > 50) {
container.removeChild(container.lastChild);
}
}
function exportVdl2Csv() {
if (vdl2CollectedData.length === 0) {
alert('No VDL2 messages to export.');
return;
}
const headers = Object.keys(vdl2CollectedData[0]);
const csvRows = [headers.join(',')];
for (const row of vdl2CollectedData) {
csvRows.push(headers.map(h => {
const val = String(row[h] || '');
return '"' + val.replace(/"/g, '""') + '"';
}).join(','));
}
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `vdl2_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Populate VDL2 device selector and check running status
document.addEventListener('DOMContentLoaded', () => {
fetch('/devices')
.then(r => r.json())
.then(devices => {
const select = document.getElementById('vdl2DeviceSelect');
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
} else {
devices.forEach((d, i) => {
const opt = document.createElement('option');
const sdrType = d.sdr_type || 'rtlsdr';
const idx = d.index !== undefined ? d.index : i;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
// Check if VDL2 is already running (e.g. after page reload)
fetch('/vdl2/status')
.then(r => r.json())
.then(data => {
if (data.running) {
isVdl2Running = true;
vdl2MessageCount = data.message_count || 0;
document.getElementById('vdl2Count').textContent = vdl2MessageCount;
document.getElementById('vdl2ToggleBtn').innerHTML = '&#9632; STOP VDL2';
document.getElementById('vdl2ToggleBtn').classList.add('active');
document.getElementById('vdl2PanelIndicator').classList.add('active');
startVdl2Stream(false);
// Reload message history from backend
fetch('/vdl2/messages?limit=50')
.then(r => r.json())
.then(msgs => {
if (!msgs || !msgs.length) return;
for (let i = msgs.length - 1; i >= 0; i--) {
addVdl2Message(msgs[i]);
}
})
.catch(() => {});
}
})
.catch(() => {});
});
// ============================================
// 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();
});
// ============================================
// ANTENNA GUIDE
// ============================================
function toggleAntennaGuide() {
const modal = document.getElementById('antennaGuideModal');
modal.classList.toggle('active');
}
document.getElementById('antennaGuideModal')?.addEventListener('click', (e) => {
if (e.target.id === 'antennaGuideModal') toggleAntennaGuide();
});
</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>
<!-- Antenna Guide Modal -->
<div id="antennaGuideModal" class="antenna-guide-modal">
<div class="antenna-guide-modal-content">
<div class="antenna-guide-modal-header">
<span>ANTENNA GUIDE &mdash; 1090 MHz ADS-B</span>
<button class="antenna-guide-modal-close" onclick="toggleAntennaGuide()">&times;</button>
</div>
<div class="antenna-guide-modal-body">
<p style="margin-bottom: 10px; color: var(--accent-cyan, #00d4ff); font-weight: 600; font-size: 12px;">
1090 MHz &mdash; stock SDR antenna can work but is not ideal
</p>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Stock Telescopic Antenna</strong>
<ul>
<li><strong>1090 MHz:</strong> Collapse to ~6.9 cm (quarter-wave). It works for nearby aircraft</li>
<li><strong>Range:</strong> Expect ~50 NM (90 km) indoors, ~100 NM outdoors</li>
</ul>
</div>
<div class="antenna-section recommended">
<strong style="color: #00ff88;">Recommended: 1090 MHz Collinear (~$10-20 DIY)</strong>
<ul>
<li><strong>Design:</strong> 8 coaxial collinear elements from RG-6 coax cable</li>
<li><strong>Element length:</strong> ~6.9 cm segments soldered alternating center/shield</li>
<li><strong>Gain:</strong> ~5&ndash;7 dBi omnidirectional, ideal for 360&deg; coverage</li>
<li><strong>Range:</strong> 150&ndash;250+ NM depending on height and LOS</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Commercial Options</strong>
<ul>
<li><strong>FlightAware antenna:</strong> ~$35, 1090 MHz tuned, 66cm fiberglass whip</li>
<li><strong>ADSBexchange whip:</strong> ~$40, similar performance</li>
<li><strong>Jetvision A3:</strong> ~$50, high-gain 1090 MHz collinear</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Placement &amp; LNA</strong>
<ul>
<li><strong>Location:</strong> OUTDOORS, as high as possible. Roof or mast mount</li>
<li><strong>Height:</strong> Every 3m higher adds ~10 NM range (line-of-sight)</li>
<li><strong>LNA:</strong> 1090 MHz filtered LNA at antenna feed (e.g. Uputronics, ~$30)</li>
<li><strong>Filter:</strong> A 1090 MHz bandpass filter removes cell/FM interference</li>
<li><strong>Coax:</strong> Keep short. At 1090 MHz, RG-58 loses ~10 dB per 10m</li>
<li><strong>Bias-T:</strong> Enable Bias-T in controls above if LNA is powered via coax</li>
</ul>
</div>
<div class="antenna-section">
<strong style="color: var(--accent-cyan, #00d4ff);">Quick Reference</strong>
<table class="antenna-ref-table">
<tr><td>ADS-B frequency</td><td>1090 MHz</td></tr>
<tr><td>Quarter-wave length</td><td>6.9 cm</td></tr>
<tr><td>Modulation</td><td>PPM (pulse)</td></tr>
<tr><td>Polarization</td><td>Vertical</td></tr>
<tr><td>Bandwidth</td><td>~2 MHz</td></tr>
<tr><td>Typical range (outdoor)</td><td>100&ndash;250 NM</td></tr>
</table>
</div>
</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: var(--font-mono);
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: var(--font-mono);
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;
}
/* Antenna Guide Modal */
.antenna-guide-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;
}
.antenna-guide-modal.active {
display: flex;
}
.antenna-guide-modal-content {
background: var(--bg-secondary, #1a1a1a);
border: 1px solid var(--border-color, #333);
border-radius: 8px;
width: 90%;
max-width: 480px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.antenna-guide-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);
}
.antenna-guide-modal-close {
background: none;
border: none;
color: var(--text-secondary, #888);
font-size: 20px;
cursor: pointer;
padding: 0 4px;
}
.antenna-guide-modal-close:hover {
color: #fff;
}
.antenna-guide-modal-body {
padding: 16px;
overflow-y: auto;
font-size: 11px;
color: var(--text-secondary, #999);
line-height: 1.5;
}
.antenna-section {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border-color, #333);
border-radius: 4px;
padding: 10px;
margin-bottom: 8px;
}
.antenna-section strong {
font-size: 11px;
display: block;
margin-bottom: 4px;
}
.antenna-section ul {
margin: 4px 0 0 14px;
padding: 0;
font-size: 10px;
}
.antenna-section ul li {
margin-bottom: 2px;
}
.antenna-section ul li strong {
display: inline;
color: var(--text-primary, #fff);
font-size: 10px;
}
.antenna-ref-table {
width: 100%;
margin-top: 6px;
font-size: 10px;
border-collapse: collapse;
}
.antenna-ref-table tr {
border-bottom: 1px solid var(--border-color, #333);
}
.antenna-ref-table td {
padding: 3px 4px;
}
.antenna-ref-table td:first-child {
color: var(--text-dim, #666);
}
.antenna-ref-table td:last-child {
color: var(--text-primary, #fff);
text-align: right;
}
/* 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: var(--font-mono);
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;
}
/* Agent selector styles */
.agent-selector-compact {
display: flex;
align-items: center;
gap: 6px;
margin-right: 15px;
}
.agent-select-sm {
background: rgba(0, 40, 60, 0.8);
border: 1px solid var(--border-color, rgba(0, 200, 255, 0.3));
color: var(--text-primary, #e0f7ff);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
}
.agent-select-sm:focus {
outline: none;
border-color: var(--accent-cyan, #00d4ff);
}
.agent-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-status-dot.online {
background: #4caf50;
box-shadow: 0 0 6px #4caf50;
}
.agent-status-dot.offline {
background: #f44336;
box-shadow: 0 0 6px #f44336;
}
.agent-badge {
font-size: 9px;
color: var(--accent-cyan, #00d4ff);
background: rgba(0, 200, 255, 0.1);
padding: 1px 4px;
border-radius: 2px;
margin-left: 4px;
}
#agentModeWarning {
color: #f0ad4e;
font-size: 10px;
padding: 4px 8px;
background: rgba(240,173,78,0.1);
border-radius: 4px;
margin-top: 4px;
}
.show-all-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--text-muted, #a0c4d0);
cursor: pointer;
margin-left: 8px;
}
.show-all-label input {
margin: 0;
cursor: pointer;
}
</style>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script>
window.addEventListener('DOMContentLoaded', () => {
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
});
</script>
<!-- Agent Manager -->
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script>
// ADS-B specific agent integration
let adsbCurrentAgent = 'local';
let adsbTrackingSource = null; // Track which source started the tracking ('local' or agent id)
// Helper to get current agent name for display
function getAdsbAgentName() {
if (adsbCurrentAgent === 'local') return 'Local';
if (typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == adsbCurrentAgent);
return agent ? agent.name : `Agent ${adsbCurrentAgent}`;
}
return `Agent ${adsbCurrentAgent}`;
}
// Helper to get tracking source name
function getTrackingSourceName() {
if (!adsbTrackingSource) return null;
if (adsbTrackingSource === 'local') return 'Local';
if (typeof agents !== 'undefined') {
const agent = agents.find(a => a.id == adsbTrackingSource);
return agent ? agent.name : `Agent ${adsbTrackingSource}`;
}
return `Agent ${adsbTrackingSource}`;
}
// Update tracking status display
function updateTrackingStatusDisplay() {
const statusEl = document.getElementById('trackingStatus');
if (!statusEl) return;
if (!isTracking) {
statusEl.textContent = 'STANDBY';
statusEl.title = 'Select source and click START';
} else {
const sourceName = getTrackingSourceName() || getAdsbAgentName();
if (sourceName === 'Local') {
statusEl.textContent = 'TRACKING';
statusEl.title = 'Tracking via Local device';
} else {
statusEl.textContent = `TRACKING (${sourceName})`;
statusEl.title = `Tracking via remote agent: ${sourceName}`;
}
}
}
// Override selectAgent for ADS-B page specifics
function selectAdsbAgent(agentId) {
adsbCurrentAgent = agentId;
currentAgent = agentId; // Update global agent state
// Stop current tracking if switching agents while tracking
if (isTracking && adsbTrackingSource !== agentId) {
// Don't auto-stop, just let user see current state
console.log('ADS-B: Agent changed while tracking - user should stop/restart');
}
if (agentId === 'local') {
// Refresh local devices
loadDevices();
// Check if local ADS-B is running
syncLocalTrackingStatus();
// Enable airband controls (local audio works)
setAirbandEnabled(true);
console.log('ADS-B: Using local device');
} else {
// Get agent devices
refreshAgentDevicesForAdsb(agentId);
// Check if agent has ADS-B running (syncAgentModeStates will call setADSBRunning)
syncAgentModeStates(agentId);
// Disable airband controls (no audio streaming from remote agents)
setAirbandEnabled(false);
console.log(`ADS-B: Using agent ${agentId}`);
}
updateAgentStatus();
updateSourceIndicator();
}
// Enable/disable airband controls based on local vs remote agent
function setAirbandEnabled(enabled) {
const airbandControls = document.querySelectorAll('.airband-controls');
const playBtn = document.getElementById('airbandBtn');
airbandControls.forEach(ctrl => {
ctrl.disabled = !enabled;
ctrl.style.opacity = enabled ? '1' : '0.5';
});
if (playBtn) {
playBtn.disabled = !enabled;
playBtn.style.opacity = enabled ? '1' : '0.5';
if (!enabled) {
playBtn.textContent = '▶ LOCAL ONLY';
playBtn.title = 'Airband requires local audio - not available for remote agents';
} else {
playBtn.textContent = '▶ LISTEN';
playBtn.title = 'Start airband audio';
}
}
}
// Check local ADS-B tracking status
async function syncLocalTrackingStatus() {
try {
const response = await fetch('/adsb/session');
if (!response.ok) return;
const data = await response.json();
if (data.tracking_active) {
setADSBRunning(true, 'local');
}
} catch (err) {
console.debug('Failed to sync local ADS-B status:', err);
}
}
/**
* Set ADS-B tracking UI state - called by syncModeUI from agents.js
* Also starts/stops data streaming as needed.
* @param {boolean} running - Whether ADS-B is running
* @param {string|number|null} source - 'local' or agent ID that started tracking
*/
function setADSBRunning(running, source = null) {
console.log(`[ADS-B] setADSBRunning called - running=${running}, source=${source}, adsbCurrentAgent=${adsbCurrentAgent}`);
const btn = document.getElementById('startBtn');
if (running) {
isTracking = true;
const normalizedSource = source === null || source === undefined
? (adsbTrackingSource || 'local')
: source;
// If source is an agent ID (not 'local' and not null), update adsbCurrentAgent
// This ensures startEventStream uses the correct routing
if (normalizedSource !== 'local') {
adsbCurrentAgent = normalizedSource;
// Also update the dropdown to match
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) {
agentSelect.value = normalizedSource;
}
// Update global agent state too
if (typeof currentAgent !== 'undefined') {
currentAgent = normalizedSource;
}
console.log(`[ADS-B] Updated adsbCurrentAgent to ${normalizedSource}`);
}
adsbTrackingSource = normalizedSource; // Track which source is active
// Update button
if (btn) {
btn.textContent = 'STOP';
btn.classList.add('active');
btn.title = 'Click to stop tracking';
}
const trackingDot = document.getElementById('trackingDot');
trackingDot.classList.remove('inactive', 'warn'); // Clear any previous state
updateTrackingStatusDisplay();
updateSourceIndicator(); // Update source display
// Disable selectors
const adsbSelect = document.getElementById('adsbDeviceSelect');
if (adsbSelect) adsbSelect.disabled = true;
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) agentSelect.disabled = true;
// Start data stream for the active source
const isAgentSource = normalizedSource !== 'local';
if (isAgentSource) {
console.log(`[ADS-B] Starting data stream from agent ${normalizedSource}`);
startEventStream();
drawRangeRings();
startSessionTimer();
} else {
console.log('[ADS-B] Starting local data stream');
startEventStream();
drawRangeRings();
startSessionTimer();
}
} else {
isTracking = false;
adsbTrackingSource = null;
// Update button
if (btn) {
btn.textContent = 'START';
btn.classList.remove('active');
btn.title = 'Click to start tracking';
}
const trackingDot = document.getElementById('trackingDot');
trackingDot.classList.remove('warn'); // Clear warn state
trackingDot.classList.add('inactive');
updateTrackingStatusDisplay();
// Re-enable selectors
const adsbSelect = document.getElementById('adsbDeviceSelect');
if (adsbSelect) adsbSelect.disabled = false;
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) agentSelect.disabled = false;
// Stop data stream
stopEventStream();
}
}
// Expose setADSBRunning globally so agents.js syncModeUI can call it
window.setADSBRunning = setADSBRunning;
// Update the SOURCE indicator in the stats strip
function updateSourceIndicator() {
const sourceEl = document.getElementById('stripSource');
if (!sourceEl) return;
const name = getAdsbAgentName();
sourceEl.textContent = name;
sourceEl.title = name === 'Local' ? 'Using local device' : `Using remote agent: ${name}`;
// Update color based on source
if (name === 'Local') {
sourceEl.style.color = 'var(--accent-green, #00ff88)';
} else {
sourceEl.style.color = 'var(--accent-cyan, #00d4ff)';
}
}
async function refreshAgentDevicesForAdsb(agentId) {
try {
const response = await fetch(`/controller/agents/${agentId}?refresh=true`);
const data = await response.json();
if (data.agent && data.agent.interfaces) {
// Agent stores SDR devices in interfaces.sdr_devices (same fix as agents.js)
const devices = data.agent.interfaces.sdr_devices || data.agent.interfaces.devices || [];
populateAdsbDeviceSelects(devices);
// Update observer location if agent has GPS
if (data.agent.gps_coords) {
const gps = typeof data.agent.gps_coords === 'string'
? JSON.parse(data.agent.gps_coords)
: data.agent.gps_coords;
if (gps.lat !== undefined && gps.lat !== null && gps.lon !== undefined && gps.lon !== null) {
document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4);
updateObserverLoc();
console.log(`Updated observer location from agent GPS: ${gps.lat}, ${gps.lon}`);
}
}
}
} catch (error) {
console.error('Failed to refresh agent devices:', error);
}
}
function populateAdsbDeviceSelects(devices) {
const adsbSelect = document.getElementById('adsbDeviceSelect');
const airbandSelect = document.getElementById('airbandDeviceSelect');
const acarsSelect = document.getElementById('acarsDeviceSelect');
const vdl2Select = document.getElementById('vdl2DeviceSelect');
[adsbSelect, airbandSelect, acarsSelect, vdl2Select].forEach(select => {
if (!select) return;
select.innerHTML = '';
if (devices.length === 0) {
select.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
} else {
devices.forEach(device => {
const opt = document.createElement('option');
const sdrType = device.sdr_type || 'rtlsdr';
const idx = device.index !== undefined ? device.index : 0;
opt.value = `${sdrType}:${idx}`;
opt.dataset.sdrType = sdrType;
opt.dataset.index = idx;
opt.textContent = `SDR ${idx}: ${device.name || device.type || 'SDR'}`;
select.appendChild(opt);
});
}
});
}
// Hook into page init
document.addEventListener('DOMContentLoaded', function() {
const agentSelect = document.getElementById('agentSelect');
if (agentSelect) {
agentSelect.addEventListener('change', function(e) {
selectAdsbAgent(e.target.value);
});
}
// Initialize source indicator
updateSourceIndicator();
});
// Show All Agents mode - display aircraft from all agents on the map
let showAllAgentsMode = false;
let allAgentsEventSource = null;
function toggleShowAllAgents() {
const checkbox = document.getElementById('showAllAgents');
showAllAgentsMode = checkbox ? checkbox.checked : false;
const agentSelect = document.getElementById('agentSelect');
const startBtn = document.getElementById('startBtn');
if (showAllAgentsMode) {
// Keep dropdown enabled to select which agent to control
// Keep start button enabled to start/stop on selected agent
// (agentSelect and startBtn stay enabled)
// Connect to multi-agent stream (passive listening to all agents)
startAllAgentsStream();
const statusEl = document.getElementById('trackingStatus');
statusEl.textContent = 'ALL AGENTS';
statusEl.title = 'Receiving data from all connected agents';
document.getElementById('trackingDot').classList.remove('inactive');
// Update source indicator to show ALL
const sourceEl = document.getElementById('stripSource');
if (sourceEl) {
sourceEl.textContent = 'ALL';
sourceEl.style.color = 'var(--accent-yellow, #ffcc00)';
sourceEl.title = 'Receiving from all connected agents';
}
console.log('Show All Agents mode enabled');
} else {
// When unchecking "All", dropdown follows normal tracking rules
// (disabled only if tracking is active on a specific agent)
if (agentSelect) agentSelect.disabled = isTracking && !showAllAgentsMode;
if (startBtn) startBtn.disabled = false;
// Stop multi-agent stream
stopAllAgentsStream();
// Restore proper tracking status and source indicator
updateTrackingStatusDisplay();
updateSourceIndicator();
if (!isTracking) {
document.getElementById('trackingDot').classList.add('inactive');
}
console.log('Show All Agents mode disabled');
}
}
function startAllAgentsStream() {
if (allAgentsEventSource) allAgentsEventSource.close();
allAgentsEventSource = new EventSource('/controller/stream/all');
allAgentsEventSource.onmessage = function(e) {
try {
const data = JSON.parse(e.data);
if (data.type === 'keepalive') return;
// Handle ADS-B data from any agent
if (data.scan_type === 'adsb' && data.payload) {
const payload = data.payload;
if (payload.aircraft) {
const aircraftList = Array.isArray(payload.aircraft)
? payload.aircraft
: Object.values(payload.aircraft);
aircraftList.forEach(ac => {
ac._agent = data.agent_name;
updateAircraft({ type: 'aircraft', ...ac });
});
}
}
} catch (err) {
console.error('All agents stream parse error:', err);
}
};
allAgentsEventSource.onerror = function() {
console.error('All agents stream error');
setTimeout(() => {
if (showAllAgentsMode) startAllAgentsStream();
}, 3000);
};
}
function stopAllAgentsStream() {
if (allAgentsEventSource) {
allAgentsEventSource.close();
allAgentsEventSource = null;
}
}
</script>
</body>
</html>