1// globe.js - A lightweight 3D globe: what the site used CesiumJS for, and no more. 2// 3// The Earth is ray-traced in one full-screen pass: for each pixel, find where 4// the view ray meets a unit sphere and look the colour up in the layers below, 5// then shade day and night from the sun and add a little air. That replaces 6// Cesium's quadtree of terrain tiles, imagery reprojection, and render passes. 7// 8// layers, bottom to top 9// base image one latitude-longitude texture of the whole Earth (self-hosted) 10// base detail optional XYZ tiles for close zoom (TileLayer) 11// image overlay a latitude-longitude image, e.g. the aurora heatmap 12// tile overlay optional XYZ tiles, e.g. clouds (TileLayer) 13// then entities on a 2D canvas on top 14// 15// It only draws when something changed: requestRender() schedules one frame, 16// and nothing on the globe animates by itself, so an idle globe costs nothing. 17 18import { Camera } from './camera.js'; 19import { attachControls } from './controls.js'; 20import { Entities, drawEntities, pickEntity } from './entities.js'; 21import { TileLayer } from './tiles.js'; 22import { createProgram, createTexture, uploadTexture } from './gl.js'; 23import { lonLatToVec } from './math.js'; 24import { getSubsolarPoint } from '../utils/astronomy.js'; 25 26const FULLSCREEN_VERTEX = `#version 300 es 27out vec2 v_ndc; 28void main() { 29 // One triangle that covers the screen. 30 vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2) * 2.0 - 1.0; 31 v_ndc = p; 32 gl_Position = vec4(p, 0.0, 1.0); 33}`; 34 35const GLOBE_FRAGMENT = `#version 300 es 36precision highp float; 37in vec2 v_ndc; 38out vec4 fragColor; 39 40uniform vec3 u_eye; 41uniform vec3 u_forward; 42uniform vec3 u_right; 43uniform vec3 u_up; 44uniform vec2 u_tanHalf; 45uniform vec3 u_sun; 46uniform float u_lighting; // 1: day and night; 0: lit evenly 47uniform vec3 u_background; 48 49uniform sampler2D u_base; 50uniform float u_baseLoaded; 51 52uniform sampler2D u_detail; 53uniform vec4 u_detailRect; // mosaic: first tile x, y, and tiles across, down 54uniform float u_detailN; // tiles around the world at its zoom (0: none) 55uniform float u_detailAlpha; 56 57uniform sampler2D u_image; 58uniform vec3 u_imageStyle; // alpha, brightness, 1/gamma 59 60uniform sampler2D u_tilesGlobal; 61uniform vec4 u_tilesGlobalRect; 62uniform float u_tilesGlobalN; 63uniform sampler2D u_tilesView; 64uniform vec4 u_tilesViewRect; 65uniform float u_tilesViewN; 66uniform float u_tilesAlpha; 67 68const float PI = 3.14159265358979; 69const vec3 NO_IMAGERY = vec3(0.02, 0.035, 0.06); 70const vec3 AIR = vec3(0.35, 0.62, 1.0); 71 72// Where web-mercator position (mx, my) falls in a mosaic, if it does. 73bool mosaicUV(vec4 rect, float n, float mx, float my, out vec2 uv) { 74 uv = vec2(mod(mx * n - rect.x, n) / rect.z, (my * n - rect.y) / rect.w); 75 return uv.x >= 0.0 && uv.x < 1.0 && uv.y >= 0.0 && uv.y < 1.0; 76} 77 78vec4 sampleMosaic(sampler2D tex, vec4 rect, float n, vec2 uv, vec2 dx, vec2 dy) { 79 vec2 s = n / rect.zw; 80 return textureGrad(tex, uv, dx * s, dy * s); 81} 82 83void main() { 84 vec3 dir = normalize(u_forward + v_ndc.x * u_tanHalf.x * u_right + v_ndc.y * u_tanHalf.y * u_up); 85 float b = dot(u_eye, dir); 86 float disc = b * b - (dot(u_eye, u_eye) - 1.0); 87 vec3 closest = u_eye + dir * max(-b, 0.0); 88 89 // The surface point: where the ray lands, or for a ray that misses, the 90 // point beneath its closest approach, so it stays continuous across the 91 // limb and the texture derivatives stay sane there. 92 vec3 p = disc > 0.0 ? normalize(u_eye + dir * (-b - sqrt(disc))) : normalize(closest); 93 94 // Longitude-latitude texture coordinates (north at the top), with the 95 // derivative jump at the antimeridian taken out so no seam shows. 96 vec2 ll = vec2(atan(p.y, p.x) / (2.0 * PI) + 0.5, acos(clamp(p.z, -1.0, 1.0)) / PI); 97 vec2 llx = dFdx(ll); 98 vec2 lly = dFdy(ll); 99 llx.x -= round(llx.x); 100 lly.x -= round(lly.x); 101 // Web mercator for the tile layers; x is the same as longitude's. 102 float z = clamp(p.z, -0.99999, 0.99999); 103 float my = 0.5 - log((1.0 + z) / (1.0 - z)) / (4.0 * PI); 104 vec2 mdx = vec2(llx.x, dFdx(my));
105 vec2 mdy = vec2(lly.x, dFdy(my)); 106 107 vec3 color = NO_IMAGERY; 108 if (u_baseLoaded > 0.5) color = textureGrad(u_base, ll, llx, lly).rgb; 109 110 vec2 uv; 111 if (u_detailAlpha > 0.0 && u_detailN > 0.0 && mosaicUV(u_detailRect, u_detailN, ll.x, my, uv)) { 112 vec4 d = sampleMosaic(u_detail, u_detailRect, u_detailN, uv, mdx, mdy); 113 // Fade out over the outer half tile, so the patch has no hard edge. 114 vec2 edge = min(uv, 1.0 - uv) * u_detailRect.zw; 115 float feather = smoothstep(0.0, 0.5, min(u_detailRect.z < u_detailN ? edge.x : 1.0, edge.y)); 116 color = mix(color, d.rgb, d.a * u_detailAlpha * feather); 117 } 118 119 if (u_imageStyle.x > 0.0) { 120 // Cesium's imagery-layer adjustments: gamma, then brightness, then blend. 121 vec4 a = textureGrad(u_image, ll, llx, lly); 122 vec3 glow = pow(a.rgb, vec3(u_imageStyle.z)) * u_imageStyle.y; 123 color = mix(color, glow, a.a * u_imageStyle.x); 124 } 125 126 if (u_tilesAlpha > 0.0) { 127 vec4 t = vec4(0.0); 128 if (u_tilesViewN > 0.0 && mosaicUV(u_tilesViewRect, u_tilesViewN, ll.x, my, uv)) { 129 t = sampleMosaic(u_tilesView, u_tilesViewRect, u_tilesViewN, uv, mdx, mdy); 130 } else if (u_tilesGlobalN > 0.0 && mosaicUV(u_tilesGlobalRect, u_tilesGlobalN, ll.x, my, uv)) { 131 t = sampleMosaic(u_tilesGlobal, u_tilesGlobalRect, u_tilesGlobalN, uv, mdx, mdy); 132 } 133 color = mix(color, t.rgb, t.a * u_tilesAlpha); 134 } 135 136 // Day and night, as Cesium lit the globe with its fade distances at 0: the 137 // night side keeps 30% of the day's brightness, at every zoom. 138 float sunUp = dot(p, u_sun); 139 color *= mix(1.0, clamp(max(sunUp, 0.0) * 5.0 + 0.3, 0.0, 1.0), u_lighting); 140 141 // Air: haze thickening towards the limb on the lit side... 142 float day = mix(1.0, smoothstep(-0.15, 0.3, sunUp), u_lighting); 143 float limb = 1.0 - clamp(dot(p, -dir), 0.0, 1.0); 144 color = mix(color, AIR * 0.9, pow(limb, 3.0) * day * 0.6); 145 146 // ...and a glow around the Earth, thinning with height. 147 float height = max(length(closest) - 1.0, 0.0); 148 float lit = mix(1.0, smoothstep(-0.3, 0.3, dot(normalize(closest), u_sun)), u_lighting); 149 vec3 sky = u_background + AIR * exp(-height / 0.012) * (0.08 + 0.92 * lit); 150 151 // Antialiased edge: disc rises linearly from 0 at the limb. Rays pointing 152 // away from the Earth (b > 0) only meet it behind the camera. 153 float coverage = b > 0.0 ? 0.0 : clamp(0.5 + disc / max(fwidth(disc), 1e-9), 0.0, 1.0); 154 fragColor = vec4(mix(sky, color, coverage), 1.0); 155}`; 156 157const hexToRgb = (hex) => { 158 const n = parseInt(hex.replace('#', '').slice(0, 6), 16); 159 return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; 160}; 161 162/** An equirectangular image over the whole globe: (-180, 90) top left. */ 163class ImageLayer { 164 constructor(onChange) { 165 this.image = null; 166 this.alpha = 1; 167 this.brightness = 1; 168 this.gamma = 1; 169 this.show = true; 170 this.version = 0; 171 this._onChange = onChange; 172 } 173 174 /** Show `image` (canvas, image or ImageData). It is copied to the GPU now, so a reused canvas is safe. */ 175 setImage(image, style = {}) { 176 this.image = image; 177 Object.assign(this, style); 178 this.version++; 179 this._onChange(this); 180 } 181 182 clear() { 183 this.image = null; 184 this.version++; 185 this._onChange(this); 186 } 187} 188 189export class Globe { 190 /** 191 * @param {HTMLElement} container the globe fills it (it should be positioned) 192 * @param {object} [options] 193 * @param {string} [options.baseImage] URL of a whole-Earth equirectangular image 194 * @param {string} [options.basePreview] a small version of it, drawn first 195 * so the globe appears within a round trip; the full image replaces it 196 * @param {string} [options.baseCredit] 197 * @param {object} [options.baseDetail] TileLayer options for close zoom 198 * @param {string} [options.background] CSS hex colour of space 199 */ 200 constructor(container, { baseImage = null, basePreview = null, baseCredit = '', baseDetail = null, background = '#07070c' } = {}) { 201 this.container = container; 202 this.background = hexToRgb(background); 203 this.baseCredit = baseCredit; 204 this.frameNumber = 0; 205 this.time = new Date(); 206 this.lighting = true; // shade day and night from the sun at `time` 207 208 this.view = document.createElement('div'); 209 this.view.className = 'globe-view'; 210 this.view.style.cssText = 'position:absolute;inset:0;overflow:hidden;'; 211 this.canvas = document.createElement('canvas'); 212 this.canvas.className = 'globe-canvas'; 213 this.canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;display:block;outline:none;'; 214 this.overlayCanvas = document.createElement('canvas'); 215 this.overlayCanvas.className = 'globe-overlay'; 216 this.overlayCanvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;'; 217 this.credits = document.createElement('div'); 218 this.credits.className = 'globe-credits'; 219 this.credits.style.cssText = 'position:absolute;right:6px;bottom:3px;font:10px/1.4 system-ui,sans-serif;' 220 + 'color:rgba(255,255,255,0.5);pointer-events:none;text-shadow:0 0 2px #000;'; 221 this.view.append(this.canvas, this.overlayCanvas, this.credits); 222 container.prepend(this.view); 223 224 const gl = this.canvas.getContext('webgl2', {
225 alpha: false, antialias: false, depth: false, stencil: false, 226 premultipliedAlpha: true, preserveDrawingBuffer: false, 227 }); 228 if (!gl) throw new Error('This browser cannot draw the 3D globe (WebGL 2 is unavailable).'); 229 this.gl = gl; 230 this.overlayCtx = this.overlayCanvas.getContext('2d'); 231 232 this.camera = new Camera(); 233 this.camera.onChange = () => this._cameraChanged(); 234 this.entities = new Entities(() => this.requestRender()); 235 this.imageOverlay = new ImageLayer((layer) => this._uploadImage(layer)); 236 this.tileOverlay = null; 237 this.baseDetail = baseDetail ? new TileLayer(baseDetail, () => this.requestRender()) : null; 238 239 this._listeners = new Map(); 240 this._animations = new Set(); 241 this._hits = []; 242 this._raf = 0; 243 this._inFrame = false; 244 this._visible = true; 245 this._lost = false; 246 this._uploaded = new Map(); // texture -> version on the GPU 247 this._frame = this._frame.bind(this); 248 249 this._resize(); 250 this._initGL(); 251 this.setTime(new Date()); 252 this._loadBase(baseImage, basePreview); 253 attachControls(this); 254 255 new ResizeObserver(() => this._resize()).observe(this.view); 256 // Nothing to draw while the globe is scrolled out of sight; catch up when it returns. 257 new IntersectionObserver(([entry]) => { 258 this._visible = entry.isIntersecting; 259 if (this._visible) this.requestRender(); 260 }).observe(this.view); 261 262 this.canvas.addEventListener('webglcontextlost', (e) => { 263 e.preventDefault(); // ask for it back 264 this._lost = true; 265 });
266 this.canvas.addEventListener('webglcontextrestored', () => { 267 this._lost = false; 268 this._initGL(); 269 if (this._baseImage) this._uploadBase(this._baseImage); 270 this._uploadImage(this.imageOverlay); 271 this.requestRender(); 272 }); 273 } 274 275 // ââ Events ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 276 // 'click' and 'dblclick' receive {x, y, entity, lonLat}; 'camerachange' nothing. 277 278 on(type, fn) { 279 if (!this._listeners.has(type)) this._listeners.set(type, new Set()); 280 this._listeners.get(type).add(fn); 281 } 282 283 off(type, fn) { 284 this._listeners.get(type)?.delete(fn); 285 } 286 287 emit(type, detail) { 288 for (const fn of this._listeners.get(type) ?? []) { 289 try { fn(detail); } catch (err) { console.error(`[globe] ${type} handler failed:`, err); } 290 } 291 } 292 293 // ââ Scene âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 294 295 /** Light the globe for this moment: puts the day/night terminator in place. */ 296 setTime(date) { 297 this.time = date; 298 const { lon, lat } = getSubsolarPoint(date); 299 this.sun = lonLatToVec(lon, lat); 300 this.requestRender(); 301 } 302 303 /** Add an XYZ tile layer over everything else (replacing any previous one). */ 304 setTileOverlay(options) { 305 this.tileOverlay = options ? new TileLayer(options, () => this.requestRender()) : null; 306 this.tileOverlay?.update(this.camera); 307 this.requestRender(); 308 return this.tileOverlay; 309 } 310 311 /** The entity drawn at CSS pixel (x, y) of the globe, or null. */ 312 pick(x, y) { 313 return pickEntity(this._hits, x, y); 314 } 315 316 /** 317 * Run `fn(now)` every frame until it returns false. For motion that is not 318 * a camera flight (inertia, eased zoom). 319 */ 320 animate(fn) { 321 this._animations.add(fn); 322 this._schedule(); 323 } 324 325 /** Draw a new frame soon (once, however many times it is asked for before then). */ 326 requestRender() { 327 this._schedule(); 328 } 329 330 /** Render now and return a canvas holding the globe with its pins and labels. */ 331 snapshot() { 332 this.render(); 333 const out = document.createElement('canvas'); 334 out.width = this.canvas.width; 335 out.height = this.canvas.height; 336 const ctx = out.getContext('2d'); 337 ctx.drawImage(this.canvas, 0, 0); 338 ctx.drawImage(this.overlayCanvas, 0, 0); 339 return out; 340 } 341 342 // ââ Frame loop ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 343 344 _schedule() { 345 if (!this._raf && !this._inFrame) this._raf = requestAnimationFrame(this._frame); 346 } 347 348 _frame(now) { 349 this._raf = 0; 350 this._inFrame = true; 351 let moving = this.camera.stepFlight(now); 352 for (const fn of this._animations) { 353 if (fn(now)) moving = true; 354 else this._animations.delete(fn); 355 } 356 this._inFrame = false; 357 if (this._visible) this.render(); 358 else if (this._baseSettled) this._resolveReady?.(); // nothing on screen to wait for 359 if (moving) this._schedule(); 360 } 361 362 _cameraChanged() { 363 this._schedule(); 364 // Fetch tiles once the camera has settled, not on every step of a drag. 365 clearTimeout(this._tileTimer); 366 this._tileTimer = setTimeout(() => { 367 this.baseDetail?.update(this.camera); 368 this.tileOverlay?.update(this.camera); 369 }, 250); 370 this.emit('camerachange'); 371 } 372 373 render() { 374 const gl = this.gl; 375 if (this._lost) return; 376 gl.viewport(0, 0, this.canvas.width, this.canvas.height); 377 this._drawEarth(); 378 this._hits = drawEntities(this.overlayCtx, this.camera, this.entities.values, this._pixelRatio); 379 this._updateCredits(); 380 this.frameNumber++; 381 if (this._baseSettled) this._resolveReady?.(); 382 } 383 384 /** Ray-trace the Earth and its layers. */ 385 _drawEarth() { 386 const gl = this.gl; 387 const cam = this.camera; 388 const t = this._textures; 389 gl.useProgram(this._globe.program); 390 const u = this._globe.uniform; 391 gl.uniform3fv(u('u_eye'), cam.eye); 392 gl.uniform3fv(u('u_forward'), cam.forward); 393 gl.uniform3fv(u('u_right'), cam.right); 394 gl.uniform3fv(u('u_up'), cam.up); 395 gl.uniform2f(u('u_tanHalf'), cam.tanHalfX, cam.tanHalfY); 396 gl.uniform3fv(u('u_sun'), this.sun); 397 gl.uniform1f(u('u_lighting'), this.lighting ? 1 : 0); 398 gl.uniform3fv(u('u_background'), this.background); 399 400 const bind = (unit, name, texture) => { 401 gl.activeTexture(gl.TEXTURE0 + unit); 402 gl.bindTexture(gl.TEXTURE_2D, texture); 403 gl.uniform1i(u(name), unit); 404 }; 405 bind(0, 'u_base', t.base); 406 gl.uniform1f(u('u_baseLoaded'), this._baseImage ? 1 : 0); 407 408 const mosaic = (unit, prefix, texture, m) => { 409 bind(unit, prefix, texture); 410 if (m && this._uploaded.get(texture) !== m.version) { 411 uploadTexture(gl, texture, m.canvas);
412 this._uploaded.set(texture, m.version); 413 } 414 const blk = m?.block; 415 gl.uniform4f(u(`${prefix}Rect`), blk?.x0 ?? 0, blk?.y0 ?? 0, blk?.nx ?? 1, blk?.ny ?? 1); 416 gl.uniform1f(u(`${prefix}N`), m ? 2 ** blk.z : 0); 417 }; 418 const detail = this.baseDetail; 419 mosaic(1, 'u_detail', t.detail, detail?.view); 420 gl.uniform1f(u('u_detailAlpha'), detail?.view ? detail.opacity(cam) : 0); 421 422 const img = this.imageOverlay; 423 bind(2, 'u_image', t.image); 424 gl.uniform3f(u('u_imageStyle'), img.image && img.show ? img.alpha : 0, img.brightness, 1 / img.gamma); 425 426 const tiles = this.tileOverlay; 427 mosaic(3, 'u_tilesGlobal', t.tilesGlobal, tiles?.global); 428 mosaic(4, 'u_tilesView', t.tilesView, tiles?.view); 429 gl.uniform1f(u('u_tilesAlpha'), tiles ? tiles.opacity(cam) : 0); 430 431 gl.bindVertexArray(this._globe.vao); 432 gl.drawArrays(gl.TRIANGLES, 0, 3); 433 gl.bindVertexArray(null); 434 } 435 436 // ââ Setup âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 437 438 _initGL() { 439 const gl = this.gl; 440 this._globe = createProgram(gl, FULLSCREEN_VERTEX, GLOBE_FRAGMENT); 441 this._globe.vao = gl.createVertexArray(); 442 this._textures = { 443 base: createTexture(gl, { repeatX: true }), 444 detail: createTexture(gl), 445 image: createTexture(gl, { repeatX: true }), 446 tilesGlobal: createTexture(gl), 447 tilesView: createTexture(gl), 448 }; 449 this._uploaded.clear(); 450 } 451 452 _resize() { 453 const w = this.view.clientWidth || 1; 454 const h = this.view.clientHeight || 1; 455 this._pixelRatio = Math.min(window.devicePixelRatio || 1, 2); 456 for (const c of [this.canvas, this.overlayCanvas]) { 457 c.width = Math.round(w * this._pixelRatio); 458 c.height = Math.round(h * this._pixelRatio); 459 } 460 this.camera.setViewport(w, h); 461 this.requestRender(); 462 } 463 464 /** 465 * Load the Earth image, and its small preview if there is one, in 466 * parallel. Whichever arrives first goes on screen (and resolves `ready`); 467 * the full image replaces the preview, never the other way round. 468 */ 469 _loadBase(url, previewUrl) { 470 this.ready = new Promise((resolve) => { this._resolveReady = () => { this._resolveReady = null; resolve(); }; });
471 const sources = [previewUrl, url].filter(Boolean); 472 let pending = sources.length; 473 let sharp = false; 474 const settle = () => { 475 this._baseSettled = true; 476 this.requestRender(); 477 }; 478 if (!pending) return settle(); 479 480 for (const src of sources) { 481 const img = new Image(); 482 img.src = src; 483 img.decode() 484 .then(() => { 485 if (sharp) return; // the preview came in after the full image 486 sharp = src === url; 487 this._baseImage = img; 488 if (!this._lost) this._uploadBase(img); 489 settle(); 490 }) 491 .catch((err) => console.warn(`[globe] Earth image ${src} failed to load.`, err)) 492 .finally(() => { 493 // Nothing loaded at all: draw a plain Earth rather than wait forever. 494 if (--pending === 0 && !this._baseSettled) settle(); 495 }); 496 } 497 } 498 499 _uploadBase(img) { 500 const max = this.gl.getParameter(this.gl.MAX_TEXTURE_SIZE); 501 let source = img; 502 if (img.naturalWidth > max) { 503 // A GPU that can't hold it: shrink it rather than draw nothing. 504 source = document.createElement('canvas'); 505 source.width = max; 506 source.height = Math.round((max * img.naturalHeight) / img.naturalWidth); 507 source.getContext('2d').drawImage(img, 0, 0, source.width, source.height); 508 } 509 uploadTexture(this.gl, this._textures.base, source); 510 } 511 512 _uploadImage(layer) { 513 if (this._lost || !this._textures) return; 514 if (layer.image) uploadTexture(this.gl, this._textures.image, layer.image); 515 this.requestRender(); 516 } 517 518 _updateCredits() { 519 const parts = [this.baseCredit]; 520 const cam = this.camera; 521 if (this.baseDetail?.view && this.baseDetail.opacity(cam) > 0) parts.push(this.baseDetail.credit); 522 if (this.tileOverlay?.show) parts.push(this.tileOverlay.credit); 523 const text = parts.filter(Boolean).join(' · '); 524 if (text !== this.credits.textContent) this.credits.textContent = text; 525 } 526}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.