// 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'; import { CallsignManager } from './modules/callsign-manager.js'; import { escapeHtml } from './modules/html-utils.js'; // 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); } } } class SkyView { constructor() { // Initialize managers this.wsManager = null; this.aircraftManager = null; this.mapManager = null; this.uiManager = null; this.callsignManager = null; // 3D Radar this.radar3d = null; // Charts this.charts = {}; // Selected aircraft tracking this.selectedAircraft = null; this.selectedTrailEnabled = false; // 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; this.init(); } async init() { try { // Initialize UI manager first this.uiManager = new UIManager(); this.uiManager.initializeViews(); this.uiManager.initializeEventListeners(); // Initialize callsign manager for enriched callsign display this.callsignManager = new CallsignManager(); // Initialize map manager and get the main map this.mapManager = new MapManager(); const map = await this.mapManager.initializeMap(); // Initialize aircraft manager with the map and callsign manager this.aircraftManager = new AircraftManager(map, this.callsignManager); // Set up selected aircraft trail callback this.aircraftManager.setSelectedAircraftCallback((icao) => { return this.selectedTrailEnabled && this.selectedAircraft === icao; }); // 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(); // Flag to track 3D radar initialization this.radar3dInitialized = false; // Add view change listener for 3D radar initialization this.setupViewChangeListener(); // Set up map controls this.setupMapControls(); // Set up aircraft selection listener this.setupAircraftSelection(); this.startPeriodicTasks(); } 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 toggleSourcesBtn = document.getElementById('toggle-sources'); if (centerMapBtn) { centerMapBtn.addEventListener('click', () => { this.aircraftManager.centerMapOnAircraft(() => this.mapManager.getSourcePositions()); }); } 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 (toggleSourcesBtn) { toggleSourcesBtn.addEventListener('click', () => { const showSources = this.mapManager.toggleSources(); toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources'; }); } // Setup collapsible sections this.setupCollapsibleSections(); const toggleDarkModeBtn = document.getElementById('toggle-dark-mode'); if (toggleDarkModeBtn) { toggleDarkModeBtn.addEventListener('click', () => { const isDarkMode = this.mapManager.toggleDarkMode(); toggleDarkModeBtn.innerHTML = isDarkMode ? '☀️ Light Mode' : '🌙 Night Mode'; }); } // 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(); }); } // 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); } }); } } setupAircraftSelection() { document.addEventListener('aircraftSelected', (e) => { const { icao, aircraft } = e.detail; this.uiManager.switchView('map-view'); // Hide trail for previously selected aircraft if (this.selectedAircraft && this.selectedTrailEnabled) { this.aircraftManager.hideAircraftTrail(this.selectedAircraft); } // Update selected aircraft this.selectedAircraft = icao; // 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; } } // Show trail for newly selected aircraft this.aircraftManager.showAircraftTrail(icao); // 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) { const aircraftCount = Object.keys(message.data.aircraft || {}).length; // Only log WebSocket messages in verbose mode if (this.verbose) { console.debug(`WebSocket message: ${message.type}, ${aircraftCount} aircraft, timestamp: ${message.timestamp}`); } switch (message.type) { case 'initial_data': console.log(`Received initial data with ${aircraftCount} aircraft`); 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.warn(`Unknown WebSocket message type: ${message.type}`); } } updateData(data) { // Update all managers with new data - ORDER MATTERS // Only log data updates in verbose mode if (this.verbose) { console.debug(`Updating data: ${Object.keys(data.aircraft || {}).length} aircraft`); } this.uiManager.updateData(data); this.aircraftManager.updateAircraftData(data); this.mapManager.updateSourcesData(data); // Update UI components - CRITICAL: updateMarkers must be called for track propagation this.aircraftManager.updateMarkers(); // Update map components that depend on aircraft data this.mapManager.updateSourceMarkers(); // Update UI tables and statistics this.uiManager.updateAircraftTable(); this.uiManager.updateStatistics(); this.uiManager.updateHeaderInfo(); // Clear selected aircraft if it no longer exists if (this.selectedAircraft && !this.aircraftManager.aircraftData.has(this.selectedAircraft)) { console.debug(`Selected aircraft ${this.selectedAircraft} no longer exists, clearing selection`); this.selectedAircraft = null; } // Update coverage controls this.mapManager.updateCoverageControls(); if (this.uiManager.currentView === 'radar3d-view' && this.radar3dInitialized) { this.update3DRadar(); } // Only log completion messages in verbose mode if (this.verbose) { console.debug(`Data update complete: ${this.aircraftManager.aircraftMarkers.size} markers displayed`); } } // 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'); } 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) { setTimeout(async () => { await this.initialize3DRadar(); this.radar3dInitialized = true; }, 100); // Small delay to ensure the view is visible } return result; }; } // 3D Radar (basic implementation) async initialize3DRadar() { if (this.verbose) { console.log('🚀 Starting 3D radar initialization'); } try { const container = document.getElementById('radar3d-container'); if (!container) { console.error('❌ Container radar3d-container not found'); return; } 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 }); } // Check if Three.js is available if (typeof THREE === 'undefined') { console.error('❌ Three.js is not available'); return; } if (this.verbose) { console.log('✅ Three.js available, version:', THREE.REVISION); } // 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 = '
WebGL not supported. Please use a modern browser that supports WebGL.
'; return; } if (this.verbose) { console.log('✅ WebGL is supported'); } // 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); if (this.verbose) { console.log('📐 Using forced dimensions:', width, 'x', height); } // 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, aircraftMeshes: new Map(), aircraftLabels: new Map(), labelContainer: null, aircraftTrails: new Map(), showTrails: false }; // 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, aircraftMeshes: new Map(), aircraftLabels: new Map(), labelContainer: null, aircraftTrails: new Map(), showTrails: false }; // Set up renderer this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight); } if (this.verbose) { console.log('✅ Three.js objects created:', { scene: this.radar3d.scene, camera: this.radar3d.camera, renderer: this.radar3d.renderer }); } // Check WebGL context const rendererGL = this.radar3d.renderer.getContext(); if (!rendererGL) { console.error('❌ Failed to get WebGL context'); return; } if (this.verbose) { console.log('✅ WebGL context created'); } this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9); container.appendChild(this.radar3d.renderer.domElement); // 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); if (this.verbose) { console.log('✅ Renderer added to container'); } // Add lighting - ensure the scene is visible const ambientLight = new THREE.AmbientLight(0x404040, 1.0); // Increased intensity this.radar3d.scene.add(ambientLight); const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); // Increased intensity directionalLight.position.set(10, 50, 25); this.radar3d.scene.add(directionalLight); // 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); // Set up camera with better view of larger map area this.radar3d.camera.position.set(0, 80, 80); this.radar3d.camera.lookAt(0, 0, 0); // Add controls if (this.verbose) { console.log('🎮 Adding OrbitControls...'); } if (typeof OrbitControls === 'undefined') { console.error('❌ OrbitControls not available'); return; } this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement); this.radar3d.controls.enableDamping = true; this.radar3d.controls.dampingFactor = 0.05; if (this.verbose) { console.log('✅ OrbitControls added'); } // Store default camera position for reset functionality this.radar3d.defaultPosition = { x: 0, y: 80, z: 80 }; 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 // Initialize raycaster for click interactions this.radar3d.raycaster = new THREE.Raycaster(); this.radar3d.mouse = new THREE.Vector2(); this.radar3d.selectedAircraft = null; // Initialize 3D controls and fetch origin this.initialize3DControls(); this.fetch3DOrigin(); // Add click event listener for aircraft selection this.radar3d.renderer.domElement.addEventListener('click', (event) => this.handle3DClick(event)); // Initialize map tiles instead of simple ground plane this.radar3d.tileManager = new TileManager(this.radar3d.scene, this.radar3d.origin); await this.radar3d.tileManager.loadInitialTiles(); if (this.verbose) { console.log('3D Radar initialized successfully', { scene: this.radar3d.scene, camera: this.radar3d.camera, renderer: this.radar3d.renderer }); } // Start render loop this.render3DRadar(); } catch (error) { console.error('Failed to initialize 3D radar:', error); } } update3DRadar() { if (!this.radar3d || !this.radar3d.scene || !this.aircraftManager) return; try { // 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 // Update aircraft positions in 3D space this.aircraftManager.aircraftData.forEach((aircraft, icao) => { if (aircraft.Latitude && aircraft.Longitude) { const key = icao.toString(); // Calculate distance from origin to filter by range const distance = this.calculateDistance( originLat, originLon, aircraft.Latitude, aircraft.Longitude ); // Check if aircraft is within range const withinRange = distance <= currentRange; if (!this.radar3d.aircraftMeshes.has(key)) { // Create new aircraft mesh only if within range if (withinRange) { const { geometry, material } = this.create3DAircraftGeometry(aircraft); const mesh = new THREE.Mesh(geometry, material); // Apply signal quality visual effects this.apply3DSignalQualityEffects(mesh, aircraft); this.radar3d.aircraftMeshes.set(key, mesh); this.radar3d.scene.add(mesh); // Create aircraft label this.create3DAircraftLabel(key, aircraft); // Create trail if trails are enabled if (this.radar3d.showTrails) { this.create3DAircraftTrail(key, aircraft); } } } else { // Aircraft mesh exists, check if it should be visible const mesh = this.radar3d.aircraftMeshes.get(key); mesh.visible = withinRange; // Update label visibility const label = this.radar3d.aircraftLabels.get(key); if (label) { label.style.display = withinRange ? 'block' : 'none'; } } // 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); // Update aircraft visual indicators (direction, climb/descent) this.update3DAircraftVisuals(mesh, aircraft); // Update signal quality effects this.apply3DSignalQualityEffects(mesh, aircraft); // Update trail if trails are enabled if (this.radar3d.showTrails && aircraft.position_history) { this.update3DAircraftTrail(key, aircraft); } } } }); // Remove old aircraft and their labels this.radar3d.aircraftMeshes.forEach((mesh, key) => { if (!this.aircraftManager.aircraftData.has(key)) { this.radar3d.scene.remove(mesh); this.radar3d.aircraftMeshes.delete(key); // 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); } } }); } 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); // 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'; const speed = aircraft.GroundSpeed ? `${Math.round(aircraft.GroundSpeed)}kts` : 'N/A'; label.innerHTML = `
${escapeHtml(callsign)}
${escapeHtml(altitude)} • ${escapeHtml(speed)}
`; } 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; // Reset mesh appearance to original aircraft color const mesh = this.radar3d.aircraftMeshes.get(icao); 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 } // 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 => { 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 points.push(new THREE.Vector3(x, y, z)); } }); if (points.length < 2) return; // Create enhanced line geometry with gradient colors const geometry = this.create3DTrailGeometry(points, aircraft); // Create material with vertex colors for gradient effect const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.8 }); 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 => { 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 points.push(new THREE.Vector3(x, y, z)); } }); if (points.length >= 2) { // Update geometry with enhanced trail visualization const newGeometry = this.create3DTrailGeometry(points, aircraft); trail.geometry.dispose(); // Clean up old geometry trail.geometry = newGeometry; } } 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'}`); } } // 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); } } } // Apply signal quality visual effects to aircraft mesh apply3DSignalQualityEffects(mesh, aircraft) { // Get signal quality data from sources const signalData = this.getAircraftSignalData(aircraft); // Apply opacity based on signal strength this.updateAircraftOpacity(mesh, signalData); // Apply emissive intensity based on signal quality this.updateAircraftEmissiveIntensity(mesh, signalData); // Add data source indicator this.addDataSourceIndicator(mesh, signalData); } // Extract signal data from aircraft sources getAircraftSignalData(aircraft) { if (!aircraft.sources || Object.keys(aircraft.sources).length === 0) { return { bestSignal: -50, // Default medium signal sourceCount: 0, primarySource: null, signalQuality: 'Unknown' }; } let bestSignal = -999; let sourceCount = 0; let primarySource = null; // Find strongest signal and count sources Object.entries(aircraft.sources).forEach(([sourceId, sourceData]) => { sourceCount++; if (sourceData.signal_level > bestSignal) { bestSignal = sourceData.signal_level; primarySource = sourceId; } }); return { bestSignal, sourceCount, primarySource, signalQuality: aircraft.SignalQuality || 'Unknown' }; } // Update aircraft opacity based on signal strength updateAircraftOpacity(mesh, signalData) { // Map signal strength to opacity (stronger signal = more opaque) // Typical ADS-B signal range: -60 dBFS (weak) to 0 dBFS (strong) const minSignal = -60; const maxSignal = -5; const minOpacity = 0.3; const maxOpacity = 1.0; // Clamp signal to expected range const clampedSignal = Math.max(minSignal, Math.min(maxSignal, signalData.bestSignal)); // Calculate opacity (linear mapping) const signalRange = maxSignal - minSignal; const signalNormalized = (clampedSignal - minSignal) / signalRange; const opacity = minOpacity + (signalNormalized * (maxOpacity - minOpacity)); // Apply opacity to material mesh.material.transparent = true; mesh.material.opacity = opacity; // Also adjust emissive intensity for glow effect const baseEmissiveIntensity = 0.1; const maxEmissiveBoost = 0.3; mesh.material.emissiveIntensity = baseEmissiveIntensity + (signalNormalized * maxEmissiveBoost); } // Update emissive intensity based on signal quality assessment updateAircraftEmissiveIntensity(mesh, signalData) { let qualityMultiplier = 1.0; // Adjust based on signal quality description switch (signalData.signalQuality) { case 'Excellent': qualityMultiplier = 1.5; break; case 'Good': qualityMultiplier = 1.2; break; case 'Fair': qualityMultiplier = 0.8; break; case 'Poor': qualityMultiplier = 0.5; break; default: qualityMultiplier = 1.0; } // Apply quality multiplier to emissive intensity const currentIntensity = mesh.material.emissiveIntensity || 0.1; mesh.material.emissiveIntensity = Math.min(0.5, currentIntensity * qualityMultiplier); } // Add visual indicator for data source type addDataSourceIndicator(mesh, signalData) { // Remove existing source indicator const existingIndicator = mesh.getObjectByName('sourceIndicator'); if (existingIndicator) { mesh.remove(existingIndicator); } // Only add indicator if we have multiple sources if (signalData.sourceCount > 1) { // Create small indicator showing multi-source data const indicatorGeometry = new THREE.SphereGeometry(0.1, 4, 4); const indicatorMaterial = new THREE.MeshBasicMaterial({ color: signalData.sourceCount > 2 ? 0x00ff00 : 0xffaa00, // Green for 3+, orange for 2 transparent: true, opacity: 0.7 }); const indicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial); indicator.name = 'sourceIndicator'; indicator.position.set(1, 1, 0); // Position to side of aircraft mesh.add(indicator); } } // Create enhanced trail geometry with gradient colors and speed-based effects create3DTrailGeometry(points, aircraft) { const geometry = new THREE.BufferGeometry(); // Set positions const positions = new Float32Array(points.length * 3); const colors = new Float32Array(points.length * 3); // Get signal quality for trail styling const signalData = this.getAircraftSignalData(aircraft); const baseTrailColor = this.getTrailColor(aircraft, signalData); // Calculate colors with gradient effect (newer = brighter, older = dimmer) for (let i = 0; i < points.length; i++) { // Position positions[i * 3] = points[i].x; positions[i * 3 + 1] = points[i].y; positions[i * 3 + 2] = points[i].z; // Color with age-based fading const ageRatio = i / (points.length - 1); // 0 = oldest, 1 = newest const intensity = 0.3 + (ageRatio * 0.7); // Fade from 30% to 100% brightness colors[i * 3] = baseTrailColor.r * intensity; // Red colors[i * 3 + 1] = baseTrailColor.g * intensity; // Green colors[i * 3 + 2] = baseTrailColor.b * intensity; // Blue } geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); return geometry; } // Get trail color based on aircraft type and signal quality getTrailColor(aircraft, signalData) { const visualType = this.getAircraftVisualType(aircraft); // Base colors for different aircraft types (more muted for trails) let baseColor; switch (visualType) { case 'helicopter': baseColor = { r: 0.0, g: 0.8, b: 0.8 }; // Cyan break; case 'heavy': baseColor = { r: 0.0, g: 0.4, b: 0.8 }; // Blue break; case 'light': baseColor = { r: 0.8, g: 0.8, b: 0.0 }; // Yellow break; case 'medium': default: baseColor = { r: 0.0, g: 0.6, b: 0.4 }; // Teal-green break; } // Adjust brightness based on signal quality const qualityMultiplier = this.getSignalQualityMultiplier(signalData.signalQuality); return { r: Math.min(1.0, baseColor.r * qualityMultiplier), g: Math.min(1.0, baseColor.g * qualityMultiplier), b: Math.min(1.0, baseColor.b * qualityMultiplier) }; } // Get brightness multiplier based on signal quality getSignalQualityMultiplier(signalQuality) { switch (signalQuality) { case 'Excellent': return 1.2; case 'Good': return 1.0; case 'Fair': return 0.8; case 'Poor': return 0.6; default: return 0.9; } } 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()); } // Enable and initialize the Trails toggle button const trailsButton = document.getElementById('radar3d-trails'); if (trailsButton) { trailsButton.disabled = false; trailsButton.addEventListener('click', () => this.toggle3DTrails()); } // 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; if (this.verbose) { console.log('3D Radar origin set to:', origin); } } } 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; } 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); } } } }); } startPeriodicTasks() { // Update clocks every second setInterval(() => this.uiManager.updateClocks(), 1000); // 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); // Update charts every 10 seconds setInterval(() => this.updateCharts(), 10000); // Periodic cleanup setInterval(() => { // Clean up old trail data, etc. }, 30000); } 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'); } } } } // Initialize application when DOM is ready document.addEventListener('DOMContentLoaded', () => { window.skyview = new SkyView(); });