PageSourceSearch

https://aurora-c9c22.web.app/js/globe/globe.js?v=cf37da5380

js aurora-c9c22.web.app collected 2026-10-03 12:17:37 UTC 21,629 bytes, 526 lines download raw bytes

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.