commit 0c9de30d41aeec3fb8841fc7cbcdea457524b330 Author: root Date: Tue Jan 20 11:10:25 2026 -0800 Initial commit: Pixel-ADSB flight tracker 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c39e81f --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Claude Code +.claude/ + +# OS +.DS_Store +Thumbs.db + +# Local config (uncomment if you want to ignore local changes) +# config.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ee350a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Pixel View is a retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites. It's a standalone sub-project within the larger IceNet-ADS-B system. + +## Running the Server + +```bash +# Start the server (port configured in config.json, default 2001) +python3 server.py + +# Or use the startup script +./start.sh +``` + +Access at http://localhost:{web_port} (configured in config.json) + +## Architecture + +### Components + +- **server.py** - Python WebSocket server using aiohttp + - Reads configuration from config.json + - Auto-scans for ADS-B receivers or connects to configured IPs + - Parses SBS/BaseStation format messages from receivers + - Broadcasts flight data to connected WebSocket clients every 1 second + - Cleans up flights not seen in 60 seconds + - Serves static files and receiver location API + +- **config.json** - Configuration file for receiver and location settings + - See CONFIG.md for full documentation + +- **pixel-view.js** - JavaScript rendering engine (Canvas-based) + - Handles WebSocket connection and flight data updates + - Renders 10 FPS retro-style canvas animation + - Layer order (bottom to top): sky gradient → clouds → sun → moon → directional background → grid → aircraft → labels + - Directional backgrounds include horizon, so low sun/moon is realistically occluded + - Aircraft sprites flip horizontally when heading west (track 90°-270°) + - View direction rotates between N/E/S/W (arrow keys or A/D), changing the background + +- **index.html** - Main HTML interface with embedded styles + +### Sprite Assets + +All PNG sprites face right (eastward) and are flipped in-canvas for westbound aircraft: +- 6 aircraft types: smallProp, regionalJet, narrowBody, wideBody, heavy, helicopter +- Directional backgrounds: north.png, south.png, east.png, west.png (1536x1024, shown based on view direction) +- Celestial: sun.png, moon_6_phases.png (2x3 sprite sheet) +- Weather: happycloud.png (clear), raincloud.png (rain/snow) + +### Aircraft Type Detection Logic + +Priority order in categorization: +1. **Helicopter**: altitude < 5000 ft AND speed < 150 knots +2. **Heavy (747/A380)**: specific callsigns OR altitude > 42000 ft OR speed > 550 knots +3. **Wide Body**: altitude > 40000 ft OR speed > 500 knots +4. **Regional Jet**: specific callsigns OR (altitude < 25000 ft AND speed < 350 knots) +5. **Small Prop**: N-prefix callsigns OR (altitude < 10000 ft AND speed < 200 knots) +6. **Narrow Body**: default for remaining aircraft + +### View Direction Controls + +The viewer can rotate between cardinal directions (N/E/S/W), showing aircraft in a 90° field of view: +- **Keyboard**: Left/Right arrow keys or A/D keys +- **UI**: Arrow buttons on the interface +- Each direction displays a unique background image (north.png, east.png, south.png, west.png) +- Sun and moon positions are calculated based on actual azimuth and only appear when in the current field of view + +### External APIs + +- **Open-Meteo**: Weather and sunrise/sunset data (updates every 10 minutes) +- **ADS-B Receivers**: SBS/BaseStation protocol on port 30003 + +## Dependencies + +Python packages required: +- aiohttp (web server and WebSocket) +- netifaces (network interface scanning) + +No package.json - frontend is vanilla JavaScript with no build step. + +## Key Code Patterns + +- Canvas version parameter on assets (`?v=36`) for cache busting +- Aircraft direction: `const isFacingLeft = flight.track > 90 && flight.track < 270` +- Moon phases use sprite sheet cropping with 2x3 grid (3 columns, 2 rows) +- Flight data stored in global `flights` dict keyed by ICAO hex code + +## Debugging + +```bash +# Check for running server instances +ps aux | grep server.py + +# Kill all instances +pkill -9 -f server.py + +# Test receiver connectivity (replace with your receiver IP) +nc -zv 30003 +``` diff --git a/CONFIG.md b/CONFIG.md new file mode 100644 index 0000000..3d021fb --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,190 @@ +# Pixel View Configuration Guide + +This guide explains how to configure Pixel View for your ADS-B receiver setup. + +## Configuration File + +Edit `config.json` to customize your installation: + +```json +{ + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 0.0, + "lon": 0.0 + }, + "web_port": 2001 +} +``` + +**Important:** You must set your `location.lat` and `location.lon` to your actual receiver coordinates for weather data and aircraft positioning to work correctly. + +### Configuration Options + +| Option | Type | Description | +|--------|------|-------------| +| `receivers` | string or array | `"AUTO"` to scan network, or IP address(es) of ADS-B receiver(s) | +| `receiver_port` | number | Port for SBS/BaseStation data (default: 30003) | +| `location.name` | string | Display name shown on screen (e.g., "Seattle, WA") | +| `location.lat` | number | Latitude of your receiver location | +| `location.lon` | number | Longitude of your receiver location | +| `web_port` | number | Port for the web interface (default: 2001) | + +### Receiver Configuration Examples + +**Auto-scan network (default):** +```json +"receivers": "AUTO" +``` + +**Single receiver:** +```json +"receivers": "192.168.1.100" +``` + +**Multiple receivers:** +```json +"receivers": ["192.168.1.100", "192.168.1.101"] +``` + +--- + +## Background Images + +Pixel View uses directional background images to show the horizon view from your receiver location. You should customize these to match your actual surroundings. + +### Image Files + +| File | Direction | Description | +|------|-----------|-------------| +| `north.png` | North (0°) | View looking north from your location | +| `east.png` | East (90°) | View looking east from your location | +| `south.png` | South (180°) | View looking south from your location | +| `west.png` | West (270°) | View looking west from your location | + +### Image Requirements + +- **Resolution:** 1536 x 1024 pixels (recommended) +- **Format:** PNG with transparency support +- **Aspect ratio:** 3:2 (width:height) +- **Style:** Pixel art style for best visual consistency + +### Image Composition + +Each background image should include: + +1. **Sky area** (top ~60%): Should be transparent or very light to blend with the dynamic sky gradient +2. **Horizon line**: Where the sky meets the ground/landscape +3. **Ground/landscape** (bottom ~40%): Your local terrain features + +``` +┌─────────────────────────────┐ +│ │ +│ Transparent/Sky │ ← Dynamic sky renders here +│ (alpha = 0 or light) │ +│ │ +├─────────────────────────────┤ ← Horizon line +│ │ +│ Ground/Landscape │ ← Your local scenery +│ (mountains, buildings, │ +│ trees, desert, etc.) │ +│ │ +└─────────────────────────────┘ +``` + +### Creating Custom Backgrounds + +**Option 1: Pixel Art (Recommended)** +- Use a pixel art editor (Aseprite, Piskel, GIMP) +- Create at 384x256 or 768x512, then scale up 4x or 2x +- Keep colors limited for retro aesthetic +- Use the existing backgrounds as templates + +**Option 2: Photo-based** +- Take photos looking N/E/S/W from your receiver location +- Apply a pixel art filter or posterize effect +- Reduce to limited color palette +- Resize to 1536x1024 + +**Option 3: Simplified Silhouettes** +- Create simple horizon silhouettes of local landmarks +- Mountains, buildings, trees as flat shapes +- Works well with limited artistic skills + +### Tips for Good Backgrounds + +1. **Consistency**: Use the same color palette across all 4 directions +2. **Horizon height**: Keep the horizon at roughly the same vertical position +3. **Landmarks**: Include recognizable local features (mountains, towers, etc.) +4. **Weather**: The sky portion should be transparent so the dynamic weather shows through +5. **Testing**: View each direction in the app to ensure smooth rotation + +### Example Color Palettes + +**Desert/Southwest:** +``` +Ground tones: #d4a868, #b8884c, #a87840 +Rock/mountain: #8c7c68, #6c5c4c +Vegetation: #54a844, #3c7c30 +``` + +**Forest/Pacific Northwest:** +``` +Ground tones: #3c5c3c, #4c6c4c, #2c4c2c +Trees: #2c5c2c, #1c4c1c, #3c6c3c +Mountains: #5c6c7c, #7c8c9c, #fcfcfc (snow) +``` + +**Urban/City:** +``` +Buildings: #4c5c6c, #5c6c7c, #6c7c8c +Windows: #fcd444, #fcfc9c +Ground: #3c3c3c, #4c4c4c +``` + +**Coastal:** +``` +Sand: #e4d4a8, #d4c498 +Water: #5c94fc, #4c84ec +Cliffs: #8c7c68, #9c8c78 +``` + +Sky should always be transparent (#00000000) to allow the dynamic sky gradient to show through. + +--- + +## Other Sprite Assets + +These are optional to customize: + +| File | Size | Description | +|------|------|-------------| +| `sun.png` | 64x64 | Sun sprite | +| `moon_6_phases.png` | 192x128 | Moon phases (3x2 grid) | +| `happycloud.png` | 96x64 | Clear weather cloud | +| `raincloud.png` | 96x64 | Rain/storm cloud | + +### Aircraft Sprites + +| File | Description | +|------|-------------| +| `smallProp.png` | Small propeller aircraft (Cessna) | +| `regionalJet.png` | Regional jets (CRJ, ERJ) | +| `narrowBody.png` | Narrow body jets (737, A320) | +| `wideBody.png` | Wide body jets (777, 787) | +| `heavy.png` | Heavy/jumbo jets (747, A380) | +| `helicopter.png` | Helicopters | + +All aircraft sprites should face **right (east)** - the code flips them automatically for westbound flights. + +--- + +## Quick Start Checklist + +1. [ ] Edit `config.json` with your receiver IP (or leave as AUTO) +2. [ ] Set your location name, latitude, and longitude +3. [ ] Replace `north.png`, `east.png`, `south.png`, `west.png` with your local views +4. [ ] Start the server: `python3 server.py` +5. [ ] Open browser to `http://your-server-ip:2001` diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md new file mode 100644 index 0000000..7e3b9ab --- /dev/null +++ b/PROJECT_DOCUMENTATION.md @@ -0,0 +1,434 @@ +# Pixel ADS-B View - Project Documentation + +## Overview +A retro SNES-style side-view flight tracker that displays ADS-B aircraft data in a fun pixel art interface with custom ChatGPT-generated sprites for aircraft, scenery, celestial bodies, and weather. + +## Project Structure +``` +/root/IceNet-ADS-B/pixel-view/ +├── index.html - Main HTML interface (v=36) +├── pixel-view.js - JavaScript rendering engine +├── server.py - Python WebSocket server +├── smallProp.png - Small propeller aircraft sprite +├── regionalJet.png - Regional jet sprite +├── narrowBody.png - Narrow body jet sprite (737/A320) +├── wideBody.png - Wide body jet sprite (777/787) +├── heavy.png - Jumbo jet sprite (747/A380) +├── helicopter.png - Helicopter sprite +├── desert.png - Vegas desert landscape background +├── base.png - ADS-B receiver station building with antenna +├── sun.png - Animated sun sprite +├── moon_6_phases.png - Moon sprite sheet (2x3 grid, 6 phases) +├── happycloud.png - Sunny weather cloud sprite +├── raincloud.png - Rainy/snowy weather cloud sprite +└── PROJECT_DOCUMENTATION.md - This file +``` + +## Current Configuration + +### Display Settings +- **Canvas Resolution**: 800x600 pixels +- **Container Max Width**: 1400px +- **Aircraft Sprite Size**: 110px tall (scaled from original images) +- **Moon Size**: 120px tall (200% scale) +- **Base Station**: 150px height +- **Cloud Height**: 90px (150% scale) +- **Sun Size**: 50px tall +- **Cache Version**: v=36 +- **Frame Rate**: 10 FPS (retro feel) + +### Visual Assets +All sprites are **COMPLETE** and integrated: + +**Aircraft (6 types):** +- ✅ **smallProp.png** (193K) - Right-facing, transparent background +- ✅ **regionalJet.png** (185K) - Right-facing, transparent background +- ✅ **narrowBody.png** (193K) - Right-facing, transparent background +- ✅ **wideBody.png** (264K) - Right-facing, transparent background +- ✅ **heavy.png** (361K) - Right-facing, transparent background +- ✅ **helicopter.png** (216K) - Right-facing, transparent background + +**Scenery:** +- ✅ **desert.png** (371K) - Vegas desert landscape, full-width background +- ✅ **base.png** - ADS-B receiver station with antenna tower, transparent background + +**Celestial:** +- ✅ **sun.png** - Animated sun sprite +- ✅ **moon_6_phases.png** (1.4M) - Sprite sheet with 6 moon phases + +**Weather:** +- ✅ **happycloud.png** - Clear weather clouds +- ✅ **raincloud.png** - Rain/snow weather clouds + +## Features Implemented + +### 1. Custom Pixel Art Aircraft Rendering +- **PNG image-based rendering** with fallback to sprite arrays +- Images loaded asynchronously via JavaScript Image objects +- Scaled dynamically to 110px height while maintaining aspect ratio +- **Horizontal flipping** for westbound aircraft (track > 90° and < 270°) +- Visual feedback: + - **Approaching**: Full brightness, saturated colors + - **Departing**: 85% opacity, desaturated +- Located in: `pixel-view.js:1347-1395` + +### 2. Aircraft Type Categorization +Six aircraft categories with intelligent detection: + +1. **Helicopter** + - Detection: altitude < 5000 ft AND speed < 150 knots + - Priority: Checked first before other types + +2. **Heavy** (747, A380) + - Callsigns: CPA, UAE, ETH, QTR, SIA, etc. + - OR: altitude > 42000 ft OR speed > 550 knots + - Features: Upper deck hump visible in sprite + +3. **Wide Body** (777, 787, A350) + - High altitude: > 40000 ft OR speed > 500 knots + - Larger twin-engine design + +4. **Narrow Body** (737, A320) + - Default category for most commercial aircraft + - Typical cruise altitudes and speeds + +5. **Regional Jet** (CRJ, ERJ) + - Callsigns: SKW, RPA, ASH, PDT, CHQ, ENY + - OR: altitude < 25000 ft AND speed < 350 knots + +6. **Small Prop** (Cessna, GA) + - Callsigns starting with 'N' or very short + - OR: altitude < 10000 ft AND speed < 200 knots + +### 3. Vegas Desert Landscape Background +- **Full-width background image** (desert.png) +- Cropped Vegas desert scene with mountains +- Scales to canvas width while maintaining aspect ratio +- Transparent background removed for clean integration +- Positioned at bottom of display +- Located in: `pixel-view.js:1138-1147` + +### 4. ADS-B Receiver Station +- **Base.png sprite** overlaid on desert background +- Shows receiver building with antenna tower +- 150px height, maintains aspect ratio +- Centered horizontally at screen bottom +- Transparent background allows desert to show through +- Located in: `pixel-view.js:1149-1161` + +### 5. Dynamic Sky Colors (Based on Sun Position) +Sky colors change throughout the day based on actual sunrise/sunset times: +- **Night**: Dark blue/purple (#1c2c4c → #2c3c5c) +- **Dawn**: Orange/pink sunrise (#4c5c8c → #dc8c5c) +- **Day**: Bright blue (#6ca4dc → #b4d4ec) +- **Dusk**: Orange/purple sunset (#6c5c9c → #dc9c6c) +- 30-minute transition periods for smooth changes +- Weather adjustments (darker for rain/snow) +- Located in: `pixel-view.js:678-795` + +### 6. Accurate Sun Positioning & Sprite +**Sun:** +- PNG sprite rendering (sun.png) +- Astronomical calculations for altitude (0-90°) and azimuth (90-270°) +- Moves from east (sunrise) to west (sunset) +- 50px height display size +- Dims when cloudy, hidden during rain/snow +- Located in: `pixel-view.js:940-1027` + +### 7. Realistic Moon Phases +**Moon Sprite System:** +- **6-phase sprite sheet** (moon_6_phases.png) +- 2x3 grid layout: + - Row 1: Waxing Crescent, First Quarter, Waxing Gibbous + - Row 2: Full Moon, Waning Gibbous, Last Quarter +- Waning crescent uses mirrored waxing crescent +- **Phase calculation** based on 29.53-day lunar cycle +- Scaled to 120px height (200% of original design) +- Maintains aspect ratio automatically +- Brighter at night (100% alpha), dimmer during day (60% alpha) +- Opposite positioning from sun (12-hour offset) +- New moon phase not displayed +- Located in: `pixel-view.js:876-938`, `pixel-view.js:1029-1138` + +**Moon Phases:** +- Phase 0.00-0.05: New Moon (not displayed) +- Phase 0.05-0.20: Waxing Crescent +- Phase 0.20-0.30: First Quarter +- Phase 0.30-0.48: Waxing Gibbous +- Phase 0.48-0.52: Full Moon +- Phase 0.52-0.70: Waning Gibbous +- Phase 0.70-0.80: Last Quarter +- Phase 0.80-0.95: Waning Crescent (mirrored) +- Phase 0.95-1.00: New Moon (not displayed) + +### 8. Weather Cloud Sprites +**Cloud System:** +- Automatic cloud type selection based on weather conditions +- **happycloud.png**: Clear/normal weather +- **raincloud.png**: Rain or snow conditions +- 90px height (150% scale), maintains aspect ratio +- Animated horizontal scrolling +- Randomized vertical positions for natural distribution +- Varying speeds for depth effect +- 4 clouds for clear weather, 8 clouds for rain/cloudy +- NO pixelated weather effects - clouds represent weather visually +- Located in: `pixel-view.js:797-850` + +### 9. Sunrise/Sunset Display +- Actual sunrise and sunset times from Open-Meteo API +- Format: "☀ 6:45 AM / 🌙 5:30 PM" +- Updates every 10 minutes +- Located in: `index.html:169` + +### 10. Weather Integration +- Real-time weather from Open-Meteo API +- Conditions: + - **Clear**: Sunny clouds, full brightness + - **Cloudy**: More clouds, dimmed sun + - **Rain**: Rain clouds, darker sky, no sun + - **Snow**: Rain clouds, darker sky, no sun +- Temperature in Fahrenheit +- Updates every 10 minutes +- Located in: `pixel-view.js:797-850` + +## Technical Details + +### Image Loading System +```javascript +// Aircraft images +this.aircraftImages = { + smallProp: new Image(), + regionalJet: new Image(), + narrowBody: new Image(), + wideBody: new Image(), + heavy: new Image(), + helicopter: new Image() +}; + +// Environment images +this.desertImage = new Image(); // Vegas desert landscape +this.baseImage = new Image(); // Receiver station +this.sunImage = new Image(); // Sun sprite +this.moonSprite = new Image(); // Moon phases sprite sheet +this.happyCloudImage = new Image(); // Clear weather clouds +this.rainCloudImage = new Image(); // Rain weather clouds +``` + +### Rendering Pipeline & Layer Order +Layers drawn from bottom to top: + +1. **Sky gradient** - Dynamic colors based on time of day +2. **Clouds** - Happy or rain clouds based on weather +3. **Sun** - Moves across sky based on actual sun position +4. **Moon** - Phase-accurate sprite from sheet +5. **Desert background** - Full-width Vegas landscape +6. **Grid lines** - Altitude reference (every 10,000 feet) +7. **Base station** - ADS-B receiver building with antenna +8. **Aircraft** - Scaled, positioned, and oriented correctly +9. **Scale indicators** - Altitude labels on left side +10. **Compass indicators** - North/South directional arrows + +### Aircraft Direction Logic +```javascript +// Sprites face right (eastward) by default +// Flip horizontally when heading west +const isFacingLeft = flight.track > 90 && flight.track < 270; + +// Heading ranges: +// 0°-90° (N to E): Face right ➡️ +// 90°-180° (E to S): Face right ➡️ +// 180°-270° (S to W): Flip left ⬅️ +// 270°-360° (W to N): Flip left ⬅️ +``` + +### Moon Sprite Sheet Cropping +```javascript +// 2x3 grid: 3 columns, 2 rows +const spriteWidth = moonSprite.width / 3; +const spriteHeight = moonSprite.height / 2; + +// Select sprite based on phase +const sx = spritePos.col * spriteWidth; // X position in sheet +const sy = spritePos.row * spriteHeight; // Y position in sheet + +// Draw cropped section +ctx.drawImage(moonSprite, + sx, sy, spriteWidth, spriteHeight, // Source rectangle + destX, destY, targetWidth, targetHeight // Destination +); +``` + +### API Endpoints Used +- **Open-Meteo Forecast API**: Weather and sunrise/sunset + - Endpoint: `https://api.open-meteo.com/v1/forecast` + - Parameters: latitude, longitude, current_weather, daily + - Update interval: 10 minutes + +### WebSocket Communication +- **Server**: Python 3 with aiohttp +- **Port**: 2001 +- **Protocol**: JSON messages +- **Update Rate**: 1 second +- **Message Format**: + ```json + { + "type": "flights", + "flights": [ + { + "icao": "ABC123", + "callsign": "UAL123", + "lat": 36.1, + "lon": -115.2, + "altitude": 35000, + "speed": 450, + "track": 90 + } + ] + } + ``` + +### ADS-B Data Processing +- Server scans network for SBS receivers (port 30003) +- Common receiver IPs: .38, .100, .101 +- Parses SBS/BaseStation format messages +- Broadcasts to WebSocket clients every 1 second +- Cleans up flights not seen in 60 seconds + +### Receiver Location +- **Default**: Las Vegas, NV (36.2788°N, 115.2283°W) +- **API**: `/api/receiver-location` +- **Uses**: Distance calculations, weather data, sun/moon positioning + +## Image Processing Details + +### Original Source +- Generated via ChatGPT/DALL-E with custom prompts +- Downloaded from SMB share: `\\192.168.0.150\allen\Projects\PixelADSB` +- Credentials: allen / *69ChopsOne69* + +### Processing Commands +```bash +# Download from SMB +smbclient //192.168.0.150/allen -U 'allen%*69ChopsOne69*' \ + -c "cd Projects\\PixelADSB; prompt OFF; mget *.png" + +# Flip aircraft sprites (originally facing left) +for img in smallProp.png regionalJet.png narrowBody.png \ + wideBody.png heavy.png helicopter.png +do + convert "$img" -flop "$img" +done + +# Rename background +mv VegasDesertBackground.png desert.png +``` + +### Image Specifications +**Aircraft:** +- Original: High-resolution PNG (185K - 361K) +- Facing: RIGHT (east) after horizontal flip +- Background: Transparent +- Display: 110px height, aspect ratio maintained + +**Desert:** +- Original: 839K → Cropped: 371K +- White background removed (transparent) +- Display: Full canvas width, aspect ratio maintained + +**Base Station:** +- Transparent background +- Display: 150px height, centered + +**Moon:** +- Sprite sheet: 1.4M (2x3 grid) +- Display: 120px height per phase +- Aspect ratio maintained for each phase + +**Sun:** +- Display: 50px tall + +**Clouds:** +- Display: 90px height +- Two variants: happy (clear), rain (bad weather) + +## Server Management + +### Running the Server +```bash +cd /root/IceNet-ADS-B/pixel-view +python3 server.py +``` + +### Server Access +- **Local**: http://192.168.0.254:2001 +- **WebSocket**: ws://192.168.0.254:2001/ws +- **Tailscale Funnel**: Available (check tailscale status) + +### Background Processes +Multiple server instances may be running in background. +- Check: `ps aux | grep server.py` +- Kill all: `pkill -9 -f server.py` + +## Version History +- **v=36** (Current): Removed pixelated weather effects +- **v=35**: Moon scaled to 200% (120px) +- **v=34**: Moon aspect ratio fix +- **v=33**: Moon sprite sheet implementation (6 phases) +- **v=32**: Aircraft direction logic fixed +- **v=31**: Desert background implementation +- **v=30**: Desert + base layering +- **v=29**: Base station sprite added +- **v=24**: Cloud sprites (happy + rain) +- **v=20**: Aircraft size 110px +- **v=16**: PNG image rendering with custom sprites + +## Key Code Locations + +### Aircraft +- `pixel-view.js:85-92` - Aircraft image loading +- `pixel-view.js:1347-1349` - Direction logic (flip when westbound) +- `pixel-view.js:1374-1393` - PNG rendering with flip + +### Scenery +- `pixel-view.js:95-113` - Desert and base image loading +- `pixel-view.js:1138-1147` - Desert background rendering (full width) +- `pixel-view.js:1149-1161` - Base station rendering (on top) + +### Celestial +- `pixel-view.js:115-123` - Sun image loading +- `pixel-view.js:145-153` - Moon sprite sheet loading +- `pixel-view.js:940-1027` - Sun sprite rendering +- `pixel-view.js:1029-1059` - Moon phase calculation +- `pixel-view.js:1061-1138` - Moon sprite rendering with cropping + +### Weather +- `pixel-view.js:125-143` - Cloud sprite loading +- `pixel-view.js:797-850` - Cloud rendering (auto-selects rain vs happy) + +### Sky +- `pixel-view.js:678-795` - Dynamic sky colors + +## Removed Features +The following pixel-generated features were removed and replaced with sprite images: +- ❌ Pixel-generated mountains (replaced by desert.png) +- ❌ Pixel-generated cacti (included in desert.png) +- ❌ Pixel-generated ADS-B tower (replaced by base.png) +- ❌ Pixelated rain/snow effects (weather shown via cloud sprites) + +## Status +✅ **COMPLETE** - All features implemented and working +- All 6 aircraft sprites integrated +- Vegas desert landscape background +- ADS-B receiver station sprite +- 6-phase moon sprite system +- Sun sprite +- Weather cloud sprites (happy + rain) +- Helicopter detection active +- Accurate celestial positioning +- Weather-reactive visuals +- All sprites properly layered + +--- +Last Updated: December 30, 2025 +Project Status: Complete and Operational +Current Version: v=36 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c9b5b30 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Pixel-ADSB + +A retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites. + +## Features + +- Real-time aircraft tracking via ADS-B receivers +- Custom pixel art sprites for 6 aircraft types +- Animated sun and moon with accurate positions based on location +- Weather visualization (clouds, rain/snow) +- Directional view (N/E/S/W) with unique backgrounds +- Canvas-based 10 FPS retro rendering + +## Quick Start + +```bash +# Install dependencies +pip install aiohttp netifaces + +# Start the server +python3 server.py +``` + +Access at http://localhost:2001 (or configured port in config.json) + +## Requirements + +- Python 3.8+ +- ADS-B receiver providing SBS/BaseStation format on port 30003 +- Modern web browser with Canvas support + +## Documentation + +- [CONFIG.md](CONFIG.md) - Configuration options +- [PROJECT_DOCUMENTATION.md](PROJECT_DOCUMENTATION.md) - Technical details +- [CLAUDE.md](CLAUDE.md) - Development guidance + +## Aircraft Types + +| Type | Description | +|------|-------------| +| Small Prop | Light aircraft, N-prefix callsigns | +| Regional Jet | Regional carriers | +| Narrow Body | 737/A320 class | +| Wide Body | 777/787 class | +| Heavy | 747/A380 class | +| Helicopter | Low altitude, slow speed | + +## Controls + +- **Arrow Keys / A/D**: Rotate view direction +- View cycles through North, East, South, West + +## License + +MIT diff --git a/config.json b/config.json new file mode 100644 index 0000000..98020f3 --- /dev/null +++ b/config.json @@ -0,0 +1,10 @@ +{ + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 0.0, + "lon": 0.0 + }, + "web_port": 2001 +} diff --git a/east.png b/east.png new file mode 100644 index 0000000..a6d1c92 Binary files /dev/null and b/east.png differ diff --git a/happycloud.png b/happycloud.png new file mode 100644 index 0000000..112194f Binary files /dev/null and b/happycloud.png differ diff --git a/heavy.png b/heavy.png new file mode 100644 index 0000000..6aaa48f Binary files /dev/null and b/heavy.png differ diff --git a/helicopter.png b/helicopter.png new file mode 100644 index 0000000..0ba9406 Binary files /dev/null and b/helicopter.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..48dba4d --- /dev/null +++ b/index.html @@ -0,0 +1,402 @@ + + + + + + + + + Pixel ADS-B - Retro Flight View + + + + + + + +
+
+ + + +
+
+ + +
+
+
+ CONNECTING... + VIEWING: NORTH + AIRCRAFT: 0 + RANGE: 0 NM +
+
+ Loading... + --°F + ☀ --:-- / 🌙 --:-- +
+ + + + diff --git a/moon_6_phases.png b/moon_6_phases.png new file mode 100644 index 0000000..793eee6 Binary files /dev/null and b/moon_6_phases.png differ diff --git a/narrowBody.png b/narrowBody.png new file mode 100644 index 0000000..440d7ed Binary files /dev/null and b/narrowBody.png differ diff --git a/north.png b/north.png new file mode 100644 index 0000000..4243c03 Binary files /dev/null and b/north.png differ diff --git a/pixel-view.js b/pixel-view.js new file mode 100644 index 0000000..31dbe63 --- /dev/null +++ b/pixel-view.js @@ -0,0 +1,1897 @@ +// 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 = ''; + 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(); diff --git a/plane-right.png b/plane-right.png new file mode 100644 index 0000000..74654d3 Binary files /dev/null and b/plane-right.png differ diff --git a/raincloud.png b/raincloud.png new file mode 100644 index 0000000..1facf20 Binary files /dev/null and b/raincloud.png differ diff --git a/regionalJet.png b/regionalJet.png new file mode 100644 index 0000000..2af67a1 Binary files /dev/null and b/regionalJet.png differ diff --git a/server.py b/server.py new file mode 100755 index 0000000..6e30a58 --- /dev/null +++ b/server.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Pixel ADS-B Server - Connects directly to ADS-B receivers""" +import asyncio +import socket +import json +import time +import ipaddress +from pathlib import Path +from dataclasses import dataclass, asdict +from datetime import datetime +from typing import Dict, Set, List +from aiohttp import web +import netifaces + +WEB_DIR = Path(__file__).parent +CONFIG_FILE = WEB_DIR / "config.json" + +# Default configuration +config = { + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 0.0, + "lon": 0.0 + }, + "web_port": 2001 +} + +def load_config(): + """Load configuration from config.json""" + global config + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, 'r') as f: + loaded = json.load(f) + config.update(loaded) + print(f"Loaded config from {CONFIG_FILE}") + except Exception as e: + print(f"Error loading config: {e}, using defaults") + else: + print(f"No config file found at {CONFIG_FILE}, using defaults") + + print(f" Receivers: {config['receivers']}") + print(f" Receiver port: {config['receiver_port']}") + print(f" Location: {config['location']['name']} ({config['location']['lat']}, {config['location']['lon']})") + print(f" Web port: {config['web_port']}") + +# Flight data storage +flights: Dict[str, dict] = {} +connected_clients: Set = set() +receivers: List[str] = [] + +@dataclass +class SBSMessage: + msg_type: str + icao: str + callsign: str = "" + altitude: int = 0 + speed: float = 0 + track: float = 0 + lat: float = 0 + lon: float = 0 + vrate: int = 0 + squawk: str = "" + ground: bool = False + +def parse_sbs_message(line: str): + """Parse SBS/BaseStation format message""" + parts = line.strip().split(',') + if len(parts) < 10: + return None + + msg_type = parts[0] + if msg_type not in ['MSG']: + return None + + transmission_type = parts[1] + icao = parts[4].strip() + if not icao: + return None + + msg = SBSMessage(msg_type=msg_type, icao=icao) + + # Callsign (field 10) + if len(parts) > 10 and parts[10].strip(): + msg.callsign = parts[10].strip() + + # Altitude (field 11) + if len(parts) > 11 and parts[11].strip(): + try: + msg.altitude = int(parts[11]) + except ValueError: + pass + + # Ground speed (field 12) + if len(parts) > 12 and parts[12].strip(): + try: + msg.speed = float(parts[12]) + except ValueError: + pass + + # Track (field 13) + if len(parts) > 13 and parts[13].strip(): + try: + msg.track = float(parts[13]) + except ValueError: + pass + + # Latitude (field 14) + if len(parts) > 14 and parts[14].strip(): + try: + msg.lat = float(parts[14]) + except ValueError: + pass + + # Longitude (field 15) + if len(parts) > 15 and parts[15].strip(): + try: + msg.lon = float(parts[15]) + except ValueError: + pass + + # Vertical rate (field 16) + if len(parts) > 16 and parts[16].strip(): + try: + msg.vrate = int(parts[16]) + except ValueError: + pass + + # Squawk (field 17) + if len(parts) > 17 and parts[17].strip(): + msg.squawk = parts[17].strip() + + # Ground flag (field 21) + if len(parts) > 21 and parts[21].strip() == '-1': + msg.ground = True + + return msg + +async def connect_to_receiver(host: str, port: int = 30003): + """Connect to an SBS receiver and process messages""" + print(f"Connecting to receiver at {host}:{port}...") + while True: + try: + reader, writer = await asyncio.open_connection(host, port) + print(f"Connected to {host}:{port}") + + while True: + line = await reader.readline() + if not line: + break + + line = line.decode('utf-8', errors='ignore') + msg = parse_sbs_message(line) + if msg and msg.icao: + # Update flight data + if msg.icao not in flights: + flights[msg.icao] = { + 'icao': msg.icao, + 'callsign': '', + 'altitude': 0, + 'speed': 0, + 'track': 0, + 'lat': 0, + 'lon': 0, + 'vrate': 0, + 'squawk': '', + 'ground': False, + 'last_seen': time.time() + } + + flight = flights[msg.icao] + if msg.callsign: + flight['callsign'] = msg.callsign + if msg.altitude: + flight['altitude'] = msg.altitude + if msg.speed: + flight['speed'] = msg.speed + if msg.track: + flight['track'] = msg.track + if msg.lat: + flight['lat'] = msg.lat + if msg.lon: + flight['lon'] = msg.lon + if msg.vrate: + flight['vrate'] = msg.vrate + if msg.squawk: + flight['squawk'] = msg.squawk + flight['ground'] = msg.ground + flight['last_seen'] = time.time() + + writer.close() + await writer.wait_closed() + except Exception as e: + print(f"Receiver {host}:{port} error: {e}") + await asyncio.sleep(5) # Wait before reconnecting + +async def scan_for_receivers(): + """Scan local network for ADS-B receivers on port 30003""" + print("Scanning for ADS-B receivers on port 30003...") + + # Get local network + for iface in netifaces.interfaces(): + addrs = netifaces.ifaddresses(iface) + if netifaces.AF_INET in addrs: + for addr in addrs[netifaces.AF_INET]: + ip = addr.get('addr') + netmask = addr.get('netmask') + if ip and netmask and not ip.startswith('127.'): + # Calculate network from IP and netmask + try: + network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + print(f"Scanning {network} ({network.num_addresses} hosts)...") + + # Get all host IPs in the network + host_ips = [str(host) for host in network.hosts()] + + # Scan all IPs in parallel + async def check_ip(test_ip): + if await test_port(test_ip, 30003): + return test_ip + return None + + results = await asyncio.gather(*[check_ip(host) for host in host_ips]) + for result in results: + if result: + print(f"Found receiver at {result}:30003") + receivers.append(result) + except ValueError as e: + print(f"Invalid network {ip}/{netmask}: {e}") + +async def test_port(ip: str, port: int, timeout: float = 0.5): + """Test if a port is open""" + try: + conn = asyncio.open_connection(ip, port) + reader, writer = await asyncio.wait_for(conn, timeout=timeout) + writer.close() + await writer.wait_closed() + return True + except: + return False + +async def cleanup_old_flights(): + """Remove flights not seen in 60 seconds""" + while True: + await asyncio.sleep(10) + now = time.time() + to_remove = [icao for icao, flight in flights.items() + if now - flight['last_seen'] > 60] + for icao in to_remove: + del flights[icao] + +async def broadcast_flights(): + """Broadcast flight data to all connected WebSocket clients""" + while True: + await asyncio.sleep(1) + if connected_clients: + # Get flights with position data + flight_list = [f for f in flights.values() if f['lat'] and f['lon']] + print(f"Broadcasting {len(flight_list)} flights to {len(connected_clients)} clients") + print(f"Total flights in memory: {len(flights)}") + message = json.dumps({ + 'type': 'flights', + 'flights': flight_list, + 'count': len(flight_list) + }) + + # Send to all connected clients + dead_clients = set() + for client in connected_clients: + try: + await client.send_str(message) + except: + dead_clients.add(client) + + # Remove disconnected clients + connected_clients.difference_update(dead_clients) + +async def websocket_handler(request): + """Handle WebSocket connections from browser""" + ws = web.WebSocketResponse() + await ws.prepare(request) + + connected_clients.add(ws) + print(f"WebSocket client connected ({len(connected_clients)} total)") + + try: + async for msg in ws: + pass # We don't expect messages from client + finally: + connected_clients.discard(ws) + print(f"WebSocket client disconnected ({len(connected_clients)} remaining)") + + return ws + +async def handle_receiver_location(request): + """Return receiver location from config""" + return web.json_response({ + "lat": config["location"]["lat"], + "lon": config["location"]["lon"], + "name": config["location"]["name"] + }) + +async def handle_http(request): + """Serve static files""" + path = request.path + if path == '/': + path = '/index.html' + + file_path = WEB_DIR / path.lstrip('/') + if file_path.exists() and file_path.is_file(): + return web.FileResponse(file_path) + return web.Response(status=404, text="Not Found") + +async def start_http_server(): + """Start HTTP server with WebSocket support""" + port = config["web_port"] + app = web.Application() + app.router.add_get('/ws', websocket_handler) + app.router.add_get('/api/receiver-location', handle_receiver_location) + app.router.add_get('/{tail:.*}', handle_http) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, '0.0.0.0', port) + await site.start() + print(f"HTTP server with WebSocket running on http://0.0.0.0:{port}") + print(f"WebSocket available at ws://0.0.0.0:{port}/ws") + print(f"Receiver location API at http://0.0.0.0:{port}/api/receiver-location") + +async def main(): + """Main entry point""" + global receivers + + print("Pixel ADS-B Server Starting...") + + # Load configuration + load_config() + + print(f"Access at http://0.0.0.0:{config['web_port']}") + + # Get receivers based on config + receiver_config = config["receivers"] + receiver_port = config["receiver_port"] + + if receiver_config == "AUTO": + # Auto-scan for receivers + await scan_for_receivers() + elif isinstance(receiver_config, list): + # Use specified receiver IPs + receivers = receiver_config + print(f"Using configured receivers: {receivers}") + elif isinstance(receiver_config, str): + # Single receiver IP specified + receivers = [receiver_config] + print(f"Using configured receiver: {receivers[0]}") + + if not receivers: + print("WARNING: No receivers found or configured!") + else: + print(f"Using {len(receivers)} receiver(s)") + + # Start all tasks + tasks = [ + start_http_server(), + cleanup_old_flights(), + broadcast_flights(), + ] + + # Connect to all receivers + for receiver in receivers: + tasks.append(connect_to_receiver(receiver, receiver_port)) + + await asyncio.gather(*tasks) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/smallProp.png b/smallProp.png new file mode 100644 index 0000000..8df68f8 Binary files /dev/null and b/smallProp.png differ diff --git a/south.png b/south.png new file mode 100644 index 0000000..f0e0249 Binary files /dev/null and b/south.png differ diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..c92f7e3 --- /dev/null +++ b/start.sh @@ -0,0 +1,4 @@ +#!/bin/bash +cd "$(dirname "$0")" +echo "Starting Pixel View ADS-B server..." +python3 server.py diff --git a/sun.png b/sun.png new file mode 100644 index 0000000..3b521db Binary files /dev/null and b/sun.png differ diff --git a/west.png b/west.png new file mode 100644 index 0000000..6fb04f2 Binary files /dev/null and b/west.png differ diff --git a/wideBody.png b/wideBody.png new file mode 100644 index 0000000..73eee51 Binary files /dev/null and b/wideBody.png differ