- Replaced simple geometric shapes with detailed, realistic SVG icons for all aircraft types - Implemented color differentiation based on aircraft weight categories: * Light (<7000kg): Sky blue * Medium (7000-34000kg): Green * Large (34000-136000kg): Orange * Heavy (>136000kg): Red - Created new icons for: commercial airliner, helicopter, cargo aircraft, general aviation, military fighter, ground vehicle - Updated legend to reflect new weight-based categories - Modified getAircraftColor() to assign colors based on aircraft category/weight Closes #17 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
523 lines
No EOL
21 KiB
JavaScript
523 lines
No EOL
21 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;
|
|
|
|
// SVG icon cache
|
|
this.iconCache = new Map();
|
|
this.loadIcons();
|
|
|
|
// Selected aircraft trail tracking
|
|
this.selectedAircraftCallback = null;
|
|
|
|
// Map event listeners removed - let Leaflet handle positioning naturally
|
|
}
|
|
|
|
async loadIcons() {
|
|
const iconTypes = ['commercial', 'helicopter', 'military', 'cargo', 'ga', 'ground'];
|
|
|
|
for (const type of iconTypes) {
|
|
try {
|
|
const response = await fetch(`/static/icons/${type}.svg`);
|
|
const svgText = await response.text();
|
|
this.iconCache.set(type, svgText);
|
|
} catch (error) {
|
|
console.warn(`Failed to load icon for ${type}:`, error);
|
|
// Fallback to inline SVG if needed
|
|
this.iconCache.set(type, this.createFallbackIcon(type));
|
|
}
|
|
}
|
|
}
|
|
|
|
createFallbackIcon(type) {
|
|
// Fallback inline SVG if file loading fails
|
|
const color = 'currentColor';
|
|
let path = '';
|
|
|
|
switch (type) {
|
|
case 'helicopter':
|
|
path = `<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':
|
|
path = `<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':
|
|
path = `<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':
|
|
path = `<path d="M0,-10 L-5,6 L-1,6 L0,10 L1,6 L5,6 Z" fill="${color}"/>`;
|
|
break;
|
|
case 'ground':
|
|
path = `<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:
|
|
path = `<path d="M0,-12 L-8,8 L-2,8 L0,12 L2,8 L8,8 Z" fill="${color}"/>`;
|
|
}
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
<g transform="translate(16,16)">
|
|
${path}
|
|
</g>
|
|
</svg>`;
|
|
}
|
|
|
|
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);
|
|
|
|
// Remove trail if it exists
|
|
if (this.aircraftTrails.has(icao)) {
|
|
const trail = this.aircraftTrails.get(icao);
|
|
if (trail.polyline) {
|
|
this.map.removeLayer(trail.polyline);
|
|
}
|
|
this.aircraftTrails.delete(icao);
|
|
}
|
|
|
|
// Notify if this was the selected aircraft
|
|
if (this.selectedAircraftCallback && this.selectedAircraftCallback(icao)) {
|
|
// Aircraft was selected and disappeared - could notify main app
|
|
// For now, the callback will return false automatically since selectedAircraft will be cleared
|
|
}
|
|
|
|
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);
|
|
|
|
// Check if icon needs to be updated (track rotation, aircraft type, or ground status changes)
|
|
const currentRotation = marker._currentRotation || 0;
|
|
const currentType = marker._currentType || null;
|
|
const currentOnGround = marker._currentOnGround || false;
|
|
|
|
const newType = this.getAircraftIconType(aircraft);
|
|
const rotationChanged = aircraft.Track !== undefined && Math.abs(currentRotation - aircraft.Track) > 5;
|
|
const typeChanged = currentType !== newType;
|
|
const groundStatusChanged = currentOnGround !== aircraft.OnGround;
|
|
|
|
if (rotationChanged || typeChanged || groundStatusChanged) {
|
|
marker.setIcon(this.createAircraftIcon(aircraft));
|
|
marker._currentRotation = aircraft.Track || 0;
|
|
marker._currentType = newType;
|
|
marker._currentOnGround = aircraft.OnGround || false;
|
|
}
|
|
|
|
// 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 properties for future update comparisons
|
|
marker._currentRotation = aircraft.Track || 0;
|
|
marker._currentType = this.getAircraftIconType(aircraft);
|
|
marker._currentOnGround = aircraft.OnGround || false;
|
|
|
|
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 - check both global trails and individual selected aircraft
|
|
if (this.showTrails || this.isSelectedAircraftTrailEnabled(icao)) {
|
|
this.updateAircraftTrail(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
createAircraftIcon(aircraft) {
|
|
const iconType = this.getAircraftIconType(aircraft);
|
|
const color = this.getAircraftColor(iconType, aircraft);
|
|
const size = aircraft.OnGround ? 12 : 16;
|
|
const rotation = aircraft.Track || 0;
|
|
|
|
// Get SVG template from cache
|
|
let svgTemplate = this.iconCache.get(iconType) || this.iconCache.get('commercial');
|
|
|
|
if (!svgTemplate) {
|
|
// Ultimate fallback - create a simple circle
|
|
svgTemplate = `<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
<g transform="translate(16,16)">
|
|
<circle cx="0" cy="0" r="8" fill="currentColor"/>
|
|
</g>
|
|
</svg>`;
|
|
}
|
|
|
|
// Apply color and rotation to the SVG
|
|
let svg = svgTemplate
|
|
.replace(/currentColor/g, color)
|
|
.replace(/width="32"/, `width="${size * 2}"`)
|
|
.replace(/height="32"/, `height="${size * 2}"`);
|
|
|
|
// Add rotation to the transform
|
|
if (rotation !== 0) {
|
|
svg = svg.replace(/transform="translate\(16,16\)"/, `transform="translate(16,16) rotate(${rotation})"`);
|
|
}
|
|
|
|
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, aircraft) {
|
|
// Special colors for specific types
|
|
if (type === 'military') return '#ff4444';
|
|
if (type === 'helicopter') return '#ff00ff';
|
|
if (type === 'ground') return '#888888';
|
|
if (type === 'ga') return '#ffff00';
|
|
|
|
// For commercial and cargo types, use weight-based colors
|
|
if (aircraft && aircraft.Category) {
|
|
const cat = aircraft.Category.toLowerCase();
|
|
|
|
// Check for specific weight ranges in the category string
|
|
// Light aircraft (< 7000kg) - Sky blue
|
|
if (cat.includes('light') || cat.includes('7000kg')) {
|
|
return '#00bfff';
|
|
}
|
|
// Medium aircraft (7000-34000kg) - Green
|
|
if (cat.includes('medium 7000')) {
|
|
return '#00ff88';
|
|
}
|
|
// Large aircraft (34000-136000kg) - Orange
|
|
if (cat.includes('medium 34000') || cat.includes('large')) {
|
|
return '#ff8c00';
|
|
}
|
|
// Heavy aircraft (> 136000kg) - Red
|
|
if (cat.includes('heavy') || cat.includes('136000kg') || cat.includes('super')) {
|
|
return '#ff0000';
|
|
}
|
|
}
|
|
|
|
// Default to green for unknown commercial aircraft
|
|
return '#00ff88';
|
|
}
|
|
|
|
|
|
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 = aircraft.country || 'Unknown';
|
|
const flag = aircraft.flag || '🏳️';
|
|
|
|
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>
|
|
${aircraft.TransponderCapability ? `
|
|
<div class="detail-row">
|
|
<strong>Transponder:</strong> ${aircraft.TransponderCapability}
|
|
</div>` : ''}
|
|
${aircraft.SignalQuality ? `
|
|
<div class="detail-row">
|
|
<strong>Signal Quality:</strong> ${aircraft.SignalQuality}
|
|
</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;
|
|
}
|
|
|
|
|
|
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;
|
|
}
|
|
|
|
showAircraftTrail(icao) {
|
|
const aircraft = this.aircraftData.get(icao);
|
|
if (aircraft && aircraft.position_history && aircraft.position_history.length >= 2) {
|
|
this.updateAircraftTrail(icao, aircraft);
|
|
}
|
|
}
|
|
|
|
hideAircraftTrail(icao) {
|
|
if (this.aircraftTrails.has(icao)) {
|
|
const trail = this.aircraftTrails.get(icao);
|
|
if (trail.polyline) {
|
|
this.map.removeLayer(trail.polyline);
|
|
}
|
|
this.aircraftTrails.delete(icao);
|
|
}
|
|
}
|
|
|
|
setSelectedAircraftCallback(callback) {
|
|
this.selectedAircraftCallback = callback;
|
|
}
|
|
|
|
isSelectedAircraftTrailEnabled(icao) {
|
|
return this.selectedAircraftCallback && this.selectedAircraftCallback(icao);
|
|
}
|
|
|
|
centerMapOnAircraft(includeSourcesCallback = null) {
|
|
const validAircraft = Array.from(this.aircraftData.values())
|
|
.filter(a => a.Latitude && a.Longitude);
|
|
|
|
const allPoints = [];
|
|
|
|
// Add aircraft positions
|
|
validAircraft.forEach(a => {
|
|
allPoints.push([a.Latitude, a.Longitude]);
|
|
});
|
|
|
|
// Add source positions if callback provided
|
|
if (includeSourcesCallback && typeof includeSourcesCallback === 'function') {
|
|
const sourcePositions = includeSourcesCallback();
|
|
allPoints.push(...sourcePositions);
|
|
}
|
|
|
|
if (allPoints.length === 0) return;
|
|
|
|
if (allPoints.length === 1) {
|
|
// Center on single point
|
|
this.map.setView(allPoints[0], 12);
|
|
} else {
|
|
// Fit bounds to all points (aircraft + sources)
|
|
const bounds = L.latLngBounds(allPoints);
|
|
this.map.fitBounds(bounds.pad(0.1));
|
|
}
|
|
}
|
|
|
|
} |