Fix aircraft markers not updating positions in real-time

Root cause: The merger was blocking position updates from the same source
after the first position was established, designed for multi-source scenarios
but preventing single-source position updates.

Changes:
- Refactor JavaScript into modular architecture (WebSocketManager, AircraftManager, MapManager, UIManager)
- Add CPR coordinate validation to prevent invalid latitude/longitude values
- Fix merger to allow position updates from same source for moving aircraft
- Add comprehensive coordinate bounds checking in CPR decoder
- Update HTML to use new modular JavaScript with cache busting
- Add WebSocket debug logging to track data flow

Technical details:
- CPR decoder now validates coordinates within ±90° latitude, ±180° longitude
- Merger allows updates when currentBest == sourceID (same source continuous updates)
- JavaScript modules provide better separation of concerns and debugging
- WebSocket properly transmits updated aircraft coordinates to frontend

🤖 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 14:04:17 +02:00
commit 1de3e092ae
13 changed files with 2222 additions and 33 deletions

View file

@ -183,6 +183,7 @@ func (s *Server) setupRoutes() http.Handler {
api := router.PathPrefix("/api").Subrouter()
api.HandleFunc("/aircraft", s.handleGetAircraft).Methods("GET")
api.HandleFunc("/aircraft/{icao}", s.handleGetAircraftDetails).Methods("GET")
api.HandleFunc("/debug/aircraft", s.handleDebugAircraft).Methods("GET")
api.HandleFunc("/sources", s.handleGetSources).Methods("GET")
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
api.HandleFunc("/origin", s.handleGetOrigin).Methods("GET")
@ -203,29 +204,60 @@ func (s *Server) setupRoutes() http.Handler {
return s.enableCORS(router)
}
// isAircraftUseful determines if an aircraft has enough data to be useful for the frontend.
//
// DESIGN NOTE: We WANT reasonable aircraft to appear in our table view, even if they
// don't have enough data to appear on the map. This provides users visibility into
// all tracked aircraft, not just those with complete position data.
//
// Aircraft are considered useful if they have ANY of:
// - Valid position data (both latitude and longitude non-zero) -> Can show on map
// - Callsign (flight identification) -> Can show in table with "No position" status
// - Altitude information -> Can show in table as "Aircraft at X feet"
// - Any other identifying information that makes it a "real" aircraft
//
// This inclusive approach ensures the table view shows all aircraft we're tracking,
// while the map view only shows those with valid positions (handled by frontend filtering).
func (s *Server) isAircraftUseful(aircraft *merger.AircraftState) bool {
// Aircraft is useful if it has any meaningful data:
hasValidPosition := aircraft.Latitude != 0 && aircraft.Longitude != 0
hasCallsign := aircraft.Callsign != ""
hasAltitude := aircraft.Altitude != 0
hasSquawk := aircraft.Squawk != ""
// Include aircraft with any identifying or operational data
return hasValidPosition || hasCallsign || hasAltitude || hasSquawk
}
// handleGetAircraft serves the /api/aircraft endpoint.
// Returns all currently tracked aircraft with their latest state information.
//
// Only "useful" aircraft are returned - those with position data or callsign.
// This filters out incomplete aircraft that only have altitude or squawk codes,
// which are not actionable for frontend mapping and flight tracking.
//
// The response includes:
// - timestamp: Unix timestamp of the response
// - aircraft: Map of aircraft keyed by ICAO hex strings
// - count: Total number of aircraft
// - count: Total number of useful aircraft (filtered count)
//
// Aircraft ICAO addresses are converted from uint32 to 6-digit hex strings
// for consistent JSON representation (e.g., 0xABC123 -> "ABC123").
func (s *Server) handleGetAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
// Convert ICAO keys to hex strings for JSON
// Convert ICAO keys to hex strings for JSON and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"aircraft": aircraftMap,
"count": len(aircraft),
"count": len(aircraftMap), // Count of filtered useful aircraft
}
w.Header().Set("Content-Type", "application/json")
@ -478,10 +510,12 @@ func (s *Server) sendInitialData(conn *websocket.Conn) {
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
// Convert ICAO keys to hex strings
// Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
update := AircraftUpdate{
@ -555,9 +589,10 @@ func (s *Server) periodicUpdateRoutine() {
//
// This function:
// 1. Collects current aircraft data from the merger
// 2. Formats the data as a WebSocketMessage with type "aircraft_update"
// 3. Converts ICAO addresses to hex strings for JSON compatibility
// 4. Queues the message for broadcast (non-blocking)
// 2. Filters aircraft to only include "useful" ones (with position or callsign)
// 3. Formats the data as a WebSocketMessage with type "aircraft_update"
// 4. Converts ICAO addresses to hex strings for JSON compatibility
// 5. Queues the message for broadcast (non-blocking)
//
// If the broadcast channel is full, the update is dropped to prevent blocking.
// This ensures the system continues operating even if WebSocket clients
@ -567,10 +602,12 @@ func (s *Server) broadcastUpdate() {
sources := s.merger.GetSources()
stats := s.merger.GetStatistics()
// Convert ICAO keys to hex strings
// Convert ICAO keys to hex strings and filter useful aircraft
aircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
if s.isAircraftUseful(state) {
aircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
update := AircraftUpdate{
@ -711,3 +748,34 @@ func (s *Server) enableCORS(handler http.Handler) http.Handler {
handler.ServeHTTP(w, r)
})
}
// handleDebugAircraft serves the /api/debug/aircraft endpoint.
// Returns all aircraft (filtered and unfiltered) for debugging position issues.
func (s *Server) handleDebugAircraft(w http.ResponseWriter, r *http.Request) {
aircraft := s.merger.GetAircraft()
// All aircraft (unfiltered)
allAircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
allAircraftMap[fmt.Sprintf("%06X", icao)] = state
}
// Filtered aircraft (useful ones)
filteredAircraftMap := make(map[string]*merger.AircraftState)
for icao, state := range aircraft {
if s.isAircraftUseful(state) {
filteredAircraftMap[fmt.Sprintf("%06X", icao)] = state
}
}
response := map[string]interface{}{
"timestamp": time.Now().Unix(),
"all_aircraft": allAircraftMap,
"filtered_aircraft": filteredAircraftMap,
"all_count": len(allAircraftMap),
"filtered_count": len(filteredAircraftMap),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}