mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
The airband start function was calling parseInt() directly on composite device selector values like "rtlsdr:0", which always returned NaN and fell back to device 0. This also meant sdr_type was never sent to the backend, and could result in int(None) TypeError on the server. Now properly splits the composite value (matching ADS-B/ACARS/VDL2 pattern) and sends both device index and sdr_type. Also hardened backend int() parsing to use explicit None checks. Fixes: "Airband Error: Invalid parameter: int() argument must be a string, a bytes-like object or a real number, not 'NoneType'" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5921 lines
271 KiB
HTML
5921 lines
271 KiB
HTML
<!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>
|
||
<!-- Fonts - Conditional CDN/Local loading -->
|
||
{% if offline_settings.fonts_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
||
{% else %}
|
||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||
{% endif %}
|
||
<!-- Leaflet.js - Conditional CDN/Local loading -->
|
||
{% if offline_settings.assets_source == 'local' %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||
{% else %}
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
{% endif %}
|
||
<!-- Core CSS variables -->
|
||
<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/global-nav.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') }}">
|
||
<script>
|
||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
|
||
</script>
|
||
<script 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>// INTERCEPT - 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);">⚠</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;">
|
||
▶ 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">◀</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">✈</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">50nm</option>
|
||
<option value="100">100nm</option>
|
||
<option value="200" selected>200nm</option>
|
||
<option value="300">300nm</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Location Group -->
|
||
<div class="control-group">
|
||
<span class="control-group-label">LOCATION</span>
|
||
<div class="control-group-items">
|
||
<input type="text" id="obsLat" value="51.5074" onchange="updateObserverLoc()" style="width: 70px;" title="Latitude" placeholder="Lat">
|
||
<input type="text" id="obsLon" value="-0.1278" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ADS-B Tracking Group -->
|
||
<div class="control-group tracking-group">
|
||
<span class="control-group-label">ADS-B TRACKING</span>
|
||
<div class="control-group-items">
|
||
<label title="Use remote dump1090"><input type="checkbox" id="useRemoteDump1090" onchange="toggleRemoteDump1090()"> Remote</label>
|
||
<span class="remote-dump1090-controls" style="display: none;">
|
||
<input type="text" id="remoteSbsHost" placeholder="Host" style="width: 70px;">
|
||
<input type="number" id="remoteSbsPort" value="30003" 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 = 200; // nautical miles
|
||
|
||
// Statistics
|
||
let stats = {
|
||
totalAircraftSeen: new Set(),
|
||
maxRange: 0,
|
||
messagesPerSecond: 0,
|
||
messageTimestamps: [],
|
||
countriesSeen: new Set(),
|
||
highestAltitude: 0,
|
||
fastestSpeed: 0,
|
||
closestDistance: Infinity,
|
||
sessionStart: null,
|
||
acarsMessages: 0
|
||
};
|
||
|
||
// Session log for report generation
|
||
let sessionLog = {
|
||
startTime: null,
|
||
endTime: null,
|
||
aircraftLog: [], // Array of all aircraft seen with details
|
||
highlights: [], // Notable events (military, emergency, etc)
|
||
maxConcurrent: 0,
|
||
peakMsgRate: 0
|
||
};
|
||
|
||
// ICAO Country Allocations (first hex digit ranges)
|
||
const ICAO_COUNTRY_RANGES = [
|
||
{ start: 0x000000, end: 0x003FFF, country: 'Zimbabwe' },
|
||
{ start: 0x004000, end: 0x0043FF, country: 'Mozambique' },
|
||
{ start: 0x006000, end: 0x006FFF, country: 'South Africa' },
|
||
{ start: 0x008000, end: 0x00FFFF, country: 'South Africa' },
|
||
{ start: 0x010000, end: 0x017FFF, country: 'Egypt' },
|
||
{ start: 0x018000, end: 0x01FFFF, country: 'Libya' },
|
||
{ start: 0x020000, end: 0x027FFF, country: 'Morocco' },
|
||
{ start: 0x028000, end: 0x02FFFF, country: 'Tunisia' },
|
||
{ start: 0x030000, end: 0x0303FF, country: 'Botswana' },
|
||
{ start: 0x032000, end: 0x032FFF, country: 'Burundi' },
|
||
{ start: 0x034000, end: 0x034FFF, country: 'Cameroon' },
|
||
{ start: 0x038000, end: 0x038FFF, country: 'Congo' },
|
||
{ start: 0x03E000, end: 0x03EFFF, country: 'Gabon' },
|
||
{ start: 0x040000, end: 0x040FFF, country: 'Ethiopia' },
|
||
{ start: 0x042000, end: 0x042FFF, country: 'Equatorial Guinea' },
|
||
{ start: 0x044000, end: 0x044FFF, country: 'Ghana' },
|
||
{ start: 0x048000, end: 0x0483FF, country: 'Tanzania' },
|
||
{ start: 0x050000, end: 0x050FFF, country: 'Kenya' },
|
||
{ start: 0x054000, end: 0x054FFF, country: 'Zambia' },
|
||
{ start: 0x058000, end: 0x058FFF, country: 'Seychelles' },
|
||
{ start: 0x060000, end: 0x061FFF, country: 'Algeria' },
|
||
{ start: 0x068000, end: 0x068FFF, country: 'Angola' },
|
||
{ start: 0x070000, end: 0x070FFF, country: 'Ivory Coast' },
|
||
{ start: 0x078000, end: 0x078FFF, country: 'Mauritius' },
|
||
{ start: 0x080000, end: 0x080FFF, country: 'Nigeria' },
|
||
{ start: 0x088000, end: 0x088FFF, country: 'Uganda' },
|
||
{ start: 0x090000, end: 0x090FFF, country: 'Qatar' },
|
||
{ start: 0x0A0000, end: 0x0A7FFF, country: 'India' },
|
||
{ start: 0x0C0000, end: 0x0C4FFF, country: 'Australia' },
|
||
{ start: 0x100000, end: 0x1FFFFF, country: 'Russia' },
|
||
{ start: 0x200000, end: 0x27FFFF, country: 'USA' },
|
||
{ start: 0x280000, end: 0x28FFFF, country: 'USA' },
|
||
{ start: 0x300000, end: 0x33FFFF, country: 'Italy' },
|
||
{ start: 0x340000, end: 0x37FFFF, country: 'Spain' },
|
||
{ start: 0x380000, end: 0x3BFFFF, country: 'France' },
|
||
{ start: 0x3C0000, end: 0x3FFFFF, country: 'Germany' },
|
||
{ start: 0x400000, end: 0x43FFFF, country: 'UK' },
|
||
{ start: 0x440000, end: 0x447FFF, country: 'Austria' },
|
||
{ start: 0x448000, end: 0x44FFFF, country: 'Belgium' },
|
||
{ start: 0x450000, end: 0x457FFF, country: 'Bulgaria' },
|
||
{ start: 0x458000, end: 0x45FFFF, country: 'Denmark' },
|
||
{ start: 0x460000, end: 0x467FFF, country: 'Finland' },
|
||
{ start: 0x468000, end: 0x46FFFF, country: 'Greece' },
|
||
{ start: 0x470000, end: 0x477FFF, country: 'Hungary' },
|
||
{ start: 0x478000, end: 0x47FFFF, country: 'Norway' },
|
||
{ start: 0x480000, end: 0x487FFF, country: 'Netherlands' },
|
||
{ start: 0x488000, end: 0x48FFFF, country: 'Poland' },
|
||
{ start: 0x490000, end: 0x497FFF, country: 'Portugal' },
|
||
{ start: 0x498000, end: 0x49FFFF, country: 'Czech Republic' },
|
||
{ start: 0x4A0000, end: 0x4A7FFF, country: 'Romania' },
|
||
{ start: 0x4A8000, end: 0x4AFFFF, country: 'Sweden' },
|
||
{ start: 0x4B0000, end: 0x4B7FFF, country: 'Switzerland' },
|
||
{ start: 0x4B8000, end: 0x4BFFFF, country: 'Turkey' },
|
||
{ start: 0x4C0000, end: 0x4C7FFF, country: 'Serbia' },
|
||
{ start: 0x4CA000, end: 0x4CAFFF, country: 'Ireland' },
|
||
{ start: 0x4D0000, end: 0x4D03FF, country: 'Iceland' },
|
||
{ start: 0x500000, end: 0x5003FF, country: 'Luxembourg' },
|
||
{ start: 0x501000, end: 0x5013FF, country: 'Monaco' },
|
||
{ start: 0x502000, end: 0x502FFF, country: 'Malta' },
|
||
{ start: 0x503000, end: 0x5033FF, country: 'San Marino' },
|
||
{ start: 0x505000, end: 0x5057FF, country: 'Latvia' },
|
||
{ start: 0x506000, end: 0x5067FF, country: 'Lithuania' },
|
||
{ start: 0x507000, end: 0x5077FF, country: 'Moldova' },
|
||
{ start: 0x508000, end: 0x50FFFF, country: 'Slovakia' },
|
||
{ start: 0x510000, end: 0x5107FF, country: 'Slovenia' },
|
||
{ start: 0x511000, end: 0x5117FF, country: 'Uzbekistan' },
|
||
{ start: 0x512000, end: 0x5127FF, country: 'Ukraine' },
|
||
{ start: 0x513000, end: 0x5137FF, country: 'Belarus' },
|
||
{ start: 0x514000, end: 0x5147FF, country: 'Estonia' },
|
||
{ start: 0x515000, end: 0x5157FF, country: 'Macedonia' },
|
||
{ start: 0x516000, end: 0x5167FF, country: 'Bosnia' },
|
||
{ start: 0x517000, end: 0x5177FF, country: 'Georgia' },
|
||
{ start: 0x518000, end: 0x5187FF, country: 'Tajikistan' },
|
||
{ start: 0x600000, end: 0x6003FF, country: 'Armenia' },
|
||
{ start: 0x680000, end: 0x6803FF, country: 'Kyrgyzstan' },
|
||
{ start: 0x681000, end: 0x6813FF, country: 'Turkmenistan' },
|
||
{ start: 0x682000, end: 0x6823FF, country: 'Azerbaijan' },
|
||
{ start: 0x683000, end: 0x6833FF, country: 'Kazakhstan' },
|
||
{ start: 0x700000, end: 0x700FFF, country: 'Afghanistan' },
|
||
{ start: 0x702000, end: 0x702FFF, country: 'Bangladesh' },
|
||
{ start: 0x704000, end: 0x704FFF, country: 'Maldives' },
|
||
{ start: 0x706000, end: 0x706FFF, country: 'Nepal' },
|
||
{ start: 0x708000, end: 0x708FFF, country: 'Pakistan' },
|
||
{ start: 0x70A000, end: 0x70AFFF, country: 'Sri Lanka' },
|
||
{ start: 0x70C000, end: 0x70C3FF, country: 'Myanmar' },
|
||
{ start: 0x710000, end: 0x717FFF, country: 'Japan' },
|
||
{ start: 0x718000, end: 0x71FFFF, country: 'Japan' },
|
||
{ start: 0x720000, end: 0x727FFF, country: 'Laos' },
|
||
{ start: 0x728000, end: 0x72FFFF, country: 'Mongolia' },
|
||
{ start: 0x730000, end: 0x737FFF, country: 'Nepal' },
|
||
{ start: 0x738000, end: 0x73FFFF, country: 'South Korea' },
|
||
{ start: 0x740000, end: 0x747FFF, country: 'Indonesia' },
|
||
{ start: 0x748000, end: 0x74FFFF, country: 'Malaysia' },
|
||
{ start: 0x750000, end: 0x757FFF, country: 'Philippines' },
|
||
{ start: 0x758000, end: 0x75FFFF, country: 'Singapore' },
|
||
{ start: 0x760000, end: 0x767FFF, country: 'Thailand' },
|
||
{ start: 0x768000, end: 0x76FFFF, country: 'Vietnam' },
|
||
{ start: 0x780000, end: 0x7BFFFF, country: 'China' },
|
||
{ start: 0x7C0000, end: 0x7FFFFF, country: 'Australia' },
|
||
{ start: 0x800000, end: 0x83FFFF, country: 'India' },
|
||
{ start: 0x840000, end: 0x87FFFF, country: 'Japan' },
|
||
{ start: 0x880000, end: 0x887FFF, country: 'Pakistan' },
|
||
{ start: 0x890000, end: 0x890FFF, country: 'Hong Kong' },
|
||
{ start: 0x894000, end: 0x894FFF, country: 'Taiwan' },
|
||
{ start: 0x895000, end: 0x8953FF, country: 'North Korea' },
|
||
{ start: 0x896000, end: 0x896FFF, country: 'Jordan' },
|
||
{ start: 0x897000, end: 0x897FFF, country: 'Lebanon' },
|
||
{ start: 0x898000, end: 0x898FFF, country: 'Kuwait' },
|
||
{ start: 0x899000, end: 0x8993FF, country: 'Saudi Arabia' },
|
||
{ start: 0x8A0000, end: 0x8A7FFF, country: 'Saudi Arabia' },
|
||
{ start: 0x900000, end: 0x9003FF, country: 'Kuwait' },
|
||
{ start: 0x901000, end: 0x9013FF, country: 'Bahrain' },
|
||
{ start: 0x902000, end: 0x9023FF, country: 'Yemen' },
|
||
{ start: 0x903000, end: 0x9033FF, country: 'Syria' },
|
||
{ start: 0xA00000, end: 0xAFFFFF, country: 'USA' },
|
||
{ start: 0xC00000, end: 0xC3FFFF, country: 'Canada' },
|
||
{ start: 0xC80000, end: 0xC87FFF, country: 'New Zealand' },
|
||
{ start: 0xE00000, end: 0xE3FFFF, country: 'Argentina' },
|
||
{ start: 0xE40000, end: 0xE7FFFF, country: 'Brazil' },
|
||
{ start: 0xE80000, end: 0xE80FFF, country: 'Chile' },
|
||
{ start: 0xE84000, end: 0xE84FFF, country: 'Colombia' },
|
||
{ start: 0xE88000, end: 0xE88FFF, country: 'Peru' },
|
||
{ start: 0xE8C000, end: 0xE8CFFF, country: 'Venezuela' },
|
||
{ start: 0xF00000, end: 0xF07FFF, country: 'ICAO (special)' }
|
||
];
|
||
|
||
function getCountryFromIcao(icao) {
|
||
const icaoNum = parseInt(icao, 16);
|
||
for (const range of ICAO_COUNTRY_RANGES) {
|
||
if (icaoNum >= range.start && icaoNum <= range.end) {
|
||
return range.country;
|
||
}
|
||
}
|
||
return 'Unknown';
|
||
}
|
||
|
||
// Observer location and range rings (load from localStorage or default to London)
|
||
let observerLocation = (function() {
|
||
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) {}
|
||
}
|
||
return { lat: 51.5074, lon: -0.1278 };
|
||
})();
|
||
let rangeRingsLayer = null;
|
||
let observerMarker = null;
|
||
|
||
// GPS state
|
||
let gpsConnected = false;
|
||
let gpsEventSource = null;
|
||
|
||
// ============================================
|
||
// AUDIO ALERTS
|
||
// ============================================
|
||
let audioContext = null;
|
||
function getAudioContext() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
return audioContext;
|
||
}
|
||
|
||
function playAlertSound(type) {
|
||
if (!alertsEnabled) return;
|
||
try {
|
||
const ctx = getAudioContext();
|
||
const oscillator = ctx.createOscillator();
|
||
const gainNode = ctx.createGain();
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(ctx.destination);
|
||
|
||
if (type === 'emergency') {
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.5);
|
||
} else if (type === 'military') {
|
||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.3);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Audio alert failed:', e);
|
||
}
|
||
}
|
||
|
||
function 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">×</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
|
||
// ============================================
|
||
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';
|
||
}
|
||
|
||
async function initMap() {
|
||
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;
|
||
if (typeof Settings !== 'undefined') {
|
||
// Wait for settings to load from server before applying tiles
|
||
await Settings.init();
|
||
Settings.createTileLayer().addTo(radarMap);
|
||
Settings.registerMap(radarMap);
|
||
} else {
|
||
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||
maxZoom: 19,
|
||
subdomains: 'abcd',
|
||
className: 'tile-layer-cyan'
|
||
}).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);
|
||
}
|
||
|
||
// 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 (!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">✈</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;">✕</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 = confirm(
|
||
`Warning: ADS-B tracking is using SDR ${adsbActiveDevice}.\n\n` +
|
||
'Using the same device for airband will stop ADS-B tracking.\n\n' +
|
||
'Select a different SDR device for airband listening, or click OK to stop tracking and listen.'
|
||
);
|
||
if (!useAnyway) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
document.getElementById('airbandStatus').textContent = 'STARTING...';
|
||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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 = confirm(
|
||
`Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\n\n` +
|
||
'ACARS uses VHF frequencies (129-131 MHz) while ADS-B uses 1090 MHz.\n' +
|
||
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
|
||
'Click OK to start ACARS on device ' + device + ' anyway.'
|
||
);
|
||
if (!useAnyway) return;
|
||
}
|
||
|
||
// 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">✈</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();
|
||
}
|
||
}
|
||
|
||
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 = confirm(
|
||
`Warning: ADS-B tracking is using SDR device ${adsbActiveDevice}.\n\n` +
|
||
'VDL2 uses VHF frequencies (~137 MHz) while ADS-B uses 1090 MHz.\n' +
|
||
'You need TWO separate SDR devices to receive both simultaneously.\n\n' +
|
||
'Click OK to start VDL2 on device ' + device + ' anyway.'
|
||
);
|
||
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 = '■ 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 = '▶ 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 = '■ STOP VDL2';
|
||
btn.classList.add('active');
|
||
if (indicator) indicator.classList.add('active');
|
||
if (!vdl2EventSource && !vdl2PollTimer) {
|
||
startVdl2Stream(agentId !== null);
|
||
}
|
||
} else {
|
||
vdl2CurrentAgent = null;
|
||
btn.innerHTML = '▶ 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">▸ 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">×</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 ? '▾ Hide raw JSON' : '▸ 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">✈</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 = '■ 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()">×</button>
|
||
</div>
|
||
<div id="squawkCodeInfo"></div>
|
||
<div class="squawk-ref-container">
|
||
<table class="squawk-ref-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Code</th>
|
||
<th>Name</th>
|
||
<th>Description</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="squawkRefTable"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Watchlist Modal -->
|
||
<div id="watchlistModal" class="watchlist-modal">
|
||
<div class="watchlist-modal-content">
|
||
<div class="watchlist-modal-header">
|
||
<span>★ WATCHLIST</span>
|
||
<button class="watchlist-modal-close" onclick="closeWatchlistModal()">×</button>
|
||
</div>
|
||
<div class="watchlist-add-form">
|
||
<input type="text" id="watchlistInput" placeholder="Callsign, registration, or ICAO..." onkeypress="if(event.key==='Enter')handleWatchlistAdd()">
|
||
<select id="watchlistType">
|
||
<option value="any">Any match</option>
|
||
<option value="callsign">Callsign</option>
|
||
<option value="registration">Registration</option>
|
||
<option value="icao">ICAO Hex</option>
|
||
</select>
|
||
<input type="text" id="watchlistNote" placeholder="Note (optional)" style="flex:1;">
|
||
<button onclick="handleWatchlistAdd()">ADD</button>
|
||
</div>
|
||
<div class="watchlist-entries" id="watchlistEntries">
|
||
<div class="watchlist-empty">No entries. Add callsigns, registrations, or ICAO codes to watch.</div>
|
||
</div>
|
||
<div class="watchlist-footer">
|
||
<span class="watchlist-count"><span id="watchlistCount">0</span> entries</span>
|
||
<span class="watchlist-hint">Alerts trigger when watched aircraft appear</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 — 1090 MHz ADS-B</span>
|
||
<button class="antenna-guide-modal-close" onclick="toggleAntennaGuide()">×</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 — 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–7 dBi omnidirectional, ideal for 360° coverage</li>
|
||
<li><strong>Range:</strong> 150–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 & 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–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>
|