skyview/assets/static/js/modules/aircraft-manager.js
Ole-Morten Duesund 4a0a993e81 fix: Sanitize all innerHTML dynamic values to prevent XSS
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>
2026-02-13 15:21:56 +01:00

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));
}
}
}