commit 42a50037abc6d6650bb353d83d2e316b2638cfa0 Author: root Date: Mon Jun 8 08:35:07 2026 -0700 Add admin panel, authentication, and first-run setup wizard for v1.0 Implements credentialed admin panel and multi-step setup wizard so new users can configure location, receivers, and preferences without editing JSON files. Adds bcrypt password auth with session cookies, setup lockout after first run, and expanded config schema with backward compatibility. New files: admin/{html,css,js}, setup/{html,css,js} Modified: server.py (auth, setup, admin APIs), index.html (dynamic title), pixel-view.js (site/display config), requirements.txt (bcrypt), config.json.example (full schema) Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da1f875 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +.eggs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Claude Code +.claude/ + +# Gitea token +.gitea-token + +# 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..b5632f2 --- /dev/null +++ b/CONFIG.md @@ -0,0 +1,277 @@ +# 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, + "theme": "desert" +} +``` + +**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) | +| `theme` | string | Background theme: `"desert"` (default) or `"custom"` | + +### 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 Themes + +Pixel View uses directional background images to show the horizon view from your receiver location. Backgrounds are organized into themes stored in the `backgrounds/` folder. + +### Available Themes + +| Theme | Description | +|-------|-------------| +| `desert` | Las Vegas desert landscape (default) | +| `custom` | Your own custom backgrounds | + +### Theme Folder Structure + +``` +backgrounds/ +├── desert/ # Default desert theme +│ ├── north.png +│ ├── east.png +│ ├── south.png +│ └── west.png +└── custom/ # Your custom backgrounds + ├── README.md # Instructions for creating custom backgrounds + ├── north.png + ├── east.png + ├── south.png + └── west.png +``` + +### Using Custom Backgrounds + +1. Add your background images to `backgrounds/custom/` +2. Set `"theme": "custom"` in your `config.json` +3. Restart the server + +### Image Files + +Each theme folder needs these 4 directional images: + +| 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 + +All sprite images are located in the `images/` folder. These are optional to customize: + +| File | Size | Description | +|------|------|-------------| +| `images/sun.png` | 64x64 | Sun sprite | +| `images/moon_6_phases.png` | 192x128 | Moon phases (3x2 grid) | +| `images/happycloud.png` | 96x64 | Clear weather cloud | +| `images/raincloud.png` | 96x64 | Rain/storm cloud | + +### Aircraft Sprites + +| File | Description | +|------|-------------| +| `images/smallProp.png` | Small propeller aircraft (Cessna) | +| `images/regionalJet.png` | Regional jets (CRJ, ERJ) | +| `images/narrowBody.png` | Narrow body jets (737, A320) | +| `images/wideBody.png` | Wide body jets (777, 787) | +| `images/heavy.png` | Heavy/jumbo jets (747, A380) | +| `images/helicopter.png` | Helicopters | + +All aircraft sprites should face **right (east)** - the code flips them automatically for westbound flights. + +--- + +## Quick Start Checklist + +1. [ ] Install dependencies: `pip install -r requirements.txt` +2. [ ] Edit `config.json` with your receiver IP (or leave as AUTO) +3. [ ] Set your location name, latitude, and longitude +4. [ ] (Optional) Add custom backgrounds to `backgrounds/custom/` and set `"theme": "custom"` +5. [ ] Start the server: `python3 server.py` +6. [ ] Open browser to `http://your-server-ip:2001` + +--- + +## Running as a Service (Auto-Start) + +To run Pixel-ADSB automatically on boot, create a systemd service: + +### 1. Create the service file + +```bash +sudo nano /etc/systemd/system/pixel-adsb.service +``` + +Add this content (adjust paths as needed): + +```ini +[Unit] +Description=Pixel-ADSB Flight Tracker +After=network.target + +[Service] +Type=simple +WorkingDirectory=/path/to/pixel-view +ExecStart=/usr/bin/python3 /path/to/pixel-view/server.py +Restart=always +RestartSec=5 +StandardOutput=append:/var/log/pixel-adsb.log +StandardError=append:/var/log/pixel-adsb.log + +[Install] +WantedBy=multi-user.target +``` + +### 2. Enable and start the service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable pixel-adsb +sudo systemctl start pixel-adsb +``` + +### 3. Check status + +```bash +sudo systemctl status pixel-adsb +``` + +### 4. View logs + +```bash +tail -f /var/log/pixel-adsb.log +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..031457b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Allen Cross + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfa3b37 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Pixel-ADSB + +A retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites. + +![Pixel-ADSB Screenshot](screenshots/screenshot.png) + +## Features + +- Real-time aircraft tracking via ADS-B receivers +- Custom pixel art sprites for 6 aircraft types (small prop, regional jet, narrow body, wide body, heavy, helicopter) +- Animated sun and moon with accurate astronomical positions +- Dynamic sky colors based on time of day +- Weather visualization with cloud sprites +- Directional view (N/E/S/W) with themed backgrounds +- Auto-discovery of ADS-B receivers on your network +- Canvas-based 10 FPS retro rendering + +## Quick Start + +```bash +# Clone the repository +git clone https://gitea.chops.one/allen/ADS-Bit.git +cd ADS-Bit + +# Install dependencies +pip install -r requirements.txt + +# Configure your location (required for weather and celestial positioning) +# Edit config.json and set your lat/lon coordinates + +# Start the server +python3 server.py +``` + +Access at http://localhost:2001 + +## Requirements + +- Python 3.8+ +- ADS-B receiver providing SBS/BaseStation format on port 30003 (dump1090, readsb, etc.) +- Modern web browser with Canvas support + +## Configuration + +Edit `config.json` to customize your installation: + +```json +{ + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 36.2788, + "lon": -115.2283 + }, + "web_port": 2001, + "theme": "desert" +} +``` + +**Important:** Set your `location.lat` and `location.lon` for accurate weather and sun/moon positioning. + +See [CONFIG.md](CONFIG.md) for full configuration options including custom backgrounds and running as a service. + +## Controls + +- **Arrow Keys / A/D**: Rotate view direction +- View cycles through North, East, South, West +- Click aircraft in sidebar to highlight + +## Aircraft Types + +| Type | Detection | +|------|-----------| +| Helicopter | Low altitude + slow speed | +| Heavy (747/A380) | High altitude or specific callsigns | +| Wide Body | Very high altitude/speed | +| Narrow Body | Default commercial | +| Regional Jet | Regional carrier callsigns or lower altitude | +| Small Prop | N-prefix callsigns or very low/slow | + +## Custom Backgrounds + +Create backgrounds for your location: + +1. Add 4 directional images to `backgrounds/custom/` (north.png, east.png, south.png, west.png) +2. Set `"theme": "custom"` in config.json +3. Restart the server + +See [CONFIG.md](CONFIG.md) for image specifications and tips. + +## Running as a Service + +To auto-start on boot, see the systemd service instructions in [CONFIG.md](CONFIG.md#running-as-a-service-auto-start). + +## Compatible Receivers + +Works with any receiver providing SBS/BaseStation format on port 30003: +- dump1090 / dump1090-fa / dump1090-mutability +- readsb +- ADS-B Exchange feeders +- FlightAware PiAware +- Any SBS1 compatible receiver + +## License + +MIT License - see [LICENSE](LICENSE) for details. + +## Credits + +- Aircraft and environment sprites generated with AI assistance +- Weather data from [Open-Meteo](https://open-meteo.com/) (free, no API key required) diff --git a/admin/admin.css b/admin/admin.css new file mode 100644 index 0000000..8b8350c --- /dev/null +++ b/admin/admin.css @@ -0,0 +1,392 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); + color: #fcfcfc; + font-family: 'Courier New', monospace; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* Login Screen */ +#login-screen { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 20px; +} + +.login-box { + background: rgba(52, 73, 94, 0.9); + border: 3px solid #5c94fc; + border-radius: 12px; + padding: 40px; + text-align: center; + max-width: 400px; + width: 100%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.retro-title { + font-family: 'Press Start 2P', monospace; + color: #fcd444; + text-shadow: 2px 2px 0px #000; + font-size: 20px; + margin-bottom: 10px; +} + +.login-subtitle { + color: #b4d4ec; + font-size: 14px; + margin-bottom: 24px; +} + +#login-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +input[type="password"], +input[type="text"], +input[type="number"], +textarea, +select { + background: rgba(0, 0, 0, 0.5); + border: 2px solid #5c94fc; + border-radius: 6px; + color: #fcfcfc; + font-family: 'Courier New', monospace; + font-size: 14px; + padding: 10px 12px; + width: 100%; + outline: none; + transition: border-color 0.2s; +} + +input:focus, textarea:focus, select:focus { + border-color: #fcd444; +} + +select { + cursor: pointer; +} + +select option { + background: #2c3e50; + color: #fcfcfc; +} + +textarea { + resize: vertical; + min-height: 80px; +} + +.btn { + font-family: 'Press Start 2P', monospace; + font-size: 11px; + padding: 10px 20px; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(92, 148, 252, 0.2); + color: #fcfcfc; + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + display: inline-block; + text-align: center; +} + +.btn:hover { + background: rgba(92, 148, 252, 0.4); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: rgba(252, 212, 68, 0.3); + border-color: #fcd444; + color: #fcd444; +} + +.btn-primary:hover { + background: rgba(252, 212, 68, 0.5); +} + +.btn-danger { + border-color: #fc5454; + color: #fc5454; +} + +.btn-danger:hover { + background: rgba(252, 84, 84, 0.3); +} + +.btn-small { + font-size: 9px; + padding: 6px 12px; +} + +.error-msg { + color: #fc5454; + font-size: 12px; + margin-top: 10px; +} + +.back-link { + display: inline-block; + margin-top: 16px; + color: #b4d4ec; + text-decoration: none; + font-size: 12px; +} + +.back-link:hover { + color: #fcd444; +} + +/* Admin Panel */ +.admin-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: rgba(0, 0, 0, 0.3); + border-bottom: 3px solid #5c94fc; +} + +.admin-header .retro-title { + font-size: 16px; + margin: 0; +} + +.header-actions { + display: flex; + gap: 8px; +} + +/* Tab Bar */ +.tab-bar { + display: flex; + gap: 2px; + padding: 10px 20px 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.tab { + font-family: 'Press Start 2P', monospace; + font-size: 9px; + padding: 8px 14px; + background: rgba(52, 73, 94, 0.6); + border: 2px solid #5c94fc; + border-bottom: none; + border-radius: 6px 6px 0 0; + color: #b4d4ec; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.tab:hover { + background: rgba(92, 148, 252, 0.3); + color: #fcfcfc; +} + +.tab.active { + background: rgba(52, 73, 94, 0.9); + color: #fcd444; + border-color: #fcd444; +} + +/* Tab Content */ +.tab-content { + padding: 20px; + max-width: 800px; + margin: 0 auto; +} + +.tab-pane { + background: rgba(52, 73, 94, 0.6); + border: 2px solid #5c94fc; + border-radius: 0 8px 8px 8px; + padding: 24px; +} + +.tab-pane h2 { + font-family: 'Press Start 2P', monospace; + font-size: 14px; + color: #fcd444; + margin-bottom: 20px; +} + +/* Status Grid */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.status-card { + background: rgba(0, 0, 0, 0.4); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 16px; + text-align: center; +} + +.status-label { + display: block; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + color: #b4d4ec; + margin-bottom: 8px; +} + +.status-value { + display: block; + font-family: 'Press Start 2P', monospace; + font-size: 18px; + color: #54fc54; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + color: #b4d4ec; + margin-bottom: 6px; +} + +.form-row { + display: flex; + gap: 16px; +} + +.form-row .form-group { + flex: 1; +} + +.hint { + display: block; + font-size: 11px; + color: #888; + margin-top: 4px; +} + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: #fcd444; +} + +.button-row { + display: flex; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; +} + +.info-box { + background: rgba(0, 0, 0, 0.3); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 16px; + margin-top: 16px; +} + +.info-box h3 { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: #fcd444; + margin-bottom: 10px; +} + +/* Toast Notification */ +.toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(84, 252, 84, 0.9); + color: #000; + font-family: 'Press Start 2P', monospace; + font-size: 10px; + padding: 12px 24px; + border-radius: 8px; + border: 2px solid #54fc54; + z-index: 1000; + transition: opacity 0.3s; +} + +.toast.error { + background: rgba(252, 84, 84, 0.9); + border-color: #fc5454; + color: #fff; +} + +/* Mobile */ +@media (max-width: 768px) { + .admin-header { + flex-direction: column; + gap: 8px; + text-align: center; + } + + .tab-bar { + padding: 8px 10px 0; + } + + .tab { + font-size: 8px; + padding: 6px 10px; + } + + .tab-content { + padding: 10px; + } + + .tab-pane { + padding: 16px; + } + + .form-row { + flex-direction: column; + gap: 0; + } + + .status-grid { + grid-template-columns: repeat(2, 1fr); + } + + .button-row { + flex-direction: column; + } + + .button-row .btn { + width: 100%; + } +} diff --git a/admin/admin.html b/admin/admin.html new file mode 100644 index 0000000..e414697 --- /dev/null +++ b/admin/admin.html @@ -0,0 +1,230 @@ + + + + + + ADS-Bit Admin + + + + + + + +
+ +
+ + + + + + + diff --git a/admin/admin.js b/admin/admin.js new file mode 100644 index 0000000..d39b018 --- /dev/null +++ b/admin/admin.js @@ -0,0 +1,340 @@ +// ADS-Bit Admin Panel +(function () { + const loginScreen = document.getElementById('login-screen'); + const adminPanel = document.getElementById('admin-panel'); + let statusInterval = null; + + // ------- Auth ------- + async function checkAuth() { + try { + const res = await fetch('/api/auth/check'); + if (res.ok) { + showAdmin(); + return; + } + } catch (e) { /* not logged in */ } + showLogin(); + } + + function showLogin() { + loginScreen.classList.remove('hidden'); + adminPanel.classList.add('hidden'); + if (statusInterval) clearInterval(statusInterval); + } + + function showAdmin() { + loginScreen.classList.add('hidden'); + adminPanel.classList.remove('hidden'); + loadConfig(); + loadThemes(); + refreshStatus(); + statusInterval = setInterval(refreshStatus, 5000); + } + + document.getElementById('login-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const pw = document.getElementById('login-password').value; + const errEl = document.getElementById('login-error'); + errEl.classList.add('hidden'); + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }) + }); + if (res.ok) { + showAdmin(); + } else { + const data = await res.json(); + errEl.textContent = data.error || 'Login failed'; + errEl.classList.remove('hidden'); + } + } catch (err) { + errEl.textContent = 'Connection error'; + errEl.classList.remove('hidden'); + } + }); + + document.getElementById('logout-btn').addEventListener('click', async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + showLogin(); + }); + + // ------- Tabs ------- + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden')); + tab.classList.add('active'); + document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden'); + }); + }); + + // ------- Dashboard ------- + async function refreshStatus() { + try { + const res = await fetch('/api/admin/status'); + if (!res.ok) return; + const data = await res.json(); + document.getElementById('stat-flights').textContent = data.active_flights; + document.getElementById('stat-viewers').textContent = data.connected_viewers; + document.getElementById('stat-receivers').textContent = data.receiver_count; + document.getElementById('stat-uptime').textContent = data.uptime; + + const ipsEl = document.getElementById('receiver-ips'); + if (data.receivers && data.receivers.length) { + ipsEl.textContent = data.receivers.join(', '); + } else { + ipsEl.textContent = 'None connected'; + } + } catch (e) { /* ignore */ } + } + + // ------- Load Config ------- + async function loadConfig() { + try { + const res = await fetch('/api/admin/config'); + if (!res.ok) return; + const cfg = await res.json(); + + // Receivers + const mode = (cfg.receivers === 'AUTO') ? 'AUTO' : 'MANUAL'; + document.getElementById('receiver-mode').value = mode; + toggleManualIps(mode); + if (mode === 'MANUAL') { + const ips = Array.isArray(cfg.receivers) ? cfg.receivers : [cfg.receivers]; + document.getElementById('receiver-ips-input').value = ips.join('\n'); + } + document.getElementById('receiver-port').value = cfg.receiver_port || 30003; + + // Location + document.getElementById('location-name').value = cfg.location?.name || ''; + document.getElementById('location-lat').value = cfg.location?.lat || 0; + document.getElementById('location-lon').value = cfg.location?.lon || 0; + + // Display + document.getElementById('site-title').value = cfg.site?.title || 'ADS-Bit'; + document.getElementById('site-subtitle').value = cfg.site?.subtitle || ''; + document.getElementById('temp-unit').value = cfg.display?.temperature_unit || 'F'; + document.getElementById('show-weather').checked = cfg.display?.show_weather !== false; + document.getElementById('show-sidebar').checked = cfg.display?.show_sidebar !== false; + document.getElementById('default-direction').value = cfg.display?.default_view_direction || 0; + + // Set theme select to current theme + const themeSelect = document.getElementById('theme-select'); + if (themeSelect.options.length > 0) { + themeSelect.value = cfg.theme || 'desert'; + } else { + themeSelect.dataset.pending = cfg.theme || 'desert'; + } + + // Tuning + document.getElementById('flight-timeout').value = cfg.tuning?.flight_timeout_seconds || 60; + document.getElementById('broadcast-interval').value = cfg.tuning?.broadcast_interval_seconds || 1; + document.getElementById('cleanup-interval').value = cfg.tuning?.cleanup_interval_seconds || 10; + document.getElementById('reconnect-delay').value = cfg.tuning?.receiver_reconnect_seconds || 5; + } catch (e) { + console.error('Failed to load config', e); + } + } + + // ------- Load Themes ------- + async function loadThemes() { + try { + const res = await fetch('/api/admin/themes'); + if (!res.ok) return; + const data = await res.json(); + const sel = document.getElementById('theme-select'); + sel.innerHTML = ''; + (data.themes || []).forEach(t => { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + sel.appendChild(opt); + }); + // Apply pending value if config loaded first + if (sel.dataset.pending) { + sel.value = sel.dataset.pending; + delete sel.dataset.pending; + } + } catch (e) { /* ignore */ } + } + + // ------- Receivers ------- + function toggleManualIps(mode) { + document.getElementById('manual-ips-group').classList.toggle('hidden', mode === 'AUTO'); + } + + document.getElementById('receiver-mode').addEventListener('change', (e) => { + toggleManualIps(e.target.value); + }); + + document.getElementById('scan-receivers-btn').addEventListener('click', async () => { + const btn = document.getElementById('scan-receivers-btn'); + btn.textContent = 'SCANNING...'; + btn.disabled = true; + try { + const res = await fetch('/api/admin/scan-receivers', { method: 'POST' }); + const data = await res.json(); + const box = document.getElementById('scan-results'); + const list = document.getElementById('scan-results-list'); + box.classList.remove('hidden'); + if (data.receivers && data.receivers.length) { + list.textContent = data.receivers.join(', '); + } else { + list.textContent = 'No receivers found on network'; + } + } catch (e) { + toast('Scan failed', true); + } + btn.textContent = 'SCAN NOW'; + btn.disabled = false; + }); + + document.getElementById('restart-receivers-btn').addEventListener('click', async () => { + try { + const res = await fetch('/api/admin/restart-receivers', { method: 'POST' }); + if (res.ok) toast('Receivers restarted'); + else toast('Failed to restart', true); + } catch (e) { + toast('Error restarting receivers', true); + } + }); + + document.getElementById('save-receivers-btn').addEventListener('click', async () => { + const mode = document.getElementById('receiver-mode').value; + const port = parseInt(document.getElementById('receiver-port').value) || 30003; + const body = { receiver_port: port }; + + if (mode === 'AUTO') { + body.receivers = 'AUTO'; + } else { + const ips = document.getElementById('receiver-ips-input').value + .split('\n').map(s => s.trim()).filter(Boolean); + body.receivers = ips.length ? ips : 'AUTO'; + } + + await saveConfig(body); + }); + + // ------- Location ------- + document.getElementById('browser-location-btn').addEventListener('click', () => { + if (!navigator.geolocation) { + toast('Geolocation not supported', true); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + document.getElementById('location-lat').value = pos.coords.latitude.toFixed(4); + document.getElementById('location-lon').value = pos.coords.longitude.toFixed(4); + toast('Location detected'); + }, + () => toast('Location access denied', true) + ); + }); + + document.getElementById('save-location-btn').addEventListener('click', async () => { + await saveConfig({ + location: { + name: document.getElementById('location-name').value, + lat: parseFloat(document.getElementById('location-lat').value) || 0, + lon: parseFloat(document.getElementById('location-lon').value) || 0 + } + }); + }); + + // ------- Display ------- + document.getElementById('save-display-btn').addEventListener('click', async () => { + await saveConfig({ + theme: document.getElementById('theme-select').value, + site: { + title: document.getElementById('site-title').value, + subtitle: document.getElementById('site-subtitle').value + }, + display: { + temperature_unit: document.getElementById('temp-unit').value, + show_weather: document.getElementById('show-weather').checked, + show_sidebar: document.getElementById('show-sidebar').checked, + default_view_direction: parseInt(document.getElementById('default-direction').value) || 0 + } + }); + }); + + // ------- Tuning ------- + document.getElementById('save-tuning-btn').addEventListener('click', async () => { + await saveConfig({ + tuning: { + flight_timeout_seconds: parseInt(document.getElementById('flight-timeout').value) || 60, + broadcast_interval_seconds: parseInt(document.getElementById('broadcast-interval').value) || 1, + cleanup_interval_seconds: parseInt(document.getElementById('cleanup-interval').value) || 10, + receiver_reconnect_seconds: parseInt(document.getElementById('reconnect-delay').value) || 5 + } + }); + }); + + // ------- Security ------- + document.getElementById('change-password-btn').addEventListener('click', async () => { + const current = document.getElementById('current-password').value; + const newPw = document.getElementById('new-password').value; + const confirm = document.getElementById('confirm-password').value; + + if (newPw.length < 4) { + toast('Password must be at least 4 characters', true); + return; + } + if (newPw !== confirm) { + toast('Passwords do not match', true); + return; + } + + try { + const res = await fetch('/api/admin/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ current_password: current, new_password: newPw }) + }); + if (res.ok) { + toast('Password changed'); + document.getElementById('current-password').value = ''; + document.getElementById('new-password').value = ''; + document.getElementById('confirm-password').value = ''; + } else { + const data = await res.json(); + toast(data.error || 'Failed to change password', true); + } + } catch (e) { + toast('Connection error', true); + } + }); + + // ------- Helpers ------- + async function saveConfig(body) { + try { + const res = await fetch('/api/admin/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (res.ok) { + toast('Settings saved'); + } else { + const data = await res.json(); + toast(data.error || 'Save failed', true); + } + } catch (e) { + toast('Connection error', true); + } + } + + function toast(msg, isError) { + const el = document.getElementById('toast'); + el.textContent = msg; + el.classList.remove('hidden', 'error'); + if (isError) el.classList.add('error'); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.add('hidden'), 3000); + } + + // ------- Init ------- + checkAuth(); +})(); diff --git a/backgrounds/custom/README.md b/backgrounds/custom/README.md new file mode 100644 index 0000000..7ad810f --- /dev/null +++ b/backgrounds/custom/README.md @@ -0,0 +1,35 @@ +# Custom Backgrounds + +Place your custom background images in this folder. + +## Required Files + +You need to provide 4 directional background images: +- `north.png` - View looking north +- `east.png` - View looking east +- `south.png` - View looking south +- `west.png` - View looking west + +## Image Specifications + +- **Dimensions:** 1536 x 1024 pixels +- **Format:** PNG with transparency support +- **Orientation:** Each image should show the horizon/landscape as seen when facing that cardinal direction from your location + +## Tips + +- Include a horizon line in each image - the sun and moon will set behind it +- Keep the upper portion (sky area) relatively simple for aircraft visibility +- The bottom portion can have more detail (terrain, buildings, etc.) +- Consider your local landmarks and terrain for each direction + +## Enabling Custom Theme + +Set the theme in `config.json`: +```json +{ + "theme": "custom" +} +``` + +Then restart the server. diff --git a/backgrounds/desert/east.png b/backgrounds/desert/east.png new file mode 100644 index 0000000..a6d1c92 Binary files /dev/null and b/backgrounds/desert/east.png differ diff --git a/backgrounds/desert/north.png b/backgrounds/desert/north.png new file mode 100644 index 0000000..4243c03 Binary files /dev/null and b/backgrounds/desert/north.png differ diff --git a/backgrounds/desert/south.png b/backgrounds/desert/south.png new file mode 100644 index 0000000..f0e0249 Binary files /dev/null and b/backgrounds/desert/south.png differ diff --git a/backgrounds/desert/west.png b/backgrounds/desert/west.png new file mode 100644 index 0000000..6fb04f2 Binary files /dev/null and b/backgrounds/desert/west.png differ diff --git a/config.json.example b/config.json.example new file mode 100644 index 0000000..5a359df --- /dev/null +++ b/config.json.example @@ -0,0 +1,32 @@ +{ + "setup_complete": false, + "admin": { + "password_hash": "", + "session_secret": "" + }, + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 0.0, + "lon": 0.0 + }, + "web_port": 2001, + "theme": "desert", + "site": { + "title": "ADS-Bit", + "subtitle": "Retro Flight Tracker" + }, + "display": { + "temperature_unit": "F", + "show_weather": true, + "show_sidebar": true, + "default_view_direction": 0 + }, + "tuning": { + "flight_timeout_seconds": 60, + "broadcast_interval_seconds": 1, + "cleanup_interval_seconds": 10, + "receiver_reconnect_seconds": 5 + } +} diff --git a/images/happycloud.png b/images/happycloud.png new file mode 100644 index 0000000..112194f Binary files /dev/null and b/images/happycloud.png differ diff --git a/images/heavy.png b/images/heavy.png new file mode 100644 index 0000000..6aaa48f Binary files /dev/null and b/images/heavy.png differ diff --git a/images/helicopter.png b/images/helicopter.png new file mode 100644 index 0000000..0ba9406 Binary files /dev/null and b/images/helicopter.png differ diff --git a/images/moon_6_phases.png b/images/moon_6_phases.png new file mode 100644 index 0000000..793eee6 Binary files /dev/null and b/images/moon_6_phases.png differ diff --git a/images/narrowBody.png b/images/narrowBody.png new file mode 100644 index 0000000..440d7ed Binary files /dev/null and b/images/narrowBody.png differ diff --git a/images/raincloud.png b/images/raincloud.png new file mode 100644 index 0000000..1facf20 Binary files /dev/null and b/images/raincloud.png differ diff --git a/images/regionalJet.png b/images/regionalJet.png new file mode 100644 index 0000000..2af67a1 Binary files /dev/null and b/images/regionalJet.png differ diff --git a/images/smallProp.png b/images/smallProp.png new file mode 100644 index 0000000..8df68f8 Binary files /dev/null and b/images/smallProp.png differ diff --git a/images/sun.png b/images/sun.png new file mode 100644 index 0000000..3b521db Binary files /dev/null and b/images/sun.png differ diff --git a/images/wideBody.png b/images/wideBody.png new file mode 100644 index 0000000..73eee51 Binary files /dev/null and b/images/wideBody.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..9465e82 --- /dev/null +++ b/index.html @@ -0,0 +1,402 @@ + + + + + + + + + ADS-Bit - Retro Flight Tracker + + + + + + + +
+
+ + + +
+
+ + +
+
+
+ CONNECTING... + VIEWING: NORTH + AIRCRAFT: 0 + RANGE: 0 NM +
+
+ Loading... + --°F + ☀ --:-- / 🌙 --:-- +
+ + + + diff --git a/pixel-view.js b/pixel-view.js new file mode 100644 index 0000000..0e6fb2f --- /dev/null +++ b/pixel-view.js @@ -0,0 +1,1973 @@ +// 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...'; + this.receivers = []; + + // Flight data + this.flights = new Map(); + this.aircraftTypes = new Map(); // Cache ICAO -> aircraft type + + // WebSocket + this.ws = null; + this.reconnectDelay = 1000; + + // Colors (retro pixel art theme) + this.colors = { + skyTop: '#6ca4dc', + skyBottom: '#b4d4ec', + cloud: '#ffffff', + sun: '#fcd444', + ground: '#d4a868', + groundDark: '#b8884c', + dirt: '#a87840', + antenna: '#c0c0c0', // Silver/gray antenna + antennaDark: '#808080', // Dark gray for shading + antennaBase: '#4c4c4c', // Dark base + antennaRing: '#ffffff', // White rings + plane: '#fcfcfc', + planeTowards: '#54fc54', + planeAway: '#fc9c54', + text: '#fcfcfc', + textShadow: '#000000', + grid: 'rgba(255, 255, 255, 0.1)', + cactus: '#54a844', + cactusDark: '#3c7c30', + mountain: '#8c7c68', + mountainSnow: '#fcfcfc', + rain: 'rgba(120, 160, 200, 0.5)', + snow: '#fcfcfc' + }; + + // Weather state + this.weather = { + condition: 'clear', // clear, cloudy, rain, snow + description: 'Clear', + temp: 0, + sunrise: null, + sunset: null, + lastUpdate: 0 + }; + + // View direction state (0=North, 90=East, 180=South, 270=West) + this.viewDirection = 0; + this.viewDirectionNames = { 0: 'N', 90: 'E', 180: 'S', 270: 'W' }; + this.fieldOfView = 90; // 90 degree field of view + + // Theme for background images (loaded from config) + this.theme = 'desert'; + + // Display preferences (loaded from config) + this.displayConfig = { + temperature_unit: 'F', + show_weather: true, + show_sidebar: true, + default_view_direction: 0 + }; + this.siteConfig = { + title: 'ADS-Bit', + subtitle: 'Retro Flight Tracker' + }; + + // Hover and selection tracking + this.mouseX = -1; + this.mouseY = -1; + this.hoveredAircraft = null; + this.selectedAircraftIcao = null; // Selected from sidebar click + this.visibleAircraftList = []; // For sidebar display + this.lastSidebarUpdate = 0; // Throttle sidebar updates + this.lastStatsUpdate = 0; // Throttle stats updates + this.cachedCountText = ''; + this.cachedRangeText = ''; + + // Load aircraft sprite images + this.aircraftImages = { + smallProp: new Image(), + regionalJet: new Image(), + narrowBody: new Image(), + wideBody: new Image(), + heavy: new Image(), + helicopter: new Image() + }; + + // Track which images have loaded + this.aircraftImagesLoaded = { + smallProp: false, + regionalJet: false, + narrowBody: false, + wideBody: false, + heavy: false, + helicopter: false + }; + + // Load all aircraft images + Object.keys(this.aircraftImages).forEach(type => { + this.aircraftImages[type].onload = () => { + this.aircraftImagesLoaded[type] = true; + console.log(`${type} sprite loaded`); + }; + this.aircraftImages[type].onerror = () => { + console.warn(`Failed to load ${type} sprite`); + }; + this.aircraftImages[type].src = `images/${type}.png?v=24`; + }); + + // Load environment images (directional backgrounds, base, sun, clouds) + // Directional background images (loaded after fetching config) + this.backgroundImages = { + 0: new Image(), // North + 90: new Image(), // East + 180: new Image(), // South + 270: new Image() // West + }; + this.backgroundImagesLoaded = { 0: false, 90: false, 180: false, 270: false }; + + this.sunImage = new Image(); + this.sunImage.onload = () => { + this.sunImageLoaded = true; + console.log('sun.png loaded'); + }; + this.sunImage.onerror = () => { + console.warn('Failed to load sun.png'); + }; + this.sunImage.src = 'images/sun.png?v=24'; + + this.happyCloudImage = new Image(); + this.happyCloudImage.onload = () => { + this.happyCloudImageLoaded = true; + console.log('happycloud.png loaded'); + }; + this.happyCloudImage.onerror = () => { + console.warn('Failed to load happycloud.png'); + }; + this.happyCloudImage.src = 'images/happycloud.png?v=24'; + + this.rainCloudImage = new Image(); + this.rainCloudImage.onload = () => { + this.rainCloudImageLoaded = true; + console.log('raincloud.png loaded'); + }; + this.rainCloudImage.onerror = () => { + console.warn('Failed to load raincloud.png'); + }; + this.rainCloudImage.src = 'images/raincloud.png?v=24'; + + this.moonSprite = new Image(); + this.moonSprite.onload = () => { + this.moonSpriteLoaded = true; + console.log('moon_6_phases.png loaded'); + }; + this.moonSprite.onerror = () => { + console.warn('Failed to load moon_6_phases.png'); + }; + this.moonSprite.src = 'images/moon_6_phases.png?v=32'; + + // Track which environment images have loaded + this.sunImageLoaded = false; + this.happyCloudImageLoaded = false; + this.rainCloudImageLoaded = false; + this.moonSpriteLoaded = false; + + // Pixel art sprites (kept as fallback and for other elements) + this.sprites = this.createSprites(); + + this.init(); + } + + createSprites() { + // Define pixel art sprites as 2D arrays (1 = mast, 2 = white ring, 3 = base) + return { + // ADS-B Antenna + antenna: [ + [0,0,1,0,0], // Top tip + [0,1,2,1,0], // Top ring (white) + [0,0,1,0,0], // Mast + [0,0,1,0,0], // Mast + [0,1,2,1,0], // Ring + [0,0,1,0,0], // Mast + [0,0,1,0,0], // Mast + [1,1,2,1,1], // Large ring + [0,0,1,0,0], // Mast + [0,0,1,0,0], // Mast + [0,3,3,3,0], // Base + [3,3,3,3,3] // Base platform + ], + // Cactus (saguaro style) + cactus: [ + [0,1,0,0,0,1,0], + [0,1,0,0,0,1,0], + [1,1,1,1,1,1,1], + [0,0,1,1,1,0,0], + [0,0,1,1,1,0,0], + [0,0,1,1,1,0,0], + [0,0,1,1,1,0,0], + [0,0,1,1,1,0,0] + ], + // Mountain (simple peak) + mountain: [ + [0,0,0,0,0,1,0,0,0,0,0], + [0,0,0,0,1,1,1,0,0,0,0], + [0,0,0,1,1,2,1,1,0,0,0], + [0,0,1,1,1,2,1,1,1,0,0], + [0,1,1,1,1,1,1,1,1,1,0], + [1,1,1,1,1,1,1,1,1,1,1] + ], + // Aircraft sprites (side view, facing right) + // Colors: 1=fuselage, 2=windows, 3=wings, 4=tail, 5=engine + + // Small prop plane (Cessna, small GA) + smallProp: [ + [0,0,0,0,4,4,0,0], // Tail + [0,0,0,4,4,1,4,0], // Tail fin + [0,0,0,1,1,1,1,0], // Rear fuselage + [0,0,1,2,1,1,1,1], // Fuselage with windows + [3,3,3,3,3,1,1,1], // Wings + nose + [0,0,1,2,1,1,5,1], // Fuselage with prop + [0,0,0,1,1,1,1,0], // Belly + [0,0,0,0,0,3,0,0] // Bottom wing + ], + + // Regional jet (CRJ, ERJ) + regionalJet: [ + [0,0,0,0,0,4,4,0,0], // Tail + [0,0,0,0,4,4,1,4,0], // Tail fin + [0,0,0,0,1,1,1,1,0], // Rear fuselage + [0,0,0,1,2,2,1,1,1], // Fuselage with windows + [0,3,3,3,3,3,3,1,1], // Wings + [0,0,0,1,2,2,1,5,1], // Fuselage + engine + [0,0,0,0,1,1,1,5,1], // Belly + engine + [0,0,0,0,0,0,3,0,0] // Wing tip + ], + + // Narrow body (737, A320) + narrowBody: [ + [0,0,0,0,0,4,4,4,0,0], // Tall tail + [0,0,0,0,4,4,1,1,4,0], // Tail fin + [0,0,0,0,1,1,1,1,1,0], // Rear fuselage + [0,0,0,1,2,2,2,1,1,1], // Windows + [0,0,3,3,3,3,3,3,1,1], // Wings + [0,0,0,1,2,2,2,1,5,5], // Lower fuselage + engines + [0,0,0,0,1,1,1,1,5,5], // Belly + engines + [0,0,0,0,0,0,3,3,0,0] // Wing tip + ], + + // Wide body (777, 787, A350) + wideBody: [ + [0,0,0,0,0,0,4,4,4,0,0], // Tall tail + [0,0,0,0,0,4,4,1,1,4,0], // Tail fin + [0,0,0,0,0,1,1,1,1,1,0], // Rear fuselage + [0,0,0,0,1,2,2,2,1,1,1], // Upper windows + [0,0,0,3,3,3,3,3,3,1,1], // Large wings + [0,0,0,3,3,3,3,3,3,1,1], // Wing body + [0,0,0,0,1,2,2,2,5,5,5], // Lower fuselage + big engines + [0,0,0,0,0,1,1,1,5,5,5], // Belly + engines + [0,0,0,0,0,0,0,3,3,0,0] // Wing tip + ], + + // Heavy/jumbo (747, A380) + heavy: [ + [0,0,0,0,0,0,4,4,4,4,0,0], // Very tall tail + [0,0,0,0,0,4,4,1,1,1,4,0], // Tail fin + [0,0,0,0,2,2,1,1,1,1,1,0], // Upper deck with windows! + [0,0,0,0,1,1,1,1,1,1,1,0], // Upper fuselage + [0,0,0,1,2,2,2,2,1,1,1,1], // Main deck windows + [0,0,3,3,3,3,3,3,3,1,1,1], // Massive wings + [0,0,3,3,3,3,3,3,3,1,1,1], // Wing body + [0,0,0,1,2,2,2,2,5,5,5,5], // Lower deck + huge engines + [0,0,0,0,1,1,1,1,5,5,5,5], // Belly + engines + [0,0,0,0,0,0,0,3,3,3,0,0] // Wing tips + ] + }; + } + + processPlaneImage() { + // Create an off-screen canvas to process the image + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = this.planeImage.width; + tempCanvas.height = this.planeImage.height; + const tempCtx = tempCanvas.getContext('2d'); + + // Draw the original image + tempCtx.drawImage(this.planeImage, 0, 0); + + // Get image data + const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); + const data = imageData.data; + const width = tempCanvas.width; + const height = tempCanvas.height; + + // Flood fill from corners to mark background pixels + const isBackground = new Uint8Array(width * height); + + const isWhiteish = (r, g, b) => r > 240 && g > 240 && b > 240; + + const floodFill = (startX, startY) => { + const stack = [[startX, startY]]; + + while (stack.length > 0) { + const [x, y] = stack.pop(); + + if (x < 0 || x >= width || y < 0 || y >= height) continue; + + const idx = y * width + x; + if (isBackground[idx]) continue; + + const pixelIdx = idx * 4; + const r = data[pixelIdx]; + const g = data[pixelIdx + 1]; + const b = data[pixelIdx + 2]; + + if (!isWhiteish(r, g, b)) continue; + + isBackground[idx] = 1; + + // Add neighbors + stack.push([x + 1, y]); + stack.push([x - 1, y]); + stack.push([x, y + 1]); + stack.push([x, y - 1]); + } + }; + + // Flood fill from all four corners + floodFill(0, 0); + floodFill(width - 1, 0); + floodFill(0, height - 1); + floodFill(width - 1, height - 1); + + // Make background pixels transparent + for (let i = 0; i < isBackground.length; i++) { + if (isBackground[i]) { + data[i * 4 + 3] = 0; // Set alpha to 0 + } + } + + // Put the modified data back + tempCtx.putImageData(imageData, 0, 0); + + // Create a new image from the processed canvas + this.processedPlaneImage = new Image(); + this.processedPlaneImage.src = tempCanvas.toDataURL(); + this.processedPlaneImage.onload = () => { + this.planeImageLoaded = true; + console.log('Plane image processed and loaded'); + }; + } + + setupResponsiveCanvas() { + const resizeCanvas = () => { + const container = this.canvas.parentElement; + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + + // Maintain 4:3 aspect ratio (800x600) + const aspectRatio = 4 / 3; + let newWidth, newHeight; + + if (containerWidth / containerHeight > aspectRatio) { + // Container is wider - fit to height + newHeight = containerHeight; + newWidth = newHeight * aspectRatio; + } else { + // Container is taller - fit to width + newWidth = containerWidth; + newHeight = newWidth / aspectRatio; + } + + // Set canvas internal resolution (1200x900 for larger display) + this.canvas.width = 1200; + this.canvas.height = 900; + + // Update stored dimensions + this.width = 1200; + this.height = 900; + + // CSS will handle the visual scaling via CSS in HTML + }; + + // Initial resize + resizeCanvas(); + + // Handle window resize and orientation change + window.addEventListener('resize', resizeCanvas); + window.addEventListener('orientationchange', () => { + setTimeout(resizeCanvas, 100); + }); + } + + async init() { + // Fetch config (includes theme and location) + await this.fetchConfig(); + + // Load background images based on theme + this.loadBackgroundImages(); + + // Fetch weather + await this.fetchWeather(); + + // Update weather every 10 minutes + setInterval(() => this.fetchWeather(), 600000); + + // Update date/time display every second + setInterval(() => this.updateWeatherDisplay(), 1000); + + // Setup view direction controls + this.setupViewControls(); + + // Start rendering loop + this.render(); + setInterval(() => this.render(), 100); // 10 FPS for retro feel + + // Connect to WebSocket + this.connectWebSocket(); + } + + setupViewControls() { + // Keyboard controls + document.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft' || e.key === 'a' || e.key === 'A') { + this.rotateView(-90); + } else if (e.key === 'ArrowRight' || e.key === 'd' || e.key === 'D') { + this.rotateView(90); + } + }); + + // Click handlers for arrow buttons + const leftArrow = document.getElementById('view-left'); + const rightArrow = document.getElementById('view-right'); + + if (leftArrow) { + leftArrow.addEventListener('click', () => this.rotateView(-90)); + } + if (rightArrow) { + rightArrow.addEventListener('click', () => this.rotateView(90)); + } + + // Mouse tracking for hover labels + this.canvas.addEventListener('mousemove', (e) => { + const rect = this.canvas.getBoundingClientRect(); + // Convert mouse position to canvas coordinates + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + this.mouseX = (e.clientX - rect.left) * scaleX; + this.mouseY = (e.clientY - rect.top) * scaleY; + }); + + this.canvas.addEventListener('mouseleave', () => { + this.mouseX = -1; + this.mouseY = -1; + this.hoveredAircraft = null; + }); + + // Click on canvas to deselect + this.canvas.addEventListener('click', () => { + this.selectedAircraftIcao = null; + this.updateAircraftSidebar(); + }); + + // Sidebar click handler (delegated) + const sidebar = document.getElementById('aircraft-sidebar'); + if (sidebar) { + sidebar.addEventListener('click', (e) => { + const aircraftEl = e.target.closest('.sidebar-aircraft'); + if (aircraftEl) { + const icao = aircraftEl.dataset.icao; + // Toggle selection + if (this.selectedAircraftIcao === icao) { + this.selectedAircraftIcao = null; + } else { + this.selectedAircraftIcao = icao; + } + this.updateAircraftSidebar(); + } + }); + } + + // Update initial compass display + this.updateCompassDisplay(); + } + + rotateView(degrees) { + this.viewDirection = (this.viewDirection + degrees + 360) % 360; + this.updateCompassDisplay(); + console.log(`View direction: ${this.viewDirectionNames[this.viewDirection]} (${this.viewDirection}°)`); + } + + updateCompassDisplay() { + const compassEl = document.getElementById('compass-direction'); + if (compassEl) { + const dirName = this.viewDirectionNames[this.viewDirection]; + const fullNames = { 'N': 'NORTH', 'E': 'EAST', 'S': 'SOUTH', 'W': 'WEST' }; + compassEl.textContent = `VIEWING: ${fullNames[dirName]}`; + } + } + + // Check if a bearing falls within the current field of view + isInFieldOfView(bearing) { + const halfFov = this.fieldOfView / 2; + const minAngle = (this.viewDirection - halfFov + 360) % 360; + const maxAngle = (this.viewDirection + halfFov) % 360; + + // Handle wrap-around at 0/360 + if (minAngle > maxAngle) { + return bearing >= minAngle || bearing <= maxAngle; + } else { + return bearing >= minAngle && bearing <= maxAngle; + } + } + + // Get X position based on bearing within field of view + bearingToX(bearing) { + const halfFov = this.fieldOfView / 2; + + // Calculate angle difference from view direction + let angleDiff = bearing - this.viewDirection; + + // Normalize to -180 to 180 + if (angleDiff > 180) angleDiff -= 360; + if (angleDiff < -180) angleDiff += 360; + + // Map -45 to +45 degrees to screen X (with padding) + const padding = 60; + const usableWidth = this.width - (padding * 2); + + // -45° = left edge, +45° = right edge + const normalizedAngle = (angleDiff + halfFov) / this.fieldOfView; + return padding + (normalizedAngle * usableWidth); + } + + async fetchConfig() { + try { + const response = await fetch('/api/config'); + const data = await response.json(); + this.theme = data.theme || 'desert'; + this.receiverLat = data.location?.lat || 0; + this.receiverLon = data.location?.lon || 0; + this.locationName = data.location?.name || 'My Location'; + this.receivers = data.receivers || []; + + // Apply site config + if (data.site) { + this.siteConfig = { ...this.siteConfig, ...data.site }; + const titleEl = document.getElementById('site-title'); + if (titleEl) titleEl.textContent = this.siteConfig.title; + document.title = `${this.siteConfig.title} - ${this.siteConfig.subtitle}`; + } + + // Apply display config + if (data.display) { + this.displayConfig = { ...this.displayConfig, ...data.display }; + + // Apply default view direction + if (this.displayConfig.default_view_direction) { + this.viewDirection = this.displayConfig.default_view_direction; + } + + // Show/hide sidebar + const sidebar = document.getElementById('aircraft-sidebar'); + if (sidebar) { + sidebar.style.display = this.displayConfig.show_sidebar ? '' : 'none'; + } + + // Show/hide weather bar + const weatherBar = document.getElementById('weather-info'); + if (weatherBar) { + weatherBar.style.display = this.displayConfig.show_weather ? '' : 'none'; + } + } + + console.log(`Config loaded - Theme: ${this.theme}, Location: ${this.locationName} (${this.receiverLat}, ${this.receiverLon})`); + console.log(`Receivers: ${this.receivers.join(', ') || 'none'}`); + } catch (error) { + console.warn('Could not fetch config, using defaults'); + // Fallback to receiver-location API for backwards compatibility + await this.fetchReceiverLocation(); + } + } + + loadBackgroundImages() { + const directions = [ + { deg: 0, name: 'north' }, + { deg: 90, name: 'east' }, + { deg: 180, name: 'south' }, + { deg: 270, name: 'west' } + ]; + directions.forEach(dir => { + this.backgroundImages[dir.deg].onload = () => { + this.backgroundImagesLoaded[dir.deg] = true; + console.log(`${this.theme}/${dir.name}.png loaded`); + }; + this.backgroundImages[dir.deg].onerror = () => { + console.warn(`Failed to load backgrounds/${this.theme}/${dir.name}.png`); + }; + this.backgroundImages[dir.deg].src = `backgrounds/${this.theme}/${dir.name}.png?v=1`; + }); + } + + async fetchReceiverLocation() { + try { + // Fetch from same server that serves pixel-view + const response = await fetch('/api/receiver-location'); + const data = await response.json(); + this.receiverLat = data.lat; + this.receiverLon = data.lon; + this.locationName = data.name || 'My Location'; + console.log(`Receiver location: ${this.locationName} (${this.receiverLat}, ${this.receiverLon})`); + } catch (error) { + console.warn('Could not fetch receiver location, using default'); + } + } + + async fetchWeather() { + try { + // Use Open-Meteo API (free, no API key needed) with daily forecast for sunrise/sunset + const url = `https://api.open-meteo.com/v1/forecast?latitude=${this.receiverLat}&longitude=${this.receiverLon}¤t_weather=true&daily=sunrise,sunset&timezone=auto`; + const response = await fetch(url); + const data = await response.json(); + + const weatherCode = data.current_weather.weathercode; + this.weather.temp = data.current_weather.temperature; + + // Get today's sunrise and sunset times + if (data.daily && data.daily.sunrise && data.daily.sunset) { + this.weather.sunrise = new Date(data.daily.sunrise[0]); + this.weather.sunset = new Date(data.daily.sunset[0]); + } + + // Map weather codes to conditions + // 0 = clear, 1-3 = partly cloudy, 45-48 = fog, 51-67 = rain, 71-77 = snow, 80-99 = rain/thunderstorm + if (weatherCode === 0) { + this.weather.condition = 'clear'; + this.weather.description = 'Clear'; + } else if (weatherCode <= 3) { + this.weather.condition = 'cloudy'; + this.weather.description = 'Partly Cloudy'; + } else if ((weatherCode >= 51 && weatherCode <= 67) || (weatherCode >= 80 && weatherCode <= 99)) { + this.weather.condition = 'rain'; + this.weather.description = 'Rainy'; + } else if (weatherCode >= 71 && weatherCode <= 77) { + this.weather.condition = 'snow'; + this.weather.description = 'Snowy'; + } else { + this.weather.condition = 'cloudy'; + this.weather.description = 'Cloudy'; + } + + this.weather.lastUpdate = Date.now(); + this.updateWeatherDisplay(); + console.log(`Weather updated: ${this.weather.condition}, ${this.weather.temp}°C`); + } catch (error) { + console.warn('Could not fetch weather data', error); + this.weather.condition = 'clear'; // Default to clear + } + } + + updateWeatherDisplay() { + // Update date/time display + const now = new Date(); + const options = { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }; + const dateTimeStr = now.toLocaleString('en-US', options); + document.getElementById('datetime-display').textContent = `${this.locationName} - ${dateTimeStr}`; + + // Update weather display with configured unit + let tempDisplay; + if (this.displayConfig.temperature_unit === 'C') { + tempDisplay = `${Math.round(this.weather.temp)}°C`; + } else { + tempDisplay = `${Math.round((this.weather.temp * 9/5) + 32)}°F`; + } + document.getElementById('weather-display').textContent = `${tempDisplay} - ${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'); + const receiverText = this.receivers && this.receivers.length > 0 + ? `CONNECTED: ${this.receivers.join(', ')}` + : 'CONNECTED'; + document.getElementById('connection-status').textContent = receiverText; + 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ac3155 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiohttp>=3.8.0 +netifaces>=0.11.0 +bcrypt>=4.0.0 diff --git a/screenshots/screenshot.png b/screenshots/screenshot.png new file mode 100644 index 0000000..b0d88d3 Binary files /dev/null and b/screenshots/screenshot.png differ diff --git a/server.py b/server.py new file mode 100755 index 0000000..f8db615 --- /dev/null +++ b/server.py @@ -0,0 +1,878 @@ +#!/usr/bin/env python3 +"""Pixel ADS-B Server - Connects directly to ADS-B receivers""" +import asyncio +import socket +import json +import time +import os +import copy +import secrets +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 +import bcrypt + +WEB_DIR = Path(__file__).parent +CONFIG_FILE = WEB_DIR / "config.json" + +# Track server start time +SERVER_START_TIME = time.time() + +# Default configuration - all possible settings with sane defaults +DEFAULT_CONFIG = { + "setup_complete": False, + "admin": { + "password_hash": "", + "session_secret": "" + }, + "receivers": "AUTO", + "receiver_port": 30003, + "location": { + "name": "My Location", + "lat": 0.0, + "lon": 0.0 + }, + "web_port": 2001, + "theme": "desert", + "site": { + "title": "ADS-Bit", + "subtitle": "Retro Flight Tracker" + }, + "display": { + "temperature_unit": "F", + "show_weather": True, + "show_sidebar": True, + "default_view_direction": 0 + }, + "tuning": { + "flight_timeout_seconds": 60, + "broadcast_interval_seconds": 1, + "cleanup_interval_seconds": 10, + "receiver_reconnect_seconds": 5 + } +} + + +def deep_merge(base: dict, override: dict) -> dict: + """Deep merge override into base, returning new dict. Base values used as defaults.""" + result = copy.deepcopy(base) + for key, value in override.items(): + if key in result and isinstance(result[key], dict) and isinstance(value, dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = copy.deepcopy(value) + return result + + +# Active configuration +config = copy.deepcopy(DEFAULT_CONFIG) + + +def load_config(): + """Load configuration from config.json, deep-merged over defaults.""" + global config + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, 'r') as f: + loaded = json.load(f) + config = deep_merge(DEFAULT_CONFIG, loaded) + print(f"Loaded config from {CONFIG_FILE}") + except Exception as e: + print(f"Error loading config: {e}, using defaults") + config = copy.deepcopy(DEFAULT_CONFIG) + else: + print(f"No config file found at {CONFIG_FILE}, using defaults") + config = copy.deepcopy(DEFAULT_CONFIG) + + print(f" Setup complete: {config['setup_complete']}") + 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']}") + print(f" Theme: {config.get('theme', 'desert')}") + + +def save_config(): + """Persist current config to config.json.""" + try: + with open(CONFIG_FILE, 'w') as f: + json.dump(config, f, indent=2) + print(f"Config saved to {CONFIG_FILE}") + except Exception as e: + print(f"Error saving config: {e}") + + +# --------------------------------------------------------------------------- +# Session management (in-memory, lost on restart) +# --------------------------------------------------------------------------- +sessions: Dict[str, float] = {} # token -> expiry timestamp +SESSION_DURATION = 86400 # 24 hours + + +def create_session() -> str: + token = secrets.token_hex(32) + sessions[token] = time.time() + SESSION_DURATION + return token + + +def validate_session(token: str) -> bool: + if not token: + return False + expiry = sessions.get(token) + if expiry is None: + return False + if time.time() > expiry: + sessions.pop(token, None) + return False + return True + + +def destroy_session(token: str): + sessions.pop(token, None) + + +def get_session_token(request) -> str: + """Extract session token from cookie.""" + return request.cookies.get('adsbit_session', '') + + +def require_auth(handler): + """Decorator that checks for valid admin session.""" + async def wrapper(request): + token = get_session_token(request) + if not validate_session(token): + return web.json_response({"error": "Unauthorized"}, status=401) + return await handler(request) + return wrapper + + +def require_setup_incomplete(handler): + """Decorator that only allows access when setup is not complete.""" + async def wrapper(request): + if config.get("setup_complete", False): + return web.json_response({"error": "Setup already complete"}, status=403) + return await handler(request) + return wrapper + + +# --------------------------------------------------------------------------- +# Flight data storage +# --------------------------------------------------------------------------- +flights: Dict[str, dict] = {} +connected_clients: Set = set() +receivers: List[str] = [] +receiver_tasks: List[asyncio.Task] = [] + + +@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 + + icao = parts[4].strip() + if not icao: + return None + + msg = SBSMessage(msg_type=msg_type, icao=icao) + + if len(parts) > 10 and parts[10].strip(): + msg.callsign = parts[10].strip() + if len(parts) > 11 and parts[11].strip(): + try: + msg.altitude = int(parts[11]) + except ValueError: + pass + if len(parts) > 12 and parts[12].strip(): + try: + msg.speed = float(parts[12]) + except ValueError: + pass + if len(parts) > 13 and parts[13].strip(): + try: + msg.track = float(parts[13]) + except ValueError: + pass + if len(parts) > 14 and parts[14].strip(): + try: + msg.lat = float(parts[14]) + except ValueError: + pass + if len(parts) > 15 and parts[15].strip(): + try: + msg.lon = float(parts[15]) + except ValueError: + pass + if len(parts) > 16 and parts[16].strip(): + try: + msg.vrate = int(parts[16]) + except ValueError: + pass + if len(parts) > 17 and parts[17].strip(): + msg.squawk = parts[17].strip() + 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""" + reconnect_delay = config['tuning']['receiver_reconnect_seconds'] + 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: + 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(reconnect_delay) + + +async def scan_for_receivers(): + """Scan local network for ADS-B receivers on configured port""" + port = config['receiver_port'] + print(f"Scanning for ADS-B receivers on port {port}...") + found = [] + + 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.'): + try: + network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) + print(f"Scanning {network} ({network.num_addresses} hosts)...") + + host_ips = [str(host) for host in network.hosts()] + + async def check_ip(test_ip): + if await test_port(test_ip, port): + return test_ip + return None + + results = await asyncio.gather(*[check_ip(h) for h in host_ips]) + for result in results: + if result: + print(f"Found receiver at {result}:{port}") + found.append(result) + except ValueError as e: + print(f"Invalid network {ip}/{netmask}: {e}") + + return found + + +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 recently (uses tuning config).""" + while True: + interval = config['tuning']['cleanup_interval_seconds'] + timeout = config['tuning']['flight_timeout_seconds'] + await asyncio.sleep(interval) + now = time.time() + to_remove = [icao for icao, flight in flights.items() + if now - flight['last_seen'] > timeout] + for icao in to_remove: + del flights[icao] + + +async def broadcast_flights(): + """Broadcast flight data to all connected WebSocket clients.""" + while True: + interval = config['tuning']['broadcast_interval_seconds'] + await asyncio.sleep(interval) + if connected_clients: + flight_list = [f for f in flights.values() if f['lat'] and f['lon']] + message = json.dumps({ + 'type': 'flights', + 'flights': flight_list, + 'count': len(flight_list) + }) + + dead_clients = set() + for client in connected_clients: + try: + await client.send_str(message) + except: + dead_clients.add(client) + + connected_clients.difference_update(dead_clients) + + +# --------------------------------------------------------------------------- +# WebSocket handler +# --------------------------------------------------------------------------- +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 + finally: + connected_clients.discard(ws) + print(f"WebSocket client disconnected ({len(connected_clients)} remaining)") + + return ws + + +# --------------------------------------------------------------------------- +# Public API routes (no auth) +# --------------------------------------------------------------------------- +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_config(request): + """Return client-relevant configuration (public, no secrets).""" + return web.json_response({ + "theme": config.get("theme", "desert"), + "location": config["location"], + "receivers": receivers, + "site": config.get("site", DEFAULT_CONFIG["site"]), + "display": config.get("display", DEFAULT_CONFIG["display"]) + }) + + +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- +async def handle_auth_login(request): + """POST /api/auth/login - Authenticate with admin password.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + password = body.get("password", "") + stored_hash = config.get("admin", {}).get("password_hash", "") + + if not stored_hash: + return web.json_response({"error": "No admin password set"}, status=403) + + try: + if bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')): + token = create_session() + resp = web.json_response({"ok": True}) + resp.set_cookie('adsbit_session', token, + max_age=SESSION_DURATION, + httponly=True, + samesite='Lax', + path='/') + return resp + except Exception: + pass + + return web.json_response({"error": "Invalid password"}, status=401) + + +async def handle_auth_logout(request): + """POST /api/auth/logout - Destroy session.""" + token = get_session_token(request) + destroy_session(token) + resp = web.json_response({"ok": True}) + resp.del_cookie('adsbit_session', path='/') + return resp + + +async def handle_auth_check(request): + """GET /api/auth/check - Check if current session is valid.""" + token = get_session_token(request) + if validate_session(token): + return web.json_response({"authenticated": True}) + return web.json_response({"authenticated": False}, status=401) + + +# --------------------------------------------------------------------------- +# Setup routes (only when setup_complete is false) +# --------------------------------------------------------------------------- +@require_setup_incomplete +async def handle_setup_password(request): + """POST /api/setup/password - Set initial admin password.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + password = body.get("password", "") + if len(password) < 4: + return web.json_response({"error": "Password must be at least 4 characters"}, status=400) + + hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + config['admin']['password_hash'] = hashed + config['admin']['session_secret'] = secrets.token_hex(16) + save_config() + return web.json_response({"ok": True}) + + +@require_setup_incomplete +async def handle_setup_location(request): + """POST /api/setup/location - Set location during setup.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + config['location']['name'] = body.get("name", config['location']['name']) + config['location']['lat'] = float(body.get("lat", config['location']['lat'])) + config['location']['lon'] = float(body.get("lon", config['location']['lon'])) + save_config() + return web.json_response({"ok": True, "location": config['location']}) + + +@require_setup_incomplete +async def handle_setup_receivers(request): + """POST /api/setup/receivers - Configure receivers during setup.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + mode = body.get("mode", "AUTO") + if mode == "AUTO": + config['receivers'] = "AUTO" + else: + ips = body.get("ips", []) + if isinstance(ips, list) and ips: + config['receivers'] = ips + else: + config['receivers'] = "AUTO" + + if "port" in body: + config['receiver_port'] = int(body['port']) + + save_config() + return web.json_response({"ok": True}) + + +@require_setup_incomplete +async def handle_setup_scan(request): + """GET /api/setup/scan - Scan for receivers during setup.""" + found = await scan_for_receivers() + return web.json_response({"receivers": found}) + + +@require_setup_incomplete +async def handle_setup_theme(request): + """POST /api/setup/theme - Set theme and preferences during setup.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + if "theme" in body: + config['theme'] = body['theme'] + if "temperature_unit" in body: + config['display']['temperature_unit'] = body['temperature_unit'] + if "site_title" in body: + config['site']['title'] = body['site_title'] + if "site_subtitle" in body: + config['site']['subtitle'] = body['site_subtitle'] + + save_config() + return web.json_response({"ok": True}) + + +@require_setup_incomplete +async def handle_setup_complete(request): + """POST /api/setup/complete - Mark setup as done.""" + # Verify a password was set + if not config.get('admin', {}).get('password_hash', ''): + return web.json_response({"error": "Admin password must be set first"}, status=400) + + config['setup_complete'] = True + save_config() + + # Restart receiver connections with new config + await restart_receiver_connections() + + return web.json_response({"ok": True}) + + +# --------------------------------------------------------------------------- +# Admin API routes (require auth) +# --------------------------------------------------------------------------- +@require_auth +async def handle_admin_config(request): + """GET /api/admin/config - Return full config (minus password hash).""" + safe_config = copy.deepcopy(config) + safe_config['admin'] = {"password_set": bool(config['admin']['password_hash'])} + return web.json_response(safe_config) + + +@require_auth +async def handle_admin_config_update(request): + """PUT /api/admin/config - Update config sections.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + # Updatable sections + if "location" in body: + loc = body["location"] + if "name" in loc: + config['location']['name'] = loc['name'] + if "lat" in loc: + config['location']['lat'] = float(loc['lat']) + if "lon" in loc: + config['location']['lon'] = float(loc['lon']) + + if "receivers" in body: + config['receivers'] = body['receivers'] + + if "receiver_port" in body: + config['receiver_port'] = int(body['receiver_port']) + + if "theme" in body: + config['theme'] = body['theme'] + + if "site" in body: + for k in ('title', 'subtitle'): + if k in body['site']: + config['site'][k] = body['site'][k] + + if "display" in body: + for k in ('temperature_unit', 'show_weather', 'show_sidebar', 'default_view_direction'): + if k in body['display']: + config['display'][k] = body['display'][k] + + if "tuning" in body: + for k in ('flight_timeout_seconds', 'broadcast_interval_seconds', + 'cleanup_interval_seconds', 'receiver_reconnect_seconds'): + if k in body['tuning']: + config['tuning'][k] = int(body['tuning'][k]) + + save_config() + return web.json_response({"ok": True}) + + +@require_auth +async def handle_admin_status(request): + """GET /api/admin/status - Live dashboard data.""" + flight_list = [f for f in flights.values() if f['lat'] and f['lon']] + uptime = int(time.time() - SERVER_START_TIME) + hours, remainder = divmod(uptime, 3600) + minutes, seconds = divmod(remainder, 60) + + return web.json_response({ + "active_flights": len(flight_list), + "total_tracked": len(flights), + "connected_viewers": len(connected_clients), + "receivers": receivers, + "receiver_count": len(receivers), + "uptime": f"{hours}h {minutes}m {seconds}s", + "uptime_seconds": uptime + }) + + +@require_auth +async def handle_admin_themes(request): + """GET /api/admin/themes - List available themes.""" + bg_dir = WEB_DIR / "backgrounds" + themes = [] + if bg_dir.exists(): + for d in sorted(bg_dir.iterdir()): + if d.is_dir() and (d / "north.png").exists(): + themes.append(d.name) + return web.json_response({"themes": themes}) + + +@require_auth +async def handle_admin_password(request): + """POST /api/admin/password - Change admin password.""" + try: + body = await request.json() + except: + return web.json_response({"error": "Invalid JSON"}, status=400) + + current = body.get("current_password", "") + new_pw = body.get("new_password", "") + + if len(new_pw) < 4: + return web.json_response({"error": "Password must be at least 4 characters"}, status=400) + + stored_hash = config.get("admin", {}).get("password_hash", "") + if stored_hash: + try: + if not bcrypt.checkpw(current.encode('utf-8'), stored_hash.encode('utf-8')): + return web.json_response({"error": "Current password is incorrect"}, status=401) + except Exception: + return web.json_response({"error": "Current password is incorrect"}, status=401) + + hashed = bcrypt.hashpw(new_pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + config['admin']['password_hash'] = hashed + save_config() + return web.json_response({"ok": True}) + + +@require_auth +async def handle_admin_scan_receivers(request): + """POST /api/admin/scan-receivers - Trigger a receiver scan.""" + found = await scan_for_receivers() + return web.json_response({"receivers": found}) + + +async def restart_receiver_connections(): + """Cancel existing receiver tasks and start new ones.""" + global receivers, receiver_tasks + + # Cancel old tasks + for task in receiver_tasks: + task.cancel() + receiver_tasks.clear() + + # Determine receivers + receiver_config = config["receivers"] + receiver_port = config["receiver_port"] + + try: + if receiver_config == "AUTO": + found = await scan_for_receivers() + receivers = found + elif isinstance(receiver_config, list): + receivers = list(receiver_config) + elif isinstance(receiver_config, str) and receiver_config != "AUTO": + receivers = [receiver_config] + else: + receivers = [] + except Exception as e: + print(f"Error determining receivers: {e}") + receivers = [] + + # Start new tasks + for r in receivers: + task = asyncio.create_task(connect_to_receiver(r, receiver_port)) + receiver_tasks.append(task) + + print(f"Receiver connections restarted: {len(receivers)} receiver(s)") + + +@require_auth +async def handle_admin_restart_receivers(request): + """POST /api/admin/restart-receivers - Restart all receiver connections.""" + await restart_receiver_connections() + return web.json_response({"ok": True, "receivers": receivers}) + + +# --------------------------------------------------------------------------- +# Static file serving with setup redirect +# --------------------------------------------------------------------------- +async def handle_http(request): + """Serve static files, with setup redirect logic.""" + path = request.path + + # Root redirect: if setup not complete, redirect to /setup + if path == '/' or path == '/index.html': + if not config.get('setup_complete', False): + raise web.HTTPFound('/setup') + path = '/index.html' + + # Serve /setup -> setup/setup.html + if path == '/setup' or path == '/setup/': + file_path = WEB_DIR / 'setup' / 'setup.html' + if file_path.exists(): + return web.FileResponse(file_path) + return web.Response(status=404, text="Setup page not found") + + # Serve /admin -> admin/admin.html + if path == '/admin' or path == '/admin/': + file_path = WEB_DIR / 'admin' / 'admin.html' + if file_path.exists(): + return web.FileResponse(file_path) + return web.Response(status=404, text="Admin page not found") + + 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") + + +# --------------------------------------------------------------------------- +# Application setup +# --------------------------------------------------------------------------- +async def start_http_server(): + """Start HTTP server with WebSocket support.""" + port = config["web_port"] + app = web.Application() + + # WebSocket + app.router.add_get('/ws', websocket_handler) + + # Public API + app.router.add_get('/api/receiver-location', handle_receiver_location) + app.router.add_get('/api/config', handle_config) + + # Auth API + app.router.add_post('/api/auth/login', handle_auth_login) + app.router.add_post('/api/auth/logout', handle_auth_logout) + app.router.add_get('/api/auth/check', handle_auth_check) + + # Setup API + app.router.add_post('/api/setup/password', handle_setup_password) + app.router.add_post('/api/setup/location', handle_setup_location) + app.router.add_post('/api/setup/receivers', handle_setup_receivers) + app.router.add_get('/api/setup/scan', handle_setup_scan) + app.router.add_post('/api/setup/theme', handle_setup_theme) + app.router.add_post('/api/setup/complete', handle_setup_complete) + + # Admin API + app.router.add_get('/api/admin/config', handle_admin_config) + app.router.add_put('/api/admin/config', handle_admin_config_update) + app.router.add_get('/api/admin/status', handle_admin_status) + app.router.add_get('/api/admin/themes', handle_admin_themes) + app.router.add_post('/api/admin/password', handle_admin_password) + app.router.add_post('/api/admin/scan-receivers', handle_admin_scan_receivers) + app.router.add_post('/api/admin/restart-receivers', handle_admin_restart_receivers) + + # Static files (must be last - catch-all) + 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 running on http://0.0.0.0:{port}") + if not config.get('setup_complete', False): + print(f"First-run setup at http://0.0.0.0:{port}/setup") + print(f"Admin panel at http://0.0.0.0:{port}/admin") + + +async def main(): + """Main entry point""" + global receivers, receiver_tasks + + print("Pixel ADS-B Server Starting...") + + 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": + found = await scan_for_receivers() + receivers = found + elif isinstance(receiver_config, list): + receivers = receiver_config + print(f"Using configured receivers: {receivers}") + elif isinstance(receiver_config, str): + 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: + task = asyncio.ensure_future(connect_to_receiver(receiver, receiver_port)) + receiver_tasks.append(task) + tasks.append(task) + + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/setup/setup.css b/setup/setup.css new file mode 100644 index 0000000..2fbc57e --- /dev/null +++ b/setup/setup.css @@ -0,0 +1,336 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); + color: #fcfcfc; + font-family: 'Courier New', monospace; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.hidden { display: none !important; } + +.setup-container { + max-width: 560px; + width: 100%; +} + +/* Progress */ +.progress-bar { + height: 6px; + background: rgba(92, 148, 252, 0.2); + border-radius: 3px; + margin-bottom: 8px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0%; + background: #fcd444; + border-radius: 3px; + transition: width 0.4s ease; +} + +.progress-steps { + display: flex; + justify-content: space-between; + margin-bottom: 24px; + padding: 0 4px; +} + +.step-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(92, 148, 252, 0.3); + border: 2px solid #5c94fc; + transition: all 0.3s; +} + +.step-dot.active { + background: #fcd444; + border-color: #fcd444; +} + +.step-dot.done { + background: #54fc54; + border-color: #54fc54; +} + +/* Steps */ +.step { + display: none; + background: rgba(52, 73, 94, 0.9); + border: 3px solid #5c94fc; + border-radius: 12px; + padding: 32px; + text-align: center; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + animation: fadeIn 0.3s ease; +} + +.step.active { + display: block; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.step-icon { + font-size: 48px; + color: #fcd444; + margin-bottom: 12px; +} + +.retro-title { + font-family: 'Press Start 2P', monospace; + color: #fcd444; + text-shadow: 2px 2px 0px #000; + font-size: 20px; + margin-bottom: 16px; +} + +.step-title { + font-family: 'Press Start 2P', monospace; + color: #fcd444; + text-shadow: 2px 2px 0px #000; + font-size: 14px; + margin-bottom: 12px; +} + +.step-desc { + color: #b4d4ec; + font-size: 14px; + line-height: 1.6; + margin-bottom: 16px; +} + +.feature-list { + list-style: none; + margin-bottom: 24px; + text-align: left; + display: inline-block; +} + +.feature-list li { + padding: 4px 0; + color: #b4d4ec; + font-size: 13px; +} + +.feature-list li::before { + content: '\2713 '; + color: #54fc54; + font-weight: bold; +} + +/* Forms */ +.form-group { + margin-bottom: 16px; + text-align: left; +} + +.form-group label { + display: block; + font-family: 'Press Start 2P', monospace; + font-size: 9px; + color: #b4d4ec; + margin-bottom: 6px; +} + +.form-row { + display: flex; + gap: 16px; +} + +.form-row .form-group { + flex: 1; +} + +input[type="password"], +input[type="text"], +input[type="number"], +textarea, +select { + background: rgba(0, 0, 0, 0.5); + border: 2px solid #5c94fc; + border-radius: 6px; + color: #fcfcfc; + font-family: 'Courier New', monospace; + font-size: 14px; + padding: 10px 12px; + width: 100%; + outline: none; + transition: border-color 0.2s; +} + +input:focus, textarea:focus, select:focus { + border-color: #fcd444; +} + +select option { + background: #2c3e50; + color: #fcfcfc; +} + +textarea { + resize: vertical; +} + +/* Buttons */ +.btn { + font-family: 'Press Start 2P', monospace; + font-size: 11px; + padding: 10px 20px; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(92, 148, 252, 0.2); + color: #fcfcfc; + cursor: pointer; + transition: all 0.15s; +} + +.btn:hover { + background: rgba(92, 148, 252, 0.4); + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background: rgba(252, 212, 68, 0.3); + border-color: #fcd444; + color: #fcd444; +} + +.btn-primary:hover { + background: rgba(252, 212, 68, 0.5); +} + +.btn-large { + font-size: 12px; + padding: 14px 32px; +} + +.btn-detect { + font-size: 9px; + padding: 8px 14px; + margin-bottom: 12px; + border-color: #54fc54; + color: #54fc54; +} + +.btn-detect:hover { + background: rgba(84, 252, 84, 0.2); +} + +.step-buttons { + display: flex; + justify-content: space-between; + margin-top: 24px; + gap: 12px; +} + +/* Messages */ +.error-msg { + color: #fc5454; + font-size: 12px; + margin-top: 8px; +} + +.status-msg { + font-size: 12px; + padding: 8px 12px; + border-radius: 6px; + margin-bottom: 12px; +} + +.status-msg.success { + color: #54fc54; + background: rgba(84, 252, 84, 0.1); + border: 1px solid rgba(84, 252, 84, 0.3); +} + +.status-msg.info { + color: #54d4fc; + background: rgba(84, 212, 252, 0.1); + border: 1px solid rgba(84, 212, 252, 0.3); +} + +.status-msg.error { + color: #fc5454; + background: rgba(252, 84, 84, 0.1); + border: 1px solid rgba(252, 84, 84, 0.3); +} + +/* Summary */ +.summary-box { + background: rgba(0, 0, 0, 0.4); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 16px; + text-align: left; + margin-bottom: 20px; +} + +.summary-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(92, 148, 252, 0.2); + font-size: 12px; +} + +.summary-row:last-child { + border-bottom: none; +} + +.summary-label { + color: #b4d4ec; + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.summary-value { + color: #fcd444; +} + +/* Mobile */ +@media (max-width: 480px) { + .step { + padding: 20px; + } + + .retro-title { + font-size: 16px; + } + + .step-title { + font-size: 12px; + } + + .form-row { + flex-direction: column; + gap: 0; + } + + .step-buttons { + flex-direction: column; + } + + .step-buttons .btn { + width: 100%; + } +} diff --git a/setup/setup.html b/setup/setup.html new file mode 100644 index 0000000..acd6d54 --- /dev/null +++ b/setup/setup.html @@ -0,0 +1,154 @@ + + + + + + ADS-Bit Setup + + + + + + +
+ +
+
+
+
+ + + + + + +
+ + +
+
+

Welcome to ADS-Bit

+

Your retro SNES-style ADS-B flight tracker. Let's get everything configured so you can start watching aircraft.

+

This wizard will guide you through:

+
    +
  • Setting an admin password
  • +
  • Configuring your location
  • +
  • Finding ADS-B receivers
  • +
  • Choosing your theme
  • +
+ +
+ + +
+

Set Admin Password

+

Protect your admin panel with a password. You'll use this to change settings later.

+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+

Your Location

+

This is used for weather data, sunrise/sunset times, and positioning aircraft relative to you.

+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+

Find Receivers

+

ADS-Bit connects to ADS-B receivers on your network (port 30003 by default). You can auto-scan or enter IPs manually.

+
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + +
+

Theme & Preferences

+

Customize how ADS-Bit looks and feels.

+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+

Setup Complete!

+

Everything is configured and ready to go. Here's a summary:

+
+

You can change any of these settings later in the admin panel.

+ +
+
+ + + + diff --git a/setup/setup.js b/setup/setup.js new file mode 100644 index 0000000..b8e93e5 --- /dev/null +++ b/setup/setup.js @@ -0,0 +1,265 @@ +// ADS-Bit First-Run Setup Wizard +const wizard = { + currentStep: 0, + totalSteps: 6, + + init() { + this.updateProgress(); + this.loadThemes(); + }, + + show(step) { + document.querySelectorAll('.step').forEach(s => s.classList.remove('active')); + const el = document.getElementById('step-' + step); + if (el) el.classList.add('active'); + this.currentStep = step; + this.updateProgress(); + + if (step === 5) this.buildSummary(); + }, + + next() { + if (this.currentStep < this.totalSteps - 1) { + this.show(this.currentStep + 1); + } + }, + + prev() { + if (this.currentStep > 0) { + this.show(this.currentStep - 1); + } + }, + + updateProgress() { + const pct = (this.currentStep / (this.totalSteps - 1)) * 100; + document.getElementById('progress-fill').style.width = pct + '%'; + + document.querySelectorAll('.step-dot').forEach(dot => { + const s = parseInt(dot.dataset.step); + dot.classList.remove('active', 'done'); + if (s === this.currentStep) dot.classList.add('active'); + else if (s < this.currentStep) dot.classList.add('done'); + }); + }, + + // Step 1: Password + async savePassword() { + const pw = document.getElementById('setup-password').value; + const confirm = document.getElementById('setup-password-confirm').value; + const errEl = document.getElementById('password-error'); + errEl.classList.add('hidden'); + + if (pw.length < 4) { + errEl.textContent = 'Password must be at least 4 characters'; + errEl.classList.remove('hidden'); + return; + } + if (pw !== confirm) { + errEl.textContent = 'Passwords do not match'; + errEl.classList.remove('hidden'); + return; + } + + try { + const res = await fetch('/api/setup/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pw }) + }); + if (res.ok) { + this.next(); + } else { + const data = await res.json(); + errEl.textContent = data.error || 'Failed to set password'; + errEl.classList.remove('hidden'); + } + } catch (e) { + errEl.textContent = 'Connection error'; + errEl.classList.remove('hidden'); + } + }, + + // Step 2: Location + detectLocation() { + const statusEl = document.getElementById('location-status'); + if (!navigator.geolocation) { + statusEl.textContent = 'Geolocation not supported by your browser'; + statusEl.className = 'status-msg error'; + statusEl.classList.remove('hidden'); + return; + } + + statusEl.textContent = 'Detecting location...'; + statusEl.className = 'status-msg info'; + statusEl.classList.remove('hidden'); + + navigator.geolocation.getCurrentPosition( + (pos) => { + document.getElementById('setup-lat').value = pos.coords.latitude.toFixed(4); + document.getElementById('setup-lon').value = pos.coords.longitude.toFixed(4); + statusEl.textContent = 'Location detected! You can edit the values if needed.'; + statusEl.className = 'status-msg success'; + }, + (err) => { + statusEl.textContent = 'Could not detect location. Please enter manually.'; + statusEl.className = 'status-msg error'; + } + ); + }, + + async saveLocation() { + const name = document.getElementById('setup-location-name').value || 'My Location'; + const lat = parseFloat(document.getElementById('setup-lat').value) || 0; + const lon = parseFloat(document.getElementById('setup-lon').value) || 0; + + try { + const res = await fetch('/api/setup/location', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, lat, lon }) + }); + if (res.ok) this.next(); + } catch (e) { /* proceed anyway */ this.next(); } + }, + + // Step 3: Receivers + toggleReceiverMode() { + const mode = document.getElementById('setup-receiver-mode').value; + document.getElementById('setup-manual-group').classList.toggle('hidden', mode === 'AUTO'); + }, + + async scanReceivers() { + const btn = document.getElementById('scan-btn'); + const statusEl = document.getElementById('scan-status'); + btn.textContent = 'SCANNING...'; + btn.disabled = true; + statusEl.textContent = 'Scanning your network for ADS-B receivers...'; + statusEl.className = 'status-msg info'; + statusEl.classList.remove('hidden'); + + try { + const res = await fetch('/api/setup/scan'); + const data = await res.json(); + if (data.receivers && data.receivers.length) { + statusEl.textContent = `Found ${data.receivers.length} receiver(s): ${data.receivers.join(', ')}`; + statusEl.className = 'status-msg success'; + } else { + statusEl.textContent = 'No receivers found. You can add them manually or try again later.'; + statusEl.className = 'status-msg info'; + } + } catch (e) { + statusEl.textContent = 'Scan failed. Check your network connection.'; + statusEl.className = 'status-msg error'; + } + + btn.textContent = 'SCAN FOR RECEIVERS'; + btn.disabled = false; + }, + + async saveReceivers() { + const mode = document.getElementById('setup-receiver-mode').value; + const port = parseInt(document.getElementById('setup-receiver-port').value) || 30003; + const body = { mode, port }; + + if (mode === 'MANUAL') { + const ips = document.getElementById('setup-receiver-ips').value + .split('\n').map(s => s.trim()).filter(Boolean); + body.ips = ips; + } + + try { + await fetch('/api/setup/receivers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } catch (e) { /* proceed */ } + this.next(); + }, + + // Step 4: Theme + async loadThemes() { + try { + const res = await fetch('/api/admin/themes'); + if (!res.ok) { + // Not authenticated yet, try fetching available backgrounds + this.addDefaultThemes(); + return; + } + const data = await res.json(); + const sel = document.getElementById('setup-theme'); + sel.innerHTML = ''; + (data.themes || ['desert']).forEach(t => { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + sel.appendChild(opt); + }); + } catch (e) { + this.addDefaultThemes(); + } + }, + + addDefaultThemes() { + const sel = document.getElementById('setup-theme'); + sel.innerHTML = ''; + ['desert', 'custom'].forEach(t => { + const opt = document.createElement('option'); + opt.value = t; + opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); + sel.appendChild(opt); + }); + }, + + async saveTheme() { + const theme = document.getElementById('setup-theme').value; + const tempUnit = document.getElementById('setup-temp-unit').value; + const siteTitle = document.getElementById('setup-site-title').value || 'ADS-Bit'; + + try { + await fetch('/api/setup/theme', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + theme, + temperature_unit: tempUnit, + site_title: siteTitle + }) + }); + } catch (e) { /* proceed */ } + this.next(); + }, + + // Step 5: Summary & Complete + buildSummary() { + const container = document.getElementById('setup-summary'); + const rows = [ + { label: 'LOCATION', value: document.getElementById('setup-location-name').value || 'My Location' }, + { label: 'COORDINATES', value: `${document.getElementById('setup-lat').value || '0'}, ${document.getElementById('setup-lon').value || '0'}` }, + { label: 'RECEIVERS', value: document.getElementById('setup-receiver-mode').value }, + { label: 'THEME', value: document.getElementById('setup-theme').value || 'desert' }, + { label: 'TEMP UNIT', value: document.getElementById('setup-temp-unit').value || 'F' }, + { label: 'PASSWORD', value: 'Set' } + ]; + + container.innerHTML = rows.map(r => + `
${r.label}${r.value}
` + ).join(''); + }, + + async finish() { + try { + const res = await fetch('/api/setup/complete', { method: 'POST' }); + if (res.ok) { + window.location.href = '/'; + } else { + const data = await res.json(); + alert(data.error || 'Setup could not be completed'); + } + } catch (e) { + alert('Connection error'); + } + } +}; + +wizard.init(); 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