Enhanced popup text contrast and readability: - Added text-shadow to all values for better contrast against dark background - Properly handle zero/null values (e.g., Track: 0° now shows instead of N/A) - Style N/A values with slightly dimmed gray (#aaaaaa) but still clearly visible - Add 'no-data' class to distinguish missing data from actual zero values - Ensure all text has strong white color with !important declarations This fixes visibility issues where some values appeared too faint or were incorrectly treated as missing when they were actually zero. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
437 lines
No EOL
17 KiB
JavaScript
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 rotation using Leaflet's options if available, otherwise skip rotation
|
|
if (aircraft.Track !== undefined) {
|
|
if (marker.setRotationAngle) {
|
|
// Use Leaflet rotation plugin method if available
|
|
marker.setRotationAngle(aircraft.Track);
|
|
} else if (marker.options) {
|
|
// Update the marker's options for consistency
|
|
marker.options.rotationAngle = aircraft.Track;
|
|
}
|
|
// Don't manually set CSS transforms - let Leaflet handle it
|
|
}
|
|
|
|
// 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,
|
|
rotationAngle: aircraft.Track || 0
|
|
}).addTo(this.map);
|
|
|
|
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, pos);
|
|
}
|
|
}
|
|
|
|
createAircraftIcon(aircraft) {
|
|
const type = this.getAircraftType(aircraft);
|
|
const color = this.getAircraftColor(type);
|
|
const size = aircraft.OnGround ? 12 : 16;
|
|
|
|
// Create different SVG shapes based on aircraft type
|
|
let aircraftPath;
|
|
|
|
switch (type) {
|
|
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)">
|
|
${aircraftPath}
|
|
</g>
|
|
</svg>
|
|
`;
|
|
|
|
return L.divIcon({
|
|
html: svg,
|
|
iconSize: [size * 2, size * 2],
|
|
iconAnchor: [size, size],
|
|
className: 'aircraft-marker'
|
|
});
|
|
}
|
|
|
|
getAircraftType(aircraft) {
|
|
if (aircraft.OnGround) return 'ground';
|
|
|
|
// Use ADS-B Category field for proper aircraft classification
|
|
if (aircraft.Category) {
|
|
const cat = aircraft.Category.toLowerCase();
|
|
|
|
// Standard ADS-B aircraft categories - check specific terms first
|
|
if (cat.includes('helicopter') || cat.includes('rotorcraft') || cat.includes('gyrocopter')) return 'helicopter';
|
|
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
|
|
|
|
// ADS-B weight-based categories (RTCA DO-260B standard)
|
|
if (cat.includes('heavy') || cat.includes('super') || cat.includes('136000kg')) return 'cargo'; // Heavy/Super category
|
|
if (cat.includes('light') || cat.includes('15500kg') || cat.includes('5700kg') || cat.includes('glider') || cat.includes('ultralight') || cat.includes('sport')) return 'ga'; // Light categories
|
|
if (cat.includes('medium') || cat.includes('34000kg')) return 'commercial'; // Medium category - typically commercial airliners
|
|
|
|
// Specific aircraft type categories
|
|
if (cat.includes('cargo') || cat.includes('transport') || cat.includes('freighter')) return 'cargo';
|
|
if (cat.includes('airliner') || cat.includes('jet') || cat.includes('turboprop')) return 'commercial';
|
|
}
|
|
|
|
// Fallback to callsign analysis for classification
|
|
if (aircraft.Callsign) {
|
|
const cs = aircraft.Callsign.toLowerCase();
|
|
|
|
// Helicopter identifiers
|
|
if (cs.includes('heli') || cs.includes('rescue') || cs.includes('medevac') || cs.includes('lifeguard') ||
|
|
cs.includes('police') || cs.includes('sheriff') || cs.includes('medic')) return 'helicopter';
|
|
|
|
// Military identifiers
|
|
if (cs.includes('mil') || cs.includes('army') || cs.includes('navy') || cs.includes('air force') ||
|
|
cs.includes('usaf') || cs.includes('usmc') || cs.includes('uscg')) return 'military';
|
|
|
|
// Cargo identifiers
|
|
if (cs.includes('cargo') || cs.includes('fedex') || cs.includes('ups') || cs.includes('dhl') ||
|
|
cs.includes('freight') || cs.includes('express')) return 'cargo';
|
|
}
|
|
|
|
// Default to commercial for unclassified aircraft
|
|
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, pos) {
|
|
if (!this.aircraftTrails.has(icao)) {
|
|
this.aircraftTrails.set(icao, []);
|
|
}
|
|
|
|
const trail = this.aircraftTrails.get(icao);
|
|
trail.push(pos);
|
|
|
|
// Keep only last 50 positions
|
|
if (trail.length > 50) {
|
|
trail.shift();
|
|
}
|
|
|
|
// Draw polyline
|
|
const trailLine = L.polyline(trail, {
|
|
color: '#00d4ff',
|
|
weight: 2,
|
|
opacity: 0.6
|
|
}).addTo(this.map);
|
|
|
|
// Store reference for cleanup
|
|
if (!this.aircraftTrails.get(icao).polyline) {
|
|
this.aircraftTrails.get(icao).polyline = trailLine;
|
|
} else {
|
|
this.map.removeLayer(this.aircraftTrails.get(icao).polyline);
|
|
this.aircraftTrails.get(icao).polyline = trailLine;
|
|
}
|
|
}
|
|
|
|
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.charAt(0).toUpperCase() + type.slice(1)}
|
|
</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));
|
|
}
|
|
}
|
|
|
|
} |