Initial implementation of SkyView - ADS-B aircraft tracker
- Go application with embedded static files for dump1090 frontend - TCP client for SBS-1/BaseStation format (port 30003) - Real-time WebSocket updates with aircraft tracking - Modern web frontend with Leaflet maps and mobile-responsive design - Aircraft table with filtering/sorting and statistics dashboard - Origin configuration for receiver location and distance calculations - Automatic config.json loading from current directory - Foreground execution by default with optional -daemon flag 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
8ce4f4c397
19 changed files with 1971 additions and 0 deletions
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Binaries
|
||||||
|
skyview
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Go
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN go build -ldflags="-w -s" -o skyview .
|
||||||
|
|
||||||
|
FROM alpine:latest
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
COPY --from=builder /app/skyview .
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["./skyview"]
|
||||||
48
Makefile
Normal file
48
Makefile
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
BINARY_NAME=skyview
|
||||||
|
BUILD_DIR=build
|
||||||
|
|
||||||
|
.PHONY: build clean run dev test lint
|
||||||
|
|
||||||
|
build:
|
||||||
|
@echo "Building $(BINARY_NAME)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
go build -ldflags="-w -s" -o $(BUILD_DIR)/$(BINARY_NAME) .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning..."
|
||||||
|
@rm -rf $(BUILD_DIR)
|
||||||
|
go clean
|
||||||
|
|
||||||
|
run: build
|
||||||
|
@echo "Running $(BINARY_NAME)..."
|
||||||
|
@./$(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
dev:
|
||||||
|
@echo "Running in development mode..."
|
||||||
|
go run main.go
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "Running tests..."
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Running linter..."
|
||||||
|
@if command -v golangci-lint > /dev/null 2>&1; then \
|
||||||
|
golangci-lint run; \
|
||||||
|
else \
|
||||||
|
echo "golangci-lint not installed, skipping lint"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -t skyview .
|
||||||
|
|
||||||
|
podman-build:
|
||||||
|
@echo "Building Podman image..."
|
||||||
|
podman build -t skyview .
|
||||||
|
|
||||||
|
install-deps:
|
||||||
|
@echo "Installing Go dependencies..."
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := build
|
||||||
112
README.md
Normal file
112
README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# SkyView - ADS-B Aircraft Tracker
|
||||||
|
|
||||||
|
A modern web frontend for dump1090 ADS-B data with real-time aircraft tracking, statistics, and mobile-responsive design.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time Aircraft Tracking**: Live map with aircraft positions and flight paths
|
||||||
|
- **Interactive Map**: Leaflet-based map with aircraft markers and optional trails
|
||||||
|
- **Aircraft Table**: Sortable and filterable table view with detailed aircraft information
|
||||||
|
- **Statistics Dashboard**: Real-time statistics and charts for signal strength, aircraft counts
|
||||||
|
- **WebSocket Updates**: Real-time data updates without polling
|
||||||
|
- **Mobile Responsive**: Optimized for desktop, tablet, and mobile devices
|
||||||
|
- **Single Binary**: Embedded static files for easy deployment
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
### Configuration File
|
||||||
|
|
||||||
|
SkyView automatically loads `config.json` from the current directory, or you can specify a path with `SKYVIEW_CONFIG`.
|
||||||
|
|
||||||
|
Create a `config.json` file (see `config.json.example`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"address": ":8080",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"dump1090": {
|
||||||
|
"host": "192.168.1.100",
|
||||||
|
"data_port": 30003
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
go build -o skyview .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Foreground (default) - Press Ctrl+C to stop
|
||||||
|
DUMP1090_HOST=192.168.1.100 ./skyview
|
||||||
|
|
||||||
|
# Daemon mode (background process)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Start your dump1090 instance
|
||||||
|
2. Configure SkyView to point to your dump1090 host
|
||||||
|
3. Run SkyView
|
||||||
|
4. Open your browser to `http://localhost:8080`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
## Data Sources
|
||||||
|
|
||||||
|
SkyView connects to dump1090's **SBS-1/BaseStation format** via TCP port 30003 to receive decoded aircraft data in real-time.
|
||||||
|
|
||||||
|
The application maintains an in-memory aircraft database with automatic cleanup of stale aircraft (older than 2 minutes).
|
||||||
|
|
||||||
|
## Views
|
||||||
|
|
||||||
|
- **Map View**: Interactive map with aircraft positions and trails
|
||||||
|
- **Table View**: Sortable table with aircraft details and search
|
||||||
|
- **Stats View**: Dashboard with real-time statistics and charts
|
||||||
15
config.json.example
Normal file
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
skyview:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- DUMP1090_HOST=dump1090
|
||||||
|
- DUMP1090_PORT=8080
|
||||||
|
depends_on:
|
||||||
|
- dump1090
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Example dump1090 service (uncomment and configure as needed)
|
||||||
|
# dump1090:
|
||||||
|
# image: mikenye/dump1090-fa
|
||||||
|
# ports:
|
||||||
|
# - "8080:8080"
|
||||||
|
# - "30005:30005"
|
||||||
|
# environment:
|
||||||
|
# - LAT=37.7749
|
||||||
|
# - LONG=-122.4194
|
||||||
|
# devices:
|
||||||
|
# - /dev/bus/usb/001/002:/dev/bus/usb/001/002
|
||||||
|
# restart: unless-stopped
|
||||||
8
go.mod
Normal file
8
go.mod
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module skyview
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/mux v1.8.1
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
)
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
209
internal/client/dump1090.go
Normal file
209
internal/client/dump1090.go
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
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 {
|
||||||
|
existing.Latitude = update.Latitude
|
||||||
|
}
|
||||||
|
if update.Longitude != 0 {
|
||||||
|
existing.Longitude = update.Longitude
|
||||||
|
}
|
||||||
|
if update.VertRate != 0 {
|
||||||
|
existing.VertRate = update.VertRate
|
||||||
|
}
|
||||||
|
if update.Squawk != "" {
|
||||||
|
existing.Squawk = update.Squawk
|
||||||
|
}
|
||||||
|
existing.OnGround = update.OnGround
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
for hex, aircraft := range c.aircraftMap {
|
||||||
|
if aircraft.LastSeen.Before(cutoff) {
|
||||||
|
delete(c.aircraftMap, hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
internal/config/config.go
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/parser/sbs1.go
Normal file
95
internal/parser/sbs1.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
if messageType != "1" && messageType != "3" && messageType != "4" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
aircraft := &Aircraft{
|
||||||
|
Hex: strings.TrimSpace(parts[4]),
|
||||||
|
LastSeen: time.Now(),
|
||||||
|
Messages: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Atoi(parts[13]); err == nil {
|
||||||
|
aircraft.Track = 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
return aircraft, nil
|
||||||
|
}
|
||||||
|
|
||||||
210
internal/server/server.go
Normal file
210
internal/server/server.go
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"skyview/internal/client"
|
||||||
|
"skyview/internal/config"
|
||||||
|
"skyview/internal/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
config *config.Config
|
||||||
|
staticFiles embed.FS
|
||||||
|
upgrader websocket.Upgrader
|
||||||
|
wsClients map[*websocket.Conn]bool
|
||||||
|
wsClientsMux sync.RWMutex
|
||||||
|
dump1090 *client.Dump1090Client
|
||||||
|
ctx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebSocketMessage struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, staticFiles embed.FS, ctx context.Context) http.Handler {
|
||||||
|
s := &Server{
|
||||||
|
config: cfg,
|
||||||
|
staticFiles: staticFiles,
|
||||||
|
upgrader: websocket.Upgrader{
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wsClients: make(map[*websocket.Conn]bool),
|
||||||
|
dump1090: client.NewDump1090Client(cfg),
|
||||||
|
ctx: ctx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.dump1090.Start(ctx); err != nil {
|
||||||
|
log.Printf("Failed to start dump1090 client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.subscribeToAircraftUpdates()
|
||||||
|
|
||||||
|
router := mux.NewRouter()
|
||||||
|
|
||||||
|
router.HandleFunc("/", s.serveIndex).Methods("GET")
|
||||||
|
router.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
|
||||||
|
|
||||||
|
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||||
|
apiRouter.HandleFunc("/aircraft", s.getAircraft).Methods("GET")
|
||||||
|
apiRouter.HandleFunc("/stats", s.getStats).Methods("GET")
|
||||||
|
apiRouter.HandleFunc("/config", s.getConfig).Methods("GET")
|
||||||
|
|
||||||
|
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(s.staticFiles))))
|
||||||
|
|
||||||
|
return s.enableCORS(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := s.staticFiles.ReadFile("static/index.html")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read index.html", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getAircraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := s.dump1090.GetAircraftData()
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"now": data.Now,
|
||||||
|
"messages": data.Messages,
|
||||||
|
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getStats(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := s.dump1090.GetAircraftData()
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"total": map[string]interface{}{
|
||||||
|
"aircraft": len(data.Aircraft),
|
||||||
|
"messages": map[string]interface{}{
|
||||||
|
"total": data.Messages,
|
||||||
|
"last1min": data.Messages,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
configData := map[string]interface{}{
|
||||||
|
"origin": map[string]interface{}{
|
||||||
|
"latitude": s.config.Origin.Latitude,
|
||||||
|
"longitude": s.config.Origin.Longitude,
|
||||||
|
"name": s.config.Origin.Name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(configData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) aircraftMapToSlice(aircraftMap map[string]parser.Aircraft) []parser.Aircraft {
|
||||||
|
aircraft := make([]parser.Aircraft, 0, len(aircraftMap))
|
||||||
|
for _, a := range aircraftMap {
|
||||||
|
aircraft = append(aircraft, a)
|
||||||
|
}
|
||||||
|
return aircraft
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) subscribeToAircraftUpdates() {
|
||||||
|
updates := s.dump1090.Subscribe()
|
||||||
|
|
||||||
|
for data := range updates {
|
||||||
|
message := WebSocketMessage{
|
||||||
|
Type: "aircraft_update",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"now": data.Now,
|
||||||
|
"messages": data.Messages,
|
||||||
|
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
s.broadcastToWebSocketClients(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
conn, err := s.upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
s.wsClientsMux.Lock()
|
||||||
|
s.wsClients[conn] = true
|
||||||
|
s.wsClientsMux.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
s.wsClientsMux.Lock()
|
||||||
|
delete(s.wsClients, conn)
|
||||||
|
s.wsClientsMux.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
data := s.dump1090.GetAircraftData()
|
||||||
|
initialMessage := WebSocketMessage{
|
||||||
|
Type: "aircraft_update",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"now": data.Now,
|
||||||
|
"messages": data.Messages,
|
||||||
|
"aircraft": s.aircraftMapToSlice(data.Aircraft),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
conn.WriteJSON(initialMessage)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, _, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) broadcastToWebSocketClients(message WebSocketMessage) {
|
||||||
|
s.wsClientsMux.RLock()
|
||||||
|
defer s.wsClientsMux.RUnlock()
|
||||||
|
|
||||||
|
for client := range s.wsClients {
|
||||||
|
if err := client.WriteJSON(message); err != nil {
|
||||||
|
client.Close()
|
||||||
|
delete(s.wsClients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) enableCORS(handler http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
55
internal/server/server_test.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/server/testdata/test.html
vendored
Normal file
5
internal/server/testdata/test.html
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Test</title></head>
|
||||||
|
<body><h1>Test</h1></body>
|
||||||
|
</html>
|
||||||
69
main.go
Normal file
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
static/aircraft-icon.svg
Normal file
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 |
317
static/css/style.css
Normal file
317
static/css/style.css
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #ffffff;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #00a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.connected {
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.disconnected {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
border-bottom-color: #00a8ff;
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
flex: 1;
|
||||||
|
display: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
flex: 1;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 80px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls button:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls input,
|
||||||
|
.table-controls select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #404040;
|
||||||
|
border: 1px solid #606060;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table th,
|
||||||
|
#aircraft-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table th {
|
||||||
|
background: #2d2d2d;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table tr:hover {
|
||||||
|
background: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
background: #2d2d2d;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #404040;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card h3 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card canvas {
|
||||||
|
flex: 1;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-marker {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
filter: drop-shadow(0 0 2px rgba(0,0,0,0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-popup {
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-popup .flight {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #00a8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aircraft-popup .details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-summary {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charts-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
top: 70px;
|
||||||
|
right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls button {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#aircraft-table th,
|
||||||
|
#aircraft-table td {
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
static/index.html
Normal file
102
static/index.html
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!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="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="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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="table-view" class="view">
|
||||||
|
<div class="table-controls">
|
||||||
|
<input type="text" id="search-input" placeholder="Search by flight, callsign, or hex...">
|
||||||
|
<select id="sort-select">
|
||||||
|
<option value="distance">Distance</option>
|
||||||
|
<option value="altitude">Altitude</option>
|
||||||
|
<option value="speed">Speed</option>
|
||||||
|
<option value="flight">Flight</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="aircraft-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Flight</th>
|
||||||
|
<th>Hex</th>
|
||||||
|
<th>Altitude</th>
|
||||||
|
<th>Speed</th>
|
||||||
|
<th>Track</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Msgs</th>
|
||||||
|
<th>Seen</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>Signal Strength</h3>
|
||||||
|
<div class="stat-value" id="signal-strength">0 dB</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.min.js"></script>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
521
static/js/app.js
Normal file
521
static/js/app.js
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
class SkyView {
|
||||||
|
constructor() {
|
||||||
|
this.map = null;
|
||||||
|
this.aircraftMarkers = new Map();
|
||||||
|
this.aircraftTrails = new Map();
|
||||||
|
this.websocket = null;
|
||||||
|
this.aircraftData = [];
|
||||||
|
this.showTrails = false;
|
||||||
|
this.currentView = 'map';
|
||||||
|
this.charts = {};
|
||||||
|
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadConfig().then(() => {
|
||||||
|
this.initializeViews();
|
||||||
|
this.initializeMap();
|
||||||
|
this.initializeWebSocket();
|
||||||
|
this.initializeEventListeners();
|
||||||
|
this.initializeCharts();
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAircraftData(data) {
|
||||||
|
this.aircraftData = data.aircraft || [];
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createAircraftMarker(aircraft, pos) {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: this.getAircraftIcon(aircraft),
|
||||||
|
className: 'aircraft-marker',
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10]
|
||||||
|
});
|
||||||
|
|
||||||
|
const marker = L.marker(pos, { icon }).addTo(this.map);
|
||||||
|
|
||||||
|
marker.bindPopup(this.createPopupContent(aircraft), {
|
||||||
|
className: 'aircraft-popup'
|
||||||
|
});
|
||||||
|
|
||||||
|
return marker;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAircraftIcon(aircraft) {
|
||||||
|
const rotation = aircraft.track || 0;
|
||||||
|
return `<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate(${rotation}deg);">
|
||||||
|
<polygon points="10,2 8,18 10,16 12,18" fill="#00a8ff" stroke="#ffffff" stroke-width="1"/>
|
||||||
|
</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMarkerRotation(marker, track) {
|
||||||
|
if (track !== undefined) {
|
||||||
|
const icon = L.divIcon({
|
||||||
|
html: `<svg width="20" height="20" viewBox="0 0 20 20" style="transform: rotate(${track}deg);">
|
||||||
|
<polygon points="10,2 8,18 10,16 12,18" fill="#00a8ff" stroke="#ffffff" stroke-width="1"/>
|
||||||
|
</svg>`,
|
||||||
|
className: 'aircraft-marker',
|
||||||
|
iconSize: [20, 20],
|
||||||
|
iconAnchor: [10, 10]
|
||||||
|
});
|
||||||
|
marker.setIcon(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createPopupContent(aircraft) {
|
||||||
|
return `
|
||||||
|
<div class="aircraft-popup">
|
||||||
|
<div class="flight">${aircraft.flight || aircraft.hex}</div>
|
||||||
|
<div class="details">
|
||||||
|
<div>Altitude:</div><div>${aircraft.alt_baro || 'N/A'} ft</div>
|
||||||
|
<div>Speed:</div><div>${aircraft.gs || 'N/A'} kts</div>
|
||||||
|
<div>Track:</div><div>${aircraft.track || 'N/A'}°</div>
|
||||||
|
<div>Squawk:</div><div>${aircraft.squawk || 'N/A'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortBy = document.getElementById('sort-select').value;
|
||||||
|
this.sortAircraft(filteredData, sortBy);
|
||||||
|
|
||||||
|
filteredData.forEach(aircraft => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${aircraft.flight || '-'}</td>
|
||||||
|
<td>${aircraft.hex}</td>
|
||||||
|
<td>${aircraft.alt_baro || '-'}</td>
|
||||||
|
<td>${aircraft.gs || '-'}</td>
|
||||||
|
<td>${aircraft.track || '-'}</td>
|
||||||
|
<td>${this.calculateDistance(aircraft) || '-'}</td>
|
||||||
|
<td>${aircraft.messages || '-'}</td>
|
||||||
|
<td>${aircraft.seen ? aircraft.seen.toFixed(1) : '-'}s</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);
|
||||||
|
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).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 maxDistance = Math.max(...this.aircraftData
|
||||||
|
.map(a => this.calculateDistance(a))
|
||||||
|
.filter(d => d !== null)) || 0;
|
||||||
|
|
||||||
|
document.getElementById('max-range').textContent = `${maxDistance} 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.total && stats.total.signal_power) {
|
||||||
|
document.getElementById('signal-strength').textContent =
|
||||||
|
`${stats.total.signal_power.toFixed(1)} dB`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new SkyView();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue