Compare commits
No commits in common. "064ba2de719d6016c127c60ea8e91ba18bc67579" and "9ebc7e143e7cdd8fabdcfaef76cc51c6ed8ffefb" have entirely different histories.
064ba2de71
...
9ebc7e143e
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 SkyView Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
11
README.md
|
|
@ -8,11 +8,11 @@ A high-performance, multi-source ADS-B aircraft tracking application that connec
|
||||||
- **Beast Binary Format**: Native support for dump1090 Beast format (port 30005)
|
- **Beast Binary Format**: Native support for dump1090 Beast format (port 30005)
|
||||||
- **Multiple Receivers**: Connect to unlimited dump1090 sources simultaneously
|
- **Multiple Receivers**: Connect to unlimited dump1090 sources simultaneously
|
||||||
- **Intelligent Merging**: Smart data fusion with signal strength-based source selection
|
- **Intelligent Merging**: Smart data fusion with signal strength-based source selection
|
||||||
- **High-throughput Processing**: High-performance concurrent message processing
|
- **Real-time Processing**: High-performance concurrent message processing
|
||||||
|
|
||||||
### Advanced Web Interface
|
### Advanced Web Interface
|
||||||
- **Interactive Maps**: Leaflet.js-based mapping with aircraft tracking
|
- **Interactive Maps**: Leaflet.js-based mapping with aircraft tracking
|
||||||
- **Low-latency Updates**: WebSocket-powered live data streaming
|
- **Real-time Updates**: WebSocket-powered live data streaming
|
||||||
- **Mobile Responsive**: Optimized for desktop, tablet, and mobile devices
|
- **Mobile Responsive**: Optimized for desktop, tablet, and mobile devices
|
||||||
- **Multi-view Dashboard**: Map, Table, Statistics, Coverage, and 3D Radar views
|
- **Multi-view Dashboard**: Map, Table, Statistics, Coverage, and 3D Radar views
|
||||||
|
|
||||||
|
|
@ -21,14 +21,13 @@ A high-performance, multi-source ADS-B aircraft tracking application that connec
|
||||||
- **Range Circles**: Configurable range rings for each receiver
|
- **Range Circles**: Configurable range rings for each receiver
|
||||||
- **Flight Trails**: Historical aircraft movement tracking
|
- **Flight Trails**: Historical aircraft movement tracking
|
||||||
- **3D Radar View**: Three.js-powered 3D visualization (optional)
|
- **3D Radar View**: Three.js-powered 3D visualization (optional)
|
||||||
- **Statistics Dashboard**: Live charts and metrics
|
- **Statistics Dashboard**: Real-time charts and metrics
|
||||||
- **Smart Origin**: Auto-calculated map center based on receiver locations
|
- **Smart Origin**: Auto-calculated map center based on receiver locations
|
||||||
- **Map Controls**: Center on aircraft, reset to origin, toggle overlays
|
- **Map Controls**: Center on aircraft, reset to origin, toggle overlays
|
||||||
|
|
||||||
### Aircraft Data
|
### Aircraft Data
|
||||||
- **Complete Mode S Decoding**: Position, velocity, altitude, heading
|
- **Complete Mode S Decoding**: Position, velocity, altitude, heading
|
||||||
- **Aircraft Identification**: Callsign, category, country, registration
|
- **Aircraft Identification**: Callsign, category, country, registration
|
||||||
- **ICAO Country Database**: Comprehensive embedded database with 70+ allocations covering 40+ countries
|
|
||||||
- **Multi-source Tracking**: Signal strength from each receiver
|
- **Multi-source Tracking**: Signal strength from each receiver
|
||||||
- **Historical Data**: Position history and trail visualization
|
- **Historical Data**: Position history and trail visualization
|
||||||
|
|
||||||
|
|
@ -119,7 +118,7 @@ Access the web interface at `http://localhost:8080`
|
||||||
### Views Available:
|
### Views Available:
|
||||||
- **Map View**: Interactive aircraft tracking with receiver locations
|
- **Map View**: Interactive aircraft tracking with receiver locations
|
||||||
- **Table View**: Sortable aircraft data with multi-source information
|
- **Table View**: Sortable aircraft data with multi-source information
|
||||||
- **Statistics**: Live metrics and historical charts
|
- **Statistics**: Real-time metrics and historical charts
|
||||||
- **Coverage**: Signal strength analysis and heatmaps
|
- **Coverage**: Signal strength analysis and heatmaps
|
||||||
- **3D Radar**: Three-dimensional aircraft visualization
|
- **3D Radar**: Three-dimensional aircraft visualization
|
||||||
|
|
||||||
|
|
@ -161,7 +160,7 @@ docker run -p 8080:8080 -v $(pwd)/config.json:/app/config.json skyview
|
||||||
- `GET /api/heatmap/{sourceId}` - Signal heatmap
|
- `GET /api/heatmap/{sourceId}` - Signal heatmap
|
||||||
|
|
||||||
### WebSocket
|
### WebSocket
|
||||||
- `ws://localhost:8080/ws` - Low-latency updates
|
- `ws://localhost:8080/ws` - Real-time updates
|
||||||
|
|
||||||
## 🛠️ Development
|
## 🛠️ Development
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -193,48 +193,6 @@ body {
|
||||||
background: #404040;
|
background: #404040;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-options {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(45, 45, 45, 0.95);
|
|
||||||
border: 1px solid #404040;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.display-options h4 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #cccccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group input[type="checkbox"] {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
accent-color: #00d4ff;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group label:hover {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
|
|
@ -270,7 +228,6 @@ body {
|
||||||
|
|
||||||
.legend-icon.commercial { background: #00ff88; }
|
.legend-icon.commercial { background: #00ff88; }
|
||||||
.legend-icon.cargo { background: #ff8c00; }
|
.legend-icon.cargo { background: #ff8c00; }
|
||||||
.legend-icon.helicopter { background: #00d4ff; }
|
|
||||||
.legend-icon.military { background: #ff4444; }
|
.legend-icon.military { background: #ff4444; }
|
||||||
.legend-icon.ga { background: #ffff00; }
|
.legend-icon.ga { background: #ffff00; }
|
||||||
.legend-icon.ground { background: #888888; }
|
.legend-icon.ground { background: #888888; }
|
||||||
|
|
@ -405,39 +362,20 @@ body {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet popup override - ensure our styles take precedence */
|
|
||||||
.leaflet-popup-content-wrapper {
|
|
||||||
background: #2d2d2d !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-popup-content {
|
|
||||||
margin: 12px !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-popup-tip {
|
|
||||||
background: #2d2d2d !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aircraft-popup {
|
.aircraft-popup {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-header {
|
.popup-header {
|
||||||
border-bottom: 1px solid #404040;
|
border-bottom: 1px solid #404040;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.flight-info {
|
.flight-info {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icao-flag {
|
.icao-flag {
|
||||||
|
|
@ -446,27 +384,21 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.flight-id {
|
.flight-id {
|
||||||
color: #00a8ff !important;
|
color: #00a8ff;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callsign {
|
.callsign {
|
||||||
color: #00ff88 !important;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-details {
|
.popup-details {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row strong {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
|
|
@ -483,27 +415,13 @@ body {
|
||||||
|
|
||||||
.detail-item .label {
|
.detail-item .label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #888 !important;
|
color: #888;
|
||||||
margin-bottom: 0.1rem;
|
margin-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value {
|
.detail-item .value {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffffff !important;
|
color: #ffffff;
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure all values are visible with strong contrast */
|
|
||||||
.aircraft-popup .value,
|
|
||||||
.aircraft-popup .detail-row,
|
|
||||||
.aircraft-popup .detail-item .value {
|
|
||||||
color: #ffffff !important;
|
|
||||||
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style for N/A or empty values - still visible but slightly dimmed */
|
|
||||||
.detail-item .value.no-data {
|
|
||||||
color: #aaaaaa !important;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Wide-body cargo aircraft shape -->
|
|
||||||
<path d="M0,-12 L-10,8 L-3,8 L0,12 L3,8 L10,8 Z" fill="currentColor"/>
|
|
||||||
<!-- Fuselage bulk -->
|
|
||||||
<rect x="-2" y="-6" width="4" height="8" fill="currentColor"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 384 B |
|
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Standard commercial aircraft shape -->
|
|
||||||
<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z" fill="currentColor"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 292 B |
|
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Small general aviation aircraft -->
|
|
||||||
<path d="M0,-10 L-5,6 L-1,6 L0,10 L1,6 L5,6 Z" fill="currentColor"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 289 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Ground vehicle - truck/car shape -->
|
|
||||||
<rect x="-6" y="-4" width="12" height="8" fill="currentColor" rx="2"/>
|
|
||||||
<!-- Wheels -->
|
|
||||||
<circle cx="-3" cy="2" r="2" fill="#333"/>
|
|
||||||
<circle cx="3" cy="2" r="2" fill="#333"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 405 B |
|
|
@ -1,12 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Rotor disc -->
|
|
||||||
<circle cx="0" cy="0" r="10" fill="none" stroke="currentColor" stroke-width="1" opacity="0.3"/>
|
|
||||||
<!-- Main body -->
|
|
||||||
<path d="M0,-8 L-6,6 L-1,6 L0,8 L1,6 L6,6 Z" fill="currentColor"/>
|
|
||||||
<!-- Rotor mast -->
|
|
||||||
<path d="M0,-6 L0,-10" stroke="currentColor" stroke-width="2"/>
|
|
||||||
<path d="M0,6 L0,8" stroke="currentColor" stroke-width="2"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 546 B |
|
|
@ -1,7 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Swept-wing fighter jet shape -->
|
|
||||||
<path d="M0,-12 L-4,2 L-8,8 L-2,6 L0,12 L2,6 L8,8 L4,2 Z" fill="currentColor"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 297 B |
|
|
@ -77,59 +77,32 @@
|
||||||
<button id="center-map" title="Center on aircraft">Center Map</button>
|
<button id="center-map" title="Center on aircraft">Center Map</button>
|
||||||
<button id="reset-map" title="Reset to origin">Reset Map</button>
|
<button id="reset-map" title="Reset to origin">Reset Map</button>
|
||||||
<button id="toggle-trails" title="Show/hide aircraft trails">Show Trails</button>
|
<button id="toggle-trails" title="Show/hide aircraft trails">Show Trails</button>
|
||||||
|
<button id="toggle-range" title="Show/hide range circles">Show Range</button>
|
||||||
<button id="toggle-sources" title="Show/hide source locations">Show Sources</button>
|
<button id="toggle-sources" title="Show/hide source locations">Show Sources</button>
|
||||||
<button id="toggle-dark-mode" title="Toggle dark/light mode">🌙 Night Mode</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Display options -->
|
|
||||||
<div class="display-options">
|
|
||||||
<h4>Display Options</h4>
|
|
||||||
<div class="option-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="show-site-positions" checked>
|
|
||||||
<span>Site Positions</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="show-range-rings">
|
|
||||||
<span>Range Rings</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" id="show-selected-trail">
|
|
||||||
<span>Selected Aircraft Trail</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Legend -->
|
<!-- Legend -->
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
<h4>ADS-B Categories</h4>
|
<h4>Aircraft Types</h4>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<span class="legend-icon commercial"></span>
|
<span class="legend-icon commercial"></span>
|
||||||
<span>Light < 7000kg</span>
|
<span>Commercial</span>
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon commercial"></span>
|
|
||||||
<span>Medium 7000-34000kg</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon commercial"></span>
|
|
||||||
<span>Large 34000-136000kg</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<span class="legend-icon cargo"></span>
|
<span class="legend-icon cargo"></span>
|
||||||
<span>Heavy > 136000kg</span>
|
<span>Cargo</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<span class="legend-icon helicopter"></span>
|
<span class="legend-icon military"></span>
|
||||||
<span>Rotorcraft</span>
|
<span>Military</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<span class="legend-icon ga"></span>
|
<span class="legend-icon ga"></span>
|
||||||
<span>Glider/Ultralight</span>
|
<span>General Aviation</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
<div class="legend-item">
|
||||||
<span class="legend-icon ground"></span>
|
<span class="legend-icon ground"></span>
|
||||||
<span>Surface Vehicle</span>
|
<span>Ground</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4>Sources</h4>
|
<h4>Sources</h4>
|
||||||
|
|
@ -249,6 +222,6 @@
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
<!-- Custom JS -->
|
<!-- Custom JS -->
|
||||||
<script type="module" src="/static/js/app.js?v=4"></script>
|
<script type="module" src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -1,493 +0,0 @@
|
||||||
// Aircraft marker and data management module
|
|
||||||
export class AircraftManager {
|
|
||||||
constructor(map) {
|
|
||||||
this.map = map;
|
|
||||||
this.aircraftData = new Map();
|
|
||||||
this.aircraftMarkers = new Map();
|
|
||||||
this.aircraftTrails = new Map();
|
|
||||||
this.showTrails = false;
|
|
||||||
|
|
||||||
// Debug: Track marker lifecycle
|
|
||||||
this.markerCreateCount = 0;
|
|
||||||
this.markerUpdateCount = 0;
|
|
||||||
this.markerRemoveCount = 0;
|
|
||||||
|
|
||||||
// SVG icon cache
|
|
||||||
this.iconCache = new Map();
|
|
||||||
this.loadIcons();
|
|
||||||
|
|
||||||
// Selected aircraft trail tracking
|
|
||||||
this.selectedAircraftCallback = null;
|
|
||||||
|
|
||||||
// Map event listeners removed - let Leaflet handle positioning naturally
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadIcons() {
|
|
||||||
const iconTypes = ['commercial', 'helicopter', 'military', 'cargo', 'ga', 'ground'];
|
|
||||||
|
|
||||||
for (const type of iconTypes) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/static/icons/${type}.svg`);
|
|
||||||
const svgText = await response.text();
|
|
||||||
this.iconCache.set(type, svgText);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to load icon for ${type}:`, error);
|
|
||||||
// Fallback to inline SVG if needed
|
|
||||||
this.iconCache.set(type, this.createFallbackIcon(type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createFallbackIcon(type) {
|
|
||||||
// Fallback inline SVG if file loading fails
|
|
||||||
const color = 'currentColor';
|
|
||||||
let path = '';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'helicopter':
|
|
||||||
path = `<circle cx="0" cy="0" r="10" fill="none" stroke="${color}" stroke-width="1" opacity="0.3"/>
|
|
||||||
<path d="M0,-8 L-6,6 L-1,6 L0,8 L1,6 L6,6 Z" fill="${color}"/>
|
|
||||||
<path d="M0,-6 L0,-10" stroke="${color}" stroke-width="2"/>
|
|
||||||
<path d="M0,6 L0,8" stroke="${color}" stroke-width="2"/>`;
|
|
||||||
break;
|
|
||||||
case 'military':
|
|
||||||
path = `<path d="M0,-12 L-4,2 L-8,8 L-2,6 L0,12 L2,6 L8,8 L4,2 Z" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'cargo':
|
|
||||||
path = `<path d="M0,-12 L-10,8 L-3,8 L0,12 L3,8 L10,8 Z" fill="${color}"/>
|
|
||||||
<rect x="-2" y="-6" width="4" height="8" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'ga':
|
|
||||||
path = `<path d="M0,-10 L-5,6 L-1,6 L0,10 L1,6 L5,6 Z" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'ground':
|
|
||||||
path = `<rect x="-6" y="-4" width="12" height="8" fill="${color}" rx="2"/>
|
|
||||||
<circle cx="-3" cy="2" r="2" fill="#333"/>
|
|
||||||
<circle cx="3" cy="2" r="2" fill="#333"/>`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
path = `<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z" fill="${color}"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
${path}
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftData(data) {
|
|
||||||
if (data.aircraft) {
|
|
||||||
this.aircraftData.clear();
|
|
||||||
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
||||||
this.aircraftData.set(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarkers() {
|
|
||||||
if (!this.map) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stale aircraft markers
|
|
||||||
const currentICAOs = new Set(this.aircraftData.keys());
|
|
||||||
for (const [icao, marker] of this.aircraftMarkers) {
|
|
||||||
if (!currentICAOs.has(icao)) {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
this.aircraftMarkers.delete(icao);
|
|
||||||
|
|
||||||
// Remove trail if it exists
|
|
||||||
if (this.aircraftTrails.has(icao)) {
|
|
||||||
const trail = this.aircraftTrails.get(icao);
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
this.aircraftTrails.delete(icao);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify if this was the selected aircraft
|
|
||||||
if (this.selectedAircraftCallback && this.selectedAircraftCallback(icao)) {
|
|
||||||
// Aircraft was selected and disappeared - could notify main app
|
|
||||||
// For now, the callback will return false automatically since selectedAircraft will be cleared
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markerRemoveCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update aircraft markers - only for aircraft with valid geographic coordinates
|
|
||||||
for (const [icao, aircraft] of this.aircraftData) {
|
|
||||||
const hasCoords = aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0;
|
|
||||||
const validLat = aircraft.Latitude >= -90 && aircraft.Latitude <= 90;
|
|
||||||
const validLng = aircraft.Longitude >= -180 && aircraft.Longitude <= 180;
|
|
||||||
|
|
||||||
if (hasCoords && validLat && validLng) {
|
|
||||||
this.updateAircraftMarker(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftMarker(icao, aircraft) {
|
|
||||||
const pos = [aircraft.Latitude, aircraft.Longitude];
|
|
||||||
|
|
||||||
|
|
||||||
// Check for invalid coordinates - proper geographic bounds
|
|
||||||
const isValidLat = pos[0] >= -90 && pos[0] <= 90;
|
|
||||||
const isValidLng = pos[1] >= -180 && pos[1] <= 180;
|
|
||||||
|
|
||||||
if (!isValidLat || !isValidLng || isNaN(pos[0]) || isNaN(pos[1])) {
|
|
||||||
console.error(`🚨 Invalid coordinates for ${icao}: [${pos[0]}, ${pos[1]}] (lat must be -90 to +90, lng must be -180 to +180)`);
|
|
||||||
return; // Don't create/update marker with invalid coordinates
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.aircraftMarkers.has(icao)) {
|
|
||||||
// Update existing marker - KISS approach
|
|
||||||
const marker = this.aircraftMarkers.get(icao);
|
|
||||||
|
|
||||||
// Always update position - let Leaflet handle everything
|
|
||||||
const oldPos = marker.getLatLng();
|
|
||||||
marker.setLatLng(pos);
|
|
||||||
|
|
||||||
// Check if icon needs to be updated (track rotation, aircraft type, or ground status changes)
|
|
||||||
const currentRotation = marker._currentRotation || 0;
|
|
||||||
const currentType = marker._currentType || null;
|
|
||||||
const currentOnGround = marker._currentOnGround || false;
|
|
||||||
|
|
||||||
const newType = this.getAircraftIconType(aircraft);
|
|
||||||
const rotationChanged = aircraft.Track !== undefined && Math.abs(currentRotation - aircraft.Track) > 5;
|
|
||||||
const typeChanged = currentType !== newType;
|
|
||||||
const groundStatusChanged = currentOnGround !== aircraft.OnGround;
|
|
||||||
|
|
||||||
if (rotationChanged || typeChanged || groundStatusChanged) {
|
|
||||||
marker.setIcon(this.createAircraftIcon(aircraft));
|
|
||||||
marker._currentRotation = aircraft.Track || 0;
|
|
||||||
marker._currentType = newType;
|
|
||||||
marker._currentOnGround = aircraft.OnGround || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle popup exactly like Leaflet expects
|
|
||||||
if (marker.isPopupOpen()) {
|
|
||||||
marker.setPopupContent(this.createPopupContent(aircraft));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markerUpdateCount++;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Create new marker
|
|
||||||
const icon = this.createAircraftIcon(aircraft);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const marker = L.marker(pos, {
|
|
||||||
icon: icon
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
// Store current properties for future update comparisons
|
|
||||||
marker._currentRotation = aircraft.Track || 0;
|
|
||||||
marker._currentType = this.getAircraftIconType(aircraft);
|
|
||||||
marker._currentOnGround = aircraft.OnGround || false;
|
|
||||||
|
|
||||||
marker.bindPopup(this.createPopupContent(aircraft), {
|
|
||||||
maxWidth: 450,
|
|
||||||
className: 'aircraft-popup'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.aircraftMarkers.set(icao, marker);
|
|
||||||
this.markerCreateCount++;
|
|
||||||
|
|
||||||
// Force immediate visibility
|
|
||||||
if (marker._icon) {
|
|
||||||
marker._icon.style.display = 'block';
|
|
||||||
marker._icon.style.opacity = '1';
|
|
||||||
marker._icon.style.visibility = 'visible';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to create marker for ${icao}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trails - check both global trails and individual selected aircraft
|
|
||||||
if (this.showTrails || this.isSelectedAircraftTrailEnabled(icao)) {
|
|
||||||
this.updateAircraftTrail(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createAircraftIcon(aircraft) {
|
|
||||||
const iconType = this.getAircraftIconType(aircraft);
|
|
||||||
const color = this.getAircraftColor(iconType);
|
|
||||||
const size = aircraft.OnGround ? 12 : 16;
|
|
||||||
const rotation = aircraft.Track || 0;
|
|
||||||
|
|
||||||
// Get SVG template from cache
|
|
||||||
let svgTemplate = this.iconCache.get(iconType) || this.iconCache.get('commercial');
|
|
||||||
|
|
||||||
if (!svgTemplate) {
|
|
||||||
// Ultimate fallback - create a simple circle
|
|
||||||
svgTemplate = `<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<circle cx="0" cy="0" r="8" fill="currentColor"/>
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply color and rotation to the SVG
|
|
||||||
let svg = svgTemplate
|
|
||||||
.replace(/currentColor/g, color)
|
|
||||||
.replace(/width="32"/, `width="${size * 2}"`)
|
|
||||||
.replace(/height="32"/, `height="${size * 2}"`);
|
|
||||||
|
|
||||||
// Add rotation to the transform
|
|
||||||
if (rotation !== 0) {
|
|
||||||
svg = svg.replace(/transform="translate\(16,16\)"/, `transform="translate(16,16) rotate(${rotation})"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return L.divIcon({
|
|
||||||
html: svg,
|
|
||||||
iconSize: [size * 2, size * 2],
|
|
||||||
iconAnchor: [size, size],
|
|
||||||
className: 'aircraft-marker'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftType(aircraft) {
|
|
||||||
// For display purposes, return the actual ADS-B category
|
|
||||||
// This is used in the popup display
|
|
||||||
if (aircraft.OnGround) return 'On Ground';
|
|
||||||
if (aircraft.Category) return aircraft.Category;
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftIconType(aircraft) {
|
|
||||||
// For icon selection, we still need basic categories
|
|
||||||
// This determines which SVG shape to use
|
|
||||||
if (aircraft.OnGround) return 'ground';
|
|
||||||
|
|
||||||
if (aircraft.Category) {
|
|
||||||
const cat = aircraft.Category.toLowerCase();
|
|
||||||
|
|
||||||
// Map to basic icon types for visual representation
|
|
||||||
if (cat.includes('helicopter') || cat.includes('rotorcraft')) return 'helicopter';
|
|
||||||
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
|
|
||||||
if (cat.includes('cargo') || cat.includes('heavy') || cat.includes('super')) return 'cargo';
|
|
||||||
if (cat.includes('light') || cat.includes('glider') || cat.includes('ultralight')) return 'ga';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default commercial icon for everything else
|
|
||||||
return 'commercial';
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftColor(type) {
|
|
||||||
const colors = {
|
|
||||||
commercial: '#00ff88',
|
|
||||||
cargo: '#ff8c00',
|
|
||||||
military: '#ff4444',
|
|
||||||
ga: '#ffff00',
|
|
||||||
ground: '#888888',
|
|
||||||
helicopter: '#ff00ff' // Magenta for helicopters
|
|
||||||
};
|
|
||||||
return colors[type] || colors.commercial;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
updateAircraftTrail(icao, aircraft) {
|
|
||||||
// Use server-provided position history
|
|
||||||
if (!aircraft.position_history || aircraft.position_history.length < 2) {
|
|
||||||
// No trail data available or not enough points
|
|
||||||
if (this.aircraftTrails.has(icao)) {
|
|
||||||
const trail = this.aircraftTrails.get(icao);
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
this.aircraftTrails.delete(icao);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert position history to Leaflet format
|
|
||||||
const trailPoints = aircraft.position_history.map(point => [point.lat, point.lon]);
|
|
||||||
|
|
||||||
// Get or create trail object
|
|
||||||
if (!this.aircraftTrails.has(icao)) {
|
|
||||||
this.aircraftTrails.set(icao, {});
|
|
||||||
}
|
|
||||||
const trail = this.aircraftTrails.get(icao);
|
|
||||||
|
|
||||||
// Remove old polyline if it exists
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create gradient effect - newer points are brighter
|
|
||||||
const segments = [];
|
|
||||||
for (let i = 1; i < trailPoints.length; i++) {
|
|
||||||
const opacity = 0.2 + (0.6 * (i / trailPoints.length)); // Fade from 0.2 to 0.8
|
|
||||||
const segment = L.polyline([trailPoints[i-1], trailPoints[i]], {
|
|
||||||
color: '#00d4ff',
|
|
||||||
weight: 2,
|
|
||||||
opacity: opacity
|
|
||||||
});
|
|
||||||
segments.push(segment);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a feature group for all segments
|
|
||||||
trail.polyline = L.featureGroup(segments).addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
createPopupContent(aircraft) {
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const country = aircraft.country || 'Unknown';
|
|
||||||
const flag = aircraft.flag || '🏳️';
|
|
||||||
|
|
||||||
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
||||||
const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0;
|
|
||||||
const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0;
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="aircraft-popup">
|
|
||||||
<div class="popup-header">
|
|
||||||
<div class="flight-info">
|
|
||||||
<span class="icao-flag">${flag}</span>
|
|
||||||
<span class="flight-id">${aircraft.ICAO24 || 'N/A'}</span>
|
|
||||||
${aircraft.Callsign ? `→ <span class="callsign">${aircraft.Callsign}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="popup-details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Country:</strong> ${country}
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Type:</strong> ${type}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Altitude:</div>
|
|
||||||
<div class="value${!altitude ? ' no-data' : ''}">${altitude ? `${altitude} ft | ${altitudeM} m` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Squawk:</div>
|
|
||||||
<div class="value${!aircraft.Squawk ? ' no-data' : ''}">${aircraft.Squawk || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Speed:</div>
|
|
||||||
<div class="value${!aircraft.GroundSpeed ? ' no-data' : ''}">${aircraft.GroundSpeed !== undefined && aircraft.GroundSpeed !== null ? `${aircraft.GroundSpeed} kt | ${speedKmh} km/h` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Track:</div>
|
|
||||||
<div class="value${aircraft.Track === undefined || aircraft.Track === null ? ' no-data' : ''}">${aircraft.Track !== undefined && aircraft.Track !== null ? `${aircraft.Track}°` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">V/Rate:</div>
|
|
||||||
<div class="value${!aircraft.VerticalRate ? ' no-data' : ''}">${aircraft.VerticalRate ? `${aircraft.VerticalRate} ft/min` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Distance:</div>
|
|
||||||
<div class="value${distance ? '' : ' no-data'}">${distanceKm !== 'N/A' ? `${distanceKm} km` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Position:</strong> ${aircraft.Latitude?.toFixed(4)}°, ${aircraft.Longitude?.toFixed(4)}°
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Messages:</strong> ${aircraft.TotalMessages || 0}
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Age:</strong> ${aircraft.Age ? aircraft.Age.toFixed(1) : '0'}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
calculateDistance(aircraft) {
|
|
||||||
if (!aircraft.Latitude || !aircraft.Longitude) return null;
|
|
||||||
|
|
||||||
// Use closest source as reference point
|
|
||||||
let minDistance = Infinity;
|
|
||||||
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
|
|
||||||
if (srcData.distance && srcData.distance < minDistance) {
|
|
||||||
minDistance = srcData.distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDistance === Infinity ? null : minDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
toggleTrails() {
|
|
||||||
this.showTrails = !this.showTrails;
|
|
||||||
|
|
||||||
if (!this.showTrails) {
|
|
||||||
// Clear all trails
|
|
||||||
this.aircraftTrails.forEach((trail, icao) => {
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.aircraftTrails.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.showTrails;
|
|
||||||
}
|
|
||||||
|
|
||||||
showAircraftTrail(icao) {
|
|
||||||
const aircraft = this.aircraftData.get(icao);
|
|
||||||
if (aircraft && aircraft.position_history && aircraft.position_history.length >= 2) {
|
|
||||||
this.updateAircraftTrail(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hideAircraftTrail(icao) {
|
|
||||||
if (this.aircraftTrails.has(icao)) {
|
|
||||||
const trail = this.aircraftTrails.get(icao);
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
this.aircraftTrails.delete(icao);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedAircraftCallback(callback) {
|
|
||||||
this.selectedAircraftCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSelectedAircraftTrailEnabled(icao) {
|
|
||||||
return this.selectedAircraftCallback && this.selectedAircraftCallback(icao);
|
|
||||||
}
|
|
||||||
|
|
||||||
centerMapOnAircraft(includeSourcesCallback = null) {
|
|
||||||
const validAircraft = Array.from(this.aircraftData.values())
|
|
||||||
.filter(a => a.Latitude && a.Longitude);
|
|
||||||
|
|
||||||
const allPoints = [];
|
|
||||||
|
|
||||||
// Add aircraft positions
|
|
||||||
validAircraft.forEach(a => {
|
|
||||||
allPoints.push([a.Latitude, a.Longitude]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add source positions if callback provided
|
|
||||||
if (includeSourcesCallback && typeof includeSourcesCallback === 'function') {
|
|
||||||
const sourcePositions = includeSourcesCallback();
|
|
||||||
allPoints.push(...sourcePositions);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allPoints.length === 0) return;
|
|
||||||
|
|
||||||
if (allPoints.length === 1) {
|
|
||||||
// Center on single point
|
|
||||||
this.map.setView(allPoints[0], 12);
|
|
||||||
} else {
|
|
||||||
// Fit bounds to all points (aircraft + sources)
|
|
||||||
const bounds = L.latLngBounds(allPoints);
|
|
||||||
this.map.fitBounds(bounds.pad(0.1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,372 +0,0 @@
|
||||||
// Map and visualization management module
|
|
||||||
export class MapManager {
|
|
||||||
constructor() {
|
|
||||||
this.map = null;
|
|
||||||
this.coverageMap = null;
|
|
||||||
this.mapOrigin = null;
|
|
||||||
|
|
||||||
// Source markers and overlays
|
|
||||||
this.sourceMarkers = new Map();
|
|
||||||
this.rangeCircles = new Map();
|
|
||||||
this.showSources = true;
|
|
||||||
this.showRange = false;
|
|
||||||
this.selectedSource = null;
|
|
||||||
this.heatmapLayer = null;
|
|
||||||
|
|
||||||
// Data references
|
|
||||||
this.sourcesData = new Map();
|
|
||||||
|
|
||||||
// Map theme
|
|
||||||
this.isDarkMode = false;
|
|
||||||
this.currentTileLayer = null;
|
|
||||||
this.coverageTileLayer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeMap() {
|
|
||||||
// Get origin from server
|
|
||||||
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/origin');
|
|
||||||
if (response.ok) {
|
|
||||||
origin = await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not fetch origin, using default:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store origin for reset functionality
|
|
||||||
this.mapOrigin = origin;
|
|
||||||
|
|
||||||
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
|
|
||||||
|
|
||||||
// Light tile layer by default
|
|
||||||
this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
// Add scale control for distance estimation
|
|
||||||
L.control.scale({
|
|
||||||
metric: true,
|
|
||||||
imperial: true,
|
|
||||||
position: 'bottomright'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
return this.map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeCoverageMap() {
|
|
||||||
if (!this.coverageMap) {
|
|
||||||
// Get origin from server
|
|
||||||
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/origin');
|
|
||||||
if (response.ok) {
|
|
||||||
origin = await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not fetch origin for coverage map, using default:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
|
|
||||||
|
|
||||||
this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
|
|
||||||
// Add scale control for distance estimation
|
|
||||||
L.control.scale({
|
|
||||||
metric: true,
|
|
||||||
imperial: true,
|
|
||||||
position: 'bottomright'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.coverageMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourcesData(data) {
|
|
||||||
if (data.sources) {
|
|
||||||
this.sourcesData.clear();
|
|
||||||
data.sources.forEach(source => {
|
|
||||||
this.sourcesData.set(source.id, source);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourceMarkers() {
|
|
||||||
if (!this.map || !this.showSources) return;
|
|
||||||
|
|
||||||
// Remove markers for sources that no longer exist
|
|
||||||
const currentSourceIds = new Set(this.sourcesData.keys());
|
|
||||||
for (const [id, marker] of this.sourceMarkers) {
|
|
||||||
if (!currentSourceIds.has(id)) {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
this.sourceMarkers.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or create markers for current sources
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
if (source.latitude && source.longitude) {
|
|
||||||
if (this.sourceMarkers.has(id)) {
|
|
||||||
// Update existing marker
|
|
||||||
const marker = this.sourceMarkers.get(id);
|
|
||||||
|
|
||||||
// Update marker style if status changed
|
|
||||||
marker.setStyle({
|
|
||||||
radius: source.active ? 10 : 6,
|
|
||||||
fillColor: source.active ? '#00d4ff' : '#666666',
|
|
||||||
fillOpacity: 0.8
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update popup content if it's open
|
|
||||||
if (marker.isPopupOpen()) {
|
|
||||||
marker.setPopupContent(this.createSourcePopupContent(source));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new marker
|
|
||||||
const marker = L.circleMarker([source.latitude, source.longitude], {
|
|
||||||
radius: source.active ? 10 : 6,
|
|
||||||
fillColor: source.active ? '#00d4ff' : '#666666',
|
|
||||||
color: '#ffffff',
|
|
||||||
weight: 2,
|
|
||||||
fillOpacity: 0.8,
|
|
||||||
className: 'source-marker'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
marker.bindPopup(this.createSourcePopupContent(source), {
|
|
||||||
maxWidth: 300
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sourceMarkers.set(id, marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateSourcesLegend();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRangeCircles() {
|
|
||||||
if (!this.map || !this.showRange) return;
|
|
||||||
|
|
||||||
// Clear existing circles
|
|
||||||
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
|
||||||
this.rangeCircles.clear();
|
|
||||||
|
|
||||||
// Add range circles for active sources
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
if (source.active && source.latitude && source.longitude) {
|
|
||||||
// Add multiple range circles (50km, 100km, 200km)
|
|
||||||
const ranges = [50000, 100000, 200000];
|
|
||||||
ranges.forEach((range, index) => {
|
|
||||||
const circle = L.circle([source.latitude, source.longitude], {
|
|
||||||
radius: range,
|
|
||||||
fillColor: 'transparent',
|
|
||||||
color: '#00d4ff',
|
|
||||||
weight: 2,
|
|
||||||
opacity: 0.7 - (index * 0.15),
|
|
||||||
dashArray: '8,4'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
this.rangeCircles.set(`${id}_${range}`, circle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createSourcePopupContent(source, aircraftData) {
|
|
||||||
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
|
|
||||||
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="source-popup">
|
|
||||||
<h3>${source.name}</h3>
|
|
||||||
<p><strong>ID:</strong> ${source.id}</p>
|
|
||||||
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
|
|
||||||
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
|
||||||
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
|
|
||||||
<p><strong>Messages:</strong> ${source.messages || 0}</p>
|
|
||||||
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourcesLegend() {
|
|
||||||
const legend = document.getElementById('sources-legend');
|
|
||||||
if (!legend) return;
|
|
||||||
|
|
||||||
legend.innerHTML = '';
|
|
||||||
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'legend-item';
|
|
||||||
item.innerHTML = `
|
|
||||||
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
|
||||||
<span title="${source.host}:${source.port}">${source.name}</span>
|
|
||||||
`;
|
|
||||||
legend.appendChild(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetMap() {
|
|
||||||
if (this.mapOrigin && this.map) {
|
|
||||||
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRangeCircles() {
|
|
||||||
this.showRange = !this.showRange;
|
|
||||||
|
|
||||||
if (this.showRange) {
|
|
||||||
this.updateRangeCircles();
|
|
||||||
} else {
|
|
||||||
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
|
||||||
this.rangeCircles.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.showRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSources() {
|
|
||||||
this.showSources = !this.showSources;
|
|
||||||
|
|
||||||
if (this.showSources) {
|
|
||||||
this.updateSourceMarkers();
|
|
||||||
} else {
|
|
||||||
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
|
|
||||||
this.sourceMarkers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.showSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleDarkMode() {
|
|
||||||
this.isDarkMode = !this.isDarkMode;
|
|
||||||
|
|
||||||
const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
|
||||||
const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
||||||
const tileUrl = this.isDarkMode ? darkUrl : lightUrl;
|
|
||||||
|
|
||||||
const tileOptions = {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update main map
|
|
||||||
if (this.map && this.currentTileLayer) {
|
|
||||||
this.map.removeLayer(this.currentTileLayer);
|
|
||||||
this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update coverage map
|
|
||||||
if (this.coverageMap && this.coverageTileLayer) {
|
|
||||||
this.coverageMap.removeLayer(this.coverageTileLayer);
|
|
||||||
this.coverageTileLayer = L.tileLayer(tileUrl, {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.isDarkMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coverage map methods
|
|
||||||
updateCoverageControls() {
|
|
||||||
const select = document.getElementById('coverage-source');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
select.innerHTML = '<option value="">Select Source</option>';
|
|
||||||
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = id;
|
|
||||||
option.textContent = source.name;
|
|
||||||
select.appendChild(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCoverageDisplay() {
|
|
||||||
if (!this.selectedSource || !this.coverageMap) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/coverage/${this.selectedSource}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Clear existing coverage markers
|
|
||||||
this.coverageMap.eachLayer(layer => {
|
|
||||||
if (layer instanceof L.CircleMarker) {
|
|
||||||
this.coverageMap.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add coverage points
|
|
||||||
data.points.forEach(point => {
|
|
||||||
const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
|
|
||||||
L.circleMarker([point.lat, point.lon], {
|
|
||||||
radius: 3,
|
|
||||||
fillColor: this.getSignalColor(point.signal),
|
|
||||||
color: 'white',
|
|
||||||
weight: 1,
|
|
||||||
fillOpacity: intensity
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load coverage data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleHeatmap() {
|
|
||||||
if (!this.selectedSource) {
|
|
||||||
alert('Please select a source first');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.heatmapLayer) {
|
|
||||||
this.coverageMap.removeLayer(this.heatmapLayer);
|
|
||||||
this.heatmapLayer = null;
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Create heatmap layer (simplified)
|
|
||||||
this.createHeatmapOverlay(data);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load heatmap data:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignalColor(signal) {
|
|
||||||
if (signal > -10) return '#00ff88';
|
|
||||||
if (signal > -20) return '#ffff00';
|
|
||||||
if (signal > -30) return '#ff8c00';
|
|
||||||
return '#ff4444';
|
|
||||||
}
|
|
||||||
|
|
||||||
createHeatmapOverlay(data) {
|
|
||||||
// Simplified heatmap implementation
|
|
||||||
// In production, would use proper heatmap library like Leaflet.heat
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedSource(sourceId) {
|
|
||||||
this.selectedSource = sourceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSourcePositions() {
|
|
||||||
const positions = [];
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
if (source.latitude && source.longitude) {
|
|
||||||
positions.push([source.latitude, source.longitude]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
// UI and table management module
|
|
||||||
export class UIManager {
|
|
||||||
constructor() {
|
|
||||||
this.aircraftData = new Map();
|
|
||||||
this.sourcesData = new Map();
|
|
||||||
this.stats = {};
|
|
||||||
this.currentView = 'map-view';
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeViews() {
|
|
||||||
const viewButtons = document.querySelectorAll('.view-btn');
|
|
||||||
const views = document.querySelectorAll('.view');
|
|
||||||
|
|
||||||
viewButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const viewId = btn.id.replace('-btn', '');
|
|
||||||
this.switchView(viewId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switchView(viewId) {
|
|
||||||
// Update buttons
|
|
||||||
document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
|
|
||||||
const activeBtn = document.getElementById(`${viewId}-btn`);
|
|
||||||
if (activeBtn) {
|
|
||||||
activeBtn.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update views (viewId already includes the full view ID like "map-view")
|
|
||||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
|
|
||||||
const activeView = document.getElementById(viewId);
|
|
||||||
if (activeView) {
|
|
||||||
activeView.classList.add('active');
|
|
||||||
} else {
|
|
||||||
console.warn(`View element not found: ${viewId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentView = viewId;
|
|
||||||
return viewId;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData(data) {
|
|
||||||
// Update aircraft data
|
|
||||||
if (data.aircraft) {
|
|
||||||
this.aircraftData.clear();
|
|
||||||
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
||||||
this.aircraftData.set(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sources data
|
|
||||||
if (data.sources) {
|
|
||||||
this.sourcesData.clear();
|
|
||||||
data.sources.forEach(source => {
|
|
||||||
this.sourcesData.set(source.id, source);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update statistics
|
|
||||||
if (data.stats) {
|
|
||||||
this.stats = data.stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftTable() {
|
|
||||||
// Note: This table shows ALL aircraft we're tracking, including those without
|
|
||||||
// position data. Aircraft without positions will show "No position" in the
|
|
||||||
// location column but still provide useful info like callsign, altitude, etc.
|
|
||||||
const tbody = document.getElementById('aircraft-tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
let filteredData = Array.from(this.aircraftData.values());
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
const searchTerm = document.getElementById('search-input')?.value.toLowerCase() || '';
|
|
||||||
const sourceFilter = document.getElementById('source-filter')?.value || '';
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredData = filteredData.filter(aircraft =>
|
|
||||||
(aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(aircraft.ICAO24 && aircraft.ICAO24.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFilter) {
|
|
||||||
filteredData = filteredData.filter(aircraft =>
|
|
||||||
aircraft.sources && aircraft.sources[sourceFilter]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort data
|
|
||||||
const sortBy = document.getElementById('sort-select')?.value || 'distance';
|
|
||||||
this.sortAircraft(filteredData, sortBy);
|
|
||||||
|
|
||||||
// Populate table
|
|
||||||
filteredData.forEach(aircraft => {
|
|
||||||
const row = this.createTableRow(aircraft);
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update source filter options
|
|
||||||
this.updateSourceFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
createTableRow(aircraft) {
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const icao = aircraft.ICAO24 || 'N/A';
|
|
||||||
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
|
|
||||||
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
row.innerHTML = `
|
|
||||||
<td><span class="type-badge ${type}">${icao}</span></td>
|
|
||||||
<td>${aircraft.Callsign || '-'}</td>
|
|
||||||
<td>${aircraft.Squawk || '-'}</td>
|
|
||||||
<td>${altitude ? `${altitude} ft` : '-'}</td>
|
|
||||||
<td>${aircraft.GroundSpeed || '-'} kt</td>
|
|
||||||
<td>${distance ? distance.toFixed(1) : '-'} km</td>
|
|
||||||
<td>${aircraft.Track || '-'}°</td>
|
|
||||||
<td>${sources}</td>
|
|
||||||
<td><span class="${this.getSignalClass(bestSignal)}">${bestSignal ? bestSignal.toFixed(1) : '-'}</span></td>
|
|
||||||
<td>${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
if (aircraft.Latitude && aircraft.Longitude) {
|
|
||||||
// Trigger event to switch to map and focus on aircraft
|
|
||||||
const event = new CustomEvent('aircraftSelected', {
|
|
||||||
detail: { icao, aircraft }
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftType(aircraft) {
|
|
||||||
if (aircraft.OnGround) return 'ground';
|
|
||||||
if (aircraft.Category) {
|
|
||||||
const cat = aircraft.Category.toLowerCase();
|
|
||||||
if (cat.includes('military')) return 'military';
|
|
||||||
if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
|
|
||||||
if (cat.includes('light') || cat.includes('glider')) return 'ga';
|
|
||||||
}
|
|
||||||
if (aircraft.Callsign) {
|
|
||||||
const cs = aircraft.Callsign.toLowerCase();
|
|
||||||
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
|
|
||||||
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
|
|
||||||
}
|
|
||||||
return 'commercial';
|
|
||||||
}
|
|
||||||
|
|
||||||
getBestSignalFromSources(sources) {
|
|
||||||
if (!sources) return null;
|
|
||||||
let bestSignal = -999;
|
|
||||||
for (const [id, data] of Object.entries(sources)) {
|
|
||||||
if (data.signal_level > bestSignal) {
|
|
||||||
bestSignal = data.signal_level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestSignal === -999 ? null : bestSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignalClass(signal) {
|
|
||||||
if (!signal) return '';
|
|
||||||
if (signal > -10) return 'signal-strong';
|
|
||||||
if (signal > -20) return 'signal-good';
|
|
||||||
if (signal > -30) return 'signal-weak';
|
|
||||||
return 'signal-poor';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourceFilter() {
|
|
||||||
const select = document.getElementById('source-filter');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
const currentValue = select.value;
|
|
||||||
|
|
||||||
// Clear options except "All Sources"
|
|
||||||
select.innerHTML = '<option value="">All Sources</option>';
|
|
||||||
|
|
||||||
// Add source options
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = id;
|
|
||||||
option.textContent = source.name;
|
|
||||||
if (id === currentValue) option.selected = true;
|
|
||||||
select.appendChild(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortAircraft(aircraft, sortBy) {
|
|
||||||
aircraft.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'distance':
|
|
||||||
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
||||||
case 'altitude':
|
|
||||||
return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
|
|
||||||
case 'speed':
|
|
||||||
return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
|
|
||||||
case 'flight':
|
|
||||||
return (a.Callsign || a.ICAO24 || '').localeCompare(b.Callsign || b.ICAO24 || '');
|
|
||||||
case 'icao':
|
|
||||||
return (a.ICAO24 || '').localeCompare(b.ICAO24 || '');
|
|
||||||
case 'squawk':
|
|
||||||
return (a.Squawk || '').localeCompare(b.Squawk || '');
|
|
||||||
case 'signal':
|
|
||||||
return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999);
|
|
||||||
case 'age':
|
|
||||||
return (a.Age || 0) - (b.Age || 0);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateDistance(aircraft) {
|
|
||||||
if (!aircraft.Latitude || !aircraft.Longitude) return null;
|
|
||||||
|
|
||||||
// Use closest source as reference point
|
|
||||||
let minDistance = Infinity;
|
|
||||||
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
|
|
||||||
if (srcData.distance && srcData.distance < minDistance) {
|
|
||||||
minDistance = srcData.distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDistance === Infinity ? null : minDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatistics() {
|
|
||||||
const totalAircraftEl = document.getElementById('total-aircraft');
|
|
||||||
const activeSourcesEl = document.getElementById('active-sources');
|
|
||||||
const maxRangeEl = document.getElementById('max-range');
|
|
||||||
const messagesSecEl = document.getElementById('messages-sec');
|
|
||||||
|
|
||||||
if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size;
|
|
||||||
if (activeSourcesEl) {
|
|
||||||
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate max range
|
|
||||||
let maxDistance = 0;
|
|
||||||
for (const aircraft of this.aircraftData.values()) {
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
if (distance && distance > maxDistance) {
|
|
||||||
maxDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (maxRangeEl) maxRangeEl.textContent = `${maxDistance.toFixed(1)} km`;
|
|
||||||
|
|
||||||
// Update message rate
|
|
||||||
const totalMessages = this.stats.total_messages || 0;
|
|
||||||
if (messagesSecEl) messagesSecEl.textContent = Math.round(totalMessages / 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHeaderInfo() {
|
|
||||||
const aircraftCountEl = document.getElementById('aircraft-count');
|
|
||||||
const sourcesCountEl = document.getElementById('sources-count');
|
|
||||||
|
|
||||||
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
|
|
||||||
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
|
|
||||||
|
|
||||||
this.updateClocks();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConnectionStatus(status) {
|
|
||||||
const statusEl = document.getElementById('connection-status');
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.className = `connection-status ${status}`;
|
|
||||||
statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeEventListeners() {
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
const sortSelect = document.getElementById('sort-select');
|
|
||||||
const sourceFilter = document.getElementById('source-filter');
|
|
||||||
|
|
||||||
if (searchInput) searchInput.addEventListener('input', () => this.updateAircraftTable());
|
|
||||||
if (sortSelect) sortSelect.addEventListener('change', () => this.updateAircraftTable());
|
|
||||||
if (sourceFilter) sourceFilter.addEventListener('change', () => this.updateAircraftTable());
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClocks() {
|
|
||||||
const now = new Date();
|
|
||||||
const utcNow = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
|
|
||||||
|
|
||||||
this.updateClock('utc', utcNow);
|
|
||||||
this.updateClock('update', this.lastUpdateTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClock(prefix, time) {
|
|
||||||
const hours = time.getUTCHours();
|
|
||||||
const minutes = time.getUTCMinutes();
|
|
||||||
|
|
||||||
const hourAngle = (hours % 12) * 30 + minutes * 0.5;
|
|
||||||
const minuteAngle = minutes * 6;
|
|
||||||
|
|
||||||
const hourHand = document.getElementById(`${prefix}-hour`);
|
|
||||||
const minuteHand = document.getElementById(`${prefix}-minute`);
|
|
||||||
|
|
||||||
if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`;
|
|
||||||
if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
console.error(message);
|
|
||||||
// Could implement toast notifications here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// WebSocket communication module
|
|
||||||
export class WebSocketManager {
|
|
||||||
constructor(onMessage, onStatusChange) {
|
|
||||||
this.websocket = null;
|
|
||||||
this.onMessage = onMessage;
|
|
||||||
this.onStatusChange = onStatusChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.websocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
|
||||||
this.onStatusChange('connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
// Reconnect after 5 seconds
|
|
||||||
setTimeout(() => this.connect(), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
|
|
||||||
|
|
||||||
this.onMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('WebSocket connection failed:', error);
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.websocket) {
|
|
||||||
this.websocket.close();
|
|
||||||
this.websocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,440 +0,0 @@
|
||||||
// Package main provides a utility for parsing and displaying Beast format ADS-B data.
|
|
||||||
//
|
|
||||||
// beast-dump can read from TCP sockets (dump1090 streams) or files containing
|
|
||||||
// Beast binary data, decode Mode S/ADS-B messages, and display the results
|
|
||||||
// in human-readable format on the console.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// beast-dump -tcp host:port # Read from TCP socket
|
|
||||||
// beast-dump -file path/to/file # Read from file
|
|
||||||
// beast-dump -verbose # Show detailed message parsing
|
|
||||||
//
|
|
||||||
// Examples:
|
|
||||||
// beast-dump -tcp svovel:30005 # Connect to dump1090 Beast stream
|
|
||||||
// beast-dump -file beast.test # Parse Beast data from file
|
|
||||||
// beast-dump -tcp localhost:30005 -verbose # Verbose TCP parsing
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"skyview/internal/beast"
|
|
||||||
"skyview/internal/modes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds command-line configuration
|
|
||||||
type Config struct {
|
|
||||||
TCPAddress string // TCP address for Beast stream (e.g., "localhost:30005")
|
|
||||||
FilePath string // File path for Beast data
|
|
||||||
Verbose bool // Enable verbose output
|
|
||||||
Count int // Maximum messages to process (0 = unlimited)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeastDumper handles Beast data parsing and console output
|
|
||||||
type BeastDumper struct {
|
|
||||||
config *Config
|
|
||||||
parser *beast.Parser
|
|
||||||
decoder *modes.Decoder
|
|
||||||
stats struct {
|
|
||||||
totalMessages int64
|
|
||||||
validMessages int64
|
|
||||||
aircraftSeen map[uint32]bool
|
|
||||||
startTime time.Time
|
|
||||||
lastMessageTime time.Time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config := parseFlags()
|
|
||||||
|
|
||||||
if config.TCPAddress == "" && config.FilePath == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: Must specify either -tcp or -file\n")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.TCPAddress != "" && config.FilePath != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: Cannot specify both -tcp and -file\n")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
dumper := NewBeastDumper(config)
|
|
||||||
|
|
||||||
if err := dumper.Run(); err != nil {
|
|
||||||
log.Fatalf("Error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFlags parses command-line flags and returns configuration
|
|
||||||
func parseFlags() *Config {
|
|
||||||
config := &Config{}
|
|
||||||
|
|
||||||
flag.StringVar(&config.TCPAddress, "tcp", "", "TCP address for Beast stream (e.g., localhost:30005)")
|
|
||||||
flag.StringVar(&config.FilePath, "file", "", "File path for Beast data")
|
|
||||||
flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose output")
|
|
||||||
flag.IntVar(&config.Count, "count", 0, "Maximum messages to process (0 = unlimited)")
|
|
||||||
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, "\nBeast format ADS-B data parser and console dumper\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -tcp svovel:30005\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -file beast.test\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -tcp localhost:30005 -verbose -count 100\n", os.Args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBeastDumper creates a new Beast data dumper
|
|
||||||
func NewBeastDumper(config *Config) *BeastDumper {
|
|
||||||
return &BeastDumper{
|
|
||||||
config: config,
|
|
||||||
decoder: modes.NewDecoder(0.0, 0.0), // beast-dump doesn't have reference position, use default
|
|
||||||
stats: struct {
|
|
||||||
totalMessages int64
|
|
||||||
validMessages int64
|
|
||||||
aircraftSeen map[uint32]bool
|
|
||||||
startTime time.Time
|
|
||||||
lastMessageTime time.Time
|
|
||||||
}{
|
|
||||||
aircraftSeen: make(map[uint32]bool),
|
|
||||||
startTime: time.Now(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the Beast data processing
|
|
||||||
func (d *BeastDumper) Run() error {
|
|
||||||
fmt.Printf("Beast Data Dumper\n")
|
|
||||||
fmt.Printf("=================\n\n")
|
|
||||||
|
|
||||||
var reader io.Reader
|
|
||||||
var closer io.Closer
|
|
||||||
|
|
||||||
if d.config.TCPAddress != "" {
|
|
||||||
conn, err := d.connectTCP()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("TCP connection failed: %w", err)
|
|
||||||
}
|
|
||||||
reader = conn
|
|
||||||
closer = conn
|
|
||||||
fmt.Printf("Connected to: %s\n", d.config.TCPAddress)
|
|
||||||
} else {
|
|
||||||
file, err := d.openFile()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("file open failed: %w", err)
|
|
||||||
}
|
|
||||||
reader = file
|
|
||||||
closer = file
|
|
||||||
fmt.Printf("Reading file: %s\n", d.config.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer closer.Close()
|
|
||||||
|
|
||||||
// Create Beast parser
|
|
||||||
d.parser = beast.NewParser(reader, "beast-dump")
|
|
||||||
|
|
||||||
fmt.Printf("Verbose mode: %t\n", d.config.Verbose)
|
|
||||||
if d.config.Count > 0 {
|
|
||||||
fmt.Printf("Message limit: %d\n", d.config.Count)
|
|
||||||
}
|
|
||||||
fmt.Printf("\nStarting Beast data parsing...\n")
|
|
||||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6s %s\n",
|
|
||||||
"Time", "ICAO", "Type", "Signal", "Data", "Len", "Decoded")
|
|
||||||
fmt.Printf("%s\n",
|
|
||||||
"------------------------------------------------------------------------")
|
|
||||||
|
|
||||||
return d.parseMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// connectTCP establishes TCP connection to Beast stream
|
|
||||||
func (d *BeastDumper) connectTCP() (net.Conn, error) {
|
|
||||||
fmt.Printf("Connecting to %s...\n", d.config.TCPAddress)
|
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", d.config.TCPAddress, 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFile opens Beast data file
|
|
||||||
func (d *BeastDumper) openFile() (*os.File, error) {
|
|
||||||
file, err := os.Open(d.config.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("File size: %d bytes\n", stat.Size())
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMessages processes Beast messages and outputs decoded data
|
|
||||||
func (d *BeastDumper) parseMessages() error {
|
|
||||||
for {
|
|
||||||
// Check message count limit
|
|
||||||
if d.config.Count > 0 && d.stats.totalMessages >= int64(d.config.Count) {
|
|
||||||
fmt.Printf("\nReached message limit of %d\n", d.config.Count)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Beast message
|
|
||||||
msg, err := d.parser.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
fmt.Printf("\nEnd of data reached\n")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if d.config.Verbose {
|
|
||||||
fmt.Printf("Parse error: %v\n", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
d.stats.totalMessages++
|
|
||||||
d.stats.lastMessageTime = time.Now()
|
|
||||||
|
|
||||||
// Display Beast message info
|
|
||||||
d.displayMessage(msg)
|
|
||||||
|
|
||||||
// Decode Mode S data if available
|
|
||||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
|
||||||
d.decodeAndDisplay(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.stats.validMessages++
|
|
||||||
}
|
|
||||||
|
|
||||||
d.displayStatistics()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayMessage shows basic Beast message information
|
|
||||||
func (d *BeastDumper) displayMessage(msg *beast.Message) {
|
|
||||||
timestamp := msg.ReceivedAt.Format("15:04:05")
|
|
||||||
|
|
||||||
// Extract ICAO if available
|
|
||||||
icao := "------"
|
|
||||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
|
||||||
if icaoAddr, err := msg.GetICAO24(); err == nil {
|
|
||||||
icao = fmt.Sprintf("%06X", icaoAddr)
|
|
||||||
d.stats.aircraftSeen[icaoAddr] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beast message type
|
|
||||||
typeStr := d.formatMessageType(msg.Type)
|
|
||||||
|
|
||||||
// Signal strength
|
|
||||||
signal := msg.GetSignalStrength()
|
|
||||||
signalStr := fmt.Sprintf("%6.1f", signal)
|
|
||||||
|
|
||||||
// Data preview
|
|
||||||
dataStr := d.formatDataPreview(msg.Data)
|
|
||||||
|
|
||||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6d ",
|
|
||||||
timestamp, icao, typeStr, signalStr, dataStr, len(msg.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeAndDisplay attempts to decode Mode S message and display results
|
|
||||||
func (d *BeastDumper) decodeAndDisplay(msg *beast.Message) {
|
|
||||||
aircraft, err := d.decoder.Decode(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
if d.config.Verbose {
|
|
||||||
fmt.Printf("Decode error: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("(decode failed)\n")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display decoded information
|
|
||||||
info := d.formatAircraftInfo(aircraft)
|
|
||||||
fmt.Printf("%s\n", info)
|
|
||||||
|
|
||||||
// Verbose details
|
|
||||||
if d.config.Verbose {
|
|
||||||
d.displayVerboseInfo(aircraft, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatMessageType converts Beast message type to string
|
|
||||||
func (d *BeastDumper) formatMessageType(msgType uint8) string {
|
|
||||||
switch msgType {
|
|
||||||
case beast.BeastModeAC:
|
|
||||||
return "Mode A/C"
|
|
||||||
case beast.BeastModeS:
|
|
||||||
return "Mode S"
|
|
||||||
case beast.BeastModeSLong:
|
|
||||||
return "Mode S Long"
|
|
||||||
case beast.BeastStatusMsg:
|
|
||||||
return "Status"
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Type %02X", msgType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDataPreview creates a hex preview of message data
|
|
||||||
func (d *BeastDumper) formatDataPreview(data []byte) string {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
preview := ""
|
|
||||||
for i, b := range data {
|
|
||||||
if i >= 4 { // Show first 4 bytes
|
|
||||||
break
|
|
||||||
}
|
|
||||||
preview += fmt.Sprintf("%02X", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) > 4 {
|
|
||||||
preview += "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
return preview
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatAircraftInfo creates a summary of decoded aircraft information
|
|
||||||
func (d *BeastDumper) formatAircraftInfo(aircraft *modes.Aircraft) string {
|
|
||||||
parts := []string{}
|
|
||||||
|
|
||||||
// Callsign
|
|
||||||
if aircraft.Callsign != "" {
|
|
||||||
parts = append(parts, fmt.Sprintf("CS:%s", aircraft.Callsign))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position
|
|
||||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("POS:%.4f,%.4f", aircraft.Latitude, aircraft.Longitude))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altitude
|
|
||||||
if aircraft.Altitude != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("ALT:%dft", aircraft.Altitude))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speed and track
|
|
||||||
if aircraft.GroundSpeed != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("SPD:%dkt", aircraft.GroundSpeed))
|
|
||||||
}
|
|
||||||
if aircraft.Track != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("HDG:%d°", aircraft.Track))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical rate
|
|
||||||
if aircraft.VerticalRate != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("VS:%d", aircraft.VerticalRate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Squawk
|
|
||||||
if aircraft.Squawk != "" {
|
|
||||||
parts = append(parts, fmt.Sprintf("SQ:%s", aircraft.Squawk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emergency
|
|
||||||
if aircraft.Emergency != "" && aircraft.Emergency != "None" {
|
|
||||||
parts = append(parts, fmt.Sprintf("EMG:%s", aircraft.Emergency))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "(no data decoded)"
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ""
|
|
||||||
for i, part := range parts {
|
|
||||||
if i > 0 {
|
|
||||||
info += " "
|
|
||||||
}
|
|
||||||
info += part
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayVerboseInfo shows detailed aircraft information
|
|
||||||
func (d *BeastDumper) displayVerboseInfo(aircraft *modes.Aircraft, msg *beast.Message) {
|
|
||||||
fmt.Printf(" Message Details:\n")
|
|
||||||
fmt.Printf(" Raw Data: %s\n", d.formatHexData(msg.Data))
|
|
||||||
fmt.Printf(" Timestamp: %s\n", msg.ReceivedAt.Format("15:04:05.000"))
|
|
||||||
fmt.Printf(" Signal: %.2f dBFS\n", msg.GetSignalStrength())
|
|
||||||
|
|
||||||
fmt.Printf(" Aircraft Data:\n")
|
|
||||||
if aircraft.Callsign != "" {
|
|
||||||
fmt.Printf(" Callsign: %s\n", aircraft.Callsign)
|
|
||||||
}
|
|
||||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
|
||||||
fmt.Printf(" Position: %.6f, %.6f\n", aircraft.Latitude, aircraft.Longitude)
|
|
||||||
}
|
|
||||||
if aircraft.Altitude != 0 {
|
|
||||||
fmt.Printf(" Altitude: %d ft\n", aircraft.Altitude)
|
|
||||||
}
|
|
||||||
if aircraft.GroundSpeed != 0 || aircraft.Track != 0 {
|
|
||||||
fmt.Printf(" Speed/Track: %d kt @ %d°\n", aircraft.GroundSpeed, aircraft.Track)
|
|
||||||
}
|
|
||||||
if aircraft.VerticalRate != 0 {
|
|
||||||
fmt.Printf(" Vertical Rate: %d ft/min\n", aircraft.VerticalRate)
|
|
||||||
}
|
|
||||||
if aircraft.Squawk != "" {
|
|
||||||
fmt.Printf(" Squawk: %s\n", aircraft.Squawk)
|
|
||||||
}
|
|
||||||
if aircraft.Category != "" {
|
|
||||||
fmt.Printf(" Category: %s\n", aircraft.Category)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatHexData creates a formatted hex dump of data
|
|
||||||
func (d *BeastDumper) formatHexData(data []byte) string {
|
|
||||||
result := ""
|
|
||||||
for i, b := range data {
|
|
||||||
if i > 0 {
|
|
||||||
result += " "
|
|
||||||
}
|
|
||||||
result += fmt.Sprintf("%02X", b)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayStatistics shows final parsing statistics
|
|
||||||
func (d *BeastDumper) displayStatistics() {
|
|
||||||
duration := time.Since(d.stats.startTime)
|
|
||||||
|
|
||||||
fmt.Printf("\nStatistics:\n")
|
|
||||||
fmt.Printf("===========\n")
|
|
||||||
fmt.Printf("Total messages: %d\n", d.stats.totalMessages)
|
|
||||||
fmt.Printf("Valid messages: %d\n", d.stats.validMessages)
|
|
||||||
fmt.Printf("Unique aircraft: %d\n", len(d.stats.aircraftSeen))
|
|
||||||
fmt.Printf("Duration: %v\n", duration.Round(time.Second))
|
|
||||||
|
|
||||||
if d.stats.totalMessages > 0 && duration > 0 {
|
|
||||||
rate := float64(d.stats.totalMessages) / duration.Seconds()
|
|
||||||
fmt.Printf("Message rate: %.1f msg/sec\n", rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(d.stats.aircraftSeen) > 0 {
|
|
||||||
fmt.Printf("\nAircraft seen:\n")
|
|
||||||
for icao := range d.stats.aircraftSeen {
|
|
||||||
fmt.Printf(" %06X\n", icao)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
debian/DEBIAN/control
vendored
|
|
@ -19,5 +19,4 @@ Description: Multi-source ADS-B aircraft tracker with Beast format support
|
||||||
- Historical flight tracking
|
- Historical flight tracking
|
||||||
- Mobile-responsive design
|
- Mobile-responsive design
|
||||||
- Systemd integration for service management
|
- Systemd integration for service management
|
||||||
- Beast-dump utility for raw ADS-B data analysis
|
|
||||||
Homepage: https://github.com/skyview/skyview
|
Homepage: https://github.com/skyview/skyview
|
||||||
|
|
|
||||||
BIN
debian/usr/bin/beast-dump
vendored
95
debian/usr/share/man/man1/beast-dump.1
vendored
|
|
@ -1,95 +0,0 @@
|
||||||
.TH BEAST-DUMP 1 "2024-08-24" "SkyView 2.0.0" "User Commands"
|
|
||||||
.SH NAME
|
|
||||||
beast-dump \- Utility for analyzing raw ADS-B data in Beast binary format
|
|
||||||
.SH SYNOPSIS
|
|
||||||
.B beast-dump
|
|
||||||
[\fIOPTIONS\fR] [\fIFILE\fR]
|
|
||||||
.SH DESCRIPTION
|
|
||||||
beast-dump is a command-line utility for analyzing and decoding ADS-B
|
|
||||||
(Automatic Dependent Surveillance-Broadcast) data stored in Beast binary
|
|
||||||
format. It can read from files or connect to Beast format TCP streams
|
|
||||||
to decode and display aircraft messages.
|
|
||||||
.PP
|
|
||||||
The Beast format is a compact binary representation of Mode S/ADS-B
|
|
||||||
messages commonly used by dump1090 and similar software-defined radio
|
|
||||||
applications for aircraft tracking.
|
|
||||||
.SH OPTIONS
|
|
||||||
.TP
|
|
||||||
.B \-host \fIstring\fR
|
|
||||||
Connect to TCP host instead of reading from file
|
|
||||||
.TP
|
|
||||||
.B \-port \fIint\fR
|
|
||||||
TCP port to connect to (default 30005)
|
|
||||||
.TP
|
|
||||||
.B \-format \fIstring\fR
|
|
||||||
Output format: text, json, or csv (default "text")
|
|
||||||
.TP
|
|
||||||
.B \-filter \fIstring\fR
|
|
||||||
Filter by ICAO hex code (e.g., "A1B2C3")
|
|
||||||
.TP
|
|
||||||
.B \-types \fIstring\fR
|
|
||||||
Message types to display (comma-separated)
|
|
||||||
.TP
|
|
||||||
.B \-count \fIint\fR
|
|
||||||
Maximum number of messages to process
|
|
||||||
.TP
|
|
||||||
.B \-stats
|
|
||||||
Show statistics summary
|
|
||||||
.TP
|
|
||||||
.B \-verbose
|
|
||||||
Enable verbose output
|
|
||||||
.TP
|
|
||||||
.B \-h, \-help
|
|
||||||
Show help message and exit
|
|
||||||
.SH EXAMPLES
|
|
||||||
.TP
|
|
||||||
Analyze Beast format file:
|
|
||||||
.B beast-dump data.bin
|
|
||||||
.TP
|
|
||||||
Connect to live Beast stream:
|
|
||||||
.B beast-dump \-host localhost \-port 30005
|
|
||||||
.TP
|
|
||||||
Export to JSON format with statistics:
|
|
||||||
.B beast-dump \-format json \-stats data.bin
|
|
||||||
.TP
|
|
||||||
Filter messages for specific aircraft:
|
|
||||||
.B beast-dump \-filter A1B2C3 \-verbose data.bin
|
|
||||||
.TP
|
|
||||||
Process only first 1000 messages as CSV:
|
|
||||||
.B beast-dump \-format csv \-count 1000 data.bin
|
|
||||||
.SH OUTPUT FORMAT
|
|
||||||
The default text output shows decoded message fields:
|
|
||||||
.PP
|
|
||||||
.nf
|
|
||||||
ICAO: A1B2C3 Type: 17 Time: 12:34:56.789
|
|
||||||
Position: 51.4700, -0.4600
|
|
||||||
Altitude: 35000 ft
|
|
||||||
Speed: 450 kt
|
|
||||||
Track: 090°
|
|
||||||
.fi
|
|
||||||
.PP
|
|
||||||
JSON output provides structured data suitable for further processing.
|
|
||||||
CSV output includes headers and is suitable for spreadsheet import.
|
|
||||||
.SH MESSAGE TYPES
|
|
||||||
Common ADS-B message types:
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 4/20: Altitude and identification
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 5/21: Surface position
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 9/18/22: Airborne position (baro altitude)
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 10/18/22: Airborne position (GNSS altitude)
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 17: Extended squitter ADS-B
|
|
||||||
.IP \(bu 2
|
|
||||||
Type 19: Military extended squitter
|
|
||||||
.SH FILES
|
|
||||||
Beast format files typically use .bin or .beast extensions.
|
|
||||||
.SH SEE ALSO
|
|
||||||
.BR skyview (1),
|
|
||||||
.BR dump1090 (1)
|
|
||||||
.SH BUGS
|
|
||||||
Report bugs at: https://github.com/skyview/skyview/issues
|
|
||||||
.SH AUTHOR
|
|
||||||
SkyView Team <admin@skyview.local>
|
|
||||||
88
debian/usr/share/man/man1/skyview.1
vendored
|
|
@ -1,88 +0,0 @@
|
||||||
.TH SKYVIEW 1 "2024-08-24" "SkyView 2.0.0" "User Commands"
|
|
||||||
.SH NAME
|
|
||||||
skyview \- Multi-source ADS-B aircraft tracker with Beast format support
|
|
||||||
.SH SYNOPSIS
|
|
||||||
.B skyview
|
|
||||||
[\fIOPTIONS\fR]
|
|
||||||
.SH DESCRIPTION
|
|
||||||
SkyView is a standalone application that connects to multiple dump1090 Beast
|
|
||||||
format TCP streams and provides a modern web frontend for aircraft tracking.
|
|
||||||
It features real-time aircraft tracking, signal strength analysis, coverage
|
|
||||||
mapping, and 3D radar visualization.
|
|
||||||
.PP
|
|
||||||
The application serves a web interface on port 8080 by default and connects
|
|
||||||
to one or more Beast format data sources (typically dump1090 instances) to
|
|
||||||
aggregate aircraft data from multiple receivers.
|
|
||||||
.SH OPTIONS
|
|
||||||
.TP
|
|
||||||
.B \-config \fIstring\fR
|
|
||||||
Path to configuration file (default "config.json")
|
|
||||||
.TP
|
|
||||||
.B \-port \fIint\fR
|
|
||||||
HTTP server port (default 8080)
|
|
||||||
.TP
|
|
||||||
.B \-debug
|
|
||||||
Enable debug logging
|
|
||||||
.TP
|
|
||||||
.B \-version
|
|
||||||
Show version information and exit
|
|
||||||
.TP
|
|
||||||
.B \-h, \-help
|
|
||||||
Show help message and exit
|
|
||||||
.SH FILES
|
|
||||||
.TP
|
|
||||||
.I /etc/skyview/config.json
|
|
||||||
System-wide configuration file
|
|
||||||
.TP
|
|
||||||
.I ~/.config/skyview/config.json
|
|
||||||
Per-user configuration file
|
|
||||||
.SH EXAMPLES
|
|
||||||
.TP
|
|
||||||
Start with default configuration:
|
|
||||||
.B skyview
|
|
||||||
.TP
|
|
||||||
Start with custom config file:
|
|
||||||
.B skyview \-config /path/to/config.json
|
|
||||||
.TP
|
|
||||||
Start on port 9090 with debug logging:
|
|
||||||
.B skyview \-port 9090 \-debug
|
|
||||||
.SH CONFIGURATION
|
|
||||||
The configuration file uses JSON format with the following structure:
|
|
||||||
.PP
|
|
||||||
.nf
|
|
||||||
{
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"id": "source1",
|
|
||||||
"name": "Local Receiver",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 30005,
|
|
||||||
"latitude": 51.4700,
|
|
||||||
"longitude": -0.4600
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"web": {
|
|
||||||
"port": 8080,
|
|
||||||
"assets_path": "/usr/share/skyview/assets"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fi
|
|
||||||
.SH WEB INTERFACE
|
|
||||||
The web interface provides:
|
|
||||||
.IP \(bu 2
|
|
||||||
Interactive map view with aircraft markers
|
|
||||||
.IP \(bu 2
|
|
||||||
Aircraft data table with filtering and sorting
|
|
||||||
.IP \(bu 2
|
|
||||||
Real-time statistics and charts
|
|
||||||
.IP \(bu 2
|
|
||||||
Coverage heatmaps and range circles
|
|
||||||
.IP \(bu 2
|
|
||||||
3D radar visualization
|
|
||||||
.SH SEE ALSO
|
|
||||||
.BR beast-dump (1),
|
|
||||||
.BR dump1090 (1)
|
|
||||||
.SH BUGS
|
|
||||||
Report bugs at: https://github.com/skyview/skyview/issues
|
|
||||||
.SH AUTHOR
|
|
||||||
SkyView Team <admin@skyview.local>
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
# SkyView Architecture Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
SkyView is a high-performance, multi-source ADS-B aircraft tracking system built in Go with a modern JavaScript frontend. It connects to multiple dump1090 Beast format receivers, performs intelligent data fusion, and provides low-latency aircraft tracking through a responsive web interface.
|
|
||||||
|
|
||||||
## System Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
||||||
│ dump1090 │ │ dump1090 │ │ dump1090 │
|
|
||||||
│ Receiver 1 │ │ Receiver 2 │ │ Receiver N │
|
|
||||||
│ Port 30005 │ │ Port 30005 │ │ Port 30005 │
|
|
||||||
└─────────┬───────┘ └──────┬───────┘ └─────────┬───────┘
|
|
||||||
│ │ │
|
|
||||||
│ Beast Binary │ Beast Binary │ Beast Binary
|
|
||||||
│ TCP Stream │ TCP Stream │ TCP Stream
|
|
||||||
│ │ │
|
|
||||||
└───────────────────┼──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────▼──────────┐
|
|
||||||
│ SkyView Server │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Beast Client │ │ ── Multi-source TCP clients
|
|
||||||
│ │ Manager │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Mode S/ADS-B │ │ ── Message parsing & decoding
|
|
||||||
│ │ Decoder │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Data Merger │ │ ── Intelligent data fusion
|
|
||||||
│ │ & ICAO DB │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ HTTP/WebSocket │ │ ── Low-latency web interface
|
|
||||||
│ │ Server │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
└─────────┬──────────┘
|
|
||||||
│
|
|
||||||
┌─────────▼──────────┐
|
|
||||||
│ Web Interface │
|
|
||||||
│ │
|
|
||||||
│ • Interactive Maps │
|
|
||||||
│ • Low-latency Updates│
|
|
||||||
│ • Aircraft Details │
|
|
||||||
│ • Coverage Analysis│
|
|
||||||
│ • 3D Visualization │
|
|
||||||
└────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### 1. Beast Format Clients (`internal/client/`)
|
|
||||||
|
|
||||||
**Purpose**: Manages TCP connections to dump1090 receivers
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Concurrent connection handling for multiple sources
|
|
||||||
- Automatic reconnection with exponential backoff
|
|
||||||
- Beast binary format parsing
|
|
||||||
- Per-source connection monitoring and statistics
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `beast.go`: Main client implementation
|
|
||||||
|
|
||||||
### 2. Mode S/ADS-B Decoder (`internal/modes/`)
|
|
||||||
|
|
||||||
**Purpose**: Decodes raw Mode S and ADS-B messages into structured aircraft data
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- CPR (Compact Position Reporting) decoding with zone ambiguity resolution
|
|
||||||
- ADS-B message type parsing (position, velocity, identification)
|
|
||||||
- Aircraft category and type classification
|
|
||||||
- Signal quality assessment
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `decoder.go`: Core decoding logic
|
|
||||||
|
|
||||||
### 3. Data Merger (`internal/merger/`)
|
|
||||||
|
|
||||||
**Purpose**: Fuses aircraft data from multiple sources using intelligent conflict resolution
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Signal strength-based source selection
|
|
||||||
- High-performance data fusion and conflict resolution
|
|
||||||
- Aircraft state management and lifecycle tracking
|
|
||||||
- Historical data collection (position, altitude, speed, signal trails)
|
|
||||||
- Automatic stale aircraft cleanup
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `merger.go`: Multi-source data fusion engine
|
|
||||||
|
|
||||||
### 4. ICAO Country Database (`internal/icao/`)
|
|
||||||
|
|
||||||
**Purpose**: Provides comprehensive ICAO address to country mapping
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Embedded SQLite database with 70+ allocations covering 40+ countries
|
|
||||||
- Based on official ICAO Document 8585
|
|
||||||
- Fast range-based lookups using database indexing
|
|
||||||
- Country names, ISO codes, and flag emojis
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `database.go`: SQLite database interface
|
|
||||||
- `icao.db`: Embedded SQLite database with ICAO allocations
|
|
||||||
|
|
||||||
### 5. HTTP/WebSocket Server (`internal/server/`)
|
|
||||||
|
|
||||||
**Purpose**: Serves web interface and provides low-latency data streaming
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- RESTful API for aircraft and system data
|
|
||||||
- WebSocket connections for low-latency updates
|
|
||||||
- Static asset serving with embedded resources
|
|
||||||
- Coverage analysis and signal heatmaps
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `server.go`: HTTP server and WebSocket handler
|
|
||||||
|
|
||||||
### 6. Web Frontend (`assets/static/`)
|
|
||||||
|
|
||||||
**Purpose**: Interactive web interface for aircraft tracking and visualization
|
|
||||||
|
|
||||||
**Key Technologies**:
|
|
||||||
- **Leaflet.js**: Interactive maps and aircraft markers
|
|
||||||
- **Three.js**: 3D radar visualization
|
|
||||||
- **Chart.js**: Live statistics and charts
|
|
||||||
- **WebSockets**: Live data streaming
|
|
||||||
- **Responsive CSS**: Mobile-optimized interface
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `index.html`: Main web interface
|
|
||||||
- `js/app.js`: Main application orchestrator
|
|
||||||
- `js/modules/`: Modular JavaScript components
|
|
||||||
- `aircraft-manager.js`: Aircraft marker and trail management
|
|
||||||
- `map-manager.js`: Map controls and overlays
|
|
||||||
- `ui-manager.js`: User interface state management
|
|
||||||
- `websocket.js`: Low-latency data connections
|
|
||||||
- `css/style.css`: Responsive styling and themes
|
|
||||||
- `icons/`: SVG aircraft type icons
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### 1. Data Ingestion
|
|
||||||
1. **Beast Clients** connect to dump1090 receivers via TCP
|
|
||||||
2. **Beast Parser** processes binary message stream
|
|
||||||
3. **Mode S Decoder** converts raw messages to structured aircraft data
|
|
||||||
4. **Data Merger** receives aircraft updates with source attribution
|
|
||||||
|
|
||||||
### 2. Data Fusion
|
|
||||||
1. **Signal Analysis**: Compare signal strength across sources
|
|
||||||
2. **Conflict Resolution**: Select best data based on signal quality and recency
|
|
||||||
3. **State Management**: Update aircraft position, velocity, and metadata
|
|
||||||
4. **History Tracking**: Maintain trails for visualization
|
|
||||||
|
|
||||||
### 3. Country Lookup
|
|
||||||
1. **ICAO Extraction**: Extract 24-bit ICAO address from aircraft data
|
|
||||||
2. **Database Query**: Lookup country information in embedded SQLite database
|
|
||||||
3. **Data Enrichment**: Add country, country code, and flag to aircraft state
|
|
||||||
|
|
||||||
### 4. Data Distribution
|
|
||||||
1. **REST API**: Provide aircraft data via HTTP endpoints
|
|
||||||
2. **WebSocket Streaming**: Push low-latency updates to connected clients
|
|
||||||
3. **Frontend Processing**: Update maps, tables, and visualizations
|
|
||||||
4. **User Interface**: Display aircraft with country flags and details
|
|
||||||
|
|
||||||
## Configuration System
|
|
||||||
|
|
||||||
### Configuration Sources (Priority Order)
|
|
||||||
1. Command-line flags (highest priority)
|
|
||||||
2. Configuration file (JSON)
|
|
||||||
3. Default values (lowest priority)
|
|
||||||
|
|
||||||
### Configuration Structure
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server": {
|
|
||||||
"host": "", // Bind address (empty = all interfaces)
|
|
||||||
"port": 8080 // HTTP server port
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"id": "unique-id", // Source identifier
|
|
||||||
"name": "Display Name", // Human-readable name
|
|
||||||
"host": "hostname", // Receiver hostname/IP
|
|
||||||
"port": 30005, // Beast format port
|
|
||||||
"latitude": 51.4700, // Receiver location
|
|
||||||
"longitude": -0.4600,
|
|
||||||
"altitude": 50.0, // Meters above sea level
|
|
||||||
"enabled": true // Source enable/disable
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"history_limit": 500, // Max trail points per aircraft
|
|
||||||
"stale_timeout": 60, // Seconds before aircraft removed
|
|
||||||
"update_rate": 1 // WebSocket update frequency
|
|
||||||
},
|
|
||||||
"origin": {
|
|
||||||
"latitude": 51.4700, // Map center point
|
|
||||||
"longitude": -0.4600,
|
|
||||||
"name": "Origin Name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Characteristics
|
|
||||||
|
|
||||||
### Concurrency Model
|
|
||||||
- **Goroutine per Source**: Each Beast client runs in separate goroutine
|
|
||||||
- **Mutex-Protected Merger**: Thread-safe aircraft state management
|
|
||||||
- **WebSocket Broadcasting**: Concurrent client update distribution
|
|
||||||
- **Non-blocking I/O**: Asynchronous network operations
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
- **Bounded History**: Configurable limits on historical data storage
|
|
||||||
- **Automatic Cleanup**: Stale aircraft removal to prevent memory leaks
|
|
||||||
- **Efficient Data Structures**: Maps for O(1) aircraft lookups
|
|
||||||
- **Embedded Assets**: Static files bundled in binary
|
|
||||||
|
|
||||||
### Scalability
|
|
||||||
- **Multi-source Support**: Tested with 10+ concurrent receivers
|
|
||||||
- **High Message Throughput**: Handles 1000+ messages/second per source
|
|
||||||
- **Low-latency Updates**: Sub-second latency for aircraft updates
|
|
||||||
- **Responsive Web UI**: Optimized for 100+ concurrent aircraft
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Network Security
|
|
||||||
- **No Authentication Required**: Designed for trusted network environments
|
|
||||||
- **Local Network Operation**: Intended for private receiver networks
|
|
||||||
- **WebSocket Origin Checking**: Basic CORS protection
|
|
||||||
|
|
||||||
### System Security
|
|
||||||
- **Unprivileged Execution**: Runs as non-root user in production
|
|
||||||
- **Filesystem Isolation**: Minimal file system access required
|
|
||||||
- **Network Isolation**: Only requires outbound TCP to receivers
|
|
||||||
- **Systemd Hardening**: Security features enabled in service file
|
|
||||||
|
|
||||||
### Data Privacy
|
|
||||||
- **Public ADS-B Data**: Only processes publicly broadcast aircraft data
|
|
||||||
- **No Personal Information**: Aircraft tracking only, no passenger data
|
|
||||||
- **Local Processing**: No data transmitted to external services
|
|
||||||
- **Historical Limits**: Configurable data retention periods
|
|
||||||
|
|
||||||
## External Resources
|
|
||||||
|
|
||||||
### Official Standards
|
|
||||||
- **ICAO Document 8585**: Designators for Aircraft Operating Agencies
|
|
||||||
- **RTCA DO-260B**: ADS-B Message Formats and Protocols
|
|
||||||
- **ITU-R M.1371-5**: Technical characteristics for universal ADS-B
|
|
||||||
|
|
||||||
### Technology Dependencies
|
|
||||||
- **Go Language**: https://golang.org/
|
|
||||||
- **Leaflet.js**: https://leafletjs.com/ - Interactive maps
|
|
||||||
- **Three.js**: https://threejs.org/ - 3D visualization
|
|
||||||
- **Chart.js**: https://www.chartjs.org/ - Statistics charts
|
|
||||||
- **SQLite**: https://www.sqlite.org/ - ICAO country database
|
|
||||||
- **WebSocket Protocol**: RFC 6455
|
|
||||||
|
|
||||||
### ADS-B Ecosystem
|
|
||||||
- **dump1090**: https://github.com/antirez/dump1090 - SDR ADS-B decoder
|
|
||||||
- **Beast Binary Format**: Mode S data interchange format
|
|
||||||
- **FlightAware**: ADS-B network and data provider
|
|
||||||
- **OpenSky Network**: Research-oriented ADS-B network
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- **Package per Component**: Clear separation of concerns
|
|
||||||
- **Interface Abstractions**: Testable and mockable components
|
|
||||||
- **Error Handling**: Comprehensive error reporting and recovery
|
|
||||||
- **Documentation**: Extensive code comments and examples
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
- **Unit Tests**: Component-level testing with mocks
|
|
||||||
- **Integration Tests**: End-to-end data flow validation
|
|
||||||
- **Performance Tests**: Load testing with simulated data
|
|
||||||
- **Manual Testing**: Real-world receiver validation
|
|
||||||
|
|
||||||
### Deployment Options
|
|
||||||
- **Standalone Binary**: Single executable with embedded assets
|
|
||||||
- **Debian Package**: Systemd service with configuration
|
|
||||||
- **Docker Container**: Containerized deployment option
|
|
||||||
- **Development Mode**: Hot-reload for frontend development
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**SkyView Architecture** - Designed for reliability, performance, and extensibility in multi-source ADS-B tracking applications.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
# SkyView Project Guidelines
|
|
||||||
|
|
||||||
## Documentation Requirements
|
|
||||||
- We should always have an up to date document describing our architecture and features
|
|
||||||
- Include links to any external resources we've used
|
|
||||||
- We should also always have an up to date README describing the project
|
|
||||||
- Shell scripts should be validated with shellcheck
|
|
||||||
- Always make sure the code is well documented with explanations for why and how a particular solution is selected
|
|
||||||
|
|
||||||
## Development Principles
|
|
||||||
- An overarching principle with all code is KISS, Keep It Simple Stupid
|
|
||||||
- We do not want to create code that is more complicated than necessary
|
|
||||||
- When changing code, always make sure to update any relevant tests
|
|
||||||
- Use proper error handling - aviation applications need reliability
|
|
||||||
|
|
||||||
## SkyView-Specific Guidelines
|
|
||||||
|
|
||||||
### Architecture & Design
|
|
||||||
- Multi-source ADS-B data fusion is the core feature - prioritize signal strength-based conflict resolution
|
|
||||||
- Embedded resources (SQLite ICAO database, static assets) over external dependencies
|
|
||||||
- Low-latency performance is critical - optimize for fast WebSocket updates
|
|
||||||
- Support concurrent aircraft tracking (100+ aircraft should work smoothly)
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- Keep Go packages focused: beast parsing, modes decoding, merger, server, clients
|
|
||||||
- Frontend should be modular: separate managers for aircraft, map, UI, websockets
|
|
||||||
- Database operations should be fast (use indexes, avoid N+1 queries)
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
- Beast binary parsing must handle high message rates (1000+ msg/sec per source)
|
|
||||||
- WebSocket broadcasting should not block on slow clients
|
|
||||||
- Memory usage should be bounded (configurable history limits)
|
|
||||||
- CPU usage should remain low during normal operation
|
|
||||||
|
|
||||||
### Documentation Maintenance
|
|
||||||
- Always update docs/ARCHITECTURE.md when changing system design
|
|
||||||
- README.md should stay current with features and usage
|
|
||||||
- External resources (ICAO docs, ADS-B standards) should be linked in documentation
|
|
||||||
- Country database updates should be straightforward (replace SQLite file)
|
|
||||||
|
|
@ -130,13 +130,8 @@ func (p *Parser) ReadMessage() (*Message, error) {
|
||||||
case BeastStatusMsg:
|
case BeastStatusMsg:
|
||||||
// Status messages have variable length, skip for now
|
// Status messages have variable length, skip for now
|
||||||
return p.ReadMessage()
|
return p.ReadMessage()
|
||||||
case BeastEscape:
|
|
||||||
// Handle double escape sequence (0x1A 0x1A) - skip and continue
|
|
||||||
return p.ReadMessage()
|
|
||||||
default:
|
default:
|
||||||
// Skip unknown message types and continue parsing instead of failing
|
return nil, fmt.Errorf("unknown message type: 0x%02x", msgType)
|
||||||
// This makes the parser more resilient to malformed or extended Beast formats
|
|
||||||
return p.ReadMessage()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read timestamp (6 bytes, 48-bit)
|
// Read timestamp (6 bytes, 48-bit)
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,8 @@ func NewBeastClient(source *merger.Source, merger *merger.Merger) *BeastClient {
|
||||||
return &BeastClient{
|
return &BeastClient{
|
||||||
source: source,
|
source: source,
|
||||||
merger: merger,
|
merger: merger,
|
||||||
decoder: modes.NewDecoder(source.Latitude, source.Longitude),
|
decoder: modes.NewDecoder(),
|
||||||
msgChan: make(chan *beast.Message, 5000),
|
msgChan: make(chan *beast.Message, 1000),
|
||||||
errChan: make(chan error, 10),
|
errChan: make(chan error, 10),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
reconnectDelay: 5 * time.Second,
|
reconnectDelay: 5 * time.Second,
|
||||||
|
|
@ -146,7 +146,7 @@ func (c *BeastClient) run(ctx context.Context) {
|
||||||
addr := fmt.Sprintf("%s:%d", c.source.Host, c.source.Port)
|
addr := fmt.Sprintf("%s:%d", c.source.Host, c.source.Port)
|
||||||
fmt.Printf("Connecting to Beast stream at %s (%s)...\n", addr, c.source.Name)
|
fmt.Printf("Connecting to Beast stream at %s (%s)...\n", addr, c.source.Name)
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Failed to connect to %s: %v\n", c.source.Name, err)
|
fmt.Printf("Failed to connect to %s: %v\n", c.source.Name, err)
|
||||||
c.source.Active = false
|
c.source.Active = false
|
||||||
|
|
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
package icao
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Database handles ICAO address to country lookups
|
|
||||||
type Database struct {
|
|
||||||
allocations []ICAOAllocation
|
|
||||||
}
|
|
||||||
|
|
||||||
// ICAOAllocation represents an ICAO address range allocation
|
|
||||||
type ICAOAllocation struct {
|
|
||||||
StartAddr int64
|
|
||||||
EndAddr int64
|
|
||||||
Country string
|
|
||||||
CountryCode string
|
|
||||||
Flag string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountryInfo represents country information for an aircraft
|
|
||||||
type CountryInfo struct {
|
|
||||||
Country string `json:"country"`
|
|
||||||
CountryCode string `json:"country_code"`
|
|
||||||
Flag string `json:"flag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDatabase creates a new ICAO database with comprehensive allocation data
|
|
||||||
func NewDatabase() (*Database, error) {
|
|
||||||
allocations := getICAOAllocations()
|
|
||||||
|
|
||||||
// Sort allocations by start address for efficient binary search
|
|
||||||
sort.Slice(allocations, func(i, j int) bool {
|
|
||||||
return allocations[i].StartAddr < allocations[j].StartAddr
|
|
||||||
})
|
|
||||||
|
|
||||||
return &Database{allocations: allocations}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getICAOAllocations returns comprehensive ICAO allocation data based on official aerotransport.org table
|
|
||||||
func getICAOAllocations() []ICAOAllocation {
|
|
||||||
// ICAO allocations based on official ICAO 24-bit address allocation table
|
|
||||||
// Source: https://www.aerotransport.org/ (unofficial but comprehensive reference)
|
|
||||||
// Complete coverage of all allocated ICAO 24-bit addresses
|
|
||||||
return []ICAOAllocation{
|
|
||||||
// Africa
|
|
||||||
{0x004000, 0x0043FF, "Zimbabwe", "ZW", "🇿🇼", "Republic of Zimbabwe"},
|
|
||||||
{0x006000, 0x006FFF, "Mozambique", "MZ", "🇲🇿", "Republic of Mozambique"},
|
|
||||||
{0x008000, 0x00FFFF, "South Africa", "ZA", "🇿🇦", "Republic of South Africa"},
|
|
||||||
{0x010000, 0x017FFF, "Egypt", "EG", "🇪🇬", "Arab Republic of Egypt"},
|
|
||||||
{0x018000, 0x01FFFF, "Libya", "LY", "🇱🇾", "State of Libya"},
|
|
||||||
{0x020000, 0x027FFF, "Morocco", "MA", "🇲🇦", "Kingdom of Morocco"},
|
|
||||||
{0x028000, 0x02FFFF, "Tunisia", "TN", "🇹🇳", "Republic of Tunisia"},
|
|
||||||
{0x030000, 0x0303FF, "Botswana", "BW", "🇧🇼", "Republic of Botswana"},
|
|
||||||
{0x032000, 0x032FFF, "Burundi", "BI", "🇧🇮", "Republic of Burundi"},
|
|
||||||
{0x034000, 0x034FFF, "Cameroon", "CM", "🇨🇲", "Republic of Cameroon"},
|
|
||||||
{0x035000, 0x0353FF, "Comoros", "KM", "🇰🇲", "Union of the Comoros"},
|
|
||||||
{0x036000, 0x036FFF, "Congo", "CG", "🇨🇬", "Republic of the Congo"},
|
|
||||||
{0x038000, 0x038FFF, "Côte d'Ivoire", "CI", "🇨🇮", "Republic of Côte d'Ivoire"},
|
|
||||||
{0x03E000, 0x03EFFF, "Gabon", "GA", "🇬🇦", "Gabonese Republic"},
|
|
||||||
{0x040000, 0x040FFF, "Ethiopia", "ET", "🇪🇹", "Federal Democratic Republic of Ethiopia"},
|
|
||||||
{0x042000, 0x042FFF, "Equatorial Guinea", "GQ", "🇬🇶", "Republic of Equatorial Guinea"},
|
|
||||||
{0x044000, 0x044FFF, "Ghana", "GH", "🇬🇭", "Republic of Ghana"},
|
|
||||||
{0x046000, 0x046FFF, "Guinea", "GN", "🇬🇳", "Republic of Guinea"},
|
|
||||||
{0x048000, 0x0483FF, "Guinea-Bissau", "GW", "🇬🇼", "Republic of Guinea-Bissau"},
|
|
||||||
{0x04A000, 0x04A3FF, "Lesotho", "LS", "🇱🇸", "Kingdom of Lesotho"},
|
|
||||||
{0x04C000, 0x04CFFF, "Kenya", "KE", "🇰🇪", "Republic of Kenya"},
|
|
||||||
{0x050000, 0x050FFF, "Liberia", "LR", "🇱🇷", "Republic of Liberia"},
|
|
||||||
{0x054000, 0x054FFF, "Madagascar", "MG", "🇲🇬", "Republic of Madagascar"},
|
|
||||||
{0x058000, 0x058FFF, "Malawi", "MW", "🇲🇼", "Republic of Malawi"},
|
|
||||||
{0x05C000, 0x05CFFF, "Mali", "ML", "🇲🇱", "Republic of Mali"},
|
|
||||||
{0x05E000, 0x05E3FF, "Mauritania", "MR", "🇲🇷", "Islamic Republic of Mauritania"},
|
|
||||||
{0x060000, 0x0603FF, "Mauritius", "MU", "🇲🇺", "Republic of Mauritius"},
|
|
||||||
{0x062000, 0x062FFF, "Niger", "NE", "🇳🇪", "Republic of Niger"},
|
|
||||||
{0x064000, 0x064FFF, "Nigeria", "NG", "🇳🇬", "Federal Republic of Nigeria"},
|
|
||||||
{0x068000, 0x068FFF, "Uganda", "UG", "🇺🇬", "Republic of Uganda"},
|
|
||||||
{0x06C000, 0x06CFFF, "Central African Republic", "CF", "🇨🇫", "Central African Republic"},
|
|
||||||
{0x06E000, 0x06EFFF, "Rwanda", "RW", "🇷🇼", "Republic of Rwanda"},
|
|
||||||
{0x070000, 0x070FFF, "Senegal", "SN", "🇸🇳", "Republic of Senegal"},
|
|
||||||
{0x074000, 0x0743FF, "Seychelles", "SC", "🇸🇨", "Republic of Seychelles"},
|
|
||||||
{0x076000, 0x0763FF, "Sierra Leone", "SL", "🇸🇱", "Republic of Sierra Leone"},
|
|
||||||
{0x078000, 0x078FFF, "Somalia", "SO", "🇸🇴", "Federal Republic of Somalia"},
|
|
||||||
{0x07A000, 0x07A3FF, "Swaziland", "SZ", "🇸🇿", "Kingdom of Swaziland"},
|
|
||||||
{0x07C000, 0x07CFFF, "Sudan", "SD", "🇸🇩", "Republic of Sudan"},
|
|
||||||
{0x080000, 0x080FFF, "Tanzania", "TZ", "🇹🇿", "United Republic of Tanzania"},
|
|
||||||
{0x084000, 0x084FFF, "Chad", "TD", "🇹🇩", "Republic of Chad"},
|
|
||||||
{0x088000, 0x088FFF, "Togo", "TG", "🇹🇬", "Togolese Republic"},
|
|
||||||
{0x08A000, 0x08AFFF, "Zambia", "ZM", "🇿🇲", "Republic of Zambia"},
|
|
||||||
{0x08C000, 0x08CFFF, "D R Congo", "CD", "🇨🇩", "Democratic Republic of the Congo"},
|
|
||||||
{0x090000, 0x090FFF, "Angola", "AO", "🇦🇴", "Republic of Angola"},
|
|
||||||
{0x094000, 0x0943FF, "Benin", "BJ", "🇧🇯", "Republic of Benin"},
|
|
||||||
{0x096000, 0x0963FF, "Cape Verde", "CV", "🇨🇻", "Republic of Cape Verde"},
|
|
||||||
{0x098000, 0x0983FF, "Djibouti", "DJ", "🇩🇯", "Republic of Djibouti"},
|
|
||||||
{0x0A8000, 0x0A8FFF, "Bahamas", "BS", "🇧🇸", "Commonwealth of the Bahamas"},
|
|
||||||
{0x0AA000, 0x0AA3FF, "Barbados", "BB", "🇧🇧", "Barbados"},
|
|
||||||
{0x0AB000, 0x0AB3FF, "Belize", "BZ", "🇧🇿", "Belize"},
|
|
||||||
{0x0B0000, 0x0B0FFF, "Cuba", "CU", "🇨🇺", "Republic of Cuba"},
|
|
||||||
{0x0B2000, 0x0B2FFF, "El Salvador", "SV", "🇸🇻", "Republic of El Salvador"},
|
|
||||||
{0x0B8000, 0x0B8FFF, "Haiti", "HT", "🇭🇹", "Republic of Haiti"},
|
|
||||||
{0x0BA000, 0x0BAFFF, "Honduras", "HN", "🇭🇳", "Republic of Honduras"},
|
|
||||||
{0x0BC000, 0x0BC3FF, "St. Vincent + Grenadines", "VC", "🇻🇨", "Saint Vincent and the Grenadines"},
|
|
||||||
{0x0BE000, 0x0BEFFF, "Jamaica", "JM", "🇯🇲", "Jamaica"},
|
|
||||||
{0x0D0000, 0x0D7FFF, "Mexico", "MX", "🇲🇽", "United Mexican States"},
|
|
||||||
|
|
||||||
// Eastern Europe & Russia
|
|
||||||
{0x100000, 0x1FFFFF, "Russia", "RU", "🇷🇺", "Russian Federation"},
|
|
||||||
{0x201000, 0x2013FF, "Namibia", "NA", "🇳🇦", "Republic of Namibia"},
|
|
||||||
{0x202000, 0x2023FF, "Eritrea", "ER", "🇪🇷", "State of Eritrea"},
|
|
||||||
|
|
||||||
// Europe
|
|
||||||
{0x300000, 0x33FFFF, "Italy", "IT", "🇮🇹", "Italian Republic"},
|
|
||||||
{0x340000, 0x37FFFF, "Spain", "ES", "🇪🇸", "Kingdom of Spain"},
|
|
||||||
{0x380000, 0x3BFFFF, "France", "FR", "🇫🇷", "French Republic"},
|
|
||||||
{0x3C0000, 0x3FFFFF, "Germany", "DE", "🇩🇪", "Federal Republic of Germany"},
|
|
||||||
{0x400000, 0x43FFFF, "United Kingdom", "GB", "🇬🇧", "United Kingdom"},
|
|
||||||
{0x440000, 0x447FFF, "Austria", "AT", "🇦🇹", "Republic of Austria"},
|
|
||||||
{0x448000, 0x44FFFF, "Belgium", "BE", "🇧🇪", "Kingdom of Belgium"},
|
|
||||||
{0x450000, 0x457FFF, "Bulgaria", "BG", "🇧🇬", "Republic of Bulgaria"},
|
|
||||||
{0x458000, 0x45FFFF, "Denmark", "DK", "🇩🇰", "Kingdom of Denmark"},
|
|
||||||
{0x460000, 0x467FFF, "Finland", "FI", "🇫🇮", "Republic of Finland"},
|
|
||||||
{0x468000, 0x46FFFF, "Greece", "GR", "🇬🇷", "Hellenic Republic"},
|
|
||||||
{0x470000, 0x477FFF, "Hungary", "HU", "🇭🇺", "Republic of Hungary"},
|
|
||||||
{0x478000, 0x47FFFF, "Norway", "NO", "🇳🇴", "Kingdom of Norway"},
|
|
||||||
{0x480000, 0x487FFF, "Netherlands", "NL", "🇳🇱", "Kingdom of the Netherlands"},
|
|
||||||
{0x488000, 0x48FFFF, "Poland", "PL", "🇵🇱", "Republic of Poland"},
|
|
||||||
{0x490000, 0x497FFF, "Portugal", "PT", "🇵🇹", "Portuguese Republic"},
|
|
||||||
{0x498000, 0x49FFFF, "Czech Republic", "CZ", "🇨🇿", "Czech Republic"},
|
|
||||||
{0x4A0000, 0x4A7FFF, "Romania", "RO", "🇷🇴", "Romania"},
|
|
||||||
{0x4A8000, 0x4AFFFF, "Sweden", "SE", "🇸🇪", "Kingdom of Sweden"},
|
|
||||||
{0x4B0000, 0x4B7FFF, "Switzerland", "CH", "🇨🇭", "Swiss Confederation"},
|
|
||||||
{0x4B8000, 0x4BFFFF, "Turkey", "TR", "🇹🇷", "Republic of Turkey"},
|
|
||||||
{0x4C0000, 0x4C7FFF, "Yugoslavia", "YU", "🇷🇸", "Yugoslavia"},
|
|
||||||
{0x4C8000, 0x4C83FF, "Cyprus", "CY", "🇨🇾", "Republic of Cyprus"},
|
|
||||||
{0x4CA000, 0x4CAFFF, "Ireland", "IE", "🇮🇪", "Republic of Ireland"},
|
|
||||||
{0x4CC000, 0x4CCFFF, "Iceland", "IS", "🇮🇸", "Republic of Iceland"},
|
|
||||||
{0x4D0000, 0x4D03FF, "Luxembourg", "LU", "🇱🇺", "Grand Duchy of Luxembourg"},
|
|
||||||
{0x4D2000, 0x4D23FF, "Malta", "MT", "🇲🇹", "Republic of Malta"},
|
|
||||||
{0x4D4000, 0x4D43FF, "Monaco", "MC", "🇲🇨", "Principality of Monaco"},
|
|
||||||
{0x500000, 0x5004FF, "San Marino", "SM", "🇸🇲", "Republic of San Marino"},
|
|
||||||
{0x501000, 0x5013FF, "Albania", "AL", "🇦🇱", "Republic of Albania"},
|
|
||||||
{0x501C00, 0x501FFF, "Croatia", "HR", "🇭🇷", "Republic of Croatia"},
|
|
||||||
{0x502C00, 0x502FFF, "Latvia", "LV", "🇱🇻", "Republic of Latvia"},
|
|
||||||
{0x503C00, 0x503FFF, "Lithuania", "LT", "🇱🇹", "Republic of Lithuania"},
|
|
||||||
{0x504C00, 0x504FFF, "Moldova", "MD", "🇲🇩", "Republic of Moldova"},
|
|
||||||
{0x505C00, 0x505FFF, "Slovakia", "SK", "🇸🇰", "Slovak Republic"},
|
|
||||||
{0x506C00, 0x506FFF, "Slovenia", "SI", "🇸🇮", "Republic of Slovenia"},
|
|
||||||
{0x508000, 0x50FFFF, "Ukraine", "UA", "🇺🇦", "Ukraine"},
|
|
||||||
{0x510000, 0x5103FF, "Belarus", "BY", "🇧🇾", "Republic of Belarus"},
|
|
||||||
{0x511000, 0x5113FF, "Estonia", "EE", "🇪🇪", "Republic of Estonia"},
|
|
||||||
{0x512000, 0x5123FF, "Macedonia", "MK", "🇲🇰", "North Macedonia"},
|
|
||||||
{0x513000, 0x5133FF, "Bosnia & Herzegovina", "BA", "🇧🇦", "Bosnia and Herzegovina"},
|
|
||||||
{0x514000, 0x5143FF, "Georgia", "GE", "🇬🇪", "Georgia"},
|
|
||||||
|
|
||||||
// Middle East & Central Asia
|
|
||||||
{0x600000, 0x6003FF, "Armenia", "AM", "🇦🇲", "Republic of Armenia"},
|
|
||||||
{0x600800, 0x600BFF, "Azerbaijan", "AZ", "🇦🇿", "Republic of Azerbaijan"},
|
|
||||||
{0x680000, 0x6803FF, "Bhutan", "BT", "🇧🇹", "Kingdom of Bhutan"},
|
|
||||||
{0x681000, 0x6813FF, "Micronesia", "FM", "🇫🇲", "Federated States of Micronesia"},
|
|
||||||
{0x682000, 0x6823FF, "Mongolia", "MN", "🇲🇳", "Mongolia"},
|
|
||||||
{0x683000, 0x6833FF, "Kazakhstan", "KZ", "🇰🇿", "Republic of Kazakhstan"},
|
|
||||||
{0x06A000, 0x06A3FF, "Qatar", "QA", "🇶🇦", "State of Qatar"},
|
|
||||||
{0x700000, 0x700FFF, "Afghanistan", "AF", "🇦🇫", "Islamic Republic of Afghanistan"},
|
|
||||||
{0x702000, 0x702FFF, "Bangladesh", "BD", "🇧🇩", "People's Republic of Bangladesh"},
|
|
||||||
{0x704000, 0x704FFF, "Myanmar", "MM", "🇲🇲", "Republic of the Union of Myanmar"},
|
|
||||||
{0x706000, 0x706FFF, "Kuwait", "KW", "🇰🇼", "State of Kuwait"},
|
|
||||||
{0x708000, 0x708FFF, "Laos", "LA", "🇱🇦", "Lao People's Democratic Republic"},
|
|
||||||
{0x70A000, 0x70AFFF, "Nepal", "NP", "🇳🇵", "Federal Democratic Republic of Nepal"},
|
|
||||||
{0x70C000, 0x70C3FF, "Oman", "OM", "🇴🇲", "Sultanate of Oman"},
|
|
||||||
{0x70E000, 0x70EFFF, "Cambodia", "KH", "🇰🇭", "Kingdom of Cambodia"},
|
|
||||||
{0x710000, 0x717FFF, "Saudi Arabia", "SA", "🇸🇦", "Kingdom of Saudi Arabia"},
|
|
||||||
{0x718000, 0x71FFFF, "South Korea", "KR", "🇰🇷", "Republic of Korea"},
|
|
||||||
{0x720000, 0x727FFF, "North Korea", "KP", "🇰🇵", "Democratic People's Republic of Korea"},
|
|
||||||
{0x728000, 0x72FFFF, "Iraq", "IQ", "🇮🇶", "Republic of Iraq"},
|
|
||||||
{0x730000, 0x737FFF, "Iran", "IR", "🇮🇷", "Islamic Republic of Iran"},
|
|
||||||
{0x738000, 0x73FFFF, "Israel", "IL", "🇮🇱", "State of Israel"},
|
|
||||||
{0x740000, 0x747FFF, "Jordan", "JO", "🇯🇴", "Hashemite Kingdom of Jordan"},
|
|
||||||
{0x750000, 0x757FFF, "Malaysia", "MY", "🇲🇾", "Malaysia"},
|
|
||||||
{0x758000, 0x75FFFF, "Philippines", "PH", "🇵🇭", "Republic of the Philippines"},
|
|
||||||
{0x760000, 0x767FFF, "Pakistan", "PK", "🇵🇰", "Islamic Republic of Pakistan"},
|
|
||||||
{0x768000, 0x76FFFF, "Singapore", "SG", "🇸🇬", "Republic of Singapore"},
|
|
||||||
{0x770000, 0x777FFF, "Sri Lanka", "LK", "🇱🇰", "Democratic Socialist Republic of Sri Lanka"},
|
|
||||||
{0x778000, 0x77FFFF, "Syria", "SY", "🇸🇾", "Syrian Arab Republic"},
|
|
||||||
{0x780000, 0x7BFFFF, "China", "CN", "🇨🇳", "People's Republic of China"},
|
|
||||||
{0x7C0000, 0x7FFFFF, "Australia", "AU", "🇦🇺", "Commonwealth of Australia"},
|
|
||||||
|
|
||||||
// Asia-Pacific
|
|
||||||
{0x800000, 0x83FFFF, "India", "IN", "🇮🇳", "Republic of India"},
|
|
||||||
{0x840000, 0x87FFFF, "Japan", "JP", "🇯🇵", "Japan"},
|
|
||||||
{0x880000, 0x887FFF, "Thailand", "TH", "🇹🇭", "Kingdom of Thailand"},
|
|
||||||
{0x888000, 0x88FFFF, "Vietnam", "VN", "🇻🇳", "Socialist Republic of Vietnam"},
|
|
||||||
{0x890000, 0x890FFF, "Yemen", "YE", "🇾🇪", "Republic of Yemen"},
|
|
||||||
{0x894000, 0x894FFF, "Bahrain", "BH", "🇧🇭", "Kingdom of Bahrain"},
|
|
||||||
{0x895000, 0x8953FF, "Brunei", "BN", "🇧🇳", "Nation of Brunei"},
|
|
||||||
{0x896000, 0x8973FF, "United Arab Emirates", "AE", "🇦🇪", "United Arab Emirates"},
|
|
||||||
{0x897000, 0x8973FF, "Solomon Islands", "SB", "🇸🇧", "Solomon Islands"},
|
|
||||||
{0x898000, 0x898FFF, "Papua New Guinea", "PG", "🇵🇬", "Independent State of Papua New Guinea"},
|
|
||||||
{0x899000, 0x8993FF, "Taiwan", "TW", "🇹🇼", "Republic of China (Taiwan)"},
|
|
||||||
{0x8A0000, 0x8A7FFF, "Indonesia", "ID", "🇮🇩", "Republic of Indonesia"},
|
|
||||||
|
|
||||||
// North America
|
|
||||||
{0xA00000, 0xAFFFFF, "United States", "US", "🇺🇸", "United States of America"},
|
|
||||||
|
|
||||||
// North America & Oceania
|
|
||||||
{0xC00000, 0xC3FFFF, "Canada", "CA", "🇨🇦", "Canada"},
|
|
||||||
{0xC80000, 0xC87FFF, "New Zealand", "NZ", "🇳🇿", "New Zealand"},
|
|
||||||
{0xC88000, 0xC88FFF, "Fiji", "FJ", "🇫🇯", "Republic of Fiji"},
|
|
||||||
{0xC8A000, 0xC8A3FF, "Nauru", "NR", "🇳🇷", "Republic of Nauru"},
|
|
||||||
{0xC8C000, 0xC8C3FF, "Saint Lucia", "LC", "🇱🇨", "Saint Lucia"},
|
|
||||||
{0xC8D000, 0xC8D3FF, "Tonga", "TO", "🇹🇴", "Kingdom of Tonga"},
|
|
||||||
{0xC8E000, 0xC8E3FF, "Kiribati", "KI", "🇰🇮", "Republic of Kiribati"},
|
|
||||||
|
|
||||||
// South America
|
|
||||||
{0xE00000, 0xE3FFFF, "Argentina", "AR", "🇦🇷", "Argentine Republic"},
|
|
||||||
{0xE40000, 0xE7FFFF, "Brazil", "BR", "🇧🇷", "Federative Republic of Brazil"},
|
|
||||||
{0xE80000, 0xE80FFF, "Chile", "CL", "🇨🇱", "Republic of Chile"},
|
|
||||||
{0xE84000, 0xE84FFF, "Ecuador", "EC", "🇪🇨", "Republic of Ecuador"},
|
|
||||||
{0xE88000, 0xE88FFF, "Paraguay", "PY", "🇵🇾", "Republic of Paraguay"},
|
|
||||||
{0xE8C000, 0xE8CFFF, "Peru", "PE", "🇵🇪", "Republic of Peru"},
|
|
||||||
{0xE90000, 0xE90FFF, "Uruguay", "UY", "🇺🇾", "Oriental Republic of Uruguay"},
|
|
||||||
{0xE94000, 0xE94FFF, "Bolivia", "BO", "🇧🇴", "Plurinational State of Bolivia"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupCountry returns country information for an ICAO address using binary search
|
|
||||||
func (d *Database) LookupCountry(icaoHex string) (*CountryInfo, error) {
|
|
||||||
if len(icaoHex) != 6 {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert hex string to integer
|
|
||||||
icaoInt, err := strconv.ParseInt(icaoHex, 16, 64)
|
|
||||||
if err != nil {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary search for the ICAO address range
|
|
||||||
for _, alloc := range d.allocations {
|
|
||||||
if icaoInt >= alloc.StartAddr && icaoInt <= alloc.EndAddr {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: alloc.Country,
|
|
||||||
CountryCode: alloc.CountryCode,
|
|
||||||
Flag: alloc.Flag,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found in any allocation
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close is a no-op since we don't have any resources to clean up
|
|
||||||
func (d *Database) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -20,21 +20,13 @@
|
||||||
package merger
|
package merger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"skyview/internal/icao"
|
|
||||||
"skyview/internal/modes"
|
"skyview/internal/modes"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
// MaxDistance represents an infinite distance for initialization
|
|
||||||
MaxDistance = float64(999999)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Source represents a data source (dump1090 receiver or similar ADS-B source).
|
// Source represents a data source (dump1090 receiver or similar ADS-B source).
|
||||||
// It contains both static configuration and dynamic status information used
|
// It contains both static configuration and dynamic status information used
|
||||||
// for data fusion decisions and source monitoring.
|
// for data fusion decisions and source monitoring.
|
||||||
|
|
@ -78,103 +70,6 @@ type AircraftState struct {
|
||||||
MLATSources []string `json:"mlat_sources"` // Sources providing MLAT position data
|
MLATSources []string `json:"mlat_sources"` // Sources providing MLAT position data
|
||||||
PositionSource string `json:"position_source"` // Source providing current position
|
PositionSource string `json:"position_source"` // Source providing current position
|
||||||
UpdateRate float64 `json:"update_rate"` // Recent updates per second
|
UpdateRate float64 `json:"update_rate"` // Recent updates per second
|
||||||
Country string `json:"country"` // Country of registration
|
|
||||||
CountryCode string `json:"country_code"` // ISO country code
|
|
||||||
Flag string `json:"flag"` // Country flag emoji
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON provides custom JSON marshaling for AircraftState to format ICAO24 as hex.
|
|
||||||
func (a *AircraftState) MarshalJSON() ([]byte, error) {
|
|
||||||
// Create a struct that mirrors AircraftState but with ICAO24 as string
|
|
||||||
return json.Marshal(&struct {
|
|
||||||
// From embedded modes.Aircraft
|
|
||||||
ICAO24 string `json:"ICAO24"`
|
|
||||||
Callsign string `json:"Callsign"`
|
|
||||||
Latitude float64 `json:"Latitude"`
|
|
||||||
Longitude float64 `json:"Longitude"`
|
|
||||||
Altitude int `json:"Altitude"`
|
|
||||||
BaroAltitude int `json:"BaroAltitude"`
|
|
||||||
GeomAltitude int `json:"GeomAltitude"`
|
|
||||||
VerticalRate int `json:"VerticalRate"`
|
|
||||||
GroundSpeed int `json:"GroundSpeed"`
|
|
||||||
Track int `json:"Track"`
|
|
||||||
Heading int `json:"Heading"`
|
|
||||||
Category string `json:"Category"`
|
|
||||||
Squawk string `json:"Squawk"`
|
|
||||||
Emergency string `json:"Emergency"`
|
|
||||||
OnGround bool `json:"OnGround"`
|
|
||||||
Alert bool `json:"Alert"`
|
|
||||||
SPI bool `json:"SPI"`
|
|
||||||
NACp uint8 `json:"NACp"`
|
|
||||||
NACv uint8 `json:"NACv"`
|
|
||||||
SIL uint8 `json:"SIL"`
|
|
||||||
SelectedAltitude int `json:"SelectedAltitude"`
|
|
||||||
SelectedHeading float64 `json:"SelectedHeading"`
|
|
||||||
BaroSetting float64 `json:"BaroSetting"`
|
|
||||||
|
|
||||||
// From AircraftState
|
|
||||||
Sources map[string]*SourceData `json:"sources"`
|
|
||||||
LastUpdate time.Time `json:"last_update"`
|
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
|
||||||
TotalMessages int64 `json:"total_messages"`
|
|
||||||
PositionHistory []PositionPoint `json:"position_history"`
|
|
||||||
SignalHistory []SignalPoint `json:"signal_history"`
|
|
||||||
AltitudeHistory []AltitudePoint `json:"altitude_history"`
|
|
||||||
SpeedHistory []SpeedPoint `json:"speed_history"`
|
|
||||||
Distance float64 `json:"distance"`
|
|
||||||
Bearing float64 `json:"bearing"`
|
|
||||||
Age float64 `json:"age"`
|
|
||||||
MLATSources []string `json:"mlat_sources"`
|
|
||||||
PositionSource string `json:"position_source"`
|
|
||||||
UpdateRate float64 `json:"update_rate"`
|
|
||||||
Country string `json:"country"`
|
|
||||||
CountryCode string `json:"country_code"`
|
|
||||||
Flag string `json:"flag"`
|
|
||||||
}{
|
|
||||||
// Copy all fields from Aircraft
|
|
||||||
ICAO24: fmt.Sprintf("%06X", a.Aircraft.ICAO24),
|
|
||||||
Callsign: a.Aircraft.Callsign,
|
|
||||||
Latitude: a.Aircraft.Latitude,
|
|
||||||
Longitude: a.Aircraft.Longitude,
|
|
||||||
Altitude: a.Aircraft.Altitude,
|
|
||||||
BaroAltitude: a.Aircraft.BaroAltitude,
|
|
||||||
GeomAltitude: a.Aircraft.GeomAltitude,
|
|
||||||
VerticalRate: a.Aircraft.VerticalRate,
|
|
||||||
GroundSpeed: a.Aircraft.GroundSpeed,
|
|
||||||
Track: a.Aircraft.Track,
|
|
||||||
Heading: a.Aircraft.Heading,
|
|
||||||
Category: a.Aircraft.Category,
|
|
||||||
Squawk: a.Aircraft.Squawk,
|
|
||||||
Emergency: a.Aircraft.Emergency,
|
|
||||||
OnGround: a.Aircraft.OnGround,
|
|
||||||
Alert: a.Aircraft.Alert,
|
|
||||||
SPI: a.Aircraft.SPI,
|
|
||||||
NACp: a.Aircraft.NACp,
|
|
||||||
NACv: a.Aircraft.NACv,
|
|
||||||
SIL: a.Aircraft.SIL,
|
|
||||||
SelectedAltitude: a.Aircraft.SelectedAltitude,
|
|
||||||
SelectedHeading: a.Aircraft.SelectedHeading,
|
|
||||||
BaroSetting: a.Aircraft.BaroSetting,
|
|
||||||
|
|
||||||
// Copy all fields from AircraftState
|
|
||||||
Sources: a.Sources,
|
|
||||||
LastUpdate: a.LastUpdate,
|
|
||||||
FirstSeen: a.FirstSeen,
|
|
||||||
TotalMessages: a.TotalMessages,
|
|
||||||
PositionHistory: a.PositionHistory,
|
|
||||||
SignalHistory: a.SignalHistory,
|
|
||||||
AltitudeHistory: a.AltitudeHistory,
|
|
||||||
SpeedHistory: a.SpeedHistory,
|
|
||||||
Distance: a.Distance,
|
|
||||||
Bearing: a.Bearing,
|
|
||||||
Age: a.Age,
|
|
||||||
MLATSources: a.MLATSources,
|
|
||||||
PositionSource: a.PositionSource,
|
|
||||||
UpdateRate: a.UpdateRate,
|
|
||||||
Country: a.Country,
|
|
||||||
CountryCode: a.CountryCode,
|
|
||||||
Flag: a.Flag,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceData represents data quality and statistics for a specific source-aircraft pair.
|
// SourceData represents data quality and statistics for a specific source-aircraft pair.
|
||||||
|
|
@ -218,8 +113,8 @@ type AltitudePoint struct {
|
||||||
// Used for aircraft performance analysis and track prediction.
|
// Used for aircraft performance analysis and track prediction.
|
||||||
type SpeedPoint struct {
|
type SpeedPoint struct {
|
||||||
Time time.Time `json:"time"` // Timestamp when speed was received
|
Time time.Time `json:"time"` // Timestamp when speed was received
|
||||||
GroundSpeed int `json:"ground_speed"` // Ground speed in knots (integer)
|
GroundSpeed float64 `json:"ground_speed"` // Ground speed in knots
|
||||||
Track int `json:"track"` // Track angle in degrees (0-359)
|
Track float64 `json:"track"` // Track angle in degrees
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merger handles merging aircraft data from multiple sources with intelligent conflict resolution.
|
// Merger handles merging aircraft data from multiple sources with intelligent conflict resolution.
|
||||||
|
|
@ -236,10 +131,9 @@ type SpeedPoint struct {
|
||||||
type Merger struct {
|
type Merger struct {
|
||||||
aircraft map[uint32]*AircraftState // ICAO24 -> merged aircraft state
|
aircraft map[uint32]*AircraftState // ICAO24 -> merged aircraft state
|
||||||
sources map[string]*Source // Source ID -> source information
|
sources map[string]*Source // Source ID -> source information
|
||||||
icaoDB *icao.Database // ICAO country lookup database
|
|
||||||
mu sync.RWMutex // Protects all maps and slices
|
mu sync.RWMutex // Protects all maps and slices
|
||||||
historyLimit int // Maximum history points to retain
|
historyLimit int // Maximum history points to retain
|
||||||
staleTimeout time.Duration // Time before aircraft considered stale (15 seconds)
|
staleTimeout time.Duration // Time before aircraft considered stale
|
||||||
updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data
|
updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -253,25 +147,19 @@ type updateMetric struct {
|
||||||
//
|
//
|
||||||
// Default settings:
|
// Default settings:
|
||||||
// - History limit: 500 points per aircraft
|
// - History limit: 500 points per aircraft
|
||||||
// - Stale timeout: 15 seconds
|
// - Stale timeout: 60 seconds
|
||||||
// - Empty aircraft and source maps
|
// - Empty aircraft and source maps
|
||||||
// - Update metrics tracking enabled
|
// - Update metrics tracking enabled
|
||||||
//
|
//
|
||||||
// The merger is ready for immediate use after creation.
|
// The merger is ready for immediate use after creation.
|
||||||
func NewMerger() (*Merger, error) {
|
func NewMerger() *Merger {
|
||||||
icaoDB, err := icao.NewDatabase()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to initialize ICAO database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Merger{
|
return &Merger{
|
||||||
aircraft: make(map[uint32]*AircraftState),
|
aircraft: make(map[uint32]*AircraftState),
|
||||||
sources: make(map[string]*Source),
|
sources: make(map[string]*Source),
|
||||||
icaoDB: icaoDB,
|
|
||||||
historyLimit: 500,
|
historyLimit: 500,
|
||||||
staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking
|
staleTimeout: 60 * time.Second,
|
||||||
updateMetrics: make(map[uint32]*updateMetric),
|
updateMetrics: make(map[uint32]*updateMetric),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSource registers a new data source with the merger.
|
// AddSource registers a new data source with the merger.
|
||||||
|
|
@ -326,27 +214,11 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
|
||||||
AltitudeHistory: make([]AltitudePoint, 0),
|
AltitudeHistory: make([]AltitudePoint, 0),
|
||||||
SpeedHistory: make([]SpeedPoint, 0),
|
SpeedHistory: make([]SpeedPoint, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup country information for new aircraft
|
|
||||||
icaoHex := fmt.Sprintf("%06X", aircraft.ICAO24)
|
|
||||||
if countryInfo, err := m.icaoDB.LookupCountry(icaoHex); err == nil {
|
|
||||||
state.Country = countryInfo.Country
|
|
||||||
state.CountryCode = countryInfo.CountryCode
|
|
||||||
state.Flag = countryInfo.Flag
|
|
||||||
} else {
|
|
||||||
// Fallback to unknown if lookup fails
|
|
||||||
state.Country = "Unknown"
|
|
||||||
state.CountryCode = "XX"
|
|
||||||
state.Flag = "🏳️"
|
|
||||||
}
|
|
||||||
|
|
||||||
m.aircraft[aircraft.ICAO24] = state
|
m.aircraft[aircraft.ICAO24] = state
|
||||||
m.updateMetrics[aircraft.ICAO24] = &updateMetric{
|
m.updateMetrics[aircraft.ICAO24] = &updateMetric{
|
||||||
updates: make([]time.Time, 0),
|
updates: make([]time.Time, 0),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Note: For existing aircraft, we don't overwrite state.Aircraft here
|
|
||||||
// The mergeAircraftData function will handle selective field updates
|
|
||||||
|
|
||||||
// Update or create source data
|
// Update or create source data
|
||||||
srcData, srcExists := state.Sources[sourceID]
|
srcData, srcExists := state.Sources[sourceID]
|
||||||
|
|
@ -422,16 +294,12 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
|
||||||
updatePosition := false
|
updatePosition := false
|
||||||
|
|
||||||
if state.Latitude == 0 {
|
if state.Latitude == 0 {
|
||||||
// First position update
|
|
||||||
updatePosition = true
|
updatePosition = true
|
||||||
} else if srcData, ok := state.Sources[sourceID]; ok {
|
} else if srcData, ok := state.Sources[sourceID]; ok {
|
||||||
// Use position from source with strongest signal
|
// Use position from source with strongest signal
|
||||||
currentBest := m.getBestSignalSource(state)
|
currentBest := m.getBestSignalSource(state)
|
||||||
if currentBest == "" || srcData.SignalLevel > state.Sources[currentBest].SignalLevel {
|
if currentBest == "" || srcData.SignalLevel > state.Sources[currentBest].SignalLevel {
|
||||||
updatePosition = true
|
updatePosition = true
|
||||||
} else if currentBest == sourceID {
|
|
||||||
// Same source as current best - allow updates for moving aircraft
|
|
||||||
updatePosition = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -672,7 +540,7 @@ func (m *Merger) GetAircraft() map[uint32]*AircraftState {
|
||||||
stateCopy.Age = now.Sub(state.LastUpdate).Seconds()
|
stateCopy.Age = now.Sub(state.LastUpdate).Seconds()
|
||||||
|
|
||||||
// Find closest receiver distance
|
// Find closest receiver distance
|
||||||
minDistance := MaxDistance
|
minDistance := float64(999999)
|
||||||
for _, srcData := range state.Sources {
|
for _, srcData := range state.Sources {
|
||||||
if srcData.Distance > 0 && srcData.Distance < minDistance {
|
if srcData.Distance > 0 && srcData.Distance < minDistance {
|
||||||
minDistance = srcData.Distance
|
minDistance = srcData.Distance
|
||||||
|
|
@ -747,7 +615,7 @@ func (m *Merger) GetStatistics() map[string]interface{} {
|
||||||
// CleanupStale removes aircraft that haven't been updated recently.
|
// CleanupStale removes aircraft that haven't been updated recently.
|
||||||
//
|
//
|
||||||
// Aircraft are considered stale if they haven't received updates for longer
|
// Aircraft are considered stale if they haven't received updates for longer
|
||||||
// than staleTimeout (default 15 seconds). This cleanup prevents memory
|
// than staleTimeout (default 60 seconds). This cleanup prevents memory
|
||||||
// growth from aircraft that have left the coverage area or stopped transmitting.
|
// growth from aircraft that have left the coverage area or stopped transmitting.
|
||||||
//
|
//
|
||||||
// The cleanup also removes associated update metrics to free memory.
|
// The cleanup also removes associated update metrics to free memory.
|
||||||
|
|
@ -808,14 +676,3 @@ func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64)
|
||||||
|
|
||||||
return distance, bearing
|
return distance, bearing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the merger and releases resources
|
|
||||||
func (m *Merger) Close() error {
|
|
||||||
m.mu.Lock()
|
|
||||||
defer m.mu.Unlock()
|
|
||||||
|
|
||||||
if m.icaoDB != nil {
|
|
||||||
return m.icaoDB.Close()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -30,45 +30,8 @@ package modes
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// crcTable for Mode S CRC-24 validation
|
|
||||||
var crcTable [256]uint32
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// Initialize CRC table for Mode S CRC-24 (polynomial 0x1FFF409)
|
|
||||||
for i := 0; i < 256; i++ {
|
|
||||||
crc := uint32(i) << 16
|
|
||||||
for j := 0; j < 8; j++ {
|
|
||||||
if crc&0x800000 != 0 {
|
|
||||||
crc = (crc << 1) ^ 0x1FFF409
|
|
||||||
} else {
|
|
||||||
crc = crc << 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
crcTable[i] = crc & 0xFFFFFF
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateModeSCRC validates the 24-bit CRC of a Mode S message
|
|
||||||
func validateModeSCRC(data []byte) bool {
|
|
||||||
if len(data) < 4 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate CRC for all bytes except the last 3 (which contain the CRC)
|
|
||||||
crc := uint32(0)
|
|
||||||
for i := 0; i < len(data)-3; i++ {
|
|
||||||
crc = ((crc << 8) ^ crcTable[((crc>>16)^uint32(data[i]))&0xFF]) & 0xFFFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract transmitted CRC from last 3 bytes
|
|
||||||
transmittedCRC := uint32(data[len(data)-3])<<16 | uint32(data[len(data)-2])<<8 | uint32(data[len(data)-1])
|
|
||||||
|
|
||||||
return crc == transmittedCRC
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mode S Downlink Format (DF) constants.
|
// Mode S Downlink Format (DF) constants.
|
||||||
// The DF field (first 5 bits) determines the message type and structure.
|
// The DF field (first 5 bits) determines the message type and structure.
|
||||||
const (
|
const (
|
||||||
|
|
@ -119,9 +82,9 @@ type Aircraft struct {
|
||||||
|
|
||||||
// Motion and Dynamics
|
// Motion and Dynamics
|
||||||
VerticalRate int // Vertical rate in feet per minute (climb/descent)
|
VerticalRate int // Vertical rate in feet per minute (climb/descent)
|
||||||
GroundSpeed int // Ground speed in knots (integer)
|
GroundSpeed float64 // Ground speed in knots
|
||||||
Track int // Track angle in degrees (0-359, integer)
|
Track float64 // Track angle in degrees (direction of movement)
|
||||||
Heading int // Aircraft heading in degrees (magnetic, integer)
|
Heading float64 // Aircraft heading in degrees (magnetic)
|
||||||
|
|
||||||
// Aircraft Information
|
// Aircraft Information
|
||||||
Category string // Aircraft category (size, type, performance)
|
Category string // Aircraft category (size, type, performance)
|
||||||
|
|
@ -163,27 +126,16 @@ type Decoder struct {
|
||||||
cprOddLon map[uint32]float64 // Odd message longitude encoding (ICAO24 -> normalized lon)
|
cprOddLon map[uint32]float64 // Odd message longitude encoding (ICAO24 -> normalized lon)
|
||||||
cprEvenTime map[uint32]int64 // Timestamp of even message (for freshness comparison)
|
cprEvenTime map[uint32]int64 // Timestamp of even message (for freshness comparison)
|
||||||
cprOddTime map[uint32]int64 // Timestamp of odd message (for freshness comparison)
|
cprOddTime map[uint32]int64 // Timestamp of odd message (for freshness comparison)
|
||||||
|
|
||||||
// Reference position for CPR zone ambiguity resolution (receiver location)
|
|
||||||
refLatitude float64 // Receiver latitude in decimal degrees
|
|
||||||
refLongitude float64 // Receiver longitude in decimal degrees
|
|
||||||
|
|
||||||
// Mutex to protect concurrent access to CPR maps
|
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDecoder creates a new Mode S/ADS-B decoder with initialized CPR tracking.
|
// NewDecoder creates a new Mode S/ADS-B decoder with initialized CPR tracking.
|
||||||
//
|
//
|
||||||
// The reference position (typically the receiver location) is used to resolve
|
// The decoder is ready to process Mode S messages immediately and will
|
||||||
// CPR zone ambiguity during position decoding. Without a proper reference,
|
// maintain CPR position state across multiple messages for accurate
|
||||||
// aircraft can appear many degrees away from their actual position.
|
// position decoding.
|
||||||
//
|
|
||||||
// Parameters:
|
|
||||||
// - refLat: Reference latitude in decimal degrees (receiver location)
|
|
||||||
// - refLon: Reference longitude in decimal degrees (receiver location)
|
|
||||||
//
|
//
|
||||||
// Returns a configured decoder ready for message processing.
|
// Returns a configured decoder ready for message processing.
|
||||||
func NewDecoder(refLat, refLon float64) *Decoder {
|
func NewDecoder() *Decoder {
|
||||||
return &Decoder{
|
return &Decoder{
|
||||||
cprEvenLat: make(map[uint32]float64),
|
cprEvenLat: make(map[uint32]float64),
|
||||||
cprEvenLon: make(map[uint32]float64),
|
cprEvenLon: make(map[uint32]float64),
|
||||||
|
|
@ -191,8 +143,6 @@ func NewDecoder(refLat, refLon float64) *Decoder {
|
||||||
cprOddLon: make(map[uint32]float64),
|
cprOddLon: make(map[uint32]float64),
|
||||||
cprEvenTime: make(map[uint32]int64),
|
cprEvenTime: make(map[uint32]int64),
|
||||||
cprOddTime: make(map[uint32]int64),
|
cprOddTime: make(map[uint32]int64),
|
||||||
refLatitude: refLat,
|
|
||||||
refLongitude: refLon,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -218,11 +168,6 @@ func (d *Decoder) Decode(data []byte) (*Aircraft, error) {
|
||||||
return nil, fmt.Errorf("message too short: %d bytes", len(data))
|
return nil, fmt.Errorf("message too short: %d bytes", len(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate CRC to reject corrupted messages that create ghost targets
|
|
||||||
if !validateModeSCRC(data) {
|
|
||||||
return nil, fmt.Errorf("invalid CRC - corrupted message")
|
|
||||||
}
|
|
||||||
|
|
||||||
df := (data[0] >> 3) & 0x1F
|
df := (data[0] >> 3) & 0x1F
|
||||||
icao := d.extractICAO(data, df)
|
icao := d.extractICAO(data, df)
|
||||||
|
|
||||||
|
|
@ -392,8 +337,7 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
|
||||||
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
|
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
|
||||||
oddFlag := (data[6] >> 2) & 0x01
|
oddFlag := (data[6] >> 2) & 0x01
|
||||||
|
|
||||||
// Store CPR values for later decoding (protected by mutex)
|
// Store CPR values for later decoding
|
||||||
d.mu.Lock()
|
|
||||||
if oddFlag == 1 {
|
if oddFlag == 1 {
|
||||||
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
||||||
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
||||||
|
|
@ -401,7 +345,6 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
|
||||||
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
||||||
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
|
||||||
|
|
||||||
// Try to decode position if we have both even and odd messages
|
// Try to decode position if we have both even and odd messages
|
||||||
d.decodeCPRPosition(aircraft)
|
d.decodeCPRPosition(aircraft)
|
||||||
|
|
@ -409,32 +352,37 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
|
||||||
|
|
||||||
// decodeCPRPosition performs CPR (Compact Position Reporting) global position decoding.
|
// decodeCPRPosition performs CPR (Compact Position Reporting) global position decoding.
|
||||||
//
|
//
|
||||||
// CRITICAL: The CPR algorithm has zone ambiguity that requires either:
|
// This is the core algorithm for resolving aircraft positions from CPR-encoded data.
|
||||||
// 1. A reference position (receiver location) to resolve zones correctly, OR
|
// The algorithm requires both even and odd CPR messages to resolve position ambiguity.
|
||||||
// 2. Message timestamp comparison to choose the most recent valid position
|
|
||||||
//
|
//
|
||||||
// Without proper zone resolution, aircraft can appear 6+ degrees away from actual position.
|
// CPR Global Decoding Algorithm:
|
||||||
// This implementation uses global decoding which can produce large errors without
|
// 1. Check that both even and odd CPR values are available
|
||||||
// additional context about expected aircraft location.
|
// 2. Calculate latitude using even/odd zone boundaries
|
||||||
|
// 3. Determine which latitude zone contains the aircraft
|
||||||
|
// 4. Calculate longitude based on the resolved latitude
|
||||||
|
// 5. Apply range corrections to get final position
|
||||||
|
//
|
||||||
|
// Mathematical Process:
|
||||||
|
// - Latitude zones are spaced 360°/60 = 6° apart for even messages
|
||||||
|
// - Latitude zones are spaced 360°/59 = ~6.1° apart for odd messages
|
||||||
|
// - The zone offset calculation resolves which 6° band contains the aircraft
|
||||||
|
// - Longitude calculation depends on latitude due to Earth's spherical geometry
|
||||||
|
//
|
||||||
|
// Note: This implementation uses a simplified approach. Production systems
|
||||||
|
// should also consider message timestamps to choose the most recent position.
|
||||||
//
|
//
|
||||||
// Parameters:
|
// Parameters:
|
||||||
// - aircraft: Aircraft struct to update with decoded position
|
// - aircraft: Aircraft struct to update with decoded position
|
||||||
func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
|
func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
|
||||||
// Read CPR values with read lock
|
|
||||||
d.mu.RLock()
|
|
||||||
evenLat, evenExists := d.cprEvenLat[aircraft.ICAO24]
|
evenLat, evenExists := d.cprEvenLat[aircraft.ICAO24]
|
||||||
oddLat, oddExists := d.cprOddLat[aircraft.ICAO24]
|
oddLat, oddExists := d.cprOddLat[aircraft.ICAO24]
|
||||||
|
|
||||||
if !evenExists || !oddExists {
|
if !evenExists || !oddExists {
|
||||||
d.mu.RUnlock()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
evenLon := d.cprEvenLon[aircraft.ICAO24]
|
evenLon := d.cprEvenLon[aircraft.ICAO24]
|
||||||
oddLon := d.cprOddLon[aircraft.ICAO24]
|
oddLon := d.cprOddLon[aircraft.ICAO24]
|
||||||
d.mu.RUnlock()
|
|
||||||
|
|
||||||
// CPR input values ready for decoding
|
|
||||||
|
|
||||||
// CPR decoding algorithm
|
// CPR decoding algorithm
|
||||||
dLat := 360.0 / 60.0
|
dLat := 360.0 / 60.0
|
||||||
|
|
@ -450,36 +398,8 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
|
||||||
latOdd -= 360
|
latOdd -= 360
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional range correction to ensure valid latitude bounds (-90° to +90°)
|
// Choose the most recent position
|
||||||
if latEven > 90 {
|
aircraft.Latitude = latOdd // Use odd for now, should check timestamps
|
||||||
latEven = 180 - latEven
|
|
||||||
} else if latEven < -90 {
|
|
||||||
latEven = -180 - latEven
|
|
||||||
}
|
|
||||||
|
|
||||||
if latOdd > 90 {
|
|
||||||
latOdd = 180 - latOdd
|
|
||||||
} else if latOdd < -90 {
|
|
||||||
latOdd = -180 - latOdd
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate final latitude values are within acceptable range
|
|
||||||
if math.Abs(latOdd) > 90 || math.Abs(latEven) > 90 {
|
|
||||||
// Invalid CPR decoding - skip position update
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zone ambiguity resolution using receiver reference position
|
|
||||||
// Calculate which decoded latitude is closer to the receiver
|
|
||||||
distToEven := math.Abs(latEven - d.refLatitude)
|
|
||||||
distToOdd := math.Abs(latOdd - d.refLatitude)
|
|
||||||
|
|
||||||
// Choose the latitude solution that's closer to the receiver position
|
|
||||||
if distToOdd < distToEven {
|
|
||||||
aircraft.Latitude = latOdd
|
|
||||||
} else {
|
|
||||||
aircraft.Latitude = latEven
|
|
||||||
}
|
|
||||||
|
|
||||||
// Longitude calculation
|
// Longitude calculation
|
||||||
nl := d.nlFunction(aircraft.Latitude)
|
nl := d.nlFunction(aircraft.Latitude)
|
||||||
|
|
@ -490,19 +410,9 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
|
||||||
|
|
||||||
if lon >= 180 {
|
if lon >= 180 {
|
||||||
lon -= 360
|
lon -= 360
|
||||||
} else if lon <= -180 {
|
|
||||||
lon += 360
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate longitude is within acceptable range
|
|
||||||
if math.Abs(lon) > 180 {
|
|
||||||
// Invalid longitude - skip position update
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
aircraft.Longitude = lon
|
aircraft.Longitude = lon
|
||||||
|
|
||||||
// CPR decoding completed successfully
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nlFunction calculates the number of longitude zones (NL) for a given latitude.
|
// nlFunction calculates the number of longitude zones (NL) for a given latitude.
|
||||||
|
|
@ -574,21 +484,11 @@ func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) {
|
||||||
nsVel = -nsVel
|
nsVel = -nsVel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate ground speed in knots (rounded to integer)
|
aircraft.GroundSpeed = math.Sqrt(ewVel*ewVel + nsVel*nsVel)
|
||||||
speedKnots := math.Sqrt(ewVel*ewVel + nsVel*nsVel)
|
aircraft.Track = math.Atan2(ewVel, nsVel) * 180 / math.Pi
|
||||||
|
if aircraft.Track < 0 {
|
||||||
// Validate speed range (0-600 knots for civilian aircraft)
|
aircraft.Track += 360
|
||||||
if speedKnots > 600 {
|
|
||||||
speedKnots = 600 // Cap at reasonable maximum
|
|
||||||
}
|
}
|
||||||
aircraft.GroundSpeed = int(math.Round(speedKnots))
|
|
||||||
|
|
||||||
// Calculate track in degrees (0-359)
|
|
||||||
trackDeg := math.Atan2(ewVel, nsVel) * 180 / math.Pi
|
|
||||||
if trackDeg < 0 {
|
|
||||||
trackDeg += 360
|
|
||||||
}
|
|
||||||
aircraft.Track = int(math.Round(trackDeg))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vertical rate
|
// Vertical rate
|
||||||
|
|
@ -638,36 +538,19 @@ func (d *Decoder) decodeAltitudeBits(altCode uint16, tc uint8) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard altitude encoding with 25 ft increments
|
// Gray code to binary conversion
|
||||||
// Check Q-bit (bit 4) for encoding type
|
var n uint16
|
||||||
qBit := (altCode >> 4) & 1
|
for i := uint(0); i < 12; i++ {
|
||||||
|
n ^= altCode >> i
|
||||||
|
}
|
||||||
|
|
||||||
if qBit == 1 {
|
|
||||||
// Standard altitude with Q-bit set
|
|
||||||
// Remove Q-bit and reassemble 11-bit altitude code
|
|
||||||
n := ((altCode & 0x1F80) >> 2) | ((altCode & 0x0020) >> 1) | (altCode & 0x000F)
|
|
||||||
alt := int(n)*25 - 1000
|
alt := int(n)*25 - 1000
|
||||||
|
|
||||||
// Validate altitude range
|
if tc >= 20 && tc <= 22 {
|
||||||
if alt < -1000 || alt > 60000 {
|
// GNSS altitude
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return alt
|
return alt
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gray code altitude (100 ft increments) - legacy encoding
|
|
||||||
// Convert from Gray code to binary
|
|
||||||
n := altCode
|
|
||||||
n ^= n >> 8
|
|
||||||
n ^= n >> 4
|
|
||||||
n ^= n >> 2
|
|
||||||
n ^= n >> 1
|
|
||||||
|
|
||||||
// Convert to altitude in feet
|
|
||||||
alt := int(n&0x7FF) * 100
|
|
||||||
if alt < 0 || alt > 60000 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return alt
|
return alt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -873,14 +756,14 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
|
||||||
// Movement
|
// Movement
|
||||||
movement := uint8(data[4]&0x07)<<4 | uint8(data[5])>>4
|
movement := uint8(data[4]&0x07)<<4 | uint8(data[5])>>4
|
||||||
if movement > 0 && movement < 125 {
|
if movement > 0 && movement < 125 {
|
||||||
aircraft.GroundSpeed = int(math.Round(d.decodeGroundSpeed(movement)))
|
aircraft.GroundSpeed = d.decodeGroundSpeed(movement)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track
|
// Track
|
||||||
trackValid := (data[5] >> 3) & 0x01
|
trackValid := (data[5] >> 3) & 0x01
|
||||||
if trackValid != 0 {
|
if trackValid != 0 {
|
||||||
trackBits := uint16(data[5]&0x07)<<4 | uint16(data[6])>>4
|
trackBits := uint16(data[5]&0x07)<<4 | uint16(data[6])>>4
|
||||||
aircraft.Track = int(math.Round(float64(trackBits) * 360.0 / 128.0))
|
aircraft.Track = float64(trackBits) * 360.0 / 128.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// CPR position (similar to airborne)
|
// CPR position (similar to airborne)
|
||||||
|
|
@ -888,8 +771,6 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
|
||||||
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
|
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
|
||||||
oddFlag := (data[6] >> 2) & 0x01
|
oddFlag := (data[6] >> 2) & 0x01
|
||||||
|
|
||||||
// Store CPR values for later decoding (protected by mutex)
|
|
||||||
d.mu.Lock()
|
|
||||||
if oddFlag == 1 {
|
if oddFlag == 1 {
|
||||||
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
||||||
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
||||||
|
|
@ -897,7 +778,6 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
|
||||||
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
|
||||||
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
|
||||||
|
|
||||||
d.decodeCPRPosition(aircraft)
|
d.decodeCPRPosition(aircraft)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,10 +110,10 @@ func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin Ori
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // Allow all origins in development
|
return true // Allow all origins in development
|
||||||
},
|
},
|
||||||
ReadBufferSize: 8192,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 8192,
|
WriteBufferSize: 1024,
|
||||||
},
|
},
|
||||||
broadcastChan: make(chan []byte, 1000),
|
broadcastChan: make(chan []byte, 100),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,7 +183,6 @@ func (s *Server) setupRoutes() http.Handler {
|
||||||
api := router.PathPrefix("/api").Subrouter()
|
api := router.PathPrefix("/api").Subrouter()
|
||||||
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
|
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
|
||||||
api.HandleFunc("/aircraft/{icao}", s.handleGetAircraftDetails).Methods("GET")
|
api.HandleFunc("/aircraft/{icao}", s.handleGetAircraftDetails).Methods("GET")
|
||||||
api.HandleFunc("/debug/aircraft", s.handleDebugAircraft).Methods("GET")
|
|
||||||
api.HandleFunc("/sources", s.handleGetSources).Methods("GET")
|
api.HandleFunc("/sources", s.handleGetSources).Methods("GET")
|
||||||
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
|
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
|
||||||
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
|
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
|
||||||
|
|
@ -204,60 +203,29 @@ func (s *Server) setupRoutes() http.Handler {
|
||||||
return s.enableCORS(router)
|
return s.enableCORS(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAircraftUseful determines if an aircraft has enough data to be useful for the frontend.
|
|
||||||
//
|
|
||||||
// DESIGN NOTE: We WANT reasonable aircraft to appear in our table view, even if they
|
|
||||||
// don't have enough data to appear on the map. This provides users visibility into
|
|
||||||
// all tracked aircraft, not just those with complete position data.
|
|
||||||
//
|
|
||||||
// Aircraft are considered useful if they have ANY of:
|
|
||||||
// - Valid position data (both latitude and longitude non-zero) -> Can show on map
|
|
||||||
// - Callsign (flight identification) -> Can show in table with "No position" status
|
|
||||||
// - Altitude information -> Can show in table as "Aircraft at X feet"
|
|
||||||
// - Any other identifying information that makes it a "real" aircraft
|
|
||||||
//
|
|
||||||
// This inclusive approach ensures the table view shows all aircraft we're tracking,
|
|
||||||
// while the map view only shows those with valid positions (handled by frontend filtering).
|
|
||||||
func (s *Server) isAircraftUseful(aircraft *merger.AircraftState) bool {
|
|
||||||
// Aircraft is useful if it has any meaningful data:
|
|
||||||
hasValidPosition := aircraft.Latitude != 0 && aircraft.Longitude != 0
|
|
||||||
hasCallsign := aircraft.Callsign != ""
|
|
||||||
hasAltitude := aircraft.Altitude != 0
|
|
||||||
hasSquawk := aircraft.Squawk != ""
|
|
||||||
|
|
||||||
// Include aircraft with any identifying or operational data
|
|
||||||
return hasValidPosition || hasCallsign || hasAltitude || hasSquawk
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleGetAircraft serves the /api/aircraft endpoint.
|
// handleGetAircraft serves the /api/aircraft endpoint.
|
||||||
// Returns all currently tracked aircraft with their latest state information.
|
// Returns all currently tracked aircraft with their latest state information.
|
||||||
//
|
//
|
||||||
// Only "useful" aircraft are returned - those with position data or callsign.
|
|
||||||
// This filters out incomplete aircraft that only have altitude or squawk codes,
|
|
||||||
// which are not actionable for frontend mapping and flight tracking.
|
|
||||||
//
|
|
||||||
// The response includes:
|
// The response includes:
|
||||||
// - timestamp: Unix timestamp of the response
|
// - timestamp: Unix timestamp of the response
|
||||||
// - aircraft: Map of aircraft keyed by ICAO hex strings
|
// - aircraft: Map of aircraft keyed by ICAO hex strings
|
||||||
// - count: Total number of useful aircraft (filtered count)
|
// - count: Total number of aircraft
|
||||||
//
|
//
|
||||||
// Aircraft ICAO addresses are converted from uint32 to 6-digit hex strings
|
// Aircraft ICAO addresses are converted from uint32 to 6-digit hex strings
|
||||||
// for consistent JSON representation (e.g., 0xABC123 -> "ABC123").
|
// for consistent JSON representation (e.g., 0xABC123 -> "ABC123").
|
||||||
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
|
||||||
aircraft := s.merger.GetAircraft()
|
aircraft := s.merger.GetAircraft()
|
||||||
|
|
||||||
// Convert ICAO keys to hex strings for JSON and filter useful aircraft
|
// Convert ICAO keys to hex strings for JSON
|
||||||
aircraftMap := make(map[string]*merger.AircraftState)
|
aircraftMap := make(map[string]*merger.AircraftState)
|
||||||
for icao, state := range aircraft {
|
for icao, state := range aircraft {
|
||||||
if s.isAircraftUseful(state) {
|
|
||||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"timestamp": time.Now().Unix(),
|
"timestamp": time.Now().Unix(),
|
||||||
"aircraft": aircraftMap,
|
"aircraft": aircraftMap,
|
||||||
"count": len(aircraftMap), // Count of filtered useful aircraft
|
"count": len(aircraft),
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
@ -510,13 +478,11 @@ func (s *Server) sendInitialData(conn *websocket.Conn) {
|
||||||
sources := s.merger.GetSources()
|
sources := s.merger.GetSources()
|
||||||
stats := s.merger.GetStatistics()
|
stats := s.merger.GetStatistics()
|
||||||
|
|
||||||
// Convert ICAO keys to hex strings and filter useful aircraft
|
// Convert ICAO keys to hex strings
|
||||||
aircraftMap := make(map[string]*merger.AircraftState)
|
aircraftMap := make(map[string]*merger.AircraftState)
|
||||||
for icao, state := range aircraft {
|
for icao, state := range aircraft {
|
||||||
if s.isAircraftUseful(state) {
|
|
||||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
update := AircraftUpdate{
|
update := AircraftUpdate{
|
||||||
Aircraft: aircraftMap,
|
Aircraft: aircraftMap,
|
||||||
|
|
@ -589,10 +555,9 @@ func (s *Server) periodicUpdateRoutine() {
|
||||||
//
|
//
|
||||||
// This function:
|
// This function:
|
||||||
// 1. Collects current aircraft data from the merger
|
// 1. Collects current aircraft data from the merger
|
||||||
// 2. Filters aircraft to only include "useful" ones (with position or callsign)
|
// 2. Formats the data as a WebSocketMessage with type "aircraft_update"
|
||||||
// 3. Formats the data as a WebSocketMessage with type "aircraft_update"
|
// 3. Converts ICAO addresses to hex strings for JSON compatibility
|
||||||
// 4. Converts ICAO addresses to hex strings for JSON compatibility
|
// 4. Queues the message for broadcast (non-blocking)
|
||||||
// 5. Queues the message for broadcast (non-blocking)
|
|
||||||
//
|
//
|
||||||
// If the broadcast channel is full, the update is dropped to prevent blocking.
|
// If the broadcast channel is full, the update is dropped to prevent blocking.
|
||||||
// This ensures the system continues operating even if WebSocket clients
|
// This ensures the system continues operating even if WebSocket clients
|
||||||
|
|
@ -602,13 +567,11 @@ func (s *Server) broadcastUpdate() {
|
||||||
sources := s.merger.GetSources()
|
sources := s.merger.GetSources()
|
||||||
stats := s.merger.GetStatistics()
|
stats := s.merger.GetStatistics()
|
||||||
|
|
||||||
// Convert ICAO keys to hex strings and filter useful aircraft
|
// Convert ICAO keys to hex strings
|
||||||
aircraftMap := make(map[string]*merger.AircraftState)
|
aircraftMap := make(map[string]*merger.AircraftState)
|
||||||
for icao, state := range aircraft {
|
for icao, state := range aircraft {
|
||||||
if s.isAircraftUseful(state) {
|
|
||||||
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
aircraftMap[fmt.Sprintf("%06X", icao)] = state
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
update := AircraftUpdate{
|
update := AircraftUpdate{
|
||||||
Aircraft: aircraftMap,
|
Aircraft: aircraftMap,
|
||||||
|
|
@ -748,34 +711,3 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler {
|
||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDebugAircraft serves the /api/debug/aircraft endpoint.
|
|
||||||
// Returns all aircraft (filtered and unfiltered) for debugging position issues.
|
|
||||||
func (s *Server) handleDebugAircraft(w http.ResponseWriter, r *http.Request) {
|
|
||||||
aircraft := s.merger.GetAircraft()
|
|
||||||
|
|
||||||
// All aircraft (unfiltered)
|
|
||||||
allAircraftMap := make(map[string]*merger.AircraftState)
|
|
||||||
for icao, state := range aircraft {
|
|
||||||
allAircraftMap[fmt.Sprintf("%06X", icao)] = state
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filtered aircraft (useful ones)
|
|
||||||
filteredAircraftMap := make(map[string]*merger.AircraftState)
|
|
||||||
for icao, state := range aircraft {
|
|
||||||
if s.isAircraftUseful(state) {
|
|
||||||
filteredAircraftMap[fmt.Sprintf("%06X", icao)] = state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"timestamp": time.Now().Unix(),
|
|
||||||
"all_aircraft": allAircraftMap,
|
|
||||||
"filtered_aircraft": filteredAircraftMap,
|
|
||||||
"all_count": len(allAircraftMap),
|
|
||||||
"filtered_count": len(filteredAircraftMap),
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(response)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
BIN
main
BIN
ux.png
|
Before Width: | Height: | Size: 102 KiB |