Add centralized escapeHtml() utility and apply it to every dynamic value inserted via innerHTML/template literals across the frontend. Data from VRS JSON sources and external CSV files (airline names, countries) flows through the backend as arbitrary strings that could contain HTML. While Go's json.Marshal escapes < > &, JavaScript's JSON.parse reverses those escapes before the values reach innerHTML — enabling script injection. Affected modules: aircraft-manager, ui-manager, callsign-manager, map-manager, and the 3D radar labels in app.js. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
629 lines
No EOL
27 KiB
JavaScript
629 lines
No EOL
27 KiB
JavaScript
// Aircraft marker and data management module
|
|
import { escapeHtml } from './html-utils.js';
|
|
|
|
export class AircraftManager {
|
|
constructor(map, callsignManager = null) {
|
|
this.map = map;
|
|
this.callsignManager = callsignManager;
|
|
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', 'heavy', 'large', 'light', 'medium'];
|
|
|
|
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) {
|
|
// Track which aircraft are new or have position changes for debugging
|
|
let newCount = 0;
|
|
let updatedCount = 0;
|
|
let positionChanges = 0;
|
|
|
|
// Clear old data and update with new data
|
|
const previousData = new Map(this.aircraftData);
|
|
this.aircraftData.clear();
|
|
|
|
for (const [icao, aircraft] of Object.entries(data.aircraft)) {
|
|
const previousAircraft = previousData.get(icao);
|
|
|
|
if (!previousAircraft) {
|
|
newCount++;
|
|
} else {
|
|
updatedCount++;
|
|
|
|
// Check for position changes
|
|
if (previousAircraft.Latitude !== aircraft.Latitude ||
|
|
previousAircraft.Longitude !== aircraft.Longitude ||
|
|
previousAircraft.Track !== aircraft.Track ||
|
|
previousAircraft.Altitude !== aircraft.Altitude) {
|
|
positionChanges++;
|
|
}
|
|
}
|
|
|
|
this.aircraftData.set(icao, aircraft);
|
|
}
|
|
|
|
// Debug logging for track propagation issues (only if significant changes)
|
|
if (newCount > 0 || (positionChanges > 5 && window.skyviewVerbose)) {
|
|
console.debug(`Aircraft update: ${newCount} new, ${updatedCount} updated, ${positionChanges} position changes`);
|
|
}
|
|
}
|
|
}
|
|
|
|
updateMarkers() {
|
|
if (!this.map) {
|
|
console.debug("Map not available for updateMarkers");
|
|
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++;
|
|
console.debug(`Removed stale aircraft marker: ${icao}`);
|
|
}
|
|
}
|
|
|
|
// Update aircraft markers - process ALL aircraft, not just those with valid coordinates yet
|
|
// Let updateAircraftMarker handle coordinate validation
|
|
for (const [icao, aircraft] of this.aircraftData) {
|
|
// More comprehensive coordinate check
|
|
const hasCoords = aircraft.Latitude != null && aircraft.Longitude != null &&
|
|
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);
|
|
} else if (hasCoords) {
|
|
// Log invalid coordinates for debugging
|
|
console.debug(`Invalid coordinates for ${icao}: lat=${aircraft.Latitude}, lng=${aircraft.Longitude}`);
|
|
}
|
|
// If no coordinates, we still want to process for other updates (trails, etc.)
|
|
}
|
|
|
|
// Only log marker updates in verbose mode
|
|
if (window.skyviewVerbose) {
|
|
console.debug(`Markers update complete: ${this.aircraftMarkers.size} active markers, ${this.aircraftData.size} 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 - don't try to be too smart about detecting changes
|
|
const oldPos = marker.getLatLng();
|
|
const positionChanged = Math.abs(oldPos.lat - pos[0]) > 0.0001 || Math.abs(oldPos.lng - pos[1]) > 0.0001;
|
|
|
|
// ALWAYS update position regardless of change detection to ensure propagation
|
|
marker.setLatLng(pos);
|
|
|
|
if (positionChanged) {
|
|
// Debug significant position updates
|
|
// Only log individual position changes in verbose mode
|
|
if (window.skyviewVerbose) {
|
|
console.debug(`Position change for ${icao}: [${pos[0].toFixed(4)}, ${pos[1].toFixed(4)}] (was [${oldPos.lat.toFixed(4)}, ${oldPos.lng.toFixed(4)}])`);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
// Fix rotation change detection - handle undefined/null tracks properly
|
|
const newTrack = aircraft.Track || 0;
|
|
const rotationChanged = aircraft.Track !== undefined && aircraft.Track !== null &&
|
|
Math.abs(currentRotation - newTrack) > 10; // Increased threshold
|
|
const typeChanged = currentType !== newType;
|
|
const groundStatusChanged = currentOnGround !== aircraft.OnGround;
|
|
|
|
// Update icon if anything changed, OR if this is a new track value and we didn't have one before
|
|
const firstTrackUpdate = currentRotation === 0 && aircraft.Track !== undefined && aircraft.Track !== null && aircraft.Track !== 0;
|
|
|
|
if (rotationChanged || typeChanged || groundStatusChanged || firstTrackUpdate) {
|
|
marker.setIcon(this.createAircraftIcon(aircraft));
|
|
marker._currentRotation = newTrack;
|
|
marker._currentType = newType;
|
|
marker._currentOnGround = aircraft.OnGround || false;
|
|
|
|
if (rotationChanged || firstTrackUpdate) {
|
|
console.debug(`Updated track for ${icao}: ${aircraft.Track}° (was ${currentRotation}°)`);
|
|
}
|
|
}
|
|
|
|
// Handle popup exactly like Leaflet expects
|
|
if (marker.isPopupOpen()) {
|
|
marker.setPopupContent(this.createPopupContent(aircraft));
|
|
// Enhance callsign display for updated popup
|
|
const popupElement = marker.getPopup().getElement();
|
|
if (popupElement) {
|
|
this.enhanceCallsignDisplay(popupElement);
|
|
}
|
|
}
|
|
|
|
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'
|
|
});
|
|
|
|
// Enhance callsign display when popup opens
|
|
marker.on('popupopen', (e) => {
|
|
const popupElement = e.popup.getElement();
|
|
if (popupElement) {
|
|
this.enhanceCallsignDisplay(popupElement);
|
|
}
|
|
});
|
|
|
|
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, determine which SVG shape to use based on category
|
|
if (aircraft.OnGround) return 'ground';
|
|
|
|
if (aircraft.Category) {
|
|
const cat = aircraft.Category.toLowerCase();
|
|
|
|
// Specialized aircraft types
|
|
if (cat.includes('helicopter') || cat.includes('rotorcraft')) return 'helicopter';
|
|
if (cat.includes('military') || cat.includes('fighter') || cat.includes('bomber')) return 'military';
|
|
if (cat.includes('glider') || cat.includes('ultralight')) return 'ga';
|
|
|
|
// Weight-based categories with specific icons
|
|
if (cat.includes('light') && cat.includes('7000')) return 'light';
|
|
if (cat.includes('medium') && cat.includes('7000-34000')) return 'medium';
|
|
if (cat.includes('large') && cat.includes('34000-136000')) return 'large';
|
|
if (cat.includes('heavy') && cat.includes('136000')) return 'heavy';
|
|
if (cat.includes('high vortex')) return 'large'; // Use large icon for high vortex
|
|
|
|
// Fallback category matching
|
|
if (cat.includes('heavy') || cat.includes('super')) return 'heavy';
|
|
if (cat.includes('large')) return 'large';
|
|
if (cat.includes('medium')) return 'medium';
|
|
if (cat.includes('light')) return 'light';
|
|
}
|
|
|
|
// Default to medium icon for unknown aircraft
|
|
return 'medium';
|
|
}
|
|
|
|
getAircraftColor(type, aircraft) {
|
|
// Color mapping based on aircraft type/size
|
|
switch (type) {
|
|
case 'military': return '#ff4444'; // Red-orange for military
|
|
case 'helicopter': return '#ff00ff'; // Magenta for helicopters
|
|
case 'ground': return '#888888'; // Gray for ground vehicles
|
|
case 'ga': return '#ffff00'; // Yellow for general aviation
|
|
case 'light': return '#00bfff'; // Sky blue for light aircraft
|
|
case 'medium': return '#00ff88'; // Green for medium aircraft
|
|
case 'large': return '#ff8c00'; // Orange for large aircraft
|
|
case 'heavy': return '#ff0000'; // Red for heavy aircraft
|
|
default: return '#00ff88'; // Default green for unknown
|
|
}
|
|
}
|
|
|
|
|
|
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';
|
|
|
|
const esc = escapeHtml;
|
|
return `
|
|
<div class="aircraft-popup">
|
|
<div class="popup-header">
|
|
<div class="flight-info">
|
|
<span class="icao-flag">${esc(flag)}</span>
|
|
<span class="flight-id">${esc(aircraft.ICAO24) || 'N/A'}</span>
|
|
${aircraft.Callsign ? `→ <span class="callsign-loading" data-callsign="${esc(aircraft.Callsign)}"><span class="callsign">${esc(aircraft.Callsign)}</span></span>` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="popup-details">
|
|
<div class="detail-row">
|
|
<strong>Country:</strong> ${esc(country)}
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Type:</strong> ${esc(type)}
|
|
</div>
|
|
${aircraft.TransponderCapability ? `
|
|
<div class="detail-row">
|
|
<strong>Transponder:</strong> ${esc(aircraft.TransponderCapability)}
|
|
</div>` : ''}
|
|
${aircraft.SignalQuality ? `
|
|
<div class="detail-row">
|
|
<strong>Signal Quality:</strong> ${esc(aircraft.SignalQuality)}
|
|
</div>` : ''}
|
|
|
|
<div class="detail-grid">
|
|
<div class="detail-item">
|
|
<div class="label">Altitude:</div>
|
|
<div class="value${!altitude ? ' no-data' : ''}">${altitude ? `${esc(altitude)} ft | ${esc(altitudeM)} m` : 'N/A'}</div>
|
|
</div>
|
|
<div class="detail-item">
|
|
<div class="label">Squawk:</div>
|
|
<div class="value${!aircraft.Squawk ? ' no-data' : ''}">${esc(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 ? `${esc(aircraft.GroundSpeed)} kt | ${esc(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 ? `${esc(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 ? `${esc(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' ? `${esc(distanceKm)} km` : 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="detail-row">
|
|
<strong>Position:</strong> ${esc(aircraft.Latitude?.toFixed(4))}°, ${esc(aircraft.Longitude?.toFixed(4))}°
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Messages:</strong> ${esc(aircraft.TotalMessages || 0)}
|
|
</div>
|
|
<div class="detail-row">
|
|
<strong>Age:</strong> ${esc(this.calculateAge(aircraft).toFixed(1))}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;
|
|
}
|
|
|
|
calculateAge(aircraft) {
|
|
if (!aircraft.last_update) return 0;
|
|
|
|
const lastUpdate = new Date(aircraft.last_update);
|
|
const now = new Date();
|
|
const ageMs = now.getTime() - lastUpdate.getTime();
|
|
|
|
return Math.max(0, ageMs / 1000); // Return age in seconds, minimum 0
|
|
}
|
|
|
|
// Enhance callsign display in popup after it's created
|
|
async enhanceCallsignDisplay(popupElement) {
|
|
if (!this.callsignManager) return;
|
|
|
|
const callsignElements = popupElement.querySelectorAll('.callsign-loading');
|
|
|
|
for (const element of callsignElements) {
|
|
const callsign = element.dataset.callsign;
|
|
if (!callsign) continue;
|
|
|
|
try {
|
|
const callsignInfo = await this.callsignManager.getCallsignInfo(callsign);
|
|
const richDisplay = this.callsignManager.generateCallsignDisplay(callsignInfo, callsign);
|
|
element.innerHTML = richDisplay;
|
|
element.classList.remove('callsign-loading');
|
|
element.classList.add('callsign-enhanced');
|
|
} catch (error) {
|
|
console.warn(`Failed to enhance callsign display for ${callsign}:`, error);
|
|
// Keep the simple display on error
|
|
element.classList.remove('callsign-loading');
|
|
}
|
|
}
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
} |