#!/usr/bin/env python3 """ Gradio app to visualize ADS-B flight routes from an uploaded GeoJSON file. Upload a GeoJSON FeatureCollection (e.g. produced by generate_adsb.py), pick a flight, and the app draws its route on a map and shows the aircraft info plus per-sample telemetry. IMPORTANT: this app deliberately HIDES any anomaly metadata. It is intended for an exercise where the user must find the anomaly themselves, so the `properties.anomaly` field (and anything that would label a flight as anomalous) is never displayed. """ import json import gradio as gr import plotly.graph_objects as go # Fields in feature properties that could leak the answer. Never surface these. HIDDEN_KEYS = {"anomaly", "anomalies", "is_anomalous", "anomaly_type", "anomaly_index", "label", "ground_truth"} def parse_geojson(file_path): """Load a GeoJSON file and return the list of flight features.""" with open(file_path, "r") as f: data = json.load(f) if isinstance(data, dict) and data.get("type") == "FeatureCollection": features = data.get("features", []) elif isinstance(data, dict) and data.get("type") == "Feature": features = [data] else: raise ValueError("File is not a GeoJSON Feature or FeatureCollection.") flights = [f for f in features if f.get("geometry", {}).get("type") == "LineString"] if not flights: raise ValueError("No LineString flight tracks found in the file.") return flights def flight_label(idx, feature): """Human-readable dropdown label for a flight (no anomaly hints).""" p = feature.get("properties", {}) icao = p.get("icao24", f"flight-{idx}") callsign = p.get("callsign", "?") route = f'{p.get("origin", "?")}->{p.get("destination", "?")}' return f"{idx + 1:>3}. {callsign} [{icao}] {route}" def load_file(file_path): """Handle an uploaded file: parse it and populate the flight dropdown.""" if not file_path: return (gr.update(choices=[], value=None), [], "Upload a GeoJSON file to begin.") try: flights = parse_geojson(file_path) except Exception as e: # noqa: BLE001 - surface any parse error to the UI return (gr.update(choices=[], value=None), [], f"Could not read file: {e}") choices = [(flight_label(i, f), i) for i, f in enumerate(flights)] status = (f"Loaded **{len(flights)}** flights. " "Select one from the dropdown to view its route.") # Auto-select the first flight for convenience. return (gr.update(choices=choices, value=0), flights, status) def empty_map(): """A blank world map shown before any flight is selected.""" fig = go.Figure(go.Scattermap(lat=[], lon=[], mode="markers")) fig.update_layout( map_style="open-street-map", map_zoom=1, map_center={"lat": 25, "lon": 10}, margin={"l": 0, "r": 0, "t": 0, "b": 0}, height=560, ) return fig def render_flight(flight_idx, flights): """Draw the selected flight on the map and build its info panel.""" if flight_idx is None or not flights: return empty_map(), "_No flight selected._", None feature = flights[int(flight_idx)] coords = feature["geometry"]["coordinates"] lons = [c[0] for c in coords] lats = [c[1] for c in coords] p = feature.get("properties", {}) alts = p.get("altitudes_ft", []) spds = p.get("ground_speeds_kt", []) times = p.get("timestamps", []) n = len(coords) # Per-point hover text built only from non-leaking telemetry. hover = [] for i in range(n): parts = [f"Sample {i + 1}/{n}"] if i < len(times): parts.append(f"t: {times[i]}") if i < len(alts): parts.append(f"alt: {alts[i]:,} ft") if i < len(spds): parts.append(f"gs: {spds[i]} kt") hover.append("
".join(parts)) fig = go.Figure() # The route line. fig.add_trace(go.Scattermap( lat=lats, lon=lons, mode="lines", line={"width": 3, "color": "#1f77b4"}, name="route", hoverinfo="skip", )) # Sample points with telemetry hover. fig.add_trace(go.Scattermap( lat=lats, lon=lons, mode="markers", marker={"size": 7, "color": "#1f77b4"}, text=hover, hoverinfo="text", name="samples", )) # Start / end markers. fig.add_trace(go.Scattermap( lat=[lats[0]], lon=[lons[0]], mode="markers", marker={"size": 13, "color": "#2ca02c"}, text=[f"Origin: {p.get('origin', '?')}"], hoverinfo="text", name="origin", )) fig.add_trace(go.Scattermap( lat=[lats[-1]], lon=[lons[-1]], mode="markers", marker={"size": 13, "color": "#d62728"}, text=[f"Destination: {p.get('destination', '?')}"], hoverinfo="text", name="destination", )) # Center the view on the track. center = {"lat": sum(lats) / n, "lon": sum(lons) / n} span = max(max(lats) - min(lats), max(lons) - min(lons), 0.5) zoom = max(1, min(9, 8 - (span ** 0.5))) fig.update_layout( map_style="open-street-map", map_zoom=zoom, map_center=center, margin={"l": 0, "r": 0, "t": 0, "b": 0}, height=560, showlegend=False, ) # Info panel (markdown) - anomaly fields are explicitly excluded. info_lines = [ f"### {p.get('callsign', 'Unknown flight')}", "", f"- **ICAO24:** `{p.get('icao24', '?')}`", f"- **Aircraft type:** {p.get('aircraft_type', '?')}", f"- **Route:** {p.get('origin', '?')} -> {p.get('destination', '?')}", f"- **Samples:** {n}", ] if "sample_interval_s" in p: info_lines.append(f"- **Sample interval:** {p['sample_interval_s']} s") if alts: info_lines.append(f"- **Altitude range:** {min(alts):,} - " f"{max(alts):,} ft") if spds: info_lines.append(f"- **Ground speed range:** {min(spds)} - " f"{max(spds)} kt") if times: info_lines.append(f"- **First seen:** {times[0]}") info_lines.append(f"- **Last seen:** {times[-1]}") # Surface any extra, non-sensitive properties for transparency. extra = {k: v for k, v in p.items() if k not in HIDDEN_KEYS and k not in {"icao24", "callsign", "aircraft_type", "origin", "destination", "sample_interval_s", "altitudes_ft", "ground_speeds_kt", "track_deg", "timestamps"}} if extra: info_lines.append("") info_lines.append("**Other properties:**") for k, v in extra.items(): info_lines.append(f"- {k}: {v}") info = "\n".join(info_lines) # Telemetry table rows for the dataframe. tracks = p.get("track_deg", []) rows = [] for i in range(n): rows.append([ i + 1, times[i] if i < len(times) else "", round(lats[i], 5), round(lons[i], 5), alts[i] if i < len(alts) else "", spds[i] if i < len(spds) else "", tracks[i] if i < len(tracks) else "", ]) return fig, info, rows with gr.Blocks(title="ADS-B Flight Route Viewer") as demo: gr.Markdown( "# ADS-B Flight Route Viewer\n" "Upload a GeoJSON flight dataset, pick a flight, and inspect its route " "and telemetry on the map.\n\n" "_This viewer intentionally does **not** reveal which flights are " "anomalous — finding the anomaly is the exercise._" ) flights_state = gr.State([]) with gr.Row(): file_in = gr.File( label="Upload GeoJSON (.geojson / .json)", file_types=[".geojson", ".json"], type="filepath", ) flight_dd = gr.Dropdown(label="Flight", choices=[], interactive=True) status = gr.Markdown("Upload a GeoJSON file to begin.") with gr.Row(): with gr.Column(scale=3): map_plot = gr.Plot(label="Route", value=empty_map()) with gr.Column(scale=2): info_md = gr.Markdown("_No flight selected._") telemetry = gr.Dataframe( headers=["#", "timestamp", "lat", "lon", "alt_ft", "gs_kt", "track_deg"], label="Telemetry (per sample)", interactive=False, wrap=True, ) file_in.change(load_file, inputs=file_in, outputs=[flight_dd, flights_state, status]) # Render when a file is loaded (auto-selected flight) or selection changes. flight_dd.change(render_flight, inputs=[flight_dd, flights_state], outputs=[map_plot, info_md, telemetry]) flights_state.change(render_flight, inputs=[flight_dd, flights_state], outputs=[map_plot, info_md, telemetry]) if __name__ == "__main__": demo.launch(server_name="0.0.0.0", server_port=7860)