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:
Ole-Morten Duesund 2025-08-24 14:04:17 +02:00
commit 1de3e092ae
13 changed files with 2222 additions and 33 deletions

391
assets/static/js/app-new.js Normal file
View file

@ -0,0 +1,391 @@
// Import Three.js modules
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// Import our modular components
import { WebSocketManager } from './modules/websocket.js?v=2';
import { AircraftManager } from './modules/aircraft-manager.js?v=2';
import { MapManager } from './modules/map-manager.js?v=2';
import { UIManager } from './modules/ui-manager.js?v=2';
class SkyView {
constructor() {
console.log('🚀 SkyView v2 - KISS approach loaded');
// Initialize managers
this.wsManager = null;
this.aircraftManager = null;
this.mapManager = null;
this.uiManager = null;
// 3D Radar
this.radar3d = null;
// Charts
this.charts = {};
this.init();
}
async init() {
try {
console.log('Initializing SkyView application...');
// Initialize UI manager first
this.uiManager = new UIManager();
this.uiManager.initializeViews();
this.uiManager.initializeEventListeners();
// Initialize map manager and get the main map
this.mapManager = new MapManager();
const map = await this.mapManager.initializeMap();
// Initialize aircraft manager with the map
this.aircraftManager = new AircraftManager(map);
// Initialize WebSocket with callbacks
this.wsManager = new WebSocketManager(
(message) => this.handleWebSocketMessage(message),
(status) => this.uiManager.updateConnectionStatus(status)
);
await this.wsManager.connect();
// Initialize other components
this.initializeCharts();
this.uiManager.updateClocks();
this.initialize3DRadar();
// Set up map controls
this.setupMapControls();
// Set up aircraft selection listener
this.setupAircraftSelection();
this.startPeriodicTasks();
console.log('SkyView application initialized successfully');
} catch (error) {
console.error('Initialization failed:', error);
this.uiManager.showError('Failed to initialize application');
}
}
setupMapControls() {
const centerMapBtn = document.getElementById('center-map');
const resetMapBtn = document.getElementById('reset-map');
const toggleTrailsBtn = document.getElementById('toggle-trails');
const toggleRangeBtn = document.getElementById('toggle-range');
const toggleSourcesBtn = document.getElementById('toggle-sources');
if (centerMapBtn) {
centerMapBtn.addEventListener('click', () => this.aircraftManager.centerMapOnAircraft());
}
if (resetMapBtn) {
resetMapBtn.addEventListener('click', () => this.mapManager.resetMap());
}
if (toggleTrailsBtn) {
toggleTrailsBtn.addEventListener('click', () => {
const showTrails = this.aircraftManager.toggleTrails();
toggleTrailsBtn.textContent = showTrails ? 'Hide Trails' : 'Show Trails';
});
}
if (toggleRangeBtn) {
toggleRangeBtn.addEventListener('click', () => {
const showRange = this.mapManager.toggleRangeCircles();
toggleRangeBtn.textContent = showRange ? 'Hide Range' : 'Show Range';
});
}
if (toggleSourcesBtn) {
toggleSourcesBtn.addEventListener('click', () => {
const showSources = this.mapManager.toggleSources();
toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources';
});
}
// Coverage controls
const toggleHeatmapBtn = document.getElementById('toggle-heatmap');
const coverageSourceSelect = document.getElementById('coverage-source');
if (toggleHeatmapBtn) {
toggleHeatmapBtn.addEventListener('click', async () => {
const isActive = await this.mapManager.toggleHeatmap();
toggleHeatmapBtn.textContent = isActive ? 'Hide Heatmap' : 'Show Heatmap';
});
}
if (coverageSourceSelect) {
coverageSourceSelect.addEventListener('change', (e) => {
this.mapManager.setSelectedSource(e.target.value);
this.mapManager.updateCoverageDisplay();
});
}
}
setupAircraftSelection() {
document.addEventListener('aircraftSelected', (e) => {
const { icao, aircraft } = e.detail;
this.uiManager.switchView('map-view');
// DON'T change map view - just open popup like Leaflet expects
if (this.mapManager.map && aircraft.Latitude && aircraft.Longitude) {
const marker = this.aircraftManager.aircraftMarkers.get(icao);
if (marker) {
marker.openPopup();
}
}
});
}
handleWebSocketMessage(message) {
switch (message.type) {
case 'initial_data':
console.log('Received initial data - setting up source markers');
this.updateData(message.data);
// Setup source markers only on initial data load
this.mapManager.updateSourceMarkers();
break;
case 'aircraft_update':
this.updateData(message.data);
break;
default:
console.log('Unknown message type:', message.type);
}
}
updateData(data) {
// Update all managers with new data
this.uiManager.updateData(data);
this.aircraftManager.updateAircraftData(data);
this.mapManager.updateSourcesData(data);
// Update UI components
this.aircraftManager.updateMarkers();
this.uiManager.updateAircraftTable();
this.uiManager.updateStatistics();
this.uiManager.updateHeaderInfo();
// Update coverage controls
this.mapManager.updateCoverageControls();
if (this.uiManager.currentView === 'radar3d-view') {
this.update3DRadar();
}
}
// View switching
async switchView(viewId) {
const actualViewId = this.uiManager.switchView(viewId);
// Handle view-specific initialization
const baseName = actualViewId.replace('-view', '');
switch (baseName) {
case 'coverage':
await this.mapManager.initializeCoverageMap();
break;
case 'radar3d':
this.update3DRadar();
break;
}
}
// Charts
initializeCharts() {
const aircraftChartCanvas = document.getElementById('aircraft-chart');
if (!aircraftChartCanvas) {
console.warn('Aircraft chart canvas not found');
return;
}
try {
this.charts.aircraft = new Chart(aircraftChartCanvas, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Aircraft Count',
data: [],
borderColor: '#00d4ff',
backgroundColor: 'rgba(0, 212, 255, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: { display: false },
y: {
beginAtZero: true,
ticks: { color: '#888' }
}
}
}
});
} catch (error) {
console.warn('Chart.js not available, skipping charts initialization');
}
}
updateCharts() {
if (!this.charts.aircraft) return;
const now = new Date();
const timeLabel = now.toLocaleTimeString();
// Update aircraft count chart
const chart = this.charts.aircraft;
chart.data.labels.push(timeLabel);
chart.data.datasets[0].data.push(this.aircraftManager.aircraftData.size);
if (chart.data.labels.length > 20) {
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
}
chart.update('none');
}
// 3D Radar (basic implementation)
initialize3DRadar() {
try {
const container = document.getElementById('radar3d-container');
if (!container) return;
// Create scene
this.radar3d = {
scene: new THREE.Scene(),
camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000),
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
controls: null,
aircraftMeshes: new Map()
};
// Set up renderer
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
container.appendChild(this.radar3d.renderer.domElement);
// Add lighting
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
this.radar3d.scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(10, 10, 5);
this.radar3d.scene.add(directionalLight);
// Set up camera
this.radar3d.camera.position.set(0, 50, 50);
this.radar3d.camera.lookAt(0, 0, 0);
// Add controls
this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement);
this.radar3d.controls.enableDamping = true;
this.radar3d.controls.dampingFactor = 0.05;
// Add ground plane
const groundGeometry = new THREE.PlaneGeometry(200, 200);
const groundMaterial = new THREE.MeshLambertMaterial({
color: 0x2a4d3a,
transparent: true,
opacity: 0.5
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
this.radar3d.scene.add(ground);
// Add grid
const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44);
this.radar3d.scene.add(gridHelper);
// Start render loop
this.render3DRadar();
console.log('3D Radar initialized successfully');
} catch (error) {
console.error('Failed to initialize 3D radar:', error);
}
}
update3DRadar() {
if (!this.radar3d || !this.radar3d.scene || !this.aircraftManager) return;
try {
// Update aircraft positions in 3D space
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
if (aircraft.Latitude && aircraft.Longitude) {
const key = icao.toString();
if (!this.radar3d.aircraftMeshes.has(key)) {
// Create new aircraft mesh
const geometry = new THREE.ConeGeometry(0.5, 2, 6);
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
this.radar3d.aircraftMeshes.set(key, mesh);
this.radar3d.scene.add(mesh);
}
const mesh = this.radar3d.aircraftMeshes.get(key);
// Convert lat/lon to local coordinates (simplified)
const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000;
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
mesh.position.set(x, y, z);
// Orient mesh based on track
if (aircraft.Track !== undefined) {
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
}
}
});
// Remove old aircraft
this.radar3d.aircraftMeshes.forEach((mesh, key) => {
if (!this.aircraftManager.aircraftData.has(key)) {
this.radar3d.scene.remove(mesh);
this.radar3d.aircraftMeshes.delete(key);
}
});
} catch (error) {
console.error('Failed to update 3D radar:', error);
}
}
render3DRadar() {
if (!this.radar3d) return;
requestAnimationFrame(() => this.render3DRadar());
if (this.radar3d.controls) {
this.radar3d.controls.update();
}
this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
}
startPeriodicTasks() {
// Update clocks every second
setInterval(() => this.uiManager.updateClocks(), 1000);
// Update charts every 10 seconds
setInterval(() => this.updateCharts(), 10000);
// Periodic cleanup
setInterval(() => {
// Clean up old trail data, etc.
}, 30000);
}
}
// Initialize application when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.skyview = new SkyView();
});

View file

@ -130,6 +130,11 @@ class SkyView {
document.getElementById('toggle-trails').addEventListener('click', () => this.toggleTrails());
document.getElementById('toggle-range').addEventListener('click', () => this.toggleRangeCircles());
document.getElementById('toggle-sources').addEventListener('click', () => this.toggleSources());
// If we already have aircraft data waiting, update markers now that map is ready
if (this.aircraftData.size > 0) {
this.updateMapMarkers();
}
}
async initializeCoverageMap() {
@ -251,6 +256,11 @@ class SkyView {
// Map Updates
updateMapMarkers() {
// Check if map is initialized
if (!this.map) {
return;
}
// Clear stale aircraft markers
const currentICAOs = new Set(this.aircraftData.keys());
for (const [icao, marker] of this.aircraftMarkers) {
@ -261,9 +271,10 @@ class SkyView {
}
}
// Update aircraft markers
// Update aircraft markers - only for aircraft with valid positions
// Note: Aircraft without positions are still shown in the table view
for (const [icao, aircraft] of this.aircraftData) {
if (aircraft.Latitude && aircraft.Longitude) {
if (aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0) {
this.updateAircraftMarker(icao, aircraft);
}
}
@ -486,10 +497,10 @@ class SkyView {
const distance = this.calculateDistance(aircraft);
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
const sources = aircraft.Sources ? Object.keys(aircraft.Sources).map(id => {
const sources = aircraft.sources ? Object.keys(aircraft.sources).map(id => {
const source = this.sourcesData.get(id);
const srcData = aircraft.Sources[id];
return `<span class="source-badge" title="Signal: ${srcData.SignalLevel?.toFixed(1)} dBFS">
const srcData = aircraft.sources[id];
return `<span class="source-badge" title="Signal: ${srcData.signal_level?.toFixed(1)} dBFS">
${source?.name || id}
</span>`;
}).join('') : 'N/A';
@ -567,7 +578,7 @@ class SkyView {
createSourcePopupContent(source) {
const aircraftCount = Array.from(this.aircraftData.values())
.filter(aircraft => aircraft.Sources && aircraft.Sources[source.id]).length;
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length;
return `
<div class="source-popup">
@ -599,6 +610,9 @@ class SkyView {
// Table Management
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');
tbody.innerHTML = '';
@ -618,7 +632,7 @@ class SkyView {
if (sourceFilter) {
filteredData = filteredData.filter(aircraft =>
aircraft.Sources && aircraft.Sources[sourceFilter]
aircraft.sources && aircraft.sources[sourceFilter]
);
}
@ -642,8 +656,8 @@ class SkyView {
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 sources = aircraft.sources ? Object.keys(aircraft.sources).length : 0;
const bestSignal = this.getBestSignalFromSources(aircraft.sources);
const row = document.createElement('tr');
row.innerHTML = `
@ -677,8 +691,8 @@ class SkyView {
if (!sources) return null;
let bestSignal = -999;
for (const [id, data] of Object.entries(sources)) {
if (data.SignalLevel > bestSignal) {
bestSignal = data.SignalLevel;
if (data.signal_level > bestSignal) {
bestSignal = data.signal_level;
}
}
return bestSignal === -999 ? null : bestSignal;
@ -725,7 +739,7 @@ class SkyView {
case 'squawk':
return (a.Squawk || '').localeCompare(b.Squawk || '');
case 'signal':
return (this.getBestSignalFromSources(b.Sources) || -999) - (this.getBestSignalFromSources(a.Sources) || -999);
return (this.getBestSignalFromSources(b.sources) || -999) - (this.getBestSignalFromSources(a.sources) || -999);
case 'age':
return (a.Age || 0) - (b.Age || 0);
default:
@ -1127,9 +1141,9 @@ class SkyView {
// 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;
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
if (srcData.distance && srcData.distance < minDistance) {
minDistance = srcData.distance;
}
}

View file

@ -0,0 +1,376 @@
// Aircraft marker and data management module
export class AircraftManager {
constructor(map) {
this.map = map;
this.aircraftData = new Map();
this.aircraftMarkers = new Map();
this.aircraftTrails = new Map();
this.showTrails = false;
// Debug: Track marker lifecycle
this.markerCreateCount = 0;
this.markerUpdateCount = 0;
this.markerRemoveCount = 0;
// Map event listeners removed - let Leaflet handle positioning naturally
}
updateAircraftData(data) {
if (data.aircraft) {
this.aircraftData.clear();
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
this.aircraftData.set(icao, aircraft);
}
console.log(`Aircraft data updated: ${this.aircraftData.size} aircraft`);
}
}
updateMarkers() {
if (!this.map) {
return;
}
// Clear stale aircraft markers
const currentICAOs = new Set(this.aircraftData.keys());
for (const [icao, marker] of this.aircraftMarkers) {
if (!currentICAOs.has(icao)) {
this.map.removeLayer(marker);
this.aircraftMarkers.delete(icao);
this.aircraftTrails.delete(icao);
this.markerRemoveCount++;
}
}
// Update aircraft markers - only for aircraft with valid geographic coordinates
for (const [icao, aircraft] of this.aircraftData) {
const hasCoords = aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0;
const validLat = aircraft.Latitude >= -90 && aircraft.Latitude <= 90;
const validLng = aircraft.Longitude >= -180 && aircraft.Longitude <= 180;
if (hasCoords && validLat && validLng) {
this.updateAircraftMarker(icao, aircraft);
}
}
}
updateAircraftMarker(icao, aircraft) {
const pos = [aircraft.Latitude, aircraft.Longitude];
// Debug: Log coordinate format and values
console.log(`📍 ${icao}: pos=[${pos[0]}, ${pos[1]}], types=[${typeof pos[0]}, ${typeof pos[1]}]`);
// Check for invalid coordinates - proper geographic bounds
const isValidLat = pos[0] >= -90 && pos[0] <= 90;
const isValidLng = pos[1] >= -180 && pos[1] <= 180;
if (!isValidLat || !isValidLng || isNaN(pos[0]) || isNaN(pos[1])) {
console.error(`🚨 Invalid coordinates for ${icao}: [${pos[0]}, ${pos[1]}] (lat must be -90 to +90, lng must be -180 to +180)`);
return; // Don't create/update marker with invalid coordinates
}
if (this.aircraftMarkers.has(icao)) {
// Update existing marker - KISS approach
const marker = this.aircraftMarkers.get(icao);
// Always update position - let Leaflet handle everything
marker.setLatLng(pos);
// Update rotation using Leaflet's options if available, otherwise skip rotation
if (aircraft.Track !== undefined) {
if (marker.setRotationAngle) {
// Use Leaflet rotation plugin method if available
marker.setRotationAngle(aircraft.Track);
} else if (marker.options) {
// Update the marker's options for consistency
marker.options.rotationAngle = aircraft.Track;
}
// Don't manually set CSS transforms - let Leaflet handle it
}
// Handle popup exactly like Leaflet expects
if (marker.isPopupOpen()) {
marker.setPopupContent(this.createPopupContent(aircraft));
}
this.markerUpdateCount++;
} else {
// Create new marker
console.log(`Creating new marker for ${icao}`);
const icon = this.createAircraftIcon(aircraft);
try {
const marker = L.marker(pos, {
icon: icon,
rotationAngle: aircraft.Track || 0
}).addTo(this.map);
marker.bindPopup(this.createPopupContent(aircraft), {
maxWidth: 450,
className: 'aircraft-popup'
});
this.aircraftMarkers.set(icao, marker);
this.markerCreateCount++;
console.log(`Created marker for ${icao}, total markers: ${this.aircraftMarkers.size}`);
// Force immediate visibility
if (marker._icon) {
marker._icon.style.display = 'block';
marker._icon.style.opacity = '1';
marker._icon.style.visibility = 'visible';
console.log(`Forced visibility for new marker ${icao}`);
}
} catch (error) {
console.error(`Failed to create marker for ${icao}:`, error);
}
}
// Update trails
if (this.showTrails) {
this.updateAircraftTrail(icao, pos);
}
}
createAircraftIcon(aircraft) {
const type = this.getAircraftType(aircraft);
const color = this.getAircraftColor(type);
const size = aircraft.OnGround ? 12 : 16;
const svg = `
<svg width="${size * 2}" height="${size * 2}" viewBox="0 0 32 32">
<g transform="translate(16,16)">
<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z"
fill="${color}"
stroke="#ffffff"
stroke-width="1"
filter="drop-shadow(0 0 4px rgba(0,212,255,0.8))"/>
</g>
</svg>
`;
return L.divIcon({
html: svg,
iconSize: [size * 2, size * 2],
iconAnchor: [size, size],
className: 'aircraft-marker'
});
}
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';
}
getAircraftColor(type) {
const colors = {
commercial: '#00ff88',
cargo: '#ff8c00',
military: '#ff4444',
ga: '#ffff00',
ground: '#888888'
};
return colors[type] || colors.commercial;
}
updateAircraftTrail(icao, pos) {
if (!this.aircraftTrails.has(icao)) {
this.aircraftTrails.set(icao, []);
}
const trail = this.aircraftTrails.get(icao);
trail.push(pos);
// Keep only last 50 positions
if (trail.length > 50) {
trail.shift();
}
// Draw polyline
const trailLine = L.polyline(trail, {
color: '#00d4ff',
weight: 2,
opacity: 0.6
}).addTo(this.map);
// Store reference for cleanup
if (!this.aircraftTrails.get(icao).polyline) {
this.aircraftTrails.get(icao).polyline = trailLine;
} else {
this.map.removeLayer(this.aircraftTrails.get(icao).polyline);
this.aircraftTrails.get(icao).polyline = trailLine;
}
}
createPopupContent(aircraft) {
const type = this.getAircraftType(aircraft);
const country = this.getCountryFromICAO(aircraft.ICAO24 || '');
const flag = this.getCountryFlag(country);
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0;
const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0;
const distance = this.calculateDistance(aircraft);
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
return `
<div class="aircraft-popup">
<div class="popup-header">
<div class="flight-info">
<span class="icao-flag">${flag}</span>
<span class="flight-id">${aircraft.ICAO24 || 'N/A'}</span>
${aircraft.Callsign ? `→ <span class="callsign">${aircraft.Callsign}</span>` : ''}
</div>
</div>
<div class="popup-details">
<div class="detail-row">
<strong>Country:</strong> ${country}
</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">${altitude ? `${altitude} 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.GroundSpeed ? `${aircraft.GroundSpeed} kt | ${speedKmh} km/h` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Track:</div>
<div class="value">${aircraft.Track ? `${aircraft.Track}°` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">V/Rate:</div>
<div class="value">${aircraft.VerticalRate ? `${aircraft.VerticalRate} ft/min` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Distance:</div>
<div class="value">${distanceKm} km</div>
</div>
</div>
<div class="detail-row">
<strong>Position:</strong> ${aircraft.Latitude?.toFixed(4)}°, ${aircraft.Longitude?.toFixed(4)}°
</div>
<div class="detail-row">
<strong>Messages:</strong> ${aircraft.TotalMessages || 0}
</div>
<div class="detail-row">
<strong>Age:</strong> ${aircraft.Age ? aircraft.Age.toFixed(1) : '0'}s
</div>
</div>
</div>
`;
}
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;
}
getCountryFromICAO(icao) {
if (!icao || icao.length < 6) return 'Unknown';
const prefix = icao[0];
const countryMap = {
'4': 'Europe',
'A': 'United States',
'C': 'Canada',
'D': 'Germany',
'F': 'France',
'G': 'United Kingdom',
'I': 'Italy',
'J': 'Japan'
};
return countryMap[prefix] || 'Unknown';
}
getCountryFlag(country) {
const flags = {
'United States': '🇺🇸',
'Canada': '🇨🇦',
'Germany': '🇩🇪',
'France': '🇫🇷',
'United Kingdom': '🇬🇧',
'Italy': '🇮🇹',
'Japan': '🇯🇵',
'Europe': '🇪🇺'
};
return flags[country] || '🏳️';
}
toggleTrails() {
this.showTrails = !this.showTrails;
if (!this.showTrails) {
// Clear all trails
this.aircraftTrails.forEach((trail, icao) => {
if (trail.polyline) {
this.map.removeLayer(trail.polyline);
}
});
this.aircraftTrails.clear();
}
return this.showTrails;
}
centerMapOnAircraft() {
if (this.aircraftData.size === 0) return;
const validAircraft = Array.from(this.aircraftData.values())
.filter(a => a.Latitude && a.Longitude);
if (validAircraft.length === 0) return;
if (validAircraft.length === 1) {
// Center on single aircraft
const aircraft = validAircraft[0];
this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
} else {
// Fit bounds to all aircraft
const bounds = L.latLngBounds(
validAircraft.map(a => [a.Latitude, a.Longitude])
);
this.map.fitBounds(bounds.pad(0.1));
}
}
// Simple debug method
debugState() {
console.log(`Aircraft: ${this.aircraftData.size}, Markers: ${this.aircraftMarkers.size}`);
}
}

View file

@ -0,0 +1,315 @@
// Map and visualization management module
export class MapManager {
constructor() {
this.map = null;
this.coverageMap = null;
this.mapOrigin = null;
// Source markers and overlays
this.sourceMarkers = new Map();
this.rangeCircles = new Map();
this.showSources = true;
this.showRange = false;
this.selectedSource = null;
this.heatmapLayer = null;
// Data references
this.sourcesData = new Map();
}
async initializeMap() {
// Get origin from server
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
try {
const response = await fetch('/api/origin');
if (response.ok) {
origin = await response.json();
}
} catch (error) {
console.warn('Could not fetch origin, using default:', error);
}
// Store origin for reset functionality
this.mapOrigin = origin;
this.map = L.map('map').setView([origin.latitude, origin.longitude], 10);
// Dark tile layer
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd',
maxZoom: 19
}).addTo(this.map);
console.log('Main map initialized');
return this.map;
}
async initializeCoverageMap() {
if (!this.coverageMap) {
// Get origin from server
let origin = { latitude: 51.4700, longitude: -0.4600 }; // fallback
try {
const response = await fetch('/api/origin');
if (response.ok) {
origin = await response.json();
}
} catch (error) {
console.warn('Could not fetch origin for coverage map, using default:', error);
}
this.coverageMap = L.map('coverage-map').setView([origin.latitude, origin.longitude], 10);
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(this.coverageMap);
}
return this.coverageMap;
}
updateSourcesData(data) {
if (data.sources) {
this.sourcesData.clear();
data.sources.forEach(source => {
this.sourcesData.set(source.id, source);
});
}
}
updateSourceMarkers() {
if (!this.map || !this.showSources) return;
// Remove markers for sources that no longer exist
const currentSourceIds = new Set(this.sourcesData.keys());
for (const [id, marker] of this.sourceMarkers) {
if (!currentSourceIds.has(id)) {
this.map.removeLayer(marker);
this.sourceMarkers.delete(id);
}
}
// Update or create markers for current sources
for (const [id, source] of this.sourcesData) {
if (source.latitude && source.longitude) {
if (this.sourceMarkers.has(id)) {
// Update existing marker
const marker = this.sourceMarkers.get(id);
// Update marker style if status changed
marker.setStyle({
radius: source.active ? 10 : 6,
fillColor: source.active ? '#00d4ff' : '#666666',
fillOpacity: 0.8
});
// Update popup content if it's open
if (marker.isPopupOpen()) {
marker.setPopupContent(this.createSourcePopupContent(source));
}
} else {
// Create new marker
const marker = L.circleMarker([source.latitude, source.longitude], {
radius: source.active ? 10 : 6,
fillColor: source.active ? '#00d4ff' : '#666666',
color: '#ffffff',
weight: 2,
fillOpacity: 0.8,
className: 'source-marker'
}).addTo(this.map);
marker.bindPopup(this.createSourcePopupContent(source), {
maxWidth: 300
});
this.sourceMarkers.set(id, marker);
}
}
}
this.updateSourcesLegend();
}
updateRangeCircles() {
if (!this.map || !this.showRange) return;
// Clear existing circles
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
this.rangeCircles.clear();
// Add range circles for active sources
for (const [id, source] of this.sourcesData) {
if (source.active && source.latitude && source.longitude) {
// Add multiple range circles (50km, 100km, 200km)
const ranges = [50000, 100000, 200000];
ranges.forEach((range, index) => {
const circle = L.circle([source.latitude, source.longitude], {
radius: range,
fillColor: 'transparent',
color: '#00d4ff',
weight: 1,
opacity: 0.3 - (index * 0.1),
dashArray: '5,5'
}).addTo(this.map);
this.rangeCircles.set(`${id}_${range}`, circle);
});
}
}
}
createSourcePopupContent(source, aircraftData) {
const aircraftCount = aircraftData ? Array.from(aircraftData.values())
.filter(aircraft => aircraft.sources && aircraft.sources[source.id]).length : 0;
return `
<div class="source-popup">
<h3>${source.name}</h3>
<p><strong>ID:</strong> ${source.id}</p>
<p><strong>Location:</strong> ${source.latitude.toFixed(4)}°, ${source.longitude.toFixed(4)}°</p>
<p><strong>Status:</strong> ${source.active ? 'Active' : 'Inactive'}</p>
<p><strong>Aircraft:</strong> ${aircraftCount}</p>
<p><strong>Messages:</strong> ${source.messages || 0}</p>
<p><strong>Last Seen:</strong> ${source.last_seen ? new Date(source.last_seen).toLocaleString() : 'N/A'}</p>
</div>
`;
}
updateSourcesLegend() {
const legend = document.getElementById('sources-legend');
if (!legend) return;
legend.innerHTML = '';
for (const [id, source] of this.sourcesData) {
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `
<span class="legend-icon" style="background: ${source.active ? '#00d4ff' : '#666666'}"></span>
<span title="${source.host}:${source.port}">${source.name}</span>
`;
legend.appendChild(item);
}
}
resetMap() {
if (this.mapOrigin && this.map) {
this.map.setView([this.mapOrigin.latitude, this.mapOrigin.longitude], 10);
}
}
toggleRangeCircles() {
this.showRange = !this.showRange;
if (this.showRange) {
this.updateRangeCircles();
} else {
this.rangeCircles.forEach(circle => this.map.removeLayer(circle));
this.rangeCircles.clear();
}
return this.showRange;
}
toggleSources() {
this.showSources = !this.showSources;
if (this.showSources) {
this.updateSourceMarkers();
} else {
this.sourceMarkers.forEach(marker => this.map.removeLayer(marker));
this.sourceMarkers.clear();
}
return this.showSources;
}
// Coverage map methods
updateCoverageControls() {
const select = document.getElementById('coverage-source');
if (!select) return;
select.innerHTML = '<option value="">Select Source</option>';
for (const [id, source] of this.sourcesData) {
const option = document.createElement('option');
option.value = id;
option.textContent = source.name;
select.appendChild(option);
}
}
async updateCoverageDisplay() {
if (!this.selectedSource || !this.coverageMap) return;
try {
const response = await fetch(`/api/coverage/${this.selectedSource}`);
const data = await response.json();
// Clear existing coverage markers
this.coverageMap.eachLayer(layer => {
if (layer instanceof L.CircleMarker) {
this.coverageMap.removeLayer(layer);
}
});
// Add coverage points
data.points.forEach(point => {
const intensity = Math.max(0, (point.signal + 50) / 50); // Normalize signal strength
L.circleMarker([point.lat, point.lon], {
radius: 3,
fillColor: this.getSignalColor(point.signal),
color: 'white',
weight: 1,
fillOpacity: intensity
}).addTo(this.coverageMap);
});
} catch (error) {
console.error('Failed to load coverage data:', error);
}
}
async toggleHeatmap() {
if (!this.selectedSource) {
alert('Please select a source first');
return false;
}
if (this.heatmapLayer) {
this.coverageMap.removeLayer(this.heatmapLayer);
this.heatmapLayer = null;
return false;
} else {
try {
const response = await fetch(`/api/heatmap/${this.selectedSource}`);
const data = await response.json();
// Create heatmap layer (simplified)
this.createHeatmapOverlay(data);
return true;
} catch (error) {
console.error('Failed to load heatmap data:', error);
return false;
}
}
}
getSignalColor(signal) {
if (signal > -10) return '#00ff88';
if (signal > -20) return '#ffff00';
if (signal > -30) return '#ff8c00';
return '#ff4444';
}
createHeatmapOverlay(data) {
// Simplified heatmap implementation
// In production, would use proper heatmap library like Leaflet.heat
console.log('Creating heatmap overlay with data:', data);
}
setSelectedSource(sourceId) {
this.selectedSource = sourceId;
}
}

View 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
}
}

View file

@ -0,0 +1,70 @@
// WebSocket communication module
export class WebSocketManager {
constructor(onMessage, onStatusChange) {
this.websocket = null;
this.onMessage = onMessage;
this.onStatusChange = onStatusChange;
}
async connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;
try {
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log('WebSocket connected');
this.onStatusChange('connected');
};
this.websocket.onclose = () => {
console.log('WebSocket disconnected');
this.onStatusChange('disconnected');
// Reconnect after 5 seconds
setTimeout(() => this.connect(), 5000);
};
this.websocket.onerror = (error) => {
console.error('WebSocket error:', error);
this.onStatusChange('disconnected');
};
this.websocket.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Debug: Log WebSocket messages to see what we're receiving
if (message.data && message.data.aircraft) {
const aircraftCount = Object.keys(message.data.aircraft).length;
console.log(`📡 WebSocket: ${message.type} with ${aircraftCount} aircraft`);
// Log first few aircraft with coordinates
let count = 0;
for (const [icao, aircraft] of Object.entries(message.data.aircraft)) {
if (count < 3 && aircraft.Latitude && aircraft.Longitude) {
console.log(`📡 ${icao}: lat=${aircraft.Latitude}, lon=${aircraft.Longitude}`);
}
count++;
}
}
this.onMessage(message);
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
} catch (error) {
console.error('WebSocket connection failed:', error);
this.onStatusChange('disconnected');
}
}
disconnect() {
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
}
}