skyview/assets/static/js/app.js
Ole-Morten Duesund c6aab821a3 feat: Enhance flight trails with historical altitude data and fix label display
- Extend PositionPoint struct to include altitude field in position history
- Update position history population to store altitude with each position
- Fix 3D aircraft labels to show GroundSpeed instead of undefined Speed field
- Enable true 3D flight trails with proper altitude visualization
- Improve trail accuracy by using historical altitude data per position

Resolves trails showing flat paths and labels showing N/A for speed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 21:08:43 +02:00

1378 lines
No EOL
54 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';
// 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 = new THREE.ConeGeometry(0.5, 2, 6);
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
this.radar3d.aircraftMeshes.set(key, mesh);
this.radar3d.scene.add(mesh);
// 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);
// Orient mesh based on track
if (aircraft.Track !== undefined) {
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
}
// 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;">${callsign}</div>
<div style="font-size: 10px; opacity: 0.8;">${altitude}${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
const mesh = this.radar3d.aircraftMeshes.get(icao);
if (mesh) {
mesh.material.color.setHex(0x00ff00); // Back to green
mesh.material.emissive.setHex(0x000000); // Remove 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 line geometry
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// Create gradient material for trail (older parts more transparent)
const material = new THREE.LineBasicMaterial({
color: 0x00aa88,
transparent: true,
opacity: 0.7,
linewidth: 2
});
const trail = new THREE.Line(geometry, material);
this.radar3d.scene.add(trail);
this.radar3d.aircraftTrails.set(icao, trail);
if (this.verbose) {
console.log(`Created trail for ${icao} with ${points.length} points`);
}
}
update3DAircraftTrail(icao, aircraft) {
if (!aircraft.position_history || aircraft.position_history.length < 2) {
// Remove trail if no history
const trail = this.radar3d.aircraftTrails.get(icao);
if (trail) {
this.radar3d.scene.remove(trail);
this.radar3d.aircraftTrails.delete(icao);
}
return;
}
const trail = this.radar3d.aircraftTrails.get(icao);
if (trail) {
// Update existing trail
const points = [];
const originLat = this.radar3d.origin.latitude;
const originLon = this.radar3d.origin.longitude;
// Convert position history to 3D world coordinates
aircraft.position_history.forEach(pos => {
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 new points
trail.geometry.setFromPoints(points);
trail.geometry.needsUpdate = true;
}
} else {
// Create new trail
this.create3DAircraftTrail(icao, aircraft);
}
}
toggle3DTrails() {
this.radar3d.showTrails = !this.radar3d.showTrails;
if (this.radar3d.showTrails) {
// Create trails for all existing aircraft
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
if (this.radar3d.aircraftMeshes.has(icao)) {
this.create3DAircraftTrail(icao, aircraft);
}
});
} else {
// Remove all trails
this.radar3d.aircraftTrails.forEach((trail, icao) => {
this.radar3d.scene.remove(trail);
});
this.radar3d.aircraftTrails.clear();
}
// Update button state
const trailButton = document.getElementById('radar3d-trails');
if (trailButton) {
trailButton.textContent = this.radar3d.showTrails ? 'Hide Trails' : 'Show Trails';
trailButton.classList.toggle('active', this.radar3d.showTrails);
}
if (this.verbose) {
console.log(`3D trails ${this.radar3d.showTrails ? 'enabled' : 'disabled'}`);
}
}
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();
});