Add historical flight track functionality
- Store track history with position, altitude, speed, and timestamp
- Automatic track point collection every 30 seconds when position changes
- API endpoint /api/aircraft/{hex}/history for individual aircraft tracks
- Frontend "Show History" button to display historical flight paths
- Click aircraft markers to show their historical track (dashed red line)
- Track cleanup: keep last 200 points per aircraft, 24-hour retention
- Add aircraft type badges in table view with color coding
- Start/end markers for historical tracks with timestamps
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
b6a699c24b
commit
55710614da
6 changed files with 215 additions and 16 deletions
|
|
@ -106,11 +106,28 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
|
||||||
if update.Track != 0 {
|
if update.Track != 0 {
|
||||||
existing.Track = update.Track
|
existing.Track = update.Track
|
||||||
}
|
}
|
||||||
if update.Latitude != 0 {
|
if update.Latitude != 0 && update.Longitude != 0 {
|
||||||
existing.Latitude = update.Latitude
|
existing.Latitude = update.Latitude
|
||||||
}
|
|
||||||
if update.Longitude != 0 {
|
|
||||||
existing.Longitude = update.Longitude
|
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 {
|
if update.VertRate != 0 {
|
||||||
existing.VertRate = update.VertRate
|
existing.VertRate = update.VertRate
|
||||||
|
|
@ -121,6 +138,28 @@ func (c *Dump1090Client) updateExistingAircraft(existing, update *parser.Aircraf
|
||||||
existing.OnGround = update.OnGround
|
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 {
|
func (c *Dump1090Client) GetAircraftData() parser.AircraftData {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
|
@ -201,9 +240,20 @@ func (c *Dump1090Client) cleanupStaleAircraft() {
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
cutoff := time.Now().Add(-2 * time.Minute)
|
cutoff := time.Now().Add(-2 * time.Minute)
|
||||||
|
trackCutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
for hex, aircraft := range c.aircraftMap {
|
for hex, aircraft := range c.aircraftMap {
|
||||||
if aircraft.LastSeen.Before(cutoff) {
|
if aircraft.LastSeen.Before(cutoff) {
|
||||||
delete(c.aircraftMap, hex)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,6 +6,15 @@ import (
|
||||||
"time"
|
"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 {
|
type Aircraft struct {
|
||||||
Hex string `json:"hex"`
|
Hex string `json:"hex"`
|
||||||
Flight string `json:"flight,omitempty"`
|
Flight string `json:"flight,omitempty"`
|
||||||
|
|
@ -20,6 +29,7 @@ type Aircraft struct {
|
||||||
OnGround bool `json:"on_ground,omitempty"`
|
OnGround bool `json:"on_ground,omitempty"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
LastSeen time.Time `json:"last_seen"`
|
||||||
Messages int `json:"messages"`
|
Messages int `json:"messages"`
|
||||||
|
TrackHistory []TrackPoint `json:"track_history,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AircraftData struct {
|
type AircraftData struct {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ func New(cfg *config.Config, staticFiles embed.FS, ctx context.Context) http.Han
|
||||||
|
|
||||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||||
apiRouter.HandleFunc("/aircraft", s.getAircraft).Methods("GET")
|
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("/stats", s.getStats).Methods("GET")
|
||||||
apiRouter.HandleFunc("/config", s.getConfig).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)
|
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) {
|
func (s *Server) getConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
configData := map[string]interface{}{
|
configData := map[string]interface{}{
|
||||||
"origin": map[string]interface{}{
|
"origin": map[string]interface{}{
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,20 @@ body {
|
||||||
background: #404040;
|
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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<button id="center-map">Center Map</button>
|
<button id="center-map">Center Map</button>
|
||||||
<button id="toggle-trails">Toggle Trails</button>
|
<button id="toggle-trails">Toggle Trails</button>
|
||||||
|
<button id="toggle-history">Show History</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="legend">
|
<div class="legend">
|
||||||
|
|
@ -73,6 +74,7 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th>Flight</th>
|
<th>Flight</th>
|
||||||
<th>Hex</th>
|
<th>Hex</th>
|
||||||
|
<th>Type</th>
|
||||||
<th>Altitude</th>
|
<th>Altitude</th>
|
||||||
<th>Speed</th>
|
<th>Speed</th>
|
||||||
<th>Track</th>
|
<th>Track</th>
|
||||||
|
|
|
||||||
101
static/js/app.js
101
static/js/app.js
|
|
@ -3,9 +3,11 @@ class SkyView {
|
||||||
this.map = null;
|
this.map = null;
|
||||||
this.aircraftMarkers = new Map();
|
this.aircraftMarkers = new Map();
|
||||||
this.aircraftTrails = new Map();
|
this.aircraftTrails = new Map();
|
||||||
|
this.historicalTracks = new Map();
|
||||||
this.websocket = null;
|
this.websocket = null;
|
||||||
this.aircraftData = [];
|
this.aircraftData = [];
|
||||||
this.showTrails = false;
|
this.showTrails = false;
|
||||||
|
this.showHistoricalTracks = false;
|
||||||
this.currentView = 'map';
|
this.currentView = 'map';
|
||||||
this.charts = {};
|
this.charts = {};
|
||||||
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
|
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
|
||||||
|
|
@ -95,6 +97,9 @@ class SkyView {
|
||||||
|
|
||||||
const trailsBtn = document.getElementById('toggle-trails');
|
const trailsBtn = document.getElementById('toggle-trails');
|
||||||
trailsBtn.addEventListener('click', () => this.toggleTrails());
|
trailsBtn.addEventListener('click', () => this.toggleTrails());
|
||||||
|
|
||||||
|
const historyBtn = document.getElementById('toggle-history');
|
||||||
|
historyBtn.addEventListener('click', () => this.toggleHistoricalTracks());
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeWebSocket() {
|
initializeWebSocket() {
|
||||||
|
|
@ -220,6 +225,10 @@ class SkyView {
|
||||||
if (this.showTrails) {
|
if (this.showTrails) {
|
||||||
this.updateTrail(aircraft.hex, pos);
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,6 +249,12 @@ class SkyView {
|
||||||
className: 'aircraft-popup'
|
className: 'aircraft-popup'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
marker.on('click', () => {
|
||||||
|
if (this.showHistoricalTracks) {
|
||||||
|
this.loadAircraftHistory(aircraft.hex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return marker;
|
return marker;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,10 +424,14 @@ class SkyView {
|
||||||
this.sortAircraft(filteredData, sortBy);
|
this.sortAircraft(filteredData, sortBy);
|
||||||
|
|
||||||
filteredData.forEach(aircraft => {
|
filteredData.forEach(aircraft => {
|
||||||
|
const type = this.getAircraftType(aircraft);
|
||||||
|
const typeDisplay = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<td>${aircraft.flight || '-'}</td>
|
<td>${aircraft.flight || '-'}</td>
|
||||||
<td>${aircraft.hex}</td>
|
<td>${aircraft.hex}</td>
|
||||||
|
<td><span class="type-badge ${type}">${typeDisplay}</span></td>
|
||||||
<td>${aircraft.alt_baro || '-'}</td>
|
<td>${aircraft.alt_baro || '-'}</td>
|
||||||
<td>${aircraft.gs || '-'}</td>
|
<td>${aircraft.gs || '-'}</td>
|
||||||
<td>${aircraft.track || '-'}</td>
|
<td>${aircraft.track || '-'}</td>
|
||||||
|
|
@ -578,6 +597,88 @@ class SkyView {
|
||||||
console.error('Failed to fetch stats:', error);
|
console.error('Failed to fetch stats:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleHistoricalTracks() {
|
||||||
|
this.showHistoricalTracks = !this.showHistoricalTracks;
|
||||||
|
|
||||||
|
const btn = document.getElementById('toggle-history');
|
||||||
|
btn.textContent = this.showHistoricalTracks ? 'Hide History' : 'Show History';
|
||||||
|
|
||||||
|
if (!this.showHistoricalTracks) {
|
||||||
|
this.clearAllHistoricalTracks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAircraftHistory(hex) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/aircraft/${hex}/history`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.track_history && data.track_history.length > 1) {
|
||||||
|
this.displayHistoricalTrack(hex, data.track_history, data.flight);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load aircraft history:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayHistoricalTrack(hex, trackHistory, flight) {
|
||||||
|
this.clearHistoricalTrack(hex);
|
||||||
|
|
||||||
|
const points = trackHistory.map(point => [point.lat, point.lon]);
|
||||||
|
|
||||||
|
const polyline = L.polyline(points, {
|
||||||
|
color: '#ff6b6b',
|
||||||
|
weight: 3,
|
||||||
|
opacity: 0.8,
|
||||||
|
dashArray: '5, 5'
|
||||||
|
}).addTo(this.map);
|
||||||
|
|
||||||
|
polyline.bindPopup(`<b>Historical Track</b><br>${flight || hex}<br>${trackHistory.length} points`);
|
||||||
|
|
||||||
|
this.historicalTracks.set(hex, polyline);
|
||||||
|
|
||||||
|
// Add start/end markers
|
||||||
|
if (trackHistory.length > 0) {
|
||||||
|
const start = trackHistory[0];
|
||||||
|
const end = trackHistory[trackHistory.length - 1];
|
||||||
|
|
||||||
|
L.circleMarker([start.lat, start.lon], {
|
||||||
|
color: '#ffffff',
|
||||||
|
fillColor: '#00ff00',
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
radius: 4
|
||||||
|
}).addTo(this.map).bindPopup(`<b>Start</b><br>${new Date(start.timestamp).toLocaleString()}`);
|
||||||
|
|
||||||
|
L.circleMarker([end.lat, end.lon], {
|
||||||
|
color: '#ffffff',
|
||||||
|
fillColor: '#ff0000',
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
radius: 4
|
||||||
|
}).addTo(this.map).bindPopup(`<b>End</b><br>${new Date(end.timestamp).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHistoricalTrack(hex) {
|
||||||
|
if (this.historicalTracks.has(hex)) {
|
||||||
|
this.map.removeLayer(this.historicalTracks.get(hex));
|
||||||
|
this.historicalTracks.delete(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAllHistoricalTracks() {
|
||||||
|
this.historicalTracks.forEach(track => {
|
||||||
|
this.map.removeLayer(track);
|
||||||
|
});
|
||||||
|
this.historicalTracks.clear();
|
||||||
|
|
||||||
|
// Also remove start/end markers
|
||||||
|
this.map.eachLayer(layer => {
|
||||||
|
if (layer instanceof L.CircleMarker) {
|
||||||
|
this.map.removeLayer(layer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue