diff --git a/CLAUDE.md b/CLAUDE.md index 94a1c17..6905aa7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Access at http://localhost:{web_port} (configured in config.json) ### 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 +- 9 aircraft types: smallProp, regionalJet, narrowBody, wideBody, heavy, helicopter, balloon, glider, uav - 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) @@ -75,10 +75,10 @@ Mapping from DO-260B emitter categories to sprite types: | A5 | Heavy (> 300,000 lbs) | wideBody or heavy (by altitude/speed) | | A6 | High performance (> 5g, > 400 kts) | narrowBody | | A7 | Rotorcraft | helicopter | -| B1 | Glider / sailplane | smallProp | -| B2 | Lighter-than-air | smallProp | +| B1 | Glider / sailplane | glider | +| B2 | Lighter-than-air | balloon | | B4 | Skydiver drop plane | smallProp | -| B6 | UAV | smallProp | +| B6 | UAV | uav | Once an emitter category is received for an aircraft, it is locked in and heuristic re-evaluation is skipped. diff --git a/admin/admin.css b/admin/admin.css index 8b8350c..868b5ca 100644 --- a/admin/admin.css +++ b/admin/admin.css @@ -348,6 +348,101 @@ textarea { color: #fff; } +/* Sprite Management */ +.sprite-info { + background: rgba(0, 0, 0, 0.3); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 14px 18px; + margin-bottom: 20px; +} + +.sprite-info h3 { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: #fcd444; + margin-bottom: 10px; +} + +.sprite-info p { + font-size: 12px; + color: #b4d4ec; + margin-bottom: 4px; + line-height: 1.6; +} + +.sprite-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.sprite-card { + background: rgba(0, 0, 0, 0.4); + border: 2px solid #5c94fc; + border-radius: 8px; + padding: 14px; + text-align: center; +} + +.sprite-card h4 { + font-family: 'Press Start 2P', monospace; + font-size: 10px; + color: #fcfcfc; + margin-bottom: 4px; +} + +.sprite-card .sprite-categories { + font-size: 10px; + color: #888; + margin-bottom: 10px; +} + +.sprite-preview { + background: rgba(0, 0, 0, 0.3); + border: 1px solid #444; + border-radius: 4px; + padding: 6px; + margin-bottom: 10px; + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; +} + +.sprite-preview img { + max-width: 100%; + height: auto; + max-height: 80px; + image-rendering: pixelated; +} + +.sprite-preview .no-sprite { + color: #666; + font-size: 11px; +} + +.sprite-upload-label { + display: inline-block; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + padding: 6px 12px; + border: 2px solid #5c94fc; + border-radius: 6px; + background: rgba(92, 148, 252, 0.2); + color: #fcfcfc; + cursor: pointer; + transition: all 0.15s; +} + +.sprite-upload-label:hover { + background: rgba(92, 148, 252, 0.4); +} + +.sprite-upload-input { + display: none; +} + /* Mobile */ @media (max-width: 768px) { .admin-header { @@ -389,4 +484,8 @@ textarea { .button-row .btn { width: 100%; } + + .sprite-grid { + grid-template-columns: repeat(2, 1fr); + } } diff --git a/admin/admin.html b/admin/admin.html index e414697..0dcdb9d 100644 --- a/admin/admin.html +++ b/admin/admin.html @@ -40,6 +40,7 @@ LOCATION DISPLAY TUNING + SPRITES SECURITY @@ -201,6 +202,20 @@ + + + Aircraft Sprites + + Sprite Specifications + Size: 500 x 333 px Format: PNG 8-bit RGBA (transparent background) + Orientation: Facing right (auto-flipped for westbound aircraft) + Style: Retro SNES pixel art, white/blue/red color scheme + + + + + + Change Admin Password diff --git a/admin/admin.js b/admin/admin.js index d39b018..4e7bd3a 100644 --- a/admin/admin.js +++ b/admin/admin.js @@ -67,6 +67,11 @@ document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden')); tab.classList.add('active'); document.getElementById('tab-' + tab.dataset.tab).classList.remove('hidden'); + // Lazy-load sprites on first visit + if (tab.dataset.tab === 'sprites' && !spritesLoaded) { + spritesLoaded = true; + loadSprites(); + } }); }); @@ -307,6 +312,76 @@ } }); + // ------- Sprites ------- + let spritesLoaded = false; + + async function loadSprites() { + try { + const res = await fetch('/api/admin/sprites'); + if (!res.ok) return; + const data = await res.json(); + const grid = document.getElementById('sprite-grid'); + if (!grid) return; + + grid.innerHTML = ''; + (data.sprites || []).forEach(sprite => { + const card = document.createElement('div'); + card.className = 'sprite-card'; + + const cacheBust = Date.now(); + const imgHtml = sprite.exists + ? `` + : `No sprite`; + + card.innerHTML = ` + ${sprite.type} + ${sprite.categories || ''} + ${imgHtml} + + UPLOAD + + + `; + + const fileInput = card.querySelector('.sprite-upload-input'); + fileInput.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + if (!file.type.includes('png')) { + toast('Only PNG files are allowed', true); + e.target.value = ''; + return; + } + + const formData = new FormData(); + formData.append('file', file); + + try { + const uploadRes = await fetch(`/api/admin/sprites/${sprite.type}`, { + method: 'POST', + body: formData + }); + if (uploadRes.ok) { + toast(`${sprite.type} sprite updated`); + loadSprites(); // Refresh previews + } else { + const err = await uploadRes.json(); + toast(err.error || 'Upload failed', true); + } + } catch (err) { + toast('Upload error', true); + } + e.target.value = ''; + }); + + grid.appendChild(card); + }); + } catch (e) { + console.error('Failed to load sprites', e); + } + } + // ------- Helpers ------- async function saveConfig(body) { try { diff --git a/ads-bit.js b/ads-bit.js index 05bc955..5297602 100644 --- a/ads-bit.js +++ b/ads-bit.js @@ -99,7 +99,10 @@ class ADSBit { narrowBody: new Image(), wideBody: new Image(), heavy: new Image(), - helicopter: new Image() + helicopter: new Image(), + balloon: new Image(), + glider: new Image(), + uav: new Image() }; // Track which images have loaded @@ -109,7 +112,10 @@ class ADSBit { narrowBody: false, wideBody: false, heavy: false, - helicopter: false + helicopter: false, + balloon: false, + glider: false, + uav: false }; // Load all aircraft images @@ -785,10 +791,10 @@ class ADSBit { break; case 'A6': type = 'narrowBody'; break; // High performance (> 5g, > 400 kts) case 'A7': type = 'helicopter'; break; // Rotorcraft - case 'B1': type = 'smallProp'; break; // Glider / sailplane - case 'B2': type = 'smallProp'; break; // Lighter-than-air + case 'B1': type = 'glider'; break; // Glider / sailplane + case 'B2': type = 'balloon'; break; // Lighter-than-air case 'B4': type = 'smallProp'; break; // Skydiver drop plane - case 'B6': type = 'smallProp'; break; // UAV + case 'B6': type = 'uav'; break; // UAV default: type = 'narrowBody'; break; // Unknown or surface vehicles } diff --git a/images/balloon.png b/images/balloon.png new file mode 100644 index 0000000..214d101 Binary files /dev/null and b/images/balloon.png differ diff --git a/images/glider.png b/images/glider.png new file mode 100644 index 0000000..f806bf9 Binary files /dev/null and b/images/glider.png differ diff --git a/images/uav.png b/images/uav.png new file mode 100644 index 0000000..212683d Binary files /dev/null and b/images/uav.png differ diff --git a/server.py b/server.py index 1fb8c6c..1659dd1 100755 --- a/server.py +++ b/server.py @@ -752,6 +752,77 @@ async def handle_admin_scan_receivers(request): return web.json_response({"receivers": found}) +# All valid sprite type names +SPRITE_TYPES = [ + "smallProp", "regionalJet", "narrowBody", "wideBody", + "heavy", "helicopter", "balloon", "glider", "uav" +] + +# Which ADS-B emitter categories map to each sprite +SPRITE_CATEGORY_MAP = { + "smallProp": "A1, B4", + "regionalJet": "A2", + "narrowBody": "A3, A4, A6", + "wideBody": "A5 (lower alt/speed)", + "heavy": "A5 (high alt/speed)", + "helicopter": "A7", + "balloon": "B2", + "glider": "B1", + "uav": "B6", +} + + +@require_auth +async def handle_admin_sprites(request): + """GET /api/admin/sprites - List all sprite types with exists/url info.""" + sprites = [] + for stype in SPRITE_TYPES: + filepath = WEB_DIR / "images" / f"{stype}.png" + sprites.append({ + "type": stype, + "exists": filepath.exists(), + "url": f"/images/{stype}.png", + "categories": SPRITE_CATEGORY_MAP.get(stype, ""), + }) + return web.json_response({"sprites": sprites}) + + +@require_auth +async def handle_admin_sprite_upload(request): + """POST /api/admin/sprites/{type} - Upload/replace a sprite PNG.""" + stype = request.match_info.get("type", "") + if stype not in SPRITE_TYPES: + return web.json_response( + {"error": f"Invalid sprite type: {stype}"}, status=400 + ) + + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "file": + return web.json_response({"error": "No file field in upload"}, status=400) + + # Read the uploaded file (limit to 5 MB) + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > 5 * 1024 * 1024: + return web.json_response({"error": "File too large (max 5 MB)"}, status=400) + + # Validate PNG header + if data[:8] != b'\x89PNG\r\n\x1a\n': + return web.json_response({"error": "File is not a valid PNG"}, status=400) + + # Save to images directory + filepath = WEB_DIR / "images" / f"{stype}.png" + with open(filepath, "wb") as f: + f.write(data) + + return web.json_response({"ok": True, "type": stype}) + + async def restart_receiver_connections(): """Cancel existing receiver tasks and start new ones.""" global receivers, receiver_tasks @@ -863,6 +934,8 @@ async def start_http_server(): 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) + app.router.add_get('/api/admin/sprites', handle_admin_sprites) + app.router.add_post('/api/admin/sprites/{type}', handle_admin_sprite_upload) # Static files (must be last - catch-all) app.router.add_get('/{tail:.*}', handle_http)
Size: 500 x 333 px Format: PNG 8-bit RGBA (transparent background)
Orientation: Facing right (auto-flipped for westbound aircraft)
Style: Retro SNES pixel art, white/blue/red color scheme