diff --git a/internal/client/dump1090.go b/internal/client/dump1090.go index 99ca30d..1c1510f 100644 --- a/internal/client/dump1090.go +++ b/internal/client/dump1090.go @@ -106,11 +106,28 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf if update.Track != 0 { existing.Track = update.Track } - if update.Latitude != 0 { + if update.Latitude != 0 && update.Longitude != 0 { existing.Latitude = update.Latitude - } - if update.Longitude != 0 { existing.Longitude = update.Longitude + + // Add to track history if position changed significantly + if c.shouldAddTrackPoint(existing, update) { + trackPoint := parser.TrackPoint{ + Timestamp: update.LastSeen, + Latitude: update.Latitude, + Longitude: update.Longitude, + Altitude: update.Altitude, + Speed: update.GroundSpeed, + Track: update.Track, + } + + existing.TrackHistory = append(existing.TrackHistory, trackPoint) + + // Keep only last 200 points (about 3-4 hours at 1 point/minute) + if len(existing.TrackHistory) > 200 { + existing.TrackHistory = existing.TrackHistory[1:] + } + } } if update.VertRate != 0 { existing.VertRate = update.VertRate @@ -121,6 +138,28 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf existing.OnGround = update.OnGround } +func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft) bool { + // Add track point if: + // 1. No history yet + if len(existing.TrackHistory) == 0 { + return true + } + + lastPoint := existing.TrackHistory[len(existing.TrackHistory)-1] + + // 2. At least 30 seconds since last point + if time.Since(lastPoint.Timestamp) < 30*time.Second { + return false + } + + // 3. Position changed by at least 0.001 degrees (~100m) + latDiff := existing.Latitude - lastPoint.Latitude + lonDiff := existing.Longitude - lastPoint.Longitude + distanceChange := latDiff*latDiff + lonDiff*lonDiff + + return distanceChange > 0.000001 // ~0.001 degrees squared +} + func (c *Dump1090Client) GetAircraftData() parser.AircraftData { c.mutex.RLock() defer c.mutex.RUnlock() @@ -201,9 +240,20 @@ func (c *Dump1090Client) cleanupStaleAircraft() { defer c.mutex.Unlock() cutoff := time.Now().Add(-2 * time.Minute) + trackCutoff := time.Now().Add(-24 * time.Hour) + for hex, aircraft := range c.aircraftMap { if aircraft.LastSeen.Before(cutoff) { delete(c.aircraftMap, hex) + } else { + // Clean up old track points (keep last 24 hours) + validTracks := make([]parser.TrackPoint, 0) + for _, point := range aircraft.TrackHistory { + if point.Timestamp.After(trackCutoff) { + validTracks = append(validTracks, point) + } + } + aircraft.TrackHistory = validTracks } } } \ No newline at end of file diff --git a/internal/parser/sbs1.go b/internal/parser/sbs1.go index 92d76d4..7bb66ee 100644 --- a/internal/parser/sbs1.go +++ b/internal/parser/sbs1.go @@ -6,20 +6,30 @@ import ( "time" ) +type TrackPoint struct { + Timestamp time.Time `json:"timestamp"` + Latitude float64 `json:"lat"` + Longitude float64 `json:"lon"` + Altitude int `json:"altitude"` + Speed int `json:"speed"` + Track int `json:"track"` +} + type Aircraft struct { - Hex string `json:"hex"` - Flight string `json:"flight,omitempty"` - Altitude int `json:"alt_baro,omitempty"` - GroundSpeed int `json:"gs,omitempty"` - Track int `json:"track,omitempty"` - Latitude float64 `json:"lat,omitempty"` - Longitude float64 `json:"lon,omitempty"` - VertRate int `json:"vert_rate,omitempty"` - Squawk string `json:"squawk,omitempty"` - Emergency bool `json:"emergency,omitempty"` - OnGround bool `json:"on_ground,omitempty"` - LastSeen time.Time `json:"last_seen"` - Messages int `json:"messages"` + 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"` } type AircraftData struct { diff --git a/internal/server/server.go b/internal/server/server.go index 6e311df..0ba4f4c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,6 +61,7 @@ func New(cfg *config.Config, staticFiles embed.FS, ctx context.Context) http.Han apiRouter := router.PathPrefix("/api").Subrouter() apiRouter.HandleFunc("/aircraft", s.getAircraft).Methods("GET") + apiRouter.HandleFunc("/aircraft/{hex}/history", s.getAircraftHistory).Methods("GET") apiRouter.HandleFunc("/stats", s.getStats).Methods("GET") apiRouter.HandleFunc("/config", s.getConfig).Methods("GET") @@ -122,6 +123,27 @@ func (s *Server) getStats(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(stats) } +func (s *Server) getAircraftHistory(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + hex := vars["hex"] + + data := s.dump1090.GetAircraftData() + aircraft, exists := data.Aircraft[hex] + if !exists { + http.Error(w, "Aircraft not found", http.StatusNotFound) + return + } + + response := map[string]interface{}{ + "hex": aircraft.Hex, + "flight": aircraft.Flight, + "track_history": aircraft.TrackHistory, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) { configData := map[string]interface{}{ "origin": map[string]interface{}{ diff --git a/static/css/style.css b/static/css/style.css index cba00a8..c4e9c1a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -213,6 +213,20 @@ body { 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; } + .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); diff --git a/static/index.html b/static/index.html index b5cac57..1298550 100644 --- a/static/index.html +++ b/static/index.html @@ -30,6 +30,7 @@
+
@@ -73,6 +74,7 @@ Flight Hex + Type Altitude Speed Track diff --git a/static/js/app.js b/static/js/app.js index 84be511..68116e0 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3,9 +3,11 @@ class SkyView { this.map = null; this.aircraftMarkers = new Map(); this.aircraftTrails = new Map(); + this.historicalTracks = new Map(); this.websocket = null; this.aircraftData = []; this.showTrails = false; + this.showHistoricalTracks = false; this.currentView = 'map'; this.charts = {}; this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' }; @@ -95,6 +97,9 @@ class SkyView { const trailsBtn = document.getElementById('toggle-trails'); trailsBtn.addEventListener('click', () => this.toggleTrails()); + + const historyBtn = document.getElementById('toggle-history'); + historyBtn.addEventListener('click', () => this.toggleHistoricalTracks()); } initializeWebSocket() { @@ -220,6 +225,10 @@ class SkyView { if (this.showTrails) { this.updateTrail(aircraft.hex, pos); } + + if (this.showHistoricalTracks && aircraft.track_history && aircraft.track_history.length > 1) { + this.displayHistoricalTrack(aircraft.hex, aircraft.track_history, aircraft.flight); + } }); } @@ -239,6 +248,12 @@ class SkyView { marker.bindPopup(this.createPopupContent(aircraft), { className: 'aircraft-popup' }); + + marker.on('click', () => { + if (this.showHistoricalTracks) { + this.loadAircraftHistory(aircraft.hex); + } + }); return marker; } @@ -409,10 +424,14 @@ class SkyView { this.sortAircraft(filteredData, sortBy); filteredData.forEach(aircraft => { + const type = this.getAircraftType(aircraft); + const typeDisplay = type.charAt(0).toUpperCase() + type.slice(1); + const row = document.createElement('tr'); row.innerHTML = ` ${aircraft.flight || '-'} ${aircraft.hex} + ${typeDisplay} ${aircraft.alt_baro || '-'} ${aircraft.gs || '-'} ${aircraft.track || '-'} @@ -578,6 +597,88 @@ class SkyView { console.error('Failed to fetch stats:', error); } } + + toggleHistoricalTracks() { + this.showHistoricalTracks = !this.showHistoricalTracks; + + const btn = document.getElementById('toggle-history'); + btn.textContent = this.showHistoricalTracks ? 'Hide History' : 'Show History'; + + if (!this.showHistoricalTracks) { + this.clearAllHistoricalTracks(); + } + } + + async loadAircraftHistory(hex) { + try { + const response = await fetch(`/api/aircraft/${hex}/history`); + const data = await response.json(); + + if (data.track_history && data.track_history.length > 1) { + this.displayHistoricalTrack(hex, data.track_history, data.flight); + } + } catch (error) { + console.error('Failed to load aircraft history:', error); + } + } + + displayHistoricalTrack(hex, trackHistory, flight) { + this.clearHistoricalTrack(hex); + + const points = trackHistory.map(point => [point.lat, point.lon]); + + const polyline = L.polyline(points, { + color: '#ff6b6b', + weight: 3, + opacity: 0.8, + dashArray: '5, 5' + }).addTo(this.map); + + polyline.bindPopup(`Historical Track
${flight || hex}
${trackHistory.length} points`); + + this.historicalTracks.set(hex, polyline); + + // Add start/end markers + if (trackHistory.length > 0) { + const start = trackHistory[0]; + const end = trackHistory[trackHistory.length - 1]; + + L.circleMarker([start.lat, start.lon], { + color: '#ffffff', + fillColor: '#00ff00', + fillOpacity: 0.8, + radius: 4 + }).addTo(this.map).bindPopup(`Start
${new Date(start.timestamp).toLocaleString()}`); + + L.circleMarker([end.lat, end.lon], { + color: '#ffffff', + fillColor: '#ff0000', + fillOpacity: 0.8, + radius: 4 + }).addTo(this.map).bindPopup(`End
${new Date(end.timestamp).toLocaleString()}`); + } + } + + clearHistoricalTrack(hex) { + if (this.historicalTracks.has(hex)) { + this.map.removeLayer(this.historicalTracks.get(hex)); + this.historicalTracks.delete(hex); + } + } + + clearAllHistoricalTracks() { + this.historicalTracks.forEach(track => { + this.map.removeLayer(track); + }); + this.historicalTracks.clear(); + + // Also remove start/end markers + this.map.eachLayer(layer => { + if (layer instanceof L.CircleMarker) { + this.map.removeLayer(layer); + } + }); + } } document.addEventListener('DOMContentLoaded', () => {