File size: 6,408 Bytes
41e0c9e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""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">
  &gt;&gt; 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 &nbsp;
  <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>'
    )