Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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 = ` | |
| <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <!-- Fuselage pointing up --> | |
| <path d="M16 2 L18 8 L18 22 L20 26 L20 28 L16 26 L12 28 L12 26 L14 22 L14 8 Z" fill="${COLORS.white}" stroke="${COLORS.blue}" stroke-width="0.5"/> | |
| <!-- Left wing (blue) --> | |
| <path d="M14 12 L4 18 L4 20 L14 17 Z" fill="${COLORS.blue}"/> | |
| <!-- Right wing (red) --> | |
| <path d="M18 12 L28 18 L28 20 L18 17 Z" fill="${COLORS.red}"/> | |
| <!-- Left tail --> | |
| <path d="M14 22 L10 26 L10 27 L14 24 Z" fill="${COLORS.blue}"/> | |
| <!-- Right tail --> | |
| <path d="M18 22 L22 26 L22 27 L18 24 Z" fill="${COLORS.red}"/> | |
| <!-- Cockpit --> | |
| <ellipse cx="16" cy="7" rx="1.5" ry="2.5" fill="${COLORS.blue}" opacity="0.6"/> | |
| </svg> | |
| `; | |
| 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; | |
| } | |
| } | |