skyview/assets/static/js/modules/ui-manager.js
Ole-Morten Duesund 66a995b4d0 Fix issue #21 and add aircraft position tracking indicators
- Fix Debian package upgrade issue by separating upgrade vs remove behavior in prerm script
- Add aircraft position tracking statistics in merger GetStatistics() method
- Update frontend to display position tracking indicators in both header and stats view
- Format Go code to maintain consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 11:25:42 +02:00

367 lines
No EOL
14 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>${aircraft.Squawk || '-'}</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>${aircraft.Age ? aircraft.Age.toFixed(0) : '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';
}
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';
}
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);
}
}