import mapboxgl from 'mapbox-gl'; import { COLORS } from '../config.js'; export class FlightPathLayer { constructor(map) { this.map = map; this.id = 'flight-path'; this.sourceId = 'flight-path-source'; this.animationFrame = null; this.fullPath = null; this.layersAdded = false; this.jetMarker = null; // Trail system with multiple independent fading trails this.trails = []; this.trailCounter = 0; } createArcPath(start, end, numPoints = 50) { const points = []; const [x1, y1] = start; const [x2, y2] = end; const dx = x2 - x1; const dy = y2 - y1; const distance = Math.sqrt(dx * dx + dy * dy); const arcHeight = distance * 0.15; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; const x = x1 + t * dx; const y = y1 + t * dy; const arcOffset = arcHeight * Math.sin(t * Math.PI); points.push([x, y + arcOffset]); } return points; } createJetMarker() { const el = document.createElement('div'); el.className = 'jet-marker'; // SVG jet pointing UP (north) - rotation 0 = north el.innerHTML = ` `; this.jetMarker = new mapboxgl.Marker({ element: el, anchor: 'center', rotationAlignment: 'map' }); return this.jetMarker; } updateJetPosition(coords, nextCoords) { if (!this.jetMarker) return; this.jetMarker.setLngLat(coords); // Calculate bearing/rotation based on direction of travel // Only update if next coords are different (avoid resetting at destination) if (nextCoords && (nextCoords[0] !== coords[0] || nextCoords[1] !== coords[1])) { const bearing = this.calculateBearing(coords, nextCoords); this.jetMarker.setRotation(bearing); } } calculateBearing(start, end) { const startLat = start[1] * Math.PI / 180; const startLng = start[0] * Math.PI / 180; const endLat = end[1] * Math.PI / 180; const endLng = end[0] * Math.PI / 180; const dLng = endLng - startLng; const x = Math.sin(dLng) * Math.cos(endLat); const y = Math.cos(startLat) * Math.sin(endLat) - Math.sin(startLat) * Math.cos(endLat) * Math.cos(dLng); const bearing = Math.atan2(x, y) * 180 / Math.PI; return (bearing + 360) % 360; } showJet(initialCoords) { if (!this.jetMarker) { this.createJetMarker(); } // Set position before adding to map if (initialCoords) { this.jetMarker.setLngLat(initialCoords); } this.jetMarker.addTo(this.map); } hideJet() { if (this.jetMarker) { this.jetMarker.remove(); } } ensureLayers() { if (this.layersAdded) return; // Add source with empty data if (!this.map.getSource(this.sourceId)) { this.map.addSource(this.sourceId, { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); } // Glow layer if (!this.map.getLayer(`${this.id}-glow`)) { this.map.addLayer({ id: `${this.id}-glow`, type: 'line', source: this.sourceId, paint: { 'line-color': COLORS.blue, 'line-width': 6, 'line-opacity': 0.4 } }); } // Main white line if (!this.map.getLayer(`${this.id}-main`)) { this.map.addLayer({ id: `${this.id}-main`, type: 'line', source: this.sourceId, paint: { 'line-color': COLORS.white, 'line-width': 2, 'line-opacity': 0.9 } }); } // Blue accent if (!this.map.getLayer(`${this.id}-blue`)) { this.map.addLayer({ id: `${this.id}-blue`, type: 'line', source: this.sourceId, paint: { 'line-color': COLORS.blue, 'line-width': 2, 'line-opacity': 0.7, 'line-offset': 3 } }); } // Red accent if (!this.map.getLayer(`${this.id}-red`)) { this.map.addLayer({ id: `${this.id}-red`, type: 'line', source: this.sourceId, paint: { 'line-color': COLORS.red, 'line-width': 2, 'line-opacity': 0.7, 'line-offset': -3 } }); } this.layersAdded = true; } updatePath(coordinates) { const source = this.map.getSource(this.sourceId); if (source) { source.setData({ type: 'Feature', geometry: { type: 'LineString', coordinates } }); } } createTrail(coords) { const trailId = `trail-${this.trailCounter++}`; const sourceId = `${this.id}-${trailId}-source`; const layerId = `${this.id}-${trailId}`; // Add source this.map.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: coords } } }); // Add layer this.map.addLayer({ id: layerId, type: 'line', source: sourceId, paint: { 'line-color': COLORS.white, 'line-width': 1.5, 'line-opacity': 0.5 } }, `${this.id}-glow`); // Insert below the main path layers // Start fade animation const fadeDuration = 5000; const startTime = performance.now(); const trail = { sourceId, layerId, fadeFrame: null }; const fade = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / fadeDuration, 1); const opacity = 0.5 * (1 - progress); if (this.map.getLayer(layerId)) { this.map.setPaintProperty(layerId, 'line-opacity', opacity); } if (progress < 1) { trail.fadeFrame = requestAnimationFrame(fade); } else { // Clean up when fully faded this.removeTrail(trail); } }; trail.fadeFrame = requestAnimationFrame(fade); this.trails.push(trail); } removeTrail(trail) { if (trail.fadeFrame) { cancelAnimationFrame(trail.fadeFrame); } if (this.map.getLayer(trail.layerId)) { this.map.removeLayer(trail.layerId); } if (this.map.getSource(trail.sourceId)) { this.map.removeSource(trail.sourceId); } this.trails = this.trails.filter(t => t !== trail); } clearAllTrails() { this.trails.forEach(trail => this.removeTrail(trail)); this.trails = []; } // Show full static path showPath(from, to) { this.stopAnimation(); this.hideJet(); this.ensureLayers(); this.fullPath = this.createArcPath(from, to); this.updatePath(this.fullPath); } // Animate path being traced progressively with jet animatePath(from, to, duration, onComplete) { this.stopAnimation(); this.ensureLayers(); this.fullPath = this.createArcPath(from, to); const totalPoints = this.fullPath.length; // Show jet at start position with initial rotation const initialCoords = this.fullPath[0]; const nextCoords = this.fullPath[1]; this.showJet(initialCoords); // Set initial bearing const initialBearing = this.calculateBearing(initialCoords, nextCoords); this.jetMarker.setRotation(initialBearing); const startTime = performance.now(); const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Linear progression for consistent speed const pointIndex = Math.max(0, Math.min(Math.floor(progress * totalPoints), totalPoints - 1)); // Update path this.updatePath(this.fullPath.slice(0, pointIndex + 1)); // Update jet position and rotation const currentCoords = this.fullPath[pointIndex]; const lookAheadIndex = Math.min(pointIndex + 1, totalPoints - 1); const lookAheadCoords = this.fullPath[lookAheadIndex]; this.updateJetPosition(currentCoords, lookAheadCoords); if (progress < 1) { this.animationFrame = requestAnimationFrame(animate); } else { this.animationFrame = null; this.hideJet(); // Create fading trail from completed path this.createTrail(this.fullPath); this.updatePath([]); if (onComplete) onComplete(); } }; this.animationFrame = requestAnimationFrame(animate); } stopAnimation() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } this.hideJet(); } hide() { this.stopAnimation(); this.clearAllTrails(); this.updatePath([]); this.fullPath = null; } remove() { this.hide(); this.hideJet(); ['glow', 'main', 'blue', 'red'].forEach(suffix => { if (this.map.getLayer(`${this.id}-${suffix}`)) { this.map.removeLayer(`${this.id}-${suffix}`); } }); if (this.map.getSource(this.sourceId)) { this.map.removeSource(this.sourceId); } this.layersAdded = false; } }