Resolves issue #3 where aircraft age field was always showing 0 seconds. Backend Changes: - Fix MarshalJSON in merger.go to calculate age dynamically using time.Since(a.LastUpdate) - Add comprehensive tests for age calculation in merger_test.go Frontend Changes: - Add calculateAge() utility method to both aircraft-manager.js and ui-manager.js - Update popup content and aircraft table to use client-side age calculation - Implement real-time age updates every second in app.js - Add updateOpenPopupAges() function to refresh popup displays Key Benefits: - Aircraft age now shows actual seconds since last transmission - Real-time updates in UI without requiring new WebSocket data - Proper data staleness indicators for aviation tracking - No additional server load - client-side calculation using last_update timestamp 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
404 lines
No EOL
16 KiB
JavaScript
404 lines
No EOL
16 KiB
JavaScript
// UI and table management module
|
|
export class UIManager {
|
|
constructor() {
|
|
this.aircraftData = new Map();
|
|
this.sourcesData = new Map();
|
|
this.stats = {};
|
|
this.currentView = 'map-view';
|
|
this.lastUpdateTime = new Date();
|
|
}
|
|
|
|
initializeViews() {
|
|
const viewButtons = document.querySelectorAll('.view-btn');
|
|
const views = document.querySelectorAll('.view');
|
|
|
|
viewButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const viewId = btn.id.replace('-btn', '');
|
|
this.switchView(viewId);
|
|
});
|
|
});
|
|
}
|
|
|
|
switchView(viewId) {
|
|
// Update buttons
|
|
document.querySelectorAll('.view-btn').forEach(btn => btn.classList.remove('active'));
|
|
const activeBtn = document.getElementById(`${viewId}-btn`);
|
|
if (activeBtn) {
|
|
activeBtn.classList.add('active');
|
|
}
|
|
|
|
// Update views (viewId already includes the full view ID like "map-view")
|
|
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'));
|
|
const activeView = document.getElementById(viewId);
|
|
if (activeView) {
|
|
activeView.classList.add('active');
|
|
} else {
|
|
console.warn(`View element not found: ${viewId}`);
|
|
return;
|
|
}
|
|
|
|
this.currentView = viewId;
|
|
return viewId;
|
|
}
|
|
|
|
updateData(data) {
|
|
// Update aircraft data
|
|
if (data.aircraft) {
|
|
this.aircraftData.clear();
|
|
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
this.aircraftData.set(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
// Update sources data
|
|
if (data.sources) {
|
|
this.sourcesData.clear();
|
|
data.sources.forEach(source => {
|
|
this.sourcesData.set(source.id, source);
|
|
});
|
|
}
|
|
|
|
// Update statistics
|
|
if (data.stats) {
|
|
this.stats = data.stats;
|
|
}
|
|
|
|
this.lastUpdateTime = new Date();
|
|
}
|
|
|
|
updateAircraftTable() {
|
|
// Note: This table shows ALL aircraft we're tracking, including those without
|
|
// position data. Aircraft without positions will show "No position" in the
|
|
// location column but still provide useful info like callsign, altitude, etc.
|
|
const tbody = document.getElementById('aircraft-tbody');
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
let filteredData = Array.from(this.aircraftData.values());
|
|
|
|
// Apply filters
|
|
const searchTerm = document.getElementById('search-input')?.value.toLowerCase() || '';
|
|
const sourceFilter = document.getElementById('source-filter')?.value || '';
|
|
|
|
if (searchTerm) {
|
|
filteredData = filteredData.filter(aircraft =>
|
|
(aircraft.Callsign && aircraft.Callsign.toLowerCase().includes(searchTerm)) ||
|
|
(aircraft.ICAO24 && aircraft.ICAO24.toLowerCase().includes(searchTerm)) ||
|
|
(aircraft.Squawk && aircraft.Squawk.includes(searchTerm))
|
|
);
|
|
}
|
|
|
|
if (sourceFilter) {
|
|
filteredData = filteredData.filter(aircraft =>
|
|
aircraft.sources && aircraft.sources[sourceFilter]
|
|
);
|
|
}
|
|
|
|
// Sort data
|
|
const sortBy = document.getElementById('sort-select')?.value || 'distance';
|
|
this.sortAircraft(filteredData, sortBy);
|
|
|
|
// Populate table
|
|
filteredData.forEach(aircraft => {
|
|
const row = this.createTableRow(aircraft);
|
|
tbody.appendChild(row);
|
|
});
|
|
|
|
// Update source filter options
|
|
this.updateSourceFilter();
|
|
}
|
|
|
|
createTableRow(aircraft) {
|
|
const type = this.getAircraftType(aircraft);
|
|
const icao = aircraft.ICAO24 || 'N/A';
|
|
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
|
|
const distance = this.calculateDistance(aircraft);
|
|
const sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
|
|
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
|
|
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td><span class="type-badge ${type}">${icao}</span></td>
|
|
<td>${aircraft.Callsign || '-'}</td>
|
|
<td>${this.formatSquawk(aircraft)}</td>
|
|
<td>${altitude ? `${altitude} ft` : '-'}</td>
|
|
<td>${aircraft.GroundSpeed || '-'} kt</td>
|
|
<td>${distance ? distance.toFixed(1) : '-'} km</td>
|
|
<td>${aircraft.Track || '-'}°</td>
|
|
<td>${sources}</td>
|
|
<td><span class="${this.getSignalClass(bestSignal)}">${bestSignal ? bestSignal.toFixed(1) : '-'}</span></td>
|
|
<td>${this.calculateAge(aircraft).toFixed(0)}s</td>
|
|
`;
|
|
|
|
row.addEventListener('click', () => {
|
|
if (aircraft.Latitude && aircraft.Longitude) {
|
|
// Trigger event to switch to map and focus on aircraft
|
|
const event = new CustomEvent('aircraftSelected', {
|
|
detail: { icao, aircraft }
|
|
});
|
|
document.dispatchEvent(event);
|
|
}
|
|
});
|
|
|
|
return row;
|
|
}
|
|
|
|
getAircraftType(aircraft) {
|
|
if (aircraft.OnGround) return 'ground';
|
|
if (aircraft.Category) {
|
|
const cat = aircraft.Category.toLowerCase();
|
|
if (cat.includes('military')) return 'military';
|
|
if (cat.includes('cargo') || cat.includes('heavy')) return 'cargo';
|
|
if (cat.includes('light') || cat.includes('glider')) return 'ga';
|
|
}
|
|
if (aircraft.Callsign) {
|
|
const cs = aircraft.Callsign.toLowerCase();
|
|
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy')) return 'military';
|
|
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups')) return 'cargo';
|
|
}
|
|
return 'commercial';
|
|
}
|
|
|
|
calculateAge(aircraft) {
|
|
if (!aircraft.last_update) return 0;
|
|
|
|
const lastUpdate = new Date(aircraft.last_update);
|
|
const now = new Date();
|
|
const ageMs = now.getTime() - lastUpdate.getTime();
|
|
|
|
return Math.max(0, ageMs / 1000); // Return age in seconds, minimum 0
|
|
}
|
|
|
|
getBestSignalFromSources(sources) {
|
|
if (!sources) return null;
|
|
let bestSignal = -999;
|
|
for (const [id, data] of Object.entries(sources)) {
|
|
if (data.signal_level > bestSignal) {
|
|
bestSignal = data.signal_level;
|
|
}
|
|
}
|
|
return bestSignal === -999 ? null : bestSignal;
|
|
}
|
|
|
|
getSignalClass(signal) {
|
|
if (!signal) return '';
|
|
if (signal > -10) return 'signal-strong';
|
|
if (signal > -20) return 'signal-good';
|
|
if (signal > -30) return 'signal-weak';
|
|
return 'signal-poor';
|
|
}
|
|
|
|
formatSquawk(aircraft) {
|
|
if (!aircraft.Squawk) return '-';
|
|
|
|
// If we have a description, format it nicely
|
|
if (aircraft.SquawkDescription) {
|
|
// Check if it's an emergency code (contains warning emoji)
|
|
if (aircraft.SquawkDescription.includes('⚠️')) {
|
|
return `<span class="squawk-emergency" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
|
|
}
|
|
// Check if it's a special code (contains special emoji)
|
|
else if (aircraft.SquawkDescription.includes('🔸')) {
|
|
return `<span class="squawk-special" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
|
|
}
|
|
// Check if it's a military code (contains military emoji)
|
|
else if (aircraft.SquawkDescription.includes('🔰')) {
|
|
return `<span class="squawk-military" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
|
|
}
|
|
// Standard codes
|
|
else {
|
|
return `<span class="squawk-standard" title="${aircraft.SquawkDescription}">${aircraft.Squawk}</span>`;
|
|
}
|
|
}
|
|
|
|
// No description available, show just the code
|
|
return aircraft.Squawk;
|
|
}
|
|
|
|
updateSourceFilter() {
|
|
const select = document.getElementById('source-filter');
|
|
if (!select) return;
|
|
|
|
const currentValue = select.value;
|
|
|
|
// Clear options except "All Sources"
|
|
select.innerHTML = '<option value="">All Sources</option>';
|
|
|
|
// Add source options
|
|
for (const [id, source] of this.sourcesData) {
|
|
const option = document.createElement('option');
|
|
option.value = id;
|
|
option.textContent = source.name;
|
|
if (id === currentValue) option.selected = true;
|
|
select.appendChild(option);
|
|
}
|
|
}
|
|
|
|
sortAircraft(aircraft, sortBy) {
|
|
aircraft.sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'distance':
|
|
return (this.calculateDistance(a) || Infinity) - (this.calculateDistance(b) || Infinity);
|
|
case 'altitude':
|
|
return (b.Altitude || b.BaroAltitude || 0) - (a.Altitude || a.BaroAltitude || 0);
|
|
case 'speed':
|
|
return (b.GroundSpeed || 0) - (a.GroundSpeed || 0);
|
|
case 'flight':
|
|
return (a.Callsign || a.ICAO24 || '').localeCompare(b.Callsign || b.ICAO24 || '');
|
|
case 'icao':
|
|
return (a.ICAO24 || '').localeCompare(b.ICAO24 || '');
|
|
case 'squawk':
|
|
return (a.Squawk || '').localeCompare(b.Squawk || '');
|
|
case 'signal':
|
|
return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999);
|
|
case 'age':
|
|
return (a.Age || 0) - (b.Age || 0);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
calculateDistance(aircraft) {
|
|
if (!aircraft.Latitude || !aircraft.Longitude) return null;
|
|
|
|
// Use closest source as reference point
|
|
let minDistance = Infinity;
|
|
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
|
|
if (srcData.distance && srcData.distance < minDistance) {
|
|
minDistance = srcData.distance;
|
|
}
|
|
}
|
|
|
|
return minDistance === Infinity ? null : minDistance;
|
|
}
|
|
|
|
updateStatistics() {
|
|
const totalAircraftEl = document.getElementById('total-aircraft');
|
|
const activeSourcesEl = document.getElementById('active-sources');
|
|
const activeViewersEl = document.getElementById('active-viewers');
|
|
const maxRangeEl = document.getElementById('max-range');
|
|
const messagesSecEl = document.getElementById('messages-sec');
|
|
const aircraftWithPositionEl = document.getElementById('aircraft-with-position');
|
|
const aircraftWithoutPositionEl = document.getElementById('aircraft-without-position');
|
|
|
|
if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size;
|
|
if (activeSourcesEl) {
|
|
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
|
|
}
|
|
if (activeViewersEl) {
|
|
activeViewersEl.textContent = this.stats.active_clients || 1;
|
|
}
|
|
|
|
// Update position tracking statistics from backend
|
|
if (aircraftWithPositionEl) {
|
|
aircraftWithPositionEl.textContent = this.stats.aircraft_with_position || 0;
|
|
}
|
|
if (aircraftWithoutPositionEl) {
|
|
aircraftWithoutPositionEl.textContent = this.stats.aircraft_without_position || 0;
|
|
}
|
|
|
|
// Calculate max range
|
|
let maxDistance = 0;
|
|
for (const aircraft of this.aircraftData.values()) {
|
|
const distance = this.calculateDistance(aircraft);
|
|
if (distance && distance > maxDistance) {
|
|
maxDistance = distance;
|
|
}
|
|
}
|
|
if (maxRangeEl) maxRangeEl.textContent = `${maxDistance.toFixed(1)} km`;
|
|
|
|
// Update message rate
|
|
const totalMessages = this.stats.total_messages || 0;
|
|
if (messagesSecEl) messagesSecEl.textContent = Math.round(totalMessages / 60);
|
|
}
|
|
|
|
updateHeaderInfo() {
|
|
const aircraftCountEl = document.getElementById('aircraft-count');
|
|
const positionSummaryEl = document.getElementById('position-summary');
|
|
const sourcesCountEl = document.getElementById('sources-count');
|
|
const activeClientsEl = document.getElementById('active-clients');
|
|
|
|
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
|
|
|
|
// Update position summary in header
|
|
if (positionSummaryEl) {
|
|
const positioned = this.stats.aircraft_with_position || 0;
|
|
positionSummaryEl.textContent = `${positioned} positioned`;
|
|
}
|
|
|
|
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
|
|
|
|
// Update active clients count
|
|
const clientCount = this.stats.active_clients || 1;
|
|
if (activeClientsEl) {
|
|
const clientText = clientCount === 1 ? 'viewer' : 'viewers';
|
|
activeClientsEl.textContent = `${clientCount} ${clientText}`;
|
|
}
|
|
|
|
this.updateClocks();
|
|
}
|
|
|
|
updateConnectionStatus(status) {
|
|
const statusEl = document.getElementById('connection-status');
|
|
if (statusEl) {
|
|
statusEl.className = `connection-status ${status}`;
|
|
statusEl.textContent = status === 'connected' ? 'Connected' : 'Disconnected';
|
|
}
|
|
}
|
|
|
|
initializeEventListeners() {
|
|
const searchInput = document.getElementById('search-input');
|
|
const sortSelect = document.getElementById('sort-select');
|
|
const sourceFilter = document.getElementById('source-filter');
|
|
|
|
if (searchInput) searchInput.addEventListener('input', () => this.updateAircraftTable());
|
|
if (sortSelect) sortSelect.addEventListener('change', () => this.updateAircraftTable());
|
|
if (sourceFilter) sourceFilter.addEventListener('change', () => this.updateAircraftTable());
|
|
}
|
|
|
|
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)`;
|
|
}
|
|
|
|
showError(message) {
|
|
console.error(message);
|
|
|
|
// Simple toast notification implementation
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast-notification error';
|
|
toast.textContent = message;
|
|
|
|
// Add to page
|
|
document.body.appendChild(toast);
|
|
|
|
// Show toast with animation
|
|
setTimeout(() => toast.classList.add('show'), 100);
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
setTimeout(() => document.body.removeChild(toast), 300);
|
|
}, 5000);
|
|
}
|
|
} |