2025-08-24 00:19:00 +02:00
// Import Three.js modules
import * as THREE from 'three' ;
import { OrbitControls } from 'three/addons/controls/OrbitControls.js' ;
2025-08-24 14:55:54 +02:00
// 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' ;
2025-08-31 19:43:58 +02:00
import { CallsignManager } from './modules/callsign-manager.js' ;
2025-08-24 14:55:54 +02:00
2025-09-01 21:02:22 +02:00
// Tile Management for 3D Map Base
class TileManager {
constructor ( scene , origin ) {
this . scene = scene ;
this . origin = origin ;
this . loadedTiles = new Map ( ) ;
this . zoom = 12 ; // OSM zoom level
// Calculate tile bounds around origin
this . originTileX = this . lonToTileX ( origin . longitude , this . zoom ) ;
this . originTileY = this . latToTileY ( origin . latitude , this . zoom ) ;
// Calculate actual tile size based on Web Mercator projection at this latitude
this . tileSize = this . calculateTileSize ( origin . latitude , this . zoom ) ;
}
// Calculate the ground size of a tile at given latitude and zoom
calculateTileSize ( latitude , zoom ) {
// Earth's circumference at equator in km
const earthCircumference = 40075.016686 ;
// Circumference at given latitude
const latCircumference = earthCircumference * Math . cos ( latitude * Math . PI / 180 ) ;
// Size of one tile at this latitude and zoom level
return latCircumference / Math . pow ( 2 , zoom ) ;
}
// Convert longitude to OSM tile X coordinate
lonToTileX ( lon , zoom ) {
return Math . floor ( ( lon + 180 ) / 360 * Math . pow ( 2 , zoom ) ) ;
}
// Convert latitude to OSM tile Y coordinate
latToTileY ( lat , zoom ) {
return Math . floor ( ( 1 - Math . log ( Math . tan ( lat * Math . PI / 180 ) + 1 / Math . cos ( lat * Math . PI / 180 ) ) / Math . PI ) / 2 * Math . pow ( 2 , zoom ) ) ;
}
// Convert tile coordinates to world position relative to origin
tileToWorldPosition ( tileX , tileY ) {
// Use precise positioning based on actual Web Mercator coordinates
const originLon = this . tileXToLon ( this . originTileX , this . zoom ) ;
const originLat = this . tileYToLat ( this . originTileY , this . zoom ) ;
const tileLon = this . tileXToLon ( tileX , this . zoom ) ;
const tileLat = this . tileYToLat ( tileY , this . zoom ) ;
// Convert to world coordinates using precise geographic distance
const deltaX = ( tileLon - originLon ) * 111320 * Math . cos ( this . origin . latitude * Math . PI / 180 ) / 1000 ;
const deltaZ = - ( tileLat - originLat ) * 111320 / 1000 ; // Negative for Three.js coordinates
return { x : deltaX , z : deltaZ } ;
}
// Convert OSM tile X to longitude
tileXToLon ( x , zoom ) {
return x / Math . pow ( 2 , zoom ) * 360 - 180 ;
}
// Convert OSM tile Y to latitude
tileYToLat ( y , zoom ) {
const n = Math . PI - 2 * Math . PI * y / Math . pow ( 2 , zoom ) ;
return 180 / Math . PI * Math . atan ( 0.5 * ( Math . exp ( n ) - Math . exp ( - n ) ) ) ;
}
async loadTile ( tileX , tileY ) {
const key = ` ${ tileX } _ ${ tileY } ` ;
if ( this . loadedTiles . has ( key ) ) return ;
try {
// OpenStreetMap tile server
const url = ` https://tile.openstreetmap.org/ ${ this . zoom } / ${ tileX } / ${ tileY } .png ` ;
const texture = await new THREE . TextureLoader ( ) . loadAsync ( url ) ;
// Use ClampToEdgeWrapping to prevent gaps and improve alignment
texture . wrapS = texture . wrapT = THREE . ClampToEdgeWrapping ;
texture . magFilter = THREE . LinearFilter ;
texture . minFilter = THREE . LinearFilter ;
// Create tile geometry with exact size for precise alignment
const geometry = new THREE . PlaneGeometry ( this . tileSize , this . tileSize ) ;
const material = new THREE . MeshBasicMaterial ( {
map : texture ,
transparent : false , // Remove transparency to avoid visual artifacts
side : THREE . FrontSide
} ) ;
const tile = new THREE . Mesh ( geometry , material ) ;
tile . rotation . x = - Math . PI / 2 ; // Lay flat on ground
// Position tile relative to origin with precise positioning
const worldPos = this . tileToWorldPosition ( tileX , tileY ) ;
tile . position . set ( worldPos . x , 0.01 , worldPos . z ) ; // Slightly above ground (1cm)
this . scene . add ( tile ) ;
this . loadedTiles . set ( key , tile ) ;
if ( window . skyviewVerbose ) {
console . log ( ` Loaded tile ${ tileX } , ${ tileY } at position ${ worldPos . x } , ${ worldPos . z } ` ) ;
}
} catch ( error ) {
console . warn ( ` Failed to load tile ${ tileX } , ${ tileY } : ` , error ) ;
}
}
async loadInitialTiles ( ) {
// Load a larger grid of tiles around the origin for better coverage
const tileRange = 3 ; // Load 3 tiles in each direction (7x7 = ~70km x 70km)
// Load center tiles first, then expand outward for better user experience
const loadOrder = [ ] ;
for ( let distance = 0 ; distance <= tileRange ; distance ++ ) {
for ( let x = - distance ; x <= distance ; x ++ ) {
for ( let y = - distance ; y <= distance ; y ++ ) {
if ( Math . abs ( x ) === distance || Math . abs ( y ) === distance ) {
loadOrder . push ( { x , y } ) ;
}
}
}
}
// Load tiles with small delays to avoid overwhelming the server
for ( const { x , y } of loadOrder ) {
const tileX = this . originTileX + x ;
const tileY = this . originTileY + y ;
await this . loadTile ( tileX , tileY ) ;
// Small delay to prevent rate limiting
if ( loadOrder . length > 10 ) {
await new Promise ( resolve => setTimeout ( resolve , 50 ) ) ;
}
}
// Add origin indicator
this . addOriginIndicator ( ) ;
}
addOriginIndicator ( ) {
// Create a visible marker at the origin point
const originGeometry = new THREE . CylinderGeometry ( 0.5 , 0.5 , 2 , 8 ) ;
const originMaterial = new THREE . MeshLambertMaterial ( {
color : 0xff0000 ,
emissive : 0x440000
} ) ;
const originMarker = new THREE . Mesh ( originGeometry , originMaterial ) ;
originMarker . position . set ( 0 , 1 , 0 ) ; // Position at origin, 1km above ground
this . scene . add ( originMarker ) ;
// Add ring indicator on ground
const ringGeometry = new THREE . RingGeometry ( 2 , 3 , 16 ) ;
const ringMaterial = new THREE . MeshBasicMaterial ( {
color : 0xff0000 ,
side : THREE . DoubleSide ,
transparent : true ,
opacity : 0.7
} ) ;
const originRing = new THREE . Mesh ( ringGeometry , ringMaterial ) ;
originRing . rotation . x = - Math . PI / 2 ; // Lay flat on ground
originRing . position . set ( 0 , 0.1 , 0 ) ; // Slightly above ground to prevent z-fighting
this . scene . add ( originRing ) ;
if ( window . skyviewVerbose ) {
console . log ( 'Added origin indicator at (0, 0, 0)' ) ;
}
}
// Cleanup method for removing tiles
removeTile ( tileX , tileY ) {
const key = ` ${ tileX } _ ${ tileY } ` ;
const tile = this . loadedTiles . get ( key ) ;
if ( tile ) {
this . scene . remove ( tile ) ;
tile . geometry . dispose ( ) ;
tile . material . map ? . dispose ( ) ;
tile . material . dispose ( ) ;
this . loadedTiles . delete ( key ) ;
}
}
}
2025-08-24 00:19:00 +02:00
class SkyView {
constructor ( ) {
2025-08-24 14:55:54 +02:00
// Initialize managers
this . wsManager = null ;
this . aircraftManager = null ;
this . mapManager = null ;
this . uiManager = null ;
2025-08-31 19:43:58 +02:00
this . callsignManager = null ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
// 3D Radar
this . radar3d = null ;
2025-08-24 00:19:00 +02:00
// Charts
this . charts = { } ;
2025-08-24 16:24:46 +02:00
// Selected aircraft tracking
this . selectedAircraft = null ;
this . selectedTrailEnabled = false ;
2025-09-01 20:30:41 +02:00
// Debug/verbose logging control
// Enable verbose logging with: ?verbose in URL or localStorage.setItem('skyview-verbose', 'true')
const urlParams = new URLSearchParams ( window . location . search ) ;
this . verbose = urlParams . has ( 'verbose' ) || localStorage . getItem ( 'skyview-verbose' ) === 'true' ;
// Set global verbose flag for other modules
window . skyviewVerbose = this . verbose ;
2025-08-24 00:19:00 +02:00
this . init ( ) ;
}
async init ( ) {
try {
2025-08-24 14:55:54 +02:00
// Initialize UI manager first
this . uiManager = new UIManager ( ) ;
this . uiManager . initializeViews ( ) ;
this . uiManager . initializeEventListeners ( ) ;
2025-08-24 00:24:45 +02:00
2025-08-31 19:43:58 +02:00
// Initialize callsign manager for enriched callsign display
this . callsignManager = new CallsignManager ( ) ;
2025-08-24 14:55:54 +02:00
// Initialize map manager and get the main map
this . mapManager = new MapManager ( ) ;
const map = await this . mapManager . initializeMap ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-31 19:43:58 +02:00
// Initialize aircraft manager with the map and callsign manager
this . aircraftManager = new AircraftManager ( map , this . callsignManager ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 16:24:46 +02:00
// Set up selected aircraft trail callback
this . aircraftManager . setSelectedAircraftCallback ( ( icao ) => {
return this . selectedTrailEnabled && this . selectedAircraft === icao ;
} ) ;
2025-08-24 14:55:54 +02:00
// Initialize WebSocket with callbacks
this . wsManager = new WebSocketManager (
( message ) => this . handleWebSocketMessage ( message ) ,
( status ) => this . uiManager . updateConnectionStatus ( status )
) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
await this . wsManager . connect ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
// Initialize other components
this . initializeCharts ( ) ;
this . uiManager . updateClocks ( ) ;
2025-09-01 20:22:26 +02:00
// Flag to track 3D radar initialization
this . radar3dInitialized = false ;
// Add view change listener for 3D radar initialization
this . setupViewChangeListener ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
// Set up map controls
this . setupMapControls ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
// Set up aircraft selection listener
this . setupAircraftSelection ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
this . startPeriodicTasks ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
} catch ( error ) {
console . error ( 'Initialization failed:' , error ) ;
this . uiManager . showError ( 'Failed to initialize application' ) ;
2025-08-24 00:19:00 +02:00
}
}
2025-08-24 14:55:54 +02:00
setupMapControls ( ) {
const centerMapBtn = document . getElementById ( 'center-map' ) ;
const resetMapBtn = document . getElementById ( 'reset-map' ) ;
const toggleTrailsBtn = document . getElementById ( 'toggle-trails' ) ;
const toggleSourcesBtn = document . getElementById ( 'toggle-sources' ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
if ( centerMapBtn ) {
2025-08-24 16:24:46 +02:00
centerMapBtn . addEventListener ( 'click' , ( ) => {
this . aircraftManager . centerMapOnAircraft ( ( ) => this . mapManager . getSourcePositions ( ) ) ;
} ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
if ( resetMapBtn ) {
resetMapBtn . addEventListener ( 'click' , ( ) => this . mapManager . resetMap ( ) ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
if ( toggleTrailsBtn ) {
toggleTrailsBtn . addEventListener ( 'click' , ( ) => {
const showTrails = this . aircraftManager . toggleTrails ( ) ;
toggleTrailsBtn . textContent = showTrails ? 'Hide Trails' : 'Show Trails' ;
} ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
if ( toggleSourcesBtn ) {
toggleSourcesBtn . addEventListener ( 'click' , ( ) => {
const showSources = this . mapManager . toggleSources ( ) ;
toggleSourcesBtn . textContent = showSources ? 'Hide Sources' : 'Show Sources' ;
} ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 18:24:08 +02:00
// Setup collapsible sections
this . setupCollapsibleSections ( ) ;
2025-08-24 15:09:54 +02:00
const toggleDarkModeBtn = document . getElementById ( 'toggle-dark-mode' ) ;
if ( toggleDarkModeBtn ) {
toggleDarkModeBtn . addEventListener ( 'click' , ( ) => {
const isDarkMode = this . mapManager . toggleDarkMode ( ) ;
toggleDarkModeBtn . innerHTML = isDarkMode ? '☀️ Light Mode' : '🌙 Night Mode' ;
} ) ;
}
2025-08-24 14:55:54 +02:00
// Coverage controls
const toggleHeatmapBtn = document . getElementById ( 'toggle-heatmap' ) ;
const coverageSourceSelect = document . getElementById ( 'coverage-source' ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
if ( toggleHeatmapBtn ) {
toggleHeatmapBtn . addEventListener ( 'click' , async ( ) => {
const isActive = await this . mapManager . toggleHeatmap ( ) ;
toggleHeatmapBtn . textContent = isActive ? 'Hide Heatmap' : 'Show Heatmap' ;
} ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
if ( coverageSourceSelect ) {
coverageSourceSelect . addEventListener ( 'change' , ( e ) => {
this . mapManager . setSelectedSource ( e . target . value ) ;
this . mapManager . updateCoverageDisplay ( ) ;
} ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 16:24:46 +02:00
// Display option checkboxes
const sitePositionsCheckbox = document . getElementById ( 'show-site-positions' ) ;
const rangeRingsCheckbox = document . getElementById ( 'show-range-rings' ) ;
const selectedTrailCheckbox = document . getElementById ( 'show-selected-trail' ) ;
if ( sitePositionsCheckbox ) {
sitePositionsCheckbox . addEventListener ( 'change' , ( e ) => {
if ( e . target . checked ) {
this . mapManager . showSources = true ;
this . mapManager . updateSourceMarkers ( ) ;
} else {
this . mapManager . showSources = false ;
this . mapManager . sourceMarkers . forEach ( marker => this . mapManager . map . removeLayer ( marker ) ) ;
this . mapManager . sourceMarkers . clear ( ) ;
}
} ) ;
}
if ( rangeRingsCheckbox ) {
rangeRingsCheckbox . addEventListener ( 'change' , ( e ) => {
if ( e . target . checked ) {
this . mapManager . showRange = true ;
this . mapManager . updateRangeCircles ( ) ;
} else {
this . mapManager . showRange = false ;
this . mapManager . rangeCircles . forEach ( circle => this . mapManager . map . removeLayer ( circle ) ) ;
this . mapManager . rangeCircles . clear ( ) ;
}
} ) ;
}
if ( selectedTrailCheckbox ) {
selectedTrailCheckbox . addEventListener ( 'change' , ( e ) => {
this . selectedTrailEnabled = e . target . checked ;
if ( ! e . target . checked && this . selectedAircraft ) {
// Hide currently selected aircraft trail
this . aircraftManager . hideAircraftTrail ( this . selectedAircraft ) ;
} else if ( e . target . checked && this . selectedAircraft ) {
// Show currently selected aircraft trail
this . aircraftManager . showAircraftTrail ( this . selectedAircraft ) ;
}
} ) ;
}
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
setupAircraftSelection ( ) {
document . addEventListener ( 'aircraftSelected' , ( e ) => {
const { icao , aircraft } = e . detail ;
this . uiManager . switchView ( 'map-view' ) ;
2025-08-24 16:24:46 +02:00
// Hide trail for previously selected aircraft
if ( this . selectedAircraft && this . selectedTrailEnabled ) {
this . aircraftManager . hideAircraftTrail ( this . selectedAircraft ) ;
}
// Update selected aircraft
this . selectedAircraft = icao ;
2025-08-24 17:54:17 +02:00
// Automatically enable selected aircraft trail when an aircraft is selected
if ( ! this . selectedTrailEnabled ) {
this . selectedTrailEnabled = true ;
const selectedTrailCheckbox = document . getElementById ( 'show-selected-trail' ) ;
if ( selectedTrailCheckbox ) {
selectedTrailCheckbox . checked = true ;
}
2025-08-24 16:24:46 +02:00
}
2025-08-24 17:54:17 +02:00
// Show trail for newly selected aircraft
this . aircraftManager . showAircraftTrail ( icao ) ;
2025-08-24 14:55:54 +02:00
// 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 ) ;
2025-08-24 00:19:00 +02:00
if ( marker ) {
marker . openPopup ( ) ;
}
}
} ) ;
}
2025-08-24 14:55:54 +02:00
handleWebSocketMessage ( message ) {
2025-08-25 10:14:03 +02:00
const aircraftCount = Object . keys ( message . data . aircraft || { } ) . length ;
2025-09-01 20:30:41 +02:00
// Only log WebSocket messages in verbose mode
if ( this . verbose ) {
console . debug ( ` WebSocket message: ${ message . type } , ${ aircraftCount } aircraft, timestamp: ${ message . timestamp } ` ) ;
}
2025-08-25 10:14:03 +02:00
2025-08-24 14:55:54 +02:00
switch ( message . type ) {
case 'initial_data' :
2025-08-25 10:14:03 +02:00
console . log ( ` Received initial data with ${ aircraftCount } aircraft ` ) ;
2025-08-24 14:55:54 +02:00
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 :
2025-08-25 10:14:03 +02:00
console . warn ( ` Unknown WebSocket message type: ${ message . type } ` ) ;
2025-08-24 00:19:00 +02:00
}
}
2025-08-24 14:55:54 +02:00
updateData ( data ) {
2025-08-25 10:14:03 +02:00
// Update all managers with new data - ORDER MATTERS
2025-09-01 20:30:41 +02:00
// Only log data updates in verbose mode
if ( this . verbose ) {
console . debug ( ` Updating data: ${ Object . keys ( data . aircraft || { } ).length} aircraft ` ) ;
}
2025-08-25 10:14:03 +02:00
2025-08-24 14:55:54 +02:00
this . uiManager . updateData ( data ) ;
this . aircraftManager . updateAircraftData ( data ) ;
this . mapManager . updateSourcesData ( data ) ;
2025-08-24 00:19:00 +02:00
2025-08-25 10:14:03 +02:00
// Update UI components - CRITICAL: updateMarkers must be called for track propagation
2025-08-24 14:55:54 +02:00
this . aircraftManager . updateMarkers ( ) ;
2025-08-25 10:14:03 +02:00
// Update map components that depend on aircraft data
this . mapManager . updateSourceMarkers ( ) ;
// Update UI tables and statistics
2025-08-24 14:55:54 +02:00
this . uiManager . updateAircraftTable ( ) ;
this . uiManager . updateStatistics ( ) ;
this . uiManager . updateHeaderInfo ( ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 16:24:46 +02:00
// Clear selected aircraft if it no longer exists
if ( this . selectedAircraft && ! this . aircraftManager . aircraftData . has ( this . selectedAircraft ) ) {
2025-08-25 10:14:03 +02:00
console . debug ( ` Selected aircraft ${ this . selectedAircraft } no longer exists, clearing selection ` ) ;
2025-08-24 16:24:46 +02:00
this . selectedAircraft = null ;
}
2025-08-24 14:55:54 +02:00
// Update coverage controls
this . mapManager . updateCoverageControls ( ) ;
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
if ( this . uiManager . currentView === 'radar3d-view' && this . radar3dInitialized ) {
2025-08-24 14:55:54 +02:00
this . update3DRadar ( ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-25 10:14:03 +02:00
2025-09-01 20:30:41 +02:00
// Only log completion messages in verbose mode
if ( this . verbose ) {
console . debug ( ` Data update complete: ${ this . aircraftManager . aircraftMarkers . size } markers displayed ` ) ;
}
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
// View switching
async switchView ( viewId ) {
const actualViewId = this . uiManager . switchView ( viewId ) ;
2025-08-24 00:19:00 +02:00
2025-08-24 14:55:54 +02:00
// Handle view-specific initialization
const baseName = actualViewId . replace ( '-view' , '' ) ;
switch ( baseName ) {
case 'coverage' :
await this . mapManager . initializeCoverageMap ( ) ;
break ;
case 'radar3d' :
this . update3DRadar ( ) ;
break ;
2025-08-24 00:19:00 +02:00
}
}
2025-08-24 14:55:54 +02:00
// Charts
initializeCharts ( ) {
const aircraftChartCanvas = document . getElementById ( 'aircraft-chart' ) ;
if ( ! aircraftChartCanvas ) {
console . warn ( 'Aircraft chart canvas not found' ) ;
return ;
2025-08-24 00:19:00 +02:00
}
try {
2025-08-24 14:55:54 +02:00
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' }
}
}
2025-08-24 00:19:00 +02:00
}
} ) ;
} catch ( error ) {
2025-08-24 14:55:54 +02:00
console . warn ( 'Chart.js not available, skipping charts initialization' ) ;
2025-08-24 00:19:00 +02:00
}
}
updateCharts ( ) {
2025-08-24 14:55:54 +02:00
if ( ! this . charts . aircraft ) return ;
2025-08-24 00:19:00 +02:00
const now = new Date ( ) ;
const timeLabel = now . toLocaleTimeString ( ) ;
// Update aircraft count chart
2025-08-24 14:55:54 +02:00
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 ( ) ;
2025-08-24 00:19:00 +02:00
}
2025-08-24 14:55:54 +02:00
chart . update ( 'none' ) ;
2025-08-24 00:19:00 +02:00
}
2025-09-01 20:22:26 +02:00
setupViewChangeListener ( ) {
// Override the ui manager's switchView to handle 3D radar initialization
const originalSwitchView = this . uiManager . switchView . bind ( this . uiManager ) ;
this . uiManager . switchView = ( viewId ) => {
const result = originalSwitchView ( viewId ) ;
// Initialize 3D radar when switching to 3D radar view
if ( viewId === 'radar3d-view' && ! this . radar3dInitialized ) {
2025-09-01 21:02:22 +02:00
setTimeout ( async ( ) => {
await this . initialize3DRadar ( ) ;
2025-09-01 20:22:26 +02:00
this . radar3dInitialized = true ;
} , 100 ) ; // Small delay to ensure the view is visible
}
return result ;
} ;
}
2025-08-24 00:19:00 +02:00
// 3D Radar (basic implementation)
2025-09-01 21:02:22 +02:00
async initialize3DRadar ( ) {
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '🚀 Starting 3D radar initialization' ) ;
}
2025-09-01 20:22:26 +02:00
2025-08-24 00:19:00 +02:00
try {
const container = document . getElementById ( 'radar3d-container' ) ;
2025-09-01 20:22:26 +02:00
if ( ! container ) {
console . error ( '❌ Container radar3d-container not found' ) ;
return ;
}
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ Container found:' , container , 'Size:' , container . clientWidth , 'x' , container . clientHeight ) ;
// Check if container is visible
const containerStyles = window . getComputedStyle ( container ) ;
console . log ( '📋 Container styles:' , {
display : containerStyles . display ,
visibility : containerStyles . visibility ,
width : containerStyles . width ,
height : containerStyles . height
} ) ;
}
2025-09-01 20:22:26 +02:00
// Check if Three.js is available
if ( typeof THREE === 'undefined' ) {
console . error ( '❌ Three.js is not available' ) ;
return ;
}
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ Three.js available, version:' , THREE . REVISION ) ;
}
2025-09-01 20:22:26 +02:00
// Quick WebGL test
const testCanvas = document . createElement ( 'canvas' ) ;
const gl = testCanvas . getContext ( 'webgl' ) || testCanvas . getContext ( 'experimental-webgl' ) ;
if ( ! gl ) {
console . error ( '❌ WebGL is not supported in this browser' ) ;
container . innerHTML = '<div style="color: red; padding: 20px; text-align: center;">WebGL not supported. Please use a modern browser that supports WebGL.</div>' ;
return ;
}
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ WebGL is supported' ) ;
}
2025-09-01 20:22:26 +02:00
// Check container dimensions first
if ( container . clientWidth === 0 || container . clientHeight === 0 ) {
console . error ( '❌ Container has zero dimensions:' , container . clientWidth , 'x' , container . clientHeight ) ;
// Force minimum size for initialization
const width = Math . max ( container . clientWidth , 800 ) ;
const height = Math . max ( container . clientHeight , 600 ) ;
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '📐 Using forced dimensions:' , width , 'x' , height ) ;
}
2025-09-01 20:22:26 +02:00
// Create scene with forced dimensions
this . radar3d = {
scene : new THREE . Scene ( ) ,
camera : new THREE . PerspectiveCamera ( 75 , width / height , 0.1 , 1000 ) ,
renderer : new THREE . WebGLRenderer ( { alpha : true , antialias : true } ) ,
controls : null ,
2025-09-01 21:02:22 +02:00
aircraftMeshes : new Map ( ) ,
aircraftLabels : new Map ( ) ,
labelContainer : null ,
aircraftTrails : new Map ( ) ,
showTrails : false
2025-09-01 20:22:26 +02:00
} ;
// Set up renderer with forced dimensions
this . radar3d . renderer . setSize ( width , height ) ;
} else {
// Create scene with actual container dimensions
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 ,
2025-09-01 21:02:22 +02:00
aircraftMeshes : new Map ( ) ,
aircraftLabels : new Map ( ) ,
labelContainer : null ,
aircraftTrails : new Map ( ) ,
showTrails : false
2025-09-01 20:22:26 +02:00
} ;
// Set up renderer
this . radar3d . renderer . setSize ( container . clientWidth , container . clientHeight ) ;
}
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ Three.js objects created:' , {
scene : this . radar3d . scene ,
camera : this . radar3d . camera ,
renderer : this . radar3d . renderer
} ) ;
}
2025-09-01 20:22:26 +02:00
// Check WebGL context
const rendererGL = this . radar3d . renderer . getContext ( ) ;
if ( ! rendererGL ) {
console . error ( '❌ Failed to get WebGL context' ) ;
return ;
}
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ WebGL context created' ) ;
}
2025-09-01 20:22:26 +02:00
2025-08-24 00:19:00 +02:00
this . radar3d . renderer . setClearColor ( 0x0a0a0a , 0.9 ) ;
container . appendChild ( this . radar3d . renderer . domElement ) ;
2025-09-01 20:30:41 +02:00
2025-09-01 21:02:22 +02:00
// Create label container for aircraft labels
this . radar3d . labelContainer = document . createElement ( 'div' ) ;
this . radar3d . labelContainer . style . position = 'absolute' ;
this . radar3d . labelContainer . style . top = '0' ;
this . radar3d . labelContainer . style . left = '0' ;
this . radar3d . labelContainer . style . width = '100%' ;
this . radar3d . labelContainer . style . height = '100%' ;
this . radar3d . labelContainer . style . pointerEvents = 'none' ;
this . radar3d . labelContainer . style . zIndex = '1000' ;
container . appendChild ( this . radar3d . labelContainer ) ;
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ Renderer added to container' ) ;
}
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
// Add lighting - ensure the scene is visible
const ambientLight = new THREE . AmbientLight ( 0x404040 , 1.0 ) ; // Increased intensity
2025-08-24 00:19:00 +02:00
this . radar3d . scene . add ( ambientLight ) ;
2025-09-01 20:22:26 +02:00
const directionalLight = new THREE . DirectionalLight ( 0xffffff , 1.0 ) ; // Increased intensity
directionalLight . position . set ( 10 , 50 , 25 ) ;
2025-08-24 00:19:00 +02:00
this . radar3d . scene . add ( directionalLight ) ;
2025-09-01 20:22:26 +02:00
// Add hemisphere light for better illumination
const hemiLight = new THREE . HemisphereLight ( 0x0000ff , 0x00ff00 , 0.6 ) ;
hemiLight . position . set ( 0 , 100 , 0 ) ;
this . radar3d . scene . add ( hemiLight ) ;
2025-09-01 21:02:22 +02:00
// Set up camera with better view of larger map area
this . radar3d . camera . position . set ( 0 , 80 , 80 ) ;
2025-08-24 00:19:00 +02:00
this . radar3d . camera . lookAt ( 0 , 0 , 0 ) ;
// Add controls
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '🎮 Adding OrbitControls...' ) ;
}
2025-09-01 20:22:26 +02:00
if ( typeof OrbitControls === 'undefined' ) {
console . error ( '❌ OrbitControls not available' ) ;
return ;
}
2025-08-24 00:19:00 +02:00
this . radar3d . controls = new OrbitControls ( this . radar3d . camera , this . radar3d . renderer . domElement ) ;
this . radar3d . controls . enableDamping = true ;
this . radar3d . controls . dampingFactor = 0.05 ;
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '✅ OrbitControls added' ) ;
}
2025-09-01 20:22:26 +02:00
// Store default camera position for reset functionality
2025-09-01 21:02:22 +02:00
this . radar3d . defaultPosition = { x : 0 , y : 80 , z : 80 } ;
2025-09-01 20:22:26 +02:00
this . radar3d . defaultTarget = { x : 0 , y : 0 , z : 0 } ;
// Auto-rotate state
this . radar3d . autoRotate = false ;
// Origin coordinates (will be fetched from server)
this . radar3d . origin = { latitude : 59.9081 , longitude : 10.8015 } ; // fallback
2025-08-24 00:19:00 +02:00
2025-09-01 21:02:22 +02:00
// Initialize raycaster for click interactions
this . radar3d . raycaster = new THREE . Raycaster ( ) ;
this . radar3d . mouse = new THREE . Vector2 ( ) ;
this . radar3d . selectedAircraft = null ;
2025-09-01 20:22:26 +02:00
// Initialize 3D controls and fetch origin
this . initialize3DControls ( ) ;
this . fetch3DOrigin ( ) ;
2025-09-01 21:02:22 +02:00
// Add click event listener for aircraft selection
this . radar3d . renderer . domElement . addEventListener ( 'click' , ( event ) => this . handle3DClick ( event ) ) ;
2025-08-24 00:19:00 +02:00
2025-09-01 21:02:22 +02:00
// Initialize map tiles instead of simple ground plane
this . radar3d . tileManager = new TileManager ( this . radar3d . scene , this . radar3d . origin ) ;
await this . radar3d . tileManager . loadInitialTiles ( ) ;
2025-09-01 20:22:26 +02:00
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '3D Radar initialized successfully' , {
scene : this . radar3d . scene ,
camera : this . radar3d . camera ,
renderer : this . radar3d . renderer
} ) ;
}
2025-09-01 20:22:26 +02:00
2025-08-24 00:19:00 +02:00
// Start render loop
this . render3DRadar ( ) ;
} catch ( error ) {
console . error ( 'Failed to initialize 3D radar:' , error ) ;
}
}
update3DRadar ( ) {
2025-08-24 14:55:54 +02:00
if ( ! this . radar3d || ! this . radar3d . scene || ! this . aircraftManager ) return ;
2025-08-24 00:19:00 +02:00
try {
2025-09-01 20:22:26 +02:00
// Origin coordinates for distance calculation
const originLat = this . radar3d . origin . latitude ;
const originLon = this . radar3d . origin . longitude ;
const currentRange = this . radar3d . range || 100 ; // Default to 100km if not set
2025-08-24 00:19:00 +02:00
// Update aircraft positions in 3D space
2025-08-24 14:55:54 +02:00
this . aircraftManager . aircraftData . forEach ( ( aircraft , icao ) => {
2025-08-24 00:19:00 +02:00
if ( aircraft . Latitude && aircraft . Longitude ) {
const key = icao . toString ( ) ;
2025-09-01 20:22:26 +02:00
// Calculate distance from origin to filter by range
const distance = this . calculateDistance (
originLat , originLon ,
aircraft . Latitude , aircraft . Longitude
) ;
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
// Check if aircraft is within range
const withinRange = distance <= currentRange ;
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
if ( ! this . radar3d . aircraftMeshes . has ( key ) ) {
// Create new aircraft mesh only if within range
if ( withinRange ) {
2025-09-01 21:22:14 +02:00
const { geometry , material } = this . create3DAircraftGeometry ( aircraft ) ;
2025-09-01 20:22:26 +02:00
const mesh = new THREE . Mesh ( geometry , material ) ;
this . radar3d . aircraftMeshes . set ( key , mesh ) ;
this . radar3d . scene . add ( mesh ) ;
2025-09-01 21:02:22 +02:00
// Create aircraft label
this . create3DAircraftLabel ( key , aircraft ) ;
// Create trail if trails are enabled
if ( this . radar3d . showTrails ) {
this . create3DAircraftTrail ( key , aircraft ) ;
}
2025-09-01 20:22:26 +02:00
}
} else {
// Aircraft mesh exists, check if it should be visible
const mesh = this . radar3d . aircraftMeshes . get ( key ) ;
mesh . visible = withinRange ;
2025-09-01 21:02:22 +02:00
// Update label visibility
const label = this . radar3d . aircraftLabels . get ( key ) ;
if ( label ) {
label . style . display = withinRange ? 'block' : 'none' ;
}
2025-09-01 20:22:26 +02:00
}
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
// Update position if aircraft is within range
if ( withinRange ) {
const mesh = this . radar3d . aircraftMeshes . get ( key ) ;
// Convert lat/lon to local coordinates (simplified)
const x = ( aircraft . Longitude - originLon ) * 111320 * Math . cos ( aircraft . Latitude * Math . PI / 180 ) / 1000 ;
const z = - ( aircraft . Latitude - originLat ) * 111320 / 1000 ;
const y = ( aircraft . Altitude || 0 ) / 1000 ; // Convert feet to km for display
mesh . position . set ( x , y , z ) ;
2025-09-01 21:22:14 +02:00
// Update aircraft visual indicators (direction, climb/descent)
this . update3DAircraftVisuals ( mesh , aircraft ) ;
2025-09-01 21:02:22 +02:00
// Update trail if trails are enabled
if ( this . radar3d . showTrails && aircraft . position _history ) {
this . update3DAircraftTrail ( key , aircraft ) ;
}
2025-08-24 00:19:00 +02:00
}
}
} ) ;
2025-09-01 21:02:22 +02:00
// Remove old aircraft and their labels
2025-08-24 00:19:00 +02:00
this . radar3d . aircraftMeshes . forEach ( ( mesh , key ) => {
2025-08-24 14:55:54 +02:00
if ( ! this . aircraftManager . aircraftData . has ( key ) ) {
2025-08-24 00:19:00 +02:00
this . radar3d . scene . remove ( mesh ) ;
this . radar3d . aircraftMeshes . delete ( key ) ;
2025-09-01 21:02:22 +02:00
// Remove associated label
const label = this . radar3d . aircraftLabels . get ( key ) ;
if ( label ) {
this . radar3d . labelContainer . removeChild ( label ) ;
this . radar3d . aircraftLabels . delete ( key ) ;
}
// Remove associated trail
const trail = this . radar3d . aircraftTrails . get ( key ) ;
if ( trail ) {
this . radar3d . scene . remove ( trail ) ;
this . radar3d . aircraftTrails . delete ( key ) ;
}
2025-08-24 00:19:00 +02:00
}
} ) ;
} 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 ) ;
2025-09-01 21:02:22 +02:00
// Update aircraft labels after rendering
this . update3DAircraftLabels ( ) ;
}
create3DAircraftLabel ( icao , aircraft ) {
const label = document . createElement ( 'div' ) ;
label . style . position = 'absolute' ;
label . style . background = 'rgba(0, 0, 0, 0.8)' ;
label . style . color = 'white' ;
label . style . padding = '4px 8px' ;
label . style . borderRadius = '4px' ;
label . style . fontSize = '12px' ;
label . style . fontFamily = 'monospace' ;
label . style . whiteSpace = 'nowrap' ;
label . style . border = '1px solid rgba(255, 255, 255, 0.3)' ;
label . style . pointerEvents = 'auto' ;
label . style . cursor = 'pointer' ;
label . style . transition = 'opacity 0.3s' ;
label . style . zIndex = '1001' ;
// Create label content
this . update3DAircraftLabelContent ( label , aircraft ) ;
// Add click handler to label
label . addEventListener ( 'click' , ( event ) => {
event . preventDefault ( ) ;
event . stopPropagation ( ) ;
this . select3DAircraft ( icao , aircraft ) ;
} ) ;
this . radar3d . labelContainer . appendChild ( label ) ;
this . radar3d . aircraftLabels . set ( icao , label ) ;
return label ;
}
update3DAircraftLabelContent ( label , aircraft ) {
const callsign = aircraft . Callsign || aircraft . Icao || 'N/A' ;
const altitude = aircraft . Altitude ? ` ${ Math . round ( aircraft . Altitude ) } ft ` : 'N/A' ;
2025-09-01 21:08:43 +02:00
const speed = aircraft . GroundSpeed ? ` ${ Math . round ( aircraft . GroundSpeed ) } kts ` : 'N/A' ;
2025-09-01 21:02:22 +02:00
label . innerHTML = `
< div style = "font-weight: bold;" > $ { callsign } < / d i v >
< div style = "font-size: 10px; opacity: 0.8;" > $ { altitude } • $ { speed } < / d i v >
` ;
}
update3DAircraftLabels ( ) {
if ( ! this . radar3d || ! this . radar3d . aircraftLabels || ! this . aircraftManager ) return ;
// Vector for 3D to screen coordinate conversion
const vector = new THREE . Vector3 ( ) ;
const canvas = this . radar3d . renderer . domElement ;
this . radar3d . aircraftLabels . forEach ( ( label , icao ) => {
const mesh = this . radar3d . aircraftMeshes . get ( icao ) ;
const aircraft = this . aircraftManager . aircraftData . get ( icao ) ;
if ( mesh && aircraft && mesh . visible ) {
// Get aircraft world position
vector . setFromMatrixPosition ( mesh . matrixWorld ) ;
// Project to screen coordinates
vector . project ( this . radar3d . camera ) ;
// Check if aircraft is behind camera
if ( vector . z > 1 ) {
label . style . display = 'none' ;
return ;
}
// Convert to screen pixels
const x = ( vector . x * 0.5 + 0.5 ) * canvas . clientWidth ;
const y = ( vector . y * - 0.5 + 0.5 ) * canvas . clientHeight ;
// Position label slightly offset from aircraft position
label . style . left = ` ${ x + 10 } px ` ;
label . style . top = ` ${ y - 30 } px ` ;
label . style . display = 'block' ;
label . style . opacity = '1' ;
// Update label content with current aircraft data
this . update3DAircraftLabelContent ( label , aircraft ) ;
} else {
label . style . display = 'none' ;
}
} ) ;
}
handle3DClick ( event ) {
if ( ! this . radar3d || ! this . radar3d . raycaster || ! this . aircraftManager ) return ;
// Prevent event from propagating to orbit controls
event . preventDefault ( ) ;
const canvas = this . radar3d . renderer . domElement ;
const rect = canvas . getBoundingClientRect ( ) ;
// Calculate mouse position in normalized device coordinates (-1 to +1)
this . radar3d . mouse . x = ( ( event . clientX - rect . left ) / rect . width ) * 2 - 1 ;
this . radar3d . mouse . y = - ( ( event . clientY - rect . top ) / rect . height ) * 2 + 1 ;
// Update raycaster with camera and mouse position
this . radar3d . raycaster . setFromCamera ( this . radar3d . mouse , this . radar3d . camera ) ;
// Get all visible aircraft meshes
const aircraftMeshes = [ ] ;
this . radar3d . aircraftMeshes . forEach ( ( mesh , icao ) => {
if ( mesh . visible ) {
mesh . userData . icao = icao ; // Store ICAO for identification
aircraftMeshes . push ( mesh ) ;
}
} ) ;
// Check for intersections
const intersects = this . radar3d . raycaster . intersectObjects ( aircraftMeshes ) ;
if ( intersects . length > 0 ) {
const selectedMesh = intersects [ 0 ] . object ;
const selectedIcao = selectedMesh . userData . icao ;
const aircraft = this . aircraftManager . aircraftData . get ( selectedIcao ) ;
if ( aircraft ) {
this . select3DAircraft ( selectedIcao , aircraft ) ;
}
} else {
// Clicked on empty space, deselect
this . deselect3DAircraft ( ) ;
}
}
select3DAircraft ( icao , aircraft ) {
// Deselect previous aircraft
this . deselect3DAircraft ( ) ;
this . radar3d . selectedAircraft = icao ;
// Highlight selected aircraft mesh
const mesh = this . radar3d . aircraftMeshes . get ( icao ) ;
if ( mesh ) {
mesh . material . color . setHex ( 0xff8800 ) ; // Orange color for selection
mesh . material . emissive . setHex ( 0x441100 ) ; // Subtle glow
}
// Highlight selected aircraft label
const label = this . radar3d . aircraftLabels . get ( icao ) ;
if ( label ) {
label . style . background = 'rgba(255, 136, 0, 0.9)' ;
label . style . borderColor = 'rgba(255, 136, 0, 0.8)' ;
label . style . transform = 'scale(1.1)' ;
}
// Log selection for debugging
if ( this . verbose ) {
console . log ( ` Selected aircraft: ${ icao } ` , aircraft ) ;
}
// Trigger aircraft selection in main aircraft manager (for consistency with 2D map)
if ( this . aircraftManager . onAircraftSelected ) {
this . aircraftManager . onAircraftSelected ( icao , aircraft ) ;
}
}
deselect3DAircraft ( ) {
if ( ! this . radar3d . selectedAircraft ) return ;
const icao = this . radar3d . selectedAircraft ;
2025-09-01 21:22:14 +02:00
// Reset mesh appearance to original aircraft color
2025-09-01 21:02:22 +02:00
const mesh = this . radar3d . aircraftMeshes . get ( icao ) ;
2025-09-01 21:22:14 +02:00
const aircraft = this . aircraftManager . aircraftData . get ( icao ) ;
if ( mesh && aircraft ) {
const visualType = this . getAircraftVisualType ( aircraft ) ;
const originalColor = this . getAircraftColor ( aircraft , visualType ) ;
mesh . material . color . setHex ( originalColor ) ;
mesh . material . emissive . setHex ( originalColor ) ;
mesh . material . emissiveIntensity = 0.1 ; // Restore original glow
2025-09-01 21:02:22 +02:00
}
// Reset label appearance
const label = this . radar3d . aircraftLabels . get ( icao ) ;
if ( label ) {
label . style . background = 'rgba(0, 0, 0, 0.8)' ;
label . style . borderColor = 'rgba(255, 255, 255, 0.3)' ;
label . style . transform = 'scale(1)' ;
}
this . radar3d . selectedAircraft = null ;
}
create3DAircraftTrail ( icao , aircraft ) {
if ( ! aircraft . position _history || aircraft . position _history . length < 2 ) return ;
// Create line geometry for trail
const points = [ ] ;
const originLat = this . radar3d . origin . latitude ;
const originLon = this . radar3d . origin . longitude ;
// Convert position history to 3D world coordinates
aircraft . position _history . forEach ( pos => {
2025-09-01 21:08:43 +02:00
if ( pos . lat && pos . lon ) {
const x = ( pos . lon - originLon ) * 111320 * Math . cos ( pos . lat * Math . PI / 180 ) / 1000 ;
const z = - ( pos . lat - originLat ) * 111320 / 1000 ;
const y = ( pos . altitude || 0 ) / 1000 ; // Use historical altitude from position history
2025-09-01 21:02:22 +02:00
points . push ( new THREE . Vector3 ( x , y , z ) ) ;
}
} ) ;
if ( points . length < 2 ) return ;
// Create line geometry
const geometry = new THREE . BufferGeometry ( ) . setFromPoints ( points ) ;
// Create gradient material for trail (older parts more transparent)
const material = new THREE . LineBasicMaterial ( {
color : 0x00aa88 ,
transparent : true ,
opacity : 0.7 ,
linewidth : 2
} ) ;
const trail = new THREE . Line ( geometry , material ) ;
this . radar3d . scene . add ( trail ) ;
this . radar3d . aircraftTrails . set ( icao , trail ) ;
if ( this . verbose ) {
console . log ( ` Created trail for ${ icao } with ${ points . length } points ` ) ;
}
}
update3DAircraftTrail ( icao , aircraft ) {
if ( ! aircraft . position _history || aircraft . position _history . length < 2 ) {
// Remove trail if no history
const trail = this . radar3d . aircraftTrails . get ( icao ) ;
if ( trail ) {
this . radar3d . scene . remove ( trail ) ;
this . radar3d . aircraftTrails . delete ( icao ) ;
}
return ;
}
const trail = this . radar3d . aircraftTrails . get ( icao ) ;
if ( trail ) {
// Update existing trail
const points = [ ] ;
const originLat = this . radar3d . origin . latitude ;
const originLon = this . radar3d . origin . longitude ;
// Convert position history to 3D world coordinates
aircraft . position _history . forEach ( pos => {
2025-09-01 21:08:43 +02:00
if ( pos . lat && pos . lon ) {
const x = ( pos . lon - originLon ) * 111320 * Math . cos ( pos . lat * Math . PI / 180 ) / 1000 ;
const z = - ( pos . lat - originLat ) * 111320 / 1000 ;
const y = ( pos . altitude || 0 ) / 1000 ; // Use historical altitude from position history
2025-09-01 21:02:22 +02:00
points . push ( new THREE . Vector3 ( x , y , z ) ) ;
}
} ) ;
if ( points . length >= 2 ) {
// Update geometry with new points
trail . geometry . setFromPoints ( points ) ;
trail . geometry . needsUpdate = true ;
}
} else {
// Create new trail
this . create3DAircraftTrail ( icao , aircraft ) ;
}
}
toggle3DTrails ( ) {
this . radar3d . showTrails = ! this . radar3d . showTrails ;
if ( this . radar3d . showTrails ) {
// Create trails for all existing aircraft
this . aircraftManager . aircraftData . forEach ( ( aircraft , icao ) => {
if ( this . radar3d . aircraftMeshes . has ( icao ) ) {
this . create3DAircraftTrail ( icao , aircraft ) ;
}
} ) ;
} else {
// Remove all trails
this . radar3d . aircraftTrails . forEach ( ( trail , icao ) => {
this . radar3d . scene . remove ( trail ) ;
} ) ;
this . radar3d . aircraftTrails . clear ( ) ;
}
// Update button state
const trailButton = document . getElementById ( 'radar3d-trails' ) ;
if ( trailButton ) {
trailButton . textContent = this . radar3d . showTrails ? 'Hide Trails' : 'Show Trails' ;
trailButton . classList . toggle ( 'active' , this . radar3d . showTrails ) ;
}
if ( this . verbose ) {
console . log ( ` 3D trails ${ this . radar3d . showTrails ? 'enabled' : 'disabled' } ` ) ;
}
2025-08-24 00:19:00 +02:00
}
2025-09-01 21:22:14 +02:00
// Aircraft visual type determination based on category and other data
getAircraftVisualType ( aircraft ) {
const category = aircraft . Category || '' ;
// Helicopter detection
if ( category . toLowerCase ( ) . includes ( 'helicopter' ) ||
category . toLowerCase ( ) . includes ( 'rotorcraft' ) ) {
return 'helicopter' ;
}
// Weight-based categories
if ( category . includes ( 'Heavy' ) ) return 'heavy' ;
if ( category . includes ( 'Medium' ) ) return 'medium' ;
if ( category . includes ( 'Light' ) ) return 'light' ;
// Size-based categories (fallback)
if ( category . includes ( 'Large' ) ) return 'heavy' ;
if ( category . includes ( 'Small' ) ) return 'light' ;
// Default to medium commercial aircraft
return 'medium' ;
}
// Get aircraft color based on type and status
getAircraftColor ( aircraft , visualType ) {
// Emergency override
if ( aircraft . Emergency && aircraft . Emergency !== 'None' ) {
return 0xff0000 ; // Red for emergencies
}
// Special squawk codes
if ( aircraft . Squawk ) {
if ( aircraft . Squawk === '7500' || aircraft . Squawk === '7600' || aircraft . Squawk === '7700' ) {
return 0xff0000 ; // Red for emergency squawks
}
}
// Type-based colors
switch ( visualType ) {
case 'helicopter' :
return 0x00ffff ; // Cyan for helicopters
case 'heavy' :
return 0x0088ff ; // Blue for heavy aircraft
case 'medium' :
return 0x00ff00 ; // Green for medium aircraft (default)
case 'light' :
return 0xffff00 ; // Yellow for light aircraft
default :
return 0x00ff00 ; // Default green
}
}
// Create appropriate 3D geometry and material for aircraft type
create3DAircraftGeometry ( aircraft ) {
const visualType = this . getAircraftVisualType ( aircraft ) ;
const color = this . getAircraftColor ( aircraft , visualType ) ;
let geometry , scale = 1 ;
switch ( visualType ) {
case 'helicopter' :
// Helicopter: Wider, flatter shape with rotor disk
geometry = new THREE . CylinderGeometry ( 0.8 , 0.4 , 0.6 , 8 ) ;
scale = 1.0 ;
break ;
case 'heavy' :
// Heavy aircraft: Large, wide fuselage
geometry = new THREE . CylinderGeometry ( 0.4 , 0.8 , 3.0 , 8 ) ;
scale = 1.3 ;
break ;
case 'light' :
// Light aircraft: Small, simple shape
geometry = new THREE . ConeGeometry ( 0.3 , 1.5 , 6 ) ;
scale = 0.7 ;
break ;
case 'medium' :
default :
// Medium/default: Standard cone shape
geometry = new THREE . ConeGeometry ( 0.5 , 2 , 6 ) ;
scale = 1.0 ;
break ;
}
const material = new THREE . MeshLambertMaterial ( {
color : color ,
emissive : color ,
emissiveIntensity : 0.1
} ) ;
// Scale geometry if needed
if ( scale !== 1.0 ) {
geometry . scale ( scale , scale , scale ) ;
}
return { geometry , material } ;
}
// Update aircraft visual indicators (direction, climb/descent)
update3DAircraftVisuals ( mesh , aircraft ) {
// Set aircraft direction based on track
if ( aircraft . Track !== undefined && aircraft . Track !== 0 ) {
mesh . rotation . y = - aircraft . Track * Math . PI / 180 ;
}
// Add climb/descent indicator
this . update3DClimbDescentIndicator ( mesh , aircraft ) ;
}
// Add or update climb/descent visual indicator
update3DClimbDescentIndicator ( mesh , aircraft ) {
const verticalRate = aircraft . VerticalRate || 0 ;
const threshold = 500 ; // feet per minute
// Remove existing indicator
const existingIndicator = mesh . getObjectByName ( 'climbIndicator' ) ;
if ( existingIndicator ) {
mesh . remove ( existingIndicator ) ;
}
// Add new indicator if significant vertical movement
if ( Math . abs ( verticalRate ) > threshold ) {
let indicatorGeometry , indicatorMaterial ;
if ( verticalRate > threshold ) {
// Climbing - green upward arrow
indicatorGeometry = new THREE . ConeGeometry ( 0.2 , 0.8 , 4 ) ;
indicatorMaterial = new THREE . MeshBasicMaterial ( {
color : 0x00ff00 ,
transparent : true ,
opacity : 0.8
} ) ;
} else if ( verticalRate < - threshold ) {
// Descending - red downward arrow
indicatorGeometry = new THREE . ConeGeometry ( 0.2 , 0.8 , 4 ) ;
indicatorMaterial = new THREE . MeshBasicMaterial ( {
color : 0xff0000 ,
transparent : true ,
opacity : 0.8
} ) ;
indicatorGeometry . rotateX ( Math . PI ) ; // Flip for downward arrow
}
if ( indicatorGeometry && indicatorMaterial ) {
const indicator = new THREE . Mesh ( indicatorGeometry , indicatorMaterial ) ;
indicator . name = 'climbIndicator' ;
indicator . position . set ( 0 , 2 , 0 ) ; // Position above aircraft
mesh . add ( indicator ) ;
}
}
}
2025-08-24 00:19:00 +02:00
2025-09-01 20:22:26 +02:00
initialize3DControls ( ) {
// Enable and initialize the Reset View button
const resetButton = document . getElementById ( 'radar3d-reset' ) ;
if ( resetButton ) {
resetButton . disabled = false ;
resetButton . addEventListener ( 'click' , ( ) => this . reset3DView ( ) ) ;
}
// Enable and initialize the Auto Rotate toggle button
const autoRotateButton = document . getElementById ( 'radar3d-auto-rotate' ) ;
if ( autoRotateButton ) {
autoRotateButton . disabled = false ;
autoRotateButton . addEventListener ( 'click' , ( ) => this . toggle3DAutoRotate ( ) ) ;
}
2025-09-01 21:02:22 +02:00
// Enable and initialize the Trails toggle button
const trailsButton = document . getElementById ( 'radar3d-trails' ) ;
if ( trailsButton ) {
trailsButton . disabled = false ;
trailsButton . addEventListener ( 'click' , ( ) => this . toggle3DTrails ( ) ) ;
}
2025-09-01 20:22:26 +02:00
// Enable and initialize the Range slider
const rangeSlider = document . getElementById ( 'radar3d-range' ) ;
const rangeValue = document . getElementById ( 'radar3d-range-value' ) ;
if ( rangeSlider && rangeValue ) {
rangeSlider . disabled = false ;
this . radar3d . range = parseInt ( rangeSlider . value ) ; // Store current range
rangeSlider . addEventListener ( 'input' , ( e ) => {
this . radar3d . range = parseInt ( e . target . value ) ;
rangeValue . textContent = this . radar3d . range ;
this . update3DRadarRange ( ) ;
} ) ;
}
}
reset3DView ( ) {
if ( ! this . radar3d || ! this . radar3d . controls ) return ;
// Reset camera to default position
this . radar3d . camera . position . set (
this . radar3d . defaultPosition . x ,
this . radar3d . defaultPosition . y ,
this . radar3d . defaultPosition . z
) ;
// Reset camera target
this . radar3d . controls . target . set (
this . radar3d . defaultTarget . x ,
this . radar3d . defaultTarget . y ,
this . radar3d . defaultTarget . z
) ;
// Update controls to apply changes smoothly
this . radar3d . controls . update ( ) ;
}
toggle3DAutoRotate ( ) {
if ( ! this . radar3d || ! this . radar3d . controls ) return ;
// Toggle auto-rotate state
this . radar3d . autoRotate = ! this . radar3d . autoRotate ;
this . radar3d . controls . autoRotate = this . radar3d . autoRotate ;
// Update button text to reflect current state
const autoRotateButton = document . getElementById ( 'radar3d-auto-rotate' ) ;
if ( autoRotateButton ) {
autoRotateButton . textContent = this . radar3d . autoRotate ? 'Stop Auto Rotate' : 'Auto Rotate' ;
autoRotateButton . classList . toggle ( 'active' , this . radar3d . autoRotate ) ;
}
}
async fetch3DOrigin ( ) {
try {
const response = await fetch ( '/api/origin' ) ;
if ( response . ok ) {
const origin = await response . json ( ) ;
this . radar3d . origin = origin ;
2025-09-01 20:30:41 +02:00
if ( this . verbose ) {
console . log ( '3D Radar origin set to:' , origin ) ;
}
2025-09-01 20:22:26 +02:00
}
} catch ( error ) {
console . warn ( 'Failed to fetch origin, using fallback:' , error ) ;
}
}
update3DRadarRange ( ) {
// Simply trigger a full update of the 3D radar with new range filter
this . update3DRadar ( ) ;
}
// Calculate distance between two lat/lon points in kilometers
calculateDistance ( lat1 , lon1 , lat2 , lon2 ) {
const R = 6371 ; // Earth's radius in kilometers
const dLat = ( lat2 - lat1 ) * Math . PI / 180 ;
const dLon = ( lon2 - lon1 ) * Math . PI / 180 ;
const a =
Math . sin ( dLat / 2 ) * Math . sin ( dLat / 2 ) +
Math . cos ( lat1 * Math . PI / 180 ) * Math . cos ( lat2 * 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 ;
}
2025-09-01 10:29:35 +02:00
updateOpenPopupAges ( ) {
// Find any open aircraft popups and update their age displays
if ( ! this . aircraftManager ) return ;
this . aircraftManager . aircraftMarkers . forEach ( ( marker , icao ) => {
if ( marker . isPopupOpen ( ) ) {
const aircraft = this . aircraftManager . aircraftData . get ( icao ) ;
if ( aircraft ) {
// Refresh the popup content with current age
marker . setPopupContent ( this . aircraftManager . createPopupContent ( aircraft ) ) ;
// Re-enhance callsign display for the updated popup
const popupElement = marker . getPopup ( ) . getElement ( ) ;
if ( popupElement ) {
this . aircraftManager . enhanceCallsignDisplay ( popupElement ) ;
}
}
}
} ) ;
}
2025-08-24 00:19:00 +02:00
startPeriodicTasks ( ) {
// Update clocks every second
2025-08-24 14:55:54 +02:00
setInterval ( ( ) => this . uiManager . updateClocks ( ) , 1000 ) ;
2025-09-01 10:29:35 +02:00
// Update aircraft ages and refresh displays every second
setInterval ( ( ) => {
// Update aircraft table to show current ages
this . uiManager . updateAircraftTable ( ) ;
// Update any open aircraft popups with current ages
this . updateOpenPopupAges ( ) ;
} , 1000 ) ;
2025-08-24 14:55:54 +02:00
// Update charts every 10 seconds
setInterval ( ( ) => this . updateCharts ( ) , 10000 ) ;
2025-08-24 00:19:00 +02:00
// Periodic cleanup
setInterval ( ( ) => {
// Clean up old trail data, etc.
} , 30000 ) ;
}
2025-08-24 18:24:08 +02:00
setupCollapsibleSections ( ) {
// Setup Display Options collapsible
const displayHeader = document . getElementById ( 'display-options-header' ) ;
const displayContent = document . getElementById ( 'display-options-content' ) ;
if ( displayHeader && displayContent ) {
displayHeader . addEventListener ( 'click' , ( ) => {
const isCollapsed = displayContent . classList . contains ( 'collapsed' ) ;
if ( isCollapsed ) {
// Expand
displayContent . classList . remove ( 'collapsed' ) ;
displayHeader . classList . remove ( 'collapsed' ) ;
} else {
// Collapse
displayContent . classList . add ( 'collapsed' ) ;
displayHeader . classList . add ( 'collapsed' ) ;
}
// Save state to localStorage
localStorage . setItem ( 'displayOptionsCollapsed' , ! isCollapsed ) ;
} ) ;
// Restore saved state (default to collapsed)
const savedState = localStorage . getItem ( 'displayOptionsCollapsed' ) ;
const shouldCollapse = savedState === null ? true : savedState === 'true' ;
if ( shouldCollapse ) {
displayContent . classList . add ( 'collapsed' ) ;
displayHeader . classList . add ( 'collapsed' ) ;
} else {
displayContent . classList . remove ( 'collapsed' ) ;
displayHeader . classList . remove ( 'collapsed' ) ;
}
}
}
2025-08-24 00:19:00 +02:00
}
// Initialize application when DOM is ready
document . addEventListener ( 'DOMContentLoaded' , ( ) => {
window . skyview = new SkyView ( ) ;
} ) ;