| """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 = {} |
| 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") |
| |
| 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, |
| "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 '—'}", |
| }) |
| |
| 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>' |
| ) |
|
|