mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 17:11:56 -07:00
831426948f
When navigating away from the dashboard and back, the page reloads with no knowledge of running decoders. Add status checks on page load to sync UI state and reconnect SSE streams. Also add auto-reconnect on SSE error with guard conditions to prevent loops when intentionally stopped. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
5296 lines
238 KiB
HTML
5296 lines
238 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
|
||
<!-- 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') }}">
|
||
<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>
|
||
|
||
{% set active_mode = 'adsb' %}
|
||
{% include 'partials/nav.html' with context %}
|
||
|
||
<!-- 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>
|
||
<button class="acars-btn" id="acarsToggleBtn" onclick="toggleAcars()" style="width: 100%;">
|
||
▶ START ACARS
|
||
</button>
|
||
</div>
|
||
<div class="acars-messages" id="acarsMessages">
|
||
<div class="no-aircraft" style="padding: 20px; text-align: center;">
|
||
<div style="font-size: 10px; color: var(--text-muted);">No ACARS messages</div>
|
||
<div style="font-size: 9px; color: var(--text-dim); margin-top: 5px;">Start ACARS to receive aircraft datalink messages</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="acars-collapse-btn" id="acarsCollapseBtn" onclick="toggleAcarsSidebar()" title="Toggle ACARS Panel">
|
||
<span id="acarsCollapseIcon">◀</span>
|
||
<span class="acars-collapse-label">ACARS</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
<button class="vdl2-btn" id="vdl2ToggleBtn" onclick="toggleVdl2()" style="width: 100%;">
|
||
▶ START VDL2
|
||
</button>
|
||
</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>
|
||
</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';
|
||
let alertedAircraft = {};
|
||
let alertsEnabled = true;
|
||
let detectionSoundEnabled = localStorage.getItem('adsb_detectionSound') !== 'false'; // Default on
|
||
let soundedAircraft = {}; // Track aircraft we've played detection sound for
|
||
// 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 && parsed.lon) return parsed;
|
||
} catch (e) {}
|
||
}
|
||
return { lat: 51.5074, lon: -0.1278 };
|
||
})();
|
||
let rangeRingsLayer = null;
|
||
let observerMarker = null;
|
||
|
||
// GPS state
|
||
let gpsConnected = false;
|
||
let gpsEventSource = null;
|
||
|
||
// ============================================
|
||
// AUDIO ALERTS
|
||
// ============================================
|
||
let audioContext = null;
|
||
function getAudioContext() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||
}
|
||
return audioContext;
|
||
}
|
||
|
||
function playAlertSound(type) {
|
||
if (!alertsEnabled) return;
|
||
try {
|
||
const ctx = getAudioContext();
|
||
const oscillator = ctx.createOscillator();
|
||
const gainNode = ctx.createGain();
|
||
oscillator.connect(gainNode);
|
||
gainNode.connect(ctx.destination);
|
||
|
||
if (type === 'emergency') {
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime);
|
||
oscillator.frequency.setValueAtTime(660, ctx.currentTime + 0.15);
|
||
oscillator.frequency.setValueAtTime(880, ctx.currentTime + 0.3);
|
||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.5);
|
||
} else if (type === 'military') {
|
||
oscillator.frequency.setValueAtTime(523, ctx.currentTime);
|
||
gainNode.gain.setValueAtTime(0.2, ctx.currentTime);
|
||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.3);
|
||
oscillator.start(ctx.currentTime);
|
||
oscillator.stop(ctx.currentTime + 0.3);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Audio alert failed:', e);
|
||
}
|
||
}
|
||
|
||
function checkAndAlertAircraft(icao, ac) {
|
||
if (alertedAircraft[icao]) return;
|
||
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
|
||
const squawkInfo = checkSquawkCode(ac);
|
||
const onWatchlist = isOnWatchlist(ac);
|
||
|
||
if (squawkInfo && squawkInfo.type === 'emergency') {
|
||
alertedAircraft[icao] = 'emergency';
|
||
playAlertSound('emergency');
|
||
showAlertBanner(`EMERGENCY: ${squawkInfo.name} - ${ac.callsign || icao}`, '#ff0000');
|
||
} else if (onWatchlist) {
|
||
alertedAircraft[icao] = 'watchlist';
|
||
playAlertSound('military'); // Use military sound for watchlist
|
||
showAlertBanner(`WATCHLIST: ${ac.callsign || ac.registration || icao} detected!`, '#00d4ff');
|
||
} else if (militaryInfo.military) {
|
||
alertedAircraft[icao] = 'military';
|
||
playAlertSound('military');
|
||
showAlertBanner(`MILITARY: ${ac.callsign || icao}${militaryInfo.country ? ' (' + militaryInfo.country + ')' : ''}`, '#556b2f');
|
||
}
|
||
}
|
||
|
||
function showAlertBanner(message, color) {
|
||
const banner = document.createElement('div');
|
||
banner.style.cssText = `
|
||
position: fixed; top: 70px; left: 50%; transform: translateX(-50%);
|
||
background: ${color}; color: white; padding: 10px 20px; border-radius: 6px;
|
||
font-weight: bold; font-size: 13px; z-index: 10000;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.5); animation: slideDown 0.3s ease-out;
|
||
`;
|
||
banner.textContent = message;
|
||
document.body.appendChild(banner);
|
||
setTimeout(() => {
|
||
banner.style.opacity = '0';
|
||
banner.style.transition = 'opacity 0.3s';
|
||
setTimeout(() => banner.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
|
||
function toggleAlerts() {
|
||
alertsEnabled = document.getElementById('alertToggle').checked;
|
||
}
|
||
|
||
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 && ac.lon) {
|
||
const distance = calculateDistanceNm(
|
||
observerLocation.lat, observerLocation.lon,
|
||
ac.lat, ac.lon
|
||
);
|
||
|
||
if (distance > stats.maxRange) {
|
||
stats.maxRange = distance;
|
||
}
|
||
}
|
||
|
||
// Message rate
|
||
const now = Date.now();
|
||
stats.messageTimestamps.push(now);
|
||
stats.messageTimestamps = stats.messageTimestamps.filter(t => now - t < 5000);
|
||
stats.messagesPerSecond = stats.messageTimestamps.length / 5;
|
||
|
||
// Track peak message rate
|
||
if (stats.messagesPerSecond > sessionLog.peakMsgRate) {
|
||
sessionLog.peakMsgRate = stats.messagesPerSecond;
|
||
}
|
||
|
||
updateStatsDisplay();
|
||
}
|
||
|
||
// Signal quality tracking
|
||
let signalStats = {
|
||
goodMessages: 0,
|
||
errorMessages: 0
|
||
};
|
||
|
||
function updateStatsDisplay() {
|
||
const aircraftCount = Object.keys(aircraft).length;
|
||
|
||
// Track max concurrent
|
||
if (aircraftCount > sessionLog.maxConcurrent) {
|
||
sessionLog.maxConcurrent = aircraftCount;
|
||
}
|
||
|
||
// Calculate live stats from current aircraft
|
||
let highest = 0, fastest = 0, closest = Infinity;
|
||
let highestIcao = '', fastestIcao = '', closestIcao = '';
|
||
|
||
Object.entries(aircraft).forEach(([icao, ac]) => {
|
||
if (ac.altitude && ac.altitude > highest) {
|
||
highest = ac.altitude;
|
||
highestIcao = icao;
|
||
}
|
||
if (ac.speed && ac.speed > fastest) {
|
||
fastest = ac.speed;
|
||
fastestIcao = icao;
|
||
}
|
||
if (ac.lat && ac.lon) {
|
||
const dist = calculateDistanceNm(
|
||
observerLocation.lat, observerLocation.lon,
|
||
ac.lat, ac.lon
|
||
);
|
||
if (dist < closest) {
|
||
closest = dist;
|
||
closestIcao = icao;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Update strip stats
|
||
document.getElementById('stripAircraftNow').textContent = aircraftCount;
|
||
document.getElementById('stripTotalSeen').textContent = stats.totalAircraftSeen.size;
|
||
document.getElementById('stripMaxRange').textContent = stats.maxRange.toFixed(0);
|
||
document.getElementById('stripHighest').textContent = highest > 0 ? Math.round(highest / 100) : '-';
|
||
document.getElementById('stripFastest').textContent = fastest > 0 ? Math.round(fastest) : '-';
|
||
document.getElementById('stripClosest').textContent = closest < Infinity ? closest.toFixed(1) : '-';
|
||
document.getElementById('stripCountries').textContent = stats.countriesSeen.size;
|
||
document.getElementById('stripAcars').textContent = stats.acarsMessages;
|
||
|
||
// Update signal quality
|
||
updateSignalQuality();
|
||
}
|
||
|
||
// Session timer
|
||
let sessionTimerInterval = null;
|
||
function startSessionTimer() {
|
||
if (!stats.sessionStart) {
|
||
stats.sessionStart = Date.now();
|
||
sessionLog.startTime = new Date().toISOString();
|
||
}
|
||
if (sessionTimerInterval) clearInterval(sessionTimerInterval);
|
||
sessionTimerInterval = setInterval(updateSessionTimer, 1000);
|
||
}
|
||
|
||
function stopSessionTimer() {
|
||
sessionLog.endTime = new Date().toISOString();
|
||
}
|
||
|
||
function updateSessionTimer() {
|
||
if (!stats.sessionStart) return;
|
||
const elapsed = Date.now() - stats.sessionStart;
|
||
const hours = Math.floor(elapsed / 3600000);
|
||
const mins = Math.floor((elapsed % 3600000) / 60000);
|
||
const secs = Math.floor((elapsed % 60000) / 1000);
|
||
document.getElementById('stripSession').textContent =
|
||
`${hours.toString().padStart(2,'0')}:${mins.toString().padStart(2,'0')}:${secs.toString().padStart(2,'0')}`;
|
||
}
|
||
|
||
// Report generation
|
||
function generateReport() {
|
||
stopSessionTimer();
|
||
|
||
const report = {
|
||
title: 'ADS-B Session Report',
|
||
generated: new Date().toISOString(),
|
||
session: {
|
||
start: sessionLog.startTime,
|
||
end: sessionLog.endTime || new Date().toISOString(),
|
||
duration: stats.sessionStart ? formatDuration(Date.now() - stats.sessionStart) : 'N/A'
|
||
},
|
||
location: {
|
||
lat: observerLocation.lat,
|
||
lon: observerLocation.lon
|
||
},
|
||
statistics: {
|
||
totalAircraftSeen: stats.totalAircraftSeen.size,
|
||
maxConcurrent: sessionLog.maxConcurrent,
|
||
maxRange: stats.maxRange.toFixed(1) + ' nm',
|
||
peakMessageRate: sessionLog.peakMsgRate.toFixed(1) + ' msg/s',
|
||
countriesSeen: Array.from(stats.countriesSeen).sort(),
|
||
acarsMessages: stats.acarsMessages
|
||
},
|
||
highlights: sessionLog.highlights,
|
||
aircraftLog: sessionLog.aircraftLog
|
||
};
|
||
|
||
// Show report modal
|
||
showReportModal(report);
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
const hours = Math.floor(ms / 3600000);
|
||
const mins = Math.floor((ms % 3600000) / 60000);
|
||
const secs = Math.floor((ms % 60000) / 1000);
|
||
return `${hours}h ${mins}m ${secs}s`;
|
||
}
|
||
|
||
function showReportModal(report) {
|
||
const modal = document.createElement('div');
|
||
modal.className = 'report-modal';
|
||
modal.innerHTML = `
|
||
<div class="report-content">
|
||
<div class="report-header">
|
||
<h2>📊 Session Report</h2>
|
||
<button class="report-close" onclick="this.closest('.report-modal').remove()">×</button>
|
||
</div>
|
||
<div class="report-body">
|
||
<div class="report-section">
|
||
<h3>Session Info</h3>
|
||
<div class="report-grid">
|
||
<span>Duration:</span><span>${report.session.duration}</span>
|
||
<span>Location:</span><span>${report.location.lat.toFixed(4)}, ${report.location.lon.toFixed(4)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="report-section">
|
||
<h3>Statistics</h3>
|
||
<div class="report-grid">
|
||
<span>Total Aircraft:</span><span>${report.statistics.totalAircraftSeen}</span>
|
||
<span>Max Concurrent:</span><span>${report.statistics.maxConcurrent}</span>
|
||
<span>Max Range:</span><span>${report.statistics.maxRange}</span>
|
||
<span>Peak Msg Rate:</span><span>${report.statistics.peakMessageRate}</span>
|
||
<span>Countries:</span><span>${report.statistics.countriesSeen.length} (${report.statistics.countriesSeen.slice(0,5).join(', ')}${report.statistics.countriesSeen.length > 5 ? '...' : ''})</span>
|
||
<span>ACARS Messages:</span><span>${report.statistics.acarsMessages}</span>
|
||
</div>
|
||
</div>
|
||
${report.highlights.length > 0 ? `
|
||
<div class="report-section">
|
||
<h3>Highlights</h3>
|
||
<div class="report-highlights">
|
||
${report.highlights.slice(0, 10).map(h => `
|
||
<div class="highlight-item ${h.type}">
|
||
<span class="highlight-type">${h.type.toUpperCase()}</span>
|
||
<span class="highlight-detail">${h.callsign}${h.country ? ' (' + h.country + ')' : ''}${h.name ? ' - ' + h.name : ''}</span>
|
||
</div>
|
||
`).join('')}
|
||
${report.highlights.length > 10 ? `<div class="highlight-more">+${report.highlights.length - 10} more...</div>` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="report-section">
|
||
<h3>Aircraft Log (${report.aircraftLog.length})</h3>
|
||
<div class="report-table-wrap">
|
||
<table class="report-table">
|
||
<thead>
|
||
<tr><th>ICAO</th><th>Callsign</th><th>Country</th><th>Type</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
${report.aircraftLog.slice(0, 50).map(ac => `
|
||
<tr class="${ac.military ? 'military' : ''}">
|
||
<td>${ac.icao}</td>
|
||
<td>${ac.callsign || '-'}</td>
|
||
<td>${ac.country}</td>
|
||
<td>${ac.military ? '🎖️ MIL' : 'CIV'}</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
${report.aircraftLog.length > 50 ? `<div class="report-more">Showing 50 of ${report.aircraftLog.length} aircraft</div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="report-footer">
|
||
<button class="report-btn" onclick="downloadReport()">💾 Download JSON</button>
|
||
<button class="report-btn" onclick="copyReportToClipboard()">📋 Copy Summary</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
// Store report for download
|
||
window._currentReport = report;
|
||
}
|
||
|
||
function downloadReport() {
|
||
if (!window._currentReport) return;
|
||
const blob = new Blob([JSON.stringify(window._currentReport, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `adsb-report-${new Date().toISOString().slice(0,10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function copyReportToClipboard() {
|
||
if (!window._currentReport) return;
|
||
const r = window._currentReport;
|
||
const summary = `ADS-B Session Report
|
||
Duration: ${r.session.duration}
|
||
Aircraft Seen: ${r.statistics.totalAircraftSeen}
|
||
Max Concurrent: ${r.statistics.maxConcurrent}
|
||
Max Range: ${r.statistics.maxRange}
|
||
Countries: ${r.statistics.countriesSeen.join(', ')}
|
||
Highlights: ${r.highlights.length} events
|
||
ACARS: ${r.statistics.acarsMessages} messages`;
|
||
navigator.clipboard.writeText(summary).then(() => {
|
||
alert('Summary copied to clipboard');
|
||
});
|
||
}
|
||
|
||
// ============================================
|
||
// SIGNAL QUALITY
|
||
// ============================================
|
||
function updateSignalQuality() {
|
||
const msgRate = stats.messagesPerSecond;
|
||
const el = document.getElementById('stripSignal');
|
||
const stat = el.closest('.strip-stat');
|
||
|
||
if (!isTracking || msgRate === 0) {
|
||
el.textContent = '--';
|
||
stat.classList.remove('good', 'warning', 'poor');
|
||
return;
|
||
}
|
||
|
||
// Signal quality based on message rate
|
||
// Good: >10 msg/s, Warning: 2-10, Poor: <2
|
||
if (msgRate >= 10) {
|
||
el.textContent = '●●●';
|
||
stat.classList.remove('warning', 'poor');
|
||
stat.classList.add('good');
|
||
} else if (msgRate >= 2) {
|
||
el.textContent = '●●○';
|
||
stat.classList.remove('good', 'poor');
|
||
stat.classList.add('warning');
|
||
} else {
|
||
el.textContent = '●○○';
|
||
stat.classList.remove('good', 'warning');
|
||
stat.classList.add('poor');
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// FLIGHT LOOKUP
|
||
// ============================================
|
||
function lookupSelectedFlight() {
|
||
if (!selectedIcao || !aircraft[selectedIcao]) return;
|
||
const ac = aircraft[selectedIcao];
|
||
const callsign = ac.callsign?.trim();
|
||
const reg = ac.registration?.trim();
|
||
|
||
// Prefer callsign, then registration, then ICAO
|
||
let searchTerm = callsign || reg || selectedIcao;
|
||
|
||
// Open FlightAware search
|
||
const url = `https://flightaware.com/live/flight/${searchTerm}`;
|
||
window.open(url, '_blank');
|
||
}
|
||
|
||
function updateFlightLookupBtn() {
|
||
const btn = document.getElementById('flightLookupBtn');
|
||
if (selectedIcao && aircraft[selectedIcao]) {
|
||
btn.disabled = false;
|
||
const ac = aircraft[selectedIcao];
|
||
const label = ac.callsign || ac.registration || selectedIcao;
|
||
btn.title = `Lookup ${label} on FlightAware`;
|
||
} else {
|
||
btn.disabled = true;
|
||
btn.title = 'Select an aircraft first';
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// AIRCRAFT TRAILS
|
||
// ============================================
|
||
function toggleTrails() {
|
||
showTrails = document.getElementById('showTrails').checked;
|
||
if (!showTrails) {
|
||
// Remove all trail lines from map
|
||
Object.keys(trailLines).forEach(icao => {
|
||
if (trailLines[icao]) {
|
||
trailLines[icao].forEach(line => radarMap.removeLayer(line));
|
||
delete trailLines[icao];
|
||
}
|
||
});
|
||
} else {
|
||
// Draw existing trails
|
||
Object.keys(aircraftTrails).forEach(icao => {
|
||
updateTrailLine(icao);
|
||
});
|
||
}
|
||
}
|
||
|
||
function recordTrailPoint(icao, lat, lon, alt) {
|
||
if (!aircraftTrails[icao]) aircraftTrails[icao] = [];
|
||
const trail = aircraftTrails[icao];
|
||
|
||
// Only add if moved significantly
|
||
if (trail.length === 0 ||
|
||
Math.abs(trail[trail.length-1].lat - lat) > 0.0005 ||
|
||
Math.abs(trail[trail.length-1].lon - lon) > 0.0005) {
|
||
trail.push({ lat, lon, alt: alt || 0, time: Date.now() });
|
||
if (trail.length > MAX_TRAIL_POINTS) trail.shift();
|
||
}
|
||
}
|
||
|
||
function getAltitudeColor(alt) {
|
||
if (!alt || alt <= 0) return '#888888';
|
||
if (alt < 10000) return '#00ff88'; // Green - low
|
||
if (alt < 25000) return '#00d4ff'; // Cyan - medium
|
||
if (alt < 35000) return '#ffcc00'; // Yellow - high
|
||
return '#ff9500'; // Orange - very high
|
||
}
|
||
|
||
function updateTrailLine(icao) {
|
||
if (!showTrails || !radarMap) return;
|
||
|
||
const trail = aircraftTrails[icao];
|
||
if (!trail || trail.length < 2) return;
|
||
|
||
// Remove old trail lines
|
||
if (trailLines[icao]) {
|
||
trailLines[icao].forEach(line => radarMap.removeLayer(line));
|
||
}
|
||
trailLines[icao] = [];
|
||
|
||
// Create gradient segments
|
||
const now = Date.now();
|
||
for (let i = 1; i < trail.length; i++) {
|
||
const p1 = trail[i-1];
|
||
const p2 = trail[i];
|
||
const age = (now - p2.time) / 1000; // seconds
|
||
const opacity = Math.max(0.2, 1 - (age / 120)); // Fade over 2 minutes
|
||
|
||
const color = getAltitudeColor(p2.alt);
|
||
const line = L.polyline([[p1.lat, p1.lon], [p2.lat, p2.lon]], {
|
||
color: color,
|
||
weight: 2,
|
||
opacity: opacity
|
||
}).addTo(radarMap);
|
||
trailLines[icao].push(line);
|
||
}
|
||
}
|
||
|
||
function cleanupTrail(icao) {
|
||
if (trailLines[icao]) {
|
||
trailLines[icao].forEach(line => radarMap.removeLayer(line));
|
||
delete trailLines[icao];
|
||
}
|
||
delete aircraftTrails[icao];
|
||
}
|
||
|
||
function updateRange() {
|
||
maxRange = parseInt(document.getElementById('rangeSelect').value);
|
||
drawRangeRings();
|
||
}
|
||
|
||
// ============================================
|
||
// RANGE RINGS (MAP)
|
||
// ============================================
|
||
function drawRangeRings() {
|
||
if (!radarMap) return;
|
||
|
||
if (rangeRingsLayer) {
|
||
radarMap.removeLayer(rangeRingsLayer);
|
||
rangeRingsLayer = null;
|
||
}
|
||
|
||
const showRings = document.getElementById('showRangeRings')?.checked;
|
||
if (!showRings) return;
|
||
|
||
rangeRingsLayer = L.layerGroup();
|
||
|
||
const distances = [maxRange * 0.25, maxRange * 0.5, maxRange * 0.75, maxRange];
|
||
distances.forEach(nm => {
|
||
const meters = nm * 1852;
|
||
const circle = L.circle([observerLocation.lat, observerLocation.lon], {
|
||
radius: meters,
|
||
color: '#00ff88',
|
||
fillColor: 'transparent',
|
||
fillOpacity: 0,
|
||
weight: 1,
|
||
opacity: 0.4,
|
||
dashArray: '5, 5'
|
||
});
|
||
|
||
const labelLat = observerLocation.lat + (nm * 0.0166);
|
||
const label = L.marker([labelLat, observerLocation.lon], {
|
||
icon: L.divIcon({
|
||
className: 'range-label',
|
||
html: `<span style="color: #00ff88; font-size: 10px; background: rgba(0,0,0,0.7); padding: 1px 4px; border-radius: 2px;">${Math.round(nm)} nm</span>`,
|
||
iconSize: [40, 12],
|
||
iconAnchor: [20, 6]
|
||
})
|
||
});
|
||
|
||
rangeRingsLayer.addLayer(circle);
|
||
rangeRingsLayer.addLayer(label);
|
||
});
|
||
|
||
// Observer marker
|
||
if (observerMarker) radarMap.removeLayer(observerMarker);
|
||
observerMarker = L.marker([observerLocation.lat, observerLocation.lon], {
|
||
icon: L.divIcon({
|
||
className: 'observer-marker',
|
||
html: '<div style="width: 12px; height: 12px; background: #ff0; border: 2px solid #000; border-radius: 50%; box-shadow: 0 0 10px #ff0;"></div>',
|
||
iconSize: [12, 12],
|
||
iconAnchor: [6, 6]
|
||
})
|
||
}).bindPopup('Your Location').addTo(radarMap);
|
||
|
||
rangeRingsLayer.addTo(radarMap);
|
||
}
|
||
|
||
function updateObserverLoc() {
|
||
const lat = parseFloat(document.getElementById('obsLat').value);
|
||
const lon = parseFloat(document.getElementById('obsLon').value);
|
||
|
||
if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||
observerLocation.lat = lat;
|
||
observerLocation.lon = lon;
|
||
|
||
// Save to localStorage for persistence
|
||
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 && data.longitude) {
|
||
updateLocationFromGps(data);
|
||
}
|
||
} catch (e) {
|
||
console.error('GPS parse error:', e);
|
||
}
|
||
};
|
||
gpsEventSource.onerror = (e) => {
|
||
// Don't log every error - connection suspends are normal
|
||
if (gpsEventSource) {
|
||
gpsEventSource.close();
|
||
gpsEventSource = null;
|
||
}
|
||
// Auto-reconnect after 5 seconds if still connected
|
||
if (gpsConnected && !gpsReconnectTimeout) {
|
||
gpsReconnectTimeout = setTimeout(() => {
|
||
gpsReconnectTimeout = null;
|
||
if (gpsConnected) {
|
||
startGpsStream();
|
||
}
|
||
}, 5000);
|
||
}
|
||
};
|
||
}
|
||
|
||
// Reconnect GPS stream when tab becomes visible
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden && gpsConnected && !gpsEventSource) {
|
||
startGpsStream();
|
||
}
|
||
});
|
||
|
||
function updateLocationFromGps(position) {
|
||
observerLocation.lat = position.latitude;
|
||
observerLocation.lon = position.longitude;
|
||
document.getElementById('obsLat').value = position.latitude.toFixed(4);
|
||
document.getElementById('obsLon').value = position.longitude.toFixed(4);
|
||
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 && aircraft[icao].lon) {
|
||
pendingMarkerUpdates.add(icao);
|
||
}
|
||
});
|
||
scheduleUIUpdate();
|
||
}
|
||
|
||
function passesFilter(icao, ac) {
|
||
if (currentFilter === 'all') return true;
|
||
const militaryInfo = isMilitaryAircraft(icao, ac.callsign);
|
||
const squawkInfo = checkSquawkCode(ac);
|
||
if (currentFilter === 'military') return militaryInfo.military;
|
||
if (currentFilter === 'civil') return !militaryInfo.military;
|
||
if (currentFilter === 'emergency') return squawkInfo && 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="0">No SDR found</option>';
|
||
airbandSelect.innerHTML = '<option value="0">No SDR found</option>';
|
||
airbandSelect.disabled = true;
|
||
} else {
|
||
devices.forEach((dev, i) => {
|
||
const idx = dev.index !== undefined ? dev.index : i;
|
||
const displayName = `SDR ${idx}: ${dev.name}`;
|
||
|
||
// Add to ADS-B selector
|
||
const adsbOpt = document.createElement('option');
|
||
adsbOpt.value = idx;
|
||
adsbOpt.textContent = displayName;
|
||
adsbSelect.appendChild(adsbOpt);
|
||
|
||
// Add to Airband selector
|
||
const airbandOpt = document.createElement('option');
|
||
airbandOpt.value = idx;
|
||
airbandOpt.textContent = displayName;
|
||
airbandSelect.appendChild(airbandOpt);
|
||
});
|
||
|
||
// Default: ADS-B uses first device, Airband uses second (if available)
|
||
adsbSelect.value = devices[0].index !== undefined ? devices[0].index : 0;
|
||
if (devices.length > 1) {
|
||
airbandSelect.value = devices[1].index !== undefined ? devices[1].index : 1;
|
||
}
|
||
|
||
// Show warning if only one device
|
||
if (devices.length === 1) {
|
||
document.getElementById('airbandStatus').textContent = '1 SDR only';
|
||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||
}
|
||
}
|
||
})
|
||
.catch(() => {
|
||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="0">Error</option>';
|
||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="0">Error</option>';
|
||
});
|
||
}
|
||
|
||
function 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
|
||
const adsbDevice = parseInt(document.getElementById('adsbDeviceSelect').value) || 0;
|
||
|
||
const requestBody = {
|
||
device: adsbDevice,
|
||
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 agent proxy if using remote agent
|
||
const url = useAgent
|
||
? `/controller/agents/${adsbCurrentAgent}/adsb/stop`
|
||
: '/adsb/stop';
|
||
await fetch(url, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({})
|
||
});
|
||
|
||
// Update agent running modes tracking
|
||
if (useAgent && typeof agentRunningModes !== 'undefined') {
|
||
agentRunningModes = agentRunningModes.filter(m => m !== 'adsb');
|
||
}
|
||
} catch (err) {}
|
||
|
||
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;
|
||
if (sessionDevice !== null && sessionDevice !== undefined) {
|
||
adsbActiveDevice = sessionDevice;
|
||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||
if (adsbSelect) {
|
||
adsbSelect.value = 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');�3�� ( |