Restructure assets to top-level package and add Reset Map button

- Move assets from internal/assets to top-level assets/ package for clean embed directive
- Consolidate all static files in single location (assets/static/)
- Remove duplicate static file locations to maintain single source of truth
- Add Reset Map button to map controls with full functionality
- Implement resetMap() method to return map to calculated origin position
- Store origin in this.mapOrigin for reset functionality
- Fix go:embed pattern to work without parent directory references

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-08-24 00:57:49 +02:00
commit 1425f0a018
20 changed files with 263 additions and 2139 deletions

View file

@ -6,5 +6,6 @@ import "embed"
// Static contains all embedded static assets
// The files are accessed with paths like "static/index.html", "static/css/style.css", etc.
//go:embed static/*
//
//go:embed static
var Static embed.FS

View file

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 224 B

Before After
Before After

View file

@ -75,6 +75,7 @@
<!-- 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-range" title="Show/hide range circles">Show Range</button>
<button id="toggle-sources" title="Show/hide source locations">Show Sources</button>

View file

@ -112,6 +112,9 @@ class SkyView {
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);
// Dark tile layer
@ -123,6 +126,7 @@ class SkyView {
// Map controls
document.getElementById('center-map').addEventListener('click', () => this.centerMapOnAircraft());
document.getElementById('reset-map').addEventListener('click', () => this.resetMap());
document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails());
document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles());
document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources());
@ -775,11 +779,23 @@ class SkyView {
if (validAircraft.length === 0) return;
const group = new L.featureGroup(
validAircraft.map(a => L.marker([a.Latitude, a.Longitude]))
);
this.map.fitBounds(group.getBounds().pad(0.1));
if (validAircraft.length === 1) {
// Center on single aircraft
const aircraft = validAircraft[0];
this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
} else {
// Fit bounds to all aircraft
const bounds = L.latLngBounds(
validAircraft.map(a => [a.Latitude, a.Longitude])
);
this.map.fitBounds(bounds.pad(0.1));
}
}
resetMap() {
if (this.mapOrigin && this.map) {
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
}
}
toggleTrails() {

File diff suppressed because it is too large Load diff

View file

@ -20,12 +20,12 @@ const (
// Message represents a Beast format message
type Message struct {
Type byte
Timestamp uint64 // 48-bit timestamp in 12MHz ticks
Signal uint8 // Signal level (RSSI)
Data []byte // Mode S data
Type byte
Timestamp uint64 // 48-bit timestamp in 12MHz ticks
Signal uint8 // Signal level (RSSI)
Data []byte // Mode S data
ReceivedAt time.Time
SourceID string // Identifier for the source receiver
SourceID string // Identifier for the source receiver
}
// Parser handles Beast binary format parsing
@ -146,7 +146,7 @@ func (msg *Message) GetSignalStrength() float64 {
if msg.Signal == 0 {
return -50.0 // Minimum detectable signal
}
return float64(msg.Signal)*(-50.0/255.0)
return float64(msg.Signal) * (-50.0 / 255.0)
}
// GetICAO24 extracts the ICAO 24-bit address from Mode S messages
@ -154,11 +154,11 @@ 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
@ -178,10 +178,10 @@ func (msg *Message) GetTypeCode() (uint8, error) {
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
}
}

View file

@ -6,7 +6,7 @@ import (
"net"
"sync"
"time"
"skyview/internal/beast"
"skyview/internal/merger"
"skyview/internal/modes"
@ -23,7 +23,7 @@ type BeastClient struct {
errChan chan error
stopChan chan struct{}
wg sync.WaitGroup
reconnectDelay time.Duration
maxReconnect time.Duration
}
@ -60,9 +60,9 @@ func (c *BeastClient) Stop() {
// run is the main client loop
func (c *BeastClient) run(ctx context.Context) {
defer c.wg.Done()
reconnectDelay := c.reconnectDelay
for {
select {
case <-ctx.Done():
@ -71,16 +71,16 @@ func (c *BeastClient) run(ctx context.Context) {
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, 10*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 {
@ -88,21 +88,21 @@ func (c *BeastClient) run(ctx context.Context) {
}
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():
@ -116,7 +116,7 @@ func (c *BeastClient) run(ctx context.Context) {
c.conn.Close()
c.source.Active = false
}
// Wait for goroutines to finish
time.Sleep(1 * time.Second)
}
@ -131,7 +131,7 @@ func (c *BeastClient) readMessages() {
// processMessages decodes and merges aircraft data
func (c *BeastClient) processMessages() {
defer c.wg.Done()
for {
select {
case <-c.stopChan:
@ -140,13 +140,13 @@ func (c *BeastClient) processMessages() {
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,
@ -154,7 +154,7 @@ func (c *BeastClient) processMessages() {
msg.GetSignalStrength(),
msg.ReceivedAt,
)
// Update source statistics
c.source.Messages++
}
@ -180,10 +180,10 @@ func NewMultiSourceClient(merger *merger.Merger) *MultiSourceClient {
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)
@ -193,11 +193,11 @@ func (m *MultiSourceClient) AddSource(source *merger.Source) {
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)
}
@ -206,7 +206,7 @@ func (m *MultiSourceClient) Start(ctx context.Context) {
func (m *MultiSourceClient) Stop() {
m.mu.RLock()
defer m.mu.RUnlock()
for _, client := range m.clients {
client.Stop()
}
@ -216,7 +216,7 @@ func (m *MultiSourceClient) Stop() {
func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
@ -231,9 +231,9 @@ func (m *MultiSourceClient) cleanupRoutine(ctx context.Context) {
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 {
@ -241,9 +241,9 @@ func (m *MultiSourceClient) GetStatistics() map[string]interface{} {
activeClients++
}
}
stats["active_clients"] = activeClients
stats["total_clients"] = len(m.clients)
return stats
}
}

View file

@ -52,7 +52,7 @@ func (c *Dump1090Client) startDataStream(ctx context.Context) {
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)
@ -109,7 +109,7 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
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{
@ -120,9 +120,9 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
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:]
@ -136,7 +136,7 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
existing.Squawk = update.Squawk
}
existing.OnGround = update.OnGround
// Preserve country and registration
if update.Country != "" && update.Country != "Unknown" {
existing.Country = update.Country
@ -152,19 +152,19 @@ func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft)
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
}
@ -249,7 +249,7 @@ func (c *Dump1090Client) cleanupStaleAircraft() {
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)
@ -264,4 +264,4 @@ func (c *Dump1090Client) cleanupStaleAircraft() {
aircraft.TrackHistory = validTracks
}
}
}
}

View file

@ -115,4 +115,4 @@ func loadFromEnv(cfg *Config) {
if name := os.Getenv("ORIGIN_NAME"); name != "" {
cfg.Origin.Name = name
}
}
}

View file

@ -4,7 +4,7 @@ import (
"math"
"sync"
"time"
"skyview/internal/modes"
)
@ -26,31 +26,31 @@ type Source struct {
// AircraftState represents merged aircraft state from all sources
type AircraftState struct {
*modes.Aircraft
Sources map[string]*SourceData `json:"sources"`
LastUpdate time.Time `json:"last_update"`
FirstSeen time.Time `json:"first_seen"`
TotalMessages int64 `json:"total_messages"`
PositionHistory []PositionPoint `json:"position_history"`
SignalHistory []SignalPoint `json:"signal_history"`
AltitudeHistory []AltitudePoint `json:"altitude_history"`
SpeedHistory []SpeedPoint `json:"speed_history"`
Distance float64 `json:"distance"` // Distance from closest receiver
Bearing float64 `json:"bearing"` // Bearing from closest receiver
Age float64 `json:"age"` // Seconds since last update
MLATSources []string `json:"mlat_sources"` // Sources providing MLAT data
PositionSource string `json:"position_source"` // Source providing current position
UpdateRate float64 `json:"update_rate"` // Updates per second
Sources map[string]*SourceData `json:"sources"`
LastUpdate time.Time `json:"last_update"`
FirstSeen time.Time `json:"first_seen"`
TotalMessages int64 `json:"total_messages"`
PositionHistory []PositionPoint `json:"position_history"`
SignalHistory []SignalPoint `json:"signal_history"`
AltitudeHistory []AltitudePoint `json:"altitude_history"`
SpeedHistory []SpeedPoint `json:"speed_history"`
Distance float64 `json:"distance"` // Distance from closest receiver
Bearing float64 `json:"bearing"` // Bearing from closest receiver
Age float64 `json:"age"` // Seconds since last update
MLATSources []string `json:"mlat_sources"` // Sources providing MLAT data
PositionSource string `json:"position_source"` // Source providing current position
UpdateRate float64 `json:"update_rate"` // Updates per second
}
// SourceData represents data from a specific source
type SourceData struct {
SourceID string `json:"source_id"`
SignalLevel float64 `json:"signal_level"`
Messages int64 `json:"messages"`
LastSeen time.Time `json:"last_seen"`
Distance float64 `json:"distance"`
Bearing float64 `json:"bearing"`
UpdateRate float64 `json:"update_rate"`
SourceID string `json:"source_id"`
SignalLevel float64 `json:"signal_level"`
Messages int64 `json:"messages"`
LastSeen time.Time `json:"last_seen"`
Distance float64 `json:"distance"`
Bearing float64 `json:"bearing"`
UpdateRate float64 `json:"update_rate"`
}
// Position/Signal/Altitude/Speed history points
@ -116,7 +116,7 @@ func (m *Merger) AddSource(source *Source) {
func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signal float64, timestamp time.Time) {
m.mu.Lock()
defer m.mu.Unlock()
// Get or create aircraft state
state, exists := m.aircraft[aircraft.ICAO24]
if !exists {
@ -134,7 +134,7 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
updates: make([]time.Time, 0),
}
}
// Update or create source data
srcData, srcExists := state.Sources[sourceID]
if !srcExists {
@ -143,12 +143,12 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
}
state.Sources[sourceID] = srcData
}
// Update source data
srcData.SignalLevel = signal
srcData.Messages++
srcData.LastSeen = timestamp
// Calculate distance and bearing from source
if source, ok := m.sources[sourceID]; ok && aircraft.Latitude != 0 && aircraft.Longitude != 0 {
srcData.Distance, srcData.Bearing = calculateDistanceBearing(
@ -156,23 +156,23 @@ func (m *Merger) UpdateAircraft(sourceID string, aircraft *modes.Aircraft, signa
aircraft.Latitude, aircraft.Longitude,
)
}
// Update merged aircraft data (use best/newest data)
m.mergeAircraftData(state, aircraft, sourceID, timestamp)
// Update histories
m.updateHistories(state, aircraft, sourceID, signal, timestamp)
// Update metrics
m.updateUpdateRate(aircraft.ICAO24, timestamp)
// Update source statistics
if source, ok := m.sources[sourceID]; ok {
source.LastSeen = timestamp
source.Messages++
source.Active = true
}
state.LastUpdate = timestamp
state.TotalMessages++
}
@ -182,7 +182,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
// Position - use source with best signal or most recent
if new.Latitude != 0 && new.Longitude != 0 {
updatePosition := false
if state.Latitude == 0 {
updatePosition = true
} else if srcData, ok := state.Sources[sourceID]; ok {
@ -192,14 +192,14 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
updatePosition = true
}
}
if updatePosition {
state.Latitude = new.Latitude
state.Longitude = new.Longitude
state.PositionSource = sourceID
}
}
// Altitude - use most recent
if new.Altitude != 0 {
state.Altitude = new.Altitude
@ -210,7 +210,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.GeomAltitude != 0 {
state.GeomAltitude = new.GeomAltitude
}
// Speed and track - use most recent
if new.GroundSpeed != 0 {
state.GroundSpeed = new.GroundSpeed
@ -221,12 +221,12 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.Heading != 0 {
state.Heading = new.Heading
}
// Vertical rate - use most recent
if new.VerticalRate != 0 {
state.VerticalRate = new.VerticalRate
}
// Identity - use most recent non-empty
if new.Callsign != "" {
state.Callsign = new.Callsign
@ -237,7 +237,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.Category != "" {
state.Category = new.Category
}
// Status - use most recent
if new.Emergency != "" {
state.Emergency = new.Emergency
@ -245,7 +245,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
state.OnGround = new.OnGround
state.Alert = new.Alert
state.SPI = new.SPI
// Navigation accuracy - use best available
if new.NACp > state.NACp {
state.NACp = new.NACp
@ -256,7 +256,7 @@ func (m *Merger) mergeAircraftData(state *AircraftState, new *modes.Aircraft, so
if new.SIL > state.SIL {
state.SIL = new.SIL
}
// Selected values - use most recent
if new.SelectedAltitude != 0 {
state.SelectedAltitude = new.SelectedAltitude
@ -280,7 +280,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Source: sourceID,
})
}
// Signal history
if signal != 0 {
state.SignalHistory = append(state.SignalHistory, SignalPoint{
@ -289,7 +289,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Source: sourceID,
})
}
// Altitude history
if aircraft.Altitude != 0 {
state.AltitudeHistory = append(state.AltitudeHistory, AltitudePoint{
@ -298,7 +298,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
VRate: aircraft.VerticalRate,
})
}
// Speed history
if aircraft.GroundSpeed != 0 {
state.SpeedHistory = append(state.SpeedHistory, SpeedPoint{
@ -307,7 +307,7 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
Track: aircraft.Track,
})
}
// Trim histories if they exceed limit
if len(state.PositionHistory) > m.historyLimit {
state.PositionHistory = state.PositionHistory[len(state.PositionHistory)-m.historyLimit:]
@ -327,13 +327,13 @@ func (m *Merger) updateHistories(state *AircraftState, aircraft *modes.Aircraft,
func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) {
metric := m.updateMetrics[icao]
metric.updates = append(metric.updates, timestamp)
// Keep only last 30 seconds of updates
cutoff := timestamp.Add(-30 * time.Second)
for len(metric.updates) > 0 && metric.updates[0].Before(cutoff) {
metric.updates = metric.updates[1:]
}
if len(metric.updates) > 1 {
duration := metric.updates[len(metric.updates)-1].Sub(metric.updates[0]).Seconds()
if duration > 0 {
@ -348,14 +348,14 @@ func (m *Merger) updateUpdateRate(icao uint32, timestamp time.Time) {
func (m *Merger) getBestSignalSource(state *AircraftState) string {
var bestSource string
var bestSignal float64 = -999
for srcID, srcData := range state.Sources {
if srcData.SignalLevel > bestSignal {
bestSignal = srcData.SignalLevel
bestSource = srcID
}
}
return bestSource
}
@ -363,21 +363,21 @@ func (m *Merger) getBestSignalSource(state *AircraftState) string {
func (m *Merger) GetAircraft() map[uint32]*AircraftState {
m.mu.RLock()
defer m.mu.RUnlock()
// Create copy and calculate ages
result := make(map[uint32]*AircraftState)
now := time.Now()
for icao, state := range m.aircraft {
// Skip stale aircraft
if now.Sub(state.LastUpdate) > m.staleTimeout {
continue
}
// Calculate age
stateCopy := *state
stateCopy.Age = now.Sub(state.LastUpdate).Seconds()
// Find closest receiver distance
minDistance := float64(999999)
for _, srcData := range state.Sources {
@ -387,10 +387,10 @@ func (m *Merger) GetAircraft() map[uint32]*AircraftState {
stateCopy.Bearing = srcData.Bearing
}
}
result[icao] = &stateCopy
}
return result
}
@ -398,7 +398,7 @@ func (m *Merger) GetAircraft() map[uint32]*AircraftState {
func (m *Merger) GetSources() []*Source {
m.mu.RLock()
defer m.mu.RUnlock()
sources := make([]*Source, 0, len(m.sources))
for _, src := range m.sources {
sources = append(sources, src)
@ -410,23 +410,23 @@ func (m *Merger) GetSources() []*Source {
func (m *Merger) GetStatistics() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
totalMessages := int64(0)
activeSources := 0
aircraftBySources := make(map[int]int) // Count by number of sources
for _, state := range m.aircraft {
totalMessages += state.TotalMessages
numSources := len(state.Sources)
aircraftBySources[numSources]++
}
for _, src := range m.sources {
if src.Active {
activeSources++
}
}
return map[string]interface{}{
"total_aircraft": len(m.aircraft),
"total_messages": totalMessages,
@ -439,7 +439,7 @@ func (m *Merger) GetStatistics() map[string]interface{} {
func (m *Merger) CleanupStale() {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
for icao, state := range m.aircraft {
if now.Sub(state.LastUpdate) > m.staleTimeout {
@ -454,26 +454,26 @@ func (m *Merger) CleanupStale() {
func calculateDistanceBearing(lat1, lon1, lat2, lon2 float64) (float64, float64) {
// Haversine formula for distance
const R = 6371.0 // Earth radius in km
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
distance := R * c
// Bearing calculation
y := math.Sin(dLon) * math.Cos(lat2*math.Pi/180)
x := math.Cos(lat1*math.Pi/180)*math.Sin(lat2*math.Pi/180) -
math.Sin(lat1*math.Pi/180)*math.Cos(lat2*math.Pi/180)*math.Cos(dLon)
bearing := math.Atan2(y, x) * 180 / math.Pi
if bearing < 0 {
bearing += 360
}
return distance, bearing
}
}

View file

@ -90,7 +90,7 @@ func (d *Decoder) Decode(data []byte) (*Aircraft, error) {
df := (data[0] >> 3) & 0x1F
icao := d.extractICAO(data, df)
aircraft := &Aircraft{
ICAO24: icao,
}
@ -154,49 +154,49 @@ func (d *Decoder) decodeExtendedSquitter(data []byte, aircraft *Aircraft) (*Airc
// decodeIdentification extracts callsign and category
func (d *Decoder) decodeIdentification(data []byte, aircraft *Aircraft) {
tc := (data[4] >> 3) & 0x1F
// Category
aircraft.Category = d.getAircraftCategory(tc, data[4]&0x07)
// Callsign - 8 characters encoded in 6 bits each
chars := "#ABCDEFGHIJKLMNOPQRSTUVWXYZ##### ###############0123456789######"
callsign := ""
// Extract 48 bits starting from bit 40
for i := 0; i < 8; i++ {
bitOffset := 40 + i*6
byteOffset := bitOffset / 8
bitShift := bitOffset % 8
var charCode uint8
if bitShift <= 2 {
charCode = (data[byteOffset] >> (2 - bitShift)) & 0x3F
} else {
charCode = ((data[byteOffset] << (bitShift - 2)) & 0x3F) |
(data[byteOffset+1] >> (10 - bitShift))
charCode = ((data[byteOffset] << (bitShift - 2)) & 0x3F) |
(data[byteOffset+1] >> (10 - bitShift))
}
if charCode < 64 {
callsign += string(chars[charCode])
}
}
aircraft.Callsign = callsign
}
// decodeAirbornePosition extracts position from CPR encoded data
func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
tc := (data[4] >> 3) & 0x1F
// Altitude
altBits := (uint16(data[5])<<4 | uint16(data[6])>>4) & 0x0FFF
aircraft.Altitude = d.decodeAltitudeBits(altBits, tc)
// CPR latitude/longitude
cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
oddFlag := (data[6] >> 2) & 0x01
// Store CPR values for later decoding
if oddFlag == 1 {
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
@ -205,7 +205,7 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
}
// Try to decode position if we have both even and odd messages
d.decodeCPRPosition(aircraft)
}
@ -214,42 +214,42 @@ func (d *Decoder) decodeAirbornePosition(data []byte, aircraft *Aircraft) {
func (d *Decoder) decodeCPRPosition(aircraft *Aircraft) {
evenLat, evenExists := d.cprEvenLat[aircraft.ICAO24]
oddLat, oddExists := d.cprOddLat[aircraft.ICAO24]
if !evenExists || !oddExists {
return
}
evenLon := d.cprEvenLon[aircraft.ICAO24]
oddLon := d.cprOddLon[aircraft.ICAO24]
// CPR decoding algorithm
dLat := 360.0 / 60.0
j := math.Floor(evenLat*59 - oddLat*60 + 0.5)
latEven := dLat * (math.Mod(j, 60) + evenLat)
latOdd := dLat * (math.Mod(j, 59) + oddLat)
if latEven >= 270 {
latEven -= 360
}
if latOdd >= 270 {
latOdd -= 360
}
// Choose the most recent position
aircraft.Latitude = latOdd // Use odd for now, should check timestamps
// Longitude calculation
nl := d.nlFunction(aircraft.Latitude)
ni := math.Max(nl-1, 1)
dLon := 360.0 / ni
m := math.Floor(evenLon*(nl-1) - oddLon*nl + 0.5)
lon := dLon * (math.Mod(m, ni) + oddLon)
if lon >= 180 {
lon -= 360
}
aircraft.Longitude = lon
}
@ -258,41 +258,41 @@ func (d *Decoder) nlFunction(lat float64) float64 {
if math.Abs(lat) >= 87 {
return 2
}
nz := 15.0
a := 1 - math.Cos(math.Pi/(2*nz))
b := math.Pow(math.Cos(math.Pi/180.0*math.Abs(lat)), 2)
nl := 2 * math.Pi / math.Acos(1-a/b)
return math.Floor(nl)
}
// decodeVelocity extracts speed and heading
func (d *Decoder) decodeVelocity(data []byte, aircraft *Aircraft) {
subtype := (data[4]) & 0x07
if subtype == 1 || subtype == 2 {
// Ground speed
ewRaw := uint16(data[5]&0x03)<<8 | uint16(data[6])
nsRaw := uint16(data[7])<<3 | uint16(data[8])>>5
ewVel := float64(ewRaw - 1)
nsVel := float64(nsRaw - 1)
if data[5]&0x04 != 0 {
ewVel = -ewVel
}
if data[7]&0x80 != 0 {
nsVel = -nsVel
}
aircraft.GroundSpeed = math.Sqrt(ewVel*ewVel + nsVel*nsVel)
aircraft.Track = math.Atan2(ewVel, nsVel) * 180 / math.Pi
if aircraft.Track < 0 {
aircraft.Track += 360
}
}
// Vertical rate
vrSign := (data[8] >> 3) & 0x01
vrBits := uint16(data[8]&0x07)<<6 | uint16(data[9])>>2
@ -315,20 +315,20 @@ func (d *Decoder) decodeAltitudeBits(altCode uint16, tc uint8) int {
if altCode == 0 {
return 0
}
// Gray code to binary conversion
var n uint16
for i := uint(0); i < 12; i++ {
n ^= altCode >> i
}
alt := int(n)*25 - 1000
if tc >= 20 && tc <= 22 {
// GNSS altitude
return alt
}
return alt
}
@ -398,7 +398,7 @@ func (d *Decoder) getAircraftCategory(tc uint8, ca uint8) string {
// decodeStatus handles aircraft status messages
func (d *Decoder) decodeStatus(data []byte, aircraft *Aircraft) {
subtype := data[4] & 0x07
if subtype == 1 {
// Emergency/priority status
emergency := (data[5] >> 5) & 0x07
@ -428,7 +428,7 @@ func (d *Decoder) decodeTargetState(data []byte, aircraft *Aircraft) {
if altBits != 0 {
aircraft.SelectedAltitude = int(altBits)*32 - 32
}
// Barometric pressure setting
baroBits := uint16(data[7])<<1 | uint16(data[8])>>7
if baroBits != 0 {
@ -447,25 +447,25 @@ func (d *Decoder) decodeOperationalStatus(data []byte, aircraft *Aircraft) {
// decodeSurfacePosition handles surface position messages
func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
aircraft.OnGround = true
// Movement
movement := uint8(data[4]&0x07)<<4 | uint8(data[5])>>4
if movement > 0 && movement < 125 {
aircraft.GroundSpeed = d.decodeGroundSpeed(movement)
}
// Track
trackValid := (data[5] >> 3) & 0x01
if trackValid != 0 {
trackBits := uint16(data[5]&0x07)<<4 | uint16(data[6])>>4
aircraft.Track = float64(trackBits) * 360.0 / 128.0
}
// CPR position (similar to airborne)
cprLat := uint32(data[6]&0x03)<<15 | uint32(data[7])<<7 | uint32(data[8])>>1
cprLon := uint32(data[8]&0x01)<<16 | uint32(data[9])<<8 | uint32(data[10])
oddFlag := (data[6] >> 2) & 0x01
if oddFlag == 1 {
d.cprOddLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprOddLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
@ -473,7 +473,7 @@ func (d *Decoder) decodeSurfacePosition(data []byte, aircraft *Aircraft) {
d.cprEvenLat[aircraft.ICAO24] = float64(cprLat) / 131072.0
d.cprEvenLon[aircraft.ICAO24] = float64(cprLon) / 131072.0
}
d.decodeCPRPosition(aircraft)
}
@ -497,4 +497,4 @@ func (d *Decoder) decodeGroundSpeed(movement uint8) float64 {
return 175.0
}
return 0
}
}

View file

@ -16,23 +16,23 @@ type TrackPoint struct {
}
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"`
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"`
RSSI float64 `json:"rssi,omitempty"`
Country string `json:"country,omitempty"`
Registration string `json:"registration,omitempty"`
}
type AircraftData struct {
@ -117,7 +117,7 @@ func getCountryFromICAO(icao string) string {
}
prefix := icao[:1]
switch prefix {
case "4":
return getCountryFrom4xxxx(icao)
@ -222,4 +222,3 @@ func getRegistrationFromICAO(icao string) string {
return icao
}
}

View file

@ -14,7 +14,7 @@ import (
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"skyview/internal/merger"
)
@ -32,12 +32,12 @@ type Server struct {
staticFiles embed.FS
server *http.Server
origin OriginConfig
// WebSocket management
wsClients map[*websocket.Conn]bool
wsClientsMu sync.RWMutex
upgrader websocket.Upgrader
// Broadcast channels
broadcastChan chan []byte
stopChan chan struct{}
@ -60,11 +60,11 @@ type AircraftUpdate struct {
// NewServer creates a new HTTP server
func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin OriginConfig) *Server {
return &Server{
port: port,
merger: merger,
staticFiles: staticFiles,
origin: origin,
wsClients: make(map[*websocket.Conn]bool),
port: port,
merger: merger,
staticFiles: staticFiles,
origin: origin,
wsClients: make(map[*websocket.Conn]bool),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
@ -81,25 +81,25 @@ func NewServer(port int, merger *merger.Merger, staticFiles embed.FS, origin Ori
func (s *Server) Start() error {
// Start broadcast routine
go s.broadcastRoutine()
// Start periodic updates
go s.periodicUpdateRoutine()
// Setup routes
router := s.setupRoutes()
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: router,
}
return s.server.ListenAndServe()
}
// Stop gracefully stops the server
func (s *Server) Stop() {
close(s.stopChan)
if s.server != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -109,7 +109,7 @@ func (s *Server) Stop() {
func (s *Server) setupRoutes() http.Handler {
router := mux.NewRouter()
// API routes
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
@ -119,36 +119,36 @@ func (s *Server) setupRoutes() http.Handler {
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
api.HandleFunc("/coverage/{sourceId}", s.handleGetCoverage).Methods("GET")
api.HandleFunc("/heatmap/{sourceId}", s.handleGetHeatmap).Methods("GET")
// WebSocket
router.HandleFunc("/ws", s.handleWebSocket)
// Static files
router.PathPrefix("/static/").Handler(s.staticFileHandler())
router.HandleFunc("/favicon.ico", s.handleFavicon)
// Main page
router.HandleFunc("/", s.handleIndex)
// Enable CORS
return s.enableCORS(router)
}
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
// Convert ICAO keys to hex strings for JSON
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"aircraft": aircraftMap,
"count": len(aircraft),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
@ -156,14 +156,14 @@ func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
icaoStr := vars["icao"]
// Parse ICAO hex string
icao, err := strconv.ParseUint(icaoStr, 16, 32)
if err != nil {
http.Error(w, "Invalid ICAO address", http.StatusBadRequest)
return
}
aircraft := s.merger.GetAircraft()
if state, exists := aircraft[uint32(icao)]; exists {
w.Header().Set("Content-Type", "application/json")
@ -175,7 +175,7 @@ func (s *Server) handleGetAircraftDetails(w http.ResponseWriter, r *http.Request
func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
sources := s.merger.GetSources()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"sources": sources,
@ -185,7 +185,7 @@ func (s *Server) handleGetSources(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
stats := s.merger.GetStatistics()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
@ -198,11 +198,11 @@ func (s *Server) handleGetOrigin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
// Generate coverage data based on signal strength
aircraft := s.merger.GetAircraft()
coveragePoints := make([]map[string]interface{}, 0)
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
coveragePoints = append(coveragePoints, map[string]interface{}{
@ -214,7 +214,7 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"source": sourceID,
@ -225,21 +225,21 @@ func (s *Server) handleGetCoverage(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
sourceID := vars["sourceId"]
// Generate heatmap data grid
aircraft := s.merger.GetAircraft()
heatmapData := make(map[string]interface{})
// Simple grid-based heatmap
grid := make([][]float64, 100)
for i := range grid {
grid[i] = make([]float64, 100)
}
// Find bounds
minLat, maxLat := 90.0, -90.0
minLon, maxLon := 180.0, -180.0
for _, state := range aircraft {
if _, exists := state.Sources[sourceID]; exists {
if state.Latitude < minLat {
@ -256,19 +256,19 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
}
}
}
// Fill grid
for _, state := range aircraft {
if srcData, exists := state.Sources[sourceID]; exists {
latIdx := int((state.Latitude - minLat) / (maxLat - minLat) * 99)
lonIdx := int((state.Longitude - minLon) / (maxLon - minLon) * 99)
if latIdx >= 0 && latIdx < 100 && lonIdx >= 0 && lonIdx < 100 {
grid[latIdx][lonIdx] += srcData.SignalLevel
}
}
}
heatmapData["grid"] = grid
heatmapData["bounds"] = map[string]float64{
"minLat": minLat,
@ -276,7 +276,7 @@ func (s *Server) handleGetHeatmap(w http.ResponseWriter, r *http.Request) {
"minLon": minLon,
"maxLon": maxLon,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(heatmapData)
}
@ -288,15 +288,15 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
defer conn.Close()
// Register client
s.wsClientsMu.Lock()
s.wsClients[conn] = true
s.wsClientsMu.Unlock()
// Send initial data
s.sendInitialData(conn)
// Handle client messages (ping/pong)
for {
_, _, err := conn.ReadMessage()
@ -304,7 +304,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
break
}
}
// Unregister client
s.wsClientsMu.Lock()
delete(s.wsClients, conn)
@ -315,25 +315,25 @@ func (s *Server) sendInitialData(conn *websocket.Conn) {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
// Convert ICAO keys to hex strings
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
msg := WebSocketMessage{
Type: "initial_data",
Timestamp: time.Now().Unix(),
Data: update,
}
conn.WriteJSON(msg)
}
@ -358,7 +358,7 @@ func (s *Server) broadcastRoutine() {
func (s *Server) periodicUpdateRoutine() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopChan:
@ -373,25 +373,25 @@ func (s *Server) broadcastUpdate() {
aircraft := s.merger.GetAircraft()
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
// Convert ICAO keys to hex strings
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
update := AircraftUpdate{
Aircraft: aircraftMap,
Sources: sources,
Stats: stats,
}
msg := WebSocketMessage{
Type: "aircraft_update",
Timestamp: time.Now().Unix(),
Data: update,
}
if data, err := json.Marshal(msg); err == nil {
select {
case s.broadcastChan <- data:
@ -407,7 +407,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Page not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
}
@ -418,7 +418,7 @@ func (s *Server) handleFavicon(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Favicon not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/x-icon")
w.Write(data)
}
@ -427,21 +427,21 @@ func (s *Server) staticFileHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Remove /static/ prefix from URL path to get the actual file path
filePath := "static" + r.URL.Path[len("/static"):]
data, err := s.staticFiles.ReadFile(filePath)
if err != nil {
http.NotFound(w, r)
return
}
// Set content type
ext := path.Ext(filePath)
contentType := getContentType(ext)
w.Header().Set("Content-Type", contentType)
// Cache control
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(data)
})
}
@ -474,12 +474,12 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler {
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)
})
}
}

View file

@ -39,17 +39,17 @@ func TestCORSHeaders(t *testing.T) {
}
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)
}
}
}

View file

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#00a8ff" stroke="#ffffff" stroke-width="1">
<path d="M12 2l-2 16 2-2 2 2-2-16z"/>
<path d="M4 10l8-2-1 2-7 0z"/>
<path d="M20 10l-8-2 1 2 7 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 224 B

View file

@ -1,488 +0,0 @@
* {
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;
}
.clock-section {
flex: 1;
display: flex;
justify-content: center;
}
.clock-display {
display: flex;
gap: 2rem;
}
.clock {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.clock-face {
position: relative;
width: 60px;
height: 60px;
border: 2px solid #00a8ff;
border-radius: 50%;
background: #1a1a1a;
}
.clock-face::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 4px;
background: #00a8ff;
border-radius: 50%;
transform: translate(-50%, -50%);
z-index: 3;
}
.clock-hand {
position: absolute;
background: #00a8ff;
transform-origin: bottom center;
border-radius: 2px;
}
.hour-hand {
width: 3px;
height: 18px;
top: 12px;
left: 50%;
margin-left: -1.5px;
}
.minute-hand {
width: 2px;
height: 25px;
top: 5px;
left: 50%;
margin-left: -1px;
}
.clock-label {
font-size: 0.8rem;
color: #888;
text-align: center;
}
.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;
}
.legend {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(45, 45, 45, 0.95);
border: 1px solid #404040;
border-radius: 8px;
padding: 1rem;
z-index: 1000;
min-width: 150px;
}
.legend h4 {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: #ffffff;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
font-size: 0.8rem;
}
.legend-icon {
width: 16px;
height: 16px;
border-radius: 2px;
border: 1px solid #ffffff;
}
.legend-icon.commercial { background: #00ff88; }
.legend-icon.cargo { background: #ff8c00; }
.legend-icon.military { background: #ff4444; }
.legend-icon.ga { background: #ffff00; }
.legend-icon.ground { background: #888888; }
.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;
}
.type-badge {
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: bold;
color: #000000;
}
.type-badge.commercial { background: #00ff88; }
.type-badge.cargo { background: #ff8c00; }
.type-badge.military { background: #ff4444; }
.type-badge.ga { background: #ffff00; }
.type-badge.ground { background: #888888; color: #ffffff; }
/* RSSI signal strength colors */
.rssi-strong { color: #00ff88; }
.rssi-good { color: #ffff00; }
.rssi-weak { color: #ff8c00; }
.rssi-poor { color: #ff4444; }
.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 {
transform: rotate(0deg);
filter: drop-shadow(0 0 4px rgba(0,0,0,0.9));
z-index: 1000;
}
.aircraft-popup {
min-width: 300px;
max-width: 400px;
}
.popup-header {
border-bottom: 1px solid #404040;
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.flight-info {
font-size: 1.1rem;
font-weight: bold;
}
.icao-flag {
font-size: 1.2rem;
margin-right: 0.5rem;
}
.flight-id {
color: #00a8ff;
font-family: monospace;
}
.callsign {
color: #00ff88;
}
.popup-details {
font-size: 0.9rem;
}
.detail-row {
margin-bottom: 0.5rem;
padding: 0.25rem 0;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin: 0.75rem 0;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-item .label {
font-size: 0.8rem;
color: #888;
margin-bottom: 0.1rem;
}
.detail-item .value {
font-weight: bold;
color: #ffffff;
}
@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;
}
}

View file

@ -1 +0,0 @@
data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

View file

@ -1,226 +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</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="toggle-trails" title="Show/hide aircraft trails">Show Trails</button>
<button id="toggle-range" title="Show/hide range circles">Show Range</button>
<button id="toggle-sources" title="Show/hide source locations">Show Sources</button>
</div>
<!-- Legend -->
<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>
<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</h3>
<canvas id="message-chart"></canvas>
</div>
<div class="chart-card">
<h3>Signal Strength Distribution</h3>
<canvas id="signal-chart"></canvas>
</div>
<div class="chart-card">
<h3>Altitude Distribution</h3>
<canvas id="altitude-chart"></canvas>
</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">
<button id="radar3d-reset">Reset View</button>
<button id="radar3d-auto-rotate">Auto Rotate</button>
<label>
<input type="range" id="radar3d-range" min="10" max="500" value="100">
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"></script>
</body>
</html>