Compare commits
No commits in common. "50c27b62595eeb2016fdcebedc96ae50278ac8e0" and "c8562a4f0d4ae5633bf025df889f5f0e4a82c4d9" have entirely different histories.
50c27b6259
...
c8562a4f0d
4
.gitignore
vendored
|
|
@ -3,10 +3,6 @@ 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
|
|
@ -1,39 +0,0 @@
|
||||||
# SkyView Project Guidelines
|
|
||||||
|
|
||||||
## Documentation Requirements
|
|
||||||
- We should always have an up to date document describing our architecture and features
|
|
||||||
- Include links to any external resources we've used
|
|
||||||
- We should also always have an up to date README describing the project
|
|
||||||
- Shell scripts should be validated with shellcheck
|
|
||||||
- Always make sure the code is well documented with explanations for why and how a particular solution is selected
|
|
||||||
|
|
||||||
## Development Principles
|
|
||||||
- An overarching principle with all code is KISS, Keep It Simple Stupid
|
|
||||||
- We do not want to create code that is more complicated than necessary
|
|
||||||
- When changing code, always make sure to update any relevant tests
|
|
||||||
- Use proper error handling - aviation applications need reliability
|
|
||||||
|
|
||||||
## SkyView-Specific Guidelines
|
|
||||||
|
|
||||||
### Architecture & Design
|
|
||||||
- Multi-source ADS-B data fusion is the core feature - prioritize signal strength-based conflict resolution
|
|
||||||
- Embedded resources (SQLite ICAO database, static assets) over external dependencies
|
|
||||||
- Low-latency performance is critical - optimize for fast WebSocket updates
|
|
||||||
- Support concurrent aircraft tracking (100+ aircraft should work smoothly)
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- Keep Go packages focused: beast parsing, modes decoding, merger, server, clients
|
|
||||||
- Frontend should be modular: separate managers for aircraft, map, UI, websockets
|
|
||||||
- Database operations should be fast (use indexes, avoid N+1 queries)
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
- Beast binary parsing must handle high message rates (1000+ msg/sec per source)
|
|
||||||
- WebSocket broadcasting should not block on slow clients
|
|
||||||
- Memory usage should be bounded (configurable history limits)
|
|
||||||
- CPU usage should remain low during normal operation
|
|
||||||
|
|
||||||
### Documentation Maintenance
|
|
||||||
- Always update docs/ARCHITECTURE.md when changing system design
|
|
||||||
- README.md should stay current with features and usage
|
|
||||||
- External resources (ICAO docs, ADS-B standards) should be linked in documentation
|
|
||||||
- Country database updates should be straightforward (replace SQLite file)
|
|
||||||
21
LICENSE
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 SkyView Contributors
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
64
Makefile
|
|
@ -1,26 +1,12 @@
|
||||||
PACKAGE_NAME=skyview
|
BINARY_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 build-all clean run dev test lint deb deb-clean install-deps
|
.PHONY: build clean run dev test lint
|
||||||
|
|
||||||
# Build main skyview binary
|
|
||||||
build:
|
build:
|
||||||
@echo "Building skyview..."
|
@echo "Building $(BINARY_NAME)..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/skyview ./cmd/skyview
|
go build -ldflags="-w -s" -o $(BUILD_DIR)/$(BINARY_NAME) .
|
||||||
|
|
||||||
# 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..."
|
||||||
|
|
@ -33,7 +19,7 @@ run: build
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
@echo "Running in development mode..."
|
@echo "Running in development mode..."
|
||||||
go run ./cmd/skyview
|
go run main.go
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@echo "Running tests..."
|
@echo "Running tests..."
|
||||||
|
|
@ -47,33 +33,6 @@ 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 .
|
||||||
|
|
@ -82,21 +41,8 @@ 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,268 +1,112 @@
|
||||||
# SkyView - Multi-Source ADS-B Aircraft Tracker
|
# SkyView - ADS-B Aircraft Tracker
|
||||||
|
|
||||||
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.
|
A modern web frontend for dump1090 ADS-B data with real-time aircraft tracking, statistics, and mobile-responsive design.
|
||||||
|
|
||||||
## ✨ Features
|
## Features
|
||||||
|
|
||||||
### Multi-Source Data Fusion
|
- **Real-time Aircraft Tracking**: Live map with aircraft positions and flight paths
|
||||||
- **Beast Binary Format**: Native support for dump1090 Beast format (port 30005)
|
- **Interactive Map**: Leaflet-based map with aircraft markers and optional trails
|
||||||
- **Multiple Receivers**: Connect to unlimited dump1090 sources simultaneously
|
- **Aircraft Table**: Sortable and filterable table view with detailed aircraft information
|
||||||
- **Intelligent Merging**: Smart data fusion with signal strength-based source selection
|
- **Statistics Dashboard**: Real-time statistics and charts for signal strength, aircraft counts
|
||||||
- **High-throughput Processing**: High-performance concurrent message processing
|
- **WebSocket Updates**: Real-time data updates without polling
|
||||||
|
|
||||||
### 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
|
||||||
- **Multi-view Dashboard**: Map, Table, Statistics, Coverage, and 3D Radar views
|
- **Single Binary**: Embedded static files for easy deployment
|
||||||
|
|
||||||
### Professional Visualization
|
## Configuration
|
||||||
- **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)* 🚧
|
|
||||||
|
|
||||||
### Aircraft Data
|
### Environment Variables
|
||||||
- **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
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
- `SKYVIEW_ADDRESS`: Server listen address (default: ":8080")
|
||||||
|
- `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
|
||||||
|
|
||||||
### Using Command Line
|
### Configuration File
|
||||||
|
|
||||||
```bash
|
SkyView automatically loads `config.json` from the current directory, or you can specify a path with `SKYVIEW_CONFIG`.
|
||||||
# Single source
|
|
||||||
./skyview -sources "primary:Local:localhost:30005:51.47:-0.46"
|
|
||||||
|
|
||||||
# Multiple sources
|
Create a `config.json` file (see `config.json.example`):
|
||||||
./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": {
|
||||||
"host": "",
|
"address": ":8080",
|
||||||
"port": 8080
|
"port": 8080
|
||||||
},
|
},
|
||||||
"sources": [
|
"dump1090": {
|
||||||
{
|
"host": "192.168.1.100",
|
||||||
"id": "primary",
|
"data_port": 30003
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command Line Options
|
### Data Source
|
||||||
|
|
||||||
|
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
|
||||||
skyview [options]
|
go build -o skyview .
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🗺️ Web Interface
|
### Run
|
||||||
|
|
||||||
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
|
||||||
make build # Build binary
|
# Foreground (default) - Press Ctrl+C to stop
|
||||||
make deb # Create Debian package
|
DUMP1090_HOST=192.168.1.100 ./skyview
|
||||||
make docker-build # Build Docker image
|
|
||||||
make test # Run tests
|
# Daemon mode (background process)
|
||||||
make clean # Clean artifacts
|
DUMP1090_HOST=192.168.1.100 ./skyview -daemon
|
||||||
|
|
||||||
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Docker
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build
|
go run main.go
|
||||||
make docker-build
|
|
||||||
|
|
||||||
# Run
|
|
||||||
docker run -p 8080:8080 -v $(pwd)/config.json:/app/config.json skyview
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 API Reference
|
## Usage
|
||||||
|
|
||||||
### REST Endpoints
|
1. Start your dump1090 instance
|
||||||
- `GET /api/aircraft` - All aircraft data
|
2. Configure SkyView to point to your dump1090 host
|
||||||
- `GET /api/aircraft/{icao}` - Individual aircraft details
|
3. Run SkyView
|
||||||
- `GET /api/sources` - Data source information
|
4. Open your browser to `http://localhost:8080`
|
||||||
- `GET /api/stats` - System statistics
|
|
||||||
- `GET /api/origin` - Map origin configuration
|
|
||||||
- `GET /api/coverage/{sourceId}` - Coverage analysis
|
|
||||||
- `GET /api/heatmap/{sourceId}` - Signal heatmap
|
|
||||||
|
|
||||||
### WebSocket
|
## API Endpoints
|
||||||
- `ws://localhost:8080/ws` - Low-latency updates
|
|
||||||
|
|
||||||
## 🛠️ Development
|
- `GET /`: Main web interface
|
||||||
|
- `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
|
||||||
|
|
||||||
### Project Structure
|
## Data Sources
|
||||||
```
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Development Commands
|
SkyView connects to dump1090's **SBS-1/BaseStation format** via TCP port 30003 to receive decoded aircraft data in real-time.
|
||||||
```bash
|
|
||||||
make dev # Run in development mode
|
|
||||||
make format # Format code
|
|
||||||
make lint # Run linter
|
|
||||||
make check # Run all checks
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📦 Deployment
|
The application maintains an in-memory aircraft database with automatic cleanup of stale aircraft (older than 2 minutes).
|
||||||
|
|
||||||
### Systemd Service (Debian/Ubuntu)
|
## Views
|
||||||
```bash
|
|
||||||
# Install package
|
|
||||||
sudo dpkg -i skyview_0.0.2_amd64.deb
|
|
||||||
|
|
||||||
# Configure sources in /etc/skyview/config.json
|
- **Map View**: Interactive map with aircraft positions and trails
|
||||||
# Start service
|
- **Table View**: Sortable table with aircraft details and search
|
||||||
sudo systemctl start skyview
|
- **Stats View**: Dashboard with real-time statistics and charts
|
||||||
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.
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// 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
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Wide-body cargo aircraft -->
|
|
||||||
<!-- 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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- 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>
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -1,27 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- Small general aviation aircraft (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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,24 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- 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>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -1,29 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- 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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -1,27 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
<!-- 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>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,265 +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 - 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>
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
// 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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,527 +0,0 @@
|
||||||
// Aircraft marker and data management module
|
|
||||||
export class AircraftManager {
|
|
||||||
constructor(map) {
|
|
||||||
this.map = map;
|
|
||||||
this.aircraftData = new Map();
|
|
||||||
this.aircraftMarkers = new Map();
|
|
||||||
this.aircraftTrails = new Map();
|
|
||||||
this.showTrails = false;
|
|
||||||
|
|
||||||
// Debug: Track marker lifecycle
|
|
||||||
this.markerCreateCount = 0;
|
|
||||||
this.markerUpdateCount = 0;
|
|
||||||
this.markerRemoveCount = 0;
|
|
||||||
|
|
||||||
// SVG icon cache
|
|
||||||
this.iconCache = new Map();
|
|
||||||
this.loadIcons();
|
|
||||||
|
|
||||||
// Selected aircraft trail tracking
|
|
||||||
this.selectedAircraftCallback = null;
|
|
||||||
|
|
||||||
// Map event listeners removed - let Leaflet handle positioning naturally
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadIcons() {
|
|
||||||
const iconTypes = ['commercial', 'helicopter', 'military', 'cargo', 'ga', 'ground'];
|
|
||||||
|
|
||||||
for (const type of iconTypes) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/static/icons/${type}.svg`);
|
|
||||||
const svgText = await response.text();
|
|
||||||
this.iconCache.set(type, svgText);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to load icon for ${type}:`, error);
|
|
||||||
// Fallback to inline SVG if needed
|
|
||||||
this.iconCache.set(type, this.createFallbackIcon(type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createFallbackIcon(type) {
|
|
||||||
// Fallback inline SVG if file loading fails
|
|
||||||
const color = 'currentColor';
|
|
||||||
let path = '';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'helicopter':
|
|
||||||
path = `<circle cx="0" cy="0" r="10" fill="none" stroke="${color}" stroke-width="1" opacity="0.3"/>
|
|
||||||
<path d="M0,-8 L-6,6 L-1,6 L0,8 L1,6 L6,6 Z" fill="${color}"/>
|
|
||||||
<path d="M0,-6 L0,-10" stroke="${color}" stroke-width="2"/>
|
|
||||||
<path d="M0,6 L0,8" stroke="${color}" stroke-width="2"/>`;
|
|
||||||
break;
|
|
||||||
case 'military':
|
|
||||||
path = `<path d="M0,-12 L-4,2 L-8,8 L-2,6 L0,12 L2,6 L8,8 L4,2 Z" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'cargo':
|
|
||||||
path = `<path d="M0,-12 L-10,8 L-3,8 L0,12 L3,8 L10,8 Z" fill="${color}"/>
|
|
||||||
<rect x="-2" y="-6" width="4" height="8" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'ga':
|
|
||||||
path = `<path d="M0,-10 L-5,6 L-1,6 L0,10 L1,6 L5,6 Z" fill="${color}"/>`;
|
|
||||||
break;
|
|
||||||
case 'ground':
|
|
||||||
path = `<rect x="-6" y="-4" width="12" height="8" fill="${color}" rx="2"/>
|
|
||||||
<circle cx="-3" cy="2" r="2" fill="#333"/>
|
|
||||||
<circle cx="3" cy="2" r="2" fill="#333"/>`;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
path = `<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z" fill="${color}"/>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g transform="translate(16,16)">
|
|
||||||
${path}
|
|
||||||
</g>
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftData(data) {
|
|
||||||
if (data.aircraft) {
|
|
||||||
this.aircraftData.clear();
|
|
||||||
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
||||||
this.aircraftData.set(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMarkers() {
|
|
||||||
if (!this.map) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stale aircraft markers
|
|
||||||
const currentICAOs = new Set(this.aircraftData.keys());
|
|
||||||
for (const [icao, marker] of this.aircraftMarkers) {
|
|
||||||
if (!currentICAOs.has(icao)) {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
this.aircraftMarkers.delete(icao);
|
|
||||||
|
|
||||||
// Remove trail if it exists
|
|
||||||
if (this.aircraftTrails.has(icao)) {
|
|
||||||
const trail = this.aircraftTrails.get(icao);
|
|
||||||
if (trail.polyline) {
|
|
||||||
this.map.removeLayer(trail.polyline);
|
|
||||||
}
|
|
||||||
this.aircraftTrails.delete(icao);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify if this was the selected aircraft
|
|
||||||
if (this.selectedAircraftCallback && this.selectedAircraftCallback(icao)) {
|
|
||||||
// Aircraft was selected and disappeared - could notify main app
|
|
||||||
// For now, the callback will return false automatically since selectedAircraft will be cleared
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markerRemoveCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update aircraft markers - only for aircraft with valid geographic coordinates
|
|
||||||
for (const [icao, aircraft] of this.aircraftData) {
|
|
||||||
const hasCoords = aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0;
|
|
||||||
const validLat = aircraft.Latitude >= -90 && aircraft.Latitude <= 90;
|
|
||||||
const validLng = aircraft.Longitude >= -180 && aircraft.Longitude <= 180;
|
|
||||||
|
|
||||||
if (hasCoords && validLat && validLng) {
|
|
||||||
this.updateAircraftMarker(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftMarker(icao, aircraft) {
|
|
||||||
const pos = [aircraft.Latitude, aircraft.Longitude];
|
|
||||||
|
|
||||||
|
|
||||||
// Check for invalid coordinates - proper geographic bounds
|
|
||||||
const isValidLat = pos[0] >= -90 && pos[0] <= 90;
|
|
||||||
const isValidLng = pos[1] >= -180 && pos[1] <= 180;
|
|
||||||
|
|
||||||
if (!isValidLat || !isValidLng || isNaN(pos[0]) || isNaN(pos[1])) {
|
|
||||||
console.error(`🚨 Invalid coordinates for ${icao}: [${pos[0]}, ${pos[1]}] (lat must be -90 to +90, lng must be -180 to +180)`);
|
|
||||||
return; // Don't create/update marker with invalid coordinates
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.aircraftMarkers.has(icao)) {
|
|
||||||
// Update existing marker - KISS approach
|
|
||||||
const marker = this.aircraftMarkers.get(icao);
|
|
||||||
|
|
||||||
// Always update position - let Leaflet handle everything
|
|
||||||
const oldPos = marker.getLatLng();
|
|
||||||
marker.setLatLng(pos);
|
|
||||||
|
|
||||||
// Check if icon needs to be updated (track rotation, aircraft type, or ground status changes)
|
|
||||||
const currentRotation = marker._currentRotation || 0;
|
|
||||||
const currentType = marker._currentType || null;
|
|
||||||
const currentOnGround = marker._currentOnGround || false;
|
|
||||||
|
|
||||||
const newType = this.getAircraftIconType(aircraft);
|
|
||||||
const rotationChanged = aircraft.Track !== undefined && Math.abs(currentRotation - aircraft.Track) > 5;
|
|
||||||
const typeChanged = currentType !== newType;
|
|
||||||
const groundStatusChanged = currentOnGround !== aircraft.OnGround;
|
|
||||||
|
|
||||||
if (rotationChanged || typeChanged || groundStatusChanged) {
|
|
||||||
marker.setIcon(this.createAircraftIcon(aircraft));
|
|
||||||
marker._currentRotation = aircraft.Track || 0;
|
|
||||||
marker._currentType = newType;
|
|
||||||
marker._currentOnGround = aircraft.OnGround || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle popup exactly like Leaflet expects
|
|
||||||
if (marker.isPopupOpen()) {
|
|
||||||
marker.setPopupContent(this.createPopupContent(aircraft));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.markerUpdateCount++;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Create new marker
|
|
||||||
const icon = this.createAircraftIcon(aircraft);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const marker = L.marker(pos, {
|
|
||||||
icon: icon
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
// Store current properties for future update comparisons
|
|
||||||
marker._currentRotation = aircraft.Track || 0;
|
|
||||||
marker._currentType = this.getAircraftIconType(aircraft);
|
|
||||||
marker._currentOnGround = aircraft.OnGround || false;
|
|
||||||
|
|
||||||
marker.bindPopup(this.createPopupContent(aircraft), {
|
|
||||||
maxWidth: 450,
|
|
||||||
className: 'aircraft-popup'
|
|
||||||
});
|
|
||||||
|
|
||||||
this.aircraftMarkers.set(icao, marker);
|
|
||||||
this.markerCreateCount++;
|
|
||||||
|
|
||||||
// Force immediate visibility
|
|
||||||
if (marker._icon) {
|
|
||||||
marker._icon.style.display = 'block';
|
|
||||||
marker._icon.style.opacity = '1';
|
|
||||||
marker._icon.style.visibility = 'visible';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to create marker for ${icao}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update trails - check both global trails and individual selected aircraft
|
|
||||||
if (this.showTrails || this.isSelectedAircraftTrailEnabled(icao)) {
|
|
||||||
this.updateAircraftTrail(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createAircraftIcon(aircraft) {
|
|
||||||
const iconType = this.getAircraftIconType(aircraft);
|
|
||||||
const color = this.getAircraftColor(iconType, 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,378 +0,0 @@
|
||||||
// Map and visualization management module
|
|
||||||
export class MapManager {
|
|
||||||
constructor() {
|
|
||||||
this.map = null;
|
|
||||||
this.coverageMap = null;
|
|
||||||
this.mapOrigin = null;
|
|
||||||
|
|
||||||
// Source markers and overlays
|
|
||||||
this.sourceMarkers = new Map();
|
|
||||||
this.rangeCircles = new Map();
|
|
||||||
this.showSources = true;
|
|
||||||
this.showRange = false;
|
|
||||||
this.selectedSource = null;
|
|
||||||
this.heatmapLayer = null;
|
|
||||||
|
|
||||||
// Data references
|
|
||||||
this.sourcesData = new Map();
|
|
||||||
|
|
||||||
// Map theme
|
|
||||||
this.isDarkMode = false;
|
|
||||||
this.currentTileLayer = null;
|
|
||||||
this.coverageTileLayer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeMap() {
|
|
||||||
// Get origin from server
|
|
||||||
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/origin');
|
|
||||||
if (response.ok) {
|
|
||||||
origin = await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not fetch origin, using default:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store origin for reset functionality
|
|
||||||
this.mapOrigin = origin;
|
|
||||||
|
|
||||||
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
|
|
||||||
|
|
||||||
// Light tile layer by default
|
|
||||||
this.currentTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
// Add scale control for distance estimation
|
|
||||||
L.control.scale({
|
|
||||||
metric: true,
|
|
||||||
imperial: true,
|
|
||||||
position: 'bottomright'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
return this.map;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initializeCoverageMap() {
|
|
||||||
if (!this.coverageMap) {
|
|
||||||
// Get origin from server
|
|
||||||
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/origin');
|
|
||||||
if (response.ok) {
|
|
||||||
origin = await response.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not fetch origin for coverage map, using default:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
|
|
||||||
|
|
||||||
this.coverageTileLayer = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
|
|
||||||
// Add scale control for distance estimation
|
|
||||||
L.control.scale({
|
|
||||||
metric: true,
|
|
||||||
imperial: true,
|
|
||||||
position: 'bottomright'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.coverageMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourcesData(data) {
|
|
||||||
if (data.sources) {
|
|
||||||
this.sourcesData.clear();
|
|
||||||
data.sources.forEach(source => {
|
|
||||||
this.sourcesData.set(source.id, source);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourceMarkers() {
|
|
||||||
if (!this.map || !this.showSources) return;
|
|
||||||
|
|
||||||
// Remove markers for sources that no longer exist
|
|
||||||
const currentSourceIds = new Set(this.sourcesData.keys());
|
|
||||||
for (const [id, marker] of this.sourceMarkers) {
|
|
||||||
if (!currentSourceIds.has(id)) {
|
|
||||||
this.map.removeLayer(marker);
|
|
||||||
this.sourceMarkers.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or create markers for current sources
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
if (source.latitude && source.longitude) {
|
|
||||||
if (this.sourceMarkers.has(id)) {
|
|
||||||
// Update existing marker
|
|
||||||
const marker = this.sourceMarkers.get(id);
|
|
||||||
|
|
||||||
// Update marker style if status changed
|
|
||||||
marker.setStyle({
|
|
||||||
radius: source.active ? 10 : 6,
|
|
||||||
fillColor: source.active ? '#00d4ff' : '#666666',
|
|
||||||
fillOpacity: 0.8
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update popup content if it's open
|
|
||||||
if (marker.isPopupOpen()) {
|
|
||||||
marker.setPopupContent(this.createSourcePopupContent(source));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create new marker
|
|
||||||
const marker = L.circleMarker([source.latitude, source.longitude], {
|
|
||||||
radius: source.active ? 10 : 6,
|
|
||||||
fillColor: source.active ? '#00d4ff' : '#666666',
|
|
||||||
color: '#ffffff',
|
|
||||||
weight: 2,
|
|
||||||
fillOpacity: 0.8,
|
|
||||||
className: 'source-marker'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
marker.bindPopup(this.createSourcePopupContent(source), {
|
|
||||||
maxWidth: 300
|
|
||||||
});
|
|
||||||
|
|
||||||
this.sourceMarkers.set(id, marker);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateSourcesLegend();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateRangeCircles() {
|
|
||||||
if (!this.map || !this.showRange) return;
|
|
||||||
|
|
||||||
// Clear existing circles
|
|
||||||
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
|
||||||
this.rangeCircles.clear();
|
|
||||||
|
|
||||||
// Add range circles for active sources
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
if (source.active && source.latitude && source.longitude) {
|
|
||||||
// Add multiple range circles (50km, 100km, 200km)
|
|
||||||
const ranges = [50000, 100000, 200000];
|
|
||||||
ranges.forEach((range, index) => {
|
|
||||||
const circle = L.circle([source.latitude, source.longitude], {
|
|
||||||
radius: range,
|
|
||||||
fillColor: 'transparent',
|
|
||||||
color: '#00d4ff',
|
|
||||||
weight: 2,
|
|
||||||
opacity: 0.7 - (index * 0.15),
|
|
||||||
dashArray: '8,4'
|
|
||||||
}).addTo(this.map);
|
|
||||||
|
|
||||||
this.rangeCircles.set(`${id}_${range}`, circle);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createSourcePopupContent(source, aircraftData) {
|
|
||||||
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
|
|
||||||
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="source-popup">
|
|
||||||
<h3>${source.name}</h3>
|
|
||||||
<p><strong>ID:</strong> ${source.id}</p>
|
|
||||||
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
|
|
||||||
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
|
|
||||||
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
|
|
||||||
<p><strong>Messages:</strong> ${source.messages || 0}</p>
|
|
||||||
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourcesLegend() {
|
|
||||||
const legend = document.getElementById('sources-legend');
|
|
||||||
if (!legend) return;
|
|
||||||
|
|
||||||
legend.innerHTML = '';
|
|
||||||
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'legend-item';
|
|
||||||
item.innerHTML = `
|
|
||||||
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
|
|
||||||
<span title="${source.host}:${source.port}">${source.name}</span>
|
|
||||||
`;
|
|
||||||
legend.appendChild(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetMap() {
|
|
||||||
if (this.mapOrigin && this.map) {
|
|
||||||
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRangeCircles() {
|
|
||||||
this.showRange = !this.showRange;
|
|
||||||
|
|
||||||
if (this.showRange) {
|
|
||||||
this.updateRangeCircles();
|
|
||||||
} else {
|
|
||||||
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
|
|
||||||
this.rangeCircles.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.showRange;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSources() {
|
|
||||||
this.showSources = !this.showSources;
|
|
||||||
|
|
||||||
if (this.showSources) {
|
|
||||||
this.updateSourceMarkers();
|
|
||||||
} else {
|
|
||||||
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
|
|
||||||
this.sourceMarkers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.showSources;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleDarkMode() {
|
|
||||||
this.isDarkMode = !this.isDarkMode;
|
|
||||||
|
|
||||||
const lightUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
|
||||||
const darkUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
|
||||||
const tileUrl = this.isDarkMode ? darkUrl : lightUrl;
|
|
||||||
|
|
||||||
const tileOptions = {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
|
||||||
subdomains: 'abcd',
|
|
||||||
maxZoom: 19
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update main map
|
|
||||||
if (this.map && this.currentTileLayer) {
|
|
||||||
this.map.removeLayer(this.currentTileLayer);
|
|
||||||
this.currentTileLayer = L.tileLayer(tileUrl, tileOptions).addTo(this.map);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update coverage map
|
|
||||||
if (this.coverageMap && this.coverageTileLayer) {
|
|
||||||
this.coverageMap.removeLayer(this.coverageTileLayer);
|
|
||||||
this.coverageTileLayer = L.tileLayer(tileUrl, {
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.isDarkMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Coverage map methods
|
|
||||||
updateCoverageControls() {
|
|
||||||
const select = document.getElementById('coverage-source');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
select.innerHTML = '<option value="">Select Source</option>';
|
|
||||||
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = id;
|
|
||||||
option.textContent = source.name;
|
|
||||||
select.appendChild(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCoverageDisplay() {
|
|
||||||
if (!this.selectedSource || !this.coverageMap) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/coverage/${this.selectedSource}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Clear existing coverage markers
|
|
||||||
this.coverageMap.eachLayer(layer => {
|
|
||||||
if (layer instanceof L.CircleMarker) {
|
|
||||||
this.coverageMap.removeLayer(layer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add coverage points
|
|
||||||
data.points.forEach(point => {
|
|
||||||
const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
|
|
||||||
L.circleMarker([point.lat, point.lon], {
|
|
||||||
radius: 3,
|
|
||||||
fillColor: this.getSignalColor(point.signal),
|
|
||||||
color: 'white',
|
|
||||||
weight: 1,
|
|
||||||
fillOpacity: intensity
|
|
||||||
}).addTo(this.coverageMap);
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load coverage data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggleHeatmap() {
|
|
||||||
if (!this.selectedSource) {
|
|
||||||
alert('Please select a source first');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.heatmapLayer) {
|
|
||||||
this.coverageMap.removeLayer(this.heatmapLayer);
|
|
||||||
this.heatmapLayer = null;
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Create heatmap layer (simplified)
|
|
||||||
this.createHeatmapOverlay(data);
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load heatmap data:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignalColor(signal) {
|
|
||||||
if (signal > -10) return '#00ff88';
|
|
||||||
if (signal > -20) return '#ffff00';
|
|
||||||
if (signal > -30) return '#ff8c00';
|
|
||||||
return '#ff4444';
|
|
||||||
}
|
|
||||||
|
|
||||||
createHeatmapOverlay(data) {
|
|
||||||
// 🚧 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
// UI and table management module
|
|
||||||
export class UIManager {
|
|
||||||
constructor() {
|
|
||||||
this.aircraftData = new Map();
|
|
||||||
this.sourcesData = new Map();
|
|
||||||
this.stats = {};
|
|
||||||
this.currentView = 'map-view';
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeViews() {
|
|
||||||
const viewButtons = document.querySelectorAll('.view-btn');
|
|
||||||
const views = document.querySelectorAll('.view');
|
|
||||||
|
|
||||||
viewButtons.forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const viewId = btn.id.replace('-btn', '');
|
|
||||||
this.switchView(viewId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switchView(viewId) {
|
|
||||||
// Update buttons
|
|
||||||
document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
|
|
||||||
const activeBtn = document.getElementById(`${viewId}-btn`);
|
|
||||||
if (activeBtn) {
|
|
||||||
activeBtn.classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update views (viewId already includes the full view ID like "map-view")
|
|
||||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
|
|
||||||
const activeView = document.getElementById(viewId);
|
|
||||||
if (activeView) {
|
|
||||||
activeView.classList.add('active');
|
|
||||||
} else {
|
|
||||||
console.warn(`View element not found: ${viewId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentView = viewId;
|
|
||||||
return viewId;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateData(data) {
|
|
||||||
// Update aircraft data
|
|
||||||
if (data.aircraft) {
|
|
||||||
this.aircraftData.clear();
|
|
||||||
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
||||||
this.aircraftData.set(icao, aircraft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update sources data
|
|
||||||
if (data.sources) {
|
|
||||||
this.sourcesData.clear();
|
|
||||||
data.sources.forEach(source => {
|
|
||||||
this.sourcesData.set(source.id, source);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update statistics
|
|
||||||
if (data.stats) {
|
|
||||||
this.stats = data.stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastUpdateTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAircraftTable() {
|
|
||||||
// Note: This table shows ALL aircraft we're tracking, including those without
|
|
||||||
// position data. Aircraft without positions will show "No position" in the
|
|
||||||
// location column but still provide useful info like callsign, altitude, etc.
|
|
||||||
const tbody = document.getElementById('aircraft-tbody');
|
|
||||||
if (!tbody) return;
|
|
||||||
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
|
|
||||||
let filteredData = Array.from(this.aircraftData.values());
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
const searchTerm = document.getElementById('search-input')?.value.toLowerCase() || '';
|
|
||||||
const sourceFilter = document.getElementById('source-filter')?.value || '';
|
|
||||||
|
|
||||||
if (searchTerm) {
|
|
||||||
filteredData = filteredData.filter(aircraft =>
|
|
||||||
(aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(aircraft.ICAO24 && aircraft.ICAO24.toLowerCase().includes(searchTerm)) ||
|
|
||||||
(aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sourceFilter) {
|
|
||||||
filteredData = filteredData.filter(aircraft =>
|
|
||||||
aircraft.sources && aircraft.sources[sourceFilter]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort data
|
|
||||||
const sortBy = document.getElementById('sort-select')?.value || 'distance';
|
|
||||||
this.sortAircraft(filteredData, sortBy);
|
|
||||||
|
|
||||||
// Populate table
|
|
||||||
filteredData.forEach(aircraft => {
|
|
||||||
const row = this.createTableRow(aircraft);
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update source filter options
|
|
||||||
this.updateSourceFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
createTableRow(aircraft) {
|
|
||||||
const type = this.getAircraftType(aircraft);
|
|
||||||
const icao = aircraft.ICAO24 || 'N/A';
|
|
||||||
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
|
|
||||||
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
|
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
|
||||||
row.innerHTML = `
|
|
||||||
<td><span class="type-badge ${type}">${icao}</span></td>
|
|
||||||
<td>${aircraft.Callsign || '-'}</td>
|
|
||||||
<td>${aircraft.Squawk || '-'}</td>
|
|
||||||
<td>${altitude ? `${altitude} ft` : '-'}</td>
|
|
||||||
<td>${aircraft.GroundSpeed || '-'} kt</td>
|
|
||||||
<td>${distance ? distance.toFixed(1) : '-'} km</td>
|
|
||||||
<td>${aircraft.Track || '-'}°</td>
|
|
||||||
<td>${sources}</td>
|
|
||||||
<td><span class="${this.getSignalClass(bestSignal)}">${bestSignal ? bestSignal.toFixed(1) : '-'}</span></td>
|
|
||||||
<td>${aircraft.Age ? aircraft.Age.toFixed(0) : '0'}s</td>
|
|
||||||
`;
|
|
||||||
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
if (aircraft.Latitude && aircraft.Longitude) {
|
|
||||||
// Trigger event to switch to map and focus on aircraft
|
|
||||||
const event = new CustomEvent('aircraftSelected', {
|
|
||||||
detail: { icao, aircraft }
|
|
||||||
});
|
|
||||||
document.dispatchEvent(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAircraftType(aircraft) {
|
|
||||||
if (aircraft.OnGround) return 'ground';
|
|
||||||
if (aircraft.Category) {
|
|
||||||
const cat = aircraft.Category.toLowerCase();
|
|
||||||
if (cat.includes('military')) return 'military';
|
|
||||||
if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
|
|
||||||
if (cat.includes('light') || cat.includes('glider')) return 'ga';
|
|
||||||
}
|
|
||||||
if (aircraft.Callsign) {
|
|
||||||
const cs = aircraft.Callsign.toLowerCase();
|
|
||||||
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
|
|
||||||
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
|
|
||||||
}
|
|
||||||
return 'commercial';
|
|
||||||
}
|
|
||||||
|
|
||||||
getBestSignalFromSources(sources) {
|
|
||||||
if (!sources) return null;
|
|
||||||
let bestSignal = -999;
|
|
||||||
for (const [id, data] of Object.entries(sources)) {
|
|
||||||
if (data.signal_level > bestSignal) {
|
|
||||||
bestSignal = data.signal_level;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestSignal === -999 ? null : bestSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignalClass(signal) {
|
|
||||||
if (!signal) return '';
|
|
||||||
if (signal > -10) return 'signal-strong';
|
|
||||||
if (signal > -20) return 'signal-good';
|
|
||||||
if (signal > -30) return 'signal-weak';
|
|
||||||
return 'signal-poor';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSourceFilter() {
|
|
||||||
const select = document.getElementById('source-filter');
|
|
||||||
if (!select) return;
|
|
||||||
|
|
||||||
const currentValue = select.value;
|
|
||||||
|
|
||||||
// Clear options except "All Sources"
|
|
||||||
select.innerHTML = '<option value="">All Sources</option>';
|
|
||||||
|
|
||||||
// Add source options
|
|
||||||
for (const [id, source] of this.sourcesData) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = id;
|
|
||||||
option.textContent = source.name;
|
|
||||||
if (id === currentValue) option.selected = true;
|
|
||||||
select.appendChild(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sortAircraft(aircraft, sortBy) {
|
|
||||||
aircraft.sort((a, b) => {
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'distance':
|
|
||||||
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
||||||
case 'altitude':
|
|
||||||
return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
|
|
||||||
case 'speed':
|
|
||||||
return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
|
|
||||||
case 'flight':
|
|
||||||
return (a.Callsign || a.ICAO24 || '').localeCompare(b.Callsign || b.ICAO24 || '');
|
|
||||||
case 'icao':
|
|
||||||
return (a.ICAO24 || '').localeCompare(b.ICAO24 || '');
|
|
||||||
case 'squawk':
|
|
||||||
return (a.Squawk || '').localeCompare(b.Squawk || '');
|
|
||||||
case 'signal':
|
|
||||||
return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999);
|
|
||||||
case 'age':
|
|
||||||
return (a.Age || 0) - (b.Age || 0);
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
calculateDistance(aircraft) {
|
|
||||||
if (!aircraft.Latitude || !aircraft.Longitude) return null;
|
|
||||||
|
|
||||||
// Use closest source as reference point
|
|
||||||
let minDistance = Infinity;
|
|
||||||
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
|
|
||||||
if (srcData.distance && srcData.distance < minDistance) {
|
|
||||||
minDistance = srcData.distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return minDistance === Infinity ? null : minDistance;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatistics() {
|
|
||||||
const totalAircraftEl = document.getElementById('total-aircraft');
|
|
||||||
const activeSourcesEl = document.getElementById('active-sources');
|
|
||||||
const maxRangeEl = document.getElementById('max-range');
|
|
||||||
const messagesSecEl = document.getElementById('messages-sec');
|
|
||||||
|
|
||||||
if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size;
|
|
||||||
if (activeSourcesEl) {
|
|
||||||
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate max range
|
|
||||||
let maxDistance = 0;
|
|
||||||
for (const aircraft of this.aircraftData.values()) {
|
|
||||||
const distance = this.calculateDistance(aircraft);
|
|
||||||
if (distance && distance > maxDistance) {
|
|
||||||
maxDistance = distance;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (maxRangeEl) maxRangeEl.textContent = `${maxDistance.toFixed(1)} km`;
|
|
||||||
|
|
||||||
// Update message rate
|
|
||||||
const totalMessages = this.stats.total_messages || 0;
|
|
||||||
if (messagesSecEl) messagesSecEl.textContent = Math.round(totalMessages / 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHeaderInfo() {
|
|
||||||
const aircraftCountEl = document.getElementById('aircraft-count');
|
|
||||||
const sourcesCountEl = document.getElementById('sources-count');
|
|
||||||
|
|
||||||
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
|
|
||||||
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
|
|
||||||
|
|
||||||
this.updateClocks();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateConnectionStatus(status) {
|
|
||||||
const statusEl = document.getElementById('connection-status');
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.className = `connection-status ${status}`;
|
|
||||||
statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeEventListeners() {
|
|
||||||
const searchInput = document.getElementById('search-input');
|
|
||||||
const sortSelect = document.getElementById('sort-select');
|
|
||||||
const sourceFilter = document.getElementById('source-filter');
|
|
||||||
|
|
||||||
if (searchInput) searchInput.addEventListener('input', () => this.updateAircraftTable());
|
|
||||||
if (sortSelect) sortSelect.addEventListener('change', () => this.updateAircraftTable());
|
|
||||||
if (sourceFilter) sourceFilter.addEventListener('change', () => this.updateAircraftTable());
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClocks() {
|
|
||||||
const now = new Date();
|
|
||||||
const utcNow = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
|
|
||||||
|
|
||||||
this.updateClock('utc', utcNow);
|
|
||||||
this.updateClock('update', this.lastUpdateTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateClock(prefix, time) {
|
|
||||||
const hours = time.getUTCHours();
|
|
||||||
const minutes = time.getUTCMinutes();
|
|
||||||
|
|
||||||
const hourAngle = (hours % 12) * 30 + minutes * 0.5;
|
|
||||||
const minuteAngle = minutes * 6;
|
|
||||||
|
|
||||||
const hourHand = document.getElementById(`${prefix}-hour`);
|
|
||||||
const minuteHand = document.getElementById(`${prefix}-minute`);
|
|
||||||
|
|
||||||
if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`;
|
|
||||||
if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
console.error(message);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
// WebSocket communication module
|
|
||||||
export class WebSocketManager {
|
|
||||||
constructor(onMessage, onStatusChange) {
|
|
||||||
this.websocket = null;
|
|
||||||
this.onMessage = onMessage;
|
|
||||||
this.onStatusChange = onStatusChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.websocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
|
||||||
this.onStatusChange('connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onclose = () => {
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
// Reconnect after 5 seconds
|
|
||||||
setTimeout(() => this.connect(), 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
};
|
|
||||||
|
|
||||||
this.websocket.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(event.data);
|
|
||||||
|
|
||||||
|
|
||||||
this.onMessage(message);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('WebSocket connection failed:', error);
|
|
||||||
this.onStatusChange('disconnected');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
if (this.websocket) {
|
|
||||||
this.websocket.close();
|
|
||||||
this.websocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,442 +0,0 @@
|
||||||
// Package main provides a utility for parsing and displaying Beast format ADS-B data.
|
|
||||||
//
|
|
||||||
// beast-dump can read from TCP sockets (dump1090 streams) or files containing
|
|
||||||
// Beast binary data, decode Mode S/ADS-B messages, and display the results
|
|
||||||
// in human-readable format on the console.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
//
|
|
||||||
// beast-dump -tcp host:port # Read from TCP socket
|
|
||||||
// beast-dump -file path/to/file # Read from file
|
|
||||||
// beast-dump -verbose # Show detailed message parsing
|
|
||||||
//
|
|
||||||
// Examples:
|
|
||||||
//
|
|
||||||
// beast-dump -tcp svovel:30005 # Connect to dump1090 Beast stream
|
|
||||||
// beast-dump -file beast.test # Parse Beast data from file
|
|
||||||
// beast-dump -tcp localhost:30005 -verbose # Verbose TCP parsing
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"skyview/internal/beast"
|
|
||||||
"skyview/internal/modes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config holds command-line configuration
|
|
||||||
type Config struct {
|
|
||||||
TCPAddress string // TCP address for Beast stream (e.g., "localhost:30005")
|
|
||||||
FilePath string // File path for Beast data
|
|
||||||
Verbose bool // Enable verbose output
|
|
||||||
Count int // Maximum messages to process (0 = unlimited)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeastDumper handles Beast data parsing and console output
|
|
||||||
type BeastDumper struct {
|
|
||||||
config *Config
|
|
||||||
parser *beast.Parser
|
|
||||||
decoder *modes.Decoder
|
|
||||||
stats struct {
|
|
||||||
totalMessages int64
|
|
||||||
validMessages int64
|
|
||||||
aircraftSeen map[uint32]bool
|
|
||||||
startTime time.Time
|
|
||||||
lastMessageTime time.Time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
config := parseFlags()
|
|
||||||
|
|
||||||
if config.TCPAddress == "" && config.FilePath == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: Must specify either -tcp or -file\n")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.TCPAddress != "" && config.FilePath != "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: Cannot specify both -tcp and -file\n")
|
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
dumper := NewBeastDumper(config)
|
|
||||||
|
|
||||||
if err := dumper.Run(); err != nil {
|
|
||||||
log.Fatalf("Error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFlags parses command-line flags and returns configuration
|
|
||||||
func parseFlags() *Config {
|
|
||||||
config := &Config{}
|
|
||||||
|
|
||||||
flag.StringVar(&config.TCPAddress, "tcp", "", "TCP address for Beast stream (e.g., localhost:30005)")
|
|
||||||
flag.StringVar(&config.FilePath, "file", "", "File path for Beast data")
|
|
||||||
flag.BoolVar(&config.Verbose, "verbose", false, "Enable verbose output")
|
|
||||||
flag.IntVar(&config.Count, "count", 0, "Maximum messages to process (0 = unlimited)")
|
|
||||||
|
|
||||||
flag.Usage = func() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, "\nBeast format ADS-B data parser and console dumper\n\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Options:\n")
|
|
||||||
flag.PrintDefaults()
|
|
||||||
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -tcp svovel:30005\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -file beast.test\n", os.Args[0])
|
|
||||||
fmt.Fprintf(os.Stderr, " %s -tcp localhost:30005 -verbose -count 100\n", os.Args[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
flag.Parse()
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBeastDumper creates a new Beast data dumper
|
|
||||||
func NewBeastDumper(config *Config) *BeastDumper {
|
|
||||||
return &BeastDumper{
|
|
||||||
config: config,
|
|
||||||
decoder: modes.NewDecoder(0.0, 0.0), // beast-dump doesn't have reference position, use default
|
|
||||||
stats: struct {
|
|
||||||
totalMessages int64
|
|
||||||
validMessages int64
|
|
||||||
aircraftSeen map[uint32]bool
|
|
||||||
startTime time.Time
|
|
||||||
lastMessageTime time.Time
|
|
||||||
}{
|
|
||||||
aircraftSeen: make(map[uint32]bool),
|
|
||||||
startTime: time.Now(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the Beast data processing
|
|
||||||
func (d *BeastDumper) Run() error {
|
|
||||||
fmt.Printf("Beast Data Dumper\n")
|
|
||||||
fmt.Printf("=================\n\n")
|
|
||||||
|
|
||||||
var reader io.Reader
|
|
||||||
var closer io.Closer
|
|
||||||
|
|
||||||
if d.config.TCPAddress != "" {
|
|
||||||
conn, err := d.connectTCP()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("TCP connection failed: %w", err)
|
|
||||||
}
|
|
||||||
reader = conn
|
|
||||||
closer = conn
|
|
||||||
fmt.Printf("Connected to: %s\n", d.config.TCPAddress)
|
|
||||||
} else {
|
|
||||||
file, err := d.openFile()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("file open failed: %w", err)
|
|
||||||
}
|
|
||||||
reader = file
|
|
||||||
closer = file
|
|
||||||
fmt.Printf("Reading file: %s\n", d.config.FilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer closer.Close()
|
|
||||||
|
|
||||||
// Create Beast parser
|
|
||||||
d.parser = beast.NewParser(reader, "beast-dump")
|
|
||||||
|
|
||||||
fmt.Printf("Verbose mode: %t\n", d.config.Verbose)
|
|
||||||
if d.config.Count > 0 {
|
|
||||||
fmt.Printf("Message limit: %d\n", d.config.Count)
|
|
||||||
}
|
|
||||||
fmt.Printf("\nStarting Beast data parsing...\n")
|
|
||||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6s %s\n",
|
|
||||||
"Time", "ICAO", "Type", "Signal", "Data", "Len", "Decoded")
|
|
||||||
fmt.Printf("%s\n",
|
|
||||||
"------------------------------------------------------------------------")
|
|
||||||
|
|
||||||
return d.parseMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
// connectTCP establishes TCP connection to Beast stream
|
|
||||||
func (d *BeastDumper) connectTCP() (net.Conn, error) {
|
|
||||||
fmt.Printf("Connecting to %s...\n", d.config.TCPAddress)
|
|
||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", d.config.TCPAddress, 10*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFile opens Beast data file
|
|
||||||
func (d *BeastDumper) openFile() (*os.File, error) {
|
|
||||||
file, err := os.Open(d.config.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("File size: %d bytes\n", stat.Size())
|
|
||||||
return file, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseMessages processes Beast messages and outputs decoded data
|
|
||||||
func (d *BeastDumper) parseMessages() error {
|
|
||||||
for {
|
|
||||||
// Check message count limit
|
|
||||||
if d.config.Count > 0 && d.stats.totalMessages >= int64(d.config.Count) {
|
|
||||||
fmt.Printf("\nReached message limit of %d\n", d.config.Count)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse Beast message
|
|
||||||
msg, err := d.parser.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF {
|
|
||||||
fmt.Printf("\nEnd of data reached\n")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if d.config.Verbose {
|
|
||||||
fmt.Printf("Parse error: %v\n", err)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
d.stats.totalMessages++
|
|
||||||
d.stats.lastMessageTime = time.Now()
|
|
||||||
|
|
||||||
// Display Beast message info
|
|
||||||
d.displayMessage(msg)
|
|
||||||
|
|
||||||
// Decode Mode S data if available
|
|
||||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
|
||||||
d.decodeAndDisplay(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.stats.validMessages++
|
|
||||||
}
|
|
||||||
|
|
||||||
d.displayStatistics()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayMessage shows basic Beast message information
|
|
||||||
func (d *BeastDumper) displayMessage(msg *beast.Message) {
|
|
||||||
timestamp := msg.ReceivedAt.Format("15:04:05")
|
|
||||||
|
|
||||||
// Extract ICAO if available
|
|
||||||
icao := "------"
|
|
||||||
if msg.Type == beast.BeastModeS || msg.Type == beast.BeastModeSLong {
|
|
||||||
if icaoAddr, err := msg.GetICAO24(); err == nil {
|
|
||||||
icao = fmt.Sprintf("%06X", icaoAddr)
|
|
||||||
d.stats.aircraftSeen[icaoAddr] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Beast message type
|
|
||||||
typeStr := d.formatMessageType(msg.Type)
|
|
||||||
|
|
||||||
// Signal strength
|
|
||||||
signal := msg.GetSignalStrength()
|
|
||||||
signalStr := fmt.Sprintf("%6.1f", signal)
|
|
||||||
|
|
||||||
// Data preview
|
|
||||||
dataStr := d.formatDataPreview(msg.Data)
|
|
||||||
|
|
||||||
fmt.Printf("%-8s %-6s %-12s %-8s %-10s %-6d ",
|
|
||||||
timestamp, icao, typeStr, signalStr, dataStr, len(msg.Data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeAndDisplay attempts to decode Mode S message and display results
|
|
||||||
func (d *BeastDumper) decodeAndDisplay(msg *beast.Message) {
|
|
||||||
aircraft, err := d.decoder.Decode(msg.Data)
|
|
||||||
if err != nil {
|
|
||||||
if d.config.Verbose {
|
|
||||||
fmt.Printf("Decode error: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("(decode failed)\n")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display decoded information
|
|
||||||
info := d.formatAircraftInfo(aircraft)
|
|
||||||
fmt.Printf("%s\n", info)
|
|
||||||
|
|
||||||
// Verbose details
|
|
||||||
if d.config.Verbose {
|
|
||||||
d.displayVerboseInfo(aircraft, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatMessageType converts Beast message type to string
|
|
||||||
func (d *BeastDumper) formatMessageType(msgType uint8) string {
|
|
||||||
switch msgType {
|
|
||||||
case beast.BeastModeAC:
|
|
||||||
return "Mode A/C"
|
|
||||||
case beast.BeastModeS:
|
|
||||||
return "Mode S"
|
|
||||||
case beast.BeastModeSLong:
|
|
||||||
return "Mode S Long"
|
|
||||||
case beast.BeastStatusMsg:
|
|
||||||
return "Status"
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("Type %02X", msgType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDataPreview creates a hex preview of message data
|
|
||||||
func (d *BeastDumper) formatDataPreview(data []byte) string {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
preview := ""
|
|
||||||
for i, b := range data {
|
|
||||||
if i >= 4 { // Show first 4 bytes
|
|
||||||
break
|
|
||||||
}
|
|
||||||
preview += fmt.Sprintf("%02X", b)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(data) > 4 {
|
|
||||||
preview += "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
return preview
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatAircraftInfo creates a summary of decoded aircraft information
|
|
||||||
func (d *BeastDumper) formatAircraftInfo(aircraft *modes.Aircraft) string {
|
|
||||||
parts := []string{}
|
|
||||||
|
|
||||||
// Callsign
|
|
||||||
if aircraft.Callsign != "" {
|
|
||||||
parts = append(parts, fmt.Sprintf("CS:%s", aircraft.Callsign))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position
|
|
||||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("POS:%.4f,%.4f", aircraft.Latitude, aircraft.Longitude))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Altitude
|
|
||||||
if aircraft.Altitude != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("ALT:%dft", aircraft.Altitude))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speed and track
|
|
||||||
if aircraft.GroundSpeed != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("SPD:%dkt", aircraft.GroundSpeed))
|
|
||||||
}
|
|
||||||
if aircraft.Track != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("HDG:%d°", aircraft.Track))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vertical rate
|
|
||||||
if aircraft.VerticalRate != 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("VS:%d", aircraft.VerticalRate))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Squawk
|
|
||||||
if aircraft.Squawk != "" {
|
|
||||||
parts = append(parts, fmt.Sprintf("SQ:%s", aircraft.Squawk))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emergency
|
|
||||||
if aircraft.Emergency != "" && aircraft.Emergency != "None" {
|
|
||||||
parts = append(parts, fmt.Sprintf("EMG:%s", aircraft.Emergency))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "(no data decoded)"
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ""
|
|
||||||
for i, part := range parts {
|
|
||||||
if i > 0 {
|
|
||||||
info += " "
|
|
||||||
}
|
|
||||||
info += part
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayVerboseInfo shows detailed aircraft information
|
|
||||||
func (d *BeastDumper) displayVerboseInfo(aircraft *modes.Aircraft, msg *beast.Message) {
|
|
||||||
fmt.Printf(" Message Details:\n")
|
|
||||||
fmt.Printf(" Raw Data: %s\n", d.formatHexData(msg.Data))
|
|
||||||
fmt.Printf(" Timestamp: %s\n", msg.ReceivedAt.Format("15:04:05.000"))
|
|
||||||
fmt.Printf(" Signal: %.2f dBFS\n", msg.GetSignalStrength())
|
|
||||||
|
|
||||||
fmt.Printf(" Aircraft Data:\n")
|
|
||||||
if aircraft.Callsign != "" {
|
|
||||||
fmt.Printf(" Callsign: %s\n", aircraft.Callsign)
|
|
||||||
}
|
|
||||||
if aircraft.Latitude != 0 || aircraft.Longitude != 0 {
|
|
||||||
fmt.Printf(" Position: %.6f, %.6f\n", aircraft.Latitude, aircraft.Longitude)
|
|
||||||
}
|
|
||||||
if aircraft.Altitude != 0 {
|
|
||||||
fmt.Printf(" Altitude: %d ft\n", aircraft.Altitude)
|
|
||||||
}
|
|
||||||
if aircraft.GroundSpeed != 0 || aircraft.Track != 0 {
|
|
||||||
fmt.Printf(" Speed/Track: %d kt @ %d°\n", aircraft.GroundSpeed, aircraft.Track)
|
|
||||||
}
|
|
||||||
if aircraft.VerticalRate != 0 {
|
|
||||||
fmt.Printf(" Vertical Rate: %d ft/min\n", aircraft.VerticalRate)
|
|
||||||
}
|
|
||||||
if aircraft.Squawk != "" {
|
|
||||||
fmt.Printf(" Squawk: %s\n", aircraft.Squawk)
|
|
||||||
}
|
|
||||||
if aircraft.Category != "" {
|
|
||||||
fmt.Printf(" Category: %s\n", aircraft.Category)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatHexData creates a formatted hex dump of data
|
|
||||||
func (d *BeastDumper) formatHexData(data []byte) string {
|
|
||||||
result := ""
|
|
||||||
for i, b := range data {
|
|
||||||
if i > 0 {
|
|
||||||
result += " "
|
|
||||||
}
|
|
||||||
result += fmt.Sprintf("%02X", b)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// displayStatistics shows final parsing statistics
|
|
||||||
func (d *BeastDumper) displayStatistics() {
|
|
||||||
duration := time.Since(d.stats.startTime)
|
|
||||||
|
|
||||||
fmt.Printf("\nStatistics:\n")
|
|
||||||
fmt.Printf("===========\n")
|
|
||||||
fmt.Printf("Total messages: %d\n", d.stats.totalMessages)
|
|
||||||
fmt.Printf("Valid messages: %d\n", d.stats.validMessages)
|
|
||||||
fmt.Printf("Unique aircraft: %d\n", len(d.stats.aircraftSeen))
|
|
||||||
fmt.Printf("Duration: %v\n", duration.Round(time.Second))
|
|
||||||
|
|
||||||
if d.stats.totalMessages > 0 && duration > 0 {
|
|
||||||
rate := float64(d.stats.totalMessages) / duration.Seconds()
|
|
||||||
fmt.Printf("Message rate: %.1f msg/sec\n", rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(d.stats.aircraftSeen) > 0 {
|
|
||||||
fmt.Printf("\nAircraft seen:\n")
|
|
||||||
for icao := range d.stats.aircraftSeen {
|
|
||||||
fmt.Printf(" %06X\n", icao)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
config.json.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"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
|
|
@ -1,23 +0,0 @@
|
||||||
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
|
|
@ -1,39 +0,0 @@
|
||||||
#!/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
|
|
@ -1,31 +0,0 @@
|
||||||
#!/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
|
|
@ -1,17 +0,0 @@
|
||||||
#!/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
|
|
@ -1,47 +0,0 @@
|
||||||
[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
|
|
@ -1,95 +0,0 @@
|
||||||
.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
|
|
@ -1,88 +0,0 @@
|
||||||
.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>
|
|
||||||
|
|
@ -1,290 +0,0 @@
|
||||||
# SkyView Architecture Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
SkyView is a high-performance, multi-source ADS-B aircraft tracking system built in Go with a modern JavaScript frontend. It connects to multiple dump1090 Beast format receivers, performs intelligent data fusion, and provides low-latency aircraft tracking through a responsive web interface.
|
|
||||||
|
|
||||||
## System Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
||||||
│ dump1090 │ │ dump1090 │ │ dump1090 │
|
|
||||||
│ Receiver 1 │ │ Receiver 2 │ │ Receiver N │
|
|
||||||
│ Port 30005 │ │ Port 30005 │ │ Port 30005 │
|
|
||||||
└─────────┬───────┘ └──────┬───────┘ └─────────┬───────┘
|
|
||||||
│ │ │
|
|
||||||
│ Beast Binary │ Beast Binary │ Beast Binary
|
|
||||||
│ TCP Stream │ TCP Stream │ TCP Stream
|
|
||||||
│ │ │
|
|
||||||
└───────────────────┼──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────▼──────────┐
|
|
||||||
│ SkyView Server │
|
|
||||||
│ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Beast Client │ │ ── Multi-source TCP clients
|
|
||||||
│ │ Manager │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Mode S/ADS-B │ │ ── Message parsing & decoding
|
|
||||||
│ │ Decoder │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ Data Merger │ │ ── Intelligent data fusion
|
|
||||||
│ │ & ICAO DB │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
│ ┌────────────────┐ │
|
|
||||||
│ │ HTTP/WebSocket │ │ ── Low-latency web interface
|
|
||||||
│ │ Server │ │
|
|
||||||
│ └────────────────┘ │
|
|
||||||
└─────────┬──────────┘
|
|
||||||
│
|
|
||||||
┌─────────▼──────────┐
|
|
||||||
│ Web Interface │
|
|
||||||
│ │
|
|
||||||
│ • Interactive Maps │
|
|
||||||
│ • Low-latency Updates│
|
|
||||||
│ • Aircraft Details │
|
|
||||||
│ • Coverage Analysis│
|
|
||||||
│ • 3D Visualization │
|
|
||||||
└────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Components
|
|
||||||
|
|
||||||
### 1. Beast Format Clients (`internal/client/`)
|
|
||||||
|
|
||||||
**Purpose**: Manages TCP connections to dump1090 receivers
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Concurrent connection handling for multiple sources
|
|
||||||
- Automatic reconnection with exponential backoff
|
|
||||||
- Beast binary format parsing
|
|
||||||
- Per-source connection monitoring and statistics
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `beast.go`: Main client implementation
|
|
||||||
|
|
||||||
### 2. Mode S/ADS-B Decoder (`internal/modes/`)
|
|
||||||
|
|
||||||
**Purpose**: Decodes raw Mode S and ADS-B messages into structured aircraft data
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- CPR (Compact Position Reporting) decoding with zone ambiguity resolution
|
|
||||||
- ADS-B message type parsing (position, velocity, identification)
|
|
||||||
- Aircraft category and type classification
|
|
||||||
- Signal quality assessment
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `decoder.go`: Core decoding logic
|
|
||||||
|
|
||||||
### 3. Data Merger (`internal/merger/`)
|
|
||||||
|
|
||||||
**Purpose**: Fuses aircraft data from multiple sources using intelligent conflict resolution
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Signal strength-based source selection
|
|
||||||
- High-performance data fusion and conflict resolution
|
|
||||||
- Aircraft state management and lifecycle tracking
|
|
||||||
- Historical data collection (position, altitude, speed, signal trails)
|
|
||||||
- Automatic stale aircraft cleanup
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `merger.go`: Multi-source data fusion engine
|
|
||||||
|
|
||||||
### 4. ICAO Country Database (`internal/icao/`)
|
|
||||||
|
|
||||||
**Purpose**: Provides comprehensive ICAO address to country mapping
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- Embedded SQLite database with 70+ allocations covering 40+ countries
|
|
||||||
- Based on official ICAO Document 8585
|
|
||||||
- Fast range-based lookups using database indexing
|
|
||||||
- Country names, ISO codes, and flag emojis
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `database.go`: SQLite database interface
|
|
||||||
- `icao.db`: Embedded SQLite database with ICAO allocations
|
|
||||||
|
|
||||||
### 5. HTTP/WebSocket Server (`internal/server/`)
|
|
||||||
|
|
||||||
**Purpose**: Serves web interface and provides low-latency data streaming
|
|
||||||
|
|
||||||
**Key Features**:
|
|
||||||
- RESTful API for aircraft and system data
|
|
||||||
- WebSocket connections for low-latency updates
|
|
||||||
- Static asset serving with embedded resources
|
|
||||||
- Coverage analysis and signal heatmaps
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `server.go`: HTTP server and WebSocket handler
|
|
||||||
|
|
||||||
### 6. Web Frontend (`assets/static/`)
|
|
||||||
|
|
||||||
**Purpose**: Interactive web interface for aircraft tracking and visualization
|
|
||||||
|
|
||||||
**Key Technologies**:
|
|
||||||
- **Leaflet.js**: Interactive maps and aircraft markers
|
|
||||||
- **Three.js**: 3D radar visualization
|
|
||||||
- **Chart.js**: Live statistics and charts
|
|
||||||
- **WebSockets**: Live data streaming
|
|
||||||
- **Responsive CSS**: Mobile-optimized interface
|
|
||||||
|
|
||||||
**Files**:
|
|
||||||
- `index.html`: Main web interface
|
|
||||||
- `js/app.js`: Main application orchestrator
|
|
||||||
- `js/modules/`: Modular JavaScript components
|
|
||||||
- `aircraft-manager.js`: Aircraft marker and trail management
|
|
||||||
- `map-manager.js`: Map controls and overlays
|
|
||||||
- `ui-manager.js`: User interface state management
|
|
||||||
- `websocket.js`: Low-latency data connections
|
|
||||||
- `css/style.css`: Responsive styling and themes
|
|
||||||
- `icons/`: SVG aircraft type icons
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### 1. Data Ingestion
|
|
||||||
1. **Beast Clients** connect to dump1090 receivers via TCP
|
|
||||||
2. **Beast Parser** processes binary message stream
|
|
||||||
3. **Mode S Decoder** converts raw messages to structured aircraft data
|
|
||||||
4. **Data Merger** receives aircraft updates with source attribution
|
|
||||||
|
|
||||||
### 2. Data Fusion
|
|
||||||
1. **Signal Analysis**: Compare signal strength across sources
|
|
||||||
2. **Conflict Resolution**: Select best data based on signal quality and recency
|
|
||||||
3. **State Management**: Update aircraft position, velocity, and metadata
|
|
||||||
4. **History Tracking**: Maintain trails for visualization
|
|
||||||
|
|
||||||
### 3. Country Lookup
|
|
||||||
1. **ICAO Extraction**: Extract 24-bit ICAO address from aircraft data
|
|
||||||
2. **Database Query**: Lookup country information in embedded SQLite database
|
|
||||||
3. **Data Enrichment**: Add country, country code, and flag to aircraft state
|
|
||||||
|
|
||||||
### 4. Data Distribution
|
|
||||||
1. **REST API**: Provide aircraft data via HTTP endpoints
|
|
||||||
2. **WebSocket Streaming**: Push low-latency updates to connected clients
|
|
||||||
3. **Frontend Processing**: Update maps, tables, and visualizations
|
|
||||||
4. **User Interface**: Display aircraft with country flags and details
|
|
||||||
|
|
||||||
## Configuration System
|
|
||||||
|
|
||||||
### Configuration Sources (Priority Order)
|
|
||||||
1. Command-line flags (highest priority)
|
|
||||||
2. Configuration file (JSON)
|
|
||||||
3. Default values (lowest priority)
|
|
||||||
|
|
||||||
### Configuration Structure
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"server": {
|
|
||||||
"host": "", // Bind address (empty = all interfaces)
|
|
||||||
"port": 8080 // HTTP server port
|
|
||||||
},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"id": "unique-id", // Source identifier
|
|
||||||
"name": "Display Name", // Human-readable name
|
|
||||||
"host": "hostname", // Receiver hostname/IP
|
|
||||||
"port": 30005, // Beast format port
|
|
||||||
"latitude": 51.4700, // Receiver location
|
|
||||||
"longitude": -0.4600,
|
|
||||||
"altitude": 50.0, // Meters above sea level
|
|
||||||
"enabled": true // Source enable/disable
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"settings": {
|
|
||||||
"history_limit": 500, // Max trail points per aircraft
|
|
||||||
"stale_timeout": 60, // Seconds before aircraft removed
|
|
||||||
"update_rate": 1 // WebSocket update frequency
|
|
||||||
},
|
|
||||||
"origin": {
|
|
||||||
"latitude": 51.4700, // Map center point
|
|
||||||
"longitude": -0.4600,
|
|
||||||
"name": "Origin Name"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Characteristics
|
|
||||||
|
|
||||||
### Concurrency Model
|
|
||||||
- **Goroutine per Source**: Each Beast client runs in separate goroutine
|
|
||||||
- **Mutex-Protected Merger**: Thread-safe aircraft state management
|
|
||||||
- **WebSocket Broadcasting**: Concurrent client update distribution
|
|
||||||
- **Non-blocking I/O**: Asynchronous network operations
|
|
||||||
|
|
||||||
### Memory Management
|
|
||||||
- **Bounded History**: Configurable limits on historical data storage
|
|
||||||
- **Automatic Cleanup**: Stale aircraft removal to prevent memory leaks
|
|
||||||
- **Efficient Data Structures**: Maps for O(1) aircraft lookups
|
|
||||||
- **Embedded Assets**: Static files bundled in binary
|
|
||||||
|
|
||||||
### Scalability
|
|
||||||
- **Multi-source Support**: Tested with 10+ concurrent receivers
|
|
||||||
- **High Message Throughput**: Handles 1000+ messages/second per source
|
|
||||||
- **Low-latency Updates**: Sub-second latency for aircraft updates
|
|
||||||
- **Responsive Web UI**: Optimized for 100+ concurrent aircraft
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
### Network Security
|
|
||||||
- **No Authentication Required**: Designed for trusted network environments
|
|
||||||
- **Local Network Operation**: Intended for private receiver networks
|
|
||||||
- **WebSocket Origin Checking**: Basic CORS protection
|
|
||||||
|
|
||||||
### System Security
|
|
||||||
- **Unprivileged Execution**: Runs as non-root user in production
|
|
||||||
- **Filesystem Isolation**: Minimal file system access required
|
|
||||||
- **Network Isolation**: Only requires outbound TCP to receivers
|
|
||||||
- **Systemd Hardening**: Security features enabled in service file
|
|
||||||
|
|
||||||
### Data Privacy
|
|
||||||
- **Public ADS-B Data**: Only processes publicly broadcast aircraft data
|
|
||||||
- **No Personal Information**: Aircraft tracking only, no passenger data
|
|
||||||
- **Local Processing**: No data transmitted to external services
|
|
||||||
- **Historical Limits**: Configurable data retention periods
|
|
||||||
|
|
||||||
## External Resources
|
|
||||||
|
|
||||||
### Official Standards
|
|
||||||
- **ICAO Document 8585**: Designators for Aircraft Operating Agencies
|
|
||||||
- **RTCA DO-260B**: ADS-B Message Formats and Protocols
|
|
||||||
- **ITU-R M.1371-5**: Technical characteristics for universal ADS-B
|
|
||||||
|
|
||||||
### Technology Dependencies
|
|
||||||
- **Go Language**: https://golang.org/
|
|
||||||
- **Leaflet.js**: https://leafletjs.com/ - Interactive maps
|
|
||||||
- **Three.js**: https://threejs.org/ - 3D visualization
|
|
||||||
- **Chart.js**: https://www.chartjs.org/ - Statistics charts
|
|
||||||
- **SQLite**: https://www.sqlite.org/ - ICAO country database
|
|
||||||
- **WebSocket Protocol**: RFC 6455
|
|
||||||
|
|
||||||
### ADS-B Ecosystem
|
|
||||||
- **dump1090**: https://github.com/antirez/dump1090 - SDR ADS-B decoder
|
|
||||||
- **Beast Binary Format**: Mode S data interchange format
|
|
||||||
- **FlightAware**: ADS-B network and data provider
|
|
||||||
- **OpenSky Network**: Research-oriented ADS-B network
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Code Organization
|
|
||||||
- **Package per Component**: Clear separation of concerns
|
|
||||||
- **Interface Abstractions**: Testable and mockable components
|
|
||||||
- **Error Handling**: Comprehensive error reporting and recovery
|
|
||||||
- **Documentation**: Extensive code comments and examples
|
|
||||||
|
|
||||||
### Testing Strategy
|
|
||||||
- **Unit Tests**: Component-level testing with mocks
|
|
||||||
- **Integration Tests**: End-to-end data flow validation
|
|
||||||
- **Performance Tests**: Load testing with simulated data
|
|
||||||
- **Manual Testing**: Real-world receiver validation
|
|
||||||
|
|
||||||
### Deployment Options
|
|
||||||
- **Standalone Binary**: Single executable with embedded assets
|
|
||||||
- **Debian Package**: Systemd service with configuration
|
|
||||||
- **Docker Container**: Containerized deployment option
|
|
||||||
- **Development Mode**: Hot-reload for frontend development
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**SkyView Architecture** - Designed for reliability, performance, and extensibility in multi-source ADS-B tracking applications.
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,411 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
267
internal/client/dump1090.go
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
internal/config/config.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,268 +0,0 @@
|
||||||
package icao
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Database handles ICAO address to country lookups
|
|
||||||
type Database struct {
|
|
||||||
allocations []ICAOAllocation
|
|
||||||
}
|
|
||||||
|
|
||||||
// ICAOAllocation represents an ICAO address range allocation
|
|
||||||
type ICAOAllocation struct {
|
|
||||||
StartAddr int64
|
|
||||||
EndAddr int64
|
|
||||||
Country string
|
|
||||||
CountryCode string
|
|
||||||
Flag string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountryInfo represents country information for an aircraft
|
|
||||||
type CountryInfo struct {
|
|
||||||
Country string `json:"country"`
|
|
||||||
CountryCode string `json:"country_code"`
|
|
||||||
Flag string `json:"flag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDatabase creates a new ICAO database with comprehensive allocation data
|
|
||||||
func NewDatabase() (*Database, error) {
|
|
||||||
allocations := getICAOAllocations()
|
|
||||||
|
|
||||||
// Sort allocations by start address for efficient binary search
|
|
||||||
sort.Slice(allocations, func(i, j int) bool {
|
|
||||||
return allocations[i].StartAddr < allocations[j].StartAddr
|
|
||||||
})
|
|
||||||
|
|
||||||
return &Database{allocations: allocations}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getICAOAllocations returns comprehensive ICAO allocation data based on official aerotransport.org table
|
|
||||||
func getICAOAllocations() []ICAOAllocation {
|
|
||||||
// ICAO allocations based on official ICAO 24-bit address allocation table
|
|
||||||
// Source: https://www.aerotransport.org/ (unofficial but comprehensive reference)
|
|
||||||
// Complete coverage of all allocated ICAO 24-bit addresses
|
|
||||||
return []ICAOAllocation{
|
|
||||||
// Africa
|
|
||||||
{0x004000, 0x0043FF, "Zimbabwe", "ZW", "🇿🇼", "Republic of Zimbabwe"},
|
|
||||||
{0x006000, 0x006FFF, "Mozambique", "MZ", "🇲🇿", "Republic of Mozambique"},
|
|
||||||
{0x008000, 0x00FFFF, "South Africa", "ZA", "🇿🇦", "Republic of South Africa"},
|
|
||||||
{0x010000, 0x017FFF, "Egypt", "EG", "🇪🇬", "Arab Republic of Egypt"},
|
|
||||||
{0x018000, 0x01FFFF, "Libya", "LY", "🇱🇾", "State of Libya"},
|
|
||||||
{0x020000, 0x027FFF, "Morocco", "MA", "🇲🇦", "Kingdom of Morocco"},
|
|
||||||
{0x028000, 0x02FFFF, "Tunisia", "TN", "🇹🇳", "Republic of Tunisia"},
|
|
||||||
{0x030000, 0x0303FF, "Botswana", "BW", "🇧🇼", "Republic of Botswana"},
|
|
||||||
{0x032000, 0x032FFF, "Burundi", "BI", "🇧🇮", "Republic of Burundi"},
|
|
||||||
{0x034000, 0x034FFF, "Cameroon", "CM", "🇨🇲", "Republic of Cameroon"},
|
|
||||||
{0x035000, 0x0353FF, "Comoros", "KM", "🇰🇲", "Union of the Comoros"},
|
|
||||||
{0x036000, 0x036FFF, "Congo", "CG", "🇨🇬", "Republic of the Congo"},
|
|
||||||
{0x038000, 0x038FFF, "Côte d'Ivoire", "CI", "🇨🇮", "Republic of Côte d'Ivoire"},
|
|
||||||
{0x03E000, 0x03EFFF, "Gabon", "GA", "🇬🇦", "Gabonese Republic"},
|
|
||||||
{0x040000, 0x040FFF, "Ethiopia", "ET", "🇪🇹", "Federal Democratic Republic of Ethiopia"},
|
|
||||||
{0x042000, 0x042FFF, "Equatorial Guinea", "GQ", "🇬🇶", "Republic of Equatorial Guinea"},
|
|
||||||
{0x044000, 0x044FFF, "Ghana", "GH", "🇬🇭", "Republic of Ghana"},
|
|
||||||
{0x046000, 0x046FFF, "Guinea", "GN", "🇬🇳", "Republic of Guinea"},
|
|
||||||
{0x048000, 0x0483FF, "Guinea-Bissau", "GW", "🇬🇼", "Republic of Guinea-Bissau"},
|
|
||||||
{0x04A000, 0x04A3FF, "Lesotho", "LS", "🇱🇸", "Kingdom of Lesotho"},
|
|
||||||
{0x04C000, 0x04CFFF, "Kenya", "KE", "🇰🇪", "Republic of Kenya"},
|
|
||||||
{0x050000, 0x050FFF, "Liberia", "LR", "🇱🇷", "Republic of Liberia"},
|
|
||||||
{0x054000, 0x054FFF, "Madagascar", "MG", "🇲🇬", "Republic of Madagascar"},
|
|
||||||
{0x058000, 0x058FFF, "Malawi", "MW", "🇲🇼", "Republic of Malawi"},
|
|
||||||
{0x05C000, 0x05CFFF, "Mali", "ML", "🇲🇱", "Republic of Mali"},
|
|
||||||
{0x05E000, 0x05E3FF, "Mauritania", "MR", "🇲🇷", "Islamic Republic of Mauritania"},
|
|
||||||
{0x060000, 0x0603FF, "Mauritius", "MU", "🇲🇺", "Republic of Mauritius"},
|
|
||||||
{0x062000, 0x062FFF, "Niger", "NE", "🇳🇪", "Republic of Niger"},
|
|
||||||
{0x064000, 0x064FFF, "Nigeria", "NG", "🇳🇬", "Federal Republic of Nigeria"},
|
|
||||||
{0x068000, 0x068FFF, "Uganda", "UG", "🇺🇬", "Republic of Uganda"},
|
|
||||||
{0x06C000, 0x06CFFF, "Central African Republic", "CF", "🇨🇫", "Central African Republic"},
|
|
||||||
{0x06E000, 0x06EFFF, "Rwanda", "RW", "🇷🇼", "Republic of Rwanda"},
|
|
||||||
{0x070000, 0x070FFF, "Senegal", "SN", "🇸🇳", "Republic of Senegal"},
|
|
||||||
{0x074000, 0x0743FF, "Seychelles", "SC", "🇸🇨", "Republic of Seychelles"},
|
|
||||||
{0x076000, 0x0763FF, "Sierra Leone", "SL", "🇸🇱", "Republic of Sierra Leone"},
|
|
||||||
{0x078000, 0x078FFF, "Somalia", "SO", "🇸🇴", "Federal Republic of Somalia"},
|
|
||||||
{0x07A000, 0x07A3FF, "Swaziland", "SZ", "🇸🇿", "Kingdom of Swaziland"},
|
|
||||||
{0x07C000, 0x07CFFF, "Sudan", "SD", "🇸🇩", "Republic of Sudan"},
|
|
||||||
{0x080000, 0x080FFF, "Tanzania", "TZ", "🇹🇿", "United Republic of Tanzania"},
|
|
||||||
{0x084000, 0x084FFF, "Chad", "TD", "🇹🇩", "Republic of Chad"},
|
|
||||||
{0x088000, 0x088FFF, "Togo", "TG", "🇹🇬", "Togolese Republic"},
|
|
||||||
{0x08A000, 0x08AFFF, "Zambia", "ZM", "🇿🇲", "Republic of Zambia"},
|
|
||||||
{0x08C000, 0x08CFFF, "D R Congo", "CD", "🇨🇩", "Democratic Republic of the Congo"},
|
|
||||||
{0x090000, 0x090FFF, "Angola", "AO", "🇦🇴", "Republic of Angola"},
|
|
||||||
{0x094000, 0x0943FF, "Benin", "BJ", "🇧🇯", "Republic of Benin"},
|
|
||||||
{0x096000, 0x0963FF, "Cape Verde", "CV", "🇨🇻", "Republic of Cape Verde"},
|
|
||||||
{0x098000, 0x0983FF, "Djibouti", "DJ", "🇩🇯", "Republic of Djibouti"},
|
|
||||||
{0x0A8000, 0x0A8FFF, "Bahamas", "BS", "🇧🇸", "Commonwealth of the Bahamas"},
|
|
||||||
{0x0AA000, 0x0AA3FF, "Barbados", "BB", "🇧🇧", "Barbados"},
|
|
||||||
{0x0AB000, 0x0AB3FF, "Belize", "BZ", "🇧🇿", "Belize"},
|
|
||||||
{0x0B0000, 0x0B0FFF, "Cuba", "CU", "🇨🇺", "Republic of Cuba"},
|
|
||||||
{0x0B2000, 0x0B2FFF, "El Salvador", "SV", "🇸🇻", "Republic of El Salvador"},
|
|
||||||
{0x0B8000, 0x0B8FFF, "Haiti", "HT", "🇭🇹", "Republic of Haiti"},
|
|
||||||
{0x0BA000, 0x0BAFFF, "Honduras", "HN", "🇭🇳", "Republic of Honduras"},
|
|
||||||
{0x0BC000, 0x0BC3FF, "St. Vincent + Grenadines", "VC", "🇻🇨", "Saint Vincent and the Grenadines"},
|
|
||||||
{0x0BE000, 0x0BEFFF, "Jamaica", "JM", "🇯🇲", "Jamaica"},
|
|
||||||
{0x0D0000, 0x0D7FFF, "Mexico", "MX", "🇲🇽", "United Mexican States"},
|
|
||||||
|
|
||||||
// Eastern Europe & Russia
|
|
||||||
{0x100000, 0x1FFFFF, "Russia", "RU", "🇷🇺", "Russian Federation"},
|
|
||||||
{0x201000, 0x2013FF, "Namibia", "NA", "🇳🇦", "Republic of Namibia"},
|
|
||||||
{0x202000, 0x2023FF, "Eritrea", "ER", "🇪🇷", "State of Eritrea"},
|
|
||||||
|
|
||||||
// Europe
|
|
||||||
{0x300000, 0x33FFFF, "Italy", "IT", "🇮🇹", "Italian Republic"},
|
|
||||||
{0x340000, 0x37FFFF, "Spain", "ES", "🇪🇸", "Kingdom of Spain"},
|
|
||||||
{0x380000, 0x3BFFFF, "France", "FR", "🇫🇷", "French Republic"},
|
|
||||||
{0x3C0000, 0x3FFFFF, "Germany", "DE", "🇩🇪", "Federal Republic of Germany"},
|
|
||||||
{0x400000, 0x43FFFF, "United Kingdom", "GB", "🇬🇧", "United Kingdom"},
|
|
||||||
{0x440000, 0x447FFF, "Austria", "AT", "🇦🇹", "Republic of Austria"},
|
|
||||||
{0x448000, 0x44FFFF, "Belgium", "BE", "🇧🇪", "Kingdom of Belgium"},
|
|
||||||
{0x450000, 0x457FFF, "Bulgaria", "BG", "🇧🇬", "Republic of Bulgaria"},
|
|
||||||
{0x458000, 0x45FFFF, "Denmark", "DK", "🇩🇰", "Kingdom of Denmark"},
|
|
||||||
{0x460000, 0x467FFF, "Finland", "FI", "🇫🇮", "Republic of Finland"},
|
|
||||||
{0x468000, 0x46FFFF, "Greece", "GR", "🇬🇷", "Hellenic Republic"},
|
|
||||||
{0x470000, 0x477FFF, "Hungary", "HU", "🇭🇺", "Republic of Hungary"},
|
|
||||||
{0x478000, 0x47FFFF, "Norway", "NO", "🇳🇴", "Kingdom of Norway"},
|
|
||||||
{0x480000, 0x487FFF, "Netherlands", "NL", "🇳🇱", "Kingdom of the Netherlands"},
|
|
||||||
{0x488000, 0x48FFFF, "Poland", "PL", "🇵🇱", "Republic of Poland"},
|
|
||||||
{0x490000, 0x497FFF, "Portugal", "PT", "🇵🇹", "Portuguese Republic"},
|
|
||||||
{0x498000, 0x49FFFF, "Czech Republic", "CZ", "🇨🇿", "Czech Republic"},
|
|
||||||
{0x4A0000, 0x4A7FFF, "Romania", "RO", "🇷🇴", "Romania"},
|
|
||||||
{0x4A8000, 0x4AFFFF, "Sweden", "SE", "🇸🇪", "Kingdom of Sweden"},
|
|
||||||
{0x4B0000, 0x4B7FFF, "Switzerland", "CH", "🇨🇭", "Swiss Confederation"},
|
|
||||||
{0x4B8000, 0x4BFFFF, "Turkey", "TR", "🇹🇷", "Republic of Turkey"},
|
|
||||||
{0x4C0000, 0x4C7FFF, "Yugoslavia", "YU", "🇷🇸", "Yugoslavia"},
|
|
||||||
{0x4C8000, 0x4C83FF, "Cyprus", "CY", "🇨🇾", "Republic of Cyprus"},
|
|
||||||
{0x4CA000, 0x4CAFFF, "Ireland", "IE", "🇮🇪", "Republic of Ireland"},
|
|
||||||
{0x4CC000, 0x4CCFFF, "Iceland", "IS", "🇮🇸", "Republic of Iceland"},
|
|
||||||
{0x4D0000, 0x4D03FF, "Luxembourg", "LU", "🇱🇺", "Grand Duchy of Luxembourg"},
|
|
||||||
{0x4D2000, 0x4D23FF, "Malta", "MT", "🇲🇹", "Republic of Malta"},
|
|
||||||
{0x4D4000, 0x4D43FF, "Monaco", "MC", "🇲🇨", "Principality of Monaco"},
|
|
||||||
{0x500000, 0x5004FF, "San Marino", "SM", "🇸🇲", "Republic of San Marino"},
|
|
||||||
{0x501000, 0x5013FF, "Albania", "AL", "🇦🇱", "Republic of Albania"},
|
|
||||||
{0x501C00, 0x501FFF, "Croatia", "HR", "🇭🇷", "Republic of Croatia"},
|
|
||||||
{0x502C00, 0x502FFF, "Latvia", "LV", "🇱🇻", "Republic of Latvia"},
|
|
||||||
{0x503C00, 0x503FFF, "Lithuania", "LT", "🇱🇹", "Republic of Lithuania"},
|
|
||||||
{0x504C00, 0x504FFF, "Moldova", "MD", "🇲🇩", "Republic of Moldova"},
|
|
||||||
{0x505C00, 0x505FFF, "Slovakia", "SK", "🇸🇰", "Slovak Republic"},
|
|
||||||
{0x506C00, 0x506FFF, "Slovenia", "SI", "🇸🇮", "Republic of Slovenia"},
|
|
||||||
{0x508000, 0x50FFFF, "Ukraine", "UA", "🇺🇦", "Ukraine"},
|
|
||||||
{0x510000, 0x5103FF, "Belarus", "BY", "🇧🇾", "Republic of Belarus"},
|
|
||||||
{0x511000, 0x5113FF, "Estonia", "EE", "🇪🇪", "Republic of Estonia"},
|
|
||||||
{0x512000, 0x5123FF, "Macedonia", "MK", "🇲🇰", "North Macedonia"},
|
|
||||||
{0x513000, 0x5133FF, "Bosnia & Herzegovina", "BA", "🇧🇦", "Bosnia and Herzegovina"},
|
|
||||||
{0x514000, 0x5143FF, "Georgia", "GE", "🇬🇪", "Georgia"},
|
|
||||||
|
|
||||||
// Middle East & Central Asia
|
|
||||||
{0x600000, 0x6003FF, "Armenia", "AM", "🇦🇲", "Republic of Armenia"},
|
|
||||||
{0x600800, 0x600BFF, "Azerbaijan", "AZ", "🇦🇿", "Republic of Azerbaijan"},
|
|
||||||
{0x680000, 0x6803FF, "Bhutan", "BT", "🇧🇹", "Kingdom of Bhutan"},
|
|
||||||
{0x681000, 0x6813FF, "Micronesia", "FM", "🇫🇲", "Federated States of Micronesia"},
|
|
||||||
{0x682000, 0x6823FF, "Mongolia", "MN", "🇲🇳", "Mongolia"},
|
|
||||||
{0x683000, 0x6833FF, "Kazakhstan", "KZ", "🇰🇿", "Republic of Kazakhstan"},
|
|
||||||
{0x06A000, 0x06A3FF, "Qatar", "QA", "🇶🇦", "State of Qatar"},
|
|
||||||
{0x700000, 0x700FFF, "Afghanistan", "AF", "🇦🇫", "Islamic Republic of Afghanistan"},
|
|
||||||
{0x702000, 0x702FFF, "Bangladesh", "BD", "🇧🇩", "People's Republic of Bangladesh"},
|
|
||||||
{0x704000, 0x704FFF, "Myanmar", "MM", "🇲🇲", "Republic of the Union of Myanmar"},
|
|
||||||
{0x706000, 0x706FFF, "Kuwait", "KW", "🇰🇼", "State of Kuwait"},
|
|
||||||
{0x708000, 0x708FFF, "Laos", "LA", "🇱🇦", "Lao People's Democratic Republic"},
|
|
||||||
{0x70A000, 0x70AFFF, "Nepal", "NP", "🇳🇵", "Federal Democratic Republic of Nepal"},
|
|
||||||
{0x70C000, 0x70C3FF, "Oman", "OM", "🇴🇲", "Sultanate of Oman"},
|
|
||||||
{0x70E000, 0x70EFFF, "Cambodia", "KH", "🇰🇭", "Kingdom of Cambodia"},
|
|
||||||
{0x710000, 0x717FFF, "Saudi Arabia", "SA", "🇸🇦", "Kingdom of Saudi Arabia"},
|
|
||||||
{0x718000, 0x71FFFF, "South Korea", "KR", "🇰🇷", "Republic of Korea"},
|
|
||||||
{0x720000, 0x727FFF, "North Korea", "KP", "🇰🇵", "Democratic People's Republic of Korea"},
|
|
||||||
{0x728000, 0x72FFFF, "Iraq", "IQ", "🇮🇶", "Republic of Iraq"},
|
|
||||||
{0x730000, 0x737FFF, "Iran", "IR", "🇮🇷", "Islamic Republic of Iran"},
|
|
||||||
{0x738000, 0x73FFFF, "Israel", "IL", "🇮🇱", "State of Israel"},
|
|
||||||
{0x740000, 0x747FFF, "Jordan", "JO", "🇯🇴", "Hashemite Kingdom of Jordan"},
|
|
||||||
{0x750000, 0x757FFF, "Malaysia", "MY", "🇲🇾", "Malaysia"},
|
|
||||||
{0x758000, 0x75FFFF, "Philippines", "PH", "🇵🇭", "Republic of the Philippines"},
|
|
||||||
{0x760000, 0x767FFF, "Pakistan", "PK", "🇵🇰", "Islamic Republic of Pakistan"},
|
|
||||||
{0x768000, 0x76FFFF, "Singapore", "SG", "🇸🇬", "Republic of Singapore"},
|
|
||||||
{0x770000, 0x777FFF, "Sri Lanka", "LK", "🇱🇰", "Democratic Socialist Republic of Sri Lanka"},
|
|
||||||
{0x778000, 0x77FFFF, "Syria", "SY", "🇸🇾", "Syrian Arab Republic"},
|
|
||||||
{0x780000, 0x7BFFFF, "China", "CN", "🇨🇳", "People's Republic of China"},
|
|
||||||
{0x7C0000, 0x7FFFFF, "Australia", "AU", "🇦🇺", "Commonwealth of Australia"},
|
|
||||||
|
|
||||||
// Asia-Pacific
|
|
||||||
{0x800000, 0x83FFFF, "India", "IN", "🇮🇳", "Republic of India"},
|
|
||||||
{0x840000, 0x87FFFF, "Japan", "JP", "🇯🇵", "Japan"},
|
|
||||||
{0x880000, 0x887FFF, "Thailand", "TH", "🇹🇭", "Kingdom of Thailand"},
|
|
||||||
{0x888000, 0x88FFFF, "Vietnam", "VN", "🇻🇳", "Socialist Republic of Vietnam"},
|
|
||||||
{0x890000, 0x890FFF, "Yemen", "YE", "🇾🇪", "Republic of Yemen"},
|
|
||||||
{0x894000, 0x894FFF, "Bahrain", "BH", "🇧🇭", "Kingdom of Bahrain"},
|
|
||||||
{0x895000, 0x8953FF, "Brunei", "BN", "🇧🇳", "Nation of Brunei"},
|
|
||||||
{0x896000, 0x8973FF, "United Arab Emirates", "AE", "🇦🇪", "United Arab Emirates"},
|
|
||||||
{0x897000, 0x8973FF, "Solomon Islands", "SB", "🇸🇧", "Solomon Islands"},
|
|
||||||
{0x898000, 0x898FFF, "Papua New Guinea", "PG", "🇵🇬", "Independent State of Papua New Guinea"},
|
|
||||||
{0x899000, 0x8993FF, "Taiwan", "TW", "🇹🇼", "Republic of China (Taiwan)"},
|
|
||||||
{0x8A0000, 0x8A7FFF, "Indonesia", "ID", "🇮🇩", "Republic of Indonesia"},
|
|
||||||
|
|
||||||
// North America
|
|
||||||
{0xA00000, 0xAFFFFF, "United States", "US", "🇺🇸", "United States of America"},
|
|
||||||
|
|
||||||
// North America & Oceania
|
|
||||||
{0xC00000, 0xC3FFFF, "Canada", "CA", "🇨🇦", "Canada"},
|
|
||||||
{0xC80000, 0xC87FFF, "New Zealand", "NZ", "🇳🇿", "New Zealand"},
|
|
||||||
{0xC88000, 0xC88FFF, "Fiji", "FJ", "🇫🇯", "Republic of Fiji"},
|
|
||||||
{0xC8A000, 0xC8A3FF, "Nauru", "NR", "🇳🇷", "Republic of Nauru"},
|
|
||||||
{0xC8C000, 0xC8C3FF, "Saint Lucia", "LC", "🇱🇨", "Saint Lucia"},
|
|
||||||
{0xC8D000, 0xC8D3FF, "Tonga", "TO", "🇹🇴", "Kingdom of Tonga"},
|
|
||||||
{0xC8E000, 0xC8E3FF, "Kiribati", "KI", "🇰🇮", "Republic of Kiribati"},
|
|
||||||
|
|
||||||
// South America
|
|
||||||
{0xE00000, 0xE3FFFF, "Argentina", "AR", "🇦🇷", "Argentine Republic"},
|
|
||||||
{0xE40000, 0xE7FFFF, "Brazil", "BR", "🇧🇷", "Federative Republic of Brazil"},
|
|
||||||
{0xE80000, 0xE80FFF, "Chile", "CL", "🇨🇱", "Republic of Chile"},
|
|
||||||
{0xE84000, 0xE84FFF, "Ecuador", "EC", "🇪🇨", "Republic of Ecuador"},
|
|
||||||
{0xE88000, 0xE88FFF, "Paraguay", "PY", "🇵🇾", "Republic of Paraguay"},
|
|
||||||
{0xE8C000, 0xE8CFFF, "Peru", "PE", "🇵🇪", "Republic of Peru"},
|
|
||||||
{0xE90000, 0xE90FFF, "Uruguay", "UY", "🇺🇾", "Oriental Republic of Uruguay"},
|
|
||||||
{0xE94000, 0xE94FFF, "Bolivia", "BO", "🇧🇴", "Plurinational State of Bolivia"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LookupCountry returns country information for an ICAO address using binary search
|
|
||||||
func (d *Database) LookupCountry(icaoHex string) (*CountryInfo, error) {
|
|
||||||
if len(icaoHex) != 6 {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert hex string to integer
|
|
||||||
icaoInt, err := strconv.ParseInt(icaoHex, 16, 64)
|
|
||||||
if err != nil {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binary search for the ICAO address range
|
|
||||||
for _, alloc := range d.allocations {
|
|
||||||
if icaoInt >= alloc.StartAddr && icaoInt <= alloc.EndAddr {
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: alloc.Country,
|
|
||||||
CountryCode: alloc.CountryCode,
|
|
||||||
Flag: alloc.Flag,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not found in any allocation
|
|
||||||
return &CountryInfo{
|
|
||||||
Country: "Unknown",
|
|
||||||
CountryCode: "XX",
|
|
||||||
Flag: "🏳️",
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close is a no-op since we don't have any resources to clean up
|
|
||||||
func (d *Database) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
225
internal/parser/sbs1.go
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
internal/server/server_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
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
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
#!/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
|
|
||||||
5
static/aircraft-icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 224 B |
|
|
@ -193,48 +193,6 @@ 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;
|
||||||
|
|
@ -268,15 +226,11 @@ body {
|
||||||
border: 1px solid #ffffff;
|
border: 1px solid #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-icon.light { background: #00bfff; } /* Sky blue for light aircraft */
|
.legend-icon.commercial { background: #00ff88; }
|
||||||
.legend-icon.medium { background: #00ff88; } /* Green for medium aircraft */
|
.legend-icon.cargo { background: #ff8c00; }
|
||||||
.legend-icon.large { background: #ff8c00; } /* Orange for large aircraft */
|
.legend-icon.military { background: #ff4444; }
|
||||||
.legend-icon.high-vortex { background: #ff4500; } /* Red-orange for high vortex large */
|
.legend-icon.ga { background: #ffff00; }
|
||||||
.legend-icon.heavy { background: #ff0000; } /* Red for heavy aircraft */
|
.legend-icon.ground { background: #888888; }
|
||||||
.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;
|
||||||
|
|
@ -408,148 +362,20 @@ body {
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Leaflet popup override - ensure our styles take precedence */
|
|
||||||
.leaflet-popup-content-wrapper {
|
|
||||||
background: #2d2d2d !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.leaflet-popup-content {
|
|
||||||
margin: 12px !important;
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 {
|
||||||
|
|
@ -558,27 +384,21 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.flight-id {
|
.flight-id {
|
||||||
color: #00a8ff !important;
|
color: #00a8ff;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.callsign {
|
.callsign {
|
||||||
color: #00ff88 !important;
|
color: #00ff88;
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-details {
|
.popup-details {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
padding: 0.25rem 0;
|
padding: 0.25rem 0;
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-row strong {
|
|
||||||
color: #ffffff !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid {
|
.detail-grid {
|
||||||
|
|
@ -595,27 +415,13 @@ body {
|
||||||
|
|
||||||
.detail-item .label {
|
.detail-item .label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: #888 !important;
|
color: #888;
|
||||||
margin-bottom: 0.1rem;
|
margin-bottom: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item .value {
|
.detail-item .value {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffffff !important;
|
color: #ffffff;
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure all values are visible with strong contrast */
|
|
||||||
.aircraft-popup .value,
|
|
||||||
.aircraft-popup .detail-row,
|
|
||||||
.aircraft-popup .detail-item .value {
|
|
||||||
color: #ffffff !important;
|
|
||||||
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style for N/A or empty values - still visible but slightly dimmed */
|
|
||||||
.detail-item .value.no-data {
|
|
||||||
color: #aaaaaa !important;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
152
static/index.html
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<!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
Normal file
|
|
@ -0,0 +1,832 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||