diff --git a/.DS_Store b/.DS_Store index 0cff2e0..590979c 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..947522d --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +LaunchTracker2D diff --git a/assets/ChatGPT Image Jan 20, 2026, 09_25_17 AM.png b/assets/ChatGPT Image Jan 20, 2026, 09_25_17 AM.png deleted file mode 100644 index 5814926..0000000 Binary files a/assets/ChatGPT Image Jan 20, 2026, 09_25_17 AM.png and /dev/null differ diff --git a/assets/ChatGPT Image Jan 20, 2026, 09_55_57 AM.png b/assets/ChatGPT Image Jan 20, 2026, 09_55_57 AM.png deleted file mode 100644 index d8d90c1..0000000 Binary files a/assets/ChatGPT Image Jan 20, 2026, 09_55_57 AM.png and /dev/null differ diff --git a/assets/ChatGPT Image Jan 20, 2026, 09_58_40 AM.png b/assets/ChatGPT Image Jan 20, 2026, 09_58_40 AM.png deleted file mode 100644 index 90ad82c..0000000 Binary files a/assets/ChatGPT Image Jan 20, 2026, 09_58_40 AM.png and /dev/null differ diff --git a/assets/Falcon 9.png b/assets/Falcon 9.png deleted file mode 100644 index 6db8d98..0000000 Binary files a/assets/Falcon 9.png and /dev/null differ diff --git a/assets/Nasa Vehicles.png b/assets/Nasa Vehicles.png deleted file mode 100644 index d8d90c1..0000000 Binary files a/assets/Nasa Vehicles.png and /dev/null differ diff --git a/assets/VAB.png b/assets/VAB.png deleted file mode 100644 index 254abd1..0000000 Binary files a/assets/VAB.png and /dev/null differ diff --git a/assets/nasa_vehicles_transparent.png b/assets/nasa_vehicles_transparent.png deleted file mode 100644 index 6d8fd62..0000000 Binary files a/assets/nasa_vehicles_transparent.png and /dev/null differ diff --git a/assets/other buildings.png b/assets/other buildings.png deleted file mode 100644 index afe4a8b..0000000 Binary files a/assets/other buildings.png and /dev/null differ diff --git a/launch-timer/.DS_Store b/launch-timer/.DS_Store index 286730c..def3015 100644 Binary files a/launch-timer/.DS_Store and b/launch-timer/.DS_Store differ diff --git a/assets/.DS_Store b/launch-timer/Phase2/.DS_Store similarity index 100% rename from assets/.DS_Store rename to launch-timer/Phase2/.DS_Store diff --git a/launch-timer/Phase2/README.md b/launch-timer/Phase2/README.md new file mode 100644 index 0000000..ca49c7e --- /dev/null +++ b/launch-timer/Phase2/README.md @@ -0,0 +1,75 @@ +# Launch Countdown — Phase 2 + +## Quick Start + +```bash +pip install -r requirements.txt +python server.py +``` + +Your browser opens automatically at http://localhost:5000 + + +## File Structure + +``` +phase2/ +├── server.py ← Run this. Flask backend + API proxy. +├── requirements.txt +├── static/ +│ ├── index.html +│ ├── js/ +│ │ └── app.js ← All rendering, animation, countdown logic +│ └── assets/ ← Drop your PNG files here +│ ├── vab.png +│ ├── launch_tower.png +│ ├── launch_pad.png +│ ├── rocket_falcon9.png +│ ├── rocket_starship.png +│ ├── rocket_electron.png +│ ├── rocket_atlas.png +│ ├── rocket_sls.png +│ └── rocket_generic.png ← fallback for unknown vehicles +``` + + +## Adding PNG Assets + +Drop any PNG into `static/assets/` with the exact filename shown above. +The app loads them on startup — if a file is missing it falls back to +the procedural drawing automatically. You can add assets one at a time. + +**Recommended sizes (canvas is 800×600):** + +| Asset | Suggested size | Notes | +|----------------|------------------|------------------------------------| +| vab.png | 200 × 150 px | Anchored top-left at (60, 215) | +| launch_tower.png | 80 × 220 px | Bottom anchored at y=340 | +| launch_pad.png | 140 × 40 px | Drawn at (550, 320) | +| rocket_*.png | ~30 × 160 px | Centred on x=620, base at y=340 | + +Transparent backgrounds (RGBA PNGs) work perfectly. + + +## Architecture + +``` +Browser ──── GET /api/launches ────► server.py ──► RocketLaunch.Live + ◄──── JSON (launches) ────┘ + ──── GET /api/weather ────► server.py ──► Open-Meteo + ◄──── JSON (weather) ────┘ +``` + +- **server.py** handles all external API calls with 5-min / 15-min caches. +- **app.js** computes the countdown locally every frame (no server round-trip per second). +- Weather uses Open-Meteo — free, no API key, reliable sub-10ms responses. + + +## Launch Trigger Fix + +Phase 1 had a bug where `trigger_launch()` was called every second while +`total_seconds` was in the 0–5 window, causing multiple or missed triggers. + +Phase 2 fix: a single `state.launchTriggered` boolean is set to `true` at +T-0 and only reset to `false` when a new mission is loaded. The countdown +loop checks this flag before firing so the animation runs exactly once. diff --git a/launch-timer/Phase2/requirements.txt b/launch-timer/Phase2/requirements.txt new file mode 100644 index 0000000..d4d9dee --- /dev/null +++ b/launch-timer/Phase2/requirements.txt @@ -0,0 +1,2 @@ +flask>=2.3.0 +requests>=2.28.0 diff --git a/launch-timer/Phase2/server.py b/launch-timer/Phase2/server.py new file mode 100644 index 0000000..4c576df --- /dev/null +++ b/launch-timer/Phase2/server.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +""" +Phase 2 Backend Server +Fetches launch and weather data, serves it to the browser frontend. +Run this file to start the app — it will open your browser automatically. +""" + +import threading +import webbrowser +import time +import requests +import subprocess +from datetime import datetime, timezone +from flask import Flask, jsonify, send_from_directory, request +import os + +import sys +if getattr(sys, 'frozen', False): + BASE_DIR = os.path.dirname(sys.executable) +else: + BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +app = Flask(__name__, static_folder=os.path.join(BASE_DIR, 'static')) + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _ts(): + return datetime.now().strftime("%H:%M:%S") + + +# ── Launch API ──────────────────────────────────────────────────────────────── + +def fetch_launches(num_launches=5): + """Fetch upcoming launches from RocketLaunch.Live, filter completed ones.""" + url = f"https://fdo.rocketlaunch.live/json/launches/next/{num_launches}" + try: + response = requests.get(url, timeout=10) + response.raise_for_status() + launches = response.json().get('result', []) + + upcoming = [] + skipped = 0 + for launch in launches: + result = launch.get('result') + status_id = launch.get('status', {}).get('id', 0) + if (result is not None and result > 0) or status_id == 3: + skipped += 1 + continue + upcoming.append(launch) + + print(f"[{_ts()}] Launches fetched: {len(upcoming)} upcoming" + + (f", {skipped} skipped" if skipped else "")) + for i, lv in enumerate(upcoming): + t0 = lv.get('t0') or lv.get('win_open') or 'TBD' + vehicle = lv.get('vehicle', {}).get('name', 'Unknown') + status = lv.get('status', {}).get('name', '?') + marker = ">>>" if i == 0 else " " + print(f" {marker} [{i+1}] {lv.get('name', 'Unknown')}") + print(f" Vehicle: {vehicle} | Status: {status} | T0: {t0}") + return upcoming + except requests.exceptions.RequestException as e: + print(f"[{_ts()}] ERROR fetching launches: {e}") + return [] + + +def get_countdown(launch_time_iso): + """Return countdown dict or 'LAUNCHED' string.""" + if not launch_time_iso: + return None + try: + launch_time = datetime.fromisoformat(launch_time_iso.replace('Z', '+00:00')) + now = datetime.now(timezone.utc) + delta = launch_time - now + if delta.total_seconds() < 0: + return "LAUNCHED" + days = delta.days + hours, rem = divmod(delta.seconds, 3600) + minutes, seconds = divmod(rem, 60) + return { + 'days': days, 'hours': hours, + 'minutes': minutes, 'seconds': seconds, + 'total_seconds': delta.total_seconds() + } + except Exception: + return None + + +# ── Weather API ─────────────────────────────────────────────────────────────── + +_WIND_DIRS = ['N','NNE','NE','ENE','E','ESE','SE','SSE', + 'S','SSW','SW','WSW','W','WNW','NW','NNW'] + +_WMO_LABELS = { + 0:'Clear sky', 1:'Mainly clear', 2:'Partly cloudy', 3:'Overcast', + 45:'Foggy', 48:'Icy fog', + 51:'Light drizzle', 53:'Drizzle', 55:'Heavy drizzle', + 61:'Light rain', 63:'Rain', 65:'Heavy rain', + 80:'Light showers', 81:'Showers', 82:'Heavy showers', + 95:'Thunderstorm', 96:'Thunderstorm w/ hail', 99:'Thunderstorm w/ heavy hail', +} + +_WMO_CONDITION = { + 0:'clear', 1:'clear', 2:'cloudy', 3:'cloudy', + 45:'fog', 48:'fog', + 51:'light_rain', 53:'light_rain', 55:'light_rain', 61:'light_rain', + 63:'rain', 65:'rain', 80:'rain', 81:'rain', 82:'rain', + 95:'thunderstorm', 96:'thunderstorm', 99:'thunderstorm', +} + + +def fetch_weather(): + """Fetch Cape Canaveral weather from Open-Meteo (free, no key, reliable).""" + ts = _ts() + url = ( + "https://api.open-meteo.com/v1/forecast" + "?latitude=28.3922&longitude=-80.6077" + "¤t=temperature_2m,relative_humidity_2m,precipitation," + "weather_code,cloud_cover,wind_speed_10m,wind_direction_10m" + "&temperature_unit=fahrenheit&wind_speed_unit=mph" + "&timezone=America%2FNew_York" + ) + try: + r = requests.get(url, timeout=10) + r.raise_for_status() + c = r.json()['current'] + + wmo_code = c.get('weather_code', 0) + wind_deg = c.get('wind_direction_10m', 0) + wind_dir = _WIND_DIRS[int((wind_deg + 11.25) / 22.5) % 16] + condition = _WMO_CONDITION.get(wmo_code, 'clear') + label = _WMO_LABELS.get(wmo_code, f'Code {wmo_code}') + temp_f = round(c['temperature_2m'], 1) + + info = { + 'temp_f': temp_f, + 'temp_c': round((temp_f - 32) * 5 / 9, 1), + 'condition': condition, + 'label': label, + 'weather_code': wmo_code, + 'humidity': c.get('relative_humidity_2m', 0), + 'wind_speed': round(c.get('wind_speed_10m', 0), 1), + 'wind_dir': wind_dir, + 'precip': c.get('precipitation', 0), + 'cloud_cover': c.get('cloud_cover', 0), + } + print(f"[{ts}] Weather | {label}, {temp_f}°F, " + f"{info['wind_speed']} mph {wind_dir}, " + f"{info['cloud_cover']}% cloud → {condition}") + return info + except requests.exceptions.Timeout: + print(f"[{ts}] Weather timeout — using defaults") + except requests.exceptions.ConnectionError: + print(f"[{ts}] Weather connection error — using defaults") + except Exception as e: + print(f"[{ts}] Weather error: {e}") + return {'condition': 'clear', 'label': 'Unknown', 'temp_f': 75, + 'temp_c': 24, 'humidity': 60, 'wind_speed': 10, + 'wind_dir': 'E', 'precip': 0, 'cloud_cover': 0} + + +# ── Simple in-memory cache ───────────────────────────────────────────────────── + +_cache = { + 'launches': [], + 'launches_fetched': 0, + 'weather': None, + 'weather_fetched': 0, +} +LAUNCH_TTL = 300 +WEATHER_TTL = 900 + + +def _get_launches(): + now = time.time() + if not _cache['launches'] or (now - _cache['launches_fetched']) > LAUNCH_TTL: + _cache['launches'] = fetch_launches(5) + _cache['launches_fetched'] = now + return _cache['launches'] + + +def _get_weather(): + now = time.time() + if not _cache['weather'] or (now - _cache['weather_fetched']) > WEATHER_TTL: + _cache['weather'] = fetch_weather() + _cache['weather_fetched'] = now + return _cache['weather'] + + +# ── Flask routes ────────────────────────────────────────────────────────────── + +@app.route('/') +def index(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'index.html') + + +@app.route('/static/') +def serve_static(path): + return send_from_directory(os.path.join(BASE_DIR, 'static'), path) + + +@app.route('/api/launches') +def api_launches(): + """Return upcoming launches with countdown pre-computed.""" + launches = _get_launches() + result = [] + for lv in launches: + t0 = lv.get('t0') or lv.get('win_open') + countdown = get_countdown(t0) + result.append({ + 'id': lv.get('id'), + 'name': lv.get('name', 'Unknown Mission'), + 'vehicle': lv.get('vehicle', {}).get('name', 'Unknown'), + 'provider': lv.get('provider', {}).get('name', 'Unknown'), + 'pad': lv.get('pad', {}).get('name', 'Unknown'), + 'location': lv.get('pad', {}).get('location', {}).get('name', 'Unknown'), + 'status': lv.get('status', {}).get('name', 'TBD'), + 't0': t0, + 'win_open': lv.get('win_open'), + 'countdown': countdown, + 'result': lv.get('result'), + }) + return jsonify({'launches': result, 'fetched_at': _ts()}) + + +@app.route('/api/weather') +def api_weather(): + return jsonify(_get_weather()) + + +@app.route('/api/launches/invalidate', methods=['POST']) +def invalidate_launches(): + """Force a fresh launch fetch on next request (called after a launch completes).""" + _cache['launches_fetched'] = 0 + print(f"[{_ts()}] Launch cache invalidated") + return jsonify({'ok': True}) + + +# ── WiFi routes ─────────────────────────────────────────────────────────────── + +@app.route('/wifi') +def wifi_page(): + return send_from_directory(os.path.join(BASE_DIR, 'static'), 'wifi.html') + + +@app.route('/api/wifi/scan') +def wifi_scan(): + try: + # Bring interface up in case it's down + subprocess.run(['sudo', 'ifconfig', 'wlan0', 'up'], check=False) + import time + time.sleep(1) + result = subprocess.check_output(['sudo', 'iwlist', 'wlan0', 'scan'], text=True) + networks = [] + for line in result.split('\n'): + if 'ESSID:' in line: + ssid = line.split('ESSID:')[1].strip().strip('"') + if ssid and ssid not in networks: + networks.append(ssid) + return jsonify({'networks': networks}) + except Exception as e: + return jsonify({'networks': [], 'error': str(e)}) + + +@app.route('/api/wifi/connect', methods=['POST']) +def wifi_connect(): + data = request.get_json() + ssid = data.get('ssid', '') + password = data.get('password', '') + try: + # Add new network slot in memory + result = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'add_network'], + capture_output=True, text=True) + net_id = result.stdout.strip() + print(f"[{_ts()}] WiFi: added network id={net_id}") + + # Configure it + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'ssid', f'"{ssid}"'], check=True) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'set_network', net_id, 'psk', f'"{password}"'], check=True) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'select_network', net_id], check=True) + + # Wait for connection + time.sleep(8) + + # Check status + status = subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'status'], + capture_output=True, text=True) + print(f"[{_ts()}] WiFi status:\n{status.stdout}") + + connected = f'ssid={ssid}' in status.stdout and 'wpa_state=COMPLETED' in status.stdout + + if connected: + # Save to config permanently + clean_config = ( + 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\n' + 'update_config=1\n' + 'country=US\n\n' + 'network={\n' + f' ssid="{ssid}"\n' + f' psk="{password}"\n' + ' key_mgmt=WPA-PSK\n' + '}\n' + ) + with open('/tmp/wpa_supplicant.conf', 'w') as f: + f.write(clean_config) + subprocess.run(['sudo', 'bash', '-c', 'cp /tmp/wpa_supplicant.conf /etc/wpa_supplicant/wpa_supplicant.conf'], check=True) + _cache['launches_fetched'] = 0 + _cache['weather_fetched'] = 0 + return jsonify({'ok': True}) + else: + # Remove failed network and reconnect to old + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'remove_network', net_id], check=True) + subprocess.run(['sudo', 'wpa_cli', '-i', 'wlan0', 'reconfigure'], check=True) + return jsonify({'ok': False, 'error': 'Could not connect — wrong password?'}) + + except Exception as e: + print(f"[{_ts()}] WiFi connect error: {e}") + return jsonify({'ok': False, 'error': str(e)}) + + +# ── Entry point ─────────────────────────────────────────────────────────────── + +def open_browser(): + time.sleep(1.2) + webbrowser.open('http://localhost:5001') + +if __name__ == '__main__': + print(f"[{_ts()}] ══════════════════════════════════════") + print(f"[{_ts()}] Launch Countdown — Phase 2") + print(f"[{_ts()}] http://localhost:5001") + print(f"[{_ts()}] ══════════════════════════════════════") + threading.Thread(target=open_browser, daemon=True).start() + app.run(host='0.0.0.0', port=5001, debug=False) diff --git a/launch-timer/Phase2/static/.DS_Store b/launch-timer/Phase2/static/.DS_Store new file mode 100644 index 0000000..c76cd3a Binary files /dev/null and b/launch-timer/Phase2/static/.DS_Store differ diff --git a/launch-timer/Phase2/static/asset-positioner.html b/launch-timer/Phase2/static/asset-positioner.html new file mode 100644 index 0000000..f29290d --- /dev/null +++ b/launch-timer/Phase2/static/asset-positioner.html @@ -0,0 +1,571 @@ + + + + +Asset Positioner v2 + + + +

🚀 ASSET POSITIONER v2

+

Drag · Scale · Rotate · Save to app.js

+
+ +
+ +
+ 📂 Load PNG Assets +
select multiple files at once +
+ + +
+ 📄 Load app.js (to enable Save) +
+ + +
Assets
+
No assets loaded
+ +
Transform
+
+
Click an asset to select it
+ +
+ +
Generated Code
+
// Load assets to begin
+ + +
+ + + + +
+ DRAG to move  ·  CORNER ◢ to scale
+ 🔄 HANDLE above asset to rotate
+ X/Y/W/H for precision  ·  SLIDER to rotate
+ Del delete   Esc deselect   ←→↑↓ nudge +
+
+
+ + + + diff --git a/launch-timer/Phase2/static/assets/ground-HIF.png b/launch-timer/Phase2/static/assets/ground-HIF.png new file mode 100644 index 0000000..c014281 Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-HIF.png differ diff --git a/launch-timer/Phase2/static/assets/ground-LaunchPad.png b/launch-timer/Phase2/static/assets/ground-LaunchPad.png new file mode 100644 index 0000000..01d7364 Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-LaunchPad.png differ diff --git a/launch-timer/Phase2/static/assets/ground-TE.png b/launch-timer/Phase2/static/assets/ground-TE.png new file mode 100644 index 0000000..1ab2c59 Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-TE.png differ diff --git a/launch-timer/Phase2/static/assets/ground-VAB.png b/launch-timer/Phase2/static/assets/ground-VAB.png new file mode 100644 index 0000000..70a5eda Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-VAB.png differ diff --git a/launch-timer/Phase2/static/assets/ground-countdownclock.png b/launch-timer/Phase2/static/assets/ground-countdownclock.png new file mode 100644 index 0000000..98fbb83 Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-countdownclock.png differ diff --git a/launch-timer/Phase2/static/assets/ground-ground.png b/launch-timer/Phase2/static/assets/ground-ground.png new file mode 100644 index 0000000..01d99b0 Binary files /dev/null and b/launch-timer/Phase2/static/assets/ground-ground.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-Ariane6.png b/launch-timer/Phase2/static/assets/rocket-Ariane6.png new file mode 100644 index 0000000..43961f3 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-Ariane6.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-GSLV.png b/launch-timer/Phase2/static/assets/rocket-GSLV.png new file mode 100644 index 0000000..ca91533 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-GSLV.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-KAIROS.png b/launch-timer/Phase2/static/assets/rocket-KAIROS.png new file mode 100644 index 0000000..6ae2f06 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-KAIROS.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-Kinetica2.png b/launch-timer/Phase2/static/assets/rocket-Kinetica2.png new file mode 100644 index 0000000..832dc15 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-Kinetica2.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-NG.png b/launch-timer/Phase2/static/assets/rocket-NG.png new file mode 100644 index 0000000..3ae15c5 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-NG.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-SLS.png b/launch-timer/Phase2/static/assets/rocket-SLS.png new file mode 100644 index 0000000..28f01d0 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-SLS.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-Soyuz.png b/launch-timer/Phase2/static/assets/rocket-Soyuz.png new file mode 100644 index 0000000..b3adb1e Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-Soyuz.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-atlasV.png b/launch-timer/Phase2/static/assets/rocket-atlasV.png new file mode 100644 index 0000000..4261923 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-atlasV.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-electron.png b/launch-timer/Phase2/static/assets/rocket-electron.png new file mode 100644 index 0000000..bdb716a Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-electron.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-falcon9.png b/launch-timer/Phase2/static/assets/rocket-falcon9.png new file mode 100644 index 0000000..6cb8f15 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-falcon9.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-firefly.png b/launch-timer/Phase2/static/assets/rocket-firefly.png new file mode 100644 index 0000000..142568b Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-firefly.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-longmarch.png b/launch-timer/Phase2/static/assets/rocket-longmarch.png new file mode 100644 index 0000000..52c573b Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-longmarch.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-starship.png b/launch-timer/Phase2/static/assets/rocket-starship.png new file mode 100644 index 0000000..c2da425 Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-starship.png differ diff --git a/launch-timer/Phase2/static/assets/rocket-vulcan.png b/launch-timer/Phase2/static/assets/rocket-vulcan.png new file mode 100644 index 0000000..676e14b Binary files /dev/null and b/launch-timer/Phase2/static/assets/rocket-vulcan.png differ diff --git a/launch-timer/Phase2/static/index.html b/launch-timer/Phase2/static/index.html new file mode 100644 index 0000000..08f5ff1 --- /dev/null +++ b/launch-timer/Phase2/static/index.html @@ -0,0 +1,60 @@ + + + + + + Launch Countdown + + + +
+ + +
+ + + + diff --git a/launch-timer/Phase2/static/js/app.js b/launch-timer/Phase2/static/js/app.js new file mode 100644 index 0000000..d45cf29 --- /dev/null +++ b/launch-timer/Phase2/static/js/app.js @@ -0,0 +1,1330 @@ +/** + * Launch Countdown — Phase 2 + * 800×600 canvas renderer, mirrors Phase 1 layout. + * + * Asset loading: All PNGs live in /static/assets/. + * Fallback: If a PNG fails to load the canvas falls back to drawing + * the same coloured shapes as Phase 1 so the app always works. + * + * Launch fix: A single `launchTriggered` flag prevents the T-0 event from + * firing more than once per mission. + */ + +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// ASSET MANIFEST +// Add real PNGs to /static/assets/ with these exact filenames and they will +// be used automatically. Anything that fails to load falls back gracefully. +// ───────────────────────────────────────────────────────────────────────────── +const ASSETS = { + // Landscape / structures + vab: 'ground-VAB.png', // Updated to new VAB + launchTower: 'ground-LaunchPad.png', // This file contains both tower AND pad + launchPad: 'ground-LaunchPad.png', + countdownClock: 'ground-countdownclock.png', // New countdown clock display + hif: 'ground-HIF.png', + te: 'ground-TE.png', + rocket_falcon9: 'rocket-falcon9.png', + rocket_atlas: 'rocket-atlasV.png', + rocket_vulcan: 'rocket-vulcan.png', + rocket_electron: 'rocket-electron.png', + rocket_ng: 'rocket-NG.png', + rocket_kairos: 'rocket-KAIROS.png', + rocket_longmarch: 'rocket-longmarch.png', + rocket_generic: 'rocket-falcon9.png', + rocket_starship: 'rocket-starship.png', + rocket_soyuz: 'rocket-Soyuz.png', + rocket_ariane6: 'rocket-Ariane6.png', + rocket_sls: 'rocket-SLS.png', + rocket_kinetica: 'rocket-Kinetica2.png', + rocket_gslv: 'rocket-GSLV.png', + rocket_firefly: 'rocket-firefly.png', +}; + +// Loaded Image objects (null = not yet loaded / unavailable) +const IMG = {}; + +function loadAssets() { + return Promise.all( + Object.entries(ASSETS).map(([key, file]) => + new Promise(resolve => { + const img = new Image(); + img.onload = () => { IMG[key] = img; resolve(); }; + img.onerror = () => { IMG[key] = null; resolve(); }; // graceful fallback + img.src = `/static/assets/${file}`; + }) + ) + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// STATE +// ───────────────────────────────────────────────────────────────────────────── +const canvas = document.getElementById('c'); +const ctx = canvas.getContext('2d'); + +const W = 800, H = 510; + +let state = { + launches: [], + currentIdx: 0, + weather: { condition: 'clear', temp_f: 75, wind_speed: 10, wind_dir: 'E', cloud_cover: 0, label: 'Clear sky' }, + + // Countdown / launch + launchTriggered: false, // ← THE FIX: set true at T-0, reset on new mission + isLaunching: false, + launchFrame: 0, + rocketY: 340, // current rocket base Y during launch + rocketOffscreen: false, + launchComplete: false, + + // Rocket flame particles + flameParticles: [], + ventParticles: [], + flameIntensity: 0, + + // Smoke (pre-launch vent on pad) + smokeFrame: 0, + + // Clouds + clouds: [ + { x: 150, y: 60 }, + { x: 420, y: 90 }, + { x: 650, y: 50 }, + ], + + // Birds + birds: [], + + // Cars + cars: [], + gateTimer: 0, + lastGateOpen: 0, + + // Gator + gatorTimer: 0, + gatorPhase: 0, + + // Tower lights + lightOn: false, + lightCounter: 0, + + // Post-launch cooldown + lastFetchAt: Date.now(), + postLaunchCooldown: false, + cooldownEndsAt: 0, + launchedMissionName: '', + nextMissionName: '', + nextMissionT0: null, + buriedLaunchId: null, + + // Notification banner + notification: null, + + now: Date.now(), +}; + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── +function ts() { + return new Date().toLocaleTimeString('en-US', { hour12: false }); +} + +function getHour() { return new Date().getHours(); } + +function lerp(a, b, t) { return a + (b - a) * t; } + +function hexToRgb(hex) { + const r = parseInt(hex.slice(1,3),16); + const g = parseInt(hex.slice(3,5),16); + const b = parseInt(hex.slice(5,7),16); + return [r, g, b]; +} + +function lerpColor(c1, c2, t) { + const [r1,g1,b1] = hexToRgb(c1); + const [r2,g2,b2] = hexToRgb(c2); + const r = Math.round(r1 + (r2-r1)*t); + const g = Math.round(g1 + (g2-g1)*t); + const b = Math.round(b1 + (b2-b1)*t); + return `rgb(${r},${g},${b})`; +} + +// ───────────────────────────────────────────────────────────────────────────── +// SKY / TIME-OF-DAY +// ───────────────────────────────────────────────────────────────────────────── +function getSkyColors() { + const h = getHour(); + const cond = state.weather.condition; + if (h >= 10 && h < 16) { + if (['rain','thunderstorm'].includes(cond)) return { sky:'#5a6a7a', ocean:'#0d1a2e', cloud:'#606060' }; + if (cond === 'cloudy') return { sky:'#9ab8d3', ocean:'#1a5b6e', cloud:'#c8c8c8' }; + return { sky:'#87ceeb', ocean:'#1a8b9e', cloud:'#ffffff' }; + } + if (h >= 16 && h < 18) + return { sky:'#ff9933', ocean:'#1a5b6e', cloud:'#ffd9b3' }; + if (h >= 6 && h < 10) + return { sky:'#ff9966', ocean:'#2a5b6e', cloud:'#ffe5cc' }; + if (['rain','thunderstorm'].includes(cond)) + return { sky:'#0a0a0a', ocean:'#050510', cloud:'#404040' }; + return { sky:'#0a0a1e', ocean:'#0d1a2e', cloud:'#d0d0d0' }; +} + +function isNight() { const h = getHour(); return h >= 18 || h < 6; } + +// ───────────────────────────────────────────────────────────────────────────── +// DRAW HELPERS +// ───────────────────────────────────────────────────────────────────────────── +function drawRect(x, y, w, h, fill, stroke, lw=1) { + ctx.fillStyle = fill; + ctx.fillRect(x, y, w, h); + if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = lw; ctx.strokeRect(x, y, w, h); } +} + +function drawOval(x, y, rx, ry, fill) { + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, 0, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); +} + +function roundRectPath(x, y, w, h, r) { + var tl, tr, br, bl; + if (Array.isArray(r)) { tl=r[0]||0; tr=r[1]||0; br=r[2]||0; bl=r[3]||0; } + else { tl=tr=br=bl=r||0; } + ctx.moveTo(x+tl,y); ctx.lineTo(x+w-tr,y); ctx.quadraticCurveTo(x+w,y,x+w,y+tr); + ctx.lineTo(x+w,y+h-br); ctx.quadraticCurveTo(x+w,y+h,x+w-br,y+h); + ctx.lineTo(x+bl,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-bl); + ctx.lineTo(x,y+tl); ctx.quadraticCurveTo(x,y,x+tl,y); ctx.closePath(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// BACKGROUND +// ───────────────────────────────────────────────────────────────────────────── +function drawBackground() { + const colors = getSkyColors(); + + // Sky + ctx.fillStyle = colors.sky; + ctx.fillRect(0, 0, W, 400); + + // Stars (night only) + if (isNight() && !['cloudy','rain','thunderstorm','fog'].includes(state.weather.condition)) { + ctx.fillStyle = '#ffffff'; + const rng = mulberry32(42); + for (let i = 0; i < 60; i++) { + const sx = rng() * W; + const sy = rng() * 340; + const sz = rng() > 0.7 ? 2 : 1; + ctx.fillRect(sx, sy, sz, sz); + } + } + + // Grass + ctx.fillStyle = '#5a8c3a'; + ctx.fillRect(0, 365, W, BAR_Y - 365); + + // Road + drawRoad(); + + // Pixel grass details + drawPixelGrass(); + + // Bottom info bar background + ctx.fillStyle = 'rgba(8,8,18,0.97)'; + ctx.fillRect(0, BAR_Y, W, BAR_H); + ctx.strokeStyle = 'rgba(0,232,122,0.35)'; + ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(0, BAR_Y); ctx.lineTo(W, BAR_Y); ctx.stroke(); +} + +function mulberry32(seed) { + return function() { + seed |= 0; seed = seed + 0x6D2B79F5 | 0; + let t = Math.imul(seed ^ seed >>> 15, 1 | seed); + t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; +} + +function drawRoad() { + drawRect(0, ROAD_Y, W, 18, '#3a3a3a'); + drawRect(0, ROAD_Y, W, 2, '#5a5a5a'); + drawRect(0, ROAD_Y+16, W, 2, '#5a5a5a'); + ctx.fillStyle = '#6a6a3a'; + for (let x = 0; x < W; x += 20) ctx.fillRect(x, ROAD_Y+8, 10, 2); +} + +function drawPixelGrass() { + const rng = mulberry32(123); + const colors = ['#4a7c2a','#6a9c4a','#5a8c3a','#3a6c1a']; + for (let i = 0; i < 400; i++) { + const gx = rng() * W; + const gy = 368 + rng() * (BAR_Y - 368 - 20); + ctx.fillStyle = colors[Math.floor(rng() * 4)]; + const style = Math.floor(rng() * 4); + if (style === 0) { ctx.fillRect(gx, gy-3, 1, 3); } + else if (style === 1) { ctx.fillRect(gx, gy, 2, 3); } + else if (style === 2) { ctx.fillRect(gx, gy, 1, 1); } + else { ctx.fillRect(gx, gy-3, 1, 3); } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HIF BUILDING +// ───────────────────────────────────────────────────────────────────────────── +function drawHIF() { + if (!IMG.hif) return; + const TARGET_HEIGHT = 207; + const scale = TARGET_HEIGHT / IMG.hif.height; + const scaledW = Math.round(IMG.hif.width * scale); + // Default position: left side, sitting on grass. Adjust x/y to reposition. + ctx.drawImage(IMG.hif, -30, 229, scaledW, 216); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LAUNCH TOWER + PAD +// ───────────────────────────────────────────────────────────────────────────── +function drawLaunchTower() { + if (IMG.launchTower) { + const TARGET_HEIGHT = 275; + const scale = TARGET_HEIGHT / IMG.launchTower.height; + const scaledW = Math.round(IMG.launchTower.width * scale); + ctx.drawImage(IMG.launchTower, 454, 153, scaledW, TARGET_HEIGHT); + } +} + +function drawLaunchPad() { + // Skip - the pad is already included in the tower image + // Only draw if we have a separate pad asset AND no tower + if (IMG.launchPad && !IMG.launchTower) { + ctx.drawImage(IMG.launchPad, 550, 320, 140, 40); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// POND + GATOR +// ───────────────────────────────────────────────────────────────────────────── +function drawPond() { + drawOval(765, 393, 30, 12, '#2a5a4a'); + ctx.strokeStyle='#1a4a3a'; ctx.lineWidth=2; + ctx.beginPath(); ctx.ellipse(765,393,30,12,0,0,Math.PI*2); ctx.stroke(); + // Lily pad + drawOval(755, 390, 4, 3, '#4a7a3a'); + + // Gator + const gp = state.gatorPhase; + if(gp > 0){ + const gx=790, gy=393; + const sub = Math.round(8*(1-gp)); + if(gp>0.2){ + drawOval(gx-15, gy+5+Math.round(sub*0.5), 3, 2, '#3a5a3a'); + drawOval(gx-21, gy+7+Math.round(sub*0.5), 3, 2, '#3a5a3a'); + } + if(gp>0.5){ + drawOval(gx, gy-1+sub, 8, 4, '#3a5a3a'); + drawOval(gx+7, gy+1+sub, 3, 2, '#4a6a4a'); + } + if(gp>0.3){ + drawOval(gx-4, gy-2+Math.round(sub*0.7), 2, 2, '#ffa500'); + drawOval(gx+1, gy-2+Math.round(sub*0.7), 2, 2, '#ffa500'); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SPOTLIGHTS +// ───────────────────────────────────────────────────────────────────────────── +function drawSpotlights() { + const groundY = 370; + const poleH = 30; + const lx = NOZZLE_X - 90; + const rx = NOZZLE_X + 75; + const targetX = NOZZLE_X; + const targetY = NOZZLE_Y - 80; + drawRect(lx + 4, groundY - poleH, 3, poleH, '#505050'); + drawRect(rx + 4, groundY - poleH, 3, poleH, '#505050'); + drawRect(lx, groundY - poleH - 5, 12, 6, '#404040'); + drawRect(rx, groundY - poleH - 5, 12, 6, '#404040'); + if (isNight()) { + ctx.save(); + ctx.globalAlpha = 0.28; + const g1 = ctx.createLinearGradient(lx+6, groundY-poleH, targetX, targetY); + g1.addColorStop(0, '#ffffcc'); g1.addColorStop(1, 'rgba(255,255,180,0)'); + ctx.fillStyle = g1; + ctx.beginPath(); ctx.moveTo(lx+6, groundY-poleH); ctx.lineTo(targetX-18, targetY); ctx.lineTo(targetX+18, targetY); ctx.lineTo(lx+8, groundY-poleH); ctx.fill(); + ctx.globalAlpha = 0.28; + const g2 = ctx.createLinearGradient(rx+6, groundY-poleH, targetX, targetY); + g2.addColorStop(0, '#ffffcc'); g2.addColorStop(1, 'rgba(255,255,180,0)'); + ctx.fillStyle = g2; + ctx.beginPath(); ctx.moveTo(rx+6, groundY-poleH); ctx.lineTo(targetX-18, targetY); ctx.lineTo(targetX+18, targetY); ctx.lineTo(rx+8, groundY-poleH); ctx.fill(); + ctx.globalAlpha = 1; ctx.restore(); + drawRect(lx+3, groundY-poleH-4, 6, 4, '#ffffcc'); + drawRect(rx+3, groundY-poleH-4, 6, 4, '#ffffcc'); + } else { + drawRect(lx+3, groundY-poleH-4, 6, 4, '#2a2a2a'); + drawRect(rx+3, groundY-poleH-4, 6, 4, '#2a2a2a'); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// CLOUDS +// ───────────────────────────────────────────────────────────────────────────── +function drawClouds() { + const col = getSkyColors().cloud; + ctx.fillStyle = col; + state.clouds.forEach(c => { + ctx.beginPath(); ctx.ellipse(c.x, c.y+12, 12, 8, 0, 0, Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(c.x+20, c.y+7, 12, 9, 0, 0, Math.PI*2); ctx.fill(); + ctx.beginPath(); ctx.ellipse(c.x+40, c.y+12, 12, 8, 0, 0, Math.PI*2); ctx.fill(); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// BIRDS +// ───────────────────────────────────────────────────────────────────────────── +function spawnBirds() { + for(let i=0;i<3;i++){ + state.birds.push({ + x: -100 - i*150, y: 80 + Math.random()*200, + vx: 0.8 + Math.random()*1.0, + vy: (Math.random()-0.5)*0.3, + flap: 0, flapUp: true, + }); + } +} + +function drawBirds() { + ctx.strokeStyle='#2a2a2a'; ctx.lineWidth=2; + state.birds.forEach(b => { + const wing = b.flapUp ? -4 : -1; + ctx.beginPath(); + ctx.moveTo(b.x, b.y); + ctx.lineTo(b.x+4, b.y+wing); + ctx.lineTo(b.x+8, b.y); + ctx.stroke(); + drawOval(b.x+3, b.y, 1.5, 1, '#2a2a2a'); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// CARS +// ───────────────────────────────────────────────────────────────────────────── +const CAR_COLORS = ['#3a7bc8','#d44444','#f5f5f5','#2a2a2a','#ffd93d','#4a9d5f']; +const GATE_X = 490; +const ROAD_Y = 408; +const BAR_H = 80; +const BAR_Y = H - BAR_H; + +function spawnCars() { + for(let i=0;i<6;i++){ + state.cars.push({ + x: -50 - i*80, y: ROAD_Y+6, + speed: 0.8 + Math.random()*0.4, + baseSpeed: 0.8 + Math.random()*0.4, + color: CAR_COLORS[i % CAR_COLORS.length], + state: 'approaching', + waitStart: 0, + }); + } +} + +function drawCars() { + state.cars.forEach(car => { + const x=car.x, y=car.y; + ctx.fillStyle = car.color; + ctx.fillRect(x,y,12,6); + ctx.fillRect(x+2,y-3,8,3); + ctx.fillStyle='#add8e6'; + ctx.fillRect(x+3,y-2,2,2); + ctx.fillRect(x+7,y-2,2,2); + ctx.fillStyle='#1a1a1a'; + drawOval(x+2.5,y+6,2,2,'#1a1a1a'); + drawOval(x+9.5,y+6,2,2,'#1a1a1a'); + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ROCKET (PNG or procedural, vehicle-aware) +// ───────────────────────────────────────────────────────────────────────────── +// Rocket position — tuned via Asset Positioner +// Positioner output: ctx.drawImage(IMG.rocket_falcon9, 264, 97, rw, 267) +// top-left x=264, top y=97, height=267 → bottom y=364 +// rw at that scale ≈ 80px → center x ≈ 264 + 40 = 304 + + +function getRocketAssetKey(vehicle) { + if (!vehicle) return 'rocket_generic'; + const v = vehicle.toLowerCase(); + if (v.includes('starship')) return 'rocket_starship'; + if (v.includes('soyuz')) return 'rocket_soyuz'; + if (v.includes('ariane')) return 'rocket_ariane6'; + if (v.includes('sls') || v.includes('space launch system')) return 'rocket_sls'; + if (v.includes('kinetica')) return 'rocket_kinetica'; + if (v.includes('gslv') || v.includes('geosynchronous')) return 'rocket_gslv'; + if (v.includes('falcon')) return 'rocket_falcon9'; + if (v.includes('firefly') || v.includes('alpha')) return 'rocket_firefly'; + if (v.includes('atlas')) return 'rocket_atlas'; + if (v.includes('vulcan')) return 'rocket_vulcan'; + if (v.includes('electron')) return 'rocket_electron'; + if (v.includes('new glenn') || v.includes(' ng')) return 'rocket_ng'; + if (v.includes('kairos')) return 'rocket_kairos'; + if (v.includes('long march') || v.includes('longmarch') || v.includes('chang zheng')) return 'rocket_longmarch'; + return 'rocket_generic'; +} + +const ROCKET_CONFIG = { + rocket_falcon9: { pad: { x: 410, y: 165, h: 200 }, te: { tx: 269, ty: 293, h: 204, offsetY: -102 } }, + rocket_atlas: { pad: { x: 433, y: 130, h: 250 }, te: { tx: 280, ty: 332, h: 200, offsetY: -100 } }, + rocket_vulcan: { pad: { x: 315, y: 160, h: 209 }, te: { tx: 264, ty: 374, h: 211, offsetY: -106 } }, + rocket_electron: { pad: { x: 466, y: 219, h: 158 }, te: { tx: 287, ty: 335, h: 200, offsetY: -100 } }, + rocket_ng: { pad: { x: 428, y: 114, h: 268 }, te: { tx: 267, ty: 334, h: 229, offsetY: -115 } }, + rocket_kairos: { pad: { x: 450, y: 173, h: 200 }, te: { tx: 282, ty: 336, h: 185, offsetY: -93 } }, + rocket_longmarch: { pad: { x: 440, y: 136, h: 234 }, te: { tx: 269, ty: 334, h: 200, offsetY: -100 } }, + rocket_generic: { pad: { x: 410, y: 165, h: 200 }, te: { tx: 269, ty: 293, h: 204, offsetY: -102 } }, + rocket_firefly: { pad: { x: 450, y: 193, h: 200 }, te: { tx: 282, ty: 336, h: 185, offsetY: -93 } }, + rocket_starship: { pad: { x: 423, y: 92, h: 280 }, te: { tx: 261, ty: 331, h: 242, offsetY: -121 } }, + rocket_soyuz: { pad: { x: 459, y: 176, h: 177 }, te: { tx: 270, ty: 331, h: 177, offsetY: -89 } }, + rocket_ariane6: { pad: { x: 430, y: 140, h: 230 }, te: { tx: 269, ty: 293, h: 204, offsetY: -102 } }, + rocket_sls: { pad: { x: 433, y: 88, h: 294 }, te: { tx: 277, ty: 334, h: 193, offsetY: -97 } }, + rocket_kinetica: { pad: { x: 450, y: 180, h: 190 }, te: { tx: 269, ty: 293, h: 204, offsetY: -102 } }, + rocket_gslv: { pad: { x: 440, y: 150, h: 220 }, te: { tx: 269, ty: 293, h: 204, offsetY: -102 } }, +}; +const PAD_Y_BASE = 366; +const NOZZLE_X = 517; +const NOZZLE_Y = 340; + +function drawTE() { + if (!IMG.te) return; + const TARGET_HEIGHT = 135; + const teScale = TARGET_HEIGHT / IMG.te.height; + const scaledW = Math.round(IMG.te.width * teScale); + ctx.drawImage(IMG.te, 167, 288, scaledW, TARGET_HEIGHT); + const nextLaunch = state.launches[state.currentIdx + 1] || null; + const vehicle2 = (nextLaunch ? nextLaunch.vehicle : null) || (currentLaunch() ? currentLaunch().vehicle : null) || ''; + const assetKey2 = getRocketAssetKey(vehicle2); + const rocketImg = IMG[assetKey2]; + if (!rocketImg) return; + const cfg = (ROCKET_CONFIG[assetKey2] || ROCKET_CONFIG.rocket_generic).te; + const rScale = cfg.h / rocketImg.height; + const rw2 = Math.round(rocketImg.width * rScale); + ctx.save(); + ctx.translate(cfg.tx, cfg.ty); + ctx.rotate(-1.5708); + ctx.drawImage(rocketImg, -rw2 / 2, cfg.offsetY, rw2, cfg.h); + ctx.restore(); +} + +function drawRocket() { + if (state.rocketOffscreen) return; + if (state.launchComplete) return; + if (state.postLaunchCooldown) return; + const vehicle = (currentLaunch() ? currentLaunch().vehicle : null) || ''; + const assetKey = getRocketAssetKey(vehicle); + const launchOffset = state.isLaunching ? state.rocketY - PAD_Y_BASE : 0; + if (IMG[assetKey]) { + const img = IMG[assetKey]; + const cfg = (ROCKET_CONFIG[assetKey] || ROCKET_CONFIG.rocket_generic).pad; + const scale = cfg.h / img.height; + const rw = Math.round(img.width * scale); + ctx.drawImage(img, cfg.x, cfg.y + launchOffset, rw, cfg.h); + return; + } + const v = vehicle.toLowerCase(); + if (v.includes('falcon')) drawFalcon9(NOZZLE_X, PAD_Y_BASE + launchOffset); + else if (v.includes('electron')) drawElectron(NOZZLE_X, PAD_Y_BASE + launchOffset); + else drawGenericRocket(NOZZLE_X, PAD_Y_BASE + launchOffset); +} + +function drawGenericRocket(x, y) { + // Simple white cylinder rocket + drawRect(x-8, y-100, 16, 100, '#f0f0f0', '#aaaaaa', 1); + ctx.fillStyle='#e0e0e0'; + ctx.beginPath(); ctx.moveTo(x-8,y-100); ctx.lineTo(x,y-120); ctx.lineTo(x+8,y-100); ctx.fill(); + drawRect(x-10, y-15, 20, 6, '#888888'); // engine section +} + +function drawFalcon9(x, y) { + // Simplified Phase-1-faithful Falcon 9 + drawRect(x-8, y-95, 16, 83, '#f5f5f5', '#000000', 1); + drawRect(x+5, y-95, 3, 83, '#d5d5d5'); + drawRect(x-8, y-102, 16, 7, '#1a1a1a', '#000000', 1); + drawRect(x-7, y-125, 14, 23, '#f5f5f5', '#000000', 1); + ctx.fillStyle='#f5f5f5'; + ctx.beginPath(); ctx.moveTo(x-7,y-125); ctx.lineTo(x,y-140); ctx.lineTo(x+7,y-125); ctx.fill(); + drawRect(x-10, y-12, 5, 18, '#2a2a2a'); + drawRect(x+5, y-12, 5, 18, '#2a2a2a'); + drawRect(x-11, y-92, 3, 5, '#2a2a2a'); + drawRect(x+8, y-92, 3, 5, '#2a2a2a'); + // Engines + for(let i=-1;i<=1;i++) drawOval(x+i*4, y-7, 2, 3, '#1a1a1a'); +} + +function drawStarship(x, y) { + const sc=1.4; + drawRect(x-Math.round(12*sc), y-Math.round(120*sc), Math.round(24*sc), Math.round(120*sc), '#c0c0c0', '#888888', 1); + drawRect(x-Math.round(12*sc), y-Math.round(220*sc), Math.round(24*sc), Math.round(100*sc), '#d0d0d0', '#888888', 1); + ctx.fillStyle='#b0b0b0'; + ctx.beginPath(); + ctx.moveTo(x-Math.round(12*sc), y-Math.round(220*sc)); + ctx.lineTo(x, y-Math.round(240*sc)); + ctx.lineTo(x+Math.round(12*sc), y-Math.round(220*sc)); + ctx.fill(); +} + +function drawElectron(x, y) { + drawRect(x-5, y-80, 10, 80, '#1a1a1a', '#333333', 1); + ctx.fillStyle='#1a1a1a'; + ctx.beginPath(); ctx.moveTo(x-5,y-80); ctx.lineTo(x,y-95); ctx.lineTo(x+5,y-80); ctx.fill(); + drawRect(x-6, y-12, 12, 8, '#333333'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// PRE-LAUNCH VENT SMOKE +// ───────────────────────────────────────────────────────────────────────────── +function drawSmoke() { + if (state.isLaunching) return; + if (!currentLaunch()) return; + if (state.launchComplete) return; + if (state.postLaunchCooldown) return; + const vehicle = (currentLaunch() ? currentLaunch().vehicle : null) || ''; + const assetKey = getRocketAssetKey(vehicle); + const cfg = (ROCKET_CONFIG[assetKey] || ROCKET_CONFIG.rocket_generic).pad; + const ventX = NOZZLE_X; + const ventY = cfg.y + cfg.h * 0.5; + const f = state.smokeFrame; + + for(let i=0;i<12;i++){ + const dist = (f*0.5 + i*6) % 100; + const smx = ventX - dist; + const smy = ventY + dist*0.08 + (i%3-1)*2; + const opacity = 1 - dist/100; + if(opacity > 0.08){ + const sz = dist < 20 ? 4 + i%3 : 4 + i%3 + Math.floor(dist/6); + const gray = dist < 20 ? 245 : 220; + ctx.globalAlpha = opacity * 0.7; + ctx.fillStyle = `rgb(${gray},${gray},${gray})`; + ctx.beginPath(); + ctx.ellipse(smx, smy, sz/2, sz/2, 0, 0, Math.PI*2); + ctx.fill(); + } + } + ctx.globalAlpha = 1; +} + +// ───────────────────────────────────────────────────────────────────────────── +// LAUNCH FLAME PARTICLES +// ───────────────────────────────────────────────────────────────────────────── +const FLAME_COLORS = { + core: ['#ffffff','#ffffcc','#ffff88','#ffdd44'], + mid: ['#ffcc00','#ffaa00','#ff8800','#ff6600'], + outer: ['#ff6600','#ff4400','#dd2200','#aa1100'], +}; + +function spawnFlameParticles(flameX, flameY, intensity) { + const n = Math.floor(20 * intensity); + for(let i=0;i p.age < p.life); + state.flameParticles.forEach(p => { + const t = p.age / p.life; + const cols = FLAME_COLORS[p.type]; + const ci = Math.min(Math.floor(t * cols.length), cols.length-1); + if(t > 0.85 && Math.random() > 0.7) return; + const sz = p.size * (1.2 - t*0.8); + const wx = Math.sin(p.age*0.3)*1.5; + const wy = Math.cos(p.age*0.4)*0.8; + ctx.fillStyle = cols[ci]; + ctx.beginPath(); + ctx.ellipse(p.x+wx, p.y+wy, sz/2, sz/2, 0, 0, Math.PI*2); + ctx.fill(); + p.age++; p.x += p.vx; p.y += p.vy; + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// COUNTDOWN UI +// ───────────────────────────────────────────────────────────────────────────── +function drawCountdown() { + const launch = currentLaunch(); + if (!launch) return; + const cd = computeCountdown(launch.t0); + const vals = (cd && cd !== 'LAUNCHED') ? [cd.days, cd.hours, cd.minutes, cd.seconds] : [0,0,0,0]; + const LABELS = ['DAYS','HOURS','MINS','SECS']; + + const BW = 80, BH = 80, GAP = 7; + const TOTAL_W = 4*BW + 3*GAP; + const BX = Math.round((W - TOTAL_W) / 2); + const BY = 8; + + // Dark bar background + ctx.fillStyle = 'rgba(20,20,28,0.88)'; + ctx.beginPath(); roundRectPath(BX-16, BY-6, TOTAL_W+32, BH+30, 5); ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth=1; ctx.stroke(); + + // T-MINUS label + ctx.fillStyle='rgba(255,255,255,0.25)'; + ctx.font='bold 7px Courier New'; ctx.textAlign='center'; + ctx.fillText('T — M I N U S', BX+TOTAL_W/2, BY+2); + + if (cd === 'LAUNCHED' || state.postLaunchCooldown) { + ctx.fillStyle='#ff4444'; ctx.shadowColor='#ff2200'; ctx.shadowBlur=10; + ctx.font='bold 30px Courier New'; ctx.textAlign='center'; + ctx.fillText('LAUNCHED', BX+TOTAL_W/2, BY+BH/2+4); + ctx.shadowBlur=0; + if (state.postLaunchCooldown) { + const remSec = Math.max(0, Math.floor((state.cooldownEndsAt - Date.now()) / 1000)); + const remM = Math.floor(remSec / 60), remS = remSec % 60; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.font = 'bold 11px Courier New'; + ctx.fillText('NEXT ROCKET ON STAND IN ' + remM + ':' + String(remS).padStart(2,'0'), BX+TOTAL_W/2, BY+BH-4); + } + return; + } + if (!cd) { + ctx.fillStyle='#ffd93d'; ctx.font='bold 9px Courier New'; ctx.textAlign='center'; + ctx.fillText('LAUNCH TIME TBD', BX+TOTAL_W/2, BY+BH/2+10); return; + } + + LABELS.forEach((lbl, i) => { + const bx = BX + i*(BW+GAP); + const by = BY + 10; + + // Plastic body + ctx.fillStyle='#c8d4e0'; ctx.fillRect(bx, by, BW, BH); + // Bevels + ctx.fillStyle='#e2ecf4'; ctx.fillRect(bx, by, BW, 2); ctx.fillRect(bx, by, 2, BH); + ctx.fillStyle='#8a9db0'; ctx.fillRect(bx, by+BH-2, BW, 2); ctx.fillRect(bx+BW-2, by, 2, BH); + ctx.strokeStyle='#6080a0'; ctx.lineWidth=1; ctx.strokeRect(bx+0.5, by+0.5, BW-1, BH-1); + + // LCD screen + const px=7, py=6, sw=BW-14, sh=BH-py*2-16; + const sx=bx+px, sy=by+py; + ctx.fillStyle='#12202e'; ctx.fillRect(sx, sy, sw, sh); + ctx.strokeStyle='#1e3048'; ctx.lineWidth=1; ctx.strokeRect(sx+0.5, sy+0.5, sw-1, sh-1); + + // Segment ghost lines + ctx.strokeStyle='rgba(50,90,130,0.45)'; ctx.lineWidth=1; ctx.setLineDash([2,3]); + const hw=(sw-4)/2; + ctx.strokeRect(sx+2, sy+2, hw-1, sh-4); + ctx.strokeRect(sx+2+hw+1, sy+2, hw-1, sh-4); + ctx.beginPath(); + ctx.moveTo(sx+2, sy+2+(sh-4)/2); ctx.lineTo(sx+2+hw-1, sy+2+(sh-4)/2); + ctx.moveTo(sx+2+hw+1, sy+2+(sh-4)/2); ctx.lineTo(sx+2+hw*2+1, sy+2+(sh-4)/2); + ctx.stroke(); ctx.setLineDash([]); + + // Green digit — centred both axes inside the LCD screen + ctx.shadowColor='#00ff88'; ctx.shadowBlur=9; + ctx.fillStyle='#00e87a'; + ctx.font='bold 32px Courier New'; + ctx.textAlign='center'; + ctx.textBaseline='middle'; + ctx.fillText(String(vals[i]).padStart(2,'0'), sx + sw/2, sy + sh/2); + ctx.textBaseline='alphabetic'; + ctx.shadowBlur=0; + + // Label + ctx.fillStyle='#4a7aaa'; ctx.font='bold 7px Courier New'; ctx.textAlign='center'; + ctx.fillText(lbl, bx+BW/2, by+BH-3); + }); +} + + +// ───────────────────────────────────────────────────────────────────────────── +// BOTTOM INFO BAR +// ───────────────────────────────────────────────────────────────────────────── +function drawInfoBar() { + const BY = BAR_Y, BH = BAR_H, IX = 20; + + if (state.postLaunchCooldown) { + ctx.fillStyle = '#ff6644'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'left'; + ctx.fillText('LAUNCHED:', IX, BY + 24); + const lw = ctx.measureText('LAUNCHED:').width; + ctx.fillStyle = '#ffffff'; + ctx.fillText(' ' + state.launchedMissionName, IX + lw, BY + 24); + if (state.nextMissionName) { + let tStr = ''; + if (state.nextMissionT0) { + const cd2 = computeCountdown(state.nextMissionT0); + if (cd2 && cd2 !== 'LAUNCHED') { + tStr = cd2.days > 0 + ? 'T−' + cd2.days + 'd ' + String(cd2.hours).padStart(2,'0') + ':' + String(cd2.minutes).padStart(2,'0') + ':' + String(cd2.seconds).padStart(2,'0') + : 'T−' + String(cd2.hours).padStart(2,'0') + ':' + String(cd2.minutes).padStart(2,'0') + ':' + String(cd2.seconds).padStart(2,'0'); + } + } + ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.font = '12px monospace'; + ctx.fillText('UPCOMING:', IX, BY + 50); + const uw = ctx.measureText('UPCOMING:').width; + ctx.fillStyle = '#ffd93d'; + ctx.fillText(' ' + state.nextMissionName, IX + uw, BY + 50); + if (tStr) { + ctx.fillStyle = '#00e87a'; ctx.font = 'bold 12px monospace'; + const nw = ctx.measureText(' ' + state.nextMissionName).width; + ctx.fillText(' IN ' + tStr, IX + uw + nw, BY + 50); + } + } + return; + } + const launch = currentLaunch(); + if (!launch) { + ctx.fillStyle = 'rgba(255,100,50,0.7)'; + ctx.font = 'bold 13px monospace'; + ctx.textAlign = 'left'; + ctx.fillText('NO LAUNCH DATA', 20, BAR_Y + 24); + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.font = '11px monospace'; + ctx.fillText('Check network connection or API status', 20, BAR_Y + 46); + return; + } + const statusColors = { 'Go':'#00e87a','Go for Launch':'#00e87a','TBD':'#ffd93d','To Be Determined':'#ffd93d','To Be Confirmed':'#ffd93d' }; + const statusCol = statusColors[launch.status] || '#4a9ede'; + const formatT0 = t0 => { + if (!t0) return { date:'TBD', time:'' }; + try { + const d = new Date(t0); + return { + date: d.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric',timeZone:'UTC'}), + time: d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',timeZone:'UTC'}) + ' UTC', + }; + } catch(e) { return { date:t0, time:'' }; } + }; + const shorten = s => (s||'').replace('Space Launch Complex','SLC').replace('Launch Complex','LC') + .replace('Space Force Station','SFS').replace('Kennedy Space Center','KSC') + .replace('Cape Canaveral','CC').replace('Vandenberg Space Force Base','VSFB'); + const lt = formatT0(launch.t0 || launch.win_open); + const availW = W - IX - 100; + ctx.fillStyle = '#ffffff'; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'left'; + let missionName = launch.name || 'Unknown'; + while (ctx.measureText(missionName).width > availW && missionName.length > 4) missionName = missionName.slice(0,-1); + ctx.fillText(missionName, IX, BY + 24); + const dateStr = lt.date + (lt.time ? ' · ' + lt.time : ''); + const vehStr = (launch.vehicle||'') + ' · ' + (launch.provider||'') + ' · ' + shorten(launch.pad||launch.location||''); + ctx.fillStyle = '#ffd93d'; ctx.font = '13px monospace'; + ctx.fillText(dateStr, IX, BY + 44); + const dw = ctx.measureText(dateStr).width; + ctx.fillStyle = '#4a9ede'; + ctx.fillText(' · ' + vehStr, IX + dw, BY + 44); + const badgeW = 80, badgeH = 26, badgeX = W - badgeW - 14, badgeY = BY + 12; + ctx.fillStyle = statusCol + '28'; + ctx.beginPath(); roundRectPath(badgeX, badgeY, badgeW, badgeH, 4); ctx.fill(); + ctx.strokeStyle = statusCol; ctx.lineWidth = 2; ctx.stroke(); + ctx.fillStyle = statusCol; ctx.font = 'bold 12px monospace'; ctx.textAlign = 'center'; + ctx.fillText((launch.status||'TBD').toUpperCase(), badgeX + badgeW/2, badgeY + 17); + const minAgo = Math.floor((Date.now() - state.lastFetchAt) / 60000); + ctx.fillStyle = 'rgba(255,255,255,0.18)'; + ctx.font = '9px monospace'; + ctx.textAlign = 'right'; + ctx.fillText('v1.0.0 · data ' + minAgo + 'm ago', W - 130, BAR_Y + BAR_H - 35); +} + +// ───────────────────────────────────────────────────────────────────────────── +// UPDATE NOTIFICATION (slides in from right like Phase 1) +// ───────────────────────────────────────────────────────────────────────────── +function showNotification(text) { + state.notification = { text, alpha: 1, offset: 200, sliding: true }; +} + +function drawNotification() { + if (!state.notification) return; + const n = state.notification; + const nx = 800 - (200 - n.offset); // slides in + drawRect(nx-140, 10, 140, 30, '#2a2a2a', '#4a90e2', 2); + drawOval(nx-130, 25, 4, 4, '#00ff88'); + ctx.fillStyle='#ffffff'; ctx.font='bold 8px Courier New'; ctx.textAlign='center'; + ctx.fillText('DATA UPDATED', nx-68, 22); + ctx.fillStyle='#aaaaaa'; ctx.font='7px Courier New'; + ctx.fillText(ts(), nx-68, 33); +} + +// ───────────────────────────────────────────────────────────────────────────── +// COUNTDOWN COMPUTATION (runs locally from server-provided t0) +// ───────────────────────────────────────────────────────────────────────────── +function computeCountdown(t0) { + if (!t0) return null; + try { + const launch = new Date(t0); + const now = new Date(); + const diffMs = launch - now; + if (diffMs < 0) return 'LAUNCHED'; + const totalSec = Math.floor(diffMs / 1000); + const days = Math.floor(totalSec / 86400); + const hours = Math.floor((totalSec % 86400) / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + const seconds = totalSec % 60; + return { days, hours, minutes, seconds, total_seconds: totalSec }; + } catch(e) { return null; } +} + +// ───────────────────────────────────────────────────────────────────────────── +// LAUNCH SEQUENCE (T-0 trigger with single-fire guard) +// ───────────────────────────────────────────────────────────────────────────── +function checkLaunchTrigger() { + if (state.launchTriggered || state.isLaunching) return; + + const launch = currentLaunch(); + if (!launch || !launch.t0) return; + + const cd = computeCountdown(launch.t0); + + if (cd === 'LAUNCHED') { + const launchTime = new Date(launch.t0).getTime(); + const minsAgo = (Date.now() - launchTime) / 60000; + + if (minsAgo > 30) { + console.log(`[${ts()}] Stale launch (${Math.floor(minsAgo)}m ago) — burying and skipping`); + state.launchTriggered = true; + state.buriedLaunchId = launch.id; + fetchLaunches(true); + return; + } + + console.log(`[${ts()}] Missed launch detected — starting cooldown`); + state.launchTriggered = true; + state.launchComplete = true; + state.rocketOffscreen = true; + state.buriedLaunchId = launch.id; + state.launchedMissionName = launch.name || ''; + state.postLaunchCooldown = true; + state.cooldownEndsAt = Date.now() + 10 * 60 * 1000; + fetch('/api/launches').then(r => r.json()).then(data => { + const all = data.launches || []; + const next = all.find(l => l.id !== launch.id) || all[1] || all[0]; + state.nextMissionName = next ? (next.name || '') : ''; + state.nextMissionT0 = next ? (next.t0 || null) : null; + }).catch(() => {}); + return; +} + + if (!cd) return; + + // Fire at T-0 (within a 3-second window) + if (cd.total_seconds <= 3 && cd.total_seconds >= 0) { + console.log(`[${ts()}] T-0! Ignition sequence start`); + state.launchTriggered = true; // ← prevents re-trigger every second + startLaunchAnimation(); + } +} + +function startLaunchAnimation() { + state.isLaunching = true; + state.launchFrame = 0; + state.rocketY = PAD_Y_BASE; + state.flameParticles = []; + state.ventParticles = []; + state.flameIntensity = 0; + state.rocketOffscreen = false; +} + +function updateLaunch() { + if (!state.isLaunching) return; + + state.launchFrame++; + + // Phase 1: ignition build-up (150 frames ≈ 5s at 30fps) + if (state.launchFrame < 150) { + state.flameIntensity = state.launchFrame / 150; + } else { + // Phase 2: liftoff + const vel = Math.min(0.08 * (state.launchFrame - 150) * 0.5, 4); + state.rocketY -= vel; + + if (state.rocketY < -200) { + state.isLaunching = false; + state.rocketOffscreen = true; + state.launchComplete = true; + state.flameParticles = []; + const _launched = currentLaunch(); + state.buriedLaunchId = _launched ? _launched.id : null; + state.launchedMissionName = _launched ? (_launched.name || '') : ''; + state.postLaunchCooldown = true; + state.cooldownEndsAt = Date.now() + 10 * 60 * 1000; + if (!state.testMode) { + fetch('/api/launches/invalidate', { method: 'POST' }) + .then(() => fetch('/api/launches')).then(r => r.json()) + .then(data => { + const all = data.launches || []; + const prevId = _launched ? _launched.id : null; + const next = all.find(l => l.id !== prevId) || all[1] || all[0]; + state.nextMissionName = next ? (next.name || '') : ''; + state.nextMissionT0 = next ? (next.t0 || null) : null; + }).catch(() => {}); + } else { + fetch('/api/launches').then(r => r.json()).then(data => { + const all = data.launches || []; + const prevId = _launched ? _launched.id : null; + const next = all.find(l => l.id !== prevId) || all[1] || all[0]; + state.nextMissionName = next ? (next.name || '') : ''; + state.nextMissionT0 = next ? (next.t0 || null) : null; + }).catch(() => {}); + } + } + } + + const flameX = NOZZLE_X; + const flameY = NOZZLE_Y + (state.rocketY - PAD_Y_BASE) + 8; + if (state.flameIntensity > 0) { + spawnFlameParticles(flameX, flameY, state.flameIntensity); + } +} + +function drawNoSignal() { + if (Date.now() - state.lastFetchAt < 15 * 60 * 1000) return; + ctx.fillStyle = 'rgba(0,0,0,0.72)'; + ctx.fillRect(0, 0, W, H); + const cx = W / 2, cy = H / 2 - 20; + ctx.strokeStyle = 'rgba(255,60,30,0.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); roundRectPath(cx - 140, cy - 50, 280, 110, 4); ctx.stroke(); + ctx.fillStyle = '#ff4422'; + ctx.shadowColor = '#ff2200'; ctx.shadowBlur = 12; + ctx.font = 'bold 28px Courier New'; ctx.textAlign = 'center'; + ctx.fillText('NO SIGNAL', cx, cy); + ctx.shadowBlur = 0; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '11px Courier New'; + ctx.fillText('LAUNCH DATA UNAVAILABLE', cx, cy + 28); + const minAgo = Math.floor((Date.now() - state.lastFetchAt) / 60000); + ctx.fillStyle = 'rgba(255,120,60,0.6)'; + ctx.font = '10px Courier New'; + ctx.fillText('Last update ' + minAgo + ' min ago', cx, cy + 50); +} + +// ───────────────────────────────────────────────────────────────────────────── +// DATA FETCHING +// ───────────────────────────────────────────────────────────────────────────── +function currentLaunch() { + return state.launches[state.currentIdx] || null; +} + +async function fetchLaunches(afterLaunch=false) { + try { + const res = await fetch('/api/launches'); + const data = await res.json(); + const _cl = currentLaunch(); const prev = _cl ? _cl.id : undefined; + + const newLaunches = data.launches || []; + if (newLaunches.length === 0 && state.launches.length > 0) return; + state.launches = newLaunches; + state.lastFetchAt = Date.now(); + + if (afterLaunch) { + const newLaunch = state.launches.find(l => l.id !== state.buriedLaunchId) || state.launches[0]; + state.currentIdx = newLaunch ? state.launches.indexOf(newLaunch) : 0; + state.launchTriggered = false; + state.isLaunching = false; + state.rocketOffscreen = false; + state.launchComplete = false; + state.rocketY = PAD_Y_BASE; + state.flameParticles = []; + showNotification('NEXT MISSION'); + } else { + // Skip buried launch on every regular poll + const firstValid = state.launches.findIndex(l => l.id !== state.buriedLaunchId); + state.currentIdx = firstValid >= 0 ? firstValid : 0; + } + } catch(e) { + console.error('Launch fetch error:', e); + } +} + +async function fetchWeather() { + try { + const res = await fetch('/api/weather'); + state.weather = await res.json(); + } catch(e) { + console.error('Weather fetch error:', e); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ANIMATION UPDATES +// ───────────────────────────────────────────────────────────────────────────── +function updateClouds() { + state.clouds.forEach(c => { + c.x += 0.3; + if (c.x > 900) c.x = -60; + }); +} + +function updateBirds() { + state.birds.forEach(b => { + b.flap++; + if(b.flap >= 8){ b.flap=0; b.flapUp=!b.flapUp; } + b.x += b.vx; b.y += b.vy; + if(b.y<60||b.y>300) b.vy*=-1; + if(b.x>860){ + b.x=-50; + b.y=80+Math.random()*200; + b.vx=0.8+Math.random()*1; + b.vy=(Math.random()-0.5)*0.3; + } + }); +} + +function updateCars() { + const now = Date.now(); + // Open gate every 3 seconds + if (now - state.lastGateOpen >= 3000) { + state.lastGateOpen = now; + const waiting = state.cars.find(c => c.state === 'waiting'); + if (waiting) { waiting.state = 'entering'; waiting.waitStart = now; } + } + + state.cars.forEach(car => { + if (car.state === 'approaching') { + const ahead = state.cars.filter(c => + (c.state === 'waiting' || c.state === 'approaching') && + c.x < car.x && c.x > car.x - 100 + ); + if (ahead.length) { + const closest = ahead.reduce((a,b) => a.x>b.x?a:b); + if (car.x >= closest.x - 15) { car.state='waiting'; return; } + } + if (car.x >= GATE_X - 20) { car.state='waiting'; return; } + car.x += car.baseSpeed; + } else if (car.state === 'entering') { + if (now - car.waitStart < 2000) car.x += car.baseSpeed; + else car.state = 'driving'; + } else if (car.state === 'driving') { + car.x += car.baseSpeed; + if (car.x > 860) { + car.x = -50; + car.baseSpeed = 0.8 + Math.random()*0.4; + car.state = 'approaching'; + } + } + }); +} + +function updateGator() { + state.gatorTimer++; + if (state.gatorTimer < 25) { + state.gatorPhase = 0; + } else if (state.gatorTimer < 30) { + state.gatorPhase = (state.gatorTimer - 25) / 5; + } else if (state.gatorTimer < 50) { + state.gatorPhase = 1; + } else if (state.gatorTimer < 55) { + state.gatorPhase = 1 - (state.gatorTimer - 50) / 5; + } else { + state.gatorPhase = 0; + if (state.gatorTimer > 90) state.gatorTimer = 0; + } +} + +function updateTowerLights() { + state.lightCounter++; + if (state.lightCounter >= 30) { + state.lightCounter = 0; + state.lightOn = !state.lightOn; + } +} + +function updateNotification() { + if (!state.notification) return; + const n = state.notification; + if (n.sliding && n.offset > 0) { + n.offset = Math.max(0, n.offset - 8); + } else { + n.sliding = false; + n.alpha -= 0.008; + if (n.alpha <= 0) state.notification = null; + } +} + +function updateSmoke() { + state.smokeFrame = (state.smokeFrame + 1) % 1000; +} + +function drawWifiIcon() { + const x = W - 36, y = 16; + const connected = Date.now() - state.lastFetchAt < 20 * 60 * 1000; + const col = connected ? '#00e87a' : '#ff4422'; + + ctx.strokeStyle = col; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + + // Dot + ctx.fillStyle = col; + drawOval(x, y + 18, 2, 2, col); + + // Arc 1 (small) + ctx.beginPath(); + ctx.arc(x, y + 18, 6, Math.PI * 1.25, Math.PI * 1.75); + ctx.stroke(); + + // Arc 2 (medium) + ctx.beginPath(); + ctx.arc(x, y + 18, 11, Math.PI * 1.2, Math.PI * 1.8); + ctx.stroke(); + + // Arc 3 (large) + ctx.beginPath(); + ctx.arc(x, y + 18, 16, Math.PI * 1.15, Math.PI * 1.85); + ctx.stroke(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// MAIN RENDER LOOP +// ───────────────────────────────────────────────────────────────────────────── +let lastFrame = 0; +const TARGET_FPS = 30; +const FRAME_MS = 1000 / TARGET_FPS; + +function render(now) { + requestAnimationFrame(render); + if (now - lastFrame < FRAME_MS) return; + lastFrame = now; + + // ── Updates ── + updateClouds(); + updateBirds(); + updateCars(); + updateGator(); + updateTowerLights(); + updateNotification(); + updateSmoke(); + checkLaunchTrigger(); + updateLaunch(); + updateCooldown(); + + // ── Draw (back to front) ── + ctx.clearRect(0, 0, W, H); + + drawBackground(); + drawClouds(); + // drawVAB(); // VAB removed for now + drawTE(); + drawHIF(); + // drawFences(); + drawRocket(); // ← Draw rocket FIRST (behind) + drawLaunchTower(); // ← Draw tower AFTER (in front) + drawLaunchPad(); + drawPond(); + drawBirds(); + drawCars(); + drawSpotlights(); + drawSmoke(); + drawFlameParticles(); + drawInfoBar(); + drawCountdown(); + if (state.notification) drawNotification(); + + drawWifiIcon(); + drawNoSignal(); +} + +// ───────────────────────────────────────────────────────────────────────────── +// POLLING +// ───────────────────────────────────────────────────────────────────────────── +function updateCooldown() { + if (!state.postLaunchCooldown) return; + if (Date.now() >= state.cooldownEndsAt) { + state.postLaunchCooldown = false; + state.cooldownEndsAt = 0; + state.launchedMissionName = ''; + state.nextMissionName = ''; + state.nextMissionT0 = null; + fetchLaunches(true); + } +} + +function startPolling() { + setInterval(() => { + if (!state.isLaunching && !state.postLaunchCooldown) { + fetchLaunches().then(() => showNotification('DATA UPDATED')); + } + }, 5 * 60 * 1000); + + // Refresh weather every 15 minutes + setInterval(fetchWeather, 15 * 60 * 1000); +} + +// ───────────────────────────────────────────────────────────────────────────── +// TEST LAUNCH BUTTON +// ───────────────────────────────────────────────────────────────────────────── +document.getElementById('btn-test').addEventListener('click', () => { + if (state.isLaunching || state.testMode) return; + const launch = currentLaunch(); + if (!launch) return; + console.log(`[${ts()}] TEST MODE — overriding t0 to T-3s`); + const originalT0 = launch.t0; + const originalTriggered = state.launchTriggered; + state.testMode = true; + launch.t0 = new Date(Date.now() + 3000).toISOString(); + state.launchTriggered = false; + + const checkReset = setInterval(() => { + if (!state.rocketOffscreen) return; + clearInterval(checkReset); + + // Show cooldown for 10s in test mode (not 10 min) + const launchedName = launch.name || ''; + state.launchedMissionName = launchedName; + state.nextMissionName = launchedName; // same mission coming back + state.nextMissionT0 = originalT0; // real t0 = the "next" launch + state.postLaunchCooldown = true; + state.cooldownEndsAt = Date.now() + 10 * 1000; // 10 seconds for testing + + // When cooldown ends, restore everything back to normal + const cooldownEnd = setInterval(() => { + if (Date.now() < state.cooldownEndsAt) return; + clearInterval(cooldownEnd); + launch.t0 = originalT0; + state.launchTriggered = originalTriggered; + state.testMode = false; + state.postLaunchCooldown = false; + state.launchedMissionName = ''; + state.nextMissionName = ''; + state.nextMissionT0 = null; + state.rocketOffscreen = false; + state.launchComplete = false; + state.rocketY = PAD_Y_BASE; + state.flameParticles = []; + state.flameIntensity = 0; + console.log(`[${ts()}] TEST MODE complete — restored`); + }, 500); + + console.log(`[${ts()}] TEST MODE rocket offscreen — cooldown demo started`); + }, 200); +}); + +canvas.addEventListener('click', function(e) { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // WiFi icon tap zone (top right) + if (x > W - 52 && x < W && y > 0 && y < 40) { + window.location = 'http://localhost:5001/wifi'; + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// BOOT +// ───────────────────────────────────────────────────────────────────────────── +(async function boot() { + await loadAssets(); + spawnBirds(); + spawnCars(); + await Promise.all([fetchLaunches(), fetchWeather()]); + startPolling(); + requestAnimationFrame(render); + console.log(`[${ts()}] Launch Countdown Phase 2 ready`); +})(); \ No newline at end of file diff --git a/launch-timer/Phase2/static/wifi.html b/launch-timer/Phase2/static/wifi.html new file mode 100644 index 0000000..5b8ffb4 --- /dev/null +++ b/launch-timer/Phase2/static/wifi.html @@ -0,0 +1,219 @@ + + + + + WiFi Setup + + + +

WiFi Setup

+
Scanning...
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/launch-timer/Phase3/UserDashboard b/launch-timer/Phase3/UserDashboard new file mode 100644 index 0000000..6bbada5 --- /dev/null +++ b/launch-timer/Phase3/UserDashboard @@ -0,0 +1,269 @@ +import { useState } from "react"; + +const mockUnits = [ + { id: "LT-001", mac: "b8:27:eb:f0:f8:2a", online: true, lastSeen: "12s ago", version: "v1.0.0", wifi: "Riemanwifi", uptime: "3d 4h", ip: "192.168.1.93", launch: "Starlink 10-48", location: "Cape Canaveral, FL" }, + { id: "LT-002", mac: "b8:27:eb:a1:2c:4f", online: true, lastSeen: "1m ago", version: "v1.0.0", wifi: "HomeNetwork", uptime: "1d 12h", ip: "192.168.0.44", launch: "Starlink 10-48", location: "Houston, TX" }, + { id: "LT-003", mac: "b8:27:eb:33:9d:11", online: false, lastSeen: "2h ago", version: "v0.9.8", wifi: "OfficeWifi", uptime: "—", ip: "10.0.0.12", launch: "—", location: "Austin, TX" }, + { id: "LT-004", mac: "b8:27:eb:77:bc:88", online: true, lastSeen: "30s ago", version: "v1.0.0", wifi: "SpacenerdsHQ", uptime: "5h 22m", ip: "192.168.2.101", launch: "Starlink 10-48", location: "Denver, CO" }, + { id: "LT-005", mac: "b8:27:eb:cc:01:fe", online: true, lastSeen: "4m ago", version: "v1.0.0", wifi: "LaunchFan_Net", uptime: "2d 1h", ip: "172.16.0.5", launch: "Starlink 10-48", location: "Seattle, WA" }, + { id: "LT-006", mac: "b8:27:eb:55:de:23", online: false, lastSeen: "1d ago", version: "v0.9.8", wifi: "—", uptime: "—", ip: "—", launch: "—", location: "Unknown" }, +]; + +const stats = { + total: 6, + online: 4, + offline: 2, + outdated: 2, +}; + +function StatusDot({ online }) { + return ( + + ); +} + +function StatCard({ label, value, accent }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +export default function Dashboard() { + const [selected, setSelected] = useState(null); + const [filter, setFilter] = useState("all"); + const [pushStatus, setPushStatus] = useState(null); + + const filtered = mockUnits.filter(u => { + if (filter === "online") return u.online; + if (filter === "offline") return !u.online; + if (filter === "outdated") return u.version !== "v1.0.0"; + return true; + }); + + const handlePushUpdate = () => { + setPushStatus("pushing"); + setTimeout(() => setPushStatus("done"), 2000); + }; + + return ( +
+ {/* Header */} +
+
+
🚀
+
+
LAUNCHTRACKER
+
FLEET DASHBOARD
+
+
+
+
+ LAST SYNC: {new Date().toLocaleTimeString('en-US', { hour12: false })} +
+ +
+
+ +
+ {/* Stats */} +
+ + + + +
+ + {/* Filter tabs */} +
+ {["all", "online", "offline", "outdated"].map(f => ( + + ))} +
+ + {/* Unit list */} +
+ {filtered.map(unit => ( +
setSelected(selected?.id === unit.id ? null : unit)} + style={{ + background: selected?.id === unit.id ? "rgba(0,232,122,0.05)" : "rgba(255,255,255,0.02)", + border: `1px solid ${selected?.id === unit.id ? "rgba(0,232,122,0.25)" : "rgba(255,255,255,0.07)"}`, + borderRadius: 8, + padding: "14px 18px", + cursor: "pointer", + transition: "all 0.15s", + }} + > +
+ {/* ID */} +
+ + {unit.id} +
+ + {/* Location */} +
{unit.location}
+ + {/* WiFi */} +
+ wifi + {unit.wifi} +
+ + {/* Version */} +
+ {unit.version} +
+ + {/* Last seen */} +
{unit.lastSeen}
+
+ + {/* Expanded detail */} + {selected?.id === unit.id && ( +
+
+
MAC ADDRESS
+
{unit.mac}
+
+
+
IP ADDRESS
+
{unit.ip}
+
+
+
UPTIME
+
{unit.uptime}
+
+
+
CURRENT LAUNCH
+
{unit.launch}
+
+
+ + + +
+
+ )} +
+ ))} +
+ + {/* Footer */} +
+ LAUNCHTRACKER FLEET MANAGEMENT · {stats.online}/{stats.total} UNITS ONLINE +
+
+
+ ); +} \ No newline at end of file diff --git a/launch-timer/__pycache__/api_client.cpython-312.pyc b/launch-timer/__pycache__/api_client.cpython-312.pyc index 64a5011..2dc26a7 100644 Binary files a/launch-timer/__pycache__/api_client.cpython-312.pyc and b/launch-timer/__pycache__/api_client.cpython-312.pyc differ diff --git a/launch-timer/__pycache__/landscape.cpython-312.pyc b/launch-timer/__pycache__/landscape.cpython-312.pyc index deb662a..ba75c37 100644 Binary files a/launch-timer/__pycache__/landscape.cpython-312.pyc and b/launch-timer/__pycache__/landscape.cpython-312.pyc differ diff --git a/launch-timer/__pycache__/launch_animation.cpython-312.pyc b/launch-timer/__pycache__/launch_animation.cpython-312.pyc index 621b437..e548ab9 100644 Binary files a/launch-timer/__pycache__/launch_animation.cpython-312.pyc and b/launch-timer/__pycache__/launch_animation.cpython-312.pyc differ diff --git a/launch-timer/__pycache__/rockets.cpython-312.pyc b/launch-timer/__pycache__/rockets.cpython-312.pyc index ef8a78f..41f0106 100644 Binary files a/launch-timer/__pycache__/rockets.cpython-312.pyc and b/launch-timer/__pycache__/rockets.cpython-312.pyc differ diff --git a/launch-timer/__pycache__/ui_elements.cpython-312.pyc b/launch-timer/__pycache__/ui_elements.cpython-312.pyc index 5bf5b80..ac50e15 100644 Binary files a/launch-timer/__pycache__/ui_elements.cpython-312.pyc and b/launch-timer/__pycache__/ui_elements.cpython-312.pyc differ diff --git a/launch-timer/__pycache__/weather.cpython-312.pyc b/launch-timer/__pycache__/weather.cpython-312.pyc index 79b9be3..6a53093 100644 Binary files a/launch-timer/__pycache__/weather.cpython-312.pyc and b/launch-timer/__pycache__/weather.cpython-312.pyc differ diff --git a/launch-timer/aircraft.py b/launch-timer/aircraft.py deleted file mode 100644 index 9fc66ee..0000000 --- a/launch-timer/aircraft.py +++ /dev/null @@ -1,686 +0,0 @@ -#!/usr/bin/env python3 -""" -T-38 aircraft animation for flyby sequences - PIXEL ART STYLE. -""" - -import random -import time - - -class T38Aircraft: - """T-38 trainer jet that flies across the screen periodically.""" - - def __init__(self, canvas): - self.canvas = canvas - self.active = False - self.x = 0 - self.y = 0 - self.direction = 1 # 1 for left-to-right, -1 for right-to-left - self.speed = 5 - self.aircraft_ids = [] - self.trail_ids = [] - # Set first flyby to happen 45-60 seconds after initialization - current_time = time.time() * 1000 - self.next_flyby_time = current_time + random.randint(45000, 60000) - self.last_update_time = 0 - - def should_start_flyby(self, current_time): - """Check if it's time to start a new flyby.""" - actual_current_time = time.time() * 1000 - if not self.active and actual_current_time >= self.next_flyby_time: - return True - return False - - def start_flyby(self): - """Initialize a new flyby.""" - self.active = True - - # Random direction - self.direction = random.choice([-1, 1]) - - # Random height in upper sky area (well above launch tower at y=140) - self.y = random.randint(60, 120) - - # Start position off screen - if self.direction == 1: # Left to right - self.x = -100 - else: # Right to left - self.x = 900 - - # Draw the aircraft - self.draw_aircraft() - - def draw_aircraft(self): - """Draw the T-38 aircraft in pixel art style.""" - # Clear any existing aircraft - self.clear_aircraft() - - # Aircraft is drawn facing the direction of travel - if self.direction == 1: # Flying right - self.draw_t38_right() - else: # Flying left - self.draw_t38_left() - - def draw_t38_right(self): - """Draw pixel-art T-38 flying to the right.""" - x = self.x - y = self.y - - # Color palette - white = '#f5f5f5' - light_gray = '#d8d8d8' - med_blue = '#4a7dc8' - dark_blue = '#1a3a6a' - red = '#d62828' - outline = '#0a1a3a' - - # === MAIN FUSELAGE BODY === - # White main body - pointed nose expanding to cockpit, tapering to tail - fuselage_points = [ - x - 10, y, # Nose point - x + 5, y - 4, # Expanding - x + 20, y - 6, # Cockpit area (widest) - x + 35, y - 6, - x + 50, y - 5, # Tapering - x + 65, y - 3, - x + 75, y - 2, # Tail - x + 75, y + 2, # Tail bottom - x + 65, y + 3, - x + 50, y + 5, - x + 35, y + 6, - x + 20, y + 6, - x + 5, y + 4, - ] - - # Main white body - body = self.canvas.create_polygon( - fuselage_points, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(body) - - # Subtle top shading (light gray) - top_shade = self.canvas.create_polygon( - x - 8, y, - x + 10, y - 3, - x + 30, y - 5, - x + 55, y - 4, - x + 72, y - 1, - x + 70, y, - x + 50, y - 2, - x + 25, y - 3, - x + 5, y - 1, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(top_shade) - - # === DARK BLUE UNDERSIDE BAND === - underside = self.canvas.create_polygon( - x - 5, y + 2, - x + 20, y + 5, - x + 50, y + 5, - x + 73, y + 2, - x + 73, y + 1, - x + 50, y + 3, - x + 20, y + 3, - x - 5, y + 1, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(underside) - - # === MEDIUM BLUE HORIZONTAL STRIPE === - stripe = self.canvas.create_rectangle( - x + 12, y, - x + 68, y + 3, - fill=med_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(stripe) - - # === COCKPIT === - # Raised canopy base - canopy_base = self.canvas.create_rectangle( - x + 18, y - 8, - x + 38, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(canopy_base) - - # Front window - window1 = self.canvas.create_rectangle( - x + 19, y - 8, - x + 27, y - 6, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(window1) - - # Rear window - window2 = self.canvas.create_rectangle( - x + 28, y - 8, - x + 37, y - 6, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(window2) - - # White frame separator - frame = self.canvas.create_rectangle( - x + 27, y - 8, - x + 28, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(frame) - - # === BODY DETAILS === - # Small red square behind cockpit - red_mark = self.canvas.create_rectangle( - x + 40, y - 4, - x + 43, y - 2, - fill=red, outline='', tags='aircraft' - ) - self.aircraft_ids.append(red_mark) - - # Small dark panel on fuselage (in the stripe) - panel = self.canvas.create_rectangle( - x + 48, y + 1, - x + 52, y + 2, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(panel) - - # === WINGS (small swept delta) === - # Top wing - wing_top = self.canvas.create_polygon( - x + 30, y - 6, - x + 26, y - 14, - x + 38, y - 12, - x + 42, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_top) - - # Top wing shading - wing_top_shade = self.canvas.create_polygon( - x + 30, y - 6, - x + 27, y - 13, - x + 34, y - 11, - x + 36, y - 6, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_top_shade) - - # Bottom wing (no shading on bottom) - wing_bottom = self.canvas.create_polygon( - x + 30, y + 6, - x + 26, y + 14, - x + 38, y + 12, - x + 42, y + 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_bottom) - - # === TAIL SECTION === - # Vertical stabilizer - tail = self.canvas.create_polygon( - x + 66, y - 2, - x + 64, y - 12, - x + 73, y - 10, - x + 75, y - 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(tail) - - # Tail shading - tail_shade = self.canvas.create_polygon( - x + 66, y - 2, - x + 65, y - 10, - x + 70, y - 9, - x + 71, y - 2, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(tail_shade) - - # NASA meatball on tail - # Blue circle - nasa_circle = self.canvas.create_oval( - x + 67, y - 8, - x + 73, y - 4, - fill=med_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(nasa_circle) - - # Red vector slash - nasa_vector = self.canvas.create_polygon( - x + 68, y - 6.5, - x + 72, y - 5.5, - x + 71, y - 6.8, - fill=red, outline='', tags='aircraft' - ) - self.aircraft_ids.append(nasa_vector) - - # Horizontal stabilizers - h_stab_top = self.canvas.create_polygon( - x + 66, y - 2, - x + 63, y - 7, - x + 72, y - 6, - x + 74, y - 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(h_stab_top) - - h_stab_bottom = self.canvas.create_polygon( - x + 66, y + 2, - x + 63, y + 7, - x + 72, y + 6, - x + 74, y + 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(h_stab_bottom) - - # === REGISTRATION TEXT === - reg_text = self.canvas.create_text( - x + 58, y + 1.5, - text="N901NA", - font=('Courier', 5, 'bold'), - fill=white, - tags='aircraft' - ) - self.aircraft_ids.append(reg_text) - - # === ENGINE NOZZLE === - nozzle = self.canvas.create_rectangle( - x + 74, y - 2, - x + 78, y + 2, - fill='#2a2a2a', outline='', tags='aircraft' - ) - self.aircraft_ids.append(nozzle) - - # Inner nozzle - nozzle_inner = self.canvas.create_rectangle( - x + 75, y - 1, - x + 77, y + 1, - fill='#1a1a1a', outline='', tags='aircraft' - ) - self.aircraft_ids.append(nozzle_inner) - - # === DARK BLUE OUTLINE (draw last so it's on top) === - outline_elem = self.canvas.create_polygon( - fuselage_points, - fill='', outline=outline, width=2, tags='aircraft' - ) - self.aircraft_ids.append(outline_elem) - - # Wing outlines - wing_outline_top = self.canvas.create_polygon( - x + 30, y - 6, - x + 26, y - 14, - x + 38, y - 12, - x + 42, y - 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(wing_outline_top) - - wing_outline_bottom = self.canvas.create_polygon( - x + 30, y + 6, - x + 26, y + 14, - x + 38, y + 12, - x + 42, y + 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(wing_outline_bottom) - - # Tail outline - tail_outline = self.canvas.create_polygon( - x + 66, y - 2, - x + 64, y - 12, - x + 73, y - 10, - x + 75, y - 2, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(tail_outline) - - # Cockpit outline - canopy_outline = self.canvas.create_rectangle( - x + 18, y - 8, - x + 38, y - 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(canopy_outline) - - def draw_t38_left(self): - """Draw pixel-art T-38 flying to the left (mirrored).""" - x = self.x - y = self.y - - # Color palette - white = '#f5f5f5' - light_gray = '#d8d8d8' - med_blue = '#4a7dc8' - dark_blue = '#1a3a6a' - red = '#d62828' - outline = '#0a1a3a' - - # === MAIN FUSELAGE BODY (mirrored) === - fuselage_points = [ - x + 10, y, - x - 5, y - 4, - x - 20, y - 6, - x - 35, y - 6, - x - 50, y - 5, - x - 65, y - 3, - x - 75, y - 2, - x - 75, y + 2, - x - 65, y + 3, - x - 50, y + 5, - x - 35, y + 6, - x - 20, y + 6, - x - 5, y + 4, - ] - - body = self.canvas.create_polygon( - fuselage_points, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(body) - - top_shade = self.canvas.create_polygon( - x + 8, y, - x - 10, y - 3, - x - 30, y - 5, - x - 55, y - 4, - x - 72, y - 1, - x - 70, y, - x - 50, y - 2, - x - 25, y - 3, - x - 5, y - 1, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(top_shade) - - # === DARK BLUE UNDERSIDE === - underside = self.canvas.create_polygon( - x + 5, y + 2, - x - 20, y + 5, - x - 50, y + 5, - x - 73, y + 2, - x - 73, y + 1, - x - 50, y + 3, - x - 20, y + 3, - x + 5, y + 1, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(underside) - - # === STRIPE === - stripe = self.canvas.create_rectangle( - x - 12, y, - x - 68, y + 3, - fill=med_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(stripe) - - # === COCKPIT === - canopy_base = self.canvas.create_rectangle( - x - 18, y - 8, - x - 38, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(canopy_base) - - window1 = self.canvas.create_rectangle( - x - 19, y - 8, - x - 27, y - 6, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(window1) - - window2 = self.canvas.create_rectangle( - x - 28, y - 8, - x - 37, y - 6, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(window2) - - frame = self.canvas.create_rectangle( - x - 27, y - 8, - x - 28, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(frame) - - # === DETAILS === - red_mark = self.canvas.create_rectangle( - x - 40, y - 4, - x - 43, y - 2, - fill=red, outline='', tags='aircraft' - ) - self.aircraft_ids.append(red_mark) - - panel = self.canvas.create_rectangle( - x - 48, y + 1, - x - 52, y + 2, - fill=dark_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(panel) - - # === WINGS === - wing_top = self.canvas.create_polygon( - x - 30, y - 6, - x - 26, y - 14, - x - 38, y - 12, - x - 42, y - 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_top) - - wing_top_shade = self.canvas.create_polygon( - x - 30, y - 6, - x - 27, y - 13, - x - 34, y - 11, - x - 36, y - 6, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_top_shade) - - wing_bottom = self.canvas.create_polygon( - x - 30, y + 6, - x - 26, y + 14, - x - 38, y + 12, - x - 42, y + 6, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(wing_bottom) - - # === TAIL === - tail = self.canvas.create_polygon( - x - 66, y - 2, - x - 64, y - 12, - x - 73, y - 10, - x - 75, y - 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(tail) - - tail_shade = self.canvas.create_polygon( - x - 66, y - 2, - x - 65, y - 10, - x - 70, y - 9, - x - 71, y - 2, - fill=light_gray, outline='', tags='aircraft' - ) - self.aircraft_ids.append(tail_shade) - - # NASA logo - nasa_circle = self.canvas.create_oval( - x - 73, y - 8, - x - 67, y - 4, - fill=med_blue, outline='', tags='aircraft' - ) - self.aircraft_ids.append(nasa_circle) - - nasa_vector = self.canvas.create_polygon( - x - 72, y - 6.5, - x - 68, y - 5.5, - x - 69, y - 6.8, - fill=red, outline='', tags='aircraft' - ) - self.aircraft_ids.append(nasa_vector) - - # H-stabs - h_stab_top = self.canvas.create_polygon( - x - 66, y - 2, - x - 63, y - 7, - x - 72, y - 6, - x - 74, y - 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(h_stab_top) - - h_stab_bottom = self.canvas.create_polygon( - x - 66, y + 2, - x - 63, y + 7, - x - 72, y + 6, - x - 74, y + 2, - fill=white, outline='', tags='aircraft' - ) - self.aircraft_ids.append(h_stab_bottom) - - # === TEXT === - reg_text = self.canvas.create_text( - x - 58, y + 1.5, - text="N901NA", - font=('Courier', 5, 'bold'), - fill=white, - tags='aircraft' - ) - self.aircraft_ids.append(reg_text) - - # === NOZZLE === - nozzle = self.canvas.create_rectangle( - x - 74, y - 2, - x - 78, y + 2, - fill='#2a2a2a', outline='', tags='aircraft' - ) - self.aircraft_ids.append(nozzle) - - nozzle_inner = self.canvas.create_rectangle( - x - 75, y - 1, - x - 77, y + 1, - fill='#1a1a1a', outline='', tags='aircraft' - ) - self.aircraft_ids.append(nozzle_inner) - - # === OUTLINES === - outline_elem = self.canvas.create_polygon( - fuselage_points, - fill='', outline=outline, width=2, tags='aircraft' - ) - self.aircraft_ids.append(outline_elem) - - wing_outline_top = self.canvas.create_polygon( - x - 30, y - 6, - x - 26, y - 14, - x - 38, y - 12, - x - 42, y - 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(wing_outline_top) - - wing_outline_bottom = self.canvas.create_polygon( - x - 30, y + 6, - x - 26, y + 14, - x - 38, y + 12, - x - 42, y + 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(wing_outline_bottom) - - tail_outline = self.canvas.create_polygon( - x - 66, y - 2, - x - 64, y - 12, - x - 73, y - 10, - x - 75, y - 2, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(tail_outline) - - canopy_outline = self.canvas.create_rectangle( - x - 18, y - 8, - x - 38, y - 6, - fill='', outline=outline, width=1, tags='aircraft' - ) - self.aircraft_ids.append(canopy_outline) - - def draw_trail(self): - """Draw a contrail/exhaust trail behind the aircraft.""" - # Clear old trail segments (keep only last 30) - while len(self.trail_ids) > 30: - old_trail = self.trail_ids.pop(0) - try: - self.canvas.delete(old_trail) - except: - pass - - # Add new trail segment - positioned at exhaust - trail_length = 20 - - if self.direction == 1: # Flying right - trail_x = self.x + 76 - else: # Flying left - trail_x = self.x - 76 - - # Create fading trail effect - trail_id = self.canvas.create_line( - trail_x, self.y, - trail_x - (trail_length * self.direction), self.y, - fill='#e8e8e8', width=2, tags='aircraft_trail' - ) - self.trail_ids.append(trail_id) - - def update(self, delta_time): - """Update aircraft position.""" - if not self.active: - return - - # Move aircraft - self.x += self.speed * self.direction - - # Draw trail occasionally - if random.random() < 0.3: - self.draw_trail() - - # Redraw aircraft at new position - self.draw_aircraft() - - # Check if aircraft has left the screen - if self.direction == 1 and self.x > 900: - self.end_flyby() - elif self.direction == -1 and self.x < -100: - self.end_flyby() - - def end_flyby(self): - """End the current flyby and schedule next one.""" - self.active = False - self.clear_aircraft() - self.clear_trail() - - # Schedule next flyby in 45-60 seconds from NOW - current_time = time.time() * 1000 - self.next_flyby_time = current_time + random.randint(45000, 60000) - self.last_update_time = 0 - - def clear_aircraft(self): - """Remove aircraft from canvas.""" - for aircraft_id in self.aircraft_ids: - try: - self.canvas.delete(aircraft_id) - except: - pass - self.aircraft_ids = [] - - def clear_trail(self): - """Remove trail from canvas.""" - for trail_id in self.trail_ids: - try: - self.canvas.delete(trail_id) - except: - pass - self.trail_ids = [] \ No newline at end of file diff --git a/launch-timer/api_client.py b/launch-timer/api_client.py index cd0044d..408abd6 100644 --- a/launch-timer/api_client.py +++ b/launch-timer/api_client.py @@ -7,6 +7,11 @@ from datetime import datetime, timezone +def _ts(): + """Return a compact timestamp string for log lines.""" + return datetime.now().strftime("%H:%M:%S") + + def fetch_launches(num_launches=5): """Fetch the next upcoming rocket launches. @@ -21,10 +26,9 @@ def fetch_launches(num_launches=5): data = response.json() launches = data.get('result', []) - print(f"\n=== API returned {len(launches)} launches ===") - # Filter to only upcoming launches (not already completed) filtered_launches = [] + skipped = [] for launch in launches: name = launch.get('name', 'Unknown') @@ -37,33 +41,35 @@ def fetch_launches(num_launches=5): # Get result (1=success, 2=failure, 3=partial, null=not launched, -1=scrubbed/TBD) result = launch.get('result') - # Get launch time - launch_time_str = launch.get('t0') or launch.get('win_open') - - print(f"\n{name}") - print(f" Status: {status_name} (id={status_id})") - print(f" Result: {result}") - print(f" T0: {launch_time_str}") - # Skip if launch has a POSITIVE result (1, 2, 3 = already completed) if result is not None and result > 0: - print(f" -> SKIPPING (already completed with result={result})") + skipped.append(f"{name} (completed)") continue - # Skip if status is "Launch Successful" + # Skip if status is "Launch Successful" if status_id == 3: - print(f" -> SKIPPING (status indicates completed)") + skipped.append(f"{name} (status=completed)") continue - # This is an upcoming launch - print(f" -> KEEPING (upcoming)") filtered_launches.append(launch) - print(f"\n=== Filtered to {len(filtered_launches)} upcoming launches ===\n") + # Single condensed summary line + print(f"[{_ts()}] Launches fetched: {len(filtered_launches)} upcoming" + + (f", {len(skipped)} skipped" if skipped else "")) + + # Print upcoming launches in a compact table + for i, launch in enumerate(filtered_launches): + t0 = launch.get('t0') or launch.get('win_open') or 'TBD' + vehicle = launch.get('vehicle', {}).get('name', 'Unknown') + status_name = launch.get('status', {}).get('name', '?') + marker = ">>>" if i == 0 else " " + print(f" {marker} [{i+1}] {launch.get('name', 'Unknown')}") + print(f" Vehicle: {vehicle} | Status: {status_name} | T0: {t0}") + return filtered_launches except requests.exceptions.RequestException as e: - print(f"Error fetching data: {e}") + print(f"[{_ts()}] ERROR fetching launch data: {e}") return [] diff --git a/launch-timer/landscape.py b/launch-timer/landscape.py index a42a6d5..9847918 100644 --- a/launch-timer/landscape.py +++ b/launch-timer/landscape.py @@ -401,186 +401,7 @@ def draw_vab_building(canvas): ) -def draw_secondary_building(canvas): - """Draw an OPF-style hangar building matching the reference.""" - # Position moved right, away from VAB, and decreased height - hangar_x = 250 - hangar_y = 305 - hangar_width = 70 - hangar_height = 60 - - # More rounded roof using arc - properly positioned to attach to building - roof_height = 10 - canvas.create_arc( - hangar_x - 5, hangar_y - roof_height, - hangar_x + hangar_width + 5, hangar_y + 5, - start=0, extent=180, - fill='#5a5e64', outline='#3a3a3a', width=2, style='chord' - ) - - # Main building body - cream/white color - canvas.create_rectangle( - hangar_x, hangar_y, - hangar_x + hangar_width, hangar_y + hangar_height, - fill='#e8e8e8', outline='#3a3a3a', width=2 - ) - - # Small blue windows on right side - for i in range(2): - canvas.create_rectangle( - hangar_x + hangar_width - 12, hangar_y + 8 + i * 10, - hangar_x + hangar_width - 6, hangar_y + 13 + i * 10, - fill='#4a6a8a', outline='#3a5a7a' - ) - - # Horizontal line separator - canvas.create_line( - hangar_x + 5, hangar_y + 18, - hangar_x + hangar_width - 5, hangar_y + 18, - fill='#5a7a9a', width=2 - ) - - # Large hangar door - dark blue - door_x = hangar_x + 10 - door_y = hangar_y + 22 - door_w = 50 - door_h = hangar_height - 24 - - # Door opening (dark blue interior) - canvas.create_rectangle( - door_x, door_y, - door_x + door_w, door_y + door_h, - fill='#1a3a5a', outline='#2a2a2a', width=2 - ) - - # Darker inner door section - canvas.create_rectangle( - door_x + 5, door_y + 3, - door_x + door_w - 5, door_y + door_h - 3, - fill='#0a2a4a', outline='' - ) - - # Side windows next to door - # Left windows - canvas.create_rectangle( - hangar_x + 5, door_y + 3, - hangar_x + 9, door_y + 9, - fill='#4a6a8a', outline='#3a5a7a' - ) -def draw_operations_building(canvas): - """Draw the Launch Control Center (LCC) from Kennedy Space Center.""" - # Position closer to hangar (moved left) - lcc_x = 330 - lcc_y = 255 - lcc_width = 80 - lcc_height = 110 - - # Main building body - cream/beige color - canvas.create_rectangle( - lcc_x, lcc_y, - lcc_x + lcc_width, lcc_y + lcc_height, - fill='#e8dfd0', outline='#000000', width=2 - ) - - # Top section - dark gray roof area - roof_height = 15 - canvas.create_rectangle( - lcc_x, lcc_y, - lcc_x + lcc_width, lcc_y + roof_height, - fill='#4a4e54', outline='' - ) - - # Roof equipment (small structures on top) - canvas.create_rectangle( - lcc_x + 15, lcc_y - 6, - lcc_x + 23, lcc_y, - fill='#3a3a3a', outline='#000000', width=1 - ) - canvas.create_rectangle( - lcc_x + 55, lcc_y - 6, - lcc_x + 63, lcc_y, - fill='#3a3a3a', outline='#000000', width=1 - ) - - # Famous "Firing Room" windows - multiple rows of blue/dark windows - firing_room_top = lcc_y + roof_height + 5 - firing_room_height = 45 - - # Dark section behind windows - canvas.create_rectangle( - lcc_x + 5, firing_room_top, - lcc_x + lcc_width - 5, firing_room_top + firing_room_height, - fill='#2a2a2a', outline='' - ) - - # Multiple rows of windows (the iconic firing room windows) - window_rows = 4 - window_cols = 8 - window_w = 7 - window_h = 8 - - for row in range(window_rows): - for col in range(window_cols): - win_x = lcc_x + 8 + col * 9 - win_y = firing_room_top + 4 + row * 10 - canvas.create_rectangle( - win_x, win_y, - win_x + window_w, win_y + window_h, - fill='#4a6a8a', outline='#3a5a7a' - ) - - # Middle section - regular office windows - middle_start = firing_room_top + firing_room_height + 8 - - for row in range(3): - for col in range(6): - win_x = lcc_x + 10 + col * 12 - win_y = middle_start + row * 12 - canvas.create_rectangle( - win_x, win_y, - win_x + 8, win_y + 8, - fill='#5a7a9a', outline='#4a6a8a' - ) - - # Bottom section - entrance area - bottom_start = lcc_y + lcc_height - 15 - - # Entrance door - door_x = lcc_x + lcc_width // 2 - 10 - canvas.create_rectangle( - door_x, bottom_start, - door_x + 20, lcc_y + lcc_height - 2, - fill='#3a3a3a', outline='#2a2a2a', width=1 - ) - - # Small windows on sides of entrance - for i in range(2): - # Left side - canvas.create_rectangle( - lcc_x + 8, bottom_start + 3, - lcc_x + 14, bottom_start + 9, - fill='#5a7a9a', outline='#4a6a8a' - ) - # Right side - canvas.create_rectangle( - lcc_x + lcc_width - 14, bottom_start + 3, - lcc_x + lcc_width - 8, bottom_start + 9, - fill='#5a7a9a', outline='#4a6a8a' - ) - - # Right side depth panels (3D effect) - canvas.create_rectangle( - lcc_x + lcc_width, lcc_y + 8, - lcc_x + lcc_width + 12, lcc_y + lcc_height, - fill='#6a6e74', outline='' - ) - - canvas.create_rectangle( - lcc_x + lcc_width + 12, lcc_y + 12, - lcc_x + lcc_width + 20, lcc_y + lcc_height, - fill='#5a5e64', outline='' - ) def draw_launch_tower(canvas): """Draw the launch tower structure based on Saturn V Mobile Launcher.""" @@ -936,8 +757,6 @@ def draw_background(canvas): # Draw all buildings and structures draw_vab_building(canvas) - draw_secondary_building(canvas) - draw_operations_building(canvas) # Draw back fence BEFORE launch tower/pad so it appears behind draw_back_fence(canvas) diff --git a/launch-timer/main.py b/launch-timer/main.py index 651dd9d..0808663 100644 --- a/launch-timer/main.py +++ b/launch-timer/main.py @@ -16,10 +16,15 @@ draw_attribution ) from launch_animation import LaunchAnimation -from aircraft import T38Aircraft from weather import WeatherSystem +def _ts(): + """Return a compact HH:MM:SS timestamp for log lines.""" + from datetime import datetime + return datetime.now().strftime("%H:%M:%S") + + class LaunchPadDisplay: def __init__(self, root): self.root = root @@ -52,9 +57,6 @@ def __init__(self, root): self.gator_visible = False self.gator_timer = 0 - # T-38 Aircraft - self.aircraft = T38Aircraft(self.canvas) - # Birds self.birds = [] self.spawn_birds() @@ -91,7 +93,6 @@ def __init__(self, root): self.animate_birds() self.animate_cars() self.animate_gator() - self.animate_aircraft() self.animate_tower_lights() self.animate_sky_colors() self.animate_weather() @@ -100,11 +101,11 @@ def __init__(self, root): def fetch_and_display(self, is_initial=True): """Fetch launch data and display it.""" - print("Fetching fresh launch data...") + print(f"[{_ts()}] Fetching launch data...") launches = fetch_launches(5) if not launches: - print("ERROR: No upcoming launches found!") + print(f"[{_ts()}] ERROR: No upcoming launches found!") self.canvas.create_text(400, 50, text="NO UPCOMING LAUNCHES", font=('Courier', 16, 'bold'), fill='#ff4444') # Retry in 60 seconds @@ -116,12 +117,7 @@ def fetch_and_display(self, is_initial=True): self.launch_time = self.launch_data.get('t0') or self.launch_data.get('win_open') self.vehicle_name = self.launch_data.get('vehicle', {}).get('name', 'Unknown') - print(f"\n=== SELECTED LAUNCH ===") - print(f"Name: {self.launch_data.get('name')}") - print(f"Vehicle: {self.vehicle_name}") - print(f"Status: {self.launch_data.get('status', {}).get('name')}") - print(f"Launch time: {self.launch_time}") - print(f"======================\n") + print(f"[{_ts()}] Selected: {self.launch_data.get('name')} | {self.vehicle_name} | T0: {self.launch_time}") # Only draw rocket and create animator if it's the initial load # or if we're explicitly refreshing after a launch @@ -157,37 +153,33 @@ def fetch_and_display(self, is_initial=True): seconds_to_launch = countdown.get('total_seconds', 0) # Only schedule refresh if launch is more than 10 minutes away if seconds_to_launch > 600: - print(f"Scheduling data refresh in 5 minutes (launch is {seconds_to_launch/60:.1f} minutes away)") + print(f"[{_ts()}] Next refresh in 5 min (T-{seconds_to_launch/60:.0f}m)") self.root.after(300000, self.safe_refresh) else: - print(f"Not scheduling refresh - launch is only {seconds_to_launch/60:.1f} minutes away") + print(f"[{_ts()}] Refresh paused — launch in {seconds_to_launch/60:.1f} min") def safe_refresh(self): """Safely refresh data only if conditions are right.""" # Don't refresh if we're currently launching if self.launch_animator and self.launch_animator.is_launching: - print("Skipping refresh - launch in progress") - # Try again in 2 minutes + print(f"[{_ts()}] Refresh skipped — launch in progress") self.root.after(120000, self.safe_refresh) return - # Check if we're still far from launch if self.launch_time: countdown = get_countdown(self.launch_time) if countdown and countdown != "LAUNCHED": seconds_to_launch = countdown.get('total_seconds', 0) - if seconds_to_launch < 300: # Less than 5 minutes - print(f"Skipping refresh - too close to launch ({seconds_to_launch/60:.1f} minutes)") - # Try again in 1 minute + if seconds_to_launch < 300: + print(f"[{_ts()}] Refresh skipped — T-{seconds_to_launch/60:.1f}m") self.root.after(60000, self.safe_refresh) return - print("Performing safe data refresh...") - # Fetch fresh data + print(f"[{_ts()}] Refreshing launch data...") launches = fetch_launches(5) if not launches: - print("No launches found during refresh") + print(f"[{_ts()}] No launches found during refresh") self.root.after(300000, self.safe_refresh) return @@ -196,35 +188,26 @@ def safe_refresh(self): new_launch_id = new_launch.get('id') if new_launch_id != current_launch_id: - # Launch has changed! Need full refresh - print(f"Launch has changed! Old: {current_launch_id}, New: {new_launch_id}") + print(f"[{_ts()}] Launch changed → loading new mission") self.load_next_launch() else: - # Same launch - check if launch time changed old_time = self.launch_time new_time = new_launch.get('t0') or new_launch.get('win_open') if new_time != old_time: - print(f"⚠️ LAUNCH TIME CHANGED!") - print(f" Old time: {old_time}") - print(f" New time: {new_time}") + print(f"[{_ts()}] ⚠ Launch time changed: {old_time} → {new_time}") self.launch_time = new_time - # Update launch data self.launch_data = new_launch - - # Refresh info sign with updated data self.canvas.delete('info_sign') draw_info_sign(self.canvas, self.launch_data, self.vehicle_name) - print("Data refreshed successfully") - - # Schedule next refresh + print(f"[{_ts()}] Data refreshed — next refresh in 5 min") self.root.after(300000, self.safe_refresh) def load_next_launch(self): """Load the next launch after current one completes.""" - print("Loading next launch...") + print(f"[{_ts()}] Loading next launch...") # Clean up current rocket self.canvas.delete('launch_flame') @@ -420,7 +403,7 @@ def animate_weather(self): self.root.after(50, self.animate_weather) def refresh_weather(self): """Refresh weather data every 15 minutes.""" - print("Refreshing weather data...") + print(f"[{_ts()}] Refreshing weather data...") self.weather.fetch_weather() # Update sky colors immediately @@ -470,21 +453,6 @@ def animate_tower_lights(self): self.root.after(30, self.animate_tower_lights) - def animate_aircraft(self): - """Animate T-38 aircraft flyby.""" - import time - current_time = time.time() * 1000 - - # Check if it's time to start a new flyby - if self.aircraft.should_start_flyby(current_time): - self.aircraft.start_flyby() - - # Update aircraft if active - if self.aircraft.active: - self.aircraft.update(33) - - self.root.after(33, self.animate_aircraft) - def spawn_birds(self): """Create initial birds at random positions off-screen.""" used_y_positions = [] @@ -709,7 +677,7 @@ def check_launch_status(self): if not self.launch_data: return - print("Checking launch status...") + print(f"[{_ts()}] Checking launch status...") # Re-fetch launches to get updated status launches = fetch_launches(5) @@ -735,44 +703,38 @@ def check_launch_status(self): new_launch_time = updated_launch.get('t0') or updated_launch.get('win_open') if new_launch_time != self.launch_time: - # Launch was postponed! - print(f"Launch postponed! New time: {new_launch_time}") + print(f"[{_ts()}] ⚠ Launch postponed → {new_launch_time}") self.launch_time = new_launch_time self.launch_data = updated_launch - # Update the info sign with new data self.canvas.delete('info_sign') draw_info_sign(self.canvas, self.launch_data, self.vehicle_name) return # Check if in flight or completed if status == 'In Flight': - print("Launch is in flight - waiting for completion...") - # Check again in 30 seconds + print(f"[{_ts()}] In flight — rechecking in 30s") self.root.after(30000, self.check_launch_status) return # Check launch result if available result = updated_launch.get('result') - if result == 1: # Success - print("Launch confirmed successful! Loading next launch...") + if result == 1: + print(f"[{_ts()}] Launch successful — loading next") self.load_next_launch() - elif result == 2: # Failure - print("Launch failed - loading next launch...") + elif result == 2: + print(f"[{_ts()}] Launch failed — loading next") self.load_next_launch() - elif result == 3: # Partial failure - print("Launch partial failure - loading next launch...") + elif result == 3: + print(f"[{_ts()}] Launch partial failure — loading next") self.load_next_launch() elif status not in ['In Flight', 'Go', 'Go for Launch']: - # Launch completed (no longer in flight), load next - print(f"Launch status: {status} - loading next launch...") + print(f"[{_ts()}] Status: {status} — loading next") self.load_next_launch() else: - # Still unclear, check again in 30 seconds - print("Status unclear, checking again in 30 seconds...") + print(f"[{_ts()}] Status unclear ({status}) — rechecking in 30s") self.root.after(30000, self.check_launch_status) else: - # Couldn't find our launch, it might have been removed (scrubbed) - print("Launch data no longer available - loading next launch") + print(f"[{_ts()}] Launch no longer in feed (scrubbed?) — loading next") self.load_next_launch() def update_countdown(self): @@ -798,30 +760,24 @@ def update_countdown(self): def trigger_launch(self): """Trigger the launch animation at T-0.""" if self.launch_animator and not self.launch_animator.is_launching: - print("T-0! Launching rocket!") - # Start checking status after launch animation + print(f"[{_ts()}] T-0! Ignition sequence start") self.launch_animator.start_launch(on_complete=self.check_post_launch_status) def check_post_launch_status(self): """Check status after launch animation completes.""" - print("Launch animation complete, checking status...") - # Wait a few seconds then check if we should load next launch + print(f"[{_ts()}] Launch animation complete — checking status") self.root.after(5000, self.check_launch_status) def test_launch(self): if self.launch_animator: - print("Test launch initiated!") - # Debug: check if rocket elements exist rocket_items = self.canvas.find_withtag('rocket') - print(f"Found {len(rocket_items)} rocket elements") + print(f"[{_ts()}] Test launch — {len(rocket_items)} rocket elements") self.launch_animator.start_launch(on_complete=self.reset_same_rocket) else: - print("No rocket to launch!") + print(f"[{_ts()}] Test launch: no rocket on pad") def reset_same_rocket(self): """Reset the same rocket after a test launch.""" - print("Resetting same rocket...") - self.canvas.delete('launch_flame') self.canvas.delete('rocket') @@ -839,20 +795,19 @@ def reset_same_rocket(self): self.canvas.delete('spotlight') draw_spotlights(self.canvas, self.vehicle_name) - print("Rocket reset complete!") + print(f"[{_ts()}] Rocket reset") def load_next_launch(self): """Load the next launch after T-0 launch completes.""" - print("Loading next launch...") + print(f"[{_ts()}] Loading next launch...") self.canvas.delete('launch_flame') self.canvas.delete('rocket') - # Fetch multiple launches to ensure we get a different one launches = fetch_launches(5) if not launches: - print("No more launches available") + print(f"[{_ts()}] No more launches available") return # Find a launch that's different from the current one AND not in flight @@ -912,7 +867,7 @@ def load_next_launch(self): vehicle_name=self.vehicle_name ) else: - print("Next launch is also in flight - not displaying rocket") + print(f"[{_ts()}] Next launch also in flight — rocket not displayed") self.launch_animator = None self.canvas.delete('info_sign') @@ -922,7 +877,7 @@ def load_next_launch(self): self.canvas.delete('spotlight') draw_spotlights(self.canvas, self.vehicle_name) - print("Next launch loaded!") + print(f"[{_ts()}] Loaded: {self.launch_data.get('name')} | T0: {self.launch_time}") def main(): diff --git a/launch-timer/python api_throttle.py b/launch-timer/python api_throttle.py deleted file mode 100644 index 49385ed..0000000 --- a/launch-timer/python api_throttle.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python3 -"""Check API usage status.""" - -from api_client import check_api_usage - -if __name__ == "__main__": - check_api_usage() \ No newline at end of file diff --git a/launch-timer/weather.py b/launch-timer/weather.py index bec8672..55b98ba 100644 --- a/launch-timer/weather.py +++ b/launch-timer/weather.py @@ -21,94 +21,115 @@ def __init__(self, canvas): self.lightning_timer = 0 def fetch_weather(self): - """Fetch current weather from Cape Canaveral, FL using wttr.in API.""" + """Fetch current weather for Cape Canaveral, FL using Open-Meteo API. + + Open-Meteo is free, no API key required, and far more reliable than wttr.in. + Uses WMO weather interpretation codes. + Cape Canaveral: 28.3922N, -80.6077W + """ + from datetime import datetime + ts = datetime.now().strftime("%H:%M:%S") + try: - # Using wttr.in - free, no API key needed - # Cape Canaveral coordinates: 28.3922° N, 80.6077° W - url = "https://wttr.in/Cape_Canaveral,Florida?format=j1" - - response = requests.get(url, timeout=15, headers={ - 'User-Agent': 'Mozilla/5.0 (compatible; LaunchPad/1.0)' - }) + url = ( + "https://api.open-meteo.com/v1/forecast" + "?latitude=28.3922&longitude=-80.6077" + "¤t=temperature_2m,relative_humidity_2m,precipitation," + "weather_code,cloud_cover,wind_speed_10m,wind_direction_10m" + "&temperature_unit=fahrenheit" + "&wind_speed_unit=mph" + "&timezone=America%2FNew_York" + ) + + response = requests.get(url, timeout=10) response.raise_for_status() data = response.json() - - # Extract current conditions - current = data['current_condition'][0] - + + current = data['current'] + + # Convert wind direction degrees to cardinal + wind_deg = current.get('wind_direction_10m', 0) + directions = ['N','NNE','NE','ENE','E','ESE','SE','SSE', + 'S','SSW','SW','WSW','W','WNW','NW','NNW'] + wind_dir = directions[int((wind_deg + 11.25) / 22.5) % 16] + + # WMO weather code -> human description + wmo_descriptions = { + 0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast', + 45: 'Foggy', 48: 'Icy fog', + 51: 'Light drizzle', 53: 'Drizzle', 55: 'Heavy drizzle', + 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain', + 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow', + 80: 'Light showers', 81: 'Showers', 82: 'Heavy showers', + 95: 'Thunderstorm', 96: 'Thunderstorm w/ hail', 99: 'Thunderstorm w/ heavy hail', + } + wmo_code = current.get('weather_code', 0) + condition = wmo_descriptions.get(wmo_code, f'Code {wmo_code}') + + # Celsius from Fahrenheit for display + temp_f = current['temperature_2m'] + temp_c = round((temp_f - 32) * 5 / 9, 1) + weather_info = { - 'temp_f': current['temp_F'], - 'temp_c': current['temp_C'], - 'condition': current['weatherDesc'][0]['value'], - 'weather_code': current['weatherCode'], - 'humidity': current['humidity'], - 'wind_speed': current['windspeedMiles'], - 'wind_dir': current['winddir16Point'], - 'precip': current['precipMM'], - 'cloud_cover': current['cloudcover'] + 'temp_f': round(temp_f, 1), + 'temp_c': temp_c, + 'condition': condition, + 'weather_code': wmo_code, + 'humidity': current.get('relative_humidity_2m', 0), + 'wind_speed': round(current.get('wind_speed_10m', 0), 1), + 'wind_dir': wind_dir, + 'precip': current.get('precipitation', 0), + 'cloud_cover': current.get('cloud_cover', 0), } - + self.current_weather = weather_info self.determine_weather_condition(weather_info) - - print(f"\n=== WEATHER UPDATE ===") - print(f"Location: Cape Canaveral, FL") - print(f"Condition: {weather_info['condition']}") - print(f"Temperature: {weather_info['temp_f']}°F ({weather_info['temp_c']}°C)") - print(f"Humidity: {weather_info['humidity']}%") - print(f"Wind: {weather_info['wind_speed']} mph {weather_info['wind_dir']}") - print(f"Cloud Cover: {weather_info['cloud_cover']}%") - print(f"======================\n") - + + print(f"[{ts}] Weather | {condition}, {weather_info['temp_f']}°F, " + f"{weather_info['wind_speed']} mph {wind_dir}, " + f"{weather_info['cloud_cover']}% cloud → visual: {self.weather_condition}") + return weather_info - + except requests.exceptions.Timeout: - print(f"Weather API timeout - using default clear weather") - self.weather_condition = "clear" + print(f"[{ts}] Weather API timeout — keeping current condition: {self.weather_condition}") return None except requests.exceptions.ConnectionError as e: - print(f"Weather API connection error - using default clear weather") - self.weather_condition = "clear" + print(f"[{ts}] Weather API connection error — keeping current condition: {self.weather_condition}") return None except Exception as e: - print(f"Error fetching weather: {e}") - print("Using default clear weather") - self.weather_condition = "clear" + print(f"[{ts}] Weather fetch error: {e} — keeping current condition: {self.weather_condition}") return None def determine_weather_condition(self, weather_info): - """Determine visual weather condition from weather data.""" + """Determine visual weather condition from WMO weather code (Open-Meteo).""" code = int(weather_info['weather_code']) - - # Weather codes from wttr.in - # 113: Clear/Sunny - # 116: Partly cloudy - # 119: Cloudy - # 122: Overcast - # 143: Mist - # 176-353: Various rain conditions - # 200-232: Thundery conditions - # 248-260: Fog - # 263-284: Light to moderate rain - # 293-299: Moderate to heavy rain - # 302-365: Heavy rain and freezing rain - - if code == 113: + + # WMO Weather Interpretation Codes + # 0-2: Clear / mainly clear + # 3: Overcast + # 45,48: Fog + # 51-67: Drizzle / rain + # 71-77: Snow + # 80-82: Rain showers + # 85-86: Snow showers + # 95: Thunderstorm + # 96,99: Thunderstorm with hail + + if code in (0, 1): self.weather_condition = "clear" - elif code in [116, 119, 122]: + elif code in (2, 3): self.weather_condition = "cloudy" - elif code in [143, 248, 260]: + elif code in (45, 48): self.weather_condition = "fog" - elif code in [176, 263, 266, 281, 284, 293, 296]: + elif code in (51, 53, 55, 61): self.weather_condition = "light_rain" - elif code in [299, 302, 305, 308, 311, 314, 317, 320, 323, 326]: + elif code in (63, 65, 80, 81, 82): self.weather_condition = "rain" - elif code in [200, 386, 389, 392, 395]: + elif code in (95, 96, 99): self.weather_condition = "thunderstorm" else: self.weather_condition = "clear" - - print(f"Visual weather condition set to: {self.weather_condition}") def get_weather_sky_color(self): """Get sky color based on current weather and time of day."""