skyview/assets/static/js/app.js
Ole-Morten Duesund 4a0a993e81 fix: Sanitize all innerHTML dynamic values to prevent XSS
Add centralized escapeHtml() utility and apply it to every dynamic value
inserted via innerHTML/template literals across the frontend. Data from
VRS JSON sources and external CSV files (airline names, countries) flows
through the backend as arbitrary strings that could contain HTML. While
Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those
escapes before the values reach innerHTML — enabling script injection.

Affected modules: aircraft-manager, ui-manager, callsign-manager,
map-manager, and the 3D radar labels in app.js.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:21:56 +01:00

1744 lines
No EOL
68 KiB
JavaScript

// 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 = '<div style="color: red; padding: 20px; text-align: center;">WebGL not supported. Please use a modern browser that supports WebGL.</div>';
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 = `
<div style="font-weight: bold;">${escapeHtml(callsign)}</div>
<div style="font-size: 10px; opacity: 0.8;">${escapeHtml(altitude)}${escapeHtml(speed)}</div>
`;
}
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();
});