Enhance aircraft details display to match dump1090 format

- Add country lookup from ICAO hex codes with flag display
- Implement UTC clocks and last update time indicators
- Enhance aircraft table with ICAO, squawk, and RSSI columns
- Add color-coded RSSI signal strength indicators
- Fix calculateDistance returning string instead of number
- Accept all SBS-1 message types for complete data capture
- Improve data merging to preserve country and registration

🤖 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-23 23:20:31 +02:00
commit c8562a4f0d
5 changed files with 457 additions and 41 deletions

View file

@ -136,6 +136,14 @@ 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
}
if update.Registration != "" {
existing.Registration = update.Registration
}
}
func (c *Dump1090Client) shouldAddTrackPoint(existing, update *parser.Aircraft) bool {

View file

@ -30,6 +30,9 @@ type Aircraft struct {
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"`
}
type AircraftData struct {
@ -44,10 +47,10 @@ func ParseSBS1Line(line string) (*Aircraft, error) {
return nil, nil
}
messageType := parts[1]
if messageType != "1" && messageType != "3" && messageType != "4" {
return nil, nil
}
// messageType := parts[1]
// Accept all message types to get complete data
// MSG types: 1=ES_IDENT_AND_CATEGORY, 2=ES_SURFACE_POS, 3=ES_AIRBORNE_POS
// 4=ES_AIRBORNE_VEL, 5=SURVEILLANCE_ALT, 6=SURVEILLANCE_ID, 7=AIR_TO_AIR, 8=ALL_CALL_REPLY
aircraft := &Aircraft{
Hex: strings.TrimSpace(parts[4]),
@ -55,6 +58,8 @@ func ParseSBS1Line(line string) (*Aircraft, error) {
Messages: 1,
}
// Different message types contain different fields
// Always try to extract what's available
if parts[10] != "" {
aircraft.Flight = strings.TrimSpace(parts[10])
}
@ -72,8 +77,8 @@ func ParseSBS1Line(line string) (*Aircraft, error) {
}
if parts[13] != "" {
if track, err := strconv.Atoi(parts[13]); err == nil {
aircraft.Track = track
if track, err := strconv.ParseFloat(parts[13], 64); err == nil {
aircraft.Track = int(track)
}
}
@ -100,6 +105,121 @@ func ParseSBS1Line(line string) (*Aircraft, error) {
aircraft.OnGround = parts[21] == "1"
}
aircraft.Country = getCountryFromICAO(aircraft.Hex)
aircraft.Registration = getRegistrationFromICAO(aircraft.Hex)
return aircraft, nil
}
func getCountryFromICAO(icao string) string {
if len(icao) < 6 {
return "Unknown"
}
prefix := icao[:1]
switch prefix {
case "4":
return getCountryFrom4xxxx(icao)
case "A":
return "United States"
case "C":
return "Canada"
case "D":
return "Germany"
case "F":
return "France"
case "G":
return "United Kingdom"
case "I":
return "Italy"
case "J":
return "Japan"
case "P":
return getPCountry(icao)
case "S":
return getSCountry(icao)
case "O":
return getOCountry(icao)
default:
return "Unknown"
}
}
func getCountryFrom4xxxx(icao string) string {
if len(icao) >= 2 {
switch icao[:2] {
case "40":
return "United Kingdom"
case "44":
return "Austria"
case "45":
return "Denmark"
case "46":
return "Germany"
case "47":
return "Germany"
case "48":
return "Netherlands"
case "49":
return "Netherlands"
}
}
return "Europe"
}
func getPCountry(icao string) string {
if len(icao) >= 2 {
switch icao[:2] {
case "PH":
return "Netherlands"
case "PJ":
return "Netherlands Antilles"
}
}
return "Unknown"
}
func getSCountry(icao string) string {
if len(icao) >= 2 {
switch icao[:2] {
case "SE":
return "Sweden"
case "SX":
return "Greece"
}
}
return "Unknown"
}
func getOCountry(icao string) string {
if len(icao) >= 2 {
switch icao[:2] {
case "OO":
return "Belgium"
case "OH":
return "Finland"
}
}
return "Unknown"
}
func getRegistrationFromICAO(icao string) string {
// This is a simplified conversion - real registration lookup would need a database
country := getCountryFromICAO(icao)
switch country {
case "Germany":
return "D-" + icao[2:]
case "United Kingdom":
return "G-" + icao[2:]
case "France":
return "F-" + icao[2:]
case "Netherlands":
return "PH-" + icao[2:]
case "Sweden":
return "SE-" + icao[2:]
default:
return icao
}
}

View file

@ -27,6 +27,75 @@ body {
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;
@ -227,6 +296,12 @@ body {
.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));
@ -288,21 +363,65 @@ body {
}
.aircraft-popup {
min-width: 200px;
min-width: 300px;
max-width: 400px;
}
.aircraft-popup .flight {
font-weight: bold;
.popup-header {
border-bottom: 1px solid #404040;
padding-bottom: 0.5rem;
margin-bottom: 0.75rem;
}
.flight-info {
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: #00a8ff;
font-weight: bold;
}
.aircraft-popup .details {
.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.25rem;
font-size: 0.9rem;
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) {

View file

@ -12,6 +12,24 @@
<div id="app">
<header class="header">
<h1>SkyView</h1>
<div class="clock-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>
<div class="stats-summary">
<span id="aircraft-count">0 aircraft</span>
<span id="connection-status" class="connected">Connected</span>
@ -60,27 +78,32 @@
<div id="table-view" class="view">
<div class="table-controls">
<input type="text" id="search-input" placeholder="Search by flight, callsign, or hex...">
<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="age">Age</option>
<option value="rssi">RSSI</option>
</select>
</div>
<div class="table-container">
<table id="aircraft-table">
<thead>
<tr>
<th>ICAO</th>
<th>Flight</th>
<th>Hex</th>
<th>Type</th>
<th>Squawk</th>
<th>Altitude</th>
<th>Speed</th>
<th>Track</th>
<th>Distance</th>
<th>Track</th>
<th>Msgs</th>
<th>Seen</th>
<th>Age</th>
<th>RSSI</th>
</tr>
</thead>
<tbody id="aircraft-tbody">
@ -100,8 +123,8 @@
<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>
<h3>Avg RSSI</h3>
<div class="stat-value" id="signal-strength">0 dBFS</div>
</div>
<div class="stat-card">
<h3>Max Range</h3>

View file

@ -11,6 +11,7 @@ class SkyView {
this.currentView = 'map';
this.charts = {};
this.origin = { latitude: 37.7749, longitude: -122.4194, name: 'Default' };
this.lastUpdateTime = new Date();
this.init();
}
@ -22,6 +23,7 @@ class SkyView {
this.initializeWebSocket();
this.initializeEventListeners();
this.initializeCharts();
this.initializeClocks();
this.startPeriodicUpdates();
});
@ -188,8 +190,36 @@ class SkyView {
});
}
initializeClocks() {
this.updateClocks();
setInterval(() => this.updateClocks(), 1000);
}
updateClocks() {
const now = new Date();
const utcNow = new Date(now.getTime() + (now.getTimezoneOffset() * 60000));
this.updateClock('utc', utcNow);
this.updateClock('update', this.lastUpdateTime);
}
updateClock(prefix, time) {
const hours = time.getUTCHours();
const minutes = time.getUTCMinutes();
const hourAngle = (hours % 12) * 30 + minutes * 0.5;
const minuteAngle = minutes * 6;
const hourHand = document.getElementById(`${prefix}-hour`);
const minuteHand = document.getElementById(`${prefix}-minute`);
if (hourHand) hourHand.style.transform = `rotate(${hourAngle}deg)`;
if (minuteHand) minuteHand.style.transform = `rotate(${minuteAngle}deg)`;
}
updateAircraftData(data) {
this.aircraftData = data.aircraft || [];
this.lastUpdateTime = new Date();
this.updateMapMarkers();
this.updateAircraftTable();
this.updateStats();
@ -341,18 +371,100 @@ class SkyView {
}
createPopupContent(aircraft) {
const type = this.getAircraftType(aircraft);
const distance = this.calculateDistance(aircraft);
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
const altitudeM = aircraft.alt_baro ? Math.round(aircraft.alt_baro * 0.3048) : 'N/A';
const speedKmh = aircraft.gs ? Math.round(aircraft.gs * 1.852) : 'N/A';
const trackText = aircraft.track ? `${aircraft.track}° (${this.getTrackDirection(aircraft.track)})` : 'N/A';
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 class="popup-header">
<div class="flight-info">
<span class="icao-flag">${this.getCountryFlag(aircraft.country)}</span>
<span class="flight-id">${aircraft.hex}</span>
${aircraft.flight ? `→ <span class="callsign">${aircraft.flight}</span>` : ''}
</div>
</div>
<div class="popup-details">
<div class="detail-row">
<strong>Country of registration:</strong> ${aircraft.country || 'Unknown'}
</div>
<div class="detail-row">
<strong>Registration:</strong> ${aircraft.registration || aircraft.hex}
</div>
<div class="detail-row">
<strong>Type:</strong> ${type.charAt(0).toUpperCase() + type.slice(1)}
</div>
<div class="detail-grid">
<div class="detail-item">
<div class="label">Altitude:</div>
<div class="value">${aircraft.alt_baro ? `${aircraft.alt_baro} ft | ${altitudeM} m` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Squawk:</div>
<div class="value">${aircraft.squawk || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Speed:</div>
<div class="value">${aircraft.gs ? `${aircraft.gs} kt | ${speedKmh} km/h` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">RSSI:</div>
<div class="value">${aircraft.rssi ? `${aircraft.rssi.toFixed(1)} dBFS` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Track:</div>
<div class="value">${trackText}</div>
</div>
<div class="detail-item">
<div class="label">Last seen:</div>
<div class="value">${aircraft.seen ? `${aircraft.seen.toFixed(1)}s ago` : 'now'}</div>
</div>
</div>
<div class="detail-row">
<strong>Position:</strong> ${aircraft.lat && aircraft.lon ?
`${aircraft.lat.toFixed(3)}°, ${aircraft.lon.toFixed(3)}°` : 'N/A'}
</div>
<div class="detail-row">
<strong>Distance from Site:</strong> ${distance ? `${distance} NM | ${distanceKm} km` : 'N/A'}
</div>
</div>
</div>
`;
}
getCountryFlag(country) {
const flags = {
'United States': '🇺🇸',
'United Kingdom': '🇬🇧',
'Germany': '🇩🇪',
'France': '🇫🇷',
'Netherlands': '🇳🇱',
'Sweden': '🇸🇪',
'Spain': '🇪🇸',
'Italy': '🇮🇹',
'Canada': '🇨🇦',
'Japan': '🇯🇵',
'Denmark': '🇩🇰',
'Austria': '🇦🇹',
'Belgium': '🇧🇪',
'Finland': '🇫🇮',
'Greece': '🇬🇷'
};
return flags[country] || '🏳️';
}
getTrackDirection(track) {
const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
const index = Math.round(track / 22.5) % 16;
return directions[index];
}
updatePopupContent(marker, aircraft) {
marker.setPopupContent(this.createPopupContent(aircraft));
@ -416,7 +528,8 @@ class SkyView {
if (searchTerm) {
filteredData = filteredData.filter(aircraft =>
(aircraft.flight && aircraft.flight.toLowerCase().includes(searchTerm)) ||
aircraft.hex.toLowerCase().includes(searchTerm)
aircraft.hex.toLowerCase().includes(searchTerm) ||
(aircraft.squawk && aircraft.squawk.includes(searchTerm))
);
}
@ -425,19 +538,39 @@ class SkyView {
filteredData.forEach(aircraft => {
const type = this.getAircraftType(aircraft);
const typeDisplay = type.charAt(0).toUpperCase() + type.slice(1);
const country = aircraft.country || 'Unknown';
const countryFlag = this.getCountryFlag(country);
const age = aircraft.seen ? aircraft.seen.toFixed(0) : '0';
const distance = this.calculateDistance(aircraft);
const distanceStr = distance ? distance.toFixed(1) : '-';
const altitudeStr = aircraft.alt_baro ?
(aircraft.alt_baro >= 0 ? `${aircraft.alt_baro}` : `${Math.abs(aircraft.alt_baro)}`) :
'-';
const row = document.createElement('tr');
// Color code RSSI values
let rssiStr = '-';
let rssiClass = '';
if (aircraft.rssi) {
const rssi = aircraft.rssi;
rssiStr = rssi.toFixed(1);
if (rssi > -10) rssiClass = 'rssi-strong';
else if (rssi > -20) rssiClass = 'rssi-good';
else if (rssi > -30) rssiClass = 'rssi-weak';
else rssiClass = 'rssi-poor';
}
row.innerHTML = `
<td><span class="type-badge ${type}">${aircraft.hex}</span></td>
<td>${aircraft.flight || '-'}</td>
<td>${aircraft.hex}</td>
<td><span class="type-badge ${type}">${typeDisplay}</span></td>
<td>${aircraft.alt_baro || '-'}</td>
<td>${aircraft.squawk || '-'}</td>
<td>${altitudeStr}</td>
<td>${aircraft.gs || '-'}</td>
<td>${aircraft.track || '-'}</td>
<td>${this.calculateDistance(aircraft) || '-'}</td>
<td>${distanceStr}</td>
<td>${aircraft.track || '-'}°</td>
<td>${aircraft.messages || '-'}</td>
<td>${aircraft.seen ? aircraft.seen.toFixed(1) : '-'}s</td>
<td>${age}</td>
<td><span class="${rssiClass}">${rssiStr}</span></td>
`;
row.addEventListener('click', () => {
@ -482,6 +615,14 @@ class SkyView {
return (b.gs || 0) - (a.gs || 0);
case 'flight':
return (a.flight || a.hex).localeCompare(b.flight || b.hex);
case 'icao':
return a.hex.localeCompare(b.hex);
case 'squawk':
return (a.squawk || '').localeCompare(b.squawk || '');
case 'age':
return (a.seen || 0) - (b.seen || 0);
case 'rssi':
return (b.rssi || -999) - (a.rssi || -999);
default:
return 0;
}
@ -501,7 +642,7 @@ class SkyView {
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);
return R * c; // Return a number, not a string
}
updateStats() {
@ -512,11 +653,12 @@ class SkyView {
.filter(a => a.alt_baro)
.reduce((sum, a) => sum + a.alt_baro, 0) / this.aircraftData.length || 0;
const maxDistance = Math.max(...this.aircraftData
const distances = this.aircraftData
.map(a => this.calculateDistance(a))
.filter(d => d !== null)) || 0;
.filter(d => d !== null);
const maxDistance = distances.length > 0 ? Math.max(...distances) : 0;
document.getElementById('max-range').textContent = `${maxDistance} nm`;
document.getElementById('max-range').textContent = `${maxDistance.toFixed(1)} nm`;
this.updateChartData();
}
@ -589,9 +731,13 @@ class SkyView {
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`;
// Calculate average RSSI from aircraft data
const aircraftWithRSSI = this.aircraftData.filter(a => a.rssi);
if (aircraftWithRSSI.length > 0) {
const avgRSSI = aircraftWithRSSI.reduce((sum, a) => sum + a.rssi, 0) / aircraftWithRSSI.length;
document.getElementById('signal-strength').textContent = `${avgRSSI.toFixed(1)} dBFS`;
} else {
document.getElementById('signal-strength').textContent = '0 dBFS';
}
} catch (error) {
console.error('Failed to fetch stats:', error);