Spaces:
Running on Zero
Running on Zero
| """Builds a self-contained, transparent 3D globe (Globe.gl / Three.js) as an | |
| iframe srcdoc, with flight points and animated estimated-path arcs.""" | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| _TEMPLATE = r"""<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <style> | |
| html, body { margin:0; height:100%; background:#02060a; overflow:hidden; | |
| font-family: 'Courier New', monospace; } | |
| #globeViz { width:100%; height:100%; } | |
| .scene-tooltip { color:#7CFFB2; text-shadow:0 0 6px #00ff88; font-size:12px; } | |
| #hud { | |
| position:absolute; top:10px; left:12px; color:#39FF14; | |
| text-shadow:0 0 8px #39FF14; font-size:12px; letter-spacing:1px; | |
| pointer-events:none; line-height:1.5; | |
| } | |
| #legend { | |
| position:absolute; bottom:10px; right:12px; color:#7CFFB2; | |
| text-shadow:0 0 6px #00ff88; font-size:11px; text-align:right; line-height:1.6; | |
| } | |
| /* green cube marker = a flight's current position */ | |
| .fcube { | |
| width:11px; height:11px; background:#39FF14; | |
| border:1px solid #d6ffd6; cursor:pointer; | |
| box-shadow:0 0 9px #39FF14, 0 0 3px #39FF14, | |
| inset -2px -2px 0 rgba(0,70,0,0.65), | |
| inset 2px 2px 0 rgba(210,255,210,0.55); | |
| } | |
| /* grey cube marker = the destination airport */ | |
| .fcube.dest { | |
| background:#9aa0a8; border-color:#e2e6ea; | |
| box-shadow:0 0 9px #aab0b8, 0 0 3px #cfd4da, | |
| inset -2px -2px 0 rgba(40,45,50,0.7), | |
| inset 2px 2px 0 rgba(230,234,238,0.6); | |
| } | |
| </style> | |
| <script src="https://unpkg.com/globe.gl@2.32.0/dist/globe.gl.min.js"></script> | |
| </head> | |
| <body> | |
| <div id="globeViz"></div> | |
| <div id="hud"> | |
| >> FLIGHTDECK // LIVE TELEMETRY<br/> | |
| TARGETS: <span id="count">0</span><br/> | |
| <span id="ts"></span> | |
| </div> | |
| <div id="legend"> | |
| <span style="color:#39FF14">▣</span> flight | |
| <span style="color:#cfd4da">▣</span> destination<br/> | |
| ╌╌ path to destination | |
| </div> | |
| <script> | |
| const DATA = /*__DATA__*/; | |
| const world = Globe()(document.getElementById('globeViz')) | |
| .backgroundColor('rgba(0,0,0,0)') | |
| .globeImageUrl('https://unpkg.com/three-globe/example/img/earth-night.jpg') | |
| .showGraticules(true) | |
| .showAtmosphere(true) | |
| .atmosphereColor('#19ff8a') | |
| .atmosphereAltitude(0.3) | |
| .htmlElementsData(DATA.points) | |
| .htmlLat('lat').htmlLng('lng') | |
| .htmlAltitude('alt') | |
| .htmlElement(d => { | |
| const el = document.createElement('div'); | |
| el.className = 'fcube' + (d.kind === 'dest' ? ' dest' : ''); | |
| el.title = d.label; | |
| return el; | |
| }) | |
| .arcsData(DATA.arcs) | |
| .arcStartLat('sLat').arcStartLng('sLng') | |
| .arcEndLat('eLat').arcEndLng('eLng') | |
| .arcColor('color') | |
| .arcStroke(0.4) | |
| .arcDashLength(0.4) | |
| .arcDashGap(0.2) | |
| .arcDashInitialGap(() => Math.random()) | |
| .arcDashAnimateTime(2600) | |
| .arcAltitudeAutoScale(0.4) | |
| .arcLabel('label'); | |
| // Slightly transparent, neon-tinted globe (kept visible even if the texture | |
| // is slow: a dark-teal base + emissive glow + graticule grid define the sphere). | |
| const mat = world.globeMaterial(); | |
| mat.transparent = true; | |
| mat.opacity = 0.9; | |
| if (mat.color) mat.color.set('#0a1f17'); | |
| if (mat.emissive) { mat.emissive.set('#0a3a24'); mat.emissiveIntensity = 0.55; } | |
| // User can spin/drag/zoom freely; idle auto-rotation when not interacting. | |
| const ctrl = world.controls(); | |
| ctrl.autoRotate = true; | |
| ctrl.autoRotateSpeed = 0.35; | |
| ctrl.enableZoom = true; | |
| ctrl.enablePan = false; | |
| // As soon as the user grabs/drags (or zooms) the globe, stop the idle spin. | |
| ctrl.addEventListener('start', () => { ctrl.autoRotate = false; }); | |
| document.getElementById('count').textContent = DATA.points.length; | |
| document.getElementById('ts').textContent = DATA.stamp || ''; | |
| // Search fly-in: start wide, then TURN + MAGNIFY onto the target location. | |
| if (DATA.focus) { | |
| ctrl.autoRotate = false; // pause idle spin during the fly-in | |
| world.pointOfView({lat: 5, lng: DATA.focus.lng, altitude: 3.2}, 0); // wide | |
| setTimeout(() => { | |
| world.pointOfView( | |
| {lat: DATA.focus.lat, lng: DATA.focus.lng, altitude: 1.25}, 2600); // zoom in | |
| }, 250); | |
| setTimeout(() => { ctrl.autoRotate = true; }, 3200); // resume gentle spin | |
| } | |
| addEventListener('resize', () => { | |
| world.width(innerWidth); world.height(innerHeight); | |
| }); | |
| world.width(innerWidth); world.height(innerHeight); | |
| </script> | |
| </body> | |
| </html>""" | |
| def build_globe_html(flights, stamp="", focus=None) -> str: | |
| """flights: list of dicts from app.normalize_flight(). Returns an iframe.""" | |
| points, arcs = [], [] | |
| dest_seen = {} # dedup destination cubes by rounded coordinate | |
| for f in flights: | |
| lat, lon = f.get("lat"), f.get("lon") | |
| if lat is None or lon is None: | |
| continue | |
| alt = f.get("alt") | |
| # Plain-text tooltip (htmlElement uses the title attribute). | |
| label = ( | |
| f"{f.get('callsign') or f.get('flight') or '??'} " | |
| f"{f.get('orig') or '?'} -> {f.get('dest') or '?'} | " | |
| f"alt {alt or '?'} ft · {f.get('gspeed') or '?'} kt · " | |
| f"ETA {f.get('eta_human') or '—'}" | |
| ) | |
| points.append({ | |
| "lat": lat, "lng": lon, "kind": "flight", | |
| "alt": 0.015, # sit just above the surface (a marker, not a pole) | |
| "label": label, | |
| }) | |
| ep = f.get("est_path") | |
| if ep: | |
| arcs.append({ | |
| "sLat": lat, "sLng": lon, | |
| "eLat": ep[0], "eLng": ep[1], | |
| "color": ["#39FF14", "#aaffcc"], | |
| "label": f"{f.get('callsign') or '??'} → {f.get('dest') or '?'} · ETA {f.get('eta_human') or '—'}", | |
| }) | |
| # Grey cube marking the destination airport (deduplicated). | |
| key = (round(ep[0], 3), round(ep[1], 3)) | |
| if key not in dest_seen: | |
| dest_seen[key] = True | |
| points.append({ | |
| "lat": ep[0], "lng": ep[1], "kind": "dest", | |
| "alt": 0.015, | |
| "label": f"DEST {f.get('dest') or '?'}", | |
| }) | |
| data = {"points": points, "arcs": arcs, "stamp": stamp, "focus": focus} | |
| html = _TEMPLATE.replace("/*__DATA__*/", json.dumps(data)) | |
| b64 = base64.b64encode(html.encode("utf-8")).decode("ascii") | |
| return ( | |
| f'<iframe src="data:text/html;base64,{b64}" ' | |
| 'style="width:100%;height:640px;border:0;border-radius:10px;' | |
| 'box-shadow:0 0 24px #0aff9d55, inset 0 0 40px #00000080;"></iframe>' | |
| ) | |