Adds advanced visual feedback system for 3D radar aircraft with real-time signal quality indicators: Signal Quality Visualization: - Opacity mapping based on signal strength (-60 to -5 dBFS range) - Emissive glow intensity reflects signal quality (Excellent/Good/Fair/Poor) - Multi-source data indicators (small spheres for aircraft with multiple receivers) - Dynamic signal strength analysis from aircraft sources data - Real-time updates with changing signal conditions Enhanced Trail System: - Gradient coloring with age-based fading (30% to 100% intensity) - Aircraft type-based trail colors (cyan=helicopter, blue=heavy, yellow=light, teal=medium) - Signal quality affects trail brightness and visibility - Custom BufferGeometry with per-vertex colors - Memory-efficient geometry disposal and rebuilding Technical Improvements: - Modular signal data extraction from aircraft sources - Linear signal strength to opacity mapping - Quality multiplier system for visual feedback - Proper Three.js BufferAttribute management - Dynamic visual updates synchronized with aircraft data Visual Results: - Strong signals = bright, opaque aircraft with vivid trails - Weak signals = dim, transparent aircraft with faded trails - Multi-source aircraft display small indicator spheres - Trail colors match aircraft types for consistency - Smooth gradient effects enhance flight path visualization Addresses core signal quality and trail enhancement requirements from issue #42. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1743 lines
No EOL
68 KiB
JavaScript
1743 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';
|
|
|
|
// 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;">${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 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();
|
|
}); |