Compare commits

..

14 commits

Author SHA1 Message Date
064ba2de71 Clean up and optimize codebase for production readiness
Performance & Reliability Improvements:
- Increase WebSocket buffer sizes from 1KB to 8KB for better throughput
- Increase broadcast channel buffer from 100 to 1000 messages
- Increase Beast message channel buffer from 1000 to 5000 messages
- Increase connection timeout from 10s to 30s for remote receivers

Code Quality Improvements:
- Remove debug output from production code (CPR Debug, Merger Update spam)
- Add MaxDistance constant to replace magic number (999999)
- Clean up comments for better maintainability
- Implement auto-enable for selected aircraft trails

Benefits:
- Much cleaner server logs without debug spam
- Better performance under high aircraft density
- More reliable WebSocket connections with larger buffers
- Improved fault tolerance with increased timeouts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 17:54:17 +02:00
9389cb8823 Implement comprehensive ICAO country lookup with complete global coverage
- Fix country field marshaling in JSON output to display countries in web frontend
- Replace incomplete ICAO database with comprehensive allocation table from aerotransport.org
- Add all 120+ countries and territories with correct hex address ranges
- Fix aircraft legend label: change "Medium 34000-136000kg" to "Large 34000-136000kg"
- Ensure complete coverage for all allocated ICAO 24-bit addresses worldwide

Fixes:
- 3C64AE now correctly shows Germany 🇩🇪 (range 3C0000-3FFFFF)
- 4ACB58 now correctly shows Sweden 🇸🇪 (range 4A8000-4AFFFF)
- 04008D now correctly shows Ethiopia 🇪🇹 (range 040000-040FFF)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 17:27:28 +02:00
79f0509bea Update aircraft legend to show real ADS-B categories and implement pure Go ICAO country lookup
- Replace imaginary aircraft types (Commercial/Cargo/GA) with actual ADS-B emitter categories
- Add proper weight-based classifications: Light <7000kg, Medium 7000-34000kg, etc.
- Replace SQLite-based ICAO lookup with pure Go implementation using slice of allocations
- Remove SQLite dependency completely for simpler architecture
- Add comprehensive ICAO address allocations based on ICAO Document 8585
- Implement efficient linear search through sorted allocations by start address

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 16:59:20 +02:00
20bdcf54ec Move documentation to docs/ directory and improve terminology
- Move ARCHITECTURE.md and CLAUDE.md to docs/ directory
- Replace "real-time" terminology with accurate "low-latency" and "high-performance"
- Update README to reflect correct performance characteristics
- Add comprehensive ICAO country database with SQLite backend
- Fix display options positioning and functionality
- Add map scale controls and improved range ring visibility
- Enhance aircraft marker orientation and trail management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 16:24:46 +02:00
43e55b2ba0 Extract aircraft SVG icons to external files and add dynamic marker updates
- Extract all aircraft type SVG designs to separate files in /static/icons/
- Add icon caching system with async loading and fallback mechanisms
- Implement dynamic marker icon updates when aircraft properties change
- Detect and respond to aircraft type/category, ground status, and rotation changes
- Use currentColor in SVG files for dynamic color application
- Maintain performance with intelligent change detection (5° rotation threshold)
- Support real-time marker updates for weight class transitions and ADS-B changes

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:29:43 +02:00
da4645d483 Implement server-side trail tracking and fix aircraft marker orientation
- Replace client-side trail collection with server-provided position history
- Fix aircraft markers to properly orient based on track heading using SVG rotation
- Add beast-dump binary to debian package with comprehensive man pages
- Trail visualization now uses gradient effect where newer positions are brighter
- Marker icons update when track heading changes by more than 5 degrees for performance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:18:51 +02:00
b527f5a8ee Switch to light map theme by default with dark mode toggle
Major improvements to map theming and aircraft type display:

Map Theme Changes:
- Changed default map from dark to light theme (CartoDB Positron)
- Added night mode toggle button with sun/moon icons
- Both main map and coverage map now switch themes together
- Light theme provides better daylight visibility

Aircraft Type Display:
- Now displays actual ADS-B category directly (e.g., "Medium 34000-136000kg")
- Removed guessing/interpretation of aircraft types
- Icons still use simplified categories for visual distinction
- More accurate and standards-compliant display

This provides a cleaner, more professional appearance with the light map
and gives users accurate ADS-B category information instead of interpreted types.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:09:54 +02:00
6437d8e8a3 Improve text visibility in aircraft popups
Enhanced popup text contrast and readability:
- Added text-shadow to all values for better contrast against dark background
- Properly handle zero/null values (e.g., Track: 0° now shows instead of N/A)
- Style N/A values with slightly dimmed gray (#aaaaaa) but still clearly visible
- Add 'no-data' class to distinguish missing data from actual zero values
- Ensure all text has strong white color with !important declarations

This fixes visibility issues where some values appeared too faint or were
incorrectly treated as missing when they were actually zero.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:03:33 +02:00
ae2d80f3c9 Fix white-on-white text visibility in aircraft popups
Override Leaflet's default popup styles with !important declarations to ensure
proper text visibility in aircraft information popups:
- Set dark background (#2d2d2d) for popup content wrapper and tip
- Force white text color (#ffffff) for all popup text elements
- Ensure labels remain muted gray (#888) for visual hierarchy
- Preserve accent colors for flight ID (blue) and callsign (green)

The issue was caused by Leaflet CSS overriding custom popup styling,
resulting in poor readability with white text on white backgrounds.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:00:11 +02:00
776cef1185 Clean up excessive console logging and remove duplicate app files
- Remove verbose console.log statements from WebSocket, map, and aircraft managers
- Keep essential error messages and warnings for debugging
- Consolidate app-new.js into app.js to eliminate confusing duplicate files
- Update HTML reference to use clean app.js with incremented cache version
- Significantly reduce console noise for better user experience

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 14:55:54 +02:00
e51af89d84 Enhance aircraft type detection with proper ADS-B categories and visual differentiation
Replace hardcoded ICAO matches with standards-compliant ADS-B category parsing:
- Implement RTCA DO-260B weight-based categories (Light/Medium/Heavy/Super)
- Add helicopter, military, cargo, and GA aircraft type detection
- Create distinct SVG icons for each aircraft type (helicopter with rotor disc, swept-wing military, wide-body cargo, etc.)
- Enhanced callsign-based fallback classification
- Remove hardcoded aircraft 478058 helicopter detection per user feedback
- Support proper ADS-B category strings like "Medium 34000-136000kg"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 14:53:24 +02:00
f364ffe061 Fix CPR zone ambiguity causing aircraft to appear 100km away from actual position
PROBLEM:
- Aircraft were appearing ~100km from receiver when actually ~5km away
- CPR (Compact Position Reporting) algorithm has zone ambiguity issue
- Without reference position, aircraft can appear in wrong 6-degree zones

SOLUTION:
- Add receiver reference position to CPR decoder for zone resolution
- Modified NewDecoder() to accept reference latitude/longitude parameters
- Implement distance-based zone selection (choose solution closest to receiver)
- Updated all decoder instantiations to pass receiver coordinates

TECHNICAL CHANGES:
- decoder.go: Add refLatitude/refLongitude fields and zone ambiguity resolution
- beast.go: Pass source coordinates to NewDecoder()
- beast-dump/main.go: Use default coordinates (0,0) for command-line tool
- merger.go: Add position update debugging for verification
- JavaScript: Add coordinate validation and update logging

RESULT:
- Aircraft now appear at correct distances from receiver
- CPR zone selection based on proximity to known receiver location
- Resolves fundamental ADS-B position accuracy issue

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 14:40:36 +02:00
1de3e092ae Fix aircraft markers not updating positions in real-time
Root cause: The merger was blocking position updates from the same source
after the first position was established, designed for multi-source scenarios
but preventing single-source position updates.

Changes:
- Refactor JavaScript into modular architecture (WebSocketManager, AircraftManager, MapManager, UIManager)
- Add CPR coordinate validation to prevent invalid latitude/longitude values
- Fix merger to allow position updates from same source for moving aircraft
- Add comprehensive coordinate bounds checking in CPR decoder
- Update HTML to use new modular JavaScript with cache busting
- Add WebSocket debug logging to track data flow

Technical details:
- CPR decoder now validates coordinates within ±90° latitude, ±180° longitude
- Merger allows updates when currentBest == sourceID (same source continuous updates)
- JavaScript modules provide better separation of concerns and debugging
- WebSocket properly transmits updated aircraft coordinates to frontend

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 14:04:17 +02:00
ddffe1428d Improve Beast decoder with proper data formatting and altitude decoding
Major improvements to Beast message decoding and data presentation:

Speed & Track Formatting:
- Convert ground speed from float to integer (knots)
- Convert track angle from float to integer (0-359 degrees)
- Add proper rounding for velocity calculations
- Fix surface movement speed calculations

Altitude Decoding Enhancement:
- Implement proper Q-bit handling in altitude decoding
- Add altitude validation (range: -1000 to 60000 feet)
- Fix standard altitude encoding with 25-foot increments
- Add legacy Gray code support for older transponders
- Remove duplicate altitude values caused by incorrect decoding

ICAO Address Display:
- Fix JavaScript hex conversion that caused display corruption
- Remove duplicate toString(16) calls on already-formatted hex strings
- Proper ICAO display format (6-character hex like "4ACA0C")

Data Type Consistency:
- Update Aircraft struct to use integers for GroundSpeed and Track
- Update SpeedPoint struct for consistent integer speed/track storage
- Maintain float64 precision internally while displaying clean integers

Signal Strength:
- Confirm signal strength extraction is working properly
- Signal levels properly flow from Beast parser through merger to frontend
- Display in dBFS format (e.g., -1.57 dBFS)

Results:
- Clean integer speed values (198 kt instead of 238.03361107205006 kt)
- Proper track angles (41° instead of 37.83276127148023°)
- Realistic varying altitudes (1750-1825 ft instead of repeated 24450 ft)
- Correct ICAO formatting (4ACA0C instead of corrupted 103A)
- Working signal strength display (-0.98 to -2.16 dBFS)

The Beast decoder now produces accurate, properly formatted aircraft data
that displays cleanly in the web interface without data corruption.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 10:47:38 +02:00
32 changed files with 3315 additions and 1058 deletions

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
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.

View file

@ -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
- **Real-time Processing**: High-performance concurrent message processing - **High-throughput 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
- **Real-time Updates**: WebSocket-powered live data streaming - **Low-latency 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,13 +21,14 @@ 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**: Real-time charts and metrics - **Statistics Dashboard**: Live 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
@ -118,7 +119,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**: Real-time metrics and historical charts - **Statistics**: Live 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
@ -160,7 +161,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` - Real-time updates - `ws://localhost:8080/ws` - Low-latency updates
## 🛠️ Development ## 🛠️ Development

View file

@ -193,6 +193,48 @@ 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;
@ -228,6 +270,7 @@ 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; }
@ -362,20 +405,39 @@ 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 {
@ -384,21 +446,27 @@ body {
} }
.flight-id { .flight-id {
color: #00a8ff; color: #00a8ff !important;
font-family: monospace; font-family: monospace;
} }
.callsign { .callsign {
color: #00ff88; color: #00ff88 !important;
} }
.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 {
@ -415,13 +483,27 @@ body {
.detail-item .label { .detail-item .label {
font-size: 0.8rem; font-size: 0.8rem;
color: #888; color: #888 !important;
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
.detail-item .value { .detail-item .value {
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff !important;
}
/* 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) {

View file

@ -0,0 +1,9 @@
<?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>

After

Width:  |  Height:  |  Size: 384 B

View file

@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 292 B

View file

@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 289 B

View file

@ -0,0 +1,10 @@
<?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>

After

Width:  |  Height:  |  Size: 405 B

View file

@ -0,0 +1,12 @@
<?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>

After

Width:  |  Height:  |  Size: 546 B

View file

@ -0,0 +1,7 @@
<?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>

After

Width:  |  Height:  |  Size: 297 B

View file

@ -77,32 +77,59 @@
<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>Aircraft Types</h4> <h4>ADS-B Categories</h4>
<div class="legend-item"> <div class="legend-item">
<span class="legend-icon commercial"></span> <span class="legend-icon commercial"></span>
<span>Commercial</span> <span>Light &lt; 7000kg</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>Cargo</span> <span>Heavy &gt; 136000kg</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span class="legend-icon military"></span> <span class="legend-icon helicopter"></span>
<span>Military</span> <span>Rotorcraft</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span class="legend-icon ga"></span> <span class="legend-icon ga"></span>
<span>General Aviation</span> <span>Glider/Ultralight</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<span class="legend-icon ground"></span> <span class="legend-icon ground"></span>
<span>Ground</span> <span>Surface Vehicle</span>
</div> </div>
<h4>Sources</h4> <h4>Sources</h4>
@ -222,6 +249,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"></script> <script type="module" src="/static/js/app.js?v=4"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,493 @@
// 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));
}
}
}

View file

@ -0,0 +1,372 @@
// 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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: '&copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <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: '&copy; <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;
}
}

View file

@ -0,0 +1,321 @@
// 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
}
}

View file

@ -0,0 +1,54 @@
// 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;
}
}
}

BIN
beast-dump-with-heli.bin Normal file

Binary file not shown.

440
cmd/beast-dump/main.go Normal file
View file

@ -0,0 +1,440 @@
// 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)
}
}
}

View file

@ -19,4 +19,5 @@ 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 Executable file

Binary file not shown.

95
debian/usr/share/man/man1/beast-dump.1 vendored Normal file
View file

@ -0,0 +1,95 @@
.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 Normal file
View file

@ -0,0 +1,88 @@
.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>

Binary file not shown.

290
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,290 @@
# 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.

39
docs/CLAUDE.md Normal file
View file

@ -0,0 +1,39 @@
# 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)

View file

@ -130,8 +130,13 @@ 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:
return nil, fmt.Errorf("unknown message type: 0x%02x", msgType) // Skip unknown message types and continue parsing instead of failing
// 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)

View file

@ -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(), decoder: modes.NewDecoder(source.Latitude, source.Longitude),
msgChan: make(chan *beast.Message, 1000), msgChan: make(chan *beast.Message, 5000),
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, 10*time.Second) conn, err := net.DialTimeout("tcp", addr, 30*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

268
internal/icao/database.go Normal file
View file

@ -0,0 +1,268 @@
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
}

View file

@ -20,13 +20,21 @@
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.
@ -70,6 +78,103 @@ 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.
@ -113,8 +218,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 float64 `json:"ground_speed"` // Ground speed in knots GroundSpeed int `json:"ground_speed"` // Ground speed in knots (integer)
Track float64 `json:"track"` // Track angle in degrees Track int `json:"track"` // Track angle in degrees (0-359)
} }
// Merger handles merging aircraft data from multiple sources with intelligent conflict resolution. // Merger handles merging aircraft data from multiple sources with intelligent conflict resolution.
@ -131,9 +236,10 @@ 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 staleTimeout time.Duration // Time before aircraft considered stale (15 seconds)
updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data updateMetrics map[uint32]*updateMetric // ICAO24 -> update rate calculation data
} }
@ -147,19 +253,25 @@ type updateMetric struct {
// //
// Default settings: // Default settings:
// - History limit: 500 points per aircraft // - History limit: 500 points per aircraft
// - Stale timeout: 60 seconds // - Stale timeout: 15 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 { func NewMerger() (*Merger, error) {
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: 60 * time.Second, staleTimeout: 15 * time.Second, // Aircraft timeout - reasonable for ADS-B tracking
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.
@ -214,11 +326,27 @@ 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]
@ -294,12 +422,16 @@ 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
} }
} }
@ -540,7 +672,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 := float64(999999) minDistance := MaxDistance
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
@ -615,7 +747,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 60 seconds). This cleanup prevents memory // than staleTimeout (default 15 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.
@ -676,3 +808,14 @@ 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
}

View file

@ -30,8 +30,45 @@ 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 (
@ -82,9 +119,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 float64 // Ground speed in knots GroundSpeed int // Ground speed in knots (integer)
Track float64 // Track angle in degrees (direction of movement) Track int // Track angle in degrees (0-359, integer)
Heading float64 // Aircraft heading in degrees (magnetic) Heading int // Aircraft heading in degrees (magnetic, integer)
// Aircraft Information // Aircraft Information
Category string // Aircraft category (size, type, performance) Category string // Aircraft category (size, type, performance)
@ -126,16 +163,27 @@ 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 decoder is ready to process Mode S messages immediately and will // The reference position (typically the receiver location) is used to resolve
// maintain CPR position state across multiple messages for accurate // CPR zone ambiguity during position decoding. Without a proper reference,
// position decoding. // aircraft can appear many degrees away from their actual position.
//
// 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() *Decoder { func NewDecoder(refLat, refLon float64) *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),
@ -143,6 +191,8 @@ func NewDecoder() *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,
} }
} }
@ -168,6 +218,11 @@ 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)
@ -337,7 +392,8 @@ 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 // 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
@ -345,6 +401,7 @@ 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)
@ -352,37 +409,32 @@ 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.
// //
// This is the core algorithm for resolving aircraft positions from CPR-encoded data. // CRITICAL: The CPR algorithm has zone ambiguity that requires either:
// The algorithm requires both even and odd CPR messages to resolve position ambiguity. // 1. A reference position (receiver location) to resolve zones correctly, OR
// 2. Message timestamp comparison to choose the most recent valid position
// //
// CPR Global Decoding Algorithm: // Without proper zone resolution, aircraft can appear 6+ degrees away from actual position.
// 1. Check that both even and odd CPR values are available // This implementation uses global decoding which can produce large errors without
// 2. Calculate latitude using even/odd zone boundaries // additional context about expected aircraft location.
// 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
@ -398,8 +450,36 @@ func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
latOdd -= 360 latOdd -= 360
} }
// Choose the most recent position // Additional range correction to ensure valid latitude bounds (-90° to +90°)
aircraft.Latitude = latOdd // Use odd for now, should check timestamps if latEven > 90 {
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)
@ -410,9 +490,19 @@ 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.
@ -484,11 +574,21 @@ func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) {
nsVel = -nsVel nsVel = -nsVel
} }
aircraft.GroundSpeed = math.Sqrt(ewVel*ewVel + nsVel*nsVel) // Calculate ground speed in knots (rounded to integer)
aircraft.Track = math.Atan2(ewVel, nsVel) * 180 / math.Pi speedKnots := math.Sqrt(ewVel*ewVel + nsVel*nsVel)
if aircraft.Track < 0 {
aircraft.Track += 360 // Validate speed range (0-600 knots for civilian aircraft)
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
@ -538,19 +638,36 @@ func (d *Decoder) decodeAltitudeBits(altCode uint16, tc uint8) int {
return 0 return 0
} }
// Gray code to binary conversion // Standard altitude encoding with 25 ft increments
var n uint16 // Check Q-bit (bit 4) for encoding type
for i := uint(0); i < 12; i++ { qBit := (altCode >> 4) & 1
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
if tc >= 20 && tc <= 22 { // Validate altitude range
// GNSS altitude if alt < -1000 || alt > 60000 {
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
} }
@ -756,14 +873,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 = d.decodeGroundSpeed(movement) aircraft.GroundSpeed = int(math.Round(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 = float64(trackBits) * 360.0 / 128.0 aircraft.Track = int(math.Round(float64(trackBits) * 360.0 / 128.0))
} }
// CPR position (similar to airborne) // CPR position (similar to airborne)
@ -771,6 +888,8 @@ 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
@ -778,6 +897,7 @@ 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)
} }

View file

@ -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: 1024, ReadBufferSize: 8192,
WriteBufferSize: 1024, WriteBufferSize: 8192,
}, },
broadcastChan: make(chan []byte, 100), broadcastChan: make(chan []byte, 1000),
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }
} }
@ -183,6 +183,7 @@ 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")
@ -203,29 +204,60 @@ 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 aircraft // - count: Total number of useful aircraft (filtered count)
// //
// 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 // Convert ICAO keys to hex strings for JSON and filter useful aircraft
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(aircraft), "count": len(aircraftMap), // Count of filtered useful aircraft
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@ -478,11 +510,13 @@ 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 // Convert ICAO keys to hex strings and filter useful aircraft
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,
@ -555,9 +589,10 @@ 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. Formats the data as a WebSocketMessage with type "aircraft_update" // 2. Filters aircraft to only include "useful" ones (with position or callsign)
// 3. Converts ICAO addresses to hex strings for JSON compatibility // 3. Formats the data as a WebSocketMessage with type "aircraft_update"
// 4. Queues the message for broadcast (non-blocking) // 4. Converts ICAO addresses to hex strings for JSON compatibility
// 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
@ -567,11 +602,13 @@ 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 // Convert ICAO keys to hex strings and filter useful aircraft
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,
@ -711,3 +748,34 @@ 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 Executable file

Binary file not shown.

BIN
ux.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB