// 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';
// 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 = `
${callsign}
${altitude} • ${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();
});