Add centralized escapeHtml() utility and apply it to every dynamic value inserted via innerHTML/template literals across the frontend. Data from VRS JSON sources and external CSV files (airline names, countries) flows through the backend as arbitrary strings that could contain HTML. While Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those escapes before the values reach innerHTML — enabling script injection. Affected modules: aircraft-manager, ui-manager, callsign-manager, map-manager, and the 3D radar labels in app.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1744 lines
No EOL
68 KiB
JavaScript
1744 lines
No EOL
68 KiB
JavaScript
// Import Three.js modules
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
|
|
// Import our modular components
|
|
import { WebSocketManager } from './modules/websocket.js?v=2';
|
|
import { AircraftManager } from './modules/aircraft-manager.js?v=2';
|
|
import { MapManager } from './modules/map-manager.js?v=2';
|
|
import { UIManager } from './modules/ui-manager.js?v=2';
|
|
import { CallsignManager } from './modules/callsign-manager.js';
|
|
import { escapeHtml } from './modules/html-utils.js';
|
|
|
|
// Tile Management for 3D Map Base
|
|
class TileManager {
|
|
constructor(scene, origin) {
|
|
this.scene = scene;
|
|
this.origin = origin;
|
|
this.loadedTiles = new Map();
|
|
this.zoom = 12; // OSM zoom level
|
|
|
|
// Calculate tile bounds around origin
|
|
this.originTileX = this.lonToTileX(origin.longitude, this.zoom);
|
|
this.originTileY = this.latToTileY(origin.latitude, this.zoom);
|
|
|
|
// Calculate actual tile size based on Web Mercator projection at this latitude
|
|
this.tileSize = this.calculateTileSize(origin.latitude, this.zoom);
|
|
}
|
|
|
|
// Calculate the ground size of a tile at given latitude and zoom
|
|
calculateTileSize(latitude, zoom) {
|
|
// Earth's circumference at equator in km
|
|
const earthCircumference = 40075.016686;
|
|
// Circumference at given latitude
|
|
const latCircumference = earthCircumference * Math.cos(latitude * Math.PI / 180);
|
|
// Size of one tile at this latitude and zoom level
|
|
return latCircumference / Math.pow(2, zoom);
|
|
}
|
|
|
|
// Convert longitude to OSM tile X coordinate
|
|
lonToTileX(lon, zoom) {
|
|
return Math.floor((lon + 180) / 360 * Math.pow(2, zoom));
|
|
}
|
|
|
|
// Convert latitude to OSM tile Y coordinate
|
|
latToTileY(lat, zoom) {
|
|
return Math.floor((1 - Math.log(Math.tan(lat * Math.PI / 180) + 1 / Math.cos(lat * Math.PI / 180)) / Math.PI) / 2 * Math.pow(2, zoom));
|
|
}
|
|
|
|
// Convert tile coordinates to world position relative to origin
|
|
tileToWorldPosition(tileX, tileY) {
|
|
// Use precise positioning based on actual Web Mercator coordinates
|
|
const originLon = this.tileXToLon(this.originTileX, this.zoom);
|
|
const originLat = this.tileYToLat(this.originTileY, this.zoom);
|
|
const tileLon = this.tileXToLon(tileX, this.zoom);
|
|
const tileLat = this.tileYToLat(tileY, this.zoom);
|
|
|
|
// Convert to world coordinates using precise geographic distance
|
|
const deltaX = (tileLon - originLon) * 111320 * Math.cos(this.origin.latitude * Math.PI / 180) / 1000;
|
|
const deltaZ = -(tileLat - originLat) * 111320 / 1000; // Negative for Three.js coordinates
|
|
|
|
return { x: deltaX, z: deltaZ };
|
|
}
|
|
|
|
// Convert OSM tile X to longitude
|
|
tileXToLon(x, zoom) {
|
|
return x / Math.pow(2, zoom) * 360 - 180;
|
|
}
|
|
|
|
// Convert OSM tile Y to latitude
|
|
tileYToLat(y, zoom) {
|
|
const n = Math.PI - 2 * Math.PI * y / Math.pow(2, zoom);
|
|
return 180 / Math.PI * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
|
}
|
|
|
|
async loadTile(tileX, tileY) {
|
|
const key = `${tileX}_${tileY}`;
|
|
if (this.loadedTiles.has(key)) return;
|
|
|
|
try {
|
|
// OpenStreetMap tile server
|
|
const url = `https://tile.openstreetmap.org/${this.zoom}/${tileX}/${tileY}.png`;
|
|
|
|
const texture = await new THREE.TextureLoader().loadAsync(url);
|
|
// Use ClampToEdgeWrapping to prevent gaps and improve alignment
|
|
texture.wrapS = texture.wrapT = THREE.ClampToEdgeWrapping;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.minFilter = THREE.LinearFilter;
|
|
|
|
// Create tile geometry with exact size for precise alignment
|
|
const geometry = new THREE.PlaneGeometry(this.tileSize, this.tileSize);
|
|
const material = new THREE.MeshBasicMaterial({
|
|
map: texture,
|
|
transparent: false, // Remove transparency to avoid visual artifacts
|
|
side: THREE.FrontSide
|
|
});
|
|
|
|
const tile = new THREE.Mesh(geometry, material);
|
|
tile.rotation.x = -Math.PI / 2; // Lay flat on ground
|
|
|
|
// Position tile relative to origin with precise positioning
|
|
const worldPos = this.tileToWorldPosition(tileX, tileY);
|
|
tile.position.set(worldPos.x, 0.01, worldPos.z); // Slightly above ground (1cm)
|
|
|
|
this.scene.add(tile);
|
|
this.loadedTiles.set(key, tile);
|
|
|
|
if (window.skyviewVerbose) {
|
|
console.log(`Loaded tile ${tileX},${tileY} at position ${worldPos.x},${worldPos.z}`);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.warn(`Failed to load tile ${tileX},${tileY}:`, error);
|
|
}
|
|
}
|
|
|
|
async loadInitialTiles() {
|
|
// Load a larger grid of tiles around the origin for better coverage
|
|
const tileRange = 3; // Load 3 tiles in each direction (7x7 = ~70km x 70km)
|
|
|
|
// Load center tiles first, then expand outward for better user experience
|
|
const loadOrder = [];
|
|
for (let distance = 0; distance <= tileRange; distance++) {
|
|
for (let x = -distance; x <= distance; x++) {
|
|
for (let y = -distance; y <= distance; y++) {
|
|
if (Math.abs(x) === distance || Math.abs(y) === distance) {
|
|
loadOrder.push({ x, y });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load tiles with small delays to avoid overwhelming the server
|
|
for (const { x, y } of loadOrder) {
|
|
const tileX = this.originTileX + x;
|
|
const tileY = this.originTileY + y;
|
|
await this.loadTile(tileX, tileY);
|
|
|
|
// Small delay to prevent rate limiting
|
|
if (loadOrder.length > 10) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
}
|
|
|
|
// Add origin indicator
|
|
this.addOriginIndicator();
|
|
}
|
|
|
|
addOriginIndicator() {
|
|
// Create a visible marker at the origin point
|
|
const originGeometry = new THREE.CylinderGeometry(0.5, 0.5, 2, 8);
|
|
const originMaterial = new THREE.MeshLambertMaterial({
|
|
color: 0xff0000,
|
|
emissive: 0x440000
|
|
});
|
|
const originMarker = new THREE.Mesh(originGeometry, originMaterial);
|
|
originMarker.position.set(0, 1, 0); // Position at origin, 1km above ground
|
|
this.scene.add(originMarker);
|
|
|
|
// Add ring indicator on ground
|
|
const ringGeometry = new THREE.RingGeometry(2, 3, 16);
|
|
const ringMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0xff0000,
|
|
side: THREE.DoubleSide,
|
|
transparent: true,
|
|
opacity: 0.7
|
|
});
|
|
const originRing = new THREE.Mesh(ringGeometry, ringMaterial);
|
|
originRing.rotation.x = -Math.PI / 2; // Lay flat on ground
|
|
originRing.position.set(0, 0.1, 0); // Slightly above ground to prevent z-fighting
|
|
this.scene.add(originRing);
|
|
|
|
if (window.skyviewVerbose) {
|
|
console.log('Added origin indicator at (0, 0, 0)');
|
|
}
|
|
}
|
|
|
|
// Cleanup method for removing tiles
|
|
removeTile(tileX, tileY) {
|
|
const key = `${tileX}_${tileY}`;
|
|
const tile = this.loadedTiles.get(key);
|
|
if (tile) {
|
|
this.scene.remove(tile);
|
|
tile.geometry.dispose();
|
|
tile.material.map?.dispose();
|
|
tile.material.dispose();
|
|
this.loadedTiles.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
class SkyView {
|
|
constructor() {
|
|
// Initialize managers
|
|
this.wsManager = null;
|
|
this.aircraftManager = null;
|
|
this.mapManager = null;
|
|
this.uiManager = null;
|
|
this.callsignManager = null;
|
|
|
|
// 3D Radar
|
|
this.radar3d = null;
|
|
|
|
// Charts
|
|
this.charts = {};
|
|
|
|
// Selected aircraft tracking
|
|
this.selectedAircraft = null;
|
|
this.selectedTrailEnabled = false;
|
|
|
|
// Debug/verbose logging control
|
|
// Enable verbose logging with: ?verbose in URL or localStorage.setItem('skyview-verbose', 'true')
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
this.verbose = urlParams.has('verbose') || localStorage.getItem('skyview-verbose') === 'true';
|
|
|
|
// Set global verbose flag for other modules
|
|
window.skyviewVerbose = this.verbose;
|
|
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
try {
|
|
|
|
// Initialize UI manager first
|
|
this.uiManager = new UIManager();
|
|
this.uiManager.initializeViews();
|
|
this.uiManager.initializeEventListeners();
|
|
|
|
// Initialize callsign manager for enriched callsign display
|
|
this.callsignManager = new CallsignManager();
|
|
|
|
// Initialize map manager and get the main map
|
|
this.mapManager = new MapManager();
|
|
const map = await this.mapManager.initializeMap();
|
|
|
|
// Initialize aircraft manager with the map and callsign manager
|
|
this.aircraftManager = new AircraftManager(map, this.callsignManager);
|
|
|
|
// Set up selected aircraft trail callback
|
|
this.aircraftManager.setSelectedAircraftCallback((icao) => {
|
|
return this.selectedTrailEnabled && this.selectedAircraft === icao;
|
|
});
|
|
|
|
// Initialize WebSocket with callbacks
|
|
this.wsManager = new WebSocketManager(
|
|
(message) => this.handleWebSocketMessage(message),
|
|
(status) => this.uiManager.updateConnectionStatus(status)
|
|
);
|
|
|
|
await this.wsManager.connect();
|
|
|
|
// Initialize other components
|
|
this.initializeCharts();
|
|
this.uiManager.updateClocks();
|
|
|
|
// Flag to track 3D radar initialization
|
|
this.radar3dInitialized = false;
|
|
|
|
// Add view change listener for 3D radar initialization
|
|
this.setupViewChangeListener();
|
|
|
|
// Set up map controls
|
|
this.setupMapControls();
|
|
|
|
// Set up aircraft selection listener
|
|
this.setupAircraftSelection();
|
|
|
|
this.startPeriodicTasks();
|
|
|
|
} catch (error) {
|
|
console.error('Initialization failed:', error);
|
|
this.uiManager.showError('Failed to initialize application');
|
|
}
|
|
}
|
|
|
|
setupMapControls() {
|
|
const centerMapBtn = document.getElementById('center-map');
|
|
const resetMapBtn = document.getElementById('reset-map');
|
|
const toggleTrailsBtn = document.getElementById('toggle-trails');
|
|
const toggleSourcesBtn = document.getElementById('toggle-sources');
|
|
|
|
if (centerMapBtn) {
|
|
centerMapBtn.addEventListener('click', () => {
|
|
this.aircraftManager.centerMapOnAircraft(() => this.mapManager.getSourcePositions());
|
|
});
|
|
}
|
|
|
|
if (resetMapBtn) {
|
|
resetMapBtn.addEventListener('click', () => this.mapManager.resetMap());
|
|
}
|
|
|
|
if (toggleTrailsBtn) {
|
|
toggleTrailsBtn.addEventListener('click', () => {
|
|
const showTrails = this.aircraftManager.toggleTrails();
|
|
toggleTrailsBtn.textContent = showTrails ? 'Hide Trails' : 'Show Trails';
|
|
});
|
|
}
|
|
|
|
|
|
if (toggleSourcesBtn) {
|
|
toggleSourcesBtn.addEventListener('click', () => {
|
|
const showSources = this.mapManager.toggleSources();
|
|
toggleSourcesBtn.textContent = showSources ? 'Hide Sources' : 'Show Sources';
|
|
});
|
|
}
|
|
|
|
// Setup collapsible sections
|
|
this.setupCollapsibleSections();
|
|
|
|
const toggleDarkModeBtn = document.getElementById('toggle-dark-mode');
|
|
if (toggleDarkModeBtn) {
|
|
toggleDarkModeBtn.addEventListener('click', () => {
|
|
const isDarkMode = this.mapManager.toggleDarkMode();
|
|
toggleDarkModeBtn.innerHTML = isDarkMode ? '☀️ Light Mode' : '🌙 Night Mode';
|
|
});
|
|
}
|
|
|
|
// Coverage controls
|
|
const toggleHeatmapBtn = document.getElementById('toggle-heatmap');
|
|
const coverageSourceSelect = document.getElementById('coverage-source');
|
|
|
|
if (toggleHeatmapBtn) {
|
|
toggleHeatmapBtn.addEventListener('click', async () => {
|
|
const isActive = await this.mapManager.toggleHeatmap();
|
|
toggleHeatmapBtn.textContent = isActive ? 'Hide Heatmap' : 'Show Heatmap';
|
|
});
|
|
}
|
|
|
|
if (coverageSourceSelect) {
|
|
coverageSourceSelect.addEventListener('change', (e) => {
|
|
this.mapManager.setSelectedSource(e.target.value);
|
|
this.mapManager.updateCoverageDisplay();
|
|
});
|
|
}
|
|
|
|
// Display option checkboxes
|
|
const sitePositionsCheckbox = document.getElementById('show-site-positions');
|
|
const rangeRingsCheckbox = document.getElementById('show-range-rings');
|
|
const selectedTrailCheckbox = document.getElementById('show-selected-trail');
|
|
|
|
if (sitePositionsCheckbox) {
|
|
sitePositionsCheckbox.addEventListener('change', (e) => {
|
|
if (e.target.checked) {
|
|
this.mapManager.showSources = true;
|
|
this.mapManager.updateSourceMarkers();
|
|
} else {
|
|
this.mapManager.showSources = false;
|
|
this.mapManager.sourceMarkers.forEach(marker => this.mapManager.map.removeLayer(marker));
|
|
this.mapManager.sourceMarkers.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (rangeRingsCheckbox) {
|
|
rangeRingsCheckbox.addEventListener('change', (e) => {
|
|
if (e.target.checked) {
|
|
this.mapManager.showRange = true;
|
|
this.mapManager.updateRangeCircles();
|
|
} else {
|
|
this.mapManager.showRange = false;
|
|
this.mapManager.rangeCircles.forEach(circle => this.mapManager.map.removeLayer(circle));
|
|
this.mapManager.rangeCircles.clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (selectedTrailCheckbox) {
|
|
selectedTrailCheckbox.addEventListener('change', (e) => {
|
|
this.selectedTrailEnabled = e.target.checked;
|
|
if (!e.target.checked && this.selectedAircraft) {
|
|
// Hide currently selected aircraft trail
|
|
this.aircraftManager.hideAircraftTrail(this.selectedAircraft);
|
|
} else if (e.target.checked && this.selectedAircraft) {
|
|
// Show currently selected aircraft trail
|
|
this.aircraftManager.showAircraftTrail(this.selectedAircraft);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
setupAircraftSelection() {
|
|
document.addEventListener('aircraftSelected', (e) => {
|
|
const { icao, aircraft } = e.detail;
|
|
this.uiManager.switchView('map-view');
|
|
|
|
// Hide trail for previously selected aircraft
|
|
if (this.selectedAircraft && this.selectedTrailEnabled) {
|
|
this.aircraftManager.hideAircraftTrail(this.selectedAircraft);
|
|
}
|
|
|
|
// Update selected aircraft
|
|
this.selectedAircraft = icao;
|
|
|
|
// Automatically enable selected aircraft trail when an aircraft is selected
|
|
if (!this.selectedTrailEnabled) {
|
|
this.selectedTrailEnabled = true;
|
|
const selectedTrailCheckbox = document.getElementById('show-selected-trail');
|
|
if (selectedTrailCheckbox) {
|
|
selectedTrailCheckbox.checked = true;
|
|
}
|
|
}
|
|
|
|
// Show trail for newly selected aircraft
|
|
this.aircraftManager.showAircraftTrail(icao);
|
|
|
|
// DON'T change map view - just open popup like Leaflet expects
|
|
if (this.mapManager.map && aircraft.Latitude && aircraft.Longitude) {
|
|
const marker = this.aircraftManager.aircraftMarkers.get(icao);
|
|
if (marker) {
|
|
marker.openPopup();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
handleWebSocketMessage(message) {
|
|
const aircraftCount = Object.keys(message.data.aircraft || {}).length;
|
|
// Only log WebSocket messages in verbose mode
|
|
if (this.verbose) {
|
|
console.debug(`WebSocket message: ${message.type}, ${aircraftCount} aircraft, timestamp: ${message.timestamp}`);
|
|
}
|
|
|
|
switch (message.type) {
|
|
case 'initial_data':
|
|
console.log(`Received initial data with ${aircraftCount} aircraft`);
|
|
this.updateData(message.data);
|
|
// Setup source markers only on initial data load
|
|
this.mapManager.updateSourceMarkers();
|
|
break;
|
|
case 'aircraft_update':
|
|
this.updateData(message.data);
|
|
break;
|
|
default:
|
|
console.warn(`Unknown WebSocket message type: ${message.type}`);
|
|
}
|
|
}
|
|
|
|
updateData(data) {
|
|
// Update all managers with new data - ORDER MATTERS
|
|
// Only log data updates in verbose mode
|
|
if (this.verbose) {
|
|
console.debug(`Updating data: ${Object.keys(data.aircraft || {}).length} aircraft`);
|
|
}
|
|
|
|
this.uiManager.updateData(data);
|
|
this.aircraftManager.updateAircraftData(data);
|
|
this.mapManager.updateSourcesData(data);
|
|
|
|
// Update UI components - CRITICAL: updateMarkers must be called for track propagation
|
|
this.aircraftManager.updateMarkers();
|
|
|
|
// Update map components that depend on aircraft data
|
|
this.mapManager.updateSourceMarkers();
|
|
|
|
// Update UI tables and statistics
|
|
this.uiManager.updateAircraftTable();
|
|
this.uiManager.updateStatistics();
|
|
this.uiManager.updateHeaderInfo();
|
|
|
|
// Clear selected aircraft if it no longer exists
|
|
if (this.selectedAircraft && !this.aircraftManager.aircraftData.has(this.selectedAircraft)) {
|
|
console.debug(`Selected aircraft ${this.selectedAircraft} no longer exists, clearing selection`);
|
|
this.selectedAircraft = null;
|
|
}
|
|
|
|
// Update coverage controls
|
|
this.mapManager.updateCoverageControls();
|
|
|
|
if (this.uiManager.currentView === 'radar3d-view' && this.radar3dInitialized) {
|
|
this.update3DRadar();
|
|
}
|
|
|
|
// Only log completion messages in verbose mode
|
|
if (this.verbose) {
|
|
console.debug(`Data update complete: ${this.aircraftManager.aircraftMarkers.size} markers displayed`);
|
|
}
|
|
}
|
|
|
|
// View switching
|
|
async switchView(viewId) {
|
|
const actualViewId = this.uiManager.switchView(viewId);
|
|
|
|
// Handle view-specific initialization
|
|
const baseName = actualViewId.replace('-view', '');
|
|
switch (baseName) {
|
|
case 'coverage':
|
|
await this.mapManager.initializeCoverageMap();
|
|
break;
|
|
case 'radar3d':
|
|
this.update3DRadar();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Charts
|
|
initializeCharts() {
|
|
const aircraftChartCanvas = document.getElementById('aircraft-chart');
|
|
if (!aircraftChartCanvas) {
|
|
console.warn('Aircraft chart canvas not found');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.charts.aircraft = new Chart(aircraftChartCanvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: [],
|
|
datasets: [{
|
|
label: 'Aircraft Count',
|
|
data: [],
|
|
borderColor: '#00d4ff',
|
|
backgroundColor: 'rgba(0, 212, 255, 0.1)',
|
|
tension: 0.4
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
x: { display: false },
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: { color: '#888' }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.warn('Chart.js not available, skipping charts initialization');
|
|
}
|
|
}
|
|
|
|
updateCharts() {
|
|
if (!this.charts.aircraft) return;
|
|
|
|
const now = new Date();
|
|
const timeLabel = now.toLocaleTimeString();
|
|
|
|
// Update aircraft count chart
|
|
const chart = this.charts.aircraft;
|
|
chart.data.labels.push(timeLabel);
|
|
chart.data.datasets[0].data.push(this.aircraftManager.aircraftData.size);
|
|
|
|
if (chart.data.labels.length > 20) {
|
|
chart.data.labels.shift();
|
|
chart.data.datasets[0].data.shift();
|
|
}
|
|
|
|
chart.update('none');
|
|
}
|
|
|
|
setupViewChangeListener() {
|
|
// Override the ui manager's switchView to handle 3D radar initialization
|
|
const originalSwitchView = this.uiManager.switchView.bind(this.uiManager);
|
|
|
|
this.uiManager.switchView = (viewId) => {
|
|
const result = originalSwitchView(viewId);
|
|
|
|
// Initialize 3D radar when switching to 3D radar view
|
|
if (viewId === 'radar3d-view' && !this.radar3dInitialized) {
|
|
setTimeout(async () => {
|
|
await this.initialize3DRadar();
|
|
this.radar3dInitialized = true;
|
|
}, 100); // Small delay to ensure the view is visible
|
|
}
|
|
|
|
return result;
|
|
};
|
|
}
|
|
|
|
// 3D Radar (basic implementation)
|
|
async initialize3DRadar() {
|
|
if (this.verbose) {
|
|
console.log('🚀 Starting 3D radar initialization');
|
|
}
|
|
|
|
try {
|
|
const container = document.getElementById('radar3d-container');
|
|
if (!container) {
|
|
console.error('❌ Container radar3d-container not found');
|
|
return;
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ Container found:', container, 'Size:', container.clientWidth, 'x', container.clientHeight);
|
|
|
|
// Check if container is visible
|
|
const containerStyles = window.getComputedStyle(container);
|
|
console.log('📋 Container styles:', {
|
|
display: containerStyles.display,
|
|
visibility: containerStyles.visibility,
|
|
width: containerStyles.width,
|
|
height: containerStyles.height
|
|
});
|
|
}
|
|
|
|
// Check if Three.js is available
|
|
if (typeof THREE === 'undefined') {
|
|
console.error('❌ Three.js is not available');
|
|
return;
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ Three.js available, version:', THREE.REVISION);
|
|
}
|
|
|
|
// Quick WebGL test
|
|
const testCanvas = document.createElement('canvas');
|
|
const gl = testCanvas.getContext('webgl') || testCanvas.getContext('experimental-webgl');
|
|
if (!gl) {
|
|
console.error('❌ WebGL is not supported in this browser');
|
|
container.innerHTML = '<div style="color: red; padding: 20px; text-align: center;">WebGL not supported. Please use a modern browser that supports WebGL.</div>';
|
|
return;
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ WebGL is supported');
|
|
}
|
|
|
|
// Check container dimensions first
|
|
if (container.clientWidth === 0 || container.clientHeight === 0) {
|
|
console.error('❌ Container has zero dimensions:', container.clientWidth, 'x', container.clientHeight);
|
|
// Force minimum size for initialization
|
|
const width = Math.max(container.clientWidth, 800);
|
|
const height = Math.max(container.clientHeight, 600);
|
|
if (this.verbose) {
|
|
console.log('📐 Using forced dimensions:', width, 'x', height);
|
|
}
|
|
|
|
// Create scene with forced dimensions
|
|
this.radar3d = {
|
|
scene: new THREE.Scene(),
|
|
camera: new THREE.PerspectiveCamera(75, width / height, 0.1, 1000),
|
|
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
|
|
controls: null,
|
|
aircraftMeshes: new Map(),
|
|
aircraftLabels: new Map(),
|
|
labelContainer: null,
|
|
aircraftTrails: new Map(),
|
|
showTrails: false
|
|
};
|
|
|
|
// Set up renderer with forced dimensions
|
|
this.radar3d.renderer.setSize(width, height);
|
|
} else {
|
|
// Create scene with actual container dimensions
|
|
this.radar3d = {
|
|
scene: new THREE.Scene(),
|
|
camera: new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000),
|
|
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
|
|
controls: null,
|
|
aircraftMeshes: new Map(),
|
|
aircraftLabels: new Map(),
|
|
labelContainer: null,
|
|
aircraftTrails: new Map(),
|
|
showTrails: false
|
|
};
|
|
|
|
// Set up renderer
|
|
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ Three.js objects created:', {
|
|
scene: this.radar3d.scene,
|
|
camera: this.radar3d.camera,
|
|
renderer: this.radar3d.renderer
|
|
});
|
|
}
|
|
|
|
// Check WebGL context
|
|
const rendererGL = this.radar3d.renderer.getContext();
|
|
if (!rendererGL) {
|
|
console.error('❌ Failed to get WebGL context');
|
|
return;
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ WebGL context created');
|
|
}
|
|
|
|
this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
|
|
container.appendChild(this.radar3d.renderer.domElement);
|
|
|
|
// Create label container for aircraft labels
|
|
this.radar3d.labelContainer = document.createElement('div');
|
|
this.radar3d.labelContainer.style.position = 'absolute';
|
|
this.radar3d.labelContainer.style.top = '0';
|
|
this.radar3d.labelContainer.style.left = '0';
|
|
this.radar3d.labelContainer.style.width = '100%';
|
|
this.radar3d.labelContainer.style.height = '100%';
|
|
this.radar3d.labelContainer.style.pointerEvents = 'none';
|
|
this.radar3d.labelContainer.style.zIndex = '1000';
|
|
container.appendChild(this.radar3d.labelContainer);
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ Renderer added to container');
|
|
}
|
|
|
|
// Add lighting - ensure the scene is visible
|
|
const ambientLight = new THREE.AmbientLight(0x404040, 1.0); // Increased intensity
|
|
this.radar3d.scene.add(ambientLight);
|
|
|
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0); // Increased intensity
|
|
directionalLight.position.set(10, 50, 25);
|
|
this.radar3d.scene.add(directionalLight);
|
|
|
|
// Add hemisphere light for better illumination
|
|
const hemiLight = new THREE.HemisphereLight(0x0000ff, 0x00ff00, 0.6);
|
|
hemiLight.position.set(0, 100, 0);
|
|
this.radar3d.scene.add(hemiLight);
|
|
|
|
// Set up camera with better view of larger map area
|
|
this.radar3d.camera.position.set(0, 80, 80);
|
|
this.radar3d.camera.lookAt(0, 0, 0);
|
|
|
|
// Add controls
|
|
if (this.verbose) {
|
|
console.log('🎮 Adding OrbitControls...');
|
|
}
|
|
if (typeof OrbitControls === 'undefined') {
|
|
console.error('❌ OrbitControls not available');
|
|
return;
|
|
}
|
|
|
|
this.radar3d.controls = new OrbitControls(this.radar3d.camera, this.radar3d.renderer.domElement);
|
|
this.radar3d.controls.enableDamping = true;
|
|
this.radar3d.controls.dampingFactor = 0.05;
|
|
|
|
if (this.verbose) {
|
|
console.log('✅ OrbitControls added');
|
|
}
|
|
|
|
// Store default camera position for reset functionality
|
|
this.radar3d.defaultPosition = { x: 0, y: 80, z: 80 };
|
|
this.radar3d.defaultTarget = { x: 0, y: 0, z: 0 };
|
|
|
|
// Auto-rotate state
|
|
this.radar3d.autoRotate = false;
|
|
|
|
// Origin coordinates (will be fetched from server)
|
|
this.radar3d.origin = { latitude: 59.9081, longitude: 10.8015 }; // fallback
|
|
|
|
// Initialize raycaster for click interactions
|
|
this.radar3d.raycaster = new THREE.Raycaster();
|
|
this.radar3d.mouse = new THREE.Vector2();
|
|
this.radar3d.selectedAircraft = null;
|
|
|
|
// Initialize 3D controls and fetch origin
|
|
this.initialize3DControls();
|
|
this.fetch3DOrigin();
|
|
|
|
// Add click event listener for aircraft selection
|
|
this.radar3d.renderer.domElement.addEventListener('click', (event) => this.handle3DClick(event));
|
|
|
|
// Initialize map tiles instead of simple ground plane
|
|
this.radar3d.tileManager = new TileManager(this.radar3d.scene, this.radar3d.origin);
|
|
await this.radar3d.tileManager.loadInitialTiles();
|
|
|
|
if (this.verbose) {
|
|
console.log('3D Radar initialized successfully', {
|
|
scene: this.radar3d.scene,
|
|
camera: this.radar3d.camera,
|
|
renderer: this.radar3d.renderer
|
|
});
|
|
}
|
|
|
|
// Start render loop
|
|
this.render3DRadar();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to initialize 3D radar:', error);
|
|
}
|
|
}
|
|
|
|
update3DRadar() {
|
|
if (!this.radar3d || !this.radar3d.scene || !this.aircraftManager) return;
|
|
|
|
try {
|
|
// Origin coordinates for distance calculation
|
|
const originLat = this.radar3d.origin.latitude;
|
|
const originLon = this.radar3d.origin.longitude;
|
|
const currentRange = this.radar3d.range || 100; // Default to 100km if not set
|
|
|
|
// Update aircraft positions in 3D space
|
|
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
|
|
if (aircraft.Latitude && aircraft.Longitude) {
|
|
const key = icao.toString();
|
|
|
|
// Calculate distance from origin to filter by range
|
|
const distance = this.calculateDistance(
|
|
originLat, originLon,
|
|
aircraft.Latitude, aircraft.Longitude
|
|
);
|
|
|
|
// Check if aircraft is within range
|
|
const withinRange = distance <= currentRange;
|
|
|
|
if (!this.radar3d.aircraftMeshes.has(key)) {
|
|
// Create new aircraft mesh only if within range
|
|
if (withinRange) {
|
|
const { geometry, material } = this.create3DAircraftGeometry(aircraft);
|
|
const mesh = new THREE.Mesh(geometry, material);
|
|
|
|
// Apply signal quality visual effects
|
|
this.apply3DSignalQualityEffects(mesh, aircraft);
|
|
|
|
this.radar3d.aircraftMeshes.set(key, mesh);
|
|
this.radar3d.scene.add(mesh);
|
|
|
|
// Create aircraft label
|
|
this.create3DAircraftLabel(key, aircraft);
|
|
|
|
// Create trail if trails are enabled
|
|
if (this.radar3d.showTrails) {
|
|
this.create3DAircraftTrail(key, aircraft);
|
|
}
|
|
}
|
|
} else {
|
|
// Aircraft mesh exists, check if it should be visible
|
|
const mesh = this.radar3d.aircraftMeshes.get(key);
|
|
mesh.visible = withinRange;
|
|
|
|
// Update label visibility
|
|
const label = this.radar3d.aircraftLabels.get(key);
|
|
if (label) {
|
|
label.style.display = withinRange ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// Update position if aircraft is within range
|
|
if (withinRange) {
|
|
const mesh = this.radar3d.aircraftMeshes.get(key);
|
|
|
|
// Convert lat/lon to local coordinates (simplified)
|
|
const x = (aircraft.Longitude - originLon) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
|
|
const z = -(aircraft.Latitude - originLat) * 111320 / 1000;
|
|
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
|
|
|
|
mesh.position.set(x, y, z);
|
|
|
|
// Update aircraft visual indicators (direction, climb/descent)
|
|
this.update3DAircraftVisuals(mesh, aircraft);
|
|
|
|
// Update signal quality effects
|
|
this.apply3DSignalQualityEffects(mesh, aircraft);
|
|
|
|
// Update trail if trails are enabled
|
|
if (this.radar3d.showTrails && aircraft.position_history) {
|
|
this.update3DAircraftTrail(key, aircraft);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Remove old aircraft and their labels
|
|
this.radar3d.aircraftMeshes.forEach((mesh, key) => {
|
|
if (!this.aircraftManager.aircraftData.has(key)) {
|
|
this.radar3d.scene.remove(mesh);
|
|
this.radar3d.aircraftMeshes.delete(key);
|
|
|
|
// Remove associated label
|
|
const label = this.radar3d.aircraftLabels.get(key);
|
|
if (label) {
|
|
this.radar3d.labelContainer.removeChild(label);
|
|
this.radar3d.aircraftLabels.delete(key);
|
|
}
|
|
|
|
// Remove associated trail
|
|
const trail = this.radar3d.aircraftTrails.get(key);
|
|
if (trail) {
|
|
this.radar3d.scene.remove(trail);
|
|
this.radar3d.aircraftTrails.delete(key);
|
|
}
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to update 3D radar:', error);
|
|
}
|
|
}
|
|
|
|
render3DRadar() {
|
|
if (!this.radar3d) return;
|
|
|
|
requestAnimationFrame(() => this.render3DRadar());
|
|
|
|
if (this.radar3d.controls) {
|
|
this.radar3d.controls.update();
|
|
}
|
|
|
|
this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
|
|
|
|
// Update aircraft labels after rendering
|
|
this.update3DAircraftLabels();
|
|
}
|
|
|
|
create3DAircraftLabel(icao, aircraft) {
|
|
const label = document.createElement('div');
|
|
label.style.position = 'absolute';
|
|
label.style.background = 'rgba(0, 0, 0, 0.8)';
|
|
label.style.color = 'white';
|
|
label.style.padding = '4px 8px';
|
|
label.style.borderRadius = '4px';
|
|
label.style.fontSize = '12px';
|
|
label.style.fontFamily = 'monospace';
|
|
label.style.whiteSpace = 'nowrap';
|
|
label.style.border = '1px solid rgba(255, 255, 255, 0.3)';
|
|
label.style.pointerEvents = 'auto';
|
|
label.style.cursor = 'pointer';
|
|
label.style.transition = 'opacity 0.3s';
|
|
label.style.zIndex = '1001';
|
|
|
|
// Create label content
|
|
this.update3DAircraftLabelContent(label, aircraft);
|
|
|
|
// Add click handler to label
|
|
label.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
this.select3DAircraft(icao, aircraft);
|
|
});
|
|
|
|
this.radar3d.labelContainer.appendChild(label);
|
|
this.radar3d.aircraftLabels.set(icao, label);
|
|
|
|
return label;
|
|
}
|
|
|
|
update3DAircraftLabelContent(label, aircraft) {
|
|
const callsign = aircraft.Callsign || aircraft.Icao || 'N/A';
|
|
const altitude = aircraft.Altitude ? `${Math.round(aircraft.Altitude)}ft` : 'N/A';
|
|
const speed = aircraft.GroundSpeed ? `${Math.round(aircraft.GroundSpeed)}kts` : 'N/A';
|
|
|
|
label.innerHTML = `
|
|
<div style="font-weight: bold;">${escapeHtml(callsign)}</div>
|
|
<div style="font-size: 10px; opacity: 0.8;">${escapeHtml(altitude)} • ${escapeHtml(speed)}</div>
|
|
`;
|
|
}
|
|
|
|
update3DAircraftLabels() {
|
|
if (!this.radar3d || !this.radar3d.aircraftLabels || !this.aircraftManager) return;
|
|
|
|
// Vector for 3D to screen coordinate conversion
|
|
const vector = new THREE.Vector3();
|
|
const canvas = this.radar3d.renderer.domElement;
|
|
|
|
this.radar3d.aircraftLabels.forEach((label, icao) => {
|
|
const mesh = this.radar3d.aircraftMeshes.get(icao);
|
|
const aircraft = this.aircraftManager.aircraftData.get(icao);
|
|
|
|
if (mesh && aircraft && mesh.visible) {
|
|
// Get aircraft world position
|
|
vector.setFromMatrixPosition(mesh.matrixWorld);
|
|
|
|
// Project to screen coordinates
|
|
vector.project(this.radar3d.camera);
|
|
|
|
// Check if aircraft is behind camera
|
|
if (vector.z > 1) {
|
|
label.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Convert to screen pixels
|
|
const x = (vector.x * 0.5 + 0.5) * canvas.clientWidth;
|
|
const y = (vector.y * -0.5 + 0.5) * canvas.clientHeight;
|
|
|
|
// Position label slightly offset from aircraft position
|
|
label.style.left = `${x + 10}px`;
|
|
label.style.top = `${y - 30}px`;
|
|
label.style.display = 'block';
|
|
label.style.opacity = '1';
|
|
|
|
// Update label content with current aircraft data
|
|
this.update3DAircraftLabelContent(label, aircraft);
|
|
} else {
|
|
label.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
handle3DClick(event) {
|
|
if (!this.radar3d || !this.radar3d.raycaster || !this.aircraftManager) return;
|
|
|
|
// Prevent event from propagating to orbit controls
|
|
event.preventDefault();
|
|
|
|
const canvas = this.radar3d.renderer.domElement;
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
// Calculate mouse position in normalized device coordinates (-1 to +1)
|
|
this.radar3d.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
this.radar3d.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
// Update raycaster with camera and mouse position
|
|
this.radar3d.raycaster.setFromCamera(this.radar3d.mouse, this.radar3d.camera);
|
|
|
|
// Get all visible aircraft meshes
|
|
const aircraftMeshes = [];
|
|
this.radar3d.aircraftMeshes.forEach((mesh, icao) => {
|
|
if (mesh.visible) {
|
|
mesh.userData.icao = icao; // Store ICAO for identification
|
|
aircraftMeshes.push(mesh);
|
|
}
|
|
});
|
|
|
|
// Check for intersections
|
|
const intersects = this.radar3d.raycaster.intersectObjects(aircraftMeshes);
|
|
|
|
if (intersects.length > 0) {
|
|
const selectedMesh = intersects[0].object;
|
|
const selectedIcao = selectedMesh.userData.icao;
|
|
const aircraft = this.aircraftManager.aircraftData.get(selectedIcao);
|
|
|
|
if (aircraft) {
|
|
this.select3DAircraft(selectedIcao, aircraft);
|
|
}
|
|
} else {
|
|
// Clicked on empty space, deselect
|
|
this.deselect3DAircraft();
|
|
}
|
|
}
|
|
|
|
select3DAircraft(icao, aircraft) {
|
|
// Deselect previous aircraft
|
|
this.deselect3DAircraft();
|
|
|
|
this.radar3d.selectedAircraft = icao;
|
|
|
|
// Highlight selected aircraft mesh
|
|
const mesh = this.radar3d.aircraftMeshes.get(icao);
|
|
if (mesh) {
|
|
mesh.material.color.setHex(0xff8800); // Orange color for selection
|
|
mesh.material.emissive.setHex(0x441100); // Subtle glow
|
|
}
|
|
|
|
// Highlight selected aircraft label
|
|
const label = this.radar3d.aircraftLabels.get(icao);
|
|
if (label) {
|
|
label.style.background = 'rgba(255, 136, 0, 0.9)';
|
|
label.style.borderColor = 'rgba(255, 136, 0, 0.8)';
|
|
label.style.transform = 'scale(1.1)';
|
|
}
|
|
|
|
// Log selection for debugging
|
|
if (this.verbose) {
|
|
console.log(`Selected aircraft: ${icao}`, aircraft);
|
|
}
|
|
|
|
// Trigger aircraft selection in main aircraft manager (for consistency with 2D map)
|
|
if (this.aircraftManager.onAircraftSelected) {
|
|
this.aircraftManager.onAircraftSelected(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
deselect3DAircraft() {
|
|
if (!this.radar3d.selectedAircraft) return;
|
|
|
|
const icao = this.radar3d.selectedAircraft;
|
|
|
|
// Reset mesh appearance to original aircraft color
|
|
const mesh = this.radar3d.aircraftMeshes.get(icao);
|
|
const aircraft = this.aircraftManager.aircraftData.get(icao);
|
|
if (mesh && aircraft) {
|
|
const visualType = this.getAircraftVisualType(aircraft);
|
|
const originalColor = this.getAircraftColor(aircraft, visualType);
|
|
mesh.material.color.setHex(originalColor);
|
|
mesh.material.emissive.setHex(originalColor);
|
|
mesh.material.emissiveIntensity = 0.1; // Restore original glow
|
|
}
|
|
|
|
// Reset label appearance
|
|
const label = this.radar3d.aircraftLabels.get(icao);
|
|
if (label) {
|
|
label.style.background = 'rgba(0, 0, 0, 0.8)';
|
|
label.style.borderColor = 'rgba(255, 255, 255, 0.3)';
|
|
label.style.transform = 'scale(1)';
|
|
}
|
|
|
|
this.radar3d.selectedAircraft = null;
|
|
}
|
|
|
|
create3DAircraftTrail(icao, aircraft) {
|
|
if (!aircraft.position_history || aircraft.position_history.length < 2) return;
|
|
|
|
// Create line geometry for trail
|
|
const points = [];
|
|
const originLat = this.radar3d.origin.latitude;
|
|
const originLon = this.radar3d.origin.longitude;
|
|
|
|
// Convert position history to 3D world coordinates
|
|
aircraft.position_history.forEach(pos => {
|
|
if (pos.lat && pos.lon) {
|
|
const x = (pos.lon - originLon) * 111320 * Math.cos(pos.lat * Math.PI / 180) / 1000;
|
|
const z = -(pos.lat - originLat) * 111320 / 1000;
|
|
const y = (pos.altitude || 0) / 1000; // Use historical altitude from position history
|
|
points.push(new THREE.Vector3(x, y, z));
|
|
}
|
|
});
|
|
|
|
if (points.length < 2) return;
|
|
|
|
// Create enhanced line geometry with gradient colors
|
|
const geometry = this.create3DTrailGeometry(points, aircraft);
|
|
|
|
// Create material with vertex colors for gradient effect
|
|
const material = new THREE.LineBasicMaterial({
|
|
vertexColors: true,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
|
|
const trail = new THREE.Line(geometry, material);
|
|
|
|
this.radar3d.scene.add(trail);
|
|
this.radar3d.aircraftTrails.set(icao, trail);
|
|
|
|
if (this.verbose) {
|
|
console.log(`Created trail for ${icao} with ${points.length} points`);
|
|
}
|
|
}
|
|
|
|
update3DAircraftTrail(icao, aircraft) {
|
|
if (!aircraft.position_history || aircraft.position_history.length < 2) {
|
|
// Remove trail if no history
|
|
const trail = this.radar3d.aircraftTrails.get(icao);
|
|
if (trail) {
|
|
this.radar3d.scene.remove(trail);
|
|
this.radar3d.aircraftTrails.delete(icao);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const trail = this.radar3d.aircraftTrails.get(icao);
|
|
|
|
if (trail) {
|
|
// Update existing trail
|
|
const points = [];
|
|
const originLat = this.radar3d.origin.latitude;
|
|
const originLon = this.radar3d.origin.longitude;
|
|
|
|
// Convert position history to 3D world coordinates
|
|
aircraft.position_history.forEach(pos => {
|
|
if (pos.lat && pos.lon) {
|
|
const x = (pos.lon - originLon) * 111320 * Math.cos(pos.lat * Math.PI / 180) / 1000;
|
|
const z = -(pos.lat - originLat) * 111320 / 1000;
|
|
const y = (pos.altitude || 0) / 1000; // Use historical altitude from position history
|
|
points.push(new THREE.Vector3(x, y, z));
|
|
}
|
|
});
|
|
|
|
if (points.length >= 2) {
|
|
// Update geometry with enhanced trail visualization
|
|
const newGeometry = this.create3DTrailGeometry(points, aircraft);
|
|
trail.geometry.dispose(); // Clean up old geometry
|
|
trail.geometry = newGeometry;
|
|
}
|
|
} else {
|
|
// Create new trail
|
|
this.create3DAircraftTrail(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
toggle3DTrails() {
|
|
this.radar3d.showTrails = !this.radar3d.showTrails;
|
|
|
|
if (this.radar3d.showTrails) {
|
|
// Create trails for all existing aircraft
|
|
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
|
|
if (this.radar3d.aircraftMeshes.has(icao)) {
|
|
this.create3DAircraftTrail(icao, aircraft);
|
|
}
|
|
});
|
|
} else {
|
|
// Remove all trails
|
|
this.radar3d.aircraftTrails.forEach((trail, icao) => {
|
|
this.radar3d.scene.remove(trail);
|
|
});
|
|
this.radar3d.aircraftTrails.clear();
|
|
}
|
|
|
|
// Update button state
|
|
const trailButton = document.getElementById('radar3d-trails');
|
|
if (trailButton) {
|
|
trailButton.textContent = this.radar3d.showTrails ? 'Hide Trails' : 'Show Trails';
|
|
trailButton.classList.toggle('active', this.radar3d.showTrails);
|
|
}
|
|
|
|
if (this.verbose) {
|
|
console.log(`3D trails ${this.radar3d.showTrails ? 'enabled' : 'disabled'}`);
|
|
}
|
|
}
|
|
|
|
// Aircraft visual type determination based on category and other data
|
|
getAircraftVisualType(aircraft) {
|
|
const category = aircraft.Category || '';
|
|
|
|
// Helicopter detection
|
|
if (category.toLowerCase().includes('helicopter') ||
|
|
category.toLowerCase().includes('rotorcraft')) {
|
|
return 'helicopter';
|
|
}
|
|
|
|
// Weight-based categories
|
|
if (category.includes('Heavy')) return 'heavy';
|
|
if (category.includes('Medium')) return 'medium';
|
|
if (category.includes('Light')) return 'light';
|
|
|
|
// Size-based categories (fallback)
|
|
if (category.includes('Large')) return 'heavy';
|
|
if (category.includes('Small')) return 'light';
|
|
|
|
// Default to medium commercial aircraft
|
|
return 'medium';
|
|
}
|
|
|
|
// Get aircraft color based on type and status
|
|
getAircraftColor(aircraft, visualType) {
|
|
// Emergency override
|
|
if (aircraft.Emergency && aircraft.Emergency !== 'None') {
|
|
return 0xff0000; // Red for emergencies
|
|
}
|
|
|
|
// Special squawk codes
|
|
if (aircraft.Squawk) {
|
|
if (aircraft.Squawk === '7500' || aircraft.Squawk === '7600' || aircraft.Squawk === '7700') {
|
|
return 0xff0000; // Red for emergency squawks
|
|
}
|
|
}
|
|
|
|
// Type-based colors
|
|
switch (visualType) {
|
|
case 'helicopter':
|
|
return 0x00ffff; // Cyan for helicopters
|
|
case 'heavy':
|
|
return 0x0088ff; // Blue for heavy aircraft
|
|
case 'medium':
|
|
return 0x00ff00; // Green for medium aircraft (default)
|
|
case 'light':
|
|
return 0xffff00; // Yellow for light aircraft
|
|
default:
|
|
return 0x00ff00; // Default green
|
|
}
|
|
}
|
|
|
|
// Create appropriate 3D geometry and material for aircraft type
|
|
create3DAircraftGeometry(aircraft) {
|
|
const visualType = this.getAircraftVisualType(aircraft);
|
|
const color = this.getAircraftColor(aircraft, visualType);
|
|
|
|
let geometry, scale = 1;
|
|
|
|
switch (visualType) {
|
|
case 'helicopter':
|
|
// Helicopter: Wider, flatter shape with rotor disk
|
|
geometry = new THREE.CylinderGeometry(0.8, 0.4, 0.6, 8);
|
|
scale = 1.0;
|
|
break;
|
|
|
|
case 'heavy':
|
|
// Heavy aircraft: Large, wide fuselage
|
|
geometry = new THREE.CylinderGeometry(0.4, 0.8, 3.0, 8);
|
|
scale = 1.3;
|
|
break;
|
|
|
|
case 'light':
|
|
// Light aircraft: Small, simple shape
|
|
geometry = new THREE.ConeGeometry(0.3, 1.5, 6);
|
|
scale = 0.7;
|
|
break;
|
|
|
|
case 'medium':
|
|
default:
|
|
// Medium/default: Standard cone shape
|
|
geometry = new THREE.ConeGeometry(0.5, 2, 6);
|
|
scale = 1.0;
|
|
break;
|
|
}
|
|
|
|
const material = new THREE.MeshLambertMaterial({
|
|
color: color,
|
|
emissive: color,
|
|
emissiveIntensity: 0.1
|
|
});
|
|
|
|
// Scale geometry if needed
|
|
if (scale !== 1.0) {
|
|
geometry.scale(scale, scale, scale);
|
|
}
|
|
|
|
return { geometry, material };
|
|
}
|
|
|
|
// Update aircraft visual indicators (direction, climb/descent)
|
|
update3DAircraftVisuals(mesh, aircraft) {
|
|
// Set aircraft direction based on track
|
|
if (aircraft.Track !== undefined && aircraft.Track !== 0) {
|
|
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
|
|
}
|
|
|
|
// Add climb/descent indicator
|
|
this.update3DClimbDescentIndicator(mesh, aircraft);
|
|
}
|
|
|
|
// Add or update climb/descent visual indicator
|
|
update3DClimbDescentIndicator(mesh, aircraft) {
|
|
const verticalRate = aircraft.VerticalRate || 0;
|
|
const threshold = 500; // feet per minute
|
|
|
|
// Remove existing indicator
|
|
const existingIndicator = mesh.getObjectByName('climbIndicator');
|
|
if (existingIndicator) {
|
|
mesh.remove(existingIndicator);
|
|
}
|
|
|
|
// Add new indicator if significant vertical movement
|
|
if (Math.abs(verticalRate) > threshold) {
|
|
let indicatorGeometry, indicatorMaterial;
|
|
|
|
if (verticalRate > threshold) {
|
|
// Climbing - green upward arrow
|
|
indicatorGeometry = new THREE.ConeGeometry(0.2, 0.8, 4);
|
|
indicatorMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0x00ff00,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
} else if (verticalRate < -threshold) {
|
|
// Descending - red downward arrow
|
|
indicatorGeometry = new THREE.ConeGeometry(0.2, 0.8, 4);
|
|
indicatorMaterial = new THREE.MeshBasicMaterial({
|
|
color: 0xff0000,
|
|
transparent: true,
|
|
opacity: 0.8
|
|
});
|
|
indicatorGeometry.rotateX(Math.PI); // Flip for downward arrow
|
|
}
|
|
|
|
if (indicatorGeometry && indicatorMaterial) {
|
|
const indicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial);
|
|
indicator.name = 'climbIndicator';
|
|
indicator.position.set(0, 2, 0); // Position above aircraft
|
|
mesh.add(indicator);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply signal quality visual effects to aircraft mesh
|
|
apply3DSignalQualityEffects(mesh, aircraft) {
|
|
// Get signal quality data from sources
|
|
const signalData = this.getAircraftSignalData(aircraft);
|
|
|
|
// Apply opacity based on signal strength
|
|
this.updateAircraftOpacity(mesh, signalData);
|
|
|
|
// Apply emissive intensity based on signal quality
|
|
this.updateAircraftEmissiveIntensity(mesh, signalData);
|
|
|
|
// Add data source indicator
|
|
this.addDataSourceIndicator(mesh, signalData);
|
|
}
|
|
|
|
// Extract signal data from aircraft sources
|
|
getAircraftSignalData(aircraft) {
|
|
if (!aircraft.sources || Object.keys(aircraft.sources).length === 0) {
|
|
return {
|
|
bestSignal: -50, // Default medium signal
|
|
sourceCount: 0,
|
|
primarySource: null,
|
|
signalQuality: 'Unknown'
|
|
};
|
|
}
|
|
|
|
let bestSignal = -999;
|
|
let sourceCount = 0;
|
|
let primarySource = null;
|
|
|
|
// Find strongest signal and count sources
|
|
Object.entries(aircraft.sources).forEach(([sourceId, sourceData]) => {
|
|
sourceCount++;
|
|
if (sourceData.signal_level > bestSignal) {
|
|
bestSignal = sourceData.signal_level;
|
|
primarySource = sourceId;
|
|
}
|
|
});
|
|
|
|
return {
|
|
bestSignal,
|
|
sourceCount,
|
|
primarySource,
|
|
signalQuality: aircraft.SignalQuality || 'Unknown'
|
|
};
|
|
}
|
|
|
|
// Update aircraft opacity based on signal strength
|
|
updateAircraftOpacity(mesh, signalData) {
|
|
// Map signal strength to opacity (stronger signal = more opaque)
|
|
// Typical ADS-B signal range: -60 dBFS (weak) to 0 dBFS (strong)
|
|
const minSignal = -60;
|
|
const maxSignal = -5;
|
|
const minOpacity = 0.3;
|
|
const maxOpacity = 1.0;
|
|
|
|
// Clamp signal to expected range
|
|
const clampedSignal = Math.max(minSignal, Math.min(maxSignal, signalData.bestSignal));
|
|
|
|
// Calculate opacity (linear mapping)
|
|
const signalRange = maxSignal - minSignal;
|
|
const signalNormalized = (clampedSignal - minSignal) / signalRange;
|
|
const opacity = minOpacity + (signalNormalized * (maxOpacity - minOpacity));
|
|
|
|
// Apply opacity to material
|
|
mesh.material.transparent = true;
|
|
mesh.material.opacity = opacity;
|
|
|
|
// Also adjust emissive intensity for glow effect
|
|
const baseEmissiveIntensity = 0.1;
|
|
const maxEmissiveBoost = 0.3;
|
|
mesh.material.emissiveIntensity = baseEmissiveIntensity + (signalNormalized * maxEmissiveBoost);
|
|
}
|
|
|
|
// Update emissive intensity based on signal quality assessment
|
|
updateAircraftEmissiveIntensity(mesh, signalData) {
|
|
let qualityMultiplier = 1.0;
|
|
|
|
// Adjust based on signal quality description
|
|
switch (signalData.signalQuality) {
|
|
case 'Excellent':
|
|
qualityMultiplier = 1.5;
|
|
break;
|
|
case 'Good':
|
|
qualityMultiplier = 1.2;
|
|
break;
|
|
case 'Fair':
|
|
qualityMultiplier = 0.8;
|
|
break;
|
|
case 'Poor':
|
|
qualityMultiplier = 0.5;
|
|
break;
|
|
default:
|
|
qualityMultiplier = 1.0;
|
|
}
|
|
|
|
// Apply quality multiplier to emissive intensity
|
|
const currentIntensity = mesh.material.emissiveIntensity || 0.1;
|
|
mesh.material.emissiveIntensity = Math.min(0.5, currentIntensity * qualityMultiplier);
|
|
}
|
|
|
|
// Add visual indicator for data source type
|
|
addDataSourceIndicator(mesh, signalData) {
|
|
// Remove existing source indicator
|
|
const existingIndicator = mesh.getObjectByName('sourceIndicator');
|
|
if (existingIndicator) {
|
|
mesh.remove(existingIndicator);
|
|
}
|
|
|
|
// Only add indicator if we have multiple sources
|
|
if (signalData.sourceCount > 1) {
|
|
// Create small indicator showing multi-source data
|
|
const indicatorGeometry = new THREE.SphereGeometry(0.1, 4, 4);
|
|
const indicatorMaterial = new THREE.MeshBasicMaterial({
|
|
color: signalData.sourceCount > 2 ? 0x00ff00 : 0xffaa00, // Green for 3+, orange for 2
|
|
transparent: true,
|
|
opacity: 0.7
|
|
});
|
|
|
|
const indicator = new THREE.Mesh(indicatorGeometry, indicatorMaterial);
|
|
indicator.name = 'sourceIndicator';
|
|
indicator.position.set(1, 1, 0); // Position to side of aircraft
|
|
mesh.add(indicator);
|
|
}
|
|
}
|
|
|
|
// Create enhanced trail geometry with gradient colors and speed-based effects
|
|
create3DTrailGeometry(points, aircraft) {
|
|
const geometry = new THREE.BufferGeometry();
|
|
|
|
// Set positions
|
|
const positions = new Float32Array(points.length * 3);
|
|
const colors = new Float32Array(points.length * 3);
|
|
|
|
// Get signal quality for trail styling
|
|
const signalData = this.getAircraftSignalData(aircraft);
|
|
const baseTrailColor = this.getTrailColor(aircraft, signalData);
|
|
|
|
// Calculate colors with gradient effect (newer = brighter, older = dimmer)
|
|
for (let i = 0; i < points.length; i++) {
|
|
// Position
|
|
positions[i * 3] = points[i].x;
|
|
positions[i * 3 + 1] = points[i].y;
|
|
positions[i * 3 + 2] = points[i].z;
|
|
|
|
// Color with age-based fading
|
|
const ageRatio = i / (points.length - 1); // 0 = oldest, 1 = newest
|
|
const intensity = 0.3 + (ageRatio * 0.7); // Fade from 30% to 100% brightness
|
|
|
|
colors[i * 3] = baseTrailColor.r * intensity; // Red
|
|
colors[i * 3 + 1] = baseTrailColor.g * intensity; // Green
|
|
colors[i * 3 + 2] = baseTrailColor.b * intensity; // Blue
|
|
}
|
|
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
|
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
|
|
|
return geometry;
|
|
}
|
|
|
|
// Get trail color based on aircraft type and signal quality
|
|
getTrailColor(aircraft, signalData) {
|
|
const visualType = this.getAircraftVisualType(aircraft);
|
|
|
|
// Base colors for different aircraft types (more muted for trails)
|
|
let baseColor;
|
|
switch (visualType) {
|
|
case 'helicopter':
|
|
baseColor = { r: 0.0, g: 0.8, b: 0.8 }; // Cyan
|
|
break;
|
|
case 'heavy':
|
|
baseColor = { r: 0.0, g: 0.4, b: 0.8 }; // Blue
|
|
break;
|
|
case 'light':
|
|
baseColor = { r: 0.8, g: 0.8, b: 0.0 }; // Yellow
|
|
break;
|
|
case 'medium':
|
|
default:
|
|
baseColor = { r: 0.0, g: 0.6, b: 0.4 }; // Teal-green
|
|
break;
|
|
}
|
|
|
|
// Adjust brightness based on signal quality
|
|
const qualityMultiplier = this.getSignalQualityMultiplier(signalData.signalQuality);
|
|
|
|
return {
|
|
r: Math.min(1.0, baseColor.r * qualityMultiplier),
|
|
g: Math.min(1.0, baseColor.g * qualityMultiplier),
|
|
b: Math.min(1.0, baseColor.b * qualityMultiplier)
|
|
};
|
|
}
|
|
|
|
// Get brightness multiplier based on signal quality
|
|
getSignalQualityMultiplier(signalQuality) {
|
|
switch (signalQuality) {
|
|
case 'Excellent': return 1.2;
|
|
case 'Good': return 1.0;
|
|
case 'Fair': return 0.8;
|
|
case 'Poor': return 0.6;
|
|
default: return 0.9;
|
|
}
|
|
}
|
|
|
|
initialize3DControls() {
|
|
// Enable and initialize the Reset View button
|
|
const resetButton = document.getElementById('radar3d-reset');
|
|
if (resetButton) {
|
|
resetButton.disabled = false;
|
|
resetButton.addEventListener('click', () => this.reset3DView());
|
|
}
|
|
|
|
// Enable and initialize the Auto Rotate toggle button
|
|
const autoRotateButton = document.getElementById('radar3d-auto-rotate');
|
|
if (autoRotateButton) {
|
|
autoRotateButton.disabled = false;
|
|
autoRotateButton.addEventListener('click', () => this.toggle3DAutoRotate());
|
|
}
|
|
|
|
// Enable and initialize the Trails toggle button
|
|
const trailsButton = document.getElementById('radar3d-trails');
|
|
if (trailsButton) {
|
|
trailsButton.disabled = false;
|
|
trailsButton.addEventListener('click', () => this.toggle3DTrails());
|
|
}
|
|
|
|
// Enable and initialize the Range slider
|
|
const rangeSlider = document.getElementById('radar3d-range');
|
|
const rangeValue = document.getElementById('radar3d-range-value');
|
|
if (rangeSlider && rangeValue) {
|
|
rangeSlider.disabled = false;
|
|
this.radar3d.range = parseInt(rangeSlider.value); // Store current range
|
|
|
|
rangeSlider.addEventListener('input', (e) => {
|
|
this.radar3d.range = parseInt(e.target.value);
|
|
rangeValue.textContent = this.radar3d.range;
|
|
this.update3DRadarRange();
|
|
});
|
|
}
|
|
}
|
|
|
|
reset3DView() {
|
|
if (!this.radar3d || !this.radar3d.controls) return;
|
|
|
|
// Reset camera to default position
|
|
this.radar3d.camera.position.set(
|
|
this.radar3d.defaultPosition.x,
|
|
this.radar3d.defaultPosition.y,
|
|
this.radar3d.defaultPosition.z
|
|
);
|
|
|
|
// Reset camera target
|
|
this.radar3d.controls.target.set(
|
|
this.radar3d.defaultTarget.x,
|
|
this.radar3d.defaultTarget.y,
|
|
this.radar3d.defaultTarget.z
|
|
);
|
|
|
|
// Update controls to apply changes smoothly
|
|
this.radar3d.controls.update();
|
|
}
|
|
|
|
toggle3DAutoRotate() {
|
|
if (!this.radar3d || !this.radar3d.controls) return;
|
|
|
|
// Toggle auto-rotate state
|
|
this.radar3d.autoRotate = !this.radar3d.autoRotate;
|
|
this.radar3d.controls.autoRotate = this.radar3d.autoRotate;
|
|
|
|
// Update button text to reflect current state
|
|
const autoRotateButton = document.getElementById('radar3d-auto-rotate');
|
|
if (autoRotateButton) {
|
|
autoRotateButton.textContent = this.radar3d.autoRotate ? 'Stop Auto Rotate' : 'Auto Rotate';
|
|
autoRotateButton.classList.toggle('active', this.radar3d.autoRotate);
|
|
}
|
|
}
|
|
|
|
async fetch3DOrigin() {
|
|
try {
|
|
const response = await fetch('/api/origin');
|
|
if (response.ok) {
|
|
const origin = await response.json();
|
|
this.radar3d.origin = origin;
|
|
if (this.verbose) {
|
|
console.log('3D Radar origin set to:', origin);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch origin, using fallback:', error);
|
|
}
|
|
}
|
|
|
|
update3DRadarRange() {
|
|
// Simply trigger a full update of the 3D radar with new range filter
|
|
this.update3DRadar();
|
|
}
|
|
|
|
// Calculate distance between two lat/lon points in kilometers
|
|
calculateDistance(lat1, lon1, lat2, lon2) {
|
|
const R = 6371; // Earth's radius in kilometers
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a =
|
|
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
return R * c;
|
|
}
|
|
|
|
updateOpenPopupAges() {
|
|
// Find any open aircraft popups and update their age displays
|
|
if (!this.aircraftManager) return;
|
|
|
|
this.aircraftManager.aircraftMarkers.forEach((marker, icao) => {
|
|
if (marker.isPopupOpen()) {
|
|
const aircraft = this.aircraftManager.aircraftData.get(icao);
|
|
if (aircraft) {
|
|
// Refresh the popup content with current age
|
|
marker.setPopupContent(this.aircraftManager.createPopupContent(aircraft));
|
|
|
|
// Re-enhance callsign display for the updated popup
|
|
const popupElement = marker.getPopup().getElement();
|
|
if (popupElement) {
|
|
this.aircraftManager.enhanceCallsignDisplay(popupElement);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
startPeriodicTasks() {
|
|
// Update clocks every second
|
|
setInterval(() => this.uiManager.updateClocks(), 1000);
|
|
|
|
// Update aircraft ages and refresh displays every second
|
|
setInterval(() => {
|
|
// Update aircraft table to show current ages
|
|
this.uiManager.updateAircraftTable();
|
|
|
|
// Update any open aircraft popups with current ages
|
|
this.updateOpenPopupAges();
|
|
}, 1000);
|
|
|
|
// Update charts every 10 seconds
|
|
setInterval(() => this.updateCharts(), 10000);
|
|
|
|
// Periodic cleanup
|
|
setInterval(() => {
|
|
// Clean up old trail data, etc.
|
|
}, 30000);
|
|
}
|
|
|
|
setupCollapsibleSections() {
|
|
// Setup Display Options collapsible
|
|
const displayHeader = document.getElementById('display-options-header');
|
|
const displayContent = document.getElementById('display-options-content');
|
|
|
|
if (displayHeader && displayContent) {
|
|
displayHeader.addEventListener('click', () => {
|
|
const isCollapsed = displayContent.classList.contains('collapsed');
|
|
|
|
if (isCollapsed) {
|
|
// Expand
|
|
displayContent.classList.remove('collapsed');
|
|
displayHeader.classList.remove('collapsed');
|
|
} else {
|
|
// Collapse
|
|
displayContent.classList.add('collapsed');
|
|
displayHeader.classList.add('collapsed');
|
|
}
|
|
|
|
// Save state to localStorage
|
|
localStorage.setItem('displayOptionsCollapsed', !isCollapsed);
|
|
});
|
|
|
|
// Restore saved state (default to collapsed)
|
|
const savedState = localStorage.getItem('displayOptionsCollapsed');
|
|
const shouldCollapse = savedState === null ? true : savedState === 'true';
|
|
|
|
if (shouldCollapse) {
|
|
displayContent.classList.add('collapsed');
|
|
displayHeader.classList.add('collapsed');
|
|
} else {
|
|
displayContent.classList.remove('collapsed');
|
|
displayHeader.classList.remove('collapsed');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize application when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.skyview = new SkyView();
|
|
}); |