Complete Beast format implementation with enhanced features and fixes #19
4
.gitignore
vendored
|
|
@ -3,6 +3,10 @@ skyview
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Debian package build artifacts
|
||||||
|
debian/usr/bin/skyview
|
||||||
|
debian/usr/bin/beast-dump
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
config.json
|
config.json
|
||||||
|
|
||||||
|
|
|
||||||
39
CLAUDE.md
Normal 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)
|
||||||
21
LICENSE
Normal 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.
|
||||||
64
Makefile
|
|
@ -1,12 +1,26 @@
|
||||||
BINARY_NAME=skyview
|
PACKAGE_NAME=skyview
|
||||||
BUILD_DIR=build
|
BUILD_DIR=build
|
||||||
|
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||||
|
LDFLAGS=-w -s -X main.version=$(VERSION)
|
||||||
|
|
||||||
.PHONY: build clean run dev test lint
|
.PHONY: build build-all clean run dev test lint deb deb-clean install-deps
|
||||||
|
|
||||||
|
# Build main skyview binary
|
||||||
build:
|
build:
|
||||||
@echo "Building $(BINARY_NAME)..."
|
@echo "Building skyview..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
go build -ldflags="-w -s" -o $(BUILD_DIR)/$(BINARY_NAME) .
|
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/skyview ./cmd/skyview
|
||||||
|
|
||||||
|
# Build beast-dump utility binary
|
||||||
|
build-beast-dump:
|
||||||
|
@echo "Building beast-dump..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/beast-dump ./cmd/beast-dump
|
||||||
|
|
||||||
|
# Build all binaries
|
||||||
|
build-all: build build-beast-dump
|
||||||
|
@echo "Built all binaries successfully:"
|
||||||
|
@ls -la $(BUILD_DIR)/
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning..."
|
@echo "Cleaning..."
|
||||||
|
|
@ -19,7 +33,7 @@ run: build
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@echo "Running in development mode..."
|
@echo "Running in development mode..."
|
||||||
go run main.go
|
go run ./cmd/skyview
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@echo "Running tests..."
|
||||||
|
|
@ -33,6 +47,33 @@ lint:
|
||||||
echo "golangci-lint not installed, skipping lint"; \
|
echo "golangci-lint not installed, skipping lint"; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Debian package targets
|
||||||
|
deb:
|
||||||
|
@echo "Building Debian package..."
|
||||||
|
@./scripts/build-deb.sh
|
||||||
|
|
||||||
|
deb-clean:
|
||||||
|
@echo "Cleaning Debian package artifacts..."
|
||||||
|
@rm -f debian/usr/bin/skyview
|
||||||
|
@rm -rf build/*.deb
|
||||||
|
|
||||||
|
deb-install: deb
|
||||||
|
@echo "Installing Debian package..."
|
||||||
|
@if [ "$$EUID" -ne 0 ]; then \
|
||||||
|
echo "Please run as root: sudo make deb-install"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@dpkg -i build/skyview_*.deb || (apt-get update && apt-get -f install -y)
|
||||||
|
|
||||||
|
deb-remove:
|
||||||
|
@echo "Removing Debian package..."
|
||||||
|
@if [ "$$EUID" -ne 0 ]; then \
|
||||||
|
echo "Please run as root: sudo make deb-remove"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@dpkg -r skyview || true
|
||||||
|
|
||||||
|
# Docker/Podman targets
|
||||||
docker-build:
|
docker-build:
|
||||||
@echo "Building Docker image..."
|
@echo "Building Docker image..."
|
||||||
docker build -t skyview .
|
docker build -t skyview .
|
||||||
|
|
@ -41,8 +82,21 @@ podman-build:
|
||||||
@echo "Building Podman image..."
|
@echo "Building Podman image..."
|
||||||
podman build -t skyview .
|
podman build -t skyview .
|
||||||
|
|
||||||
|
# Development targets
|
||||||
install-deps:
|
install-deps:
|
||||||
@echo "Installing Go dependencies..."
|
@echo "Installing Go dependencies..."
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|
||||||
|
format:
|
||||||
|
@echo "Formatting code..."
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
@echo "Running go vet..."
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# Combined quality check
|
||||||
|
check: format vet lint test
|
||||||
|
@echo "All checks passed!"
|
||||||
|
|
||||||
.DEFAULT_GOAL := build
|
.DEFAULT_GOAL := build
|
||||||
300
README.md
|
|
@ -1,112 +1,268 @@
|
||||||
# SkyView - ADS-B Aircraft Tracker
|
# SkyView - Multi-Source ADS-B Aircraft Tracker
|
||||||
|
|
||||||
A modern web frontend for dump1090 ADS-B data with real-time aircraft tracking, statistics, and mobile-responsive design.
|
A high-performance, multi-source ADS-B aircraft tracking application that connects to multiple dump1090 Beast format TCP streams and provides a modern web interface with advanced visualization capabilities.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
- **Real-time Aircraft Tracking**: Live map with aircraft positions and flight paths
|
### Multi-Source Data Fusion
|
||||||
- **Interactive Map**: Leaflet-based map with aircraft markers and optional trails
|
- **Beast Binary Format**: Native support for dump1090 Beast format (port 30005)
|
||||||
- **Aircraft Table**: Sortable and filterable table view with detailed aircraft information
|
- **Multiple Receivers**: Connect to unlimited dump1090 sources simultaneously
|
||||||
- **Statistics Dashboard**: Real-time statistics and charts for signal strength, aircraft counts
|
- **Intelligent Merging**: Smart data fusion with signal strength-based source selection
|
||||||
- **WebSocket Updates**: Real-time data updates without polling
|
- **High-throughput Processing**: High-performance concurrent message processing
|
||||||
|
|
||||||
|
### Advanced Web Interface
|
||||||
|
- **Interactive Maps**: Leaflet.js-based mapping with aircraft tracking
|
||||||
|
- **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
|
||||||
- **Single Binary**: Embedded static files for easy deployment
|
- **Multi-view Dashboard**: Map, Table, Statistics, Coverage, and 3D Radar views
|
||||||
|
|
||||||
## Configuration
|
### Professional Visualization
|
||||||
|
- **Signal Analysis**: Signal strength visualization and coverage analysis
|
||||||
|
- **Range Circles**: Configurable range rings for each receiver
|
||||||
|
- **Flight Trails**: Historical aircraft movement tracking
|
||||||
|
- **3D Radar View**: Three.js-powered 3D visualization
|
||||||
|
- **Statistics Dashboard**: Aircraft count timeline *(additional charts under construction)* 🚧
|
||||||
|
- **Smart Origin**: Auto-calculated map center based on receiver locations
|
||||||
|
- **Map Controls**: Center on aircraft, reset to origin, toggle overlays
|
||||||
|
- **Signal Heatmaps**: Coverage heatmap visualization *(under construction)* 🚧
|
||||||
|
|
||||||
### Environment Variables
|
### Aircraft Data
|
||||||
|
- **Complete Mode S Decoding**: Position, velocity, altitude, heading
|
||||||
|
- **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
|
||||||
|
- **Historical Data**: Position history and trail visualization
|
||||||
|
|
||||||
- `SKYVIEW_ADDRESS`: Server listen address (default: ":8080")
|
## 🚀 Quick Start
|
||||||
- `SKYVIEW_PORT`: Server port (default: 8080)
|
|
||||||
- `DUMP1090_HOST`: dump1090 host address (default: "localhost")
|
|
||||||
- `DUMP1090_DATA_PORT`: dump1090 SBS-1 data port (default: 30003)
|
|
||||||
- `ORIGIN_LATITUDE`: Receiver latitude for distance calculations (default: 37.7749)
|
|
||||||
- `ORIGIN_LONGITUDE`: Receiver longitude for distance calculations (default: -122.4194)
|
|
||||||
- `ORIGIN_NAME`: Name/description of receiver location (default: "Default Location")
|
|
||||||
- `SKYVIEW_CONFIG`: Path to JSON configuration file
|
|
||||||
|
|
||||||
### Configuration File
|
### Using Command Line
|
||||||
|
|
||||||
SkyView automatically loads `config.json` from the current directory, or you can specify a path with `SKYVIEW_CONFIG`.
|
```bash
|
||||||
|
# Single source
|
||||||
|
./skyview -sources "primary:Local:localhost:30005:51.47:-0.46"
|
||||||
|
|
||||||
Create a `config.json` file (see `config.json.example`):
|
# Multiple sources
|
||||||
|
./skyview -sources "site1:North:192.168.1.100:30005:51.50:-0.46,site2:South:192.168.1.101:30005:51.44:-0.46"
|
||||||
|
|
||||||
|
# Using configuration file
|
||||||
|
./skyview -config config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Debian Package
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install
|
||||||
|
sudo dpkg -i skyview_0.0.2_amd64.deb
|
||||||
|
|
||||||
|
# Configure
|
||||||
|
sudo nano /etc/skyview/config.json
|
||||||
|
|
||||||
|
# Start service
|
||||||
|
sudo systemctl start skyview
|
||||||
|
sudo systemctl enable skyview
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
### Configuration File Structure
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"server": {
|
"server": {
|
||||||
"address": ":8080",
|
"host": "",
|
||||||
"port": 8080
|
"port": 8080
|
||||||
},
|
},
|
||||||
"dump1090": {
|
"sources": [
|
||||||
"host": "192.168.1.100",
|
{
|
||||||
"data_port": 30003
|
"id": "primary",
|
||||||
|
"name": "Primary Receiver",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 30005,
|
||||||
|
"latitude": 51.4700,
|
||||||
|
"longitude": -0.4600,
|
||||||
|
"altitude": 50.0,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"history_limit": 1000,
|
||||||
|
"stale_timeout": 60,
|
||||||
|
"update_rate": 1
|
||||||
|
},
|
||||||
|
"origin": {
|
||||||
|
"latitude": 51.4700,
|
||||||
|
"longitude": -0.4600,
|
||||||
|
"name": "Custom Origin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Source
|
### Command Line Options
|
||||||
|
|
||||||
SkyView uses **SBS-1/BaseStation format** (Port 30003) which provides decoded aircraft information including:
|
|
||||||
- Aircraft position (latitude/longitude)
|
|
||||||
- Altitude, ground speed, vertical rate
|
|
||||||
- Flight number/callsign
|
|
||||||
- Squawk code and emergency status
|
|
||||||
|
|
||||||
## Building and Running
|
|
||||||
|
|
||||||
### Build
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build -o skyview .
|
skyview [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-config string
|
||||||
|
Configuration file path
|
||||||
|
-server string
|
||||||
|
Server address (default ":8080")
|
||||||
|
-sources string
|
||||||
|
Comma-separated Beast sources (id:name:host:port:lat:lon)
|
||||||
|
-verbose
|
||||||
|
Enable verbose logging
|
||||||
```
|
```
|
||||||
|
|
||||||
### Run
|
## 🗺️ Web Interface
|
||||||
|
|
||||||
|
Access the web interface at `http://localhost:8080`
|
||||||
|
|
||||||
|
### Views Available:
|
||||||
|
- **Map View**: Interactive aircraft tracking with receiver locations
|
||||||
|
- **Table View**: Sortable aircraft data with multi-source information
|
||||||
|
- **Statistics**: Aircraft count timeline *(additional charts planned)* 🚧
|
||||||
|
- **Coverage**: Signal strength analysis *(heatmaps under construction)* 🚧
|
||||||
|
- **3D Radar**: Three-dimensional aircraft visualization *(controls under construction)* 🚧
|
||||||
|
|
||||||
|
### 🚧 Features Under Construction
|
||||||
|
Some advanced features are currently in development:
|
||||||
|
- **Message Rate Charts**: Per-source message rate visualization
|
||||||
|
- **Signal Strength Distribution**: Signal strength histogram analysis
|
||||||
|
- **Altitude Distribution**: Aircraft altitude distribution charts
|
||||||
|
- **Interactive Heatmaps**: Leaflet.heat-based coverage heatmaps
|
||||||
|
- **3D Radar Controls**: Interactive 3D view manipulation (reset, auto-rotate, range)
|
||||||
|
- **Enhanced Error Notifications**: User-friendly toast notifications for issues
|
||||||
|
|
||||||
|
## 🔧 Building
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Go 1.21 or later
|
||||||
|
- Make
|
||||||
|
|
||||||
|
### Build Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Foreground (default) - Press Ctrl+C to stop
|
make build # Build binary
|
||||||
DUMP1090_HOST=192.168.1.100 ./skyview
|
make deb # Create Debian package
|
||||||
|
make docker-build # Build Docker image
|
||||||
# Daemon mode (background process)
|
make test # Run tests
|
||||||
DUMP1090_HOST=192.168.1.100 ./skyview -daemon
|
make clean # Clean artifacts
|
||||||
|
|
||||||
# With custom origin location
|
|
||||||
DUMP1090_HOST=192.168.1.100 ORIGIN_LATITUDE=59.3293 ORIGIN_LONGITUDE=18.0686 ORIGIN_NAME="Stockholm" ./skyview
|
|
||||||
|
|
||||||
# Using config file
|
|
||||||
SKYVIEW_CONFIG=config.json ./skyview
|
|
||||||
|
|
||||||
# Default (localhost:30003)
|
|
||||||
./skyview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
## 🐳 Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run main.go
|
# Build
|
||||||
|
make docker-build
|
||||||
|
|
||||||
|
# Run
|
||||||
|
docker run -p 8080:8080 -v $(pwd)/config.json:/app/config.json skyview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## 📊 API Reference
|
||||||
|
|
||||||
1. Start your dump1090 instance
|
### REST Endpoints
|
||||||
2. Configure SkyView to point to your dump1090 host
|
- `GET /api/aircraft` - All aircraft data
|
||||||
3. Run SkyView
|
- `GET /api/aircraft/{icao}` - Individual aircraft details
|
||||||
4. Open your browser to `http://localhost:8080`
|
- `GET /api/sources` - Data source information
|
||||||
|
- `GET /api/stats` - System statistics
|
||||||
|
- `GET /api/origin` - Map origin configuration
|
||||||
|
- `GET /api/coverage/{sourceId}` - Coverage analysis
|
||||||
|
- `GET /api/heatmap/{sourceId}` - Signal heatmap
|
||||||
|
|
||||||
## API Endpoints
|
### WebSocket
|
||||||
|
- `ws://localhost:8080/ws` - Low-latency updates
|
||||||
|
|
||||||
- `GET /`: Main web interface
|
## 🛠️ Development
|
||||||
- `GET /api/aircraft`: Aircraft data (parsed from dump1090 TCP stream)
|
|
||||||
- `GET /api/stats`: Statistics data (calculated from aircraft data)
|
|
||||||
- `GET /ws`: WebSocket endpoint for real-time updates
|
|
||||||
|
|
||||||
## Data Sources
|
### Project Structure
|
||||||
|
```
|
||||||
|
skyview/
|
||||||
|
├── cmd/skyview/ # Main application
|
||||||
|
├── assets/ # Embedded static web assets
|
||||||
|
├── internal/
|
||||||
|
│ ├── beast/ # Beast format parser
|
||||||
|
│ ├── modes/ # Mode S decoder
|
||||||
|
│ ├── merger/ # Multi-source merger
|
||||||
|
│ ├── client/ # Beast TCP clients
|
||||||
|
│ └── server/ # HTTP/WebSocket server
|
||||||
|
├── debian/ # Debian packaging
|
||||||
|
└── scripts/ # Build scripts
|
||||||
|
```
|
||||||
|
|
||||||
SkyView connects to dump1090's **SBS-1/BaseStation format** via TCP port 30003 to receive decoded aircraft data in real-time.
|
### Development Commands
|
||||||
|
```bash
|
||||||
|
make dev # Run in development mode
|
||||||
|
make format # Format code
|
||||||
|
make lint # Run linter
|
||||||
|
make check # Run all checks
|
||||||
|
```
|
||||||
|
|
||||||
The application maintains an in-memory aircraft database with automatic cleanup of stale aircraft (older than 2 minutes).
|
## 📦 Deployment
|
||||||
|
|
||||||
## Views
|
### Systemd Service (Debian/Ubuntu)
|
||||||
|
```bash
|
||||||
|
# Install package
|
||||||
|
sudo dpkg -i skyview_0.0.2_amd64.deb
|
||||||
|
|
||||||
- **Map View**: Interactive map with aircraft positions and trails
|
# Configure sources in /etc/skyview/config.json
|
||||||
- **Table View**: Sortable table with aircraft details and search
|
# Start service
|
||||||
- **Stats View**: Dashboard with real-time statistics and charts
|
sudo systemctl start skyview
|
||||||
|
sudo systemctl enable skyview
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
sudo systemctl status skyview
|
||||||
|
sudo journalctl -u skyview -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Installation
|
||||||
|
```bash
|
||||||
|
# Build binary
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Create user and directories
|
||||||
|
sudo useradd -r -s /bin/false skyview
|
||||||
|
sudo mkdir -p /etc/skyview /var/lib/skyview /var/log/skyview
|
||||||
|
sudo chown skyview:skyview /var/lib/skyview /var/log/skyview
|
||||||
|
|
||||||
|
# Install binary and config
|
||||||
|
sudo cp build/skyview /usr/bin/
|
||||||
|
sudo cp config.example.json /etc/skyview/config.json
|
||||||
|
sudo chown root:skyview /etc/skyview/config.json
|
||||||
|
sudo chmod 640 /etc/skyview/config.json
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
sudo cp debian/lib/systemd/system/skyview.service /lib/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable skyview
|
||||||
|
sudo systemctl start skyview
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔒 Security
|
||||||
|
|
||||||
|
The application includes security hardening:
|
||||||
|
- Runs as unprivileged user
|
||||||
|
- Restricted filesystem access
|
||||||
|
- Network isolation where possible
|
||||||
|
- Systemd security features enabled
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create feature branch
|
||||||
|
3. Make changes with tests
|
||||||
|
4. Submit pull request
|
||||||
|
|
||||||
|
## 🆘 Support
|
||||||
|
|
||||||
|
- [Issues](https://kode.naiv.no/olemd/skyview/issues)
|
||||||
|
- [Documentation](https://kode.naiv.no/olemd/skyview/wiki)
|
||||||
|
- [Configuration Examples](https://kode.naiv.no/olemd/skyview/src/branch/main/examples)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**SkyView** - Professional multi-source ADS-B tracking with Beast format support.
|
||||||
32
assets/assets.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Package assets provides embedded static web assets for the SkyView application.
|
||||||
|
//
|
||||||
|
// This package uses Go 1.16+ embed functionality to include all static web files
|
||||||
|
// directly in the compiled binary, eliminating the need for external file dependencies
|
||||||
|
// at runtime. The embedded assets include:
|
||||||
|
// - index.html: Main web interface with aircraft tracking map
|
||||||
|
// - css/style.css: Styling for the web interface
|
||||||
|
// - js/app.js: JavaScript client for WebSocket communication and map rendering
|
||||||
|
// - icons/*.svg: Type-specific SVG icons for aircraft markers
|
||||||
|
// - favicon.ico: Browser icon
|
||||||
|
//
|
||||||
|
// The embedded filesystem is used by the HTTP server to serve static content
|
||||||
|
// and enables single-binary deployment without external asset dependencies.
|
||||||
|
package assets
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// Static contains all embedded static web assets from the static/ directory.
|
||||||
|
//
|
||||||
|
// Files are embedded at build time and can be accessed using the standard
|
||||||
|
// fs.FS interface. Path names within the embedded filesystem preserve the
|
||||||
|
// directory structure, so files are accessed as:
|
||||||
|
// - "static/index.html"
|
||||||
|
// - "static/css/style.css"
|
||||||
|
// - "static/js/app.js"
|
||||||
|
// - etc.
|
||||||
|
//
|
||||||
|
// This approach ensures the web interface is always available without requiring
|
||||||
|
// external file deployment or complicated asset management.
|
||||||
|
//
|
||||||
|
//go:embed static
|
||||||
|
var Static embed.FS
|
||||||
|
|
@ -193,6 +193,48 @@ body {
|
||||||
background: #404040;
|
background: #404040;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.display-options {
|
||||||
|
position: absolute;
|
||||||
|
top: 320px;
|
||||||
|
right: 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;
|
||||||
|
|
@ -226,11 +268,15 @@ body {
|
||||||
border: 1px solid #ffffff;
|
border: 1px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-icon.commercial { background: #00ff88; }
|
.legend-icon.light { background: #00bfff; } /* Sky blue for light aircraft */
|
||||||
.legend-icon.cargo { background: #ff8c00; }
|
.legend-icon.medium { background: #00ff88; } /* Green for medium aircraft */
|
||||||
.legend-icon.military { background: #ff4444; }
|
.legend-icon.large { background: #ff8c00; } /* Orange for large aircraft */
|
||||||
.legend-icon.ga { background: #ffff00; }
|
.legend-icon.high-vortex { background: #ff4500; } /* Red-orange for high vortex large */
|
||||||
.legend-icon.ground { background: #888888; }
|
.legend-icon.heavy { background: #ff0000; } /* Red for heavy aircraft */
|
||||||
|
.legend-icon.helicopter { background: #ff00ff; } /* Magenta for helicopters */
|
||||||
|
.legend-icon.military { background: #ff4444; } /* Red-orange for military */
|
||||||
|
.legend-icon.ga { background: #ffff00; } /* Yellow for general aviation */
|
||||||
|
.legend-icon.ground { background: #888888; } /* Gray for ground vehicles */
|
||||||
|
|
||||||
.table-controls {
|
.table-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -362,20 +408,148 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Under Construction Styles */
|
||||||
|
.under-construction {
|
||||||
|
color: #ff8c00;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.construction-notice {
|
||||||
|
background: rgba(255, 140, 0, 0.1);
|
||||||
|
border: 1px solid #ff8c00;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #ff8c00;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Notifications */
|
||||||
|
.toast-notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(40, 40, 40, 0.95);
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.9em;
|
||||||
|
max-width: 300px;
|
||||||
|
z-index: 10000;
|
||||||
|
transform: translateX(320px);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-notification.error {
|
||||||
|
border-color: #ff8c00;
|
||||||
|
background: rgba(255, 140, 0, 0.1);
|
||||||
|
color: #ff8c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-notification.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version Info */
|
||||||
|
.version-info {
|
||||||
|
font-size: 0.6em;
|
||||||
|
color: #888;
|
||||||
|
font-weight: normal;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Repository Link */
|
||||||
|
.repo-link {
|
||||||
|
color: #888;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.7em;
|
||||||
|
margin-left: 6px;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-link:hover {
|
||||||
|
color: #4a9eff;
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible Sections */
|
||||||
|
.collapsible-header {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-header:hover {
|
||||||
|
color: #4a9eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-indicator {
|
||||||
|
font-size: 0.8em;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-header.collapsed .collapse-indicator {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-content {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-content.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +558,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 +595,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) {
|
||||||
28
assets/static/icons/cargo.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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 -->
|
||||||
|
<!-- Fuselage (wider) -->
|
||||||
|
<path d="M0,-14 L-2,-11 L-2,6 L-1.5,9 L0,10 L1.5,9 L2,6 L2,-11 Z" fill="currentColor"/>
|
||||||
|
<!-- Cargo door outline -->
|
||||||
|
<rect x="-1.5" y="-2" width="3" height="4" fill="none" stroke="currentColor" stroke-width="0.3" opacity="0.5"/>
|
||||||
|
<!-- Main wings (swept back more) -->
|
||||||
|
<path d="M-1.5,1 L-13,4 L-13,6 L-1.5,3.5 Z" fill="currentColor"/>
|
||||||
|
<path d="M1.5,1 L13,4 L13,6 L1.5,3.5 Z" fill="currentColor"/>
|
||||||
|
<!-- Winglets -->
|
||||||
|
<path d="M-13,4 L-13,2 L-12,4 Z" fill="currentColor"/>
|
||||||
|
<path d="M13,4 L13,2 L12,4 Z" fill="currentColor"/>
|
||||||
|
<!-- Tail wings (larger) -->
|
||||||
|
<path d="M-1,7 L-6,9 L-6,10 L-1,9 Z" fill="currentColor"/>
|
||||||
|
<path d="M1,7 L6,9 L6,10 L1,9 Z" fill="currentColor"/>
|
||||||
|
<!-- Vertical stabilizer (taller) -->
|
||||||
|
<path d="M0,5 L-0.7,5 L-3,10 L0,10 L3,10 L0.7,5 Z" fill="currentColor"/>
|
||||||
|
<!-- Nose cone (blunter for cargo) -->
|
||||||
|
<ellipse cx="0" cy="-13" rx="2" ry="2.5" fill="currentColor"/>
|
||||||
|
<!-- Engine nacelles (4 engines for heavy cargo) -->
|
||||||
|
<ellipse cx="-5" cy="2.5" rx="1.2" ry="2.2" fill="currentColor"/>
|
||||||
|
<ellipse cx="-8" cy="3" rx="1" ry="1.8" fill="currentColor"/>
|
||||||
|
<ellipse cx="5" cy="2.5" rx="1.2" ry="2.2" fill="currentColor"/>
|
||||||
|
<ellipse cx="8" cy="3" rx="1" ry="1.8" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
21
assets/static/icons/commercial.svg
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?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)">
|
||||||
|
<!-- Commercial airliner with more realistic proportions -->
|
||||||
|
<!-- Fuselage -->
|
||||||
|
<path d="M0,-14 L-1.5,-12 L-1.5,7 L-1,10 L0,11 L1,10 L1.5,7 L1.5,-12 Z" fill="currentColor"/>
|
||||||
|
<!-- Main wings -->
|
||||||
|
<path d="M-1,0 L-12,3 L-12,5 L-1,3 Z" fill="currentColor"/>
|
||||||
|
<path d="M1,0 L12,3 L12,5 L1,3 Z" fill="currentColor"/>
|
||||||
|
<!-- Tail wings (horizontal stabilizers) -->
|
||||||
|
<path d="M-0.8,8 L-5,9.5 L-5,10.5 L-0.8,9.5 Z" fill="currentColor"/>
|
||||||
|
<path d="M0.8,8 L5,9.5 L5,10.5 L0.8,9.5 Z" fill="currentColor"/>
|
||||||
|
<!-- Vertical stabilizer -->
|
||||||
|
<path d="M0,6 L-0.5,6 L-2,11 L0,11 L2,11 L0.5,6 Z" fill="currentColor"/>
|
||||||
|
<!-- Nose cone -->
|
||||||
|
<ellipse cx="0" cy="-13.5" rx="1.5" ry="2" fill="currentColor"/>
|
||||||
|
<!-- Engine nacelles -->
|
||||||
|
<ellipse cx="-4" cy="2" rx="1" ry="2" fill="currentColor"/>
|
||||||
|
<ellipse cx="4" cy="2" rx="1" ry="2" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1 KiB |
27
assets/static/icons/ga.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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 (Cessna-style) -->
|
||||||
|
<!-- Fuselage -->
|
||||||
|
<path d="M0,-12 L-1,-10 L-1,6 L-0.5,8 L0,9 L0.5,8 L1,6 L1,-10 Z" fill="currentColor"/>
|
||||||
|
<!-- High wings (typical of GA aircraft) -->
|
||||||
|
<path d="M-1,-2 L-10,0 L-10,1.5 L-1,0 Z" fill="currentColor"/>
|
||||||
|
<path d="M1,-2 L10,0 L10,1.5 L1,0 Z" fill="currentColor"/>
|
||||||
|
<!-- Wing struts -->
|
||||||
|
<path d="M-1,2 L-6,-1" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<path d="M1,2 L6,-1" stroke="currentColor" stroke-width="0.5"/>
|
||||||
|
<!-- Tail wings -->
|
||||||
|
<path d="M-0.5,6 L-3.5,7.5 L-3.5,8.5 L-0.5,7.5 Z" fill="currentColor"/>
|
||||||
|
<path d="M0.5,6 L3.5,7.5 L3.5,8.5 L0.5,7.5 Z" fill="currentColor"/>
|
||||||
|
<!-- Vertical stabilizer -->
|
||||||
|
<path d="M0,5 L-0.4,5 L-1.5,9 L0,9 L1.5,9 L0.4,5 Z" fill="currentColor"/>
|
||||||
|
<!-- Propeller spinner -->
|
||||||
|
<circle cx="0" cy="-12" r="1.5" fill="currentColor"/>
|
||||||
|
<!-- Propeller blades -->
|
||||||
|
<path d="M-4,-12 L4,-12" stroke="currentColor" stroke-width="1" opacity="0.3"/>
|
||||||
|
<!-- Landing gear -->
|
||||||
|
<circle cx="-1" cy="5" r="0.8" fill="currentColor"/>
|
||||||
|
<circle cx="1" cy="5" r="0.8" fill="currentColor"/>
|
||||||
|
<circle cx="0" cy="-8" r="0.6" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
24
assets/static/icons/ground.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?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)">
|
||||||
|
<!-- Airport ground service vehicle -->
|
||||||
|
<!-- Main body -->
|
||||||
|
<path d="M-6,-3 L-6,3 L6,3 L6,-1 L4,-3 Z" fill="currentColor"/>
|
||||||
|
<!-- Cab/cockpit -->
|
||||||
|
<path d="M4,-3 L4,1 L6,1 L6,-1 Z" fill="currentColor" opacity="0.8"/>
|
||||||
|
<!-- Windows -->
|
||||||
|
<rect x="4.5" y="-2.5" width="1" height="1.5" fill="currentColor" opacity="0.5"/>
|
||||||
|
<!-- Cargo area -->
|
||||||
|
<rect x="-5" y="-2" width="7" height="3" fill="none" stroke="currentColor" stroke-width="0.3" opacity="0.5"/>
|
||||||
|
<!-- Wheels -->
|
||||||
|
<circle cx="-4" cy="4" r="1.5" fill="currentColor" opacity="0.7"/>
|
||||||
|
<circle cx="-1" cy="4" r="1.5" fill="currentColor" opacity="0.7"/>
|
||||||
|
<circle cx="4" cy="4" r="1.5" fill="currentColor" opacity="0.7"/>
|
||||||
|
<!-- Wheel details -->
|
||||||
|
<circle cx="-4" cy="4" r="0.5" fill="currentColor" opacity="0.4"/>
|
||||||
|
<circle cx="-1" cy="4" r="0.5" fill="currentColor" opacity="0.4"/>
|
||||||
|
<circle cx="4" cy="4" r="0.5" fill="currentColor" opacity="0.4"/>
|
||||||
|
<!-- Beacon light on top -->
|
||||||
|
<rect x="-1" y="-4" width="2" height="1" fill="currentColor" opacity="0.6"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
29
assets/static/icons/helicopter.svg
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?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)">
|
||||||
|
<!-- Main rotor disc (animated feel) -->
|
||||||
|
<ellipse cx="0" cy="-2" rx="11" ry="1" fill="currentColor" opacity="0.2"/>
|
||||||
|
<path d="M-11,-2 L11,-2" stroke="currentColor" stroke-width="0.5" opacity="0.4"/>
|
||||||
|
<path d="M0,-13 L0,9" stroke="currentColor" stroke-width="0.5" opacity="0.4"/>
|
||||||
|
|
||||||
|
<!-- Main fuselage -->
|
||||||
|
<path d="M0,-8 C-3,-8 -4,-6 -4,-3 L-4,4 C-4,6 -3,7 -1,7 L1,7 C3,7 4,6 4,4 L4,-3 C4,-6 3,-8 0,-8 Z" fill="currentColor"/>
|
||||||
|
|
||||||
|
<!-- Cockpit windscreen -->
|
||||||
|
<path d="M0,-8 C-2,-8 -3,-7 -3,-5 L-3,-3 L3,-3 L3,-5 C3,-7 2,-8 0,-8 Z" fill="currentColor" opacity="0.7"/>
|
||||||
|
|
||||||
|
<!-- Tail boom -->
|
||||||
|
<rect x="-1" y="6" width="2" height="8" fill="currentColor"/>
|
||||||
|
|
||||||
|
<!-- Tail rotor -->
|
||||||
|
<ellipse cx="0" cy="13" rx="1" ry="3" fill="currentColor"/>
|
||||||
|
<path d="M-3,13 L3,13" stroke="currentColor" stroke-width="0.8"/>
|
||||||
|
|
||||||
|
<!-- Landing skids -->
|
||||||
|
<path d="M-3,7 L-3,9 L-1,9" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||||
|
<path d="M3,7 L3,9 L1,9" stroke="currentColor" stroke-width="1" fill="none"/>
|
||||||
|
|
||||||
|
<!-- Rotor hub -->
|
||||||
|
<circle cx="0" cy="-2" r="1" fill="currentColor"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
27
assets/static/icons/military.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?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)">
|
||||||
|
<!-- Fighter jet with delta wings -->
|
||||||
|
<!-- Fuselage -->
|
||||||
|
<path d="M0,-14 L-0.8,-11 L-0.8,8 L0,10 L0.8,8 L0.8,-11 Z" fill="currentColor"/>
|
||||||
|
<!-- Delta wings (swept back aggressively) -->
|
||||||
|
<path d="M-0.8,2 L-10,8 L-9,9 L-0.8,5 Z" fill="currentColor"/>
|
||||||
|
<path d="M0.8,2 L10,8 L9,9 L0.8,5 Z" fill="currentColor"/>
|
||||||
|
<!-- Canards (small forward wings) -->
|
||||||
|
<path d="M-0.5,-4 L-3,-3 L-3,-2 L-0.5,-3 Z" fill="currentColor"/>
|
||||||
|
<path d="M0.5,-4 L3,-3 L3,-2 L0.5,-3 Z" fill="currentColor"/>
|
||||||
|
<!-- Vertical stabilizers (twin tails) -->
|
||||||
|
<path d="M-1,6 L-1.5,6 L-2.5,10 L-1,10 Z" fill="currentColor"/>
|
||||||
|
<path d="M1,6 L1.5,6 L2.5,10 L1,10 Z" fill="currentColor"/>
|
||||||
|
<!-- Nose cone (pointed) -->
|
||||||
|
<path d="M0,-14 L-0.8,-11 L0,-10 L0.8,-11 Z" fill="currentColor"/>
|
||||||
|
<!-- Air intakes -->
|
||||||
|
<rect x="-1.5" y="-2" width="0.7" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<rect x="0.8" y="-2" width="0.7" height="3" fill="currentColor" opacity="0.7"/>
|
||||||
|
<!-- Weapons hardpoints -->
|
||||||
|
<rect x="-5" y="6" width="0.5" height="2" fill="currentColor"/>
|
||||||
|
<rect x="4.5" y="6" width="0.5" height="2" fill="currentColor"/>
|
||||||
|
<!-- Exhaust nozzle -->
|
||||||
|
<ellipse cx="0" cy="9" rx="0.8" ry="1.5" fill="currentColor" opacity="0.8"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
265
assets/static/index.html
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SkyView - Multi-Source ADS-B Aircraft Tracker</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
|
|
||||||
|
<!-- Leaflet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
|
||||||
|
<!-- Chart.js -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||||
|
|
||||||
|
<!-- Three.js for 3D radar (ES modules) -->
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.module.js",
|
||||||
|
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.158.0/examples/jsm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header class="header">
|
||||||
|
<h1>SkyView <span class="version-info">v0.0.2</span> <a href="https://kode.naiv.no/olemd/skyview" target="_blank" class="repo-link" title="Project Repository">⚙</a></h1>
|
||||||
|
|
||||||
|
<!-- Status indicators -->
|
||||||
|
<div class="status-section">
|
||||||
|
<div class="clock-display">
|
||||||
|
<div class="clock" id="utc-clock">
|
||||||
|
<div class="clock-face">
|
||||||
|
<div class="clock-hand hour-hand" id="utc-hour"></div>
|
||||||
|
<div class="clock-hand minute-hand" id="utc-minute"></div>
|
||||||
|
</div>
|
||||||
|
<div class="clock-label">UTC</div>
|
||||||
|
</div>
|
||||||
|
<div class="clock" id="update-clock">
|
||||||
|
<div class="clock-face">
|
||||||
|
<div class="clock-hand hour-hand" id="update-hour"></div>
|
||||||
|
<div class="clock-hand minute-hand" id="update-minute"></div>
|
||||||
|
</div>
|
||||||
|
<div class="clock-label">Last Update</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="stats-summary">
|
||||||
|
<span id="aircraft-count">0 aircraft</span>
|
||||||
|
<span id="sources-count">0 sources</span>
|
||||||
|
<span id="connection-status" class="connection-status disconnected">Connecting...</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- View selection tabs -->
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button id="map-view-btn" class="view-btn active">Map</button>
|
||||||
|
<button id="table-view-btn" class="view-btn">Table</button>
|
||||||
|
<button id="stats-view-btn" class="view-btn">Statistics</button>
|
||||||
|
<button id="coverage-view-btn" class="view-btn">Coverage</button>
|
||||||
|
<button id="radar3d-view-btn" class="view-btn">3D Radar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map View -->
|
||||||
|
<div id="map-view" class="view active">
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<!-- Map controls -->
|
||||||
|
<div class="map-controls">
|
||||||
|
<button id="center-map" title="Center on aircraft">Center 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-sources" title="Show/hide source locations">Show Sources</button>
|
||||||
|
<button id="toggle-dark-mode" title="Toggle dark/light mode">🌙 Night Mode</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Options -->
|
||||||
|
<div class="display-options">
|
||||||
|
<h4 class="collapsible-header collapsed" id="display-options-header">
|
||||||
|
<span>Options</span>
|
||||||
|
<span class="collapse-indicator">▼</span>
|
||||||
|
</h4>
|
||||||
|
<div class="option-group collapsible-content collapsed" id="display-options-content">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="legend">
|
||||||
|
<h4>ADS-B Categories</h4>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon light"></span>
|
||||||
|
<span>Light < 7000kg</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon medium"></span>
|
||||||
|
<span>Medium 7000-34000kg</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon large"></span>
|
||||||
|
<span>Large 34000-136000kg</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon high-vortex"></span>
|
||||||
|
<span>High Vortex Large</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon heavy"></span>
|
||||||
|
<span>Heavy > 136000kg</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon helicopter"></span>
|
||||||
|
<span>Rotorcraft</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon ga"></span>
|
||||||
|
<span>Glider/Ultralight</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<span class="legend-icon ground"></span>
|
||||||
|
<span>Surface Vehicle</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Sources</h4>
|
||||||
|
<div id="sources-legend"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table View -->
|
||||||
|
<div id="table-view" class="view">
|
||||||
|
<div class="table-controls">
|
||||||
|
<input type="text" id="search-input" placeholder="Search by flight, ICAO, or squawk...">
|
||||||
|
<select id="sort-select">
|
||||||
|
<option value="distance">Distance</option>
|
||||||
|
<option value="altitude">Altitude</option>
|
||||||
|
<option value="speed">Speed</option>
|
||||||
|
<option value="flight">Flight</option>
|
||||||
|
<option value="icao">ICAO</option>
|
||||||
|
<option value="squawk">Squawk</option>
|
||||||
|
<option value="signal">Signal</option>
|
||||||
|
<option value="age">Age</option>
|
||||||
|
</select>
|
||||||
|
<select id="source-filter">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="aircraft-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ICAO</th>
|
||||||
|
<th>Flight</th>
|
||||||
|
<th>Squawk</th>
|
||||||
|
<th>Altitude</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Sources</th>
|
||||||
|
<th>Signal</th>
|
||||||
|
<th>Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="aircraft-tbody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics View -->
|
||||||
|
<div id="stats-view" class="view">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Total Aircraft</h3>
|
||||||
|
<div class="stat-value" id="total-aircraft">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Active Sources</h3>
|
||||||
|
<div class="stat-value" id="active-sources">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Messages/sec</h3>
|
||||||
|
<div class="stat-value" id="messages-sec">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Max Range</h3>
|
||||||
|
<div class="stat-value" id="max-range">0 km</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="charts-container">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Aircraft Count Timeline</h3>
|
||||||
|
<canvas id="aircraft-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Message Rate by Source <span class="under-construction">🚧 Under Construction</span></h3>
|
||||||
|
<canvas id="message-chart"></canvas>
|
||||||
|
<div class="construction-notice">This chart is planned but not yet implemented</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Signal Strength Distribution <span class="under-construction">🚧 Under Construction</span></h3>
|
||||||
|
<canvas id="signal-chart"></canvas>
|
||||||
|
<div class="construction-notice">This chart is planned but not yet implemented</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3>Altitude Distribution <span class="under-construction">🚧 Under Construction</span></h3>
|
||||||
|
<canvas id="altitude-chart"></canvas>
|
||||||
|
<div class="construction-notice">This chart is planned but not yet implemented</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coverage View -->
|
||||||
|
<div id="coverage-view" class="view">
|
||||||
|
<div class="coverage-controls">
|
||||||
|
<select id="coverage-source">
|
||||||
|
<option value="">Select Source</option>
|
||||||
|
</select>
|
||||||
|
<button id="toggle-heatmap">Toggle Heatmap</button>
|
||||||
|
</div>
|
||||||
|
<div id="coverage-map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3D Radar View -->
|
||||||
|
<div id="radar3d-view" class="view">
|
||||||
|
<div class="radar3d-controls">
|
||||||
|
<div class="construction-notice">🚧 3D Controls Under Construction</div>
|
||||||
|
<button id="radar3d-reset" disabled>Reset View</button>
|
||||||
|
<button id="radar3d-auto-rotate" disabled>Auto Rotate</button>
|
||||||
|
<label>
|
||||||
|
<input type="range" id="radar3d-range" min="10" max="500" value="100" disabled>
|
||||||
|
Range: <span id="radar3d-range-value">100</span> km
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="radar3d-container"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet JS -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom JS -->
|
||||||
|
<script type="module" src="/static/js/app.js?v=4"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
506
assets/static/js/app.js
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
// Import Three.js modules
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
|
||||||
|
// Import our modular components
|
||||||
|
import { WebSocketManager } from './modules/websocket.js?v=2';
|
||||||
|
import { AircraftManager } from './modules/aircraft-manager.js?v=2';
|
||||||
|
import { MapManager } from './modules/map-manager.js?v=2';
|
||||||
|
import { UIManager } from './modules/ui-manager.js?v=2';
|
||||||
|
|
||||||
|
class SkyView {
|
||||||
|
constructor() {
|
||||||
|
// Initialize managers
|
||||||
|
this.wsManager = null;
|
||||||
|
this.aircraftManager = null;
|
||||||
|
this.mapManager = null;
|
||||||
|
this.uiManager = null;
|
||||||
|
|
||||||
|
// 3D Radar
|
||||||
|
this.radar3d = null;
|
||||||
|
|
||||||
|
// Charts
|
||||||
|
this.charts = {};
|
||||||
|
|
||||||
|
// Selected aircraft tracking
|
||||||
|
this.selectedAircraft = null;
|
||||||
|
this.selectedTrailEnabled = false;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Initialize UI manager first
|
||||||
|
this.uiManager = new UIManager();
|
||||||
|
this.uiManager.initializeViews();
|
||||||
|
this.uiManager.initializeEventListeners();
|
||||||
|
|
||||||
|
// Initialize map manager and get the main map
|
||||||
|
this.mapManager = new MapManager();
|
||||||
|
const map = await this.mapManager.initializeMap();
|
||||||
|
|
||||||
|
// Initialize aircraft manager with the map
|
||||||
|
this.aircraftManager = new AircraftManager(map);
|
||||||
|
|
||||||
|
// Set up selected aircraft trail callback
|
||||||
|
this.aircraftManager.setSelectedAircraftCallback((icao) => {
|
||||||
|
return this.selectedTrailEnabled && this.selectedAircraft === icao;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket with callbacks
|
||||||
|
this.wsManager = new WebSocketManager(
|
||||||
|
(message) => this.handleWebSocketMessage(message),
|
||||||
|
(status) => this.uiManager.updateConnectionStatus(status)
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.wsManager.connect();
|
||||||
|
|
||||||
|
// Initialize other components
|
||||||
|
this.initializeCharts();
|
||||||
|
this.uiManager.updateClocks();
|
||||||
|
this.initialize3DRadar();
|
||||||
|
|
||||||
|
// Set up map controls
|
||||||
|
this.setupMapControls();
|
||||||
|
|
||||||
|
// Set up aircraft selection listener
|
||||||
|
this.setupAircraftSelection();
|
||||||
|
|
||||||
|
this.startPeriodicTasks();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Initialization failed:', error);
|
||||||
|
this.uiManager.showError('Failed to initialize application');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMapControls() {
|
||||||
|
const centerMapBtn = document.getElementById('center-map');
|
||||||
|
const resetMapBtn = document.getElementById('reset-map');
|
||||||
|
const toggleTrailsBtn = document.getElementById('toggle-trails');
|
||||||
|
const toggleSourcesBtn = document.getElementById('toggle-sources');
|
||||||
|
|
||||||
|
if (centerMapBtn) {
|
||||||
|
centerMapBtn.addEventListener('click', () => {
|
||||||
|
this.aircraftManager.centerMapOnAircraft(() => this.mapManager.getSourcePositions());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resetMapBtn) {
|
||||||
|
resetMapBtn.addEventListener('click', () => this.mapManager.resetMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleTrailsBtn) {
|
||||||
|
toggleTrailsBtn.addEventListener('click', () => {
|
||||||
|
const showTrails = this.aircraftManager.toggleTrails();
|
||||||
|
toggleTrailsBtn.textContent = showTrails ? 'Hide Trails' : 'Show Trails';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (toggleSourcesBtn) {
|
||||||
|
toggleSourcesBtn.addEventListener('click', () => {
|
||||||
|
const showSources = this.mapManager.toggleSources();
|
||||||
|
toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup collapsible sections
|
||||||
|
this.setupCollapsibleSections();
|
||||||
|
|
||||||
|
const toggleDarkModeBtn = document.getElementById('toggle-dark-mode');
|
||||||
|
if (toggleDarkModeBtn) {
|
||||||
|
toggleDarkModeBtn.addEventListener('click', () => {
|
||||||
|
const isDarkMode = this.mapManager.toggleDarkMode();
|
||||||
|
toggleDarkModeBtn.innerHTML = isDarkMode ? '☀️ Light Mode' : '🌙 Night Mode';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage controls
|
||||||
|
const toggleHeatmapBtn = document.getElementById('toggle-heatmap');
|
||||||
|
const coverageSourceSelect = document.getElementById('coverage-source');
|
||||||
|
|
||||||
|
if (toggleHeatmapBtn) {
|
||||||
|
toggleHeatmapBtn.addEventListener('click', async () => {
|
||||||
|
const isActive = await this.mapManager.toggleHeatmap();
|
||||||
|
toggleHeatmapBtn.textContent = isActive ? 'Hide Heatmap' : 'Show Heatmap';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverageSourceSelect) {
|
||||||
|
coverageSourceSelect.addEventListener('change', (e) => {
|
||||||
|
this.mapManager.setSelectedSource(e.target.value);
|
||||||
|
this.mapManager.updateCoverageDisplay();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display option checkboxes
|
||||||
|
const sitePositionsCheckbox = document.getElementById('show-site-positions');
|
||||||
|
const rangeRingsCheckbox = document.getElementById('show-range-rings');
|
||||||
|
const selectedTrailCheckbox = document.getElementById('show-selected-trail');
|
||||||
|
|
||||||
|
if (sitePositionsCheckbox) {
|
||||||
|
sitePositionsCheckbox.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
this.mapManager.showSources = true;
|
||||||
|
this.mapManager.updateSourceMarkers();
|
||||||
|
} else {
|
||||||
|
this.mapManager.showSources = false;
|
||||||
|
this.mapManager.sourceMarkers.forEach(marker => this.mapManager.map.removeLayer(marker));
|
||||||
|
this.mapManager.sourceMarkers.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rangeRingsCheckbox) {
|
||||||
|
rangeRingsCheckbox.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
this.mapManager.showRange = true;
|
||||||
|
this.mapManager.updateRangeCircles();
|
||||||
|
} else {
|
||||||
|
this.mapManager.showRange = false;
|
||||||
|
this.mapManager.rangeCircles.forEach(circle => this.mapManager.map.removeLayer(circle));
|
||||||
|
this.mapManager.rangeCircles.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTrailCheckbox) {
|
||||||
|
selectedTrailCheckbox.addEventListener('change', (e) => {
|
||||||
|
this.selectedTrailEnabled = e.target.checked;
|
||||||
|
if (!e.target.checked && this.selectedAircraft) {
|
||||||
|
// Hide currently selected aircraft trail
|
||||||
|
this.aircraftManager.hideAircraftTrail(this.selectedAircraft);
|
||||||
|
} else if (e.target.checked && this.selectedAircraft) {
|
||||||
|
// Show currently selected aircraft trail
|
||||||
|
this.aircraftManager.showAircraftTrail(this.selectedAircraft);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAircraftSelection() {
|
||||||
|
document.addEventListener('aircraftSelected', (e) => {
|
||||||
|
const { icao, aircraft } = e.detail;
|
||||||
|
this.uiManager.switchView('map-view');
|
||||||
|
|
||||||
|
// Hide trail for previously selected aircraft
|
||||||
|
if (this.selectedAircraft && this.selectedTrailEnabled) {
|
||||||
|
this.aircraftManager.hideAircraftTrail(this.selectedAircraft);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected aircraft
|
||||||
|
this.selectedAircraft = icao;
|
||||||
|
|
||||||
|
// Automatically enable selected aircraft trail when an aircraft is selected
|
||||||
|
if (!this.selectedTrailEnabled) {
|
||||||
|
this.selectedTrailEnabled = true;
|
||||||
|
const selectedTrailCheckbox = document.getElementById('show-selected-trail');
|
||||||
|
if (selectedTrailCheckbox) {
|
||||||
|
selectedTrailCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show trail for newly selected aircraft
|
||||||
|
this.aircraftManager.showAircraftTrail(icao);
|
||||||
|
|
||||||
|
// DON'T change map view - just open popup like Leaflet expects
|
||||||
|
if (this.mapManager.map && aircraft.Latitude && aircraft.Longitude) {
|
||||||
|
const marker = this.aircraftManager.aircraftMarkers.get(icao);
|
||||||
|
if (marker) {
|
||||||
|
marker.openPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleWebSocketMessage(message) {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'initial_data':
|
||||||
|
this.updateData(message.data);
|
||||||
|
// Setup source markers only on initial data load
|
||||||
|
this.mapManager.updateSourceMarkers();
|
||||||
|
break;
|
||||||
|
case 'aircraft_update':
|
||||||
|
this.updateData(message.data);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateData(data) {
|
||||||
|
// Update all managers with new data
|
||||||
|
this.uiManager.updateData(data);
|
||||||
|
this.aircraftManager.updateAircraftData(data);
|
||||||
|
this.mapManager.updateSourcesData(data);
|
||||||
|
|
||||||
|
// Update UI components
|
||||||
|
this.aircraftManager.updateMarkers();
|
||||||
|
this.uiManager.updateAircraftTable();
|
||||||
|
this.uiManager.updateStatistics();
|
||||||
|
this.uiManager.updateHeaderInfo();
|
||||||
|
|
||||||
|
// Clear selected aircraft if it no longer exists
|
||||||
|
if (this.selectedAircraft && !this.aircraftManager.aircraftData.has(this.selectedAircraft)) {
|
||||||
|
this.selectedAircraft = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update coverage controls
|
||||||
|
this.mapManager.updateCoverageControls();
|
||||||
|
|
||||||
|
if (this.uiManager.currentView === 'radar3d-view') {
|
||||||
|
this.update3DRadar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View switching
|
||||||
|
async switchView(viewId) {
|
||||||
|
const actualViewId = this.uiManager.switchView(viewId);
|
||||||
|
|
||||||
|
// Handle view-specific initialization
|
||||||
|
const baseName = actualViewId.replace('-view', '');
|
||||||
|
switch (baseName) {
|
||||||
|
case 'coverage':
|
||||||
|
await this.mapManager.initializeCoverageMap();
|
||||||
|
break;
|
||||||
|
case 'radar3d':
|
||||||
|
this.update3DRadar();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charts
|
||||||
|
initializeCharts() {
|
||||||
|
const aircraftChartCanvas = document.getElementById('aircraft-chart');
|
||||||
|
if (!aircraftChartCanvas) {
|
||||||
|
console.warn('Aircraft chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.charts.aircraft = new Chart(aircraftChartCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Aircraft Count',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#00d4ff',
|
||||||
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { display: false },
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { color: '#888' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Chart.js not available, skipping charts initialization');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCharts() {
|
||||||
|
if (!this.charts.aircraft) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const timeLabel = now.toLocaleTimeString();
|
||||||
|
|
||||||
|
// Update aircraft count chart
|
||||||
|
const chart = this.charts.aircraft;
|
||||||
|
chart.data.labels.push(timeLabel);
|
||||||
|
chart.data.datasets[0].data.push(this.aircraftManager.aircraftData.size);
|
||||||
|
|
||||||
|
if (chart.data.labels.length > 20) {
|
||||||
|
chart.data.labels.shift();
|
||||||
|
chart.data.datasets[0].data.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D Radar (basic implementation)
|
||||||
|
initialize3DRadar() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('radar3d-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Create scene
|
||||||
|
this.radar3d = {
|
||||||
|
scene: new THREE.Scene(),
|
||||||
|
camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000),
|
||||||
|
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
|
||||||
|
controls: null,
|
||||||
|
aircraftMeshes: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up renderer
|
||||||
|
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
|
||||||
|
this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
|
||||||
|
container.appendChild(this.radar3d.renderer.domElement);
|
||||||
|
|
||||||
|
// Add lighting
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
|
||||||
|
this.radar3d.scene.add(ambientLight);
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||||
|
directionalLight.position.set(10, 10, 5);
|
||||||
|
this.radar3d.scene.add(directionalLight);
|
||||||
|
|
||||||
|
// Set up camera
|
||||||
|
this.radar3d.camera.position.set(0, 50, 50);
|
||||||
|
this.radar3d.camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
// Add controls
|
||||||
|
this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement);
|
||||||
|
this.radar3d.controls.enableDamping = true;
|
||||||
|
this.radar3d.controls.dampingFactor = 0.05;
|
||||||
|
|
||||||
|
// Add ground plane
|
||||||
|
const groundGeometry = new THREE.PlaneGeometry(200, 200);
|
||||||
|
const groundMaterial = new THREE.MeshLambertMaterial({
|
||||||
|
color: 0x2a4d3a,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.5
|
||||||
|
});
|
||||||
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
|
||||||
|
ground.rotation.x = -Math.PI / 2;
|
||||||
|
this.radar3d.scene.add(ground);
|
||||||
|
|
||||||
|
// Add grid
|
||||||
|
const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44);
|
||||||
|
this.radar3d.scene.add(gridHelper);
|
||||||
|
|
||||||
|
// Start render loop
|
||||||
|
this.render3DRadar();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize 3D radar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update3DRadar() {
|
||||||
|
if (!this.radar3d || !this.radar3d.scene || !this.aircraftManager) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update aircraft positions in 3D space
|
||||||
|
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
|
||||||
|
if (aircraft.Latitude && aircraft.Longitude) {
|
||||||
|
const key = icao.toString();
|
||||||
|
|
||||||
|
if (!this.radar3d.aircraftMeshes.has(key)) {
|
||||||
|
// Create new aircraft mesh
|
||||||
|
const geometry = new THREE.ConeGeometry(0.5, 2, 6);
|
||||||
|
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
this.radar3d.aircraftMeshes.set(key, mesh);
|
||||||
|
this.radar3d.scene.add(mesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mesh = this.radar3d.aircraftMeshes.get(key);
|
||||||
|
|
||||||
|
// Convert lat/lon to local coordinates (simplified)
|
||||||
|
const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
|
||||||
|
const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000;
|
||||||
|
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
|
||||||
|
|
||||||
|
mesh.position.set(x, y, z);
|
||||||
|
|
||||||
|
// Orient mesh based on track
|
||||||
|
if (aircraft.Track !== undefined) {
|
||||||
|
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove old aircraft
|
||||||
|
this.radar3d.aircraftMeshes.forEach((mesh, key) => {
|
||||||
|
if (!this.aircraftManager.aircraftData.has(key)) {
|
||||||
|
this.radar3d.scene.remove(mesh);
|
||||||
|
this.radar3d.aircraftMeshes.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update 3D radar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render3DRadar() {
|
||||||
|
if (!this.radar3d) return;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => this.render3DRadar());
|
||||||
|
|
||||||
|
if (this.radar3d.controls) {
|
||||||
|
this.radar3d.controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
startPeriodicTasks() {
|
||||||
|
// Update clocks every second
|
||||||
|
setInterval(() => this.uiManager.updateClocks(), 1000);
|
||||||
|
|
||||||
|
// Update charts every 10 seconds
|
||||||
|
setInterval(() => this.updateCharts(), 10000);
|
||||||
|
|
||||||
|
// Periodic cleanup
|
||||||
|
setInterval(() => {
|
||||||
|
// Clean up old trail data, etc.
|
||||||
|
}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCollapsibleSections() {
|
||||||
|
// Setup Display Options collapsible
|
||||||
|
const displayHeader = document.getElementById('display-options-header');
|
||||||
|
const displayContent = document.getElementById('display-options-content');
|
||||||
|
|
||||||
|
if (displayHeader && displayContent) {
|
||||||
|
displayHeader.addEventListener('click', () => {
|
||||||
|
const isCollapsed = displayContent.classList.contains('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
// Expand
|
||||||
|
displayContent.classList.remove('collapsed');
|
||||||
|
displayHeader.classList.remove('collapsed');
|
||||||
|
} else {
|
||||||
|
// Collapse
|
||||||
|
displayContent.classList.add('collapsed');
|
||||||
|
displayHeader.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
localStorage.setItem('displayOptionsCollapsed', !isCollapsed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore saved state (default to collapsed)
|
||||||
|
const savedState = localStorage.getItem('displayOptionsCollapsed');
|
||||||
|
const shouldCollapse = savedState === null ? true : savedState === 'true';
|
||||||
|
|
||||||
|
if (shouldCollapse) {
|
||||||
|
displayContent.classList.add('collapsed');
|
||||||
|
displayHeader.classList.add('collapsed');
|
||||||
|
} else {
|
||||||
|
displayContent.classList.remove('collapsed');
|
||||||
|
displayHeader.classList.remove('collapsed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize application when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.skyview = new SkyView();
|
||||||
|
});
|
||||||
527
assets/static/js/modules/aircraft-manager.js
Normal file
|
|
@ -0,0 +1,527 @@
|
||||||
|
// 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, aircraft);
|
||||||
|
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, aircraft) {
|
||||||
|
// Special colors for specific types
|
||||||
|
if (type === 'military') return '#ff4444';
|
||||||
|
if (type === 'helicopter') return '#ff00ff';
|
||||||
|
if (type === 'ground') return '#888888';
|
||||||
|
if (type === 'ga') return '#ffff00';
|
||||||
|
|
||||||
|
// For commercial and cargo types, use weight-based colors
|
||||||
|
if (aircraft && aircraft.Category) {
|
||||||
|
const cat = aircraft.Category.toLowerCase();
|
||||||
|
|
||||||
|
// Check for specific weight ranges in the category string
|
||||||
|
// Light aircraft (< 7000kg) - Sky blue
|
||||||
|
if (cat.includes('light')) {
|
||||||
|
return '#00bfff';
|
||||||
|
}
|
||||||
|
// Medium aircraft (7000-34000kg) - Green
|
||||||
|
if (cat.includes('medium')) {
|
||||||
|
return '#00ff88';
|
||||||
|
}
|
||||||
|
// High Vortex Large - Red-orange (special wake turbulence category)
|
||||||
|
if (cat.includes('high vortex')) {
|
||||||
|
return '#ff4500';
|
||||||
|
}
|
||||||
|
// Large aircraft (34000-136000kg) - Orange
|
||||||
|
if (cat.includes('large')) {
|
||||||
|
return '#ff8c00';
|
||||||
|
}
|
||||||
|
// Heavy aircraft (> 136000kg) - Red
|
||||||
|
if (cat.includes('heavy') || cat.includes('super')) {
|
||||||
|
return '#ff0000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to green for unknown commercial aircraft
|
||||||
|
return '#00ff88';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
|
${aircraft.TransponderCapability ? `
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>Transponder:</strong> ${aircraft.TransponderCapability}
|
||||||
|
</div>` : ''}
|
||||||
|
${aircraft.SignalQuality ? `
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>Signal Quality:</strong> ${aircraft.SignalQuality}
|
||||||
|
</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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
378
assets/static/js/modules/map-manager.js
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
// Map and visualization management module
|
||||||
|
export class MapManager {
|
||||||
|
constructor() {
|
||||||
|
this.map = null;
|
||||||
|
this.coverageMap = null;
|
||||||
|
this.mapOrigin = null;
|
||||||
|
|
||||||
|
// Source markers and overlays
|
||||||
|
this.sourceMarkers = new Map();
|
||||||
|
this.rangeCircles = new Map();
|
||||||
|
this.showSources = true;
|
||||||
|
this.showRange = false;
|
||||||
|
this.selectedSource = null;
|
||||||
|
this.heatmapLayer = null;
|
||||||
|
|
||||||
|
// Data references
|
||||||
|
this.sourcesData = new Map();
|
||||||
|
|
||||||
|
// Map theme
|
||||||
|
this.isDarkMode = false;
|
||||||
|
this.currentTileLayer = null;
|
||||||
|
this.coverageTileLayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeMap() {
|
||||||
|
// Get origin from server
|
||||||
|
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/origin');
|
||||||
|
if (response.ok) {
|
||||||
|
origin = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch origin, using default:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store origin for reset functionality
|
||||||
|
this.mapOrigin = origin;
|
||||||
|
|
||||||
|
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
|
||||||
|
|
||||||
|
// Light tile layer by default
|
||||||
|
this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
// Add scale control for distance estimation
|
||||||
|
L.control.scale({
|
||||||
|
metric: true,
|
||||||
|
imperial: true,
|
||||||
|
position: 'bottomright'
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
return this.map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initializeCoverageMap() {
|
||||||
|
if (!this.coverageMap) {
|
||||||
|
// Get origin from server
|
||||||
|
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/origin');
|
||||||
|
if (response.ok) {
|
||||||
|
origin = await response.json();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch origin for coverage map, using default:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
|
||||||
|
|
||||||
|
this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}).addTo(this.coverageMap);
|
||||||
|
|
||||||
|
// Add scale control for distance estimation
|
||||||
|
L.control.scale({
|
||||||
|
metric: true,
|
||||||
|
imperial: true,
|
||||||
|
position: 'bottomright'
|
||||||
|
}).addTo(this.coverageMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.coverageMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSourcesData(data) {
|
||||||
|
if (data.sources) {
|
||||||
|
this.sourcesData.clear();
|
||||||
|
data.sources.forEach(source => {
|
||||||
|
this.sourcesData.set(source.id, source);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSourceMarkers() {
|
||||||
|
if (!this.map || !this.showSources) return;
|
||||||
|
|
||||||
|
// Remove markers for sources that no longer exist
|
||||||
|
const currentSourceIds = new Set(this.sourcesData.keys());
|
||||||
|
for (const [id, marker] of this.sourceMarkers) {
|
||||||
|
if (!currentSourceIds.has(id)) {
|
||||||
|
this.map.removeLayer(marker);
|
||||||
|
this.sourceMarkers.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or create markers for current sources
|
||||||
|
for (const [id, source] of this.sourcesData) {
|
||||||
|
if (source.latitude && source.longitude) {
|
||||||
|
if (this.sourceMarkers.has(id)) {
|
||||||
|
// Update existing marker
|
||||||
|
const marker = this.sourceMarkers.get(id);
|
||||||
|
|
||||||
|
// Update marker style if status changed
|
||||||
|
marker.setStyle({
|
||||||
|
radius: source.active ? 10 : 6,
|
||||||
|
fillColor: source.active ? '#00d4ff' : '#666666',
|
||||||
|
fillOpacity: 0.8
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update popup content if it's open
|
||||||
|
if (marker.isPopupOpen()) {
|
||||||
|
marker.setPopupContent(this.createSourcePopupContent(source));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new marker
|
||||||
|
const marker = L.circleMarker([source.latitude, source.longitude], {
|
||||||
|
radius: source.active ? 10 : 6,
|
||||||
|
fillColor: source.active ? '#00d4ff' : '#666666',
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
className: 'source-marker'
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
marker.bindPopup(this.createSourcePopupContent(source), {
|
||||||
|
maxWidth: 300
|
||||||
|
});
|
||||||
|
|
||||||
|
this.sourceMarkers.set(id, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSourcesLegend();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRangeCircles() {
|
||||||
|
if (!this.map || !this.showRange) return;
|
||||||
|
|
||||||
|
// Clear existing circles
|
||||||
|
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
||||||
|
this.rangeCircles.clear();
|
||||||
|
|
||||||
|
// Add range circles for active sources
|
||||||
|
for (const [id, source] of this.sourcesData) {
|
||||||
|
if (source.active && source.latitude && source.longitude) {
|
||||||
|
// Add multiple range circles (50km, 100km, 200km)
|
||||||
|
const ranges = [50000, 100000, 200000];
|
||||||
|
ranges.forEach((range, index) => {
|
||||||
|
const circle = L.circle([source.latitude, source.longitude], {
|
||||||
|
radius: range,
|
||||||
|
fillColor: 'transparent',
|
||||||
|
color: '#00d4ff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.7 - (index * 0.15),
|
||||||
|
dashArray: '8,4'
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
this.rangeCircles.set(`${id}_${range}`, circle);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createSourcePopupContent(source, aircraftData) {
|
||||||
|
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
|
||||||
|
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="source-popup">
|
||||||
|
<h3>${source.name}</h3>
|
||||||
|
<p><strong>ID:</strong> ${source.id}</p>
|
||||||
|
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
|
||||||
|
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
||||||
|
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
|
||||||
|
<p><strong>Messages:</strong> ${source.messages || 0}</p>
|
||||||
|
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSourcesLegend() {
|
||||||
|
const legend = document.getElementById('sources-legend');
|
||||||
|
if (!legend) return;
|
||||||
|
|
||||||
|
legend.innerHTML = '';
|
||||||
|
|
||||||
|
for (const [id, source] of this.sourcesData) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'legend-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
||||||
|
<span title="${source.host}:${source.port}">${source.name}</span>
|
||||||
|
`;
|
||||||
|
legend.appendChild(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMap() {
|
||||||
|
if (this.mapOrigin && this.map) {
|
||||||
|
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleRangeCircles() {
|
||||||
|
this.showRange = !this.showRange;
|
||||||
|
|
||||||
|
if (this.showRange) {
|
||||||
|
this.updateRangeCircles();
|
||||||
|
} else {
|
||||||
|
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
||||||
|
this.rangeCircles.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.showRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSources() {
|
||||||
|
this.showSources = !this.showSources;
|
||||||
|
|
||||||
|
if (this.showSources) {
|
||||||
|
this.updateSourceMarkers();
|
||||||
|
} else {
|
||||||
|
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
|
||||||
|
this.sourceMarkers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.showSources;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleDarkMode() {
|
||||||
|
this.isDarkMode = !this.isDarkMode;
|
||||||
|
|
||||||
|
const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||||
|
const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||||
|
const tileUrl = this.isDarkMode ? darkUrl : lightUrl;
|
||||||
|
|
||||||
|
const tileOptions = {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 19
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update main map
|
||||||
|
if (this.map && this.currentTileLayer) {
|
||||||
|
this.map.removeLayer(this.currentTileLayer);
|
||||||
|
this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update coverage map
|
||||||
|
if (this.coverageMap && this.coverageTileLayer) {
|
||||||
|
this.coverageMap.removeLayer(this.coverageTileLayer);
|
||||||
|
this.coverageTileLayer = L.tileLayer(tileUrl, {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}).addTo(this.coverageMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.isDarkMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage map methods
|
||||||
|
updateCoverageControls() {
|
||||||
|
const select = document.getElementById('coverage-source');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.innerHTML = '<option value="">Select Source</option>';
|
||||||
|
|
||||||
|
for (const [id, source] of this.sourcesData) {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = id;
|
||||||
|
option.textContent = source.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCoverageDisplay() {
|
||||||
|
if (!this.selectedSource || !this.coverageMap) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/coverage/${this.selectedSource}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Clear existing coverage markers
|
||||||
|
this.coverageMap.eachLayer(layer => {
|
||||||
|
if (layer instanceof L.CircleMarker) {
|
||||||
|
this.coverageMap.removeLayer(layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add coverage points
|
||||||
|
data.points.forEach(point => {
|
||||||
|
const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
|
||||||
|
L.circleMarker([point.lat, point.lon], {
|
||||||
|
radius: 3,
|
||||||
|
fillColor: this.getSignalColor(point.signal),
|
||||||
|
color: 'white',
|
||||||
|
weight: 1,
|
||||||
|
fillOpacity: intensity
|
||||||
|
}).addTo(this.coverageMap);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load coverage data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleHeatmap() {
|
||||||
|
if (!this.selectedSource) {
|
||||||
|
alert('Please select a source first');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.heatmapLayer) {
|
||||||
|
this.coverageMap.removeLayer(this.heatmapLayer);
|
||||||
|
this.heatmapLayer = null;
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Create heatmap layer (simplified)
|
||||||
|
this.createHeatmapOverlay(data);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load heatmap data:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignalColor(signal) {
|
||||||
|
if (signal > -10) return '#00ff88';
|
||||||
|
if (signal > -20) return '#ffff00';
|
||||||
|
if (signal > -30) return '#ff8c00';
|
||||||
|
return '#ff4444';
|
||||||
|
}
|
||||||
|
|
||||||
|
createHeatmapOverlay(data) {
|
||||||
|
// 🚧 Under Construction: Heatmap visualization not yet implemented
|
||||||
|
// Planned: Use Leaflet.heat library for proper heatmap rendering
|
||||||
|
console.log('Heatmap overlay requested but not yet implemented');
|
||||||
|
|
||||||
|
// Show user-visible notice
|
||||||
|
if (window.uiManager) {
|
||||||
|
window.uiManager.showError('Heatmap visualization is under construction 🚧');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
337
assets/static/js/modules/ui-manager.js
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Simple toast notification implementation
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'toast-notification error';
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
// Add to page
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
// Show toast with animation
|
||||||
|
setTimeout(() => toast.classList.add('show'), 100);
|
||||||
|
|
||||||
|
// Auto-remove after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(() => document.body.removeChild(toast), 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
assets/static/js/modules/websocket.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
442
cmd/beast-dump/main.go
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
config.example.json
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"host": "",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "primary",
|
||||||
|
"name": "Primary Site",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 30005,
|
||||||
|
"latitude": 51.4700,
|
||||||
|
"longitude": -0.4600,
|
||||||
|
"altitude": 50.0,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "secondary",
|
||||||
|
"name": "Secondary Site",
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"port": 30005,
|
||||||
|
"latitude": 51.4800,
|
||||||
|
"longitude": -0.4500,
|
||||||
|
"altitude": 45.0,
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "remote",
|
||||||
|
"name": "Remote Site",
|
||||||
|
"host": "remote.example.com",
|
||||||
|
"port": 30005,
|
||||||
|
"latitude": 51.4900,
|
||||||
|
"longitude": -0.4400,
|
||||||
|
"altitude": 60.0,
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origin": {
|
||||||
|
"latitude": 51.4700,
|
||||||
|
"longitude": -0.4600,
|
||||||
|
"name": "Control Tower"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"history_limit": 1000,
|
||||||
|
"stale_timeout": 60,
|
||||||
|
"update_rate": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"server": {
|
|
||||||
"address": ":8080",
|
|
||||||
"port": 8080
|
|
||||||
},
|
|
||||||
"dump1090": {
|
|
||||||
"host": "192.168.1.100",
|
|
||||||
"data_port": 30003
|
|
||||||
},
|
|
||||||
"origin": {
|
|
||||||
"latitude": 37.7749,
|
|
||||||
"longitude": -122.4194,
|
|
||||||
"name": "San Francisco"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
debian/DEBIAN/control
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
Package: skyview
|
||||||
|
Version: 0.0.2
|
||||||
|
Section: net
|
||||||
|
Priority: optional
|
||||||
|
Architecture: amd64
|
||||||
|
Depends: systemd
|
||||||
|
Maintainer: Ole-Morten Duesund <glemt.net>
|
||||||
|
Description: Multi-source ADS-B aircraft tracker with Beast format support
|
||||||
|
SkyView is a standalone application that connects to multiple dump1090 Beast
|
||||||
|
format TCP streams and provides a modern web frontend for aircraft tracking.
|
||||||
|
Features include real-time aircraft tracking, signal strength analysis,
|
||||||
|
coverage mapping, and 3D radar visualization.
|
||||||
|
.
|
||||||
|
Key features:
|
||||||
|
- Multi-source Beast binary format parsing
|
||||||
|
- Real-time WebSocket updates
|
||||||
|
- Interactive maps with Leaflet.js
|
||||||
|
- Signal strength heatmaps and range circles
|
||||||
|
- Historical flight tracking
|
||||||
|
- Mobile-responsive design
|
||||||
|
- Systemd integration for service management
|
||||||
|
- Beast-dump utility for raw ADS-B data analysis
|
||||||
|
Homepage: https://kode.naiv.no/olemd/skyview
|
||||||
39
debian/DEBIAN/postinst
vendored
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
configure)
|
||||||
|
# Create skyview user and group if they don't exist
|
||||||
|
if ! getent group skyview >/dev/null 2>&1; then
|
||||||
|
addgroup --system --quiet skyview
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent passwd skyview >/dev/null 2>&1; then
|
||||||
|
adduser --system --ingroup skyview --home /var/lib/skyview \
|
||||||
|
--no-create-home --disabled-password --quiet skyview
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create directories with proper permissions
|
||||||
|
mkdir -p /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true
|
||||||
|
chown skyview:skyview /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true
|
||||||
|
chmod 755 /var/lib/skyview /var/log/skyview >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Set permissions on config files
|
||||||
|
if [ -f /etc/skyview/config.json ]; then
|
||||||
|
chown root:skyview /etc/skyview/config.json >/dev/null 2>&1 || true
|
||||||
|
chmod 640 /etc/skyview/config.json >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Handle systemd service
|
||||||
|
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Check if service was previously enabled
|
||||||
|
if systemctl is-enabled skyview >/dev/null 2>&1; then
|
||||||
|
# Service was enabled, restart it
|
||||||
|
systemctl restart skyview >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
31
debian/DEBIAN/postrm
vendored
Executable file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
purge)
|
||||||
|
# Remove user and group
|
||||||
|
if getent passwd skyview >/dev/null 2>&1; then
|
||||||
|
deluser --system skyview >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if getent group skyview >/dev/null 2>&1; then
|
||||||
|
delgroup --system skyview >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove data directories
|
||||||
|
rm -rf /var/lib/skyview
|
||||||
|
rm -rf /var/log/skyview
|
||||||
|
|
||||||
|
# Remove config directory if empty
|
||||||
|
rmdir /etc/skyview 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "SkyView has been completely removed."
|
||||||
|
;;
|
||||||
|
|
||||||
|
remove)
|
||||||
|
# Reload systemd after service file removal
|
||||||
|
systemctl daemon-reload
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
17
debian/DEBIAN/prerm
vendored
Executable file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
remove|upgrade|deconfigure)
|
||||||
|
# Stop and disable the service
|
||||||
|
if systemctl is-active --quiet skyview.service; then
|
||||||
|
systemctl stop skyview.service
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl is-enabled --quiet skyview.service; then
|
||||||
|
systemctl disable skyview.service
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
47
debian/lib/systemd/system/skyview.service
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
[Unit]
|
||||||
|
Description=SkyView Multi-Source ADS-B Aircraft Tracker
|
||||||
|
Documentation=https://github.com/skyview/skyview
|
||||||
|
After=network.target
|
||||||
|
Wants=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=skyview
|
||||||
|
Group=skyview
|
||||||
|
ExecStart=/usr/bin/skyview -config /etc/skyview/config.json
|
||||||
|
WorkingDirectory=/var/lib/skyview
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=skyview
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Security settings
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectHostname=true
|
||||||
|
ProtectClock=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectKernelLogs=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RemoveIPC=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
|
||||||
|
# Allow network access
|
||||||
|
PrivateNetwork=false
|
||||||
|
|
||||||
|
# Allow writing to log directory
|
||||||
|
ReadWritePaths=/var/log/skyview
|
||||||
|
|
||||||
|
# Capabilities
|
||||||
|
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||||
|
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
95
debian/usr/share/man/man1/beast-dump.1
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
.TH BEAST-DUMP 1 "2025-08-24" "SkyView 0.0.2" "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://kode.naiv.no/olemd/skyview/issues
|
||||||
|
.SH AUTHOR
|
||||||
|
Ole-Morten Duesund <glemt.net>
|
||||||
88
debian/usr/share/man/man1/skyview.1
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
.TH SKYVIEW 1 "2025-08-24" "SkyView 0.0.2" "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://kode.naiv.no/olemd/skyview/issues
|
||||||
|
.SH AUTHOR
|
||||||
|
Ole-Morten Duesund <glemt.net>
|
||||||
BIN
docs/ADS-B Decoding Guide.pdf
Normal file
290
docs/ARCHITECTURE.md
Normal 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.
|
||||||
322
internal/beast/parser.go
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
// Package beast provides Beast binary format parsing for ADS-B message streams.
|
||||||
|
//
|
||||||
|
// The Beast format is a binary protocol developed by FlightAware and used by
|
||||||
|
// dump1090, readsb, and other ADS-B software to stream real-time aircraft data
|
||||||
|
// over TCP connections (typically port 30005).
|
||||||
|
//
|
||||||
|
// Beast Format Structure:
|
||||||
|
// - Each message starts with escape byte 0x1A
|
||||||
|
// - Message type byte (0x31=Mode A/C, 0x32=Mode S Short, 0x33=Mode S Long)
|
||||||
|
// - 48-bit timestamp (12MHz clock ticks)
|
||||||
|
// - Signal level byte (RSSI)
|
||||||
|
// - Message payload (2, 7, or 14 bytes depending on type)
|
||||||
|
// - Escape sequences: 0x1A 0x1A represents literal 0x1A in data
|
||||||
|
//
|
||||||
|
// This package handles:
|
||||||
|
// - Binary message parsing and validation
|
||||||
|
// - Timestamp and signal strength extraction
|
||||||
|
// - Escape sequence processing
|
||||||
|
// - ICAO address and message type extraction
|
||||||
|
// - Continuous stream processing with error recovery
|
||||||
|
//
|
||||||
|
// The parser is designed to handle connection interruptions gracefully and
|
||||||
|
// can recover from malformed messages in the stream.
|
||||||
|
package beast
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Beast format message type constants.
|
||||||
|
// These define the different types of messages in the Beast binary protocol.
|
||||||
|
const (
|
||||||
|
BeastModeAC = 0x31 // '1' - Mode A/C squitter (2 bytes payload)
|
||||||
|
BeastModeS = 0x32 // '2' - Mode S Short squitter (7 bytes payload)
|
||||||
|
BeastModeSLong = 0x33 // '3' - Mode S Extended squitter (14 bytes payload)
|
||||||
|
BeastStatusMsg = 0x34 // '4' - Status message (variable length)
|
||||||
|
BeastEscape = 0x1A // Escape character (0x1A 0x1A = literal 0x1A)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message represents a parsed Beast format message with metadata.
|
||||||
|
//
|
||||||
|
// Contains both the raw Beast protocol fields and additional processing metadata:
|
||||||
|
// - Original Beast format fields (type, timestamp, signal, data)
|
||||||
|
// - Processing timestamp for age calculations
|
||||||
|
// - Source identification for multi-receiver setups
|
||||||
|
type Message struct {
|
||||||
|
Type byte // Beast message type (0x31, 0x32, 0x33, 0x34)
|
||||||
|
Timestamp uint64 // 48-bit timestamp in 12MHz ticks from receiver
|
||||||
|
Signal uint8 // Signal level (RSSI) - 255 = 0 dBFS, 0 = minimum
|
||||||
|
Data []byte // Mode S message payload (2, 7, or 14 bytes)
|
||||||
|
ReceivedAt time.Time // Local processing timestamp
|
||||||
|
SourceID string // Identifier for the source receiver
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser handles Beast binary format parsing from a stream.
|
||||||
|
//
|
||||||
|
// The parser maintains stream state and can recover from protocol errors
|
||||||
|
// by searching for the next valid message boundary. It uses buffered I/O
|
||||||
|
// for efficient byte-level parsing of the binary protocol.
|
||||||
|
type Parser struct {
|
||||||
|
reader *bufio.Reader // Buffered reader for efficient byte parsing
|
||||||
|
sourceID string // Source identifier for message tagging
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewParser creates a new Beast format parser for a data stream.
|
||||||
|
//
|
||||||
|
// The parser wraps the provided reader with a buffered reader for efficient
|
||||||
|
// parsing of the binary protocol. Each parsed message will be tagged with
|
||||||
|
// the provided sourceID for multi-source identification.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - r: Input stream containing Beast format data
|
||||||
|
// - sourceID: Identifier for this data source
|
||||||
|
//
|
||||||
|
// Returns a configured parser ready for message parsing.
|
||||||
|
func NewParser(r io.Reader, sourceID string) *Parser {
|
||||||
|
return &Parser{
|
||||||
|
reader: bufio.NewReader(r),
|
||||||
|
sourceID: sourceID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadMessage reads and parses a single Beast message from the stream.
|
||||||
|
//
|
||||||
|
// The parsing process:
|
||||||
|
// 1. Search for the escape character (0x1A) that marks message start
|
||||||
|
// 2. Read and validate the message type byte
|
||||||
|
// 3. Read the 48-bit timestamp (big-endian, padded to 64-bit)
|
||||||
|
// 4. Read the signal level byte
|
||||||
|
// 5. Read the message payload (length depends on message type)
|
||||||
|
// 6. Process escape sequences in the payload data
|
||||||
|
//
|
||||||
|
// The parser can recover from protocol errors by continuing to search for
|
||||||
|
// the next valid message boundary. Status messages are currently skipped
|
||||||
|
// as they contain variable-length data not needed for aircraft tracking.
|
||||||
|
//
|
||||||
|
// Returns the parsed message or an error if the stream is closed or corrupted.
|
||||||
|
func (p *Parser) ReadMessage() (*Message, error) {
|
||||||
|
// Look for escape character
|
||||||
|
for {
|
||||||
|
b, err := p.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b == BeastEscape {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read message type
|
||||||
|
msgType, err := p.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message type
|
||||||
|
var dataLen int
|
||||||
|
switch msgType {
|
||||||
|
case BeastModeAC:
|
||||||
|
dataLen = 2
|
||||||
|
case BeastModeS:
|
||||||
|
dataLen = 7
|
||||||
|
case BeastModeSLong:
|
||||||
|
dataLen = 14
|
||||||
|
case BeastStatusMsg:
|
||||||
|
// Status messages have variable length, skip for now
|
||||||
|
return p.ReadMessage()
|
||||||
|
case BeastEscape:
|
||||||
|
// Handle double escape sequence (0x1A 0x1A) - skip and continue
|
||||||
|
return p.ReadMessage()
|
||||||
|
default:
|
||||||
|
// 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)
|
||||||
|
timestampBytes := make([]byte, 8)
|
||||||
|
if _, err := io.ReadFull(p.reader, timestampBytes[2:]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timestamp := binary.BigEndian.Uint64(timestampBytes)
|
||||||
|
|
||||||
|
// Read signal level (1 byte)
|
||||||
|
signal, err := p.reader.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Mode S data
|
||||||
|
data := make([]byte, dataLen)
|
||||||
|
if _, err := io.ReadFull(p.reader, data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unescape data if needed
|
||||||
|
data = p.unescapeData(data)
|
||||||
|
|
||||||
|
return &Message{
|
||||||
|
Type: msgType,
|
||||||
|
Timestamp: timestamp,
|
||||||
|
Signal: signal,
|
||||||
|
Data: data,
|
||||||
|
ReceivedAt: time.Now(),
|
||||||
|
SourceID: p.sourceID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unescapeData removes escape sequences from Beast format payload data.
|
||||||
|
//
|
||||||
|
// Beast format uses escape sequences to embed the escape character (0x1A)
|
||||||
|
// in message payloads:
|
||||||
|
// - 0x1A 0x1A in the stream represents a literal 0x1A byte in the data
|
||||||
|
// - Single 0x1A bytes are message boundaries, not data
|
||||||
|
//
|
||||||
|
// This method processes the payload after parsing to restore the original
|
||||||
|
// Mode S message bytes with any embedded escape characters.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - data: Raw payload bytes that may contain escape sequences
|
||||||
|
//
|
||||||
|
// Returns the unescaped data with literal 0x1A bytes restored.
|
||||||
|
func (p *Parser) unescapeData(data []byte) []byte {
|
||||||
|
result := make([]byte, 0, len(data))
|
||||||
|
i := 0
|
||||||
|
for i < len(data) {
|
||||||
|
if i < len(data)-1 && data[i] == BeastEscape && data[i+1] == BeastEscape {
|
||||||
|
result = append(result, BeastEscape)
|
||||||
|
i += 2
|
||||||
|
} else {
|
||||||
|
result = append(result, data[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseStream continuously reads messages from the stream until an error occurs.
|
||||||
|
//
|
||||||
|
// This method runs in a loop, parsing messages and sending them to the provided
|
||||||
|
// channel. It handles various error conditions gracefully:
|
||||||
|
// - EOF and closed pipe errors terminate normally (expected on disconnect)
|
||||||
|
// - Other errors are reported via the error channel with source identification
|
||||||
|
// - Protocol errors within individual messages are recovered from automatically
|
||||||
|
//
|
||||||
|
// The method blocks until the stream closes or an unrecoverable error occurs.
|
||||||
|
// It's designed to run in a dedicated goroutine for continuous processing.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - msgChan: Channel for sending successfully parsed messages
|
||||||
|
// - errChan: Channel for reporting parsing errors
|
||||||
|
func (p *Parser) ParseStream(msgChan chan<- *Message, errChan chan<- error) {
|
||||||
|
for {
|
||||||
|
msg, err := p.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF && !errors.Is(err, io.ErrClosedPipe) {
|
||||||
|
errChan <- fmt.Errorf("parser error from %s: %w", p.sourceID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgChan <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSignalStrength converts the Beast signal level byte to dBFS (decibels full scale).
|
||||||
|
//
|
||||||
|
// The Beast format encodes signal strength as:
|
||||||
|
// - 255 = 0 dBFS (maximum signal, clipping)
|
||||||
|
// - Lower values = weaker signals
|
||||||
|
// - 0 = minimum detectable signal (~-50 dBFS)
|
||||||
|
//
|
||||||
|
// The conversion provides a logarithmic scale suitable for signal quality
|
||||||
|
// comparison and coverage analysis. Values typically range from -50 to 0 dBFS
|
||||||
|
// in normal operation.
|
||||||
|
//
|
||||||
|
// Returns signal strength in dBFS (negative values, closer to 0 = stronger).
|
||||||
|
func (msg *Message) GetSignalStrength() float64 {
|
||||||
|
// Beast format: signal level is in units where 255 = 0 dBFS
|
||||||
|
// Typical range is -50 to 0 dBFS
|
||||||
|
if msg.Signal == 0 {
|
||||||
|
return -50.0 // Minimum detectable signal
|
||||||
|
}
|
||||||
|
return float64(msg.Signal) * (-50.0 / 255.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetICAO24 extracts the ICAO 24-bit aircraft address from Mode S messages.
|
||||||
|
//
|
||||||
|
// The ICAO address is a unique 24-bit identifier assigned to each aircraft.
|
||||||
|
// In Mode S messages, it's located in bytes 1-3 of the message payload:
|
||||||
|
// - Byte 1: Most significant 8 bits
|
||||||
|
// - Byte 2: Middle 8 bits
|
||||||
|
// - Byte 3: Least significant 8 bits
|
||||||
|
//
|
||||||
|
// Mode A/C messages don't contain ICAO addresses and will return an error.
|
||||||
|
// The ICAO address is used as the primary key for aircraft tracking.
|
||||||
|
//
|
||||||
|
// Returns the 24-bit ICAO address as a uint32, or an error for invalid messages.
|
||||||
|
func (msg *Message) GetICAO24() (uint32, error) {
|
||||||
|
if msg.Type == BeastModeAC {
|
||||||
|
return 0, errors.New("mode A/C messages don't contain ICAO address")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Data) < 4 {
|
||||||
|
return 0, errors.New("insufficient data for ICAO address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ICAO address is in bytes 1-3 of Mode S messages
|
||||||
|
icao := uint32(msg.Data[1])<<16 | uint32(msg.Data[2])<<8 | uint32(msg.Data[3])
|
||||||
|
return icao, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownlinkFormat extracts the Downlink Format (DF) from Mode S messages.
|
||||||
|
//
|
||||||
|
// The DF field occupies the first 5 bits of every Mode S message and indicates
|
||||||
|
// the message type and structure:
|
||||||
|
// - DF 0: Short air-air surveillance
|
||||||
|
// - DF 4/5: Surveillance altitude/identity reply
|
||||||
|
// - DF 11: All-call reply
|
||||||
|
// - DF 17: Extended squitter (ADS-B)
|
||||||
|
// - DF 18: Extended squitter/non-transponder
|
||||||
|
// - DF 19: Military extended squitter
|
||||||
|
// - Others: Various surveillance and communication types
|
||||||
|
//
|
||||||
|
// Returns the 5-bit DF field value, or 0 if no data is available.
|
||||||
|
func (msg *Message) GetDownlinkFormat() uint8 {
|
||||||
|
if len(msg.Data) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (msg.Data[0] >> 3) & 0x1F
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTypeCode extracts the Type Code (TC) from ADS-B extended squitter messages.
|
||||||
|
//
|
||||||
|
// The Type Code is a 5-bit field that indicates the specific type of ADS-B message:
|
||||||
|
// - TC 1-4: Aircraft identification and category
|
||||||
|
// - TC 5-8: Surface position messages
|
||||||
|
// - TC 9-18: Airborne position messages (different altitude sources)
|
||||||
|
// - TC 19: Airborne velocity messages
|
||||||
|
// - TC 20-22: Reserved for future use
|
||||||
|
// - Others: Various operational and status messages
|
||||||
|
//
|
||||||
|
// Only extended squitter messages (DF 17/18) contain type codes. Other
|
||||||
|
// message types will return an error.
|
||||||
|
//
|
||||||
|
// Returns the 5-bit type code, or an error for non-extended squitter messages.
|
||||||
|
func (msg *Message) GetTypeCode() (uint8, error) {
|
||||||
|
df := msg.GetDownlinkFormat()
|
||||||
|
if df != 17 && df != 18 { // Extended squitter
|
||||||
|
return 0, errors.New("not an extended squitter message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Data) < 5 {
|
||||||
|
return 0, errors.New("insufficient data for type code")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (msg.Data[4] >> 3) & 0x1F, nil
|
||||||
|
}
|
||||||
411
internal/client/beast.go
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
// Package client provides Beast format TCP client implementations for connecting to ADS-B receivers.
|
||||||
|
//
|
||||||
|
// This package handles the network connectivity and data streaming from dump1090 or similar
|
||||||
|
// Beast format sources. It provides:
|
||||||
|
// - Single-source Beast TCP client with automatic reconnection
|
||||||
|
// - Multi-source client manager for handling multiple receivers
|
||||||
|
// - Exponential backoff for connection failures
|
||||||
|
// - Message parsing and Mode S decoding integration
|
||||||
|
// - Automatic stale aircraft cleanup
|
||||||
|
//
|
||||||
|
// The Beast format is a binary protocol commonly used by dump1090 and other ADS-B
|
||||||
|
// software to stream real-time aircraft data over TCP port 30005. This package
|
||||||
|
// abstracts the connection management and integrates with the merger for
|
||||||
|
// multi-source data fusion.
|
||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"skyview/internal/beast"
|
||||||
|
"skyview/internal/merger"
|
||||||
|
"skyview/internal/modes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BeastClient handles connection to a single dump1090 Beast format TCP stream.
|
||||||
|
//
|
||||||
|
// The client provides robust connectivity with:
|
||||||
|
// - Automatic reconnection with exponential backoff
|
||||||
|
// - Concurrent message reading and processing
|
||||||
|
// - Integration with Mode S decoder and data merger
|
||||||
|
// - Source status tracking and statistics
|
||||||
|
// - Graceful shutdown handling
|
||||||
|
//
|
||||||
|
// Each client maintains a persistent connection to one Beast source and
|
||||||
|
// continuously processes incoming messages until stopped or the source
|
||||||
|
// becomes unavailable.
|
||||||
|
type BeastClient struct {
|
||||||
|
source *merger.Source // Source configuration and status
|
||||||
|
merger *merger.Merger // Data merger for multi-source fusion
|
||||||
|
decoder *modes.Decoder // Mode S/ADS-B message decoder
|
||||||
|
conn net.Conn // TCP connection to Beast source
|
||||||
|
parser *beast.Parser // Beast format message parser
|
||||||
|
msgChan chan *beast.Message // Buffered channel for parsed messages
|
||||||
|
errChan chan error // Error reporting channel
|
||||||
|
stopChan chan struct{} // Shutdown signal channel
|
||||||
|
wg sync.WaitGroup // Wait group for goroutine coordination
|
||||||
|
|
||||||
|
// Reconnection parameters
|
||||||
|
reconnectDelay time.Duration // Initial reconnect delay
|
||||||
|
maxReconnect time.Duration // Maximum reconnect delay (for backoff cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBeastClient creates a new Beast format TCP client for a specific data source.
|
||||||
|
//
|
||||||
|
// The client is configured with:
|
||||||
|
// - Buffered message channel (1000 messages) to handle burst traffic
|
||||||
|
// - Error channel for connection and parsing issues
|
||||||
|
// - Initial reconnect delay of 5 seconds
|
||||||
|
// - Maximum reconnect delay of 60 seconds (exponential backoff cap)
|
||||||
|
// - Fresh Mode S decoder instance
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - source: Source configuration including host, port, and metadata
|
||||||
|
// - merger: Data merger instance for aircraft state management
|
||||||
|
//
|
||||||
|
// Returns a configured but not yet started BeastClient.
|
||||||
|
func NewBeastClient(source *merger.Source, merger *merger.Merger) *BeastClient {
|
||||||
|
return &BeastClient{
|
||||||
|
source: source,
|
||||||
|
merger: merger,
|
||||||
|
decoder: modes.NewDecoder(source.Latitude, source.Longitude),
|
||||||
|
msgChan: make(chan *beast.Message, 5000),
|
||||||
|
errChan: make(chan error, 10),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
reconnectDelay: 5 * time.Second,
|
||||||
|
maxReconnect: 60 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the client connection and message processing in the background.
|
||||||
|
//
|
||||||
|
// The client will:
|
||||||
|
// - Attempt to connect to the configured Beast source
|
||||||
|
// - Handle connection failures with exponential backoff
|
||||||
|
// - Start message reading and processing goroutines
|
||||||
|
// - Continuously reconnect if the connection is lost
|
||||||
|
//
|
||||||
|
// The method returns immediately; the client runs in background goroutines
|
||||||
|
// until Stop() is called or the context is cancelled.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: Context for cancellation and timeout control
|
||||||
|
func (c *BeastClient) Start(ctx context.Context) {
|
||||||
|
c.wg.Add(1)
|
||||||
|
go c.run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down the client and all associated goroutines.
|
||||||
|
//
|
||||||
|
// The shutdown process:
|
||||||
|
// 1. Signals all goroutines to stop via stopChan
|
||||||
|
// 2. Closes the TCP connection if active
|
||||||
|
// 3. Waits for all goroutines to complete
|
||||||
|
//
|
||||||
|
// This method blocks until the shutdown is complete.
|
||||||
|
func (c *BeastClient) Stop() {
|
||||||
|
close(c.stopChan)
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
c.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// run implements the main client connection and reconnection loop.
|
||||||
|
//
|
||||||
|
// This method handles the complete client lifecycle:
|
||||||
|
// 1. Connection establishment with timeout
|
||||||
|
// 2. Exponential backoff on connection failures
|
||||||
|
// 3. Message parsing and processing goroutine management
|
||||||
|
// 4. Connection monitoring and failure detection
|
||||||
|
// 5. Automatic reconnection on disconnection
|
||||||
|
//
|
||||||
|
// The exponential backoff starts at reconnectDelay (5s) and doubles on each
|
||||||
|
// failure up to maxReconnect (60s), then resets on successful connection.
|
||||||
|
//
|
||||||
|
// Source status is updated to reflect connection state for monitoring.
|
||||||
|
func (c *BeastClient) run(ctx context.Context) {
|
||||||
|
defer c.wg.Done()
|
||||||
|
|
||||||
|
reconnectDelay := c.reconnectDelay
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to Beast TCP stream
|
||||||
|
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)
|
||||||
|
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 30*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed to connect to %s: %v\n", c.source.Name, err)
|
||||||
|
c.source.Active = false
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
time.Sleep(reconnectDelay)
|
||||||
|
if reconnectDelay < c.maxReconnect {
|
||||||
|
reconnectDelay *= 2
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
c.conn = conn
|
||||||
|
c.source.Active = true
|
||||||
|
reconnectDelay = c.reconnectDelay // Reset backoff
|
||||||
|
|
||||||
|
fmt.Printf("Connected to %s at %s\n", c.source.Name, addr)
|
||||||
|
|
||||||
|
// Create parser for this connection
|
||||||
|
c.parser = beast.NewParser(conn, c.source.ID)
|
||||||
|
|
||||||
|
// Start processing messages
|
||||||
|
c.wg.Add(2)
|
||||||
|
go c.readMessages()
|
||||||
|
go c.processMessages()
|
||||||
|
|
||||||
|
// Wait for disconnect
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
c.conn.Close()
|
||||||
|
return
|
||||||
|
case <-c.stopChan:
|
||||||
|
c.conn.Close()
|
||||||
|
return
|
||||||
|
case err := <-c.errChan:
|
||||||
|
fmt.Printf("Error from %s: %v\n", c.source.Name, err)
|
||||||
|
c.conn.Close()
|
||||||
|
c.source.Active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for goroutines to finish
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMessages runs in a dedicated goroutine to read Beast format messages.
|
||||||
|
//
|
||||||
|
// This method:
|
||||||
|
// - Continuously reads from the TCP connection
|
||||||
|
// - Parses Beast format binary data into Message structs
|
||||||
|
// - Queues parsed messages for processing
|
||||||
|
// - Reports parsing errors to the error channel
|
||||||
|
//
|
||||||
|
// The method blocks on the parser's ParseStream call and exits when
|
||||||
|
// the connection is closed or an unrecoverable error occurs.
|
||||||
|
func (c *BeastClient) readMessages() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
c.parser.ParseStream(c.msgChan, c.errChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMessages runs in a dedicated goroutine to decode and merge aircraft data.
|
||||||
|
//
|
||||||
|
// For each received Beast message, this method:
|
||||||
|
// 1. Decodes the Mode S/ADS-B message payload
|
||||||
|
// 2. Extracts aircraft information (position, altitude, speed, etc.)
|
||||||
|
// 3. Updates the data merger with new aircraft state
|
||||||
|
// 4. Updates source statistics (message count)
|
||||||
|
//
|
||||||
|
// Invalid or unparseable messages are silently discarded to maintain
|
||||||
|
// system stability. The merger handles data fusion from multiple sources
|
||||||
|
// and conflict resolution based on signal strength.
|
||||||
|
func (c *BeastClient) processMessages() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case msg := <-c.msgChan:
|
||||||
|
if msg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode Mode S message
|
||||||
|
aircraft, err := c.decoder.Decode(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip invalid messages
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update merger with new data
|
||||||
|
c.merger.UpdateAircraft(
|
||||||
|
c.source.ID,
|
||||||
|
aircraft,
|
||||||
|
msg.GetSignalStrength(),
|
||||||
|
msg.ReceivedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update source statistics
|
||||||
|
c.source.Messages++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultiSourceClient manages multiple Beast TCP clients for multi-receiver setups.
|
||||||
|
//
|
||||||
|
// This client coordinator:
|
||||||
|
// - Manages connections to multiple Beast format sources simultaneously
|
||||||
|
// - Provides unified control for starting and stopping all clients
|
||||||
|
// - Runs periodic cleanup tasks for stale aircraft data
|
||||||
|
// - Aggregates statistics from all managed clients
|
||||||
|
// - Handles dynamic source addition and management
|
||||||
|
//
|
||||||
|
// All clients share the same data merger, enabling automatic data fusion
|
||||||
|
// and conflict resolution across multiple receivers.
|
||||||
|
type MultiSourceClient struct {
|
||||||
|
clients []*BeastClient // Managed Beast clients
|
||||||
|
merger *merger.Merger // Shared data merger for all sources
|
||||||
|
mu sync.RWMutex // Protects clients slice
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMultiSourceClient creates a client manager for multiple Beast format sources.
|
||||||
|
//
|
||||||
|
// The multi-source client enables connecting to multiple dump1090 instances
|
||||||
|
// or other Beast format sources simultaneously. All sources feed into the
|
||||||
|
// same data merger, which handles automatic data fusion and conflict resolution.
|
||||||
|
//
|
||||||
|
// This is essential for:
|
||||||
|
// - Improved coverage from multiple receivers
|
||||||
|
// - Redundancy in case of individual receiver failures
|
||||||
|
// - Data quality improvement through signal strength comparison
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - merger: Shared data merger instance for all sources
|
||||||
|
//
|
||||||
|
// Returns a configured multi-source client ready for source addition.
|
||||||
|
func NewMultiSourceClient(merger *merger.Merger) *MultiSourceClient {
|
||||||
|
return &MultiSourceClient{
|
||||||
|
clients: make([]*BeastClient, 0),
|
||||||
|
merger: merger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSource registers and configures a new Beast format data source.
|
||||||
|
//
|
||||||
|
// This method:
|
||||||
|
// 1. Registers the source with the data merger
|
||||||
|
// 2. Creates a new BeastClient for the source
|
||||||
|
// 3. Adds the client to the managed clients list
|
||||||
|
//
|
||||||
|
// The source is not automatically started; call Start() to begin connections.
|
||||||
|
// Sources can be added before or after starting the multi-source client.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - source: Source configuration including connection details and metadata
|
||||||
|
func (m *MultiSourceClient) AddSource(source *merger.Source) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Register source with merger
|
||||||
|
m.merger.AddSource(source)
|
||||||
|
|
||||||
|
// Create and start client
|
||||||
|
client := NewBeastClient(source, m.merger)
|
||||||
|
m.clients = append(m.clients, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins connections to all configured Beast sources.
|
||||||
|
//
|
||||||
|
// This method:
|
||||||
|
// - Starts all managed BeastClient instances in parallel
|
||||||
|
// - Begins the periodic cleanup routine for stale aircraft data
|
||||||
|
// - Uses the provided context for cancellation control
|
||||||
|
//
|
||||||
|
// Each client will independently attempt connections with their own
|
||||||
|
// reconnection logic. The method returns immediately; all clients
|
||||||
|
// operate in background goroutines.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: Context for cancellation and timeout control
|
||||||
|
func (m *MultiSourceClient) Start(ctx context.Context) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, client := range m.clients {
|
||||||
|
client.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start cleanup routine
|
||||||
|
go m.cleanupRoutine(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully shuts down all managed Beast clients.
|
||||||
|
//
|
||||||
|
// This method stops all clients in parallel and waits for their
|
||||||
|
// goroutines to complete. The shutdown is coordinated to ensure
|
||||||
|
// clean termination of all network connections and processing routines.
|
||||||
|
func (m *MultiSourceClient) Stop() {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, client := range m.clients {
|
||||||
|
client.Stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupRoutine runs periodic maintenance tasks in a background goroutine.
|
||||||
|
//
|
||||||
|
// Currently performs:
|
||||||
|
// - Stale aircraft cleanup every 30 seconds
|
||||||
|
// - Removal of aircraft that haven't been updated recently
|
||||||
|
//
|
||||||
|
// The cleanup frequency is designed to balance memory usage with
|
||||||
|
// the typical aircraft update rates in ADS-B systems. Aircraft
|
||||||
|
// typically update their position every few seconds when in range.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - ctx: Context for cancellation when the client shuts down
|
||||||
|
func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.merger.CleanupStale()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatistics returns comprehensive statistics from all managed clients.
|
||||||
|
//
|
||||||
|
// The statistics include:
|
||||||
|
// - All merger statistics (aircraft count, message rates, etc.)
|
||||||
|
// - Number of active client connections
|
||||||
|
// - Total number of configured clients
|
||||||
|
// - Per-source connection status and message counts
|
||||||
|
//
|
||||||
|
// This information is useful for monitoring system health, diagnosing
|
||||||
|
// connectivity issues, and understanding data quality across sources.
|
||||||
|
//
|
||||||
|
// Returns a map of statistics suitable for JSON serialization and web display.
|
||||||
|
func (m *MultiSourceClient) GetStatistics() map[string]interface{} {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
stats := m.merger.GetStatistics()
|
||||||
|
|
||||||
|
// Add client-specific stats
|
||||||
|
activeClients := 0
|
||||||
|
for _, client := range m.clients {
|
||||||
|
if client.source.Active {
|
||||||
|
activeClients++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats["active_clients"] = activeClients
|
||||||
|
stats["total_clients"] = len(m.clients)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"skyview/internal/config"
|
|
||||||
"skyview/internal/parser"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Dump1090Client struct {
|
|
||||||
config *config.Config
|
|
||||||
aircraftMap map[string]*parser.Aircraft
|
|
||||||
mutex sync.RWMutex
|
|
||||||
subscribers []chan parser.AircraftData
|
|
||||||
subMutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDump1090Client(cfg *config.Config) *Dump1090Client {
|
|
||||||
return &Dump1090Client{
|
|
||||||
config: cfg,
|
|
||||||
aircraftMap: make(map[string]*parser.Aircraft),
|
|
||||||
subscribers: make([]chan parser.AircraftData, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) Start(ctx context.Context) error {
|
|
||||||
go c.startDataStream(ctx)
|
|
||||||
go c.startPeriodicBroadcast(ctx)
|
|
||||||
go c.startCleanup(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) startDataStream(ctx context.Context) {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
if err := c.connectAndRead(ctx); err != nil {
|
|
||||||
log.Printf("Connection error: %v, retrying in 5s", err)
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) connectAndRead(ctx context.Context) error {
|
|
||||||
address := fmt.Sprintf("%s:%d", c.config.Dump1090.Host, c.config.Dump1090.DataPort)
|
|
||||||
|
|
||||||
conn, err := net.Dial("tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to %s: %w", address, err)
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
log.Printf("Connected to dump1090 at %s", address)
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
|
||||||
for scanner.Scan() {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
line := scanner.Text()
|
|
||||||
c.processLine(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return scanner.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) processLine(line string) {
|
|
||||||
aircraft, err := parser.ParseSBS1Line(line)
|
|
||||||
if err != nil || aircraft == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mutex.Lock()
|
|
||||||
if existing, exists := c.aircraftMap[aircraft.Hex]; exists {
|
|
||||||
c.updateExistingAircraft(existing, aircraft)
|
|
||||||
} else {
|
|
||||||
c.aircraftMap[aircraft.Hex] = aircraft
|
|
||||||
}
|
|
||||||
c.mutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraft) {
|
|
||||||
existing.LastSeen = update.LastSeen
|
|
||||||
existing.Messages++
|
|
||||||
|
|
||||||
if update.Flight != "" {
|
|
||||||
existing.Flight = update.Flight
|
|
||||||
}
|
|
||||||
if update.Altitude != 0 {
|
|
||||||
existing.Altitude = update.Altitude
|
|
||||||
}
|
|
||||||
if update.GroundSpeed != 0 {
|
|
||||||
existing.GroundSpeed = update.GroundSpeed
|
|
||||||
}
|
|
||||||
if update.Track != 0 {
|
|
||||||
existing.Track = update.Track
|
|
||||||
}
|
|
||||||
if update.Latitude != 0 && update.Longitude != 0 {
|
|
||||||
existing.Latitude = update.Latitude
|
|
||||||
existing.Longitude = update.Longitude
|
|
||||||
|
|
||||||
// Add to track history if position changed significantly
|
|
||||||
if c.shouldAddTrackPoint(existing, update) {
|
|
||||||
trackPoint := parser.TrackPoint{
|
|
||||||
Timestamp: update.LastSeen,
|
|
||||||
Latitude: update.Latitude,
|
|
||||||
Longitude: update.Longitude,
|
|
||||||
Altitude: update.Altitude,
|
|
||||||
Speed: update.GroundSpeed,
|
|
||||||
Track: update.Track,
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.TrackHistory = append(existing.TrackHistory, trackPoint)
|
|
||||||
|
|
||||||
// Keep only last 200 points (about 3-4 hours at 1 point/minute)
|
|
||||||
if len(existing.TrackHistory) > 200 {
|
|
||||||
existing.TrackHistory = existing.TrackHistory[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if update.VertRate != 0 {
|
|
||||||
existing.VertRate = update.VertRate
|
|
||||||
}
|
|
||||||
if update.Squawk != "" {
|
|
||||||
existing.Squawk = update.Squawk
|
|
||||||
}
|
|
||||||
existing.OnGround = update.OnGround
|
|
||||||
|
|
||||||
// Preserve country and registration
|
|
||||||
if update.Country != "" && update.Country != "Unknown" {
|
|
||||||
existing.Country = update.Country
|
|
||||||
}
|
|
||||||
if update.Registration != "" {
|
|
||||||
existing.Registration = update.Registration
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft) bool {
|
|
||||||
// Add track point if:
|
|
||||||
// 1. No history yet
|
|
||||||
if len(existing.TrackHistory) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
lastPoint := existing.TrackHistory[len(existing.TrackHistory)-1]
|
|
||||||
|
|
||||||
// 2. At least 30 seconds since last point
|
|
||||||
if time.Since(lastPoint.Timestamp) < 30*time.Second {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Position changed by at least 0.001 degrees (~100m)
|
|
||||||
latDiff := existing.Latitude - lastPoint.Latitude
|
|
||||||
lonDiff := existing.Longitude - lastPoint.Longitude
|
|
||||||
distanceChange := latDiff*latDiff + lonDiff*lonDiff
|
|
||||||
|
|
||||||
return distanceChange > 0.000001 // ~0.001 degrees squared
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) GetAircraftData() parser.AircraftData {
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
aircraftMap := make(map[string]parser.Aircraft)
|
|
||||||
totalMessages := 0
|
|
||||||
|
|
||||||
for hex, aircraft := range c.aircraftMap {
|
|
||||||
aircraftMap[hex] = *aircraft
|
|
||||||
totalMessages += aircraft.Messages
|
|
||||||
}
|
|
||||||
|
|
||||||
return parser.AircraftData{
|
|
||||||
Now: time.Now().Unix(),
|
|
||||||
Messages: totalMessages,
|
|
||||||
Aircraft: aircraftMap,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) Subscribe() <-chan parser.AircraftData {
|
|
||||||
c.subMutex.Lock()
|
|
||||||
defer c.subMutex.Unlock()
|
|
||||||
|
|
||||||
ch := make(chan parser.AircraftData, 10)
|
|
||||||
c.subscribers = append(c.subscribers, ch)
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) startPeriodicBroadcast(ctx context.Context) {
|
|
||||||
ticker := time.NewTicker(1 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
data := c.GetAircraftData()
|
|
||||||
c.broadcastToSubscribers(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) broadcastToSubscribers(data parser.AircraftData) {
|
|
||||||
c.subMutex.RLock()
|
|
||||||
defer c.subMutex.RUnlock()
|
|
||||||
|
|
||||||
for i, ch := range c.subscribers {
|
|
||||||
select {
|
|
||||||
case ch <- data:
|
|
||||||
default:
|
|
||||||
close(ch)
|
|
||||||
c.subMutex.RUnlock()
|
|
||||||
c.subMutex.Lock()
|
|
||||||
c.subscribers = append(c.subscribers[:i], c.subscribers[i+1:]...)
|
|
||||||
c.subMutex.Unlock()
|
|
||||||
c.subMutex.RLock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) startCleanup(ctx context.Context) {
|
|
||||||
ticker := time.NewTicker(30 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
c.cleanupStaleAircraft()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Dump1090Client) cleanupStaleAircraft() {
|
|
||||||
c.mutex.Lock()
|
|
||||||
defer c.mutex.Unlock()
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-2 * time.Minute)
|
|
||||||
trackCutoff := time.Now().Add(-24 * time.Hour)
|
|
||||||
|
|
||||||
for hex, aircraft := range c.aircraftMap {
|
|
||||||
if aircraft.LastSeen.Before(cutoff) {
|
|
||||||
delete(c.aircraftMap, hex)
|
|
||||||
} else {
|
|
||||||
// Clean up old track points (keep last 24 hours)
|
|
||||||
validTracks := make([]parser.TrackPoint, 0)
|
|
||||||
for _, point := range aircraft.TrackHistory {
|
|
||||||
if point.Timestamp.After(trackCutoff) {
|
|
||||||
validTracks = append(validTracks, point)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aircraft.TrackHistory = validTracks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Server ServerConfig `json:"server"`
|
|
||||||
Dump1090 Dump1090Config `json:"dump1090"`
|
|
||||||
Origin OriginConfig `json:"origin"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerConfig struct {
|
|
||||||
Address string `json:"address"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Dump1090Config struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
DataPort int `json:"data_port"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type OriginConfig struct {
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
cfg := &Config{
|
|
||||||
Server: ServerConfig{
|
|
||||||
Address: ":8080",
|
|
||||||
Port: 8080,
|
|
||||||
},
|
|
||||||
Dump1090: Dump1090Config{
|
|
||||||
Host: "localhost",
|
|
||||||
DataPort: 30003,
|
|
||||||
},
|
|
||||||
Origin: OriginConfig{
|
|
||||||
Latitude: 37.7749,
|
|
||||||
Longitude: -122.4194,
|
|
||||||
Name: "Default Location",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
configFile := os.Getenv("SKYVIEW_CONFIG")
|
|
||||||
if configFile == "" {
|
|
||||||
// Check for config files in common locations
|
|
||||||
candidates := []string{"config.json", "./config.json", "skyview.json"}
|
|
||||||
for _, candidate := range candidates {
|
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
|
||||||
configFile = candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if configFile != "" {
|
|
||||||
if err := loadFromFile(cfg, configFile); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load config file %s: %w", configFile, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadFromEnv(cfg)
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFromFile(cfg *Config, filename string) error {
|
|
||||||
data, err := os.ReadFile(filename)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Unmarshal(data, cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadFromEnv(cfg *Config) {
|
|
||||||
if addr := os.Getenv("SKYVIEW_ADDRESS"); addr != "" {
|
|
||||||
cfg.Server.Address = addr
|
|
||||||
}
|
|
||||||
|
|
||||||
if portStr := os.Getenv("SKYVIEW_PORT"); portStr != "" {
|
|
||||||
if port, err := strconv.Atoi(portStr); err == nil {
|
|
||||||
cfg.Server.Port = port
|
|
||||||
cfg.Server.Address = fmt.Sprintf(":%d", port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if host := os.Getenv("DUMP1090_HOST"); host != "" {
|
|
||||||
cfg.Dump1090.Host = host
|
|
||||||
}
|
|
||||||
|
|
||||||
if dataPortStr := os.Getenv("DUMP1090_DATA_PORT"); dataPortStr != "" {
|
|
||||||
if port, err := strconv.Atoi(dataPortStr); err == nil {
|
|
||||||
cfg.Dump1090.DataPort = port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if latStr := os.Getenv("ORIGIN_LATITUDE"); latStr != "" {
|
|
||||||
if lat, err := strconv.ParseFloat(latStr, 64); err == nil {
|
|
||||||
cfg.Origin.Latitude = lat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lonStr := os.Getenv("ORIGIN_LONGITUDE"); lonStr != "" {
|
|
||||||
if lon, err := strconv.ParseFloat(lonStr, 64); err == nil {
|
|
||||||
cfg.Origin.Longitude = lon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if name := os.Getenv("ORIGIN_NAME"); name != "" {
|
|
||||||
cfg.Origin.Name = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
268
internal/icao/database.go
Normal 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
|
||||||
|
}
|
||||||
1034
internal/merger/merger.go
Normal file
1150
internal/modes/decoder.go
Normal file
|
|
@ -1,225 +0,0 @@
|
||||||
package parser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TrackPoint struct {
|
|
||||||
Timestamp time.Time `json:"timestamp"`
|
|
||||||
Latitude float64 `json:"lat"`
|
|
||||||
Longitude float64 `json:"lon"`
|
|
||||||
Altitude int `json:"altitude"`
|
|
||||||
Speed int `json:"speed"`
|
|
||||||
Track int `json:"track"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Aircraft struct {
|
|
||||||
Hex string `json:"hex"`
|
|
||||||
Flight string `json:"flight,omitempty"`
|
|
||||||
Altitude int `json:"alt_baro,omitempty"`
|
|
||||||
GroundSpeed int `json:"gs,omitempty"`
|
|
||||||
Track int `json:"track,omitempty"`
|
|
||||||
Latitude float64 `json:"lat,omitempty"`
|
|
||||||
Longitude float64 `json:"lon,omitempty"`
|
|
||||||
VertRate int `json:"vert_rate,omitempty"`
|
|
||||||
Squawk string `json:"squawk,omitempty"`
|
|
||||||
Emergency bool `json:"emergency,omitempty"`
|
|
||||||
OnGround bool `json:"on_ground,omitempty"`
|
|
||||||
LastSeen time.Time `json:"last_seen"`
|
|
||||||
Messages int `json:"messages"`
|
|
||||||
TrackHistory []TrackPoint `json:"track_history,omitempty"`
|
|
||||||
RSSI float64 `json:"rssi,omitempty"`
|
|
||||||
Country string `json:"country,omitempty"`
|
|
||||||
Registration string `json:"registration,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type AircraftData struct {
|
|
||||||
Now int64 `json:"now"`
|
|
||||||
Messages int `json:"messages"`
|
|
||||||
Aircraft map[string]Aircraft `json:"aircraft"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseSBS1Line(line string) (*Aircraft, error) {
|
|
||||||
parts := strings.Split(strings.TrimSpace(line), ",")
|
|
||||||
if len(parts) < 22 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// messageType := parts[1]
|
|
||||||
// Accept all message types to get complete data
|
|
||||||
// MSG types: 1=ES_IDENT_AND_CATEGORY, 2=ES_SURFACE_POS, 3=ES_AIRBORNE_POS
|
|
||||||
// 4=ES_AIRBORNE_VEL, 5=SURVEILLANCE_ALT, 6=SURVEILLANCE_ID, 7=AIR_TO_AIR, 8=ALL_CALL_REPLY
|
|
||||||
|
|
||||||
aircraft := &Aircraft{
|
|
||||||
Hex: strings.TrimSpace(parts[4]),
|
|
||||||
LastSeen: time.Now(),
|
|
||||||
Messages: 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different message types contain different fields
|
|
||||||
// Always try to extract what's available
|
|
||||||
if parts[10] != "" {
|
|
||||||
aircraft.Flight = strings.TrimSpace(parts[10])
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[11] != "" {
|
|
||||||
if alt, err := strconv.Atoi(parts[11]); err == nil {
|
|
||||||
aircraft.Altitude = alt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[12] != "" {
|
|
||||||
if gs, err := strconv.Atoi(parts[12]); err == nil {
|
|
||||||
aircraft.GroundSpeed = gs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[13] != "" {
|
|
||||||
if track, err := strconv.ParseFloat(parts[13], 64); err == nil {
|
|
||||||
aircraft.Track = int(track)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[14] != "" && parts[15] != "" {
|
|
||||||
if lat, err := strconv.ParseFloat(parts[14], 64); err == nil {
|
|
||||||
aircraft.Latitude = lat
|
|
||||||
}
|
|
||||||
if lon, err := strconv.ParseFloat(parts[15], 64); err == nil {
|
|
||||||
aircraft.Longitude = lon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[16] != "" {
|
|
||||||
if vr, err := strconv.Atoi(parts[16]); err == nil {
|
|
||||||
aircraft.VertRate = vr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[17] != "" {
|
|
||||||
aircraft.Squawk = strings.TrimSpace(parts[17])
|
|
||||||
}
|
|
||||||
|
|
||||||
if parts[21] != "" {
|
|
||||||
aircraft.OnGround = parts[21] == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
aircraft.Country = getCountryFromICAO(aircraft.Hex)
|
|
||||||
aircraft.Registration = getRegistrationFromICAO(aircraft.Hex)
|
|
||||||
|
|
||||||
return aircraft, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCountryFromICAO(icao string) string {
|
|
||||||
if len(icao) < 6 {
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix := icao[:1]
|
|
||||||
|
|
||||||
switch prefix {
|
|
||||||
case "4":
|
|
||||||
return getCountryFrom4xxxx(icao)
|
|
||||||
case "A":
|
|
||||||
return "United States"
|
|
||||||
case "C":
|
|
||||||
return "Canada"
|
|
||||||
case "D":
|
|
||||||
return "Germany"
|
|
||||||
case "F":
|
|
||||||
return "France"
|
|
||||||
case "G":
|
|
||||||
return "United Kingdom"
|
|
||||||
case "I":
|
|
||||||
return "Italy"
|
|
||||||
case "J":
|
|
||||||
return "Japan"
|
|
||||||
case "P":
|
|
||||||
return getPCountry(icao)
|
|
||||||
case "S":
|
|
||||||
return getSCountry(icao)
|
|
||||||
case "O":
|
|
||||||
return getOCountry(icao)
|
|
||||||
default:
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getCountryFrom4xxxx(icao string) string {
|
|
||||||
if len(icao) >= 2 {
|
|
||||||
switch icao[:2] {
|
|
||||||
case "40":
|
|
||||||
return "United Kingdom"
|
|
||||||
case "44":
|
|
||||||
return "Austria"
|
|
||||||
case "45":
|
|
||||||
return "Denmark"
|
|
||||||
case "46":
|
|
||||||
return "Germany"
|
|
||||||
case "47":
|
|
||||||
return "Germany"
|
|
||||||
case "48":
|
|
||||||
return "Netherlands"
|
|
||||||
case "49":
|
|
||||||
return "Netherlands"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Europe"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPCountry(icao string) string {
|
|
||||||
if len(icao) >= 2 {
|
|
||||||
switch icao[:2] {
|
|
||||||
case "PH":
|
|
||||||
return "Netherlands"
|
|
||||||
case "PJ":
|
|
||||||
return "Netherlands Antilles"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSCountry(icao string) string {
|
|
||||||
if len(icao) >= 2 {
|
|
||||||
switch icao[:2] {
|
|
||||||
case "SE":
|
|
||||||
return "Sweden"
|
|
||||||
case "SX":
|
|
||||||
return "Greece"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getOCountry(icao string) string {
|
|
||||||
if len(icao) >= 2 {
|
|
||||||
switch icao[:2] {
|
|
||||||
case "OO":
|
|
||||||
return "Belgium"
|
|
||||||
case "OH":
|
|
||||||
return "Finland"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "Unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func getRegistrationFromICAO(icao string) string {
|
|
||||||
// This is a simplified conversion - real registration lookup would need a database
|
|
||||||
country := getCountryFromICAO(icao)
|
|
||||||
switch country {
|
|
||||||
case "Germany":
|
|
||||||
return "D-" + icao[2:]
|
|
||||||
case "United Kingdom":
|
|
||||||
return "G-" + icao[2:]
|
|
||||||
case "France":
|
|
||||||
return "F-" + icao[2:]
|
|
||||||
case "Netherlands":
|
|
||||||
return "PH-" + icao[2:]
|
|
||||||
case "Sweden":
|
|
||||||
return "SE-" + icao[2:]
|
|
||||||
default:
|
|
||||||
return icao
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"skyview/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed testdata/*
|
|
||||||
var testStaticFiles embed.FS
|
|
||||||
|
|
||||||
func TestNew(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Server: config.ServerConfig{
|
|
||||||
Address: ":8080",
|
|
||||||
Port: 8080,
|
|
||||||
},
|
|
||||||
Dump1090: config.Dump1090Config{
|
|
||||||
Host: "localhost",
|
|
||||||
Port: 8080,
|
|
||||||
URL: "http://localhost:8080",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(cfg, testStaticFiles)
|
|
||||||
if handler == nil {
|
|
||||||
t.Fatal("Expected handler to be created")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCORSHeaders(t *testing.T) {
|
|
||||||
cfg := &config.Config{
|
|
||||||
Dump1090: config.Dump1090Config{
|
|
||||||
URL: "http://localhost:8080",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := New(cfg, testStaticFiles)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("OPTIONS", "/api/aircraft", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
|
|
||||||
t.Errorf("Expected CORS header, got %s", w.Header().Get("Access-Control-Allow-Origin"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if w.Code != http.StatusOK {
|
|
||||||
t.Errorf("Expected status 200, got %d", w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
main.go
|
|
@ -1,69 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"embed"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"skyview/internal/config"
|
|
||||||
"skyview/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed static/*
|
|
||||||
var staticFiles embed.FS
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
daemon := flag.Bool("daemon", false, "Run as daemon (background process)")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to load configuration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
srv := server.New(cfg, staticFiles, ctx)
|
|
||||||
|
|
||||||
log.Printf("Starting skyview server on %s", cfg.Server.Address)
|
|
||||||
log.Printf("Connecting to dump1090 SBS-1 at %s:%d", cfg.Dump1090.Host, cfg.Dump1090.DataPort)
|
|
||||||
|
|
||||||
httpServer := &http.Server{
|
|
||||||
Addr: cfg.Server.Address,
|
|
||||||
Handler: srv,
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("Server failed to start: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if *daemon {
|
|
||||||
log.Printf("Running as daemon...")
|
|
||||||
select {}
|
|
||||||
} else {
|
|
||||||
log.Printf("Press Ctrl+C to stop")
|
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
<-sigChan
|
|
||||||
|
|
||||||
log.Printf("Shutting down...")
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer shutdownCancel()
|
|
||||||
|
|
||||||
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Printf("Server shutdown error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
111
scripts/build-deb.sh
Executable file
|
|
@ -0,0 +1,111 @@
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Build script for creating Debian package
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BUILD_DIR="$PROJECT_DIR/build"
|
||||||
|
DEB_DIR="$PROJECT_DIR/debian"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean previous builds
|
||||||
|
echo_info "Cleaning previous builds..."
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
# Change to project directory
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Build the applications
|
||||||
|
echo_info "Building SkyView applications..."
|
||||||
|
export CGO_ENABLED=0
|
||||||
|
export GOOS=linux
|
||||||
|
export GOARCH=amd64
|
||||||
|
|
||||||
|
VERSION=$(git describe --tags --always --dirty)
|
||||||
|
LDFLAGS="-w -s -X main.version=$VERSION"
|
||||||
|
|
||||||
|
# Build main skyview binary
|
||||||
|
echo_info "Building skyview..."
|
||||||
|
if ! go build -ldflags="$LDFLAGS" \
|
||||||
|
-o "$DEB_DIR/usr/bin/skyview" \
|
||||||
|
./cmd/skyview; then
|
||||||
|
echo_error "Failed to build skyview"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build beast-dump utility
|
||||||
|
echo_info "Building beast-dump..."
|
||||||
|
if ! go build -ldflags="$LDFLAGS" \
|
||||||
|
-o "$DEB_DIR/usr/bin/beast-dump" \
|
||||||
|
./cmd/beast-dump; then
|
||||||
|
echo_error "Failed to build beast-dump"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_info "Built binaries:"
|
||||||
|
echo_info " skyview: $(file "$DEB_DIR/usr/bin/skyview")"
|
||||||
|
echo_info " beast-dump: $(file "$DEB_DIR/usr/bin/beast-dump")"
|
||||||
|
|
||||||
|
# Set executable permissions
|
||||||
|
chmod +x "$DEB_DIR/usr/bin/skyview"
|
||||||
|
chmod +x "$DEB_DIR/usr/bin/beast-dump"
|
||||||
|
|
||||||
|
# Get package info
|
||||||
|
VERSION=$(grep "Version:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2)
|
||||||
|
PACKAGE=$(grep "Package:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2)
|
||||||
|
ARCH=$(grep "Architecture:" "$DEB_DIR/DEBIAN/control" | cut -d' ' -f2)
|
||||||
|
|
||||||
|
DEB_FILE="${PACKAGE}_${VERSION}_${ARCH}.deb"
|
||||||
|
|
||||||
|
echo_info "Creating Debian package: $DEB_FILE"
|
||||||
|
|
||||||
|
# Calculate installed size
|
||||||
|
INSTALLED_SIZE=$(du -sk "$DEB_DIR" | cut -f1)
|
||||||
|
sed -i "s/Installed-Size:.*/Installed-Size: $INSTALLED_SIZE/" "$DEB_DIR/DEBIAN/control" 2>/dev/null || \
|
||||||
|
echo "Installed-Size: $INSTALLED_SIZE" >> "$DEB_DIR/DEBIAN/control"
|
||||||
|
|
||||||
|
# Build the package
|
||||||
|
if dpkg-deb --root-owner-group --build "$DEB_DIR" "$BUILD_DIR/$DEB_FILE"; then
|
||||||
|
echo_info "Successfully created: $BUILD_DIR/$DEB_FILE"
|
||||||
|
|
||||||
|
# Show package info
|
||||||
|
echo_info "Package information:"
|
||||||
|
dpkg-deb --info "$BUILD_DIR/$DEB_FILE"
|
||||||
|
|
||||||
|
echo_info "Package contents:"
|
||||||
|
dpkg-deb --contents "$BUILD_DIR/$DEB_FILE"
|
||||||
|
|
||||||
|
# Test the package (requires root)
|
||||||
|
if [ "$EUID" -eq 0 ]; then
|
||||||
|
echo_info "Testing package installation (as root)..."
|
||||||
|
dpkg --dry-run -i "$BUILD_DIR/$DEB_FILE"
|
||||||
|
else
|
||||||
|
echo_warn "Run as root to test package installation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_info "Debian package build complete!"
|
||||||
|
echo_info "Install with: sudo dpkg -i $BUILD_DIR/$DEB_FILE"
|
||||||
|
echo_info "Or upload to repository for apt installation"
|
||||||
|
else
|
||||||
|
echo_error "Failed to create Debian package"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00a8ff" stroke="#ffffff" stroke-width="1">
|
|
||||||
<path d="M12 2l-2 16 2-2 2 2-2-16z"/>
|
|
||||||
<path d="M4 10l8-2-1 2-7 0z"/>
|
|
||||||
<path d="M20 10l-8-2 1 2 7 0z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 224 B |
|
|
@ -1,152 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>SkyView - ADS-B Aircraft Tracker</title>
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app">
|
|
||||||
<header class="header">
|
|
||||||
<h1>SkyView</h1>
|
|
||||||
<div class="clock-section">
|
|
||||||
<div class="clock-display">
|
|
||||||
<div class="clock" id="utc-clock">
|
|
||||||
<div class="clock-face">
|
|
||||||
<div class="clock-hand hour-hand" id="utc-hour"></div>
|
|
||||||
<div class="clock-hand minute-hand" id="utc-minute"></div>
|
|
||||||
</div>
|
|
||||||
<div class="clock-label">UTC</div>
|
|
||||||
</div>
|
|
||||||
<div class="clock" id="update-clock">
|
|
||||||
<div class="clock-face">
|
|
||||||
<div class="clock-hand hour-hand" id="update-hour"></div>
|
|
||||||
<div class="clock-hand minute-hand" id="update-minute"></div>
|
|
||||||
</div>
|
|
||||||
<div class="clock-label">Last Update</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stats-summary">
|
|
||||||
<span id="aircraft-count">0 aircraft</span>
|
|
||||||
<span id="connection-status" class="connected">Connected</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<div class="view-toggle">
|
|
||||||
<button id="map-view-btn" class="view-btn active">Map</button>
|
|
||||||
<button id="table-view-btn" class="view-btn">Table</button>
|
|
||||||
<button id="stats-view-btn" class="view-btn">Stats</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="map-view" class="view active">
|
|
||||||
<div id="map"></div>
|
|
||||||
<div class="map-controls">
|
|
||||||
<button id="center-map">Center Map</button>
|
|
||||||
<button id="toggle-trails">Toggle Trails</button>
|
|
||||||
<button id="toggle-history">Show History</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="legend">
|
|
||||||
<h4>Aircraft Types</h4>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon commercial"></span>
|
|
||||||
<span>Commercial</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon cargo"></span>
|
|
||||||
<span>Cargo</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon military"></span>
|
|
||||||
<span>Military</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon ga"></span>
|
|
||||||
<span>General Aviation</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<span class="legend-icon ground"></span>
|
|
||||||
<span>Ground</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="table-view" class="view">
|
|
||||||
<div class="table-controls">
|
|
||||||
<input type="text" id="search-input" placeholder="Search by flight, ICAO, or squawk...">
|
|
||||||
<select id="sort-select">
|
|
||||||
<option value="distance">Distance</option>
|
|
||||||
<option value="altitude">Altitude</option>
|
|
||||||
<option value="speed">Speed</option>
|
|
||||||
<option value="flight">Flight</option>
|
|
||||||
<option value="icao">ICAO</option>
|
|
||||||
<option value="squawk">Squawk</option>
|
|
||||||
<option value="age">Age</option>
|
|
||||||
<option value="rssi">RSSI</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table id="aircraft-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ICAO</th>
|
|
||||||
<th>Flight</th>
|
|
||||||
<th>Squawk</th>
|
|
||||||
<th>Altitude</th>
|
|
||||||
<th>Speed</th>
|
|
||||||
<th>Distance</th>
|
|
||||||
<th>Track</th>
|
|
||||||
<th>Msgs</th>
|
|
||||||
<th>Age</th>
|
|
||||||
<th>RSSI</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="aircraft-tbody">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="stats-view" class="view">
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Total Aircraft</h3>
|
|
||||||
<div class="stat-value" id="total-aircraft">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Messages/sec</h3>
|
|
||||||
<div class="stat-value" id="messages-sec">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Avg RSSI</h3>
|
|
||||||
<div class="stat-value" id="signal-strength">0 dBFS</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<h3>Max Range</h3>
|
|
||||||
<div class="stat-value" id="max-range">0 nm</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="charts-container">
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>Aircraft Count (24h)</h3>
|
|
||||||
<canvas id="aircraft-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
<div class="chart-card">
|
|
||||||
<h3>Message Rate</h3>
|
|
||||||
<canvas id="message-chart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
|
||||||
<script src="/static/js/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
832
static/js/app.js
|
|
@ -1,832 +0,0 @@
|
||||||
class SkyView {
|
|
||||||
constructor() {
|
|
||||||
this.map = null;
|
|
||||||
this.aircraftMarkers = new Map();
|
|
||||||
this.aircraftTrails = new Map();
|
|
||||||
this.historicalTracks = new Map();
|
|
||||||
this.websocket = null;
|
|
||||||
this.aircraftData = [];
|
|
||||||
this.showTrails = false;
|
|
||||||
this.showHistoricalTracks = false;
|
|
||||||
this.currentView = 'map';
|
|
||||||
this.charts = {};
|
|
||||||
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.loadConfig().then(() => {
|
|
||||||
this.initializeViews();
|
|
||||||
this.initializeMap();
|
|
||||||
this.initializeWebSocket();
|
|
||||||
this.initializeEventListeners();
|
|
||||||
this.initializeCharts();
|
|
||||||
this.initializeClocks();
|
|
||||||
|
|
||||||
this.startPeriodicUpdates();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadConfig() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/config');
|
|
||||||
const config = await response.json();
|
|
||||||
if (config.origin) {
|
|
||||||
this.origin = config.origin;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to load config, using defaults:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
viewButtons.forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
|
|
||||||
views.forEach(v => v.classList.remove('active'));
|
|
||||||
document.getElementById(viewId).classList.add('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switchView(view) {
|
|
||||||
this.currentView = view;
|
|
||||||
if (view === 'map' && this.map) {
|
|
||||||
setTimeout(() => this.map.invalidateSize(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeMap() {
|
|
||||||
this.map = L.map('map', {
|
|
||||||
center: [this.origin.latitude, this.origin.longitude],
|
|
||||||
zoom: 8,
|
|
||||||
zoomControl: true
|
|
||||||
});
|
|
||||||
|
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
||||||
attribution: '© OpenStreetMap contributors'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
L.marker([this.origin.latitude, this.origin.longitude], {
|
|
||||||
icon: L.divIcon({
|
|
||||||
html: '<div style="background: #e74c3c; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white;"></div>',
|
|
||||||
className: 'origin-marker',
|
|
||||||
iconSize: [16, 16],
|
|
||||||
iconAnchor: [8, 8]
|
|
||||||
})
|
|
||||||
}).addTo(this.map).bindPopup(`<b>Origin</b><br>${this.origin.name}`);
|
|
||||||
|
|
||||||
L.circle([this.origin.latitude, this.origin.longitude], {
|
|
||||||
radius: 185200,
|
|
||||||
fillColor: 'transparent',
|
|
||||||
color: '#404040',
|
|
||||||
weight: 1,
|
|
||||||
opacity: 0.5
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
const centerBtn = document.getElementById('center-map');
|
|
||||||
centerBtn.addEventListener('click', () => this.centerMapOnAircraft());
|
|
||||||
|
|
||||||
const trailsBtn = document.getElementById('toggle-trails');
|
|
||||||
trailsBtn.addEventListener('click', () => this.toggleTrails());
|
|
||||||
|
|
||||||
const historyBtn = document.getElementById('toggle-history');
|
|
||||||
historyBtn.addEventListener('click', () => this.toggleHistoricalTracks());
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeWebSocket() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
||||||
|
|
||||||
this.websocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
|
||||||
document.getElementById('connection-status').textContent = 'Connected';
|
|
||||||
document.getElementById('connection-status').className = 'connection-status connected';
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
|
||||||
document.getElementById('connection-status').textContent = 'Disconnected';
|
|
||||||
document.getElementById('connection-status').className = 'connection-status disconnected';
|
|
||||||
setTimeout(() => this.initializeWebSocket(), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onmessage = (event) => {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
if (message.type === 'aircraft_update') {
|
|
||||||
this.updateAircraftData(message.data);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeEventListeners() {
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
const sortSelect = document.getElementById('sort-select');
|
|
||||||
|
|
||||||
searchInput.addEventListener('input', () => this.filterAircraftTable());
|
|
||||||
sortSelect.addEventListener('change', () => this.sortAircraftTable());
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeCharts() {
|
|
||||||
const aircraftCtx = document.getElementById('aircraft-chart').getContext('2d');
|
|
||||||
const messageCtx = document.getElementById('message-chart').getContext('2d');
|
|
||||||
|
|
||||||
this.charts.aircraft = new Chart(aircraftCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Aircraft Count',
|
|
||||||
data: [],
|
|
||||||
borderColor: '#00a8ff',
|
|
||||||
backgroundColor: 'rgba(0, 168, 255, 0.1)',
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.charts.messages = new Chart(messageCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: 'Messages/sec',
|
|
||||||
data: [],
|
|
||||||
borderColor: '#2ecc71',
|
|
||||||
backgroundColor: 'rgba(46, 204, 113, 0.1)',
|
|
||||||
tension: 0.4
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeClocks() {
|
|
||||||
this.updateClocks();
|
|
||||||
setInterval(() => this.updateClocks(), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftData(data) {
|
|
||||||
this.aircraftData = data.aircraft || [];
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
this.updateMapMarkers();
|
|
||||||
this.updateAircraftTable();
|
|
||||||
this.updateStats();
|
|
||||||
|
|
||||||
document.getElementById('aircraft-count').textContent = `${this.aircraftData.length} aircraft`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMapMarkers() {
|
|
||||||
const currentHexCodes = new Set(this.aircraftData.map(a => a.hex));
|
|
||||||
|
|
||||||
this.aircraftMarkers.forEach((marker, hex) => {
|
|
||||||
if (!currentHexCodes.has(hex)) {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
this.aircraftMarkers.delete(hex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.aircraftData.forEach(aircraft => {
|
|
||||||
if (!aircraft.lat || !aircraft.lon) return;
|
|
||||||
|
|
||||||
const pos = [aircraft.lat, aircraft.lon];
|
|
||||||
|
|
||||||
if (this.aircraftMarkers.has(aircraft.hex)) {
|
|
||||||
const marker = this.aircraftMarkers.get(aircraft.hex);
|
|
||||||
marker.setLatLng(pos);
|
|
||||||
this.updateMarkerRotation(marker, aircraft.track, aircraft);
|
|
||||||
this.updatePopupContent(marker, aircraft);
|
|
||||||
} else {
|
|
||||||
const marker = this.createAircraftMarker(aircraft, pos);
|
|
||||||
this.aircraftMarkers.set(aircraft.hex, marker);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.showTrails) {
|
|
||||||
this.updateTrail(aircraft.hex, pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.showHistoricalTracks && aircraft.track_history && aircraft.track_history.length > 1) {
|
|
||||||
this.displayHistoricalTrack(aircraft.hex, aircraft.track_history, aircraft.flight);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
createAircraftMarker(aircraft, pos) {
|
|
||||||
const hasPosition = aircraft.lat && aircraft.lon;
|
|
||||||
const size = hasPosition ? 24 : 18;
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: this.getAircraftIcon(aircraft),
|
|
||||||
className: 'aircraft-marker',
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size/2, size/2]
|
|
||||||
});
|
|
||||||
|
|
||||||
const marker = L.marker(pos, { icon }).addTo(this.map);
|
|
||||||
|
|
||||||
marker.bindPopup(this.createPopupContent(aircraft), {
|
|
||||||
className: 'aircraft-popup'
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.on('click', () => {
|
|
||||||
if (this.showHistoricalTracks) {
|
|
||||||
this.loadAircraftHistory(aircraft.hex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return marker;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftType(aircraft) {
|
|
||||||
if (aircraft.on_ground) return 'ground';
|
|
||||||
|
|
||||||
// Determine type based on flight number patterns and characteristics
|
|
||||||
const flight = aircraft.flight || '';
|
|
||||||
|
|
||||||
// Cargo airlines (simplified patterns)
|
|
||||||
if (/^(UPS|FDX|FX|ABX|ATN|5Y|QY)/i.test(flight)) return 'cargo';
|
|
||||||
|
|
||||||
// Military patterns (basic)
|
|
||||||
if (/^(RCH|CNV|MAC|EVAC|ARMY|NAVY|AF|USAF)/i.test(flight)) return 'military';
|
|
||||||
|
|
||||||
// General aviation (no airline code pattern)
|
|
||||||
if (flight.length > 0 && !/^[A-Z]{2,3}[0-9]/.test(flight)) return 'ga';
|
|
||||||
|
|
||||||
// Default commercial
|
|
||||||
return 'commercial';
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftIcon(aircraft) {
|
|
||||||
const rotation = aircraft.track || 0;
|
|
||||||
const hasPosition = aircraft.lat && aircraft.lon;
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const size = hasPosition ? 24 : 18;
|
|
||||||
|
|
||||||
let color, icon;
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'cargo':
|
|
||||||
color = hasPosition ? "#ff8c00" : "#666666";
|
|
||||||
icon = `<rect x="8" y="6" width="8" height="12" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
||||||
<polygon points="12,2 10,6 14,6" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="4,10 8,8 8,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="20,10 16,8 16,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
||||||
break;
|
|
||||||
case 'military':
|
|
||||||
color = hasPosition ? "#ff4444" : "#666666";
|
|
||||||
icon = `<polygon points="12,2 9,20 12,18 15,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
||||||
<polygon points="6,8 12,6 12,10" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="18,8 12,6 12,10" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="8,16 12,14 12,18" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="16,16 12,14 12,18" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
||||||
break;
|
|
||||||
case 'ga':
|
|
||||||
color = hasPosition ? "#ffff00" : "#666666";
|
|
||||||
icon = `<polygon points="12,2 11,20 12,19 13,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
||||||
<polygon points="7,12 12,10 12,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="17,12 12,10 12,14" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
||||||
break;
|
|
||||||
case 'ground':
|
|
||||||
color = "#888888";
|
|
||||||
icon = `<circle cx="12" cy="12" r="6" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
||||||
<text x="12" y="16" text-anchor="middle" font-size="8" fill="#ffffff">G</text>`;
|
|
||||||
break;
|
|
||||||
default: // commercial
|
|
||||||
color = hasPosition ? "#00ff88" : "#666666";
|
|
||||||
icon = `<polygon points="12,2 10,20 12,18 14,20" fill="${color}" stroke="#ffffff" stroke-width="2"/>
|
|
||||||
<polygon points="5,10 12,8 12,12" fill="${color}" stroke="#ffffff" stroke-width="1"/>
|
|
||||||
<polygon points="19,10 12,8 12,12" fill="${color}" stroke="#ffffff" stroke-width="1"/>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" style="transform: rotate(${rotation}deg); filter: drop-shadow(0 0 3px rgba(0,0,0,0.8));">
|
|
||||||
${icon}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarkerRotation(marker, track, aircraft) {
|
|
||||||
if (track !== undefined) {
|
|
||||||
const hasPosition = aircraft.lat && aircraft.lon;
|
|
||||||
const size = hasPosition ? 24 : 18;
|
|
||||||
|
|
||||||
const icon = L.divIcon({
|
|
||||||
html: this.getAircraftIcon(aircraft),
|
|
||||||
className: 'aircraft-marker',
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size/2, size/2]
|
|
||||||
});
|
|
||||||
marker.setIcon(icon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createPopupContent(aircraft) {
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
|
|
||||||
const altitudeM = aircraft.alt_baro ? Math.round(aircraft.alt_baro * 0.3048) : 'N/A';
|
|
||||||
const speedKmh = aircraft.gs ? Math.round(aircraft.gs * 1.852) : 'N/A';
|
|
||||||
const trackText = aircraft.track ? `${aircraft.track}° (${this.getTrackDirection(aircraft.track)})` : 'N/A';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="aircraft-popup">
|
|
||||||
<div class="popup-header">
|
|
||||||
<div class="flight-info">
|
|
||||||
<span class="icao-flag">${this.getCountryFlag(aircraft.country)}</span>
|
|
||||||
<span class="flight-id">${aircraft.hex}</span>
|
|
||||||
${aircraft.flight ? `→ <span class="callsign">${aircraft.flight}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="popup-details">
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Country of registration:</strong> ${aircraft.country || 'Unknown'}
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Registration:</strong> ${aircraft.registration || aircraft.hex}
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Type:</strong> ${type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-grid">
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Altitude:</div>
|
|
||||||
<div class="value">${aircraft.alt_baro ? `▲ ${aircraft.alt_baro} ft | ${altitudeM} m` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Squawk:</div>
|
|
||||||
<div class="value">${aircraft.squawk || 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Speed:</div>
|
|
||||||
<div class="value">${aircraft.gs ? `${aircraft.gs} kt | ${speedKmh} km/h` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">RSSI:</div>
|
|
||||||
<div class="value">${aircraft.rssi ? `${aircraft.rssi.toFixed(1)} dBFS` : 'N/A'}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Track:</div>
|
|
||||||
<div class="value">${trackText}</div>
|
|
||||||
</div>
|
|
||||||
<div class="detail-item">
|
|
||||||
<div class="label">Last seen:</div>
|
|
||||||
<div class="value">${aircraft.seen ? `${aircraft.seen.toFixed(1)}s ago` : 'now'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Position:</strong> ${aircraft.lat && aircraft.lon ?
|
|
||||||
`${aircraft.lat.toFixed(3)}°, ${aircraft.lon.toFixed(3)}°` : 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<strong>Distance from Site:</strong> ${distance ? `${distance} NM | ${distanceKm} km` : 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCountryFlag(country) {
|
|
||||||
const flags = {
|
|
||||||
'United States': '🇺🇸',
|
|
||||||
'United Kingdom': '🇬🇧',
|
|
||||||
'Germany': '🇩🇪',
|
|
||||||
'France': '🇫🇷',
|
|
||||||
'Netherlands': '🇳🇱',
|
|
||||||
'Sweden': '🇸🇪',
|
|
||||||
'Spain': '🇪🇸',
|
|
||||||
'Italy': '🇮🇹',
|
|
||||||
'Canada': '🇨🇦',
|
|
||||||
'Japan': '🇯🇵',
|
|
||||||
'Denmark': '🇩🇰',
|
|
||||||
'Austria': '🇦🇹',
|
|
||||||
'Belgium': '🇧🇪',
|
|
||||||
'Finland': '🇫🇮',
|
|
||||||
'Greece': '🇬🇷'
|
|
||||||
};
|
|
||||||
return flags[country] || '🏳️';
|
|
||||||
}
|
|
||||||
|
|
||||||
getTrackDirection(track) {
|
|
||||||
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
||||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
||||||
const index = Math.round(track / 22.5) % 16;
|
|
||||||
return directions[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePopupContent(marker, aircraft) {
|
|
||||||
marker.setPopupContent(this.createPopupContent(aircraft));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTrail(hex, pos) {
|
|
||||||
if (!this.aircraftTrails.has(hex)) {
|
|
||||||
this.aircraftTrails.set(hex, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trail = this.aircraftTrails.get(hex);
|
|
||||||
trail.push(pos);
|
|
||||||
|
|
||||||
if (trail.length > 50) {
|
|
||||||
trail.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
const polyline = L.polyline(trail, {
|
|
||||||
color: '#00a8ff',
|
|
||||||
weight: 2,
|
|
||||||
opacity: 0.6
|
|
||||||
}).addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTrails() {
|
|
||||||
this.showTrails = !this.showTrails;
|
|
||||||
|
|
||||||
if (!this.showTrails) {
|
|
||||||
this.aircraftTrails.clear();
|
|
||||||
this.map.eachLayer(layer => {
|
|
||||||
if (layer instanceof L.Polyline) {
|
|
||||||
this.map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('toggle-trails').textContent =
|
|
||||||
this.showTrails ? 'Hide Trails' : 'Show Trails';
|
|
||||||
}
|
|
||||||
|
|
||||||
centerMapOnAircraft() {
|
|
||||||
if (this.aircraftData.length === 0) return;
|
|
||||||
|
|
||||||
const validAircraft = this.aircraftData.filter(a => a.lat && a.lon);
|
|
||||||
if (validAircraft.length === 0) return;
|
|
||||||
|
|
||||||
const group = new L.featureGroup(
|
|
||||||
validAircraft.map(a => L.marker([a.lat, a.lon]))
|
|
||||||
);
|
|
||||||
|
|
||||||
this.map.fitBounds(group.getBounds().pad(0.1));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftTable() {
|
|
||||||
const tbody = document.getElementById('aircraft-tbody');
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
let filteredData = [...this.aircraftData];
|
|
||||||
|
|
||||||
const searchTerm = document.getElementById('search-input').value.toLowerCase();
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredData = filteredData.filter(aircraft =>
|
|
||||||
(aircraft.flight && aircraft.flight.toLowerCase().includes(searchTerm)) ||
|
|
||||||
aircraft.hex.toLowerCase().includes(searchTerm) ||
|
|
||||||
(aircraft.squawk && aircraft.squawk.includes(searchTerm))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortBy = document.getElementById('sort-select').value;
|
|
||||||
this.sortAircraft(filteredData, sortBy);
|
|
||||||
|
|
||||||
filteredData.forEach(aircraft => {
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const country = aircraft.country || 'Unknown';
|
|
||||||
const countryFlag = this.getCountryFlag(country);
|
|
||||||
const age = aircraft.seen ? aircraft.seen.toFixed(0) : '0';
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
const distanceStr = distance ? distance.toFixed(1) : '-';
|
|
||||||
const altitudeStr = aircraft.alt_baro ?
|
|
||||||
(aircraft.alt_baro >= 0 ? `▲ ${aircraft.alt_baro}` : `▼ ${Math.abs(aircraft.alt_baro)}`) :
|
|
||||||
'-';
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
// Color code RSSI values
|
|
||||||
let rssiStr = '-';
|
|
||||||
let rssiClass = '';
|
|
||||||
if (aircraft.rssi) {
|
|
||||||
const rssi = aircraft.rssi;
|
|
||||||
rssiStr = rssi.toFixed(1);
|
|
||||||
if (rssi > -10) rssiClass = 'rssi-strong';
|
|
||||||
else if (rssi > -20) rssiClass = 'rssi-good';
|
|
||||||
else if (rssi > -30) rssiClass = 'rssi-weak';
|
|
||||||
else rssiClass = 'rssi-poor';
|
|
||||||
}
|
|
||||||
|
|
||||||
row.innerHTML = `
|
|
||||||
<td><span class="type-badge ${type}">${aircraft.hex}</span></td>
|
|
||||||
<td>${aircraft.flight || '-'}</td>
|
|
||||||
<td>${aircraft.squawk || '-'}</td>
|
|
||||||
<td>${altitudeStr}</td>
|
|
||||||
<td>${aircraft.gs || '-'}</td>
|
|
||||||
<td>${distanceStr}</td>
|
|
||||||
<td>${aircraft.track || '-'}°</td>
|
|
||||||
<td>${aircraft.messages || '-'}</td>
|
|
||||||
<td>${age}</td>
|
|
||||||
<td><span class="${rssiClass}">${rssiStr}</span></td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
if (aircraft.lat && aircraft.lon) {
|
|
||||||
this.switchView('map');
|
|
||||||
document.getElementById('map-view-btn').classList.add('active');
|
|
||||||
document.querySelectorAll('.view-btn:not(#map-view-btn)').forEach(btn =>
|
|
||||||
btn.classList.remove('active'));
|
|
||||||
document.querySelectorAll('.view:not(#map-view)').forEach(view =>
|
|
||||||
view.classList.remove('active'));
|
|
||||||
document.getElementById('map-view').classList.add('active');
|
|
||||||
|
|
||||||
this.map.setView([aircraft.lat, aircraft.lon], 12);
|
|
||||||
|
|
||||||
const marker = this.aircraftMarkers.get(aircraft.hex);
|
|
||||||
if (marker) {
|
|
||||||
marker.openPopup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
filterAircraftTable() {
|
|
||||||
this.updateAircraftTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
sortAircraftTable() {
|
|
||||||
this.updateAircraftTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
sortAircraft(aircraft, sortBy) {
|
|
||||||
aircraft.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'distance':
|
|
||||||
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
||||||
case 'altitude':
|
|
||||||
return (b.alt_baro || 0) - (a.alt_baro || 0);
|
|
||||||
case 'speed':
|
|
||||||
return (b.gs || 0) - (a.gs || 0);
|
|
||||||
case 'flight':
|
|
||||||
return (a.flight || a.hex).localeCompare(b.flight || b.hex);
|
|
||||||
case 'icao':
|
|
||||||
return a.hex.localeCompare(b.hex);
|
|
||||||
case 'squawk':
|
|
||||||
return (a.squawk || '').localeCompare(b.squawk || '');
|
|
||||||
case 'age':
|
|
||||||
return (a.seen || 0) - (b.seen || 0);
|
|
||||||
case 'rssi':
|
|
||||||
return (b.rssi || -999) - (a.rssi || -999);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateDistance(aircraft) {
|
|
||||||
if (!aircraft.lat || !aircraft.lon) return null;
|
|
||||||
|
|
||||||
const centerLat = this.origin.latitude;
|
|
||||||
const centerLng = this.origin.longitude;
|
|
||||||
|
|
||||||
const R = 3440.065;
|
|
||||||
const dLat = (aircraft.lat - centerLat) * Math.PI / 180;
|
|
||||||
const dLon = (aircraft.lon - centerLng) * Math.PI / 180;
|
|
||||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
||||||
Math.cos(centerLat * Math.PI / 180) * Math.cos(aircraft.lat * Math.PI / 180) *
|
|
||||||
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
||||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
||||||
return R * c; // Return a number, not a string
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats() {
|
|
||||||
document.getElementById('total-aircraft').textContent = this.aircraftData.length;
|
|
||||||
|
|
||||||
const withPosition = this.aircraftData.filter(a => a.lat && a.lon).length;
|
|
||||||
const avgAltitude = this.aircraftData
|
|
||||||
.filter(a => a.alt_baro)
|
|
||||||
.reduce((sum, a) => sum + a.alt_baro, 0) / this.aircraftData.length || 0;
|
|
||||||
|
|
||||||
const distances = this.aircraftData
|
|
||||||
.map(a => this.calculateDistance(a))
|
|
||||||
.filter(d => d !== null);
|
|
||||||
const maxDistance = distances.length > 0 ? Math.max(...distances) : 0;
|
|
||||||
|
|
||||||
document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} nm`;
|
|
||||||
|
|
||||||
this.updateChartData();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateChartData() {
|
|
||||||
const now = new Date();
|
|
||||||
const timeLabel = now.toLocaleTimeString();
|
|
||||||
|
|
||||||
if (this.charts.aircraft) {
|
|
||||||
const chart = this.charts.aircraft;
|
|
||||||
chart.data.labels.push(timeLabel);
|
|
||||||
chart.data.datasets[0].data.push(this.aircraftData.length);
|
|
||||||
|
|
||||||
if (chart.data.labels.length > 20) {
|
|
||||||
chart.data.labels.shift();
|
|
||||||
chart.data.datasets[0].data.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.update('none');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.charts.messages) {
|
|
||||||
const chart = this.charts.messages;
|
|
||||||
const totalMessages = this.aircraftData.reduce((sum, a) => sum + (a.messages || 0), 0);
|
|
||||||
const messagesPerSec = totalMessages / 60;
|
|
||||||
|
|
||||||
chart.data.labels.push(timeLabel);
|
|
||||||
chart.data.datasets[0].data.push(messagesPerSec);
|
|
||||||
|
|
||||||
if (chart.data.labels.length > 20) {
|
|
||||||
chart.data.labels.shift();
|
|
||||||
chart.data.datasets[0].data.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.update('none');
|
|
||||||
|
|
||||||
document.getElementById('messages-sec').textContent = Math.round(messagesPerSec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startPeriodicUpdates() {
|
|
||||||
setInterval(() => {
|
|
||||||
if (this.websocket.readyState !== WebSocket.OPEN) {
|
|
||||||
this.fetchAircraftData();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
this.fetchStatsData();
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchAircraftData() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/aircraft');
|
|
||||||
const data = await response.json();
|
|
||||||
this.updateAircraftData(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch aircraft data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchStatsData() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/stats');
|
|
||||||
const stats = await response.json();
|
|
||||||
|
|
||||||
if (stats.total && stats.total.messages) {
|
|
||||||
const messagesPerSec = stats.total.messages.last1min / 60;
|
|
||||||
document.getElementById('messages-sec').textContent = Math.round(messagesPerSec);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate average RSSI from aircraft data
|
|
||||||
const aircraftWithRSSI = this.aircraftData.filter(a => a.rssi);
|
|
||||||
if (aircraftWithRSSI.length > 0) {
|
|
||||||
const avgRSSI = aircraftWithRSSI.reduce((sum, a) => sum + a.rssi, 0) / aircraftWithRSSI.length;
|
|
||||||
document.getElementById('signal-strength').textContent = `${avgRSSI.toFixed(1)} dBFS`;
|
|
||||||
} else {
|
|
||||||
document.getElementById('signal-strength').textContent = '0 dBFS';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch stats:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleHistoricalTracks() {
|
|
||||||
this.showHistoricalTracks = !this.showHistoricalTracks;
|
|
||||||
|
|
||||||
const btn = document.getElementById('toggle-history');
|
|
||||||
btn.textContent = this.showHistoricalTracks ? 'Hide History' : 'Show History';
|
|
||||||
|
|
||||||
if (!this.showHistoricalTracks) {
|
|
||||||
this.clearAllHistoricalTracks();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadAircraftHistory(hex) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/aircraft/${hex}/history`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.track_history && data.track_history.length > 1) {
|
|
||||||
this.displayHistoricalTrack(hex, data.track_history, data.flight);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load aircraft history:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
displayHistoricalTrack(hex, trackHistory, flight) {
|
|
||||||
this.clearHistoricalTrack(hex);
|
|
||||||
|
|
||||||
const points = trackHistory.map(point => [point.lat, point.lon]);
|
|
||||||
|
|
||||||
const polyline = L.polyline(points, {
|
|
||||||
color: '#ff6b6b',
|
|
||||||
weight: 3,
|
|
||||||
opacity: 0.8,
|
|
||||||
dashArray: '5, 5'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
polyline.bindPopup(`<b>Historical Track</b><br>${flight || hex}<br>${trackHistory.length} points`);
|
|
||||||
|
|
||||||
this.historicalTracks.set(hex, polyline);
|
|
||||||
|
|
||||||
// Add start/end markers
|
|
||||||
if (trackHistory.length > 0) {
|
|
||||||
const start = trackHistory[0];
|
|
||||||
const end = trackHistory[trackHistory.length - 1];
|
|
||||||
|
|
||||||
L.circleMarker([start.lat, start.lon], {
|
|
||||||
color: '#ffffff',
|
|
||||||
fillColor: '#00ff00',
|
|
||||||
fillOpacity: 0.8,
|
|
||||||
radius: 4
|
|
||||||
}).addTo(this.map).bindPopup(`<b>Start</b><br>${new Date(start.timestamp).toLocaleString()}`);
|
|
||||||
|
|
||||||
L.circleMarker([end.lat, end.lon], {
|
|
||||||
color: '#ffffff',
|
|
||||||
fillColor: '#ff0000',
|
|
||||||
fillOpacity: 0.8,
|
|
||||||
radius: 4
|
|
||||||
}).addTo(this.map).bindPopup(`<b>End</b><br>${new Date(end.timestamp).toLocaleString()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearHistoricalTrack(hex) {
|
|
||||||
if (this.historicalTracks.has(hex)) {
|
|
||||||
this.map.removeLayer(this.historicalTracks.get(hex));
|
|
||||||
this.historicalTracks.delete(hex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAllHistoricalTracks() {
|
|
||||||
this.historicalTracks.forEach(track => {
|
|
||||||
this.map.removeLayer(track);
|
|
||||||
});
|
|
||||||
this.historicalTracks.clear();
|
|
||||||
|
|
||||||
// Also remove start/end markers
|
|
||||||
this.map.eachLayer(layer => {
|
|
||||||
if (layer instanceof L.CircleMarker) {
|
|
||||||
this.map.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
new SkyView();
|
|
||||||
});
|
|
||||||