feat: Implement 3D Radar View Controls - resolves #9

- Add Reset View button functionality to reset camera to default position
- Implement Auto Rotate toggle with visual feedback and button text updates
- Add Range slider (10-500km) with real-time aircraft distance filtering
- Use server-provided origin coordinates via /api/origin API endpoint
- Implement Haversine formula for accurate geographic distance calculations
- Add deferred initialization to prevent black screen issue
- Enhanced lighting and ground plane visibility for better 3D orientation
- Add comprehensive debugging and error handling for WebGL/Three.js
- Style 3D radar controls with proper CSS for consistent UI
- Remove construction notice as controls are now fully functional

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2025-09-01 20:22:26 +02:00
commit a2cf9d8fdb
3 changed files with 352 additions and 44 deletions

View file

@ -857,4 +857,60 @@ body {
.squawk-standard:hover {
background: #218838;
}
/* 3D Radar Styles */
#radar3d-view {
position: relative;
}
#radar3d-container {
flex: 1;
min-height: 500px;
width: 100%;
background: #0a0a0a;
position: relative;
}
.radar3d-controls {
display: flex;
gap: 1rem;
padding: 1rem;
background: #2d2d2d;
border-bottom: 1px solid #404040;
align-items: center;
}
.radar3d-controls button {
padding: 0.5rem 1rem;
background: #404040;
color: white;
border: 1px solid #666;
border-radius: 4px;
cursor: pointer;
}
.radar3d-controls button:hover:not(:disabled) {
background: #505050;
}
.radar3d-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.radar3d-controls button.active {
background: #00a8ff;
border-color: #0088cc;
}
.radar3d-controls label {
display: flex;
gap: 0.5rem;
align-items: center;
color: #ccc;
}
.radar3d-controls input[type="range"] {
width: 150px;
}

View file

@ -411,7 +411,6 @@
<!-- 3D Radar View -->
<div id="radar3d-view" class="view">
<div class="radar3d-controls">
<div class="construction-notice">🚧 3D Controls Under Construction</div>
<button id="radar3d-reset" disabled>Reset View</button>
<button id="radar3d-auto-rotate" disabled>Auto Rotate</button>
<label>

View file

@ -65,7 +65,12 @@ class SkyView {
// Initialize other components
this.initializeCharts();
this.uiManager.updateClocks();
this.initialize3DRadar();
// 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();
@ -268,7 +273,7 @@ class SkyView {
// Update coverage controls
this.mapManager.updateCoverageControls();
if (this.uiManager.currentView === 'radar3d-view') {
if (this.uiManager.currentView === 'radar3d-view' && this.radar3dInitialized) {
this.update3DRadar();
}
@ -351,58 +356,186 @@ class SkyView {
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(() => {
this.initialize3DRadar();
this.radar3dInitialized = true;
}, 100); // Small delay to ensure the view is visible
}
return result;
};
}
// 3D Radar (basic implementation)
initialize3DRadar() {
console.log('🚀 Starting 3D radar initialization');
try {
const container = document.getElementById('radar3d-container');
if (!container) return;
if (!container) {
console.error('❌ Container radar3d-container not found');
return;
}
// Create scene
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()
};
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;
}
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;
}
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);
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()
};
// 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()
};
// Set up renderer
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
}
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;
}
console.log('✅ WebGL context created');
// Set up renderer
this.radar3d.renderer.setSize(container.clientWidth, container.clientHeight);
this.radar3d.renderer.setClearColor(0x0a0a0a, 0.9);
container.appendChild(this.radar3d.renderer.domElement);
console.log('✅ Renderer added to container');
// Add lighting
const ambientLight = new THREE.AmbientLight(0x404040, 0.6);
// 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, 0.8);
directionalLight.position.set(10, 10, 5);
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
this.radar3d.camera.position.set(0, 50, 50);
this.radar3d.camera.lookAt(0, 0, 0);
// Add controls
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;
console.log('✅ OrbitControls added');
// Add ground plane
const groundGeometry = new THREE.PlaneGeometry(200, 200);
// Store default camera position for reset functionality
this.radar3d.defaultPosition = { x: 0, y: 50, z: 50 };
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 3D controls and fetch origin
this.initialize3DControls();
this.fetch3DOrigin();
// Add ground plane with better visibility
const groundGeometry = new THREE.PlaneGeometry(400, 400);
const groundMaterial = new THREE.MeshLambertMaterial({
color: 0x2a4d3a,
color: 0x1a3d2a,
transparent: true,
opacity: 0.5
opacity: 0.8
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
this.radar3d.scene.add(ground);
// Add grid
const gridHelper = new THREE.GridHelper(200, 20, 0x44aa44, 0x44aa44);
// Add grid with better visibility
const gridHelper = new THREE.GridHelper(400, 40, 0x44aa44, 0x44aa44);
this.radar3d.scene.add(gridHelper);
// Add some reference objects to help with debugging
const cubeGeometry = new THREE.BoxGeometry(2, 2, 2);
const cubeMaterial = new THREE.MeshLambertMaterial({ color: 0xff0000 });
const cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
cube.position.set(10, 1, 10);
this.radar3d.scene.add(cube);
console.log('3D Radar initialized successfully', {
scene: this.radar3d.scene,
camera: this.radar3d.camera,
renderer: this.radar3d.renderer
});
// Start render loop
this.render3DRadar();
@ -415,32 +548,55 @@ class SkyView {
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
const geometry = new THREE.ConeGeometry(0.5, 2, 6);
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
this.radar3d.aircraftMeshes.set(key, mesh);
this.radar3d.scene.add(mesh);
// Create new aircraft mesh only if within range
if (withinRange) {
const geometry = new THREE.ConeGeometry(0.5, 2, 6);
const material = new THREE.MeshLambertMaterial({ color: 0x00ff00 });
const mesh = new THREE.Mesh(geometry, material);
this.radar3d.aircraftMeshes.set(key, mesh);
this.radar3d.scene.add(mesh);
}
} else {
// Aircraft mesh exists, check if it should be visible
const mesh = this.radar3d.aircraftMeshes.get(key);
mesh.visible = withinRange;
}
const mesh = this.radar3d.aircraftMeshes.get(key);
// Convert lat/lon to local coordinates (simplified)
const x = (aircraft.Longitude - (-0.4600)) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
const z = -(aircraft.Latitude - 51.4700) * 111320 / 1000;
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
mesh.position.set(x, y, z);
// Orient mesh based on track
if (aircraft.Track !== undefined) {
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
// Update position if aircraft is within range
if (withinRange) {
const mesh = this.radar3d.aircraftMeshes.get(key);
// Convert lat/lon to local coordinates (simplified)
const x = (aircraft.Longitude - originLon) * 111320 * Math.cos(aircraft.Latitude * Math.PI / 180) / 1000;
const z = -(aircraft.Latitude - originLat) * 111320 / 1000;
const y = (aircraft.Altitude || 0) / 1000; // Convert feet to km for display
mesh.position.set(x, y, z);
// Orient mesh based on track
if (aircraft.Track !== undefined) {
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
}
}
}
});
@ -469,6 +625,103 @@ class SkyView {
this.radar3d.renderer.render(this.radar3d.scene, this.radar3d.camera);
}
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 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;
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;