Spaces:
Running on Zero
Running on Zero
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">
>> 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>'
)
|