paf-tracker / src /webgl /FlightPathLayer.js
hlarcher's picture
hlarcher HF Staff
Add fading contrail effect to flight paths
099d0c4 unverified
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;
}
}