// Pixel ADS-B - Retro Side View class PixelADSB { constructor() { this.canvas = document.getElementById('pixel-canvas'); this.ctx = this.canvas.getContext('2d'); // Set up responsive canvas sizing this.setupResponsiveCanvas(); this.width = this.canvas.width; this.height = this.canvas.height; // Receiver location (will be fetched from config API) this.receiverLat = 0; this.receiverLon = 0; this.locationName = 'Loading...'; // Flight data this.flights = new Map(); this.aircraftTypes = new Map(); // Cache ICAO -> aircraft type // WebSocket this.ws = null; this.reconnectDelay = 1000; // Colors (retro pixel art theme) this.colors = { skyTop: '#6ca4dc', skyBottom: '#b4d4ec', cloud: '#ffffff', sun: '#fcd444', ground: '#d4a868', groundDark: '#b8884c', dirt: '#a87840', antenna: '#c0c0c0', // Silver/gray antenna antennaDark: '#808080', // Dark gray for shading antennaBase: '#4c4c4c', // Dark base antennaRing: '#ffffff', // White rings plane: '#fcfcfc', planeTowards: '#54fc54', planeAway: '#fc9c54', text: '#fcfcfc', textShadow: '#000000', grid: 'rgba(255, 255, 255, 0.1)', cactus: '#54a844', cactusDark: '#3c7c30', mountain: '#8c7c68', mountainSnow: '#fcfcfc', rain: 'rgba(120, 160, 200, 0.5)', snow: '#fcfcfc' }; // Weather state this.weather = { condition: 'clear', // clear, cloudy, rain, snow description: 'Clear', temp: 0, sunrise: null, sunset: null, lastUpdate: 0 }; // View direction state (0=North, 90=East, 180=South, 270=West) this.viewDirection = 0; this.viewDirectionNames = { 0: 'N', 90: 'E', 180: 'S', 270: 'W' }; this.fieldOfView = 90; // 90 degree field of view // Theme for background images (loaded from config) this.theme = 'desert'; // Hover and selection tracking this.mouseX = -1; this.mouseY = -1; this.hoveredAircraft = null; this.selectedAircraftIcao = null; // Selected from sidebar click this.visibleAircraftList = []; // For sidebar display this.lastSidebarUpdate = 0; // Throttle sidebar updates this.lastStatsUpdate = 0; // Throttle stats updates this.cachedCountText = ''; this.cachedRangeText = ''; // Load aircraft sprite images this.aircraftImages = { smallProp: new Image(), regionalJet: new Image(), narrowBody: new Image(), wideBody: new Image(), heavy: new Image(), helicopter: new Image() }; // Track which images have loaded this.aircraftImagesLoaded = { smallProp: false, regionalJet: false, narrowBody: false, wideBody: false, heavy: false, helicopter: false }; // Load all aircraft images Object.keys(this.aircraftImages).forEach(type => { this.aircraftImages[type].onload = () => { this.aircraftImagesLoaded[type] = true; console.log(`${type} sprite loaded`); }; this.aircraftImages[type].onerror = () => { console.warn(`Failed to load ${type} sprite`); }; this.aircraftImages[type].src = `images/${type}.png?v=24`; }); // Load environment images (directional backgrounds, base, sun, clouds) // Directional background images (loaded after fetching config) this.backgroundImages = { 0: new Image(), // North 90: new Image(), // East 180: new Image(), // South 270: new Image() // West }; this.backgroundImagesLoaded = { 0: false, 90: false, 180: false, 270: false }; this.sunImage = new Image(); this.sunImage.onload = () => { this.sunImageLoaded = true; console.log('sun.png loaded'); }; this.sunImage.onerror = () => { console.warn('Failed to load sun.png'); }; this.sunImage.src = 'images/sun.png?v=24'; this.happyCloudImage = new Image(); this.happyCloudImage.onload = () => { this.happyCloudImageLoaded = true; console.log('happycloud.png loaded'); }; this.happyCloudImage.onerror = () => { console.warn('Failed to load happycloud.png'); }; this.happyCloudImage.src = 'images/happycloud.png?v=24'; this.rainCloudImage = new Image(); this.rainCloudImage.onload = () => { this.rainCloudImageLoaded = true; console.log('raincloud.png loaded'); }; this.rainCloudImage.onerror = () => { console.warn('Failed to load raincloud.png'); }; this.rainCloudImage.src = 'images/raincloud.png?v=24'; this.moonSprite = new Image(); this.moonSprite.onload = () => { this.moonSpriteLoaded = true; console.log('moon_6_phases.png loaded'); }; this.moonSprite.onerror = () => { console.warn('Failed to load moon_6_phases.png'); }; this.moonSprite.src = 'images/moon_6_phases.png?v=32'; // Track which environment images have loaded this.sunImageLoaded = false; this.happyCloudImageLoaded = false; this.rainCloudImageLoaded = false; this.moonSpriteLoaded = false; // Pixel art sprites (kept as fallback and for other elements) this.sprites = this.createSprites(); this.init(); } createSprites() { // Define pixel art sprites as 2D arrays (1 = mast, 2 = white ring, 3 = base) return { // ADS-B Antenna antenna: [ [0,0,1,0,0], // Top tip [0,1,2,1,0], // Top ring (white) [0,0,1,0,0], // Mast [0,0,1,0,0], // Mast [0,1,2,1,0], // Ring [0,0,1,0,0], // Mast [0,0,1,0,0], // Mast [1,1,2,1,1], // Large ring [0,0,1,0,0], // Mast [0,0,1,0,0], // Mast [0,3,3,3,0], // Base [3,3,3,3,3] // Base platform ], // Cactus (saguaro style) cactus: [ [0,1,0,0,0,1,0], [0,1,0,0,0,1,0], [1,1,1,1,1,1,1], [0,0,1,1,1,0,0], [0,0,1,1,1,0,0], [0,0,1,1,1,0,0], [0,0,1,1,1,0,0], [0,0,1,1,1,0,0] ], // Mountain (simple peak) mountain: [ [0,0,0,0,0,1,0,0,0,0,0], [0,0,0,0,1,1,1,0,0,0,0], [0,0,0,1,1,2,1,1,0,0,0], [0,0,1,1,1,2,1,1,1,0,0], [0,1,1,1,1,1,1,1,1,1,0], [1,1,1,1,1,1,1,1,1,1,1] ], // Aircraft sprites (side view, facing right) // Colors: 1=fuselage, 2=windows, 3=wings, 4=tail, 5=engine // Small prop plane (Cessna, small GA) smallProp: [ [0,0,0,0,4,4,0,0], // Tail [0,0,0,4,4,1,4,0], // Tail fin [0,0,0,1,1,1,1,0], // Rear fuselage [0,0,1,2,1,1,1,1], // Fuselage with windows [3,3,3,3,3,1,1,1], // Wings + nose [0,0,1,2,1,1,5,1], // Fuselage with prop [0,0,0,1,1,1,1,0], // Belly [0,0,0,0,0,3,0,0] // Bottom wing ], // Regional jet (CRJ, ERJ) regionalJet: [ [0,0,0,0,0,4,4,0,0], // Tail [0,0,0,0,4,4,1,4,0], // Tail fin [0,0,0,0,1,1,1,1,0], // Rear fuselage [0,0,0,1,2,2,1,1,1], // Fuselage with windows [0,3,3,3,3,3,3,1,1], // Wings [0,0,0,1,2,2,1,5,1], // Fuselage + engine [0,0,0,0,1,1,1,5,1], // Belly + engine [0,0,0,0,0,0,3,0,0] // Wing tip ], // Narrow body (737, A320) narrowBody: [ [0,0,0,0,0,4,4,4,0,0], // Tall tail [0,0,0,0,4,4,1,1,4,0], // Tail fin [0,0,0,0,1,1,1,1,1,0], // Rear fuselage [0,0,0,1,2,2,2,1,1,1], // Windows [0,0,3,3,3,3,3,3,1,1], // Wings [0,0,0,1,2,2,2,1,5,5], // Lower fuselage + engines [0,0,0,0,1,1,1,1,5,5], // Belly + engines [0,0,0,0,0,0,3,3,0,0] // Wing tip ], // Wide body (777, 787, A350) wideBody: [ [0,0,0,0,0,0,4,4,4,0,0], // Tall tail [0,0,0,0,0,4,4,1,1,4,0], // Tail fin [0,0,0,0,0,1,1,1,1,1,0], // Rear fuselage [0,0,0,0,1,2,2,2,1,1,1], // Upper windows [0,0,0,3,3,3,3,3,3,1,1], // Large wings [0,0,0,3,3,3,3,3,3,1,1], // Wing body [0,0,0,0,1,2,2,2,5,5,5], // Lower fuselage + big engines [0,0,0,0,0,1,1,1,5,5,5], // Belly + engines [0,0,0,0,0,0,0,3,3,0,0] // Wing tip ], // Heavy/jumbo (747, A380) heavy: [ [0,0,0,0,0,0,4,4,4,4,0,0], // Very tall tail [0,0,0,0,0,4,4,1,1,1,4,0], // Tail fin [0,0,0,0,2,2,1,1,1,1,1,0], // Upper deck with windows! [0,0,0,0,1,1,1,1,1,1,1,0], // Upper fuselage [0,0,0,1,2,2,2,2,1,1,1,1], // Main deck windows [0,0,3,3,3,3,3,3,3,1,1,1], // Massive wings [0,0,3,3,3,3,3,3,3,1,1,1], // Wing body [0,0,0,1,2,2,2,2,5,5,5,5], // Lower deck + huge engines [0,0,0,0,1,1,1,1,5,5,5,5], // Belly + engines [0,0,0,0,0,0,0,3,3,3,0,0] // Wing tips ] }; } processPlaneImage() { // Create an off-screen canvas to process the image const tempCanvas = document.createElement('canvas'); tempCanvas.width = this.planeImage.width; tempCanvas.height = this.planeImage.height; const tempCtx = tempCanvas.getContext('2d'); // Draw the original image tempCtx.drawImage(this.planeImage, 0, 0); // Get image data const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const data = imageData.data; const width = tempCanvas.width; const height = tempCanvas.height; // Flood fill from corners to mark background pixels const isBackground = new Uint8Array(width * height); const isWhiteish = (r, g, b) => r > 240 && g > 240 && b > 240; const floodFill = (startX, startY) => { const stack = [[startX, startY]]; while (stack.length > 0) { const [x, y] = stack.pop(); if (x < 0 || x >= width || y < 0 || y >= height) continue; const idx = y * width + x; if (isBackground[idx]) continue; const pixelIdx = idx * 4; const r = data[pixelIdx]; const g = data[pixelIdx + 1]; const b = data[pixelIdx + 2]; if (!isWhiteish(r, g, b)) continue; isBackground[idx] = 1; // Add neighbors stack.push([x + 1, y]); stack.push([x - 1, y]); stack.push([x, y + 1]); stack.push([x, y - 1]); } }; // Flood fill from all four corners floodFill(0, 0); floodFill(width - 1, 0); floodFill(0, height - 1); floodFill(width - 1, height - 1); // Make background pixels transparent for (let i = 0; i < isBackground.length; i++) { if (isBackground[i]) { data[i * 4 + 3] = 0; // Set alpha to 0 } } // Put the modified data back tempCtx.putImageData(imageData, 0, 0); // Create a new image from the processed canvas this.processedPlaneImage = new Image(); this.processedPlaneImage.src = tempCanvas.toDataURL(); this.processedPlaneImage.onload = () => { this.planeImageLoaded = true; console.log('Plane image processed and loaded'); }; } setupResponsiveCanvas() { const resizeCanvas = () => { const container = this.canvas.parentElement; const containerWidth = container.clientWidth; const containerHeight = container.clientHeight; // Maintain 4:3 aspect ratio (800x600) const aspectRatio = 4 / 3; let newWidth, newHeight; if (containerWidth / containerHeight > aspectRatio) { // Container is wider - fit to height newHeight = containerHeight; newWidth = newHeight * aspectRatio; } else { // Container is taller - fit to width newWidth = containerWidth; newHeight = newWidth / aspectRatio; } // Set canvas internal resolution (1200x900 for larger display) this.canvas.width = 1200; this.canvas.height = 900; // Update stored dimensions this.width = 1200; this.height = 900; // CSS will handle the visual scaling via CSS in HTML }; // Initial resize resizeCanvas(); // Handle window resize and orientation change window.addEventListener('resize', resizeCanvas); window.addEventListener('orientationchange', () => { setTimeout(resizeCanvas, 100); }); } async init() { // Fetch config (includes theme and location) await this.fetchConfig(); // Load background images based on theme this.loadBackgroundImages(); // Fetch weather await this.fetchWeather(); // Update weather every 10 minutes setInterval(() => this.fetchWeather(), 600000); // Update date/time display every second setInterval(() => this.updateWeatherDisplay(), 1000); // Setup view direction controls this.setupViewControls(); // Start rendering loop this.render(); setInterval(() => this.render(), 100); // 10 FPS for retro feel // Connect to WebSocket this.connectWebSocket(); } setupViewControls() { // Keyboard controls document.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') { this.rotateView(-90); } else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { this.rotateView(90); } }); // Click handlers for arrow buttons const leftArrow = document.getElementById('view-left'); const rightArrow = document.getElementById('view-right'); if (leftArrow) { leftArrow.addEventListener('click', () => this.rotateView(-90)); } if (rightArrow) { rightArrow.addEventListener('click', () => this.rotateView(90)); } // Mouse tracking for hover labels this.canvas.addEventListener('mousemove', (e) => { const rect = this.canvas.getBoundingClientRect(); // Convert mouse position to canvas coordinates const scaleX = this.canvas.width / rect.width; const scaleY = this.canvas.height / rect.height; this.mouseX = (e.clientX - rect.left) * scaleX; this.mouseY = (e.clientY - rect.top) * scaleY; }); this.canvas.addEventListener('mouseleave', () => { this.mouseX = -1; this.mouseY = -1; this.hoveredAircraft = null; }); // Click on canvas to deselect this.canvas.addEventListener('click', () => { this.selectedAircraftIcao = null; this.updateAircraftSidebar(); }); // Sidebar click handler (delegated) const sidebar = document.getElementById('aircraft-sidebar'); if (sidebar) { sidebar.addEventListener('click', (e) => { const aircraftEl = e.target.closest('.sidebar-aircraft'); if (aircraftEl) { const icao = aircraftEl.dataset.icao; // Toggle selection if (this.selectedAircraftIcao === icao) { this.selectedAircraftIcao = null; } else { this.selectedAircraftIcao = icao; } this.updateAircraftSidebar(); } }); } // Update initial compass display this.updateCompassDisplay(); } rotateView(degrees) { this.viewDirection = (this.viewDirection + degrees + 360) % 360; this.updateCompassDisplay(); console.log(`View direction: ${this.viewDirectionNames[this.viewDirection]} (${this.viewDirection}°)`); } updateCompassDisplay() { const compassEl = document.getElementById('compass-direction'); if (compassEl) { const dirName = this.viewDirectionNames[this.viewDirection]; const fullNames = { 'N': 'NORTH', 'E': 'EAST', 'S': 'SOUTH', 'W': 'WEST' }; compassEl.textContent = `VIEWING: ${fullNames[dirName]}`; } } // Check if a bearing falls within the current field of view isInFieldOfView(bearing) { const halfFov = this.fieldOfView / 2; const minAngle = (this.viewDirection - halfFov + 360) % 360; const maxAngle = (this.viewDirection + halfFov) % 360; // Handle wrap-around at 0/360 if (minAngle > maxAngle) { return bearing >= minAngle || bearing <= maxAngle; } else { return bearing >= minAngle && bearing <= maxAngle; } } // Get X position based on bearing within field of view bearingToX(bearing) { const halfFov = this.fieldOfView / 2; // Calculate angle difference from view direction let angleDiff = bearing - this.viewDirection; // Normalize to -180 to 180 if (angleDiff > 180) angleDiff -= 360; if (angleDiff < -180) angleDiff += 360; // Map -45 to +45 degrees to screen X (with padding) const padding = 60; const usableWidth = this.width - (padding * 2); // -45° = left edge, +45° = right edge const normalizedAngle = (angleDiff + halfFov) / this.fieldOfView; return padding + (normalizedAngle * usableWidth); } async fetchConfig() { try { const response = await fetch('/api/config'); const data = await response.json(); this.theme = data.theme || 'desert'; this.receiverLat = data.location?.lat || 0; this.receiverLon = data.location?.lon || 0; this.locationName = data.location?.name || 'My Location'; console.log(`Config loaded - Theme: ${this.theme}, Location: ${this.locationName} (${this.receiverLat}, ${this.receiverLon})`); } catch (error) { console.warn('Could not fetch config, using defaults'); // Fallback to receiver-location API for backwards compatibility await this.fetchReceiverLocation(); } } loadBackgroundImages() { const directions = [ { deg: 0, name: 'north' }, { deg: 90, name: 'east' }, { deg: 180, name: 'south' }, { deg: 270, name: 'west' } ]; directions.forEach(dir => { this.backgroundImages[dir.deg].onload = () => { this.backgroundImagesLoaded[dir.deg] = true; console.log(`${this.theme}/${dir.name}.png loaded`); }; this.backgroundImages[dir.deg].onerror = () => { console.warn(`Failed to load backgrounds/${this.theme}/${dir.name}.png`); }; this.backgroundImages[dir.deg].src = `backgrounds/${this.theme}/${dir.name}.png?v=1`; }); } async fetchReceiverLocation() { try { // Fetch from same server that serves pixel-view const response = await fetch('/api/receiver-location'); const data = await response.json(); this.receiverLat = data.lat; this.receiverLon = data.lon; this.locationName = data.name || 'My Location'; console.log(`Receiver location: ${this.locationName} (${this.receiverLat}, ${this.receiverLon})`); } catch (error) { console.warn('Could not fetch receiver location, using default'); } } async fetchWeather() { try { // Use Open-Meteo API (free, no API key needed) with daily forecast for sunrise/sunset const url = `https://api.open-meteo.com/v1/forecast?latitude=${this.receiverLat}&longitude=${this.receiverLon}¤t_weather=true&daily=sunrise,sunset&timezone=auto`; const response = await fetch(url); const data = await response.json(); const weatherCode = data.current_weather.weathercode; this.weather.temp = data.current_weather.temperature; // Get today's sunrise and sunset times if (data.daily && data.daily.sunrise && data.daily.sunset) { this.weather.sunrise = new Date(data.daily.sunrise[0]); this.weather.sunset = new Date(data.daily.sunset[0]); } // Map weather codes to conditions // 0 = clear, 1-3 = partly cloudy, 45-48 = fog, 51-67 = rain, 71-77 = snow, 80-99 = rain/thunderstorm if (weatherCode === 0) { this.weather.condition = 'clear'; this.weather.description = 'Clear'; } else if (weatherCode <= 3) { this.weather.condition = 'cloudy'; this.weather.description = 'Partly Cloudy'; } else if ((weatherCode >= 51 && weatherCode <= 67) || (weatherCode >= 80 && weatherCode <= 99)) { this.weather.condition = 'rain'; this.weather.description = 'Rainy'; } else if (weatherCode >= 71 && weatherCode <= 77) { this.weather.condition = 'snow'; this.weather.description = 'Snowy'; } else { this.weather.condition = 'cloudy'; this.weather.description = 'Cloudy'; } this.weather.lastUpdate = Date.now(); this.updateWeatherDisplay(); console.log(`Weather updated: ${this.weather.condition}, ${this.weather.temp}°C`); } catch (error) { console.warn('Could not fetch weather data', error); this.weather.condition = 'clear'; // Default to clear } } updateWeatherDisplay() { // Update date/time display const now = new Date(); const options = { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }; const dateTimeStr = now.toLocaleString('en-US', options); document.getElementById('datetime-display').textContent = `${this.locationName} - ${dateTimeStr}`; // Update weather display // Convert Celsius to Fahrenheit const tempF = Math.round((this.weather.temp * 9/5) + 32); document.getElementById('weather-display').textContent = `${tempF}°F - ${this.weather.description}`; // Update sunrise/sunset times if (this.weather.sunrise && this.weather.sunset) { const sunriseStr = this.weather.sunrise.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); const sunsetStr = this.weather.sunset.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); document.getElementById('sun-times').textContent = `☀ ${sunriseStr} / 🌙 ${sunsetStr}`; } } connectWebSocket() { // Dynamically build WebSocket URL based on current page const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = window.location.host; const wsUrl = `${protocol}//${host}/ws`; console.log(`Connecting to WebSocket: ${wsUrl}`); this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('WebSocket connected'); document.getElementById('connection-status').textContent = 'CONNECTED'; document.getElementById('connection-status').classList.remove('blink'); this.reconnectDelay = 1000; }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'flights') { this.updateFlights(data.flights); } }; this.ws.onclose = () => { console.log('WebSocket disconnected, reconnecting...'); document.getElementById('connection-status').textContent = 'RECONNECTING...'; document.getElementById('connection-status').classList.add('blink'); setTimeout(() => this.connectWebSocket(), this.reconnectDelay); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; } updateFlights(flights) { this.flights.clear(); flights.forEach(flight => { if (flight.lat && flight.lon && flight.altitude) { this.flights.set(flight.icao, flight); // Categorize aircraft type if not already done if (!this.aircraftTypes.has(flight.icao)) { this.categorizeAircraft(flight); } } }); // Aircraft count is now updated in drawAircraft() with visible/total format } categorizeAircraft(flight) { // Categorize aircraft based on available data // Priority: 1. Helicopter detection, 2. Callsign patterns, 3. Altitude/Speed heuristics let category = 'narrowBody'; // Default const callsign = (flight.callsign || '').trim(); const altitude = flight.altitude || 0; const speed = flight.speed || 0; // Helicopter detection with multiple criteria // Known helicopter callsign patterns (medical, news, police, tours, etc.) const helicopterCallsigns = ['LIFE', 'MED', 'STAR', 'AIR', 'CARE', 'MERCY', 'REACH', 'CHP', 'COPTER', 'HELO', 'N', 'PHI']; const isHelicopterCallsign = helicopterCallsigns.some(pattern => callsign.includes(pattern) || callsign.startsWith('N') && callsign.length <= 6 ); // Helicopter speed/altitude heuristics: // - Very low speed at any altitude (< 100 knots) // - Low altitude with moderate speed (< 3000 ft and < 180 knots) // - Known helicopter callsign with reasonable parameters (< 10000 ft and < 200 knots) if (isHelicopterCallsign && altitude < 10000 && speed < 200) { category = 'helicopter'; } else if (speed < 100 && altitude < 15000) { category = 'helicopter'; } else if (altitude < 3000 && speed < 180) { category = 'helicopter'; } // Heavy/jumbo aircraft callsigns (cargo and passenger) else if (callsign.length > 0) { const heavyCallsigns = ['CPA', 'UAE', 'ETH', 'QTR', 'SIA', 'KLM', 'AFL', 'BAW', 'AAL', 'DAL', 'UAL', 'FDX', 'UPS']; // Regional jet callsigns const regionalCallsigns = ['SKW', 'RPA', 'ASH', 'PDT', 'CHQ', 'ENY']; // Small prop patterns (typically GA aircraft with N-numbers or short callsigns) const isSmallProp = callsign.length <= 4 && (callsign.startsWith('N') || !callsign.match(/[0-9]/)); // Check callsign patterns const airline = callsign.substring(0, 3); if (heavyCallsigns.includes(airline) || altitude > 40000 || speed > 500) { // High altitude or speed suggests wide body or heavy if (altitude > 42000 || speed > 550) { category = 'heavy'; } else { category = 'wideBody'; } } else if (regionalCallsigns.includes(airline) || (altitude < 25000 && speed < 350)) { category = 'regionalJet'; } else if (isSmallProp || (altitude < 10000 && speed < 200)) { category = 'smallProp'; } else { // Default narrow body (737, A320 family) category = 'narrowBody'; } } this.aircraftTypes.set(flight.icao, category); } getAircraftSprite(icao) { const category = this.aircraftTypes.get(icao) || 'narrowBody'; return this.sprites[category] || this.sprites.narrowBody; } // Calculate distance in nautical miles calculateDistance(lat1, lon1, lat2, lon2) { const R = 3440.065; // Earth radius in nautical miles const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); return R * c; } // Calculate bearing from receiver to aircraft calculateBearing(lat1, lon1, lat2, lon2) { const dLon = (lon2 - lon1) * Math.PI / 180; const y = Math.sin(dLon) * Math.cos(lat2 * Math.PI / 180); const x = Math.cos(lat1 * Math.PI / 180) * Math.sin(lat2 * Math.PI / 180) - Math.sin(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.cos(dLon); const bearing = Math.atan2(y, x) * 180 / Math.PI; return (bearing + 360) % 360; } // Check if plane is flying towards or away from receiver isFlyingTowards(flight) { const bearingToReceiver = this.calculateBearing( flight.lat, flight.lon, this.receiverLat, this.receiverLon ); const headingDiff = Math.abs(flight.track - bearingToReceiver); const normalizedDiff = headingDiff > 180 ? 360 - headingDiff : headingDiff; return normalizedDiff < 90; // Less than 90 degrees = flying towards } render() { // Draw sky gradient this.drawSky(); // Draw clouds this.drawClouds(); // Draw sun this.drawSun(); // Draw moon this.drawMoon(); // Weather is represented by cloud sprites (rain clouds vs happy clouds) // No additional weather effects needed // Draw ground (directional background) - after celestial bodies so horizon covers low sun/moon this.drawGround(); // Draw grid lines for altitude reference this.drawGrid(); // Calculate auto-scaling const scale = this.calculateScale(); // Draw aircraft on top of everything this.drawAircraft(scale); // Draw scale indicators this.drawScaleIndicators(scale); // Draw compass indicators this.drawCompassIndicators(); } getSkyColors() { // Calculate sun position to determine sky colors const now = new Date(); if (!this.weather.sunrise || !this.weather.sunset) { // Default day colors if no sun data return { top: this.colors.skyTop, bottom: this.colors.skyBottom }; } const sunrise = this.weather.sunrise.getTime(); const sunset = this.weather.sunset.getTime(); const current = now.getTime(); // Dawn/Dusk transition period (30 minutes) const transitionTime = 30 * 60 * 1000; // Dawn colors (orange/pink sunrise) const dawnTop = '#4c5c8c'; const dawnBottom = '#dc8c5c'; // Day colors (bright blue) const dayTop = '#6ca4dc'; const dayBottom = '#b4d4ec'; // Dusk colors (orange/purple sunset) const duskTop = '#6c5c9c'; const duskBottom = '#dc9c6c'; // Night colors (dark blue/purple) const nightTop = '#1c2c4c'; const nightBottom = '#2c3c5c'; // Determine time of day and interpolate colors if (current < sunrise - transitionTime) { // Night (before dawn) return { top: nightTop, bottom: nightBottom }; } else if (current < sunrise) { // Dawn transition const progress = (current - (sunrise - transitionTime)) / transitionTime; return { top: this.interpolateColor(nightTop, dawnTop, progress), bottom: this.interpolateColor(nightBottom, dawnBottom, progress) }; } else if (current < sunrise + transitionTime) { // Sunrise to day transition const progress = (current - sunrise) / transitionTime; return { top: this.interpolateColor(dawnTop, dayTop, progress), bottom: this.interpolateColor(dawnBottom, dayBottom, progress) }; } else if (current < sunset - transitionTime) { // Full day return { top: dayTop, bottom: dayBottom }; } else if (current < sunset) { // Day to dusk transition const progress = (current - (sunset - transitionTime)) / transitionTime; return { top: this.interpolateColor(dayTop, duskTop, progress), bottom: this.interpolateColor(dayBottom, duskBottom, progress) }; } else if (current < sunset + transitionTime) { // Dusk to night transition const progress = (current - sunset) / transitionTime; return { top: this.interpolateColor(duskTop, nightTop, progress), bottom: this.interpolateColor(duskBottom, nightBottom, progress) }; } else { // Night (after dusk) return { top: nightTop, bottom: nightBottom }; } } interpolateColor(color1, color2, factor) { // Interpolate between two hex colors const c1 = parseInt(color1.slice(1), 16); const c2 = parseInt(color2.slice(1), 16); const r1 = (c1 >> 16) & 0xff; const g1 = (c1 >> 8) & 0xff; const b1 = c1 & 0xff; const r2 = (c2 >> 16) & 0xff; const g2 = (c2 >> 8) & 0xff; const b2 = c2 & 0xff; const r = Math.round(r1 + (r2 - r1) * factor); const g = Math.round(g1 + (g2 - g1) * factor); const b = Math.round(b1 + (b2 - b1) * factor); return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`; } drawSky() { // Sky gradient (SNES style) - adjust based on time of day and weather const gradient = this.ctx.createLinearGradient(0, 0, 0, this.height - 60); // Get base sky colors based on sun position let skyColors = this.getSkyColors(); let skyTop = skyColors.top; let skyBottom = skyColors.bottom; // Adjust for weather conditions (darken for rain/snow) if (this.weather.condition === 'rain' || this.weather.condition === 'snow') { // Darken the sky colors skyTop = this.interpolateColor(skyTop, '#4c5c6c', 0.4); skyBottom = this.interpolateColor(skyBottom, '#7c8c9c', 0.4); } else if (this.weather.condition === 'cloudy') { // Slightly darken for cloudy skyTop = this.interpolateColor(skyTop, '#5c6c7c', 0.2); skyBottom = this.interpolateColor(skyBottom, '#8c9cac', 0.2); } gradient.addColorStop(0, skyTop); gradient.addColorStop(1, skyBottom); this.ctx.fillStyle = gradient; this.ctx.fillRect(0, 0, this.width, this.height - 60); } drawClouds() { // Choose cloud image based on weather condition const isRainy = (this.weather.condition === 'rain' || this.weather.condition === 'snow'); const cloudImage = isRainy ? this.rainCloudImage : this.happyCloudImage; const cloudImageLoaded = isRainy ? this.rainCloudImageLoaded : this.happyCloudImageLoaded; // Use PNG images if loaded, otherwise fall back to programmatic rendering if (cloudImageLoaded && cloudImage.complete) { // Animate clouds moving across screen const time = Date.now() / 10000; // Slow cloud movement const numClouds = (this.weather.condition === 'cloudy' || isRainy) ? 8 : 4; // Calculate proper width/height maintaining aspect ratio (150% scale) const cloudHeight = 90; const aspectRatio = cloudImage.width / cloudImage.height; const cloudWidth = cloudHeight * aspectRatio; // Generate random but consistent offsets for each cloud using seeded positions for (let i = 0; i < numClouds; i++) { // Use cloud index as seed for consistent random positions const seed = i * 123.456; const xOffset = Math.sin(seed) * 200; // Random horizontal offset const yOffset = Math.cos(seed * 1.5) * 60; // Random vertical offset const speed = 15 + (Math.sin(seed * 2) * 5); // Varying cloud speeds const x = ((i * 180 + xOffset + time * speed) % (this.width + cloudWidth)) - cloudWidth; const y = 20 + yOffset + Math.abs(Math.sin(seed * 3) * 80); this.ctx.drawImage(cloudImage, x, y, cloudWidth, cloudHeight); } } else { // Fallback to programmatic cloud rendering const time = Date.now() / 10000; const pixelSize = 4; const numClouds = (this.weather.condition === 'cloudy' || isRainy) ? 10 : 5; for (let i = 0; i < numClouds; i++) { const x = ((i * 100 + time * 20) % (this.width + 100)) - 50; const y = 20 + (i % 5) * 15; // Darker clouds for rain/snow if (isRainy) { this.ctx.fillStyle = '#c4c4c4'; } else { this.ctx.fillStyle = this.colors.cloud; } // Cloud shape (simple) this.ctx.fillRect(x, y, pixelSize * 3, pixelSize); this.ctx.fillRect(x + pixelSize, y - pixelSize, pixelSize * 2, pixelSize); this.ctx.fillRect(x - pixelSize, y, pixelSize * 5, pixelSize); } } } calculateSunPosition() { // Calculate sun's position in the sky using astronomical formulas const now = new Date(); if (!this.weather.sunrise || !this.weather.sunset) { // Default position if no sun data return { altitude: 45, azimuth: 180 }; } const sunrise = this.weather.sunrise.getTime(); const sunset = this.weather.sunset.getTime(); const current = now.getTime(); // Sun is not visible at night if (current < sunrise || current > sunset) { return { altitude: -10, azimuth: 0 }; // Below horizon } // Calculate day progress (0 = sunrise, 0.5 = solar noon, 1 = sunset) const dayLength = sunset - sunrise; const dayProgress = (current - sunrise) / dayLength; // Sun's altitude: peaks at solar noon (around 0.5 day progress) // Max altitude varies by season based on receiver latitude // Simplified: use parabolic curve peaking at solar noon const maxAltitude = 90 - Math.abs(this.receiverLat); // Rough approximation const altitude = maxAltitude * Math.sin(dayProgress * Math.PI); // Sun's azimuth: rises in east (90°), sets in west (270°) // 90° = east, 180° = south, 270° = west const azimuth = 90 + (dayProgress * 180); // East to West return { altitude, azimuth }; } calculateMoonPhase() { // Calculate moon phase based on lunar cycle // Returns a value from 0 to 1 representing the moon phase // 0 = New Moon, 0.25 = First Quarter, 0.5 = Full Moon, 0.75 = Last Quarter const now = new Date(); // Known new moon: January 11, 2024 const knownNewMoon = new Date('2024-01-11T11:57:00Z').getTime(); const lunarCycle = 29.53059 * 24 * 60 * 60 * 1000; // Lunar cycle in milliseconds const timeSinceNewMoon = now.getTime() - knownNewMoon; const phase = (timeSinceNewMoon % lunarCycle) / lunarCycle; return phase; } calculateMoonPosition() { // Calculate moon's position in the sky const now = new Date(); if (!this.weather.sunrise || !this.weather.sunset) { return { altitude: 30, azimuth: 180 }; } const sunrise = this.weather.sunrise.getTime(); const sunset = this.weather.sunset.getTime(); const current = now.getTime(); // Moon is roughly 12 hours offset from sun // Simplified: moon is highest during night, low during day let moonProgress; if (current >= sunrise && current <= sunset) { // Daytime: moon may be visible, calculate based on day progress const dayLength = sunset - sunrise; const dayProg = (current - sunrise) / dayLength; moonProgress = dayProg + 0.5; // Offset from sun } else { // Nighttime: moon follows night cycle const midnight = new Date(now); midnight.setHours(0, 0, 0, 0); const nextMidnight = new Date(midnight.getTime() + 24 * 60 * 60 * 1000); if (current < sunrise) { // Before sunrise const nightLength = sunrise - midnight.getTime(); moonProgress = (current - midnight.getTime()) / nightLength * 0.5; } else { // After sunset const nightLength = nextMidnight.getTime() - sunset; moonProgress = 0.5 + ((current - sunset) / nightLength * 0.5); } } // Moon's altitude calculation (visible mostly at night) const maxAltitude = 70; const altitude = maxAltitude * Math.sin(moonProgress * Math.PI); // Moon's azimuth (opposite side from sun generally) const azimuth = 90 + (moonProgress * 180); return { altitude, azimuth }; } drawSun() { // Calculate sun position const sunPos = this.calculateSunPosition(); // Don't draw sun if it's below horizon or not clear weather if (sunPos.altitude < 0) { return; } // Only show sun when clear or partly cloudy if (this.weather.condition === 'rain' || this.weather.condition === 'snow') { return; } // Check if sun is in current field of view if (!this.isInFieldOfView(sunPos.azimuth)) { return; } // Map sun's altitude (0-90°) to Y position in sky // 0° = horizon (bottom of sky), 90° = zenith (top of sky) const skyHeight = this.height - 60; // Sky area height const horizonY = skyHeight; // Bottom of sky const zenithY = 20; // Top of sky // Convert altitude to Y position (inverted because Y=0 is top) const sunY = horizonY - (sunPos.altitude / 90) * (horizonY - zenithY); // Map azimuth to X position based on current view direction const sunX = this.bearingToX(sunPos.azimuth); // Use sun.png if loaded, otherwise fall back to programmatic rendering if (this.sunImageLoaded && this.sunImage.complete) { // Draw sun.png with opacity based on weather const alpha = this.weather.condition === 'cloudy' ? 0.6 : 1.0; this.ctx.globalAlpha = alpha; // Size the sun image preserving aspect ratio const sunHeight = 80; const sunAspectRatio = this.sunImage.width / this.sunImage.height; const sunWidth = sunHeight * sunAspectRatio; this.ctx.drawImage(this.sunImage, sunX - sunWidth / 2, sunY - sunHeight / 2, sunWidth, sunHeight); this.ctx.globalAlpha = 1.0; // Reset alpha } else { // Fallback to programmatic sun rendering const pixelSize = 5; const alpha = this.weather.condition === 'cloudy' ? 0.6 : 1.0; // Outer glow this.ctx.fillStyle = `rgba(252, 212, 68, ${alpha * 0.3})`; this.ctx.fillRect(sunX - pixelSize * 3, sunY - pixelSize * 3, pixelSize * 6, pixelSize * 6); // Inner glow this.ctx.fillStyle = `rgba(252, 212, 68, ${alpha * 0.6})`; this.ctx.fillRect(sunX - pixelSize * 2, sunY - pixelSize * 2, pixelSize * 4, pixelSize * 4); // Sun core this.ctx.fillStyle = this.weather.condition === 'cloudy' ? 'rgba(252, 212, 68, 0.8)' : this.colors.sun; this.ctx.fillRect(sunX - pixelSize, sunY - pixelSize, pixelSize * 2, pixelSize * 2); // Sun rays (8 directions) const rayLength = pixelSize * 2; this.ctx.fillStyle = this.weather.condition === 'cloudy' ? 'rgba(252, 212, 68, 0.7)' : this.colors.sun; // Horizontal and vertical rays this.ctx.fillRect(sunX - rayLength - pixelSize, sunY - pixelSize / 2, pixelSize, pixelSize); this.ctx.fillRect(sunX + rayLength, sunY - pixelSize / 2, pixelSize, pixelSize); this.ctx.fillRect(sunX - pixelSize / 2, sunY - rayLength - pixelSize, pixelSize, pixelSize); this.ctx.fillRect(sunX - pixelSize / 2, sunY + rayLength, pixelSize, pixelSize); // Diagonal rays this.ctx.fillRect(sunX - rayLength, sunY - rayLength, pixelSize, pixelSize); this.ctx.fillRect(sunX + rayLength - pixelSize, sunY - rayLength, pixelSize, pixelSize); this.ctx.fillRect(sunX - rayLength, sunY + rayLength - pixelSize, pixelSize, pixelSize); this.ctx.fillRect(sunX + rayLength - pixelSize, sunY + rayLength - pixelSize, pixelSize, pixelSize); } } getMoonSpritePosition(phase) { // Map moon phase (0-1) to sprite sheet position (2x3 grid) // Row 0: waxing crescent, first quarter, waxing gibbous // Row 1: full moon, waning gibbous, last quarter if (phase < 0.05 || phase > 0.95) { // New moon - don't display return null; } else if (phase >= 0.05 && phase < 0.20) { // Waxing crescent return { row: 0, col: 0 }; } else if (phase >= 0.20 && phase < 0.30) { // First quarter return { row: 0, col: 1 }; } else if (phase >= 0.30 && phase < 0.48) { // Waxing gibbous return { row: 0, col: 2 }; } else if (phase >= 0.48 && phase <= 0.52) { // Full moon return { row: 1, col: 0 }; } else if (phase > 0.52 && phase < 0.70) { // Waning gibbous return { row: 1, col: 1 }; } else if (phase >= 0.70 && phase < 0.80) { // Last quarter return { row: 1, col: 2 }; } else { // Waning crescent (0.80-0.95) - mirror waxing crescent return { row: 0, col: 0, mirror: true }; } } drawMoon() { // Calculate moon position and phase const moonPos = this.calculateMoonPosition(); const phase = this.calculateMoonPhase(); // Don't draw moon if it's below horizon if (moonPos.altitude < 0) { return; } // Moon is more visible at night, less during day const sunPos = this.calculateSunPosition(); const isNight = sunPos.altitude < 0; // Only show moon at night or if it's high enough during day if (!isNight && moonPos.altitude < 30) { return; } // Check if moon is in current field of view if (!this.isInFieldOfView(moonPos.azimuth)) { return; } // Map moon's altitude to Y position const skyHeight = this.height - 60; const horizonY = skyHeight; const zenithY = 20; const moonY = horizonY - (moonPos.altitude / 90) * (horizonY - zenithY); // Map azimuth to X position based on current view direction const moonX = this.bearingToX(moonPos.azimuth); // Use sprite sheet if loaded if (this.moonSpriteLoaded && this.moonSprite.complete) { const spritePos = this.getMoonSpritePosition(phase); // Don't draw during new moon if (!spritePos) { return; } // Calculate sprite sheet dimensions (2x3 grid) const spriteWidth = this.moonSprite.width / 3; // 3 columns const spriteHeight = this.moonSprite.height / 2; // 2 rows // Source rectangle (which part of sprite sheet to use) const sx = spritePos.col * spriteWidth; const sy = spritePos.row * spriteHeight; // Destination size (scaled for display, maintaining aspect ratio) const spriteAspectRatio = spriteWidth / spriteHeight; const targetHeight = 120; // 200% scale (was 60) const targetWidth = targetHeight * spriteAspectRatio; const destX = moonX - targetWidth / 2; const destY = moonY - targetHeight / 2; // Moon brightness (brighter at night) const alpha = isNight ? 1.0 : 0.6; this.ctx.globalAlpha = alpha; // Draw moon with optional horizontal flip for waning crescent if (spritePos.mirror) { this.ctx.save(); this.ctx.translate(moonX, moonY); this.ctx.scale(-1, 1); this.ctx.drawImage( this.moonSprite, sx, sy, spriteWidth, spriteHeight, -targetWidth / 2, -targetHeight / 2, targetWidth, targetHeight ); this.ctx.restore(); } else { this.ctx.drawImage( this.moonSprite, sx, sy, spriteWidth, spriteHeight, destX, destY, targetWidth, targetHeight ); } this.ctx.globalAlpha = 1.0; } } drawGround() { // Use directional background for current view direction const bgImage = this.backgroundImages[this.viewDirection]; const bgLoaded = this.backgroundImagesLoaded[this.viewDirection]; if (bgLoaded && bgImage && bgImage.complete) { const bgAspectRatio = bgImage.width / bgImage.height; const bgWidth = this.width; const bgHeight = this.width / bgAspectRatio; const bgY = this.height - bgHeight; const bgX = 0; this.ctx.drawImage(bgImage, bgX, bgY, bgWidth, bgHeight); } } drawGrid() { this.ctx.strokeStyle = this.colors.grid; this.ctx.lineWidth = 1; // Horizontal lines every 10,000 feet for (let alt = 0; alt <= 50000; alt += 10000) { const y = this.height - 60 - (alt / 50000) * (this.height - 100); this.ctx.beginPath(); this.ctx.moveTo(0, y); this.ctx.lineTo(this.width, y); this.ctx.stroke(); } } calculateScale() { let minLat = this.receiverLat; let maxLat = this.receiverLat; let maxAltitude = 0; this.flights.forEach(flight => { minLat = Math.min(minLat, flight.lat); maxLat = Math.max(maxLat, flight.lat); maxAltitude = Math.max(maxAltitude, flight.altitude); }); // Add more padding for better horizontal spacing // Ensure minimum range of 1.0 degree latitude (about 60 nautical miles) const latRange = Math.max((maxLat - minLat) * 2.5, 1.0); // Center the view around receiver with extra padding const latCenter = (minLat + maxLat) / 2; minLat = latCenter - latRange / 2; maxLat = latCenter + latRange / 2; maxAltitude = Math.max(maxAltitude * 1.2, 10000); // Minimum 10,000 feet return { latScale: (this.width - 100) / (maxLat - minLat), // Pixels per degree latitude altScale: (this.height - 100) / maxAltitude, // Pixels per foot minLat, maxLat, maxAltitude }; } drawMountains() { const sprite = this.sprites.mountain; const spriteHeight = sprite.length; const spriteWidth = sprite[0].length; const pixelSize = 4; const groundY = this.height - 60; const mountainY = groundY - (spriteHeight * pixelSize); // Connect to ground // Draw multiple mountains across the horizon for (let mountainX = -50; mountainX < this.width; mountainX += 120) { for (let y = 0; y < spriteHeight; y++) { for (let x = 0; x < spriteWidth; x++) { if (sprite[y][x] === 1 || sprite[y][x] === 2) { // Snow caps (value 2) if (sprite[y][x] === 2) { this.ctx.fillStyle = this.colors.mountainSnow; } else { this.ctx.fillStyle = this.colors.mountain; } this.ctx.fillRect( mountainX + x * pixelSize, mountainY + y * pixelSize, pixelSize, pixelSize ); } } } } } drawCacti() { const sprite = this.sprites.cactus; const spriteHeight = sprite.length; const spriteWidth = sprite[0].length; const pixelSize = 2; const groundY = this.height - 60; // Draw cacti at various positions along the ground const cactusPositions = [100, 250, 350, 550, 700]; cactusPositions.forEach(cactusX => { for (let y = 0; y < spriteHeight; y++) { for (let x = 0; x < spriteWidth; x++) { if (sprite[y][x] === 1) { // Randomize cactus shading slightly this.ctx.fillStyle = (x + y) % 3 === 0 ? this.colors.cactusDark : this.colors.cactus; this.ctx.fillRect( cactusX + x * pixelSize, groundY - spriteHeight * pixelSize + y * pixelSize, pixelSize, pixelSize ); } } } }); } drawWeather() { if (this.weather.condition === 'rain') { this.ctx.fillStyle = this.colors.rain; // Draw rain drops for (let i = 0; i < 100; i++) { const x = Math.random() * this.width; const y = (Math.random() * (this.height - 60)) + (Date.now() / 10) % this.height; this.ctx.fillRect(x, y % (this.height - 60), 1, 8); } } else if (this.weather.condition === 'snow') { this.ctx.fillStyle = this.colors.snow; // Draw snowflakes for (let i = 0; i < 50; i++) { const x = (Math.random() * this.width + Date.now() / 50) % this.width; const y = (Math.random() * (this.height - 60) + Date.now() / 30) % (this.height - 60); this.ctx.fillRect(x, y, 3, 3); } } } drawHouse(scale) { // Position antenna based on receiver latitude const antennaX = 50 + (this.receiverLat - scale.minLat) * scale.latScale; const antennaY = this.height - 60; const pixelSize = 2; const sprite = this.sprites.antenna; const spriteWidth = sprite[0].length; const spriteHeight = sprite.length; for (let y = 0; y < spriteHeight; y++) { for (let x = 0; x < spriteWidth; x++) { const pixel = sprite[y][x]; if (pixel > 0) { // Choose color based on pixel value if (pixel === 1) { // Mast - silver with subtle shading this.ctx.fillStyle = (x === 1) ? this.colors.antennaDark : this.colors.antenna; } else if (pixel === 2) { // White rings this.ctx.fillStyle = this.colors.antennaRing; } else if (pixel === 3) { // Dark base this.ctx.fillStyle = this.colors.antennaBase; } this.ctx.fillRect( antennaX - (spriteWidth * pixelSize / 2) + x * pixelSize, antennaY - spriteHeight * pixelSize + y * pixelSize, pixelSize, pixelSize ); } } } } getAircraftColor(pixelValue, isFlyingTowards) { // Map sprite pixel values to colors // 1 = fuselage, 2 = windows, 3 = wings, 4 = tail, 5 = engine switch(pixelValue) { case 1: // Fuselage - use direction-based color (white or green/orange) return isFlyingTowards ? this.colors.planeTowards : this.colors.planeAway; case 2: // Windows - light blue/cyan return '#54d4fc'; case 3: // Wings - gray return '#a0a0a0'; case 4: // Tail - red accent return '#fc5454'; case 5: // Engine - dark gray return '#606060'; default: return this.colors.plane; } } // Check if two rectangles overlap rectsOverlap(r1, r2) { return !(r1.right < r2.left || r1.left > r2.right || r1.bottom < r2.top || r1.top > r2.bottom); } // Find a non-overlapping position for a label findLabelPosition(x, baseY, labelWidth, labelHeight, placedLabels) { // Try different positions: below, above, left-below, right-below, further below const offsets = [ { dx: 0, dy: 0 }, // Default position { dx: 0, dy: -70 }, // Above aircraft { dx: 80, dy: 0 }, // Right { dx: -80, dy: 0 }, // Left { dx: 80, dy: -35 }, // Right-up { dx: -80, dy: -35 }, // Left-up { dx: 0, dy: 40 }, // Further below { dx: 100, dy: -70 }, // Far right-up { dx: -100, dy: -70 }, // Far left-up ]; for (const offset of offsets) { const testRect = { left: x - labelWidth / 2 + offset.dx, right: x + labelWidth / 2 + offset.dx, top: baseY + offset.dy, bottom: baseY + labelHeight + offset.dy }; // Check if this position overlaps with any placed label let hasOverlap = false; for (const placed of placedLabels) { if (this.rectsOverlap(testRect, placed)) { hasOverlap = true; break; } } if (!hasOverlap) { return { x: x + offset.dx, y: baseY + offset.dy, rect: testRect }; } } // If all positions overlap, return default (will overlap but at least shows) return { x: x, y: baseY, rect: { left: x - labelWidth / 2, right: x + labelWidth / 2, top: baseY, bottom: baseY + labelHeight } }; } drawAircraft(scale) { let maxRange = 0; let visibleCount = 0; const aircraftData = []; // Collect aircraft data for hover and sidebar // First pass: draw all aircraft and collect data this.flights.forEach(flight => { // Calculate bearing from receiver to aircraft const bearing = this.calculateBearing( this.receiverLat, this.receiverLon, flight.lat, flight.lon ); // Skip if not in current field of view if (!this.isInFieldOfView(bearing)) { return; } visibleCount++; // Calculate distance for range display and sprite scaling const distance = this.calculateDistance( this.receiverLat, this.receiverLon, flight.lat, flight.lon ); maxRange = Math.max(maxRange, distance); // Calculate distance-based scale factor // Close (0-10 NM) = large, Far (100+ NM) = small but still visible const minScale = 0.35; // Minimum 35% size at far distance const maxScale = 1.1; // Maximum 110% size when very close const nearDistance = 5; // Distance (NM) for max scale const farDistance = 80; // Distance (NM) for min scale let distanceScale; if (distance <= nearDistance) { distanceScale = maxScale; } else if (distance >= farDistance) { distanceScale = minScale; } else { // Smooth interpolation between near and far const t = (distance - nearDistance) / (farDistance - nearDistance); distanceScale = maxScale - (t * (maxScale - minScale)); } // X position based on bearing within field of view const x = this.bearingToX(bearing); // Y position: altitude const y = this.height - 60 - (flight.altitude * scale.altScale); // Skip if out of bounds if (x < 0 || x > this.width || y < 0 || y > this.height - 60) { return; } // Get aircraft category and image const category = this.aircraftTypes.get(flight.icao) || 'narrowBody'; const aircraftImage = this.aircraftImages[category]; const imageLoaded = this.aircraftImagesLoaded[category]; // Determine if flying left or right relative to viewer // Compare aircraft track to view direction // If track is to the left of view direction, aircraft appears to fly left let trackRelative = flight.track - this.viewDirection; if (trackRelative > 180) trackRelative -= 360; if (trackRelative < -180) trackRelative += 360; const isFacingLeft = trackRelative < 0 || trackRelative > 180; // Determine flight direction for color coding const isFlyingTowards = this.isFlyingTowards(flight); this.ctx.save(); let spriteHeight = 110; // Default for PNG // Use PNG image if loaded, otherwise fall back to sprite array if (imageLoaded && aircraftImage) { // Apply distance-based scaling to base height const baseHeight = 110; const targetHeight = baseHeight * distanceScale; const imageScale = targetHeight / aircraftImage.height; const scaledWidth = aircraftImage.width * imageScale; const scaledHeight = aircraftImage.height * imageScale; spriteHeight = scaledHeight; this.ctx.globalAlpha = 1.0; this.ctx.filter = 'none'; if (isFacingLeft) { this.ctx.translate(x, y); this.ctx.scale(-1, 1); this.ctx.drawImage(aircraftImage, -scaledWidth / 2, -scaledHeight / 2, scaledWidth, scaledHeight); } else { this.ctx.drawImage(aircraftImage, x - scaledWidth / 2, y - scaledHeight / 2, scaledWidth, scaledHeight); } } else { // Fallback to sprite array rendering with distance scaling const sprite = this.getAircraftSprite(flight.icao); const spriteWidth = sprite[0].length; const spriteH = sprite.length; const basePixelSize = 2; const pixelSize = basePixelSize * distanceScale; spriteHeight = spriteH * pixelSize; for (let row = 0; row < spriteH; row++) { for (let col = 0; col < spriteWidth; col++) { const pixelValue = sprite[row][col]; if (pixelValue > 0) { this.ctx.fillStyle = this.getAircraftColor(pixelValue, isFlyingTowards); let drawX, drawY; if (isFacingLeft) { drawX = x + (spriteWidth - 1 - col) * pixelSize - (spriteWidth * pixelSize / 2); drawY = y + row * pixelSize - (spriteH * pixelSize / 2); } else { drawX = x + col * pixelSize - (spriteWidth * pixelSize / 2); drawY = y + row * pixelSize - (spriteH * pixelSize / 2); } this.ctx.fillRect(drawX, drawY, pixelSize, pixelSize); } } } } this.ctx.restore(); // Calculate sprite width for hover detection let spriteWidth = spriteHeight * 1.5; // Approximate aspect ratio // Collect data for hover detection and sidebar aircraftData.push({ x, y, spriteWidth, spriteHeight, callsign: flight.callsign || flight.icao, icao: flight.icao, altitude: flight.altitude, distance: distance, speed: flight.speed || 0 }); }); // Check for hover and draw label for hovered aircraft this.hoveredAircraft = null; for (const aircraft of aircraftData) { // Check if mouse is over this aircraft const halfW = aircraft.spriteWidth / 2; const halfH = aircraft.spriteHeight / 2; if (this.mouseX >= aircraft.x - halfW && this.mouseX <= aircraft.x + halfW && this.mouseY >= aircraft.y - halfH && this.mouseY <= aircraft.y + halfH) { this.hoveredAircraft = aircraft; break; } } // Find selected aircraft (from sidebar click) let selectedAircraft = null; if (this.selectedAircraftIcao) { selectedAircraft = aircraftData.find(a => a.icao === this.selectedAircraftIcao); } // Draw highlight box around selected aircraft if (selectedAircraft) { const aircraft = selectedAircraft; const padding = 15; const halfW = aircraft.spriteWidth / 2 + padding; const halfH = aircraft.spriteHeight / 2 + padding; // Animated pulsing border const pulse = Math.sin(Date.now() / 200) * 0.3 + 0.7; // Draw glowing box around aircraft this.ctx.strokeStyle = `rgba(252, 212, 68, ${pulse})`; this.ctx.lineWidth = 4; this.ctx.strokeRect(aircraft.x - halfW, aircraft.y - halfH, halfW * 2, halfH * 2); // Draw corner brackets for extra visibility const bracketSize = 15; this.ctx.strokeStyle = '#fcd444'; this.ctx.lineWidth = 3; // Top-left this.ctx.beginPath(); this.ctx.moveTo(aircraft.x - halfW, aircraft.y - halfH + bracketSize); this.ctx.lineTo(aircraft.x - halfW, aircraft.y - halfH); this.ctx.lineTo(aircraft.x - halfW + bracketSize, aircraft.y - halfH); this.ctx.stroke(); // Top-right this.ctx.beginPath(); this.ctx.moveTo(aircraft.x + halfW - bracketSize, aircraft.y - halfH); this.ctx.lineTo(aircraft.x + halfW, aircraft.y - halfH); this.ctx.lineTo(aircraft.x + halfW, aircraft.y - halfH + bracketSize); this.ctx.stroke(); // Bottom-left this.ctx.beginPath(); this.ctx.moveTo(aircraft.x - halfW, aircraft.y + halfH - bracketSize); this.ctx.lineTo(aircraft.x - halfW, aircraft.y + halfH); this.ctx.lineTo(aircraft.x - halfW + bracketSize, aircraft.y + halfH); this.ctx.stroke(); // Bottom-right this.ctx.beginPath(); this.ctx.moveTo(aircraft.x + halfW - bracketSize, aircraft.y + halfH); this.ctx.lineTo(aircraft.x + halfW, aircraft.y + halfH); this.ctx.lineTo(aircraft.x + halfW, aircraft.y + halfH - bracketSize); this.ctx.stroke(); } // Draw label for hovered or selected aircraft const labelAircraft = this.hoveredAircraft || selectedAircraft; if (labelAircraft) { const aircraft = labelAircraft; this.ctx.font = 'bold 14px "Press Start 2P", "Pixelify Sans", monospace'; this.ctx.textAlign = 'center'; const callsign = aircraft.callsign; const altText = `FL${Math.round(aircraft.altitude / 100)}`; const distText = `${Math.round(aircraft.distance)} NM`; // Draw label above the aircraft const labelY = aircraft.y - aircraft.spriteHeight / 2 - 10; // Draw background box for label const labelWidth = Math.max(callsign.length, altText.length, distText.length) * 12 + 16; const labelHeight = 52; this.ctx.fillStyle = 'rgba(0, 0, 0, 0.8)'; this.ctx.fillRect(aircraft.x - labelWidth / 2, labelY - labelHeight, labelWidth, labelHeight); this.ctx.strokeStyle = selectedAircraft && !this.hoveredAircraft ? '#fcd444' : '#5c94fc'; this.ctx.lineWidth = 2; this.ctx.strokeRect(aircraft.x - labelWidth / 2, labelY - labelHeight, labelWidth, labelHeight); // Draw text this.drawTextWithShadow(callsign, aircraft.x, labelY - 34); this.drawTextWithShadow(altText, aircraft.x, labelY - 20); this.drawTextWithShadow(distText, aircraft.x, labelY - 6); } // Update visible aircraft list for sidebar (sorted by distance) this.visibleAircraftList = aircraftData.sort((a, b) => a.distance - b.distance); // Throttle DOM updates to reduce flashing (update every 500ms) const now = Date.now(); if (now - this.lastSidebarUpdate > 500) { this.updateAircraftSidebar(); this.lastSidebarUpdate = now; } // Only update stats if values changed (prevents flashing) const rangeText = `RANGE: ${Math.round(maxRange)} NM`; const countText = `AIRCRAFT: ${visibleCount}/${this.flights.size}`; if (this.cachedRangeText !== rangeText) { this.cachedRangeText = rangeText; const rangeEl = document.getElementById('range-display'); if (rangeEl) rangeEl.textContent = rangeText; } if (this.cachedCountText !== countText) { this.cachedCountText = countText; const countEl = document.getElementById('aircraft-count'); if (countEl) countEl.textContent = countText; } } updateAircraftSidebar() { const sidebar = document.getElementById('aircraft-sidebar'); if (!sidebar) return; // Get or create the list container (preserving the header) let listContainer = sidebar.querySelector('.sidebar-list'); if (!listContainer) { listContainer = document.createElement('div'); listContainer.className = 'sidebar-list'; sidebar.appendChild(listContainer); } if (this.visibleAircraftList.length === 0) { listContainer.innerHTML = ''; return; } let html = ''; for (const aircraft of this.visibleAircraftList) { const altFL = Math.round(aircraft.altitude / 100); const dist = Math.round(aircraft.distance); const isSelected = aircraft.icao === this.selectedAircraftIcao; const selectedClass = isSelected ? ' selected' : ''; html += ``; } // Only update DOM if content changed (prevents flashing) if (listContainer.innerHTML !== html) { listContainer.innerHTML = html; } } drawScaleIndicators(scale) { this.ctx.font = 'bold 11px "Courier New"'; // Altitude scale (left side) this.ctx.textAlign = 'left'; for (let alt = 0; alt <= scale.maxAltitude; alt += 10000) { const y = this.height - 60 - (alt * scale.altScale); if (y > 10 && y < this.height - 70) { this.drawTextWithShadow(`${Math.round(alt / 1000)}K`, 5, y + 3); } } } drawCompassIndicators() { this.ctx.font = 'bold 14px "Press Start 2P", "Pixelify Sans", monospace'; this.ctx.textAlign = 'center'; // Calculate left and right edge directions based on current view const leftDir = (this.viewDirection - 45 + 360) % 360; const rightDir = (this.viewDirection + 45) % 360; const centerDir = this.viewDirection; // Direction labels const dirLabels = { 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' }; const getDir = (deg) => dirLabels[deg] || dirLabels[Math.round(deg / 45) * 45 % 360] || ''; // Left edge direction this.drawTextWithShadow(getDir(leftDir), 35, this.height - 35); // Right edge direction this.drawTextWithShadow(getDir(rightDir), this.width - 35, this.height - 35); // Current view direction at top center const fullNames = { 0: 'NORTH', 90: 'EAST', 180: 'SOUTH', 270: 'WEST' }; this.ctx.font = 'bold 16px "Press Start 2P", "Pixelify Sans", monospace'; this.drawTextWithShadow(`◄ ${fullNames[centerDir]} ►`, this.width / 2, 25); // Location label at bottom center this.ctx.font = 'bold 12px "Press Start 2P", "Pixelify Sans", monospace'; this.drawTextWithShadow(this.locationName, this.width / 2, this.height - 10); } drawTextWithShadow(text, x, y) { // Shadow this.ctx.fillStyle = this.colors.textShadow; this.ctx.fillText(text, x + 1, y + 1); // Main text this.ctx.fillStyle = this.colors.text; this.ctx.fillText(text, x, y); } } // Initialize the app const app = new PixelADSB();