feat: Complete 3D radar enhancements with labels, interactions, and trails

Implements comprehensive 3D radar view enhancements:

- Fixed map tile alignment using proper Web Mercator projection calculations
- Added dynamic aircraft labels showing callsign, altitude, and speed
- Implemented click interactions with raycasting for aircraft selection
- Created flight trail visualization with toggle functionality
- Enhanced visual feedback with selection highlighting
- Improved tile positioning with precise geographic calculations

🤖 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 21:02:22 +02:00
commit fec2f111a1
2 changed files with 553 additions and 29 deletions

View file

@ -413,6 +413,7 @@
<div class="radar3d-controls">
<button id="radar3d-reset" disabled>Reset View</button>
<button id="radar3d-auto-rotate" disabled>Auto Rotate</button>
<button id="radar3d-trails" disabled>Show Trails</button>
<label>
<input type="range" id="radar3d-range" min="10" max="500" value="100" disabled>
Range: <span id="radar3d-range-value">100</span> km

View file

@ -9,6 +9,184 @@ 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
@ -382,8 +560,8 @@ class SkyView {
// Initialize 3D radar when switching to 3D radar view
if (viewId === 'radar3d-view' && !this.radar3dInitialized) {
setTimeout(() => {
this.initialize3DRadar();
setTimeout(async () => {
await this.initialize3DRadar();
this.radar3dInitialized = true;
}, 100); // Small delay to ensure the view is visible
}
@ -393,7 +571,7 @@ class SkyView {
}
// 3D Radar (basic implementation)
initialize3DRadar() {
async initialize3DRadar() {
if (this.verbose) {
console.log('🚀 Starting 3D radar initialization');
}
@ -457,7 +635,11 @@ class SkyView {
camera: new THREE.PerspectiveCamera(75, width / height, 0.1, 1000),
renderer: new THREE.WebGLRenderer({ alpha: true, antialias: true }),
controls: null,
aircraftMeshes: new Map()
aircraftMeshes: new Map(),
aircraftLabels: new Map(),
labelContainer: null,
aircraftTrails: new Map(),
showTrails: false
};
// Set up renderer with forced dimensions
@ -469,7 +651,11 @@ class SkyView {
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()
aircraftMeshes: new Map(),
aircraftLabels: new Map(),
labelContainer: null,
aircraftTrails: new Map(),
showTrails: false
};
// Set up renderer
@ -498,6 +684,17 @@ class SkyView {
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');
}
@ -515,8 +712,8 @@ class SkyView {
hemiLight.position.set(0, 100, 0);
this.radar3d.scene.add(hemiLight);
// Set up camera
this.radar3d.camera.position.set(0, 50, 50);
// 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
@ -537,7 +734,7 @@ class SkyView {
}
// Store default camera position for reset functionality
this.radar3d.defaultPosition = { x: 0, y: 50, z: 50 };
this.radar3d.defaultPosition = { x: 0, y: 80, z: 80 };
this.radar3d.defaultTarget = { x: 0, y: 0, z: 0 };
// Auto-rotate state
@ -546,31 +743,21 @@ class SkyView {
// 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 ground plane with better visibility
const groundGeometry = new THREE.PlaneGeometry(400, 400);
const groundMaterial = new THREE.MeshLambertMaterial({
color: 0x1a3d2a,
transparent: true,
opacity: 0.8
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
this.radar3d.scene.add(ground);
// Add click event listener for aircraft selection
this.radar3d.renderer.domElement.addEventListener('click', (event) => this.handle3DClick(event));
// 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);
// 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', {
@ -619,11 +806,25 @@ class SkyView {
const mesh = new THREE.Mesh(geometry, material);
this.radar3d.aircraftMeshes.set(key, mesh);
this.radar3d.scene.add(mesh);
// Create aircraft label
this.create3DAircraftLabel(key, aircraft);
// Create trail if trails are enabled
if (this.radar3d.showTrails) {
this.create3DAircraftTrail(key, aircraft);
}
}
} else {
// Aircraft mesh exists, check if it should be visible
const mesh = this.radar3d.aircraftMeshes.get(key);
mesh.visible = withinRange;
// Update label visibility
const label = this.radar3d.aircraftLabels.get(key);
if (label) {
label.style.display = withinRange ? 'block' : 'none';
}
}
// Update position if aircraft is within range
@ -641,15 +842,34 @@ class SkyView {
if (aircraft.Track !== undefined) {
mesh.rotation.y = -aircraft.Track * Math.PI / 180;
}
// Update trail if trails are enabled
if (this.radar3d.showTrails && aircraft.position_history) {
this.update3DAircraftTrail(key, aircraft);
}
}
}
});
// Remove old aircraft
// 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) {
@ -667,6 +887,302 @@ class SkyView {
}
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.Speed ? `${Math.round(aircraft.Speed)}kts` : 'N/A';
label.innerHTML = `
<div style="font-weight: bold;">${callsign}</div>
<div style="font-size: 10px; opacity: 0.8;">${altitude} ${speed}</div>
`;
}
update3DAircraftLabels() {
if (!this.radar3d || !this.radar3d.aircraftLabels || !this.aircraftManager) return;
// Vector for 3D to screen coordinate conversion
const vector = new THREE.Vector3();
const canvas = this.radar3d.renderer.domElement;
this.radar3d.aircraftLabels.forEach((label, icao) => {
const mesh = this.radar3d.aircraftMeshes.get(icao);
const aircraft = this.aircraftManager.aircraftData.get(icao);
if (mesh && aircraft && mesh.visible) {
// Get aircraft world position
vector.setFromMatrixPosition(mesh.matrixWorld);
// Project to screen coordinates
vector.project(this.radar3d.camera);
// Check if aircraft is behind camera
if (vector.z > 1) {
label.style.display = 'none';
return;
}
// Convert to screen pixels
const x = (vector.x * 0.5 + 0.5) * canvas.clientWidth;
const y = (vector.y * -0.5 + 0.5) * canvas.clientHeight;
// Position label slightly offset from aircraft position
label.style.left = `${x + 10}px`;
label.style.top = `${y - 30}px`;
label.style.display = 'block';
label.style.opacity = '1';
// Update label content with current aircraft data
this.update3DAircraftLabelContent(label, aircraft);
} else {
label.style.display = 'none';
}
});
}
handle3DClick(event) {
if (!this.radar3d || !this.radar3d.raycaster || !this.aircraftManager) return;
// Prevent event from propagating to orbit controls
event.preventDefault();
const canvas = this.radar3d.renderer.domElement;
const rect = canvas.getBoundingClientRect();
// Calculate mouse position in normalized device coordinates (-1 to +1)
this.radar3d.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.radar3d.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Update raycaster with camera and mouse position
this.radar3d.raycaster.setFromCamera(this.radar3d.mouse, this.radar3d.camera);
// Get all visible aircraft meshes
const aircraftMeshes = [];
this.radar3d.aircraftMeshes.forEach((mesh, icao) => {
if (mesh.visible) {
mesh.userData.icao = icao; // Store ICAO for identification
aircraftMeshes.push(mesh);
}
});
// Check for intersections
const intersects = this.radar3d.raycaster.intersectObjects(aircraftMeshes);
if (intersects.length > 0) {
const selectedMesh = intersects[0].object;
const selectedIcao = selectedMesh.userData.icao;
const aircraft = this.aircraftManager.aircraftData.get(selectedIcao);
if (aircraft) {
this.select3DAircraft(selectedIcao, aircraft);
}
} else {
// Clicked on empty space, deselect
this.deselect3DAircraft();
}
}
select3DAircraft(icao, aircraft) {
// Deselect previous aircraft
this.deselect3DAircraft();
this.radar3d.selectedAircraft = icao;
// Highlight selected aircraft mesh
const mesh = this.radar3d.aircraftMeshes.get(icao);
if (mesh) {
mesh.material.color.setHex(0xff8800); // Orange color for selection
mesh.material.emissive.setHex(0x441100); // Subtle glow
}
// Highlight selected aircraft label
const label = this.radar3d.aircraftLabels.get(icao);
if (label) {
label.style.background = 'rgba(255, 136, 0, 0.9)';
label.style.borderColor = 'rgba(255, 136, 0, 0.8)';
label.style.transform = 'scale(1.1)';
}
// Log selection for debugging
if (this.verbose) {
console.log(`Selected aircraft: ${icao}`, aircraft);
}
// Trigger aircraft selection in main aircraft manager (for consistency with 2D map)
if (this.aircraftManager.onAircraftSelected) {
this.aircraftManager.onAircraftSelected(icao, aircraft);
}
}
deselect3DAircraft() {
if (!this.radar3d.selectedAircraft) return;
const icao = this.radar3d.selectedAircraft;
// Reset mesh appearance
const mesh = this.radar3d.aircraftMeshes.get(icao);
if (mesh) {
mesh.material.color.setHex(0x00ff00); // Back to green
mesh.material.emissive.setHex(0x000000); // Remove glow
}
// Reset label appearance
const label = this.radar3d.aircraftLabels.get(icao);
if (label) {
label.style.background = 'rgba(0, 0, 0, 0.8)';
label.style.borderColor = 'rgba(255, 255, 255, 0.3)';
label.style.transform = 'scale(1)';
}
this.radar3d.selectedAircraft = null;
}
create3DAircraftTrail(icao, aircraft) {
if (!aircraft.position_history || aircraft.position_history.length < 2) return;
// Create line geometry for trail
const points = [];
const originLat = this.radar3d.origin.latitude;
const originLon = this.radar3d.origin.longitude;
// Convert position history to 3D world coordinates
aircraft.position_history.forEach(pos => {
if (pos.latitude && pos.longitude) {
const x = (pos.longitude - originLon) * 111320 * Math.cos(pos.latitude * Math.PI / 180) / 1000;
const z = -(pos.latitude - originLat) * 111320 / 1000;
const y = (pos.altitude || 0) / 1000; // Convert feet to km
points.push(new THREE.Vector3(x, y, z));
}
});
if (points.length < 2) return;
// Create line geometry
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// Create gradient material for trail (older parts more transparent)
const material = new THREE.LineBasicMaterial({
color: 0x00aa88,
transparent: true,
opacity: 0.7,
linewidth: 2
});
const trail = new THREE.Line(geometry, material);
this.radar3d.scene.add(trail);
this.radar3d.aircraftTrails.set(icao, trail);
if (this.verbose) {
console.log(`Created trail for ${icao} with ${points.length} points`);
}
}
update3DAircraftTrail(icao, aircraft) {
if (!aircraft.position_history || aircraft.position_history.length < 2) {
// Remove trail if no history
const trail = this.radar3d.aircraftTrails.get(icao);
if (trail) {
this.radar3d.scene.remove(trail);
this.radar3d.aircraftTrails.delete(icao);
}
return;
}
const trail = this.radar3d.aircraftTrails.get(icao);
if (trail) {
// Update existing trail
const points = [];
const originLat = this.radar3d.origin.latitude;
const originLon = this.radar3d.origin.longitude;
// Convert position history to 3D world coordinates
aircraft.position_history.forEach(pos => {
if (pos.latitude && pos.longitude) {
const x = (pos.longitude - originLon) * 111320 * Math.cos(pos.latitude * Math.PI / 180) / 1000;
const z = -(pos.latitude - originLat) * 111320 / 1000;
const y = (pos.altitude || 0) / 1000; // Convert feet to km
points.push(new THREE.Vector3(x, y, z));
}
});
if (points.length >= 2) {
// Update geometry with new points
trail.geometry.setFromPoints(points);
trail.geometry.needsUpdate = true;
}
} else {
// Create new trail
this.create3DAircraftTrail(icao, aircraft);
}
}
toggle3DTrails() {
this.radar3d.showTrails = !this.radar3d.showTrails;
if (this.radar3d.showTrails) {
// Create trails for all existing aircraft
this.aircraftManager.aircraftData.forEach((aircraft, icao) => {
if (this.radar3d.aircraftMeshes.has(icao)) {
this.create3DAircraftTrail(icao, aircraft);
}
});
} else {
// Remove all trails
this.radar3d.aircraftTrails.forEach((trail, icao) => {
this.radar3d.scene.remove(trail);
});
this.radar3d.aircraftTrails.clear();
}
// Update button state
const trailButton = document.getElementById('radar3d-trails');
if (trailButton) {
trailButton.textContent = this.radar3d.showTrails ? 'Hide Trails' : 'Show Trails';
trailButton.classList.toggle('active', this.radar3d.showTrails);
}
if (this.verbose) {
console.log(`3D trails ${this.radar3d.showTrails ? 'enabled' : 'disabled'}`);
}
}
initialize3DControls() {
@ -684,6 +1200,13 @@ class SkyView {
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');