"""FLIGHTDECK — live flights on a transparent 3D globe, with an LLM flight agent. Data: FlightRadar24 API (https://fr24api.flightradar24.com/docs/getting-started) Globe: Globe.gl / Three.js (3D, transparent, neon glow) LLM: openbmb/MiniCPM5-1B via transformers (default safetensors model) Set FR24_API_TOKEN in your environment (see .env.example), then `python app.py`. """ from __future__ import annotations import datetime as dt import os import gradio as gr try: from dotenv import load_dotenv # Load .env sitting next to this file, regardless of the launch cwd. load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) except Exception: pass import agent import fr24 import geo import globe as globe_mod import liquid import sidebar as sidebar_mod import trace_sync import transit_agent import transit_map # Mirror ./traces/ to a HF dataset repo (no-op without HF_TOKEN); the container # disk on Spaces is ephemeral, so this is what makes traces survive restarts. print(f"[flightdeck] {trace_sync.start()}") # ---- Preset regions (north, south, west, east) ----------------------------- REGIONS = { "Europe": (60.0, 36.0, -11.0, 30.0), "North America": (50.0, 25.0, -125.0, -67.0), "USA — East Coast": (45.0, 30.0, -82.0, -68.0), "UK & Ireland": (59.0, 49.0, -11.0, 2.0), "Middle East": (33.0, 22.0, 34.0, 60.0), "East Asia": (46.0, 22.0, 100.0, 146.0), "Australia": (-10.0, -44.0, 112.0, 154.0), "World (sampled)": (70.0, -60.0, -170.0, 170.0), } MAX_FLIGHTS = 220 # keep the globe/LLM responsive def normalize_flight(raw: dict) -> dict | None: """Map an FR24 record to the fields the rest of the app expects.""" lat = raw.get("lat") lon = raw.get("lon") if lat is None or lon is None: return None eta_raw = raw.get("eta") eta_iso, eta_secs = fr24.parse_eta(eta_raw) dest_code = raw.get("dest_icao") or raw.get("dest_iata") orig_code = raw.get("orig_icao") or raw.get("orig_iata") dest_ll = fr24.airport_coords(dest_code) if dest_code else None # The estimated-path arc targets the REAL destination airport. If we can't # resolve the destination's coordinates, we draw no arc (rather than a # misleading dead-reckoned line to a random point). if dest_ll: est_path = (dest_ll[0], dest_ll[1]) dist_km = round(geo.haversine_km(lat, lon, dest_ll[0], dest_ll[1]), 1) else: est_path = None dist_km = None return { "fr24_id": raw.get("fr24_id"), "callsign": raw.get("callsign") or raw.get("flight"), "flight": raw.get("flight"), "lat": float(lat), "lon": float(lon), "alt": raw.get("alt"), "gspeed": raw.get("gspeed"), "track": raw.get("track"), "orig": orig_code, "dest": dest_code, "type": raw.get("type"), "reg": raw.get("reg"), "eta_iso": eta_iso, "eta_secs": eta_secs, "eta_human": fr24.human_duration(eta_secs) if eta_secs is not None else "—", "est_dist_km": dist_km, "est_to_dest": est_path is not None, "est_path": est_path, } def _focus_point(flights): if not flights: return None avg_lat = sum(f["lat"] for f in flights) / len(flights) avg_lon = sum(f["lon"] for f in flights) / len(flights) return {"lat": avg_lat, "lng": avg_lon} def _table_rows(flights): rows = [] for f in flights: rows.append([ f.get("callsign") or "—", f.get("type") or "—", f"{f.get('orig') or '?'} → {f.get('dest') or '?'}", f.get("alt") if f.get("alt") is not None else "—", f.get("gspeed") if f.get("gspeed") is not None else "—", f.get("eta_human") or "—", (f"{f.get('est_dist_km')} km →dest" if f.get("est_to_dest") else "—"), ]) return rows # ---- Gradio callbacks ------------------------------------------------------ def scan(region_name, custom_bounds, state): region = REGIONS.get(region_name) if custom_bounds and custom_bounds.strip(): try: n, s, w, e = [float(x) for x in custom_bounds.split(",")] region = (n, s, w, e) region_label = f"custom[{custom_bounds.strip()}]" except Exception: return ( "
Bounds must be: north,south,west,east
", [], "Bad bounds.", "", state, ) else: region_label = region_name if region is None: return ("
Pick a region.
", [], "No region.", "", state) n, s, w, e = region try: raw = fr24.live_positions(n, s, w, e) except fr24.FR24Error as ex: return (f"
FR24: {ex}
", [], str(ex), "", state) except Exception as ex: # noqa: BLE001 return (f"
Error: {ex}
", [], str(ex), "", state) flights = [] for r in raw[:MAX_FLIGHTS]: nf = normalize_flight(r) if nf: flights.append(nf) stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") html = globe_mod.build_globe_html(flights, stamp=stamp, focus=_focus_point(flights)) rows = _table_rows(flights) side = sidebar_mod.build_sidebar_html(flights) state = {"flights": flights, "region": region_label} status = f">> {len(flights)} contacts in {region_label} @ {stamp}" return html, rows, status, side, state import json as _json # An idle, rotating globe shown at startup and whenever a query yields no points. STANDBY_GLOBE = globe_mod.build_globe_html([], stamp="STANDBY // DRAG TO SPIN") def _read_trace(path): """Load a saved trace file for the trace-viewer panel.""" try: with open(path, encoding="utf-8") as fh: return _json.load(fh) except Exception as e: # noqa: BLE001 return {"trace_error": str(e), "path": path} def _render(flights, label): """Build globe + table + sidebar outputs for a set of normalized flights.""" stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") html = globe_mod.build_globe_html(flights, stamp=stamp, focus=_focus_point(flights)) rows = _table_rows(flights) side = sidebar_mod.build_sidebar_html(flights) return html, rows, side, stamp def assistant(query, state): """Primary entry point: the LLM agent decides + runs the FR24 call.""" if not query or not query.strip(): return (STANDBY_GLOBE, [], "Ask e.g. 'flights from London to Dubai'.", "
", state, "Type a flight query above.", {}) result = agent.run(query.strip()) flights = [nf for nf in (normalize_flight(r) for r in result["flights"]) if nf] if flights: # Globe magnifies/flies in to the result location (focus set below). html, rows, side, stamp = _render(flights, query) else: html, rows, side = STANDBY_GLOBE, [], "
" state = {"flights": flights, "region": query.strip()} trace_file = os.path.basename(result["trace_path"]) status = (f">> agent[{result['mode']}] tools={result['tool_calls'] or '—'} " f"→ {len(flights)} flights · trace {result['trace_id']}") answer_md = ( f"{result['answer']}\n\n" f"`mode={result['mode']}` · `tools={result['tool_calls'] or 'none'}` " f"· `flights={len(flights)}` · trace saved → " f"`traces/{trace_file}`") return html, rows, status, side, state, answer_md, _read_trace(result["trace_path"]) TRANSIT_EXAMPLES = [ "fastest way from Berkeley to SFO", "next train from Embarcadero", "traffic on the Bay Bridge", "fastest way from Oakland to San Jose", ] _EMPTY_MAP = ("
" "◌ AWAITING TRANSIT QUERY ◌
") def transit_assistant(query, t_state): """Bay Area transit agent: picks a 511 tool, runs it, recommends.""" if not query or not query.strip(): return (_EMPTY_MAP, "Ask e.g. 'fastest way from Berkeley to SFO'.", "Type a Bay Area transit query above.", t_state, {}) r = transit_agent.run(query.strip()) stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ") map_html = transit_map.build_map_html(r["markers"], stamp) if r["markers"] else _EMPTY_MAP trace_file = os.path.basename(r["trace_path"]) status = (f">> agent[{r['mode']}] tools={r['tool_calls'] or '—'} · " f"{len(r['markers'])} pins · trace {r['trace_id']}") answer_md = ( f"{r['answer']}\n\n" f"`mode={r['mode']}` · `tools={r['tool_calls'] or 'none'}` · " f"trace saved → `traces/{trace_file}`") t_state = {"transit_last": query.strip()} return map_html, status, answer_md, t_state, _read_trace(r["trace_path"]) # ---- Theme / CSS (glow-in-the-dark, 80's hacker) --------------------------- NEON_CSS = """ /* Palette is variable-driven so the transit tab can repaint by swapping vars. */ :root, .gradio-container { --neon: #39FF14; --neon-rgb: 57,255,20; --neon2: #00E5FF; --neon2-rgb: 0,229,255; --bg: #02060a; --bg2: #061a12; --panel-rgb: 2,18,12; --in-bg: #02100a; --btn-a: #0a3a22; --btn-b: #04150d; } /* Transit mode: glow-in-the-dark ORANGE on BLACK — just redefine the vars. */ .gradio-container.transit-mode { --neon: #FF9A1E; --neon-rgb: 255,154,30; --neon2: #FFC65C; --neon2-rgb: 255,198,92; --bg: #000000; --bg2: #1a0c00; --panel-rgb: 20,10,0; --in-bg: #160a00; --btn-a: #3a2200; --btn-b: #150b00; } .gradio-container, body, .gradio-container * { font-family: 'Courier New', Consolas, monospace !important; } .gradio-container { background: radial-gradient(circle at 20% 0%, var(--bg2) 0%, var(--bg) 55%), repeating-linear-gradient(0deg, rgba(var(--neon-rgb),0.035) 0px, rgba(var(--neon-rgb),0.035) 1px, transparent 1px, transparent 3px) !important; color: var(--neon) !important; } h1, h2, h3, label, .prose, span, p, li, strong, em { color: var(--neon) !important; text-shadow: 0 0 8px rgba(var(--neon-rgb),0.55) !important; } #title { font-size: 30px; letter-spacing: 4px; text-align:center; text-shadow: 0 0 12px var(--neon), 0 0 24px var(--neon) !important; } #subtitle { text-align:center; color: var(--neon2) !important; text-shadow: 0 0 8px var(--neon2) !important; letter-spacing:2px; } .block, .form, .gr-box, .gr-panel, .panel { background: rgba(var(--panel-rgb),0.66) !important; border: 1px solid rgba(var(--neon-rgb),0.35) !important; box-shadow: 0 0 14px rgba(var(--neon-rgb),0.18), inset 0 0 12px rgba(0,0,0,0.6) !important; border-radius: 10px !important; } input, textarea, select, .gr-input { background: var(--in-bg) !important; color: var(--neon) !important; border: 1px solid rgba(var(--neon-rgb),0.4) !important; text-shadow: 0 0 6px rgba(var(--neon-rgb),0.5) !important; } button { background: linear-gradient(180deg, var(--btn-a), var(--btn-b)) !important; color: var(--neon) !important; border: 1px solid var(--neon) !important; text-shadow: 0 0 8px var(--neon) !important; box-shadow: 0 0 12px rgba(var(--neon-rgb),0.4) !important; text-transform: uppercase; letter-spacing: 2px; } button:hover { box-shadow: 0 0 22px var(--neon), 0 0 8px var(--neon2) !important; } .gr-dataframe table { color: var(--neon) !important; } .gr-dataframe th { color: var(--neon2) !important; text-shadow: 0 0 6px var(--neon2) !important; } footer { display:none !important; } /* ---- sidebar (MOST TRACKED / AIRPORT DISRUPTIONS) ---- */ #fd-sidebar { display:flex; flex-direction:column; gap:10px; } .fd-panel { background: rgba(2,16,11,0.78); border: 1px solid rgba(57,255,20,0.35); border-radius: 10px; padding: 6px 8px 8px; box-shadow: 0 0 14px rgba(57,255,20,0.18), inset 0 0 12px rgba(0,0,0,0.6); } .fd-panel > summary { list-style:none; cursor:pointer; user-select:none; display:flex; align-items:center; gap:8px; font-size:12px; letter-spacing:2px; color:var(--neon); text-shadow:0 0 8px var(--neon); padding:4px 2px; } .fd-panel > summary::-webkit-details-marker { display:none; } .fd-chev { margin-left:auto; transition:transform .2s; } .fd-panel[open] > summary .fd-chev { transform:rotate(180deg); } .fd-live { font-size:9px; font-weight:bold; color:#02060a; background:var(--neon); padding:1px 5px; border-radius:3px; box-shadow:0 0 8px var(--neon); animation:fd-pulse 1.6s infinite; } @keyframes fd-pulse { 0%,100%{opacity:1} 50%{opacity:0.5} } .fd-note { font-size:9px; color:#5fe0a0; opacity:0.7; margin:2px 2px 6px; letter-spacing:0.5px; text-shadow:none; } .fd-list { display:flex; flex-direction:column; gap:6px; } .fd-item { background: rgba(0,0,0,0.35); border:1px solid rgba(57,255,20,0.18); border-left:3px solid var(--neon); border-radius:6px; padding:6px 8px; } .fd-item:hover { border-left-color:var(--neon2); box-shadow:0 0 10px rgba(0,229,255,0.25); } .fd-row { display:flex; align-items:center; gap:6px; } .fd-rank { color:var(--neon2); font-weight:bold; text-shadow:0 0 6px var(--neon2); } .fd-cs { font-weight:bold; color:var(--neon); text-shadow:0 0 6px var(--neon); } .fd-badge { font-size:9px; color:var(--neon2); border:1px solid rgba(0,229,255,0.5); border-radius:3px; padding:0 4px; text-shadow:0 0 5px var(--neon2); } .fd-metric { margin-left:auto; color:#FFD23F; text-shadow:0 0 6px #FFD23F; font-weight:bold; } .fd-metric small { font-size:8px; opacity:0.8; } .fd-route { font-size:11px; color:#9fffd0; opacity:0.85; margin-top:2px; text-shadow:none; } .fd-arrow { color:var(--neon2); } .fd-wx { display:flex; justify-content:space-between; font-size:11px; color:#9fffd0; margin-top:3px; text-shadow:none; } .fd-wind { color:var(--neon2); } .fd-scores { display:flex; justify-content:space-between; font-size:11px; margin-top:3px; color:var(--neon); } .fd-dot { font-weight:bold; } .fd-empty { font-size:11px; color:#5fe0a0; opacity:0.7; padding:6px; text-align:center; } .fd-hint { font-size:11px; color:#5fe0a0; opacity:0.8; margin:2px 0 8px; letter-spacing:0.5px; } .fd-hint code { color:var(--neon2); } /* Transit mode is driven entirely by the variable swap above; just a couple of orange accents that reference the (now-orange) vars. */ #transit_hint { font-size:11px; color:var(--neon2); opacity:0.9; margin:2px 0 8px; } #transit_hint code { color:var(--neon); } """ theme = gr.themes.Base( primary_hue=gr.themes.colors.green, secondary_hue=gr.themes.colors.cyan, neutral_hue=gr.themes.colors.gray, ).set( body_background_fill="#02060a", body_text_color="#39FF14", block_background_fill="rgba(2,18,12,0.66)", ) # Force the UI into dark mode on load (FLIGHTDECK is dark-only). FORCE_DARK_JS = """ function() { const url = new URL(window.location); if (url.searchParams.get('__theme') !== 'dark') { url.searchParams.set('__theme', 'dark'); window.location.replace(url.href); } } """ EXAMPLE_QUERIES = [ "flights from London to Dubai", "arrivals into JFK", "departures from LAX", "flights from Tokyo to Singapore", ] _JS_FLIGHT = ("() => document.querySelector('.gradio-container')" ".classList.remove('transit-mode')") _JS_TRANSIT = ("() => document.querySelector('.gradio-container')" ".classList.add('transit-mode')") with gr.Blocks(title="FLIGHTDECK", theme=theme, css=NEON_CSS, js=FORCE_DARK_JS) as demo: state = gr.State({}) transit_state = gr.State({}) with gr.Tabs(): # ================= TAB 1: FLIGHTDECK (green) ================= with gr.Tab("✈ FLIGHTDECK", elem_id="flight_tab") as flight_tab: gr.HTML("
▚ F L I G H T D E C K ▞
" "
ai flight agent // 3D globe // live FR24 uplink
") with gr.Row(): question = gr.Textbox( label="ASK THE FLIGHT AGENT", scale=5, autofocus=True, placeholder="flights from London to Dubai · arrivals into JFK · " "departures from LAX") ask_btn = gr.Button("▶ DISPATCH", scale=1, variant="primary") with gr.Row(): example_btns = [gr.Button(q, size="sm") for q in EXAMPLE_QUERIES] gr.HTML("
scope: live flights TO / FROM an airport or " "on a route only · drag to spin the globe · all traces are saved " "to a dataset with almost the same name as this Space — look for it " "in the Hackathon Datasets
") answer = gr.Markdown(f"**UPLINK:** {liquid.status()} — ask a flight " "question above to dispatch the agent.") with gr.Row(): with gr.Column(scale=1, min_width=240): sidebar_out = gr.HTML( "
◌ awaiting agent ◌
") status = gr.Textbox(label="AGENT STATUS", interactive=False) with gr.Accordion("⛭ MANUAL OVERRIDE (region scan)", open=False): region = gr.Dropdown( choices=list(REGIONS.keys()), value="Europe", label="SECTOR") custom = gr.Textbox( label="CUSTOM BOUNDS (north,south,west,east)", placeholder="e.g. 51.7,51.2,-0.6,0.3") scan_btn = gr.Button("◈ SCAN AIRSPACE") with gr.Column(scale=3): globe_out = gr.HTML(STANDBY_GLOBE) table = gr.Dataframe( headers=["CALLSIGN", "TYPE", "ROUTE", "ALT ft", "GS kt", "ETA", "EST. PATH"], label="CONTACTS", interactive=False, wrap=True) with gr.Accordion("🧾 AGENT TRACE (reasoning · tool call · API URL)", open=False): trace_view = gr.JSON(label="last run — saved to ./traces/") # ================= TAB 2: BAY TRANSIT (orange) ================= with gr.Tab("🚉 BAY TRANSIT", elem_id="transit_tab") as transit_tab: gr.HTML("
◢ B A Y L I N E ◣
" "
bay area transit agent // 511 live data // " "fastest-route reasoning
") with gr.Row(): t_question = gr.Textbox( label="ASK THE TRANSIT AGENT", scale=5, placeholder="fastest way from Berkeley to SFO · next train from " "Embarcadero · traffic on the Bay Bridge") t_ask = gr.Button("▶ DISPATCH", scale=1, variant="primary") with gr.Row(): t_example_btns = [gr.Button(q, size="sm") for q in TRANSIT_EXAMPLES] gr.HTML("
scope: Bay Area transit & traffic only · " "powered by 511.org live data · the agent reasons " "the fastest option · all traces are saved to a dataset with almost " "the same name as this Space — look for it in the Hackathon Datasets
") t_answer = gr.Markdown("**511 UPLINK ready** — ask a Bay Area transit " "question above.") t_status = gr.Textbox(label="AGENT STATUS", interactive=False) t_map = gr.HTML(_EMPTY_MAP) with gr.Accordion("🧾 AGENT TRACE (reasoning · tool call · API URL)", open=False): t_trace_view = gr.JSON(label="last run — saved to ./traces/") # ---- theme switch on tab select ---- flight_tab.select(None, None, None, js=_JS_FLIGHT) transit_tab.select(None, None, None, js=_JS_TRANSIT) # ---- flight tab wiring ---- agent_outputs = [globe_out, table, status, sidebar_out, state, answer, trace_view] ask_btn.click(assistant, [question, state], agent_outputs) question.submit(assistant, [question, state], agent_outputs) for btn, q in zip(example_btns, EXAMPLE_QUERIES): btn.click(lambda q=q: q, None, question).then( assistant, [question, state], agent_outputs) scan_btn.click(scan, [region, custom, state], [globe_out, table, status, sidebar_out, state]) # ---- transit tab wiring ---- transit_outputs = [t_map, t_status, t_answer, transit_state, t_trace_view] t_ask.click(transit_assistant, [t_question, transit_state], transit_outputs) t_question.submit(transit_assistant, [t_question, transit_state], transit_outputs) for btn, q in zip(t_example_btns, TRANSIT_EXAMPLES): btn.click(lambda q=q: q, None, t_question).then( transit_assistant, [t_question, transit_state], transit_outputs) if __name__ == "__main__": demo.launch( # 0.0.0.0 so HuggingFace Spaces' proxy can reach the app (127.0.0.1 # only binds localhost inside the container and the Space won't load). server_name=os.environ.get("HOST", "0.0.0.0"), server_port=int(os.environ.get("PORT", "7860")), )