Retro SNES-style side-view ADS-B aircraft tracker with pixel art sprites, animated celestial bodies, weather visualization, and directional views. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1898 lines
74 KiB
JavaScript
1898 lines
74 KiB
JavaScript
// 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
|
|
|
|
// 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 = `${type}.png?v=24`;
|
|
});
|
|
|
|
// Load environment images (directional backgrounds, base, sun, clouds)
|
|
// Directional background images
|
|
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 };
|
|
|
|
// Load directional backgrounds (fallback to desert.png if not available)
|
|
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(`${dir.name}.png loaded`);
|
|
};
|
|
this.backgroundImages[dir.deg].onerror = () => {
|
|
console.warn(`Failed to load ${dir.name}.png, using fallback`);
|
|
};
|
|
this.backgroundImages[dir.deg].src = `${dir.name}.png?v=1`;
|
|
});
|
|
|
|
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 = '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 = '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 = '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 = '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 receiver location
|
|
await this.fetchReceiverLocation();
|
|
|
|
// 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 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 = '<div class="sidebar-empty">No aircraft in view</div>';
|
|
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 += `<div class="sidebar-aircraft${selectedClass}" data-icao="${aircraft.icao}">
|
|
<span class="sidebar-callsign">${aircraft.callsign}</span>
|
|
<span class="sidebar-info">FL${altFL} · ${dist}NM</span>
|
|
</div>`;
|
|
}
|
|
|
|
// 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();
|