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:
parent
ddffe1428d
commit
1de3e092ae
13 changed files with 2222 additions and 33 deletions
321
assets/static/js/modules/ui-manager.js
Normal file
321
assets/static/js/modules/ui-manager.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// 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 maxRangeEl = document.getElementById('max-range');
|
||||
const messagesSecEl = document.getElementById('messages-sec');
|
||||
|
||||
if (totalAircraftEl) totalAircraftEl.textContent = this.aircraftData.size;
|
||||
if (activeSourcesEl) {
|
||||
activeSourcesEl.textContent = Array.from(this.sourcesData.values()).filter(s => s.active).length;
|
||||
}
|
||||
|
||||
// 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 sourcesCountEl = document.getElementById('sources-count');
|
||||
|
||||
if (aircraftCountEl) aircraftCountEl.textContent = `${this.aircraftData.size} aircraft`;
|
||||
if (sourcesCountEl) sourcesCountEl.textContent = `${this.sourcesData.size} sources`;
|
||||
|
||||
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);
|
||||
// Could implement toast notifications here
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue