skyview/assets/static/js/modules/aircraft-manager.js
Ole-Morten Duesund da4645d483 Implement server-side trail tracking and fix aircraft marker orientation
- Replace client-side trail collection with server-provided position history
- Fix aircraft markers to properly orient based on track heading using SVG rotation
- Add beast-dump binary to debian package with comprehensive man pages
- Trail visualization now uses gradient effect where newer positions are brighter
- Marker icons update when track heading changes by more than 5 degrees for performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 15:18:51 +02:00

437 lines
No EOL
17 KiB
JavaScript

// Aircraft marker and data management module
export class AircraftManager {
constructor(map) {
this.map = map;
this.aircraftData = new Map();
this.aircraftMarkers = new Map();
this.aircraftTrails = new Map();
this.showTrails = false;
// Debug: Track marker lifecycle
this.markerCreateCount = 0;
this.markerUpdateCount = 0;
this.markerRemoveCount = 0;
// Map event listeners removed - let Leaflet handle positioning naturally
}
updateAircraftData(data) {
if (data.aircraft) {
this.aircraftData.clear();
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
this.aircraftData.set(icao, aircraft);
}
}
}
updateMarkers() {
if (!this.map) {
return;
}
// Clear stale aircraft markers
const currentICAOs = new Set(this.aircraftData.keys());
for (const [icao, marker] of this.aircraftMarkers) {
if (!currentICAOs.has(icao)) {
this.map.removeLayer(marker);
this.aircraftMarkers.delete(icao);
this.aircraftTrails.delete(icao);
this.markerRemoveCount++;
}
}
// Update aircraft markers - only for aircraft with valid geographic coordinates
for (const [icao, aircraft] of this.aircraftData) {
const hasCoords = aircraft.Latitude && aircraft.Longitude && aircraft.Latitude !== 0 && aircraft.Longitude !== 0;
const validLat = aircraft.Latitude >= -90 && aircraft.Latitude <= 90;
const validLng = aircraft.Longitude >= -180 && aircraft.Longitude <= 180;
if (hasCoords && validLat && validLng) {
this.updateAircraftMarker(icao, aircraft);
}
}
}
updateAircraftMarker(icao, aircraft) {
const pos = [aircraft.Latitude, aircraft.Longitude];
// Check for invalid coordinates - proper geographic bounds
const isValidLat = pos[0] >= -90 && pos[0] <= 90;
const isValidLng = pos[1] >= -180 && pos[1] <= 180;
if (!isValidLat || !isValidLng || isNaN(pos[0]) || isNaN(pos[1])) {
console.error(`🚨 Invalid coordinates for ${icao}: [${pos[0]}, ${pos[1]}] (lat must be -90 to +90, lng must be -180 to +180)`);
return; // Don't create/update marker with invalid coordinates
}
if (this.aircraftMarkers.has(icao)) {
// Update existing marker - KISS approach
const marker = this.aircraftMarkers.get(icao);
// Always update position - let Leaflet handle everything
const oldPos = marker.getLatLng();
marker.setLatLng(pos);
// Update icon if track has changed to apply new rotation
if (aircraft.Track !== undefined) {
const currentRotation = marker._currentRotation || 0;
if (Math.abs(currentRotation - aircraft.Track) > 5) { // Update if rotation changed by more than 5 degrees
marker.setIcon(this.createAircraftIcon(aircraft));
marker._currentRotation = aircraft.Track;
}
}
// Handle popup exactly like Leaflet expects
if (marker.isPopupOpen()) {
marker.setPopupContent(this.createPopupContent(aircraft));
}
this.markerUpdateCount++;
} else {
// Create new marker
const icon = this.createAircraftIcon(aircraft);
try {
const marker = L.marker(pos, {
icon: icon
}).addTo(this.map);
// Store current rotation for future updates
marker._currentRotation = aircraft.Track || 0;
marker.bindPopup(this.createPopupContent(aircraft), {
maxWidth: 450,
className: 'aircraft-popup'
});
this.aircraftMarkers.set(icao, marker);
this.markerCreateCount++;
// Force immediate visibility
if (marker._icon) {
marker._icon.style.display = 'block';
marker._icon.style.opacity = '1';
marker._icon.style.visibility = 'visible';
}
} catch (error) {
console.error(`Failed to create marker for ${icao}:`, error);
}
}
// Update trails
if (this.showTrails) {
this.updateAircraftTrail(icao, aircraft);
}
}
createAircraftIcon(aircraft) {
const iconType = this.getAircraftIconType(aircraft);
const color = this.getAircraftColor(iconType);
const size = aircraft.OnGround ? 12 : 16;
const rotation = aircraft.Track || 0;
// Create different SVG shapes based on aircraft type
let aircraftPath;
switch (iconType) {
case 'helicopter':
// Helicopter shape with rotor disc
aircraftPath = `
<circle cx="0" cy="0" r="10" fill="none" stroke="${color}" stroke-width="1" opacity="0.3"/>
<path d="M0,-8 L-6,6 L-1,6 L0,8 L1,6 L6,6 Z" fill="${color}"/>
<path d="M0,-6 L0,-10" stroke="${color}" stroke-width="2"/>
<path d="M0,6 L0,8" stroke="${color}" stroke-width="2"/>
`;
break;
case 'military':
// Swept-wing fighter jet shape
aircraftPath = `
<path d="M0,-12 L-4,2 L-8,8 L-2,6 L0,12 L2,6 L8,8 L4,2 Z" fill="${color}"/>
`;
break;
case 'cargo':
// Wide-body cargo aircraft shape
aircraftPath = `
<path d="M0,-12 L-10,8 L-3,8 L0,12 L3,8 L10,8 Z" fill="${color}"/>
<rect x="-2" y="-6" width="4" height="8" fill="${color}"/>
`;
break;
case 'ga':
// Small general aviation aircraft
aircraftPath = `
<path d="M0,-10 L-5,6 L-1,6 L0,10 L1,6 L5,6 Z" fill="${color}"/>
`;
break;
case 'ground':
// Ground vehicle - simplified truck/car shape
aircraftPath = `
<rect x="-6" y="-4" width="12" height="8" fill="${color}" rx="2"/>
<circle cx="-3" cy="2" r="2" fill="#333"/>
<circle cx="3" cy="2" r="2" fill="#333"/>
`;
break;
default:
// Default commercial aircraft shape
aircraftPath = `
<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z" fill="${color}"/>
`;
}
const svg = `
<svg width="${size * 2}" height="${size * 2}" viewBox="0 0 32 32">
<g transform="translate(16,16) rotate(${rotation})">
${aircraftPath}
</g>
</svg>
`;
return L.divIcon({
html: svg,
iconSize: [size * 2, size * 2],
iconAnchor: [size, size],
className: 'aircraft-marker'
});
}
getAircraftType(aircraft) {
// For display purposes, return the actual ADS-B category
// This is used in the popup display
if (aircraft.OnGround) return 'On Ground';
if (aircraft.Category) return aircraft.Category;
return 'Unknown';
}
getAircraftIconType(aircraft) {
// For icon selection, we still need basic categories
// This determines which SVG shape to use
if (aircraft.OnGround) return 'ground';
if (aircraft.Category) {
const cat = aircraft.Category.toLowerCase();
// Map to basic icon types for visual representation
if (cat.includes('helicopter') || cat.includes('rotorcraft')) return 'helicopter';
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
if (cat.includes('cargo') || cat.includes('heavy') || cat.includes('super')) return 'cargo';
if (cat.includes('light') || cat.includes('glider') || cat.includes('ultralight')) return 'ga';
}
// Default commercial icon for everything else
return 'commercial';
}
getAircraftColor(type) {
const colors = {
commercial: '#00ff88',
cargo: '#ff8c00',
military: '#ff4444',
ga: '#ffff00',
ground: '#888888',
helicopter: '#ff00ff' // Magenta for helicopters
};
return colors[type] || colors.commercial;
}
updateAircraftTrail(icao, aircraft) {
// Use server-provided position history
if (!aircraft.position_history || aircraft.position_history.length < 2) {
// No trail data available or not enough points
if (this.aircraftTrails.has(icao)) {
const trail = this.aircraftTrails.get(icao);
if (trail.polyline) {
this.map.removeLayer(trail.polyline);
}
this.aircraftTrails.delete(icao);
}
return;
}
// Convert position history to Leaflet format
const trailPoints = aircraft.position_history.map(point => [point.lat, point.lon]);
// Get or create trail object
if (!this.aircraftTrails.has(icao)) {
this.aircraftTrails.set(icao, {});
}
const trail = this.aircraftTrails.get(icao);
// Remove old polyline if it exists
if (trail.polyline) {
this.map.removeLayer(trail.polyline);
}
// Create gradient effect - newer points are brighter
const segments = [];
for (let i = 1; i < trailPoints.length; i++) {
const opacity = 0.2 + (0.6 * (i / trailPoints.length)); // Fade from 0.2 to 0.8
const segment = L.polyline([trailPoints[i-1], trailPoints[i]], {
color: '#00d4ff',
weight: 2,
opacity: opacity
});
segments.push(segment);
}
// Create a feature group for all segments
trail.polyline = L.featureGroup(segments).addTo(this.map);
}
createPopupContent(aircraft) {
const type = this.getAircraftType(aircraft);
const country = this.getCountryFromICAO(aircraft.ICAO24 || '');
const flag = this.getCountryFlag(country);
const altitude = aircraft.Altitude || aircraft.BaroAltitude || 0;
const altitudeM = altitude ? Math.round(altitude * 0.3048) : 0;
const speedKmh = aircraft.GroundSpeed ? Math.round(aircraft.GroundSpeed * 1.852) : 0;
const distance = this.calculateDistance(aircraft);
const distanceKm = distance ? (distance * 1.852).toFixed(1) : 'N/A';
return `
<div class="aircraft-popup">
<div class="popup-header">
<div class="flight-info">
<span class="icao-flag">${flag}</span>
<span class="flight-id">${aircraft.ICAO24 || 'N/A'}</span>
${aircraft.Callsign ? `→ <span class="callsign">${aircraft.Callsign}</span>` : ''}
</div>
</div>
<div class="popup-details">
<div class="detail-row">
<strong>Country:</strong> ${country}
</div>
<div class="detail-row">
<strong>Type:</strong> ${type}
</div>
<div class="detail-grid">
<div class="detail-item">
<div class="label">Altitude:</div>
<div class="value${!altitude ? ' no-data' : ''}">${altitude ? `${altitude} ft | ${altitudeM} m` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Squawk:</div>
<div class="value${!aircraft.Squawk ? ' no-data' : ''}">${aircraft.Squawk || 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Speed:</div>
<div class="value${!aircraft.GroundSpeed ? ' no-data' : ''}">${aircraft.GroundSpeed !== undefined && aircraft.GroundSpeed !== null ? `${aircraft.GroundSpeed} kt | ${speedKmh} km/h` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Track:</div>
<div class="value${aircraft.Track === undefined || aircraft.Track === null ? ' no-data' : ''}">${aircraft.Track !== undefined && aircraft.Track !== null ? `${aircraft.Track}°` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">V/Rate:</div>
<div class="value${!aircraft.VerticalRate ? ' no-data' : ''}">${aircraft.VerticalRate ? `${aircraft.VerticalRate} ft/min` : 'N/A'}</div>
</div>
<div class="detail-item">
<div class="label">Distance:</div>
<div class="value${distance ? '' : ' no-data'}">${distanceKm !== 'N/A' ? `${distanceKm} km` : 'N/A'}</div>
</div>
</div>
<div class="detail-row">
<strong>Position:</strong> ${aircraft.Latitude?.toFixed(4)}°, ${aircraft.Longitude?.toFixed(4)}°
</div>
<div class="detail-row">
<strong>Messages:</strong> ${aircraft.TotalMessages || 0}
</div>
<div class="detail-row">
<strong>Age:</strong> ${aircraft.Age ? aircraft.Age.toFixed(1) : '0'}s
</div>
</div>
</div>
`;
}
calculateDistance(aircraft) {
if (!aircraft.Latitude || !aircraft.Longitude) return null;
// Use closest source as reference point
let minDistance = Infinity;
for (const [id, srcData] of Object.entries(aircraft.sources || {})) {
if (srcData.distance && srcData.distance < minDistance) {
minDistance = srcData.distance;
}
}
return minDistance === Infinity ? null : minDistance;
}
getCountryFromICAO(icao) {
if (!icao || icao.length < 6) return 'Unknown';
const prefix = icao[0];
const countryMap = {
'4': 'Europe',
'A': 'United States',
'C': 'Canada',
'D': 'Germany',
'F': 'France',
'G': 'United Kingdom',
'I': 'Italy',
'J': 'Japan'
};
return countryMap[prefix] || 'Unknown';
}
getCountryFlag(country) {
const flags = {
'United States': '🇺🇸',
'Canada': '🇨🇦',
'Germany': '🇩🇪',
'France': '🇫🇷',
'United Kingdom': '🇬🇧',
'Italy': '🇮🇹',
'Japan': '🇯🇵',
'Europe': '🇪🇺'
};
return flags[country] || '🏳️';
}
toggleTrails() {
this.showTrails = !this.showTrails;
if (!this.showTrails) {
// Clear all trails
this.aircraftTrails.forEach((trail, icao) => {
if (trail.polyline) {
this.map.removeLayer(trail.polyline);
}
});
this.aircraftTrails.clear();
}
return this.showTrails;
}
centerMapOnAircraft() {
if (this.aircraftData.size === 0) return;
const validAircraft = Array.from(this.aircraftData.values())
.filter(a => a.Latitude && a.Longitude);
if (validAircraft.length === 0) return;
if (validAircraft.length === 1) {
// Center on single aircraft
const aircraft = validAircraft[0];
this.map.setView([aircraft.Latitude, aircraft.Longitude], 12);
} else {
// Fit bounds to all aircraft
const bounds = L.latLngBounds(
validAircraft.map(a => [a.Latitude, a.Longitude])
);
this.map.fitBounds(bounds.pad(0.1));
}
}
}