Taos Engine

Terrain & Worlds

Geo GIS Explorer — a Living Map of the Earth's Oceans and Weather

Now a standalone project. The explorer has moved out of the Taos samples into its own open source repository, Earth Explorer, built on the Taos engine code described here. The mesh (streamed-terrain) mode discussed below exists only in this historical write-up; the standalone project keeps the flat projections and the globe. File names refer to the sample as it was when it lived in this repository.

A world map that is alive. It shows sea-surface temperature, anomaly and ice back to 1981; waves, swell and wind-sea; coral heat stress and marine heatwaves; rainfall, air, skin and humidity fields; the surface radiation budget; species occurrences, tagged-animal tracks and fishing effort; true-color and geostationary imagery; radar, cyclones, wildfires, borders and cities. The data comes from real NOAA, NASA and partner feeds, streamed straight into the browser and played as a time-lapse.

You can view it in six flat projections, on a ray-marched relief globe, or on a streamed, draped terrain mesh you can fly over at treetop height. The map also answers questions about its data: click a point, draw a line or an area, compare two dates under one color scale, export any readout as CSV with a provenance header, or run a typed analysis program — written by hand, picked from a preset, or authored by an LLM from a plain-English question.

This chapter is the technical deep dive. It covers where each byte comes from, what happens to it, how it reaches the screen, and why each piece is shaped the way it is. The analysis engine has its own module reference and tutorial series, so here it appears only where it touches the explorer.

Data pipeline overview
Data pipeline overview

The sample is one ~8,000-line TypeScript file plus a handful of helpers:

FileRole
samples/geo_gis_explorer.tsLayer table, loaders' orchestration, the WGSL display shader, every view mode, input, UI
samples/geo_flow_overlay.tsGPU particle trails for currents and wind
samples/geo_projections.tsCPU twins of the WGSL projections (tested in tests/samples/geo_projections.test.ts)
samples/lib/geo_drape.tsPaints the active data layer into terrain-tile imagery for the mesh mode
samples/lib/geo_sky.tsCurved-horizon atmosphere, aerial perspective and stars for the mesh mode
samples/lib/geo_layer_info.tsThe ⓘ explanations and citations for every layer
samples/lib/geo_reproduce_panel.ts"Get this data": the exact request, xarray/R snippets, citation
samples/lib/geo_import_panel.ts"Your data": GeoJSON study areas and station-CSV match-ups
samples/lib/analysis_panel.tsPresets / Ask / Graph front ends to the analysis engine
src/geo/live/One loader per feed, plus GriddedField, ENSO, sun, match-up and request-reproduction logic
tools/geo/Node bake scripts for everything the browser cannot fetch itself

§ 1

The load-bearing decision: one sampling contract#

Every field-shaped thing the explorer shows is normalized into one shape: an equirectangular texture on a full −180..180° / +90..−90° grid, wrapped in GriddedField. Row 0 is north, column 0 is 180°W. That covers two dozen value layers, two particle overlays, radar, imagery, relief and city lights. Every downstream system is written once against that contract:

GriddedField contract
GriddedField contract

1.1 Full-globe placement#

ERDDAP answers a griddap .json request with a table of (time, [zlev,] lat, lon, value) rows. Feeds disagree on longitude convention (0..360 vs ±180), latitude order and coverage band. WaveWatch III stops at about ±77°, and the baked currents stop at ±64.7°. buildGrid infers the grid spacing from the unique sorted lats and lons in the (possibly strided) response, sizes a full-globe raster from it (width = round(360/dLon), height = round(180/dLat)), and drops every row at its true position. A partial feed lands where it belongs, and everything it did not cover stays mask = 0:

TypeScript
// gridded_field.ts — place one response row into the full-globe raster
const lon = raw >= 180 ? raw - 360 : raw;                                      // 0..360 → ±180
const lat = flipLat ? -r[iLat] : r[iLat];                                      // GFS archive fix, §2.4
const col = ((Math.round(((lon + 180) / 360) * width) % width) + width) % width;
const row = Math.min(height - 1, Math.max(0, Math.round(((90 - lat) / 180) * (height - 1))));

1.2 Encoding#

Scalar layers store one normalized byte per cell in R (duplicated into G and B), with the valid mask in A:

One decode function serves the shader and the CPU sample() alike:

TypeScript
decode(byte: number): number {
  const n = byte / 255;
  if (this.meta.isLog) {
    const l0 = Math.log10(this.meta.min);
    return Math.pow(10, l0 + n * (Math.log10(this.meta.max) - l0));
  }
  return this.meta.min + n * (this.meta.max - this.meta.min);
}

Vector layers (currents, wind) encode R = u/uMax·0.5+0.5, G the same for v, and B = speed/uMax. The B channel exists purely so the flow overlay can read speed with one fetch.

Derived fields (analysis results) are built by GriddedField.fromBytes. It accepts an optional per-cell flag and puts it in B, the one channel no value path samples. The flag means "this cell's estimate failed its significance test", and the shader draws a stipple over those cells (§5.5). The flag is zeroed outside the mask. That way, a bilinear fetch divided by alpha gives the fraction of flagged valid neighbors rather than bleeding across a no-data edge.

Scalar frames retain their CPU cells (values bytes plus mask). That is what powers hover values, point time series, transects, area means, the Δ/mean/min/max/range views, auto-fit scales, station match-ups and the whole analysis graph, all without a single GPU readback. Vector frames retain physical (u, v) floats when loaded with retainCells. stats() computes byte-level min/mean/max over valid cells once and caches it. An all-null grid returns NaN, and the explorer uses that to reject empty frames (§3.2).

1.3 Keyless and browser-direct where possible#

The NCEI and PacIOOS ERDDAP servers, NASA GIBS, NOAA STAR, RainViewer, OBIS, IOOS ERDDAP, the ArcGIS feature services and jsDelivr all send CORS headers. So nearly the whole sample runs with zero configuration and no server of its own. Two sources need credentials, and both ship with a working built-in default:

Feeds whose hosts send no CORS header at all are baked offline by Node scripts in tools/geo/ into committed PNG atlases:


§ 2

The layers and their feeds#

2.1 The LAYERS table#

Every base layer is one BaseLayer record, and the whole UI — the grouped picker, the overlay picker, the legend, the ⓘ panel, the analysis catalog — is derived from that table. The fields that change behavior:

FieldMeaning
kindoisst / live (ERDDAP stack), baked (atlas only), imagery (GIBS RGB), geo-live (GOES composite), obis, gfw
sourcea ScalarSource: servers, datasets (tried in order), variable, hasLevel, range, isLog, strideScale
bakedStackcommitted atlas prepended to the live stack
overLandthe quantity exists over land (GFS, GPCP, land anomaly): draw data over the basemap
sparsethe scale floor means "nothing here" (rain, heat stress, counts): render it as background, and don't flood-fill holes
deltaRange± full-scale of the Δ view, in physical units
overlayBands, overlayHatchAthow the layer draws when it is the overlay (§5.6)
fmt / fmtRel / fmtSpanabsolute, relative (signed, no °F offset), and span formatters

A parallel ANALYSIS_META record gives each layer:

The coverage floor is authoritative, and it exists because feeds advertise more time than they serve. PacIOOS's GFS aggregation claims 2022-12 onward, but its four radiation fluxes return all-null grids before about 2026-01. Clamping to the floor keeps the timeline, the calendar and the analysis store from offering empty months.

GroupLayersSourcePath into the browser
OceanSST, SST anomaly, sea iceNOAA OISST v2.11° atlas 1981-09 → bake date, plus live NCEI ERDDAP top-up (native 0.25°)
OceanChlorophyll-aS-NPP VIIRS monthly (CoastWatch)baked atlas, log10 over 0.02–20 mg/m³
Coral & heat stressDegree heating weeks, bleaching alert, marine-heatwave categoryNOAA Coral Reef Watch 5 kmlive PacIOOS ERDDAP, decimated ×10 (strideScale)
WavesTotal height, swell, wind-sea, peak periodNOAA WaveWatch IIIlive PacIOOS ERDDAP (2017-02 →)
AtmosphereRainfall rate, 2 m air temp, skin temp, humidity, sea-level pressure, 4 radiation fluxesNOAA GFSlive PacIOOS ERDDAP (2022-12 →, +7-day forecast edge)
AtmosphereDaily rainfall 1983 →NOAA PERSIANN-CDRlive NCEI ERDDAP, ±60°
AtmosphereMonthly rainfall 1979 →GPCP v2.3baked atlas at native 2.5°
AtmosphereLand temperature anomaly 1979 →GHCN-CAMSbaked atlas, anomaly vs 1991–2020
LifeSpecies occurrencesOBISlive gridded endpoint (counts per cell)
Human activityApparent fishing effortGlobal Fishing Watchlive vector tiles, one year → 12 monthly frames
ImagerySatellite true colorNASA GIBS VIIRSlive 4326 WMTS tiles, 2012-01-19 →
ImageryGOES live cloudsNOAA STAR GeoColorlive full disks, reprojected on the CPU

The overlays and reference geometry that are not layers:

OverlaySourcePath
Surface currents (flow)NOAA CoastWatch geostrophic (miamicurrents)baked vector atlas, 2016-09 →
10 m wind (flow)NOAA GFSlive PacIOOS vector stack
Weather radarRainViewerlive Mercator tiles, reprojected
Animal tracksUS IOOS Animal Telemetry Networklive IOOS ERDDAP tabledap, QARTOD-passed fixes
Tropical cyclonesNOAA/NWS NHC advisory feature servicelive GeoJSON, six layers
WildfiresNIFC/WFIGS + VIIRS hotspots + USFS forestslive GeoJSON, viewport-scoped
Borders, citiesNatural Earthstatic GeoJSON via jsDelivr, three scales
Zoom detailGIBS Blue Marble, Esri World Imagerytile windows (§7)
Relief, basemap, land mask, night lights, ONIETOPO1, Solar System Scope, OISSTbaked assets

2.2 ERDDAP mechanics#

Every live gridded request has the same shape. GriddedField.loadScalar issues:

TypeScript
const q = `${src.variable}[${timeSel}]${src.hasLevel ? '[0]' : ''}[0:${stride}:last][0:${stride}:last]`;
// → `${server}/${dataset}.json?` + q, with only [ and ] percent-encoded

Details that matter:

2.3 OISST — the backbone (sst · anom · ice)#

The NCEI ERDDAP serves 0.25° daily OISST with CORS, but its aggregation only reaches back to 2020-02-28, and NCEI has dropped these datasets outright before (mid-2026). So the full record is committed:

The live stream then only tops up dates newer than the bake. SINCE_YEAR = 1981 is the timeline start, because forty-five years is what makes a trend or a 30-year climatology mean anything — a decade of it is weather.

Timeline assembly
Timeline assembly

Atlas layout. A baked stack is one tall PNG per variable, with frame i in rows [i·H, (i+1)·H). A JSON sidecar carries {min, max, log?, uMax?, width, height, frames, dates[]}. loadBakedStack decodes it like this:

The readback has a trap. The 1° OISST atlas is 360 × 97,020 px, and a canvas past the browser's size limit does not throw — it reads back all zeros, which marks every cell invalid. So the CPU decode runs in slabs of floor(16384 / H) frames.

Atlases are baked monthly. The display cadence (4, 2 or 1 months) filters frames at load with everyMonths, keeping frame i when (monthOrd(date_i) − monthOrd(date_0)) % every == 0.

Fast first paint. Before any stack arrives, a baked single-day SST snapshot (sst_oisst.png, 720×360) is loaded as bootField. It claims the screen only if SST is still the active layer and nothing else has landed. A ?layer= deeplink races that fetch, and the snapshot must not squat on another layer's colormap.

2.4 GFS atmosphere — the latitude-flip probe and the sampling hour#

The PacIOOS ERDDAP aggregates NOAA GFS (ncep_global):

The archive floor is about 2022-12, and the range's end is the +7-day forecast edge, so the calendar can pick a date a week in the future.

The flip probe. One upstream bug was found by staring at a Tibet warmer than the ocean: archived dates serve latitude-flipped grids, while current dates are labeled correctly. Before loading any GFS date, the explorer fetches two cells and asks a question whose answer does not depend on the season:

TypeScript
// The Tibetan plateau (30N/88E, ~4.7 km up) is colder than the subtropical ocean at its mirror
// point in EVERY season — if the cold end carries the −30 label, the grid is flipped.
const q = `tmp2m[(${timeISO})][(-30.0):120:(30.0)][(88.0)]`;
...
flipped = north - south > 2;   // Tibet must be the cold end (K)

The answer is cached per timestamp in gfsFlipCache and passed to the loader as flipLat. WaveWatch on the same server probes clean.

The sampling hour. GFS is 3-hourly, and the explorer's frames are daily, so it has to choose which instant represents a day. The default snapshot is 12:00 UTC, which is 06:00 in New Mexico, 13:00 in Nigeria and 21:00 in Japan. For diurnal fields that spread is larger than the geography: one high-desert cell reads 14 °C at 12 UTC and 46 °C six hours later.

The gear menu's daily max / min / mean switches to GriddedField.loadScalarDaily:

This reduction is display-only. The analysis graph keeps reading the literal 12:00 UTC frame, because it has explicit timeReduce ops and a hidden reduction under an op called layer would be a trap. The legend title carries · daily max so a screenshot says which one it is.

Because GFS layers cover land, they are overLand and draw over the basemap (§5.4). Rain is sparse, so values at the scale floor render as background instead of smearing the colormap's bottom color across the planet.

2.5 Coral Reef Watch, WaveWatch, PERSIANN#

2.6 The long monthly climate records#

Daily PERSIANN is far too heavy to average over decades in a browser, and GFS starts in 2022. ENSO work — regressing rainfall or temperature on the Niño index over ~45 winters — needs long monthly records. bake_climate_stacks.mjs reads NOAA PSL THREDDS OPeNDAP in Node, parsing the binary .dods response directly (big-endian XDR float32; ASCII would be ~10× the bytes):

The layer's caveat notes that the SST anomaly uses a 1971–2000 baseline, so the two anomaly layers are offset by recent warming.

2.7 Currents and chlorophyll#

2.8 Life: OBIS and ATN#

OBIS species occurrences are one layer with the species as a parameter rather than twelve near-identical layers, so one info entry and one citation cover every species. loadObisGrid calls api.obis.org/v3/occurrence/grid/{precision}?scientificname=…:

The ⓘ text is blunt that occurrence counts measure sampling effort as much as abundance.

ATN tracks (loadAtnTracks) read IOOS ERDDAP's atn_cacheFromUrl_collection tabledap as CSV, filtered server-side to qartod_rollup_flag=1 (QC-passed). Fixes are grouped by deployment and sorted by time. A track is split wherever consecutive fixes are more than 30 days apart or imply more than 12 km/h (a bad fix, not a fast animal), and runs shorter than 4 points are dropped. Each deployment gets its own hue (i·47 mod 360), a white dot marks where it started, and the result is rasterized into the annotation window (§9).

2.9 Human activity: Global Fishing Watch#

Apparent fishing effort comes from GFW's 4Wings heatmap API as Mapbox Vector Tiles. loadGfwEffortStack(year, zoom 2) works like this:

2.10 Imagery: GIBS, GOES, radar#

GIBS serves pre-rendered EPSG:4326 WMTS tiles, so a global true-color day is "just" a mosaic. The catch is that the 4326 TileMatrixSet is not a power-of-two pyramid:

GOES GeoColor is harder. NOAA STAR publishes each satellite's full disk (GOES-19 East at 75.2°W, GOES-18 West at 137.0°W) as a 1808² JPEG in the ABI fixed grid — a perspective "geos" projection of scan angles. loadGeoComposite walks every pixel of a 2048×1024 equirect target (about 2M pixels, CPU, one-off per load) and does the following:

TypeScript
// geostationary.ts — geodetic lon/lat → GOES scan angles, GRS80 ellipsoid
const latC = Math.atan(((R_POL * R_POL) / (R_EQ * R_EQ)) * Math.tan(lat));   // geocentric latitude
const rc   = R_POL / Math.sqrt(1 - E2 * Math.cos(latC) * Math.cos(latC));
const sx = H_ORBIT - rc * Math.cos(latC) * Math.cos(dLon);
const sy = -rc * Math.cos(latC) * Math.sin(dLon);
const sz = rc * Math.sin(latC);
if (H_ORBIT * (H_ORBIT - sx) < sy*sy + ((R_EQ*R_EQ)/(R_POL*R_POL))*sz*sz) {
  return null;                       // beyond the limb — not visible
}
return { x: Math.asin(-sy / Math.hypot(sx, sy, sz)), y: Math.atan2(sz, sx) };

Coverage runs from about 155°E eastward to 15°E. The Asia/Indian Ocean gap stays alpha-0 and shows as dim basemap.

RainViewer radar is the latest past frame of a rolling global composite. loadRadarOverlay builds it in three steps:

  1. Mosaics the 16 zoom-2 Web-Mercator tiles (1024²).
  2. Reprojects to a 2048×1024 equirect texture on the CPU, one row at a time. It uses nearest-neighbor sampling, and rows beyond ±85° are skipped.
  3. Hands back a plain texture (not a GriddedField — it is never analyzed).

The shader alpha-blends it over the data (mix(col, radar.rgb, radar.a · 0.85)). Coverage is land radar networks only.

2.11 Tropical cyclones: the one feed whose point is a forecast#

Every other live source is a grid of numbers the shader colors. A hurricane is not that. What forecasters issue, and what people act on, is a handful of vector features:

So cyclones.ts returns paths and polygons, and the overlay rasterizes them into the windowed annotation canvas (§9).

Six queries go to NOAA's advisory feature service on ArcGIS: observed positions, observed track, forecast positions, forecast track, error cone, and watches/warnings. That service is keyless, CORS-open and republished from NHC's six-hourly shapefiles. NHC's own CurrentStorms.json sends no CORS header, and the wind-radii and swath layers are skipped because at this scale they turn the cone into mud.

The overlay always shows the latest advisory and deliberately does not follow the time slider. The status strip summarizes each storm's class, wind, pressure, motion and advisory time.

2.12 Wildfires: three feeds, three questions#

wildfire.ts draws on three sources that are easy to mistake for one:

USDA Forest Service national-forest boundaries go underneath as context. They are fetched only once the view is regional (du < 0.35), with maxAllowableOffset scaled to the view so the generalization happens upstream. The Santa Fe National Forest is 1.7M acres of very crinkly edge, and full fidelity costs megabytes to draw a two-pixel line.

Rasterization choices:

Pans refetch only the viewport-scoped pieces:

2.13 Reference geometry: a ladder, not a layer#

Borders and city labels are on by default: reference geometry is the frame the rest of the map is read against, and a few hundred KB is a small price. ?borders=0 / ?cities=0 opt out. This is the one overlay whose data changes with zoom rather than just its rasterization. Natural Earth ships the same borders generalized by cartographers at 1:110M, 1:50M and 1:10M, and admin_places.ts picks by zoom: detailForZoom returns 10m at zoom ≥ 12, 50m at ≥ 3, otherwise 110m. Levels are cached forever, so only the first visit to a scale pays and zooming back out is a redraw. State lines at 10m fall back to the 50m file, because the 10m one exceeds jsDelivr's 20 MB limit.

International borders are cased and solid. State lines are thinner and dashed, and drawn first, so a state line never competes with a national border.

City labels are thinned by Natural Earth's own scalerank, its editorial judgment of which cities earn a label at which scale: placeRankForZoom = clamp(round(log2(zoom)) + 1, 1, 10), one rank per doubling of zoom. Labels then pass the screen-space collision placer. Ranking by raw population instead would put every Chinese prefecture city on a world map and drop Reykjavík. Capitals get gold, and populations appear only once the view is regional.

2.14 The ONI record#

The ENSO panel needs monthly resolution, denser than a coarse map cadence, so it has its own record:

It is OISST v2.1 against its 1971–2000 baseline, not the official ERSSTv5 ONI, and agrees with it to about 0.1 °C.

2.15 Static assets#


§ 3

Time#

3.1 Stacks and streaming#

A temporal layer loads into absFrames, a date-sorted list of {date, field, shared?}. loadLayer assembles it in this order:

  1. The baked atlas, if any.
  2. sourceTimeRange for the live datasets, clamped to the layer's coverage floor.
  3. sampledDates(range, SINCE_YEAR, stepMonths), minus any date the atlas already holds.
  4. The remaining dates streamed through streamPool(dates, 3, …), a width-3 worker pool. ERDDAP handles a few concurrent fetches fine, and one slow response no longer stalls the stream.

Newest first. The date list is chronological, because the calendar bounds, the "already have it" filter and the status line read it that way. Only the fetch order is reversed, so the first frame on screen is today's and the record fills in behind it. As older frames arrive they insert before the displayed one, which moves its index. followNewest re-pins the display to the newest frame on every arrival until the user acts — scrubs, plays, picks a day, or follows a ?date link. After that, a late arrival never yanks the view back.

Cadence. Whole-number steps (4, 2, 1 months) walk calendar months since 1981. Fractional steps are sub-monthly (0.5 ≈ 2 weeks, 0.25 ≈ weekly, 0.033 ≈ daily). Those walk days backwards from the newest data and are bounded by MAX_SUBMONTHLY_FRAMES = 120, because a fine cadence is for watching a storm, a bloom or a heat event evolve, not for scrolling forty years a day at a time. At the default daily cadence an SST stack is therefore mixed: 539 monthly baked frames followed by up to 120 daily live frames. The by-year chart accounts for that (§10.3).

When the feed is down. NCEI sometimes answers "unknown datasetID" for hours. The baked stack alone then gives the full committed timeline, views and date deeplinks, and the live range is retried with exponential backoff (30 s doubling to 5 min). The newer dates arrive once the feed is back, with no reload. A layer change (loadGen) ends the retries.

3.2 Guarding frame quality#

3.3 Playback and crossfade#

Playback is paused by default; the map opens on the newest frame, and Space, ▶ or ?play starts it. advanceTimeLapse(dt) steps idx every interval seconds (the speed slider, 0.15–2 s per frame). It is shared by both render paths, so the date cannot advance in only one of them.

While playing, the shader crossfades between the current frame and the next:

WGSL
let sA = textureSampleLevel(fieldTex, samp, uv, 0.0);
let sB = textureSampleLevel(fieldNxt, samp, uv, 0.0);
let s  = mix(sA, sB, blend);          // blend = fractional progress through the interval

A scrub, a picked day or a single-frame layer shows its exact field (blend = 0).

Picked days. The ⚙ "go to day" calendar fetches any date the live feed covers on demand into pickedField. That field is held apart from the stack and forces the absolute view, since a picked day is never a derived field.

3.4 Season filter#

?season=djf|mam|jja|son|1..12 restricts the stack to some months of the year. Everything that reads the stack goes through seasonFrames(): the time-lapse, the mean/min/max/range reductions, the point and area charts, the auto-fit. So "the map is showing summers" means the same thing in all of them. The season goes into the legend title as well as the control, because a mean over summers and a mean over everything look alike, and the legend is what survives into a screenshot. Station match-ups are the deliberate exception: they collocate to each station's own date, and filtering the record there would report a miss for a sample the product covers perfectly well.

3.5 Derived views#

The view picker turns the stack into analysis products, computed on the CPU from retained cells onto a fixed 720×360 grid:

ViewOutputNotes
Δ vs N years earlierone frame per date with a partner ~N years back (±62 days)stays animatable; balance colormap over ±deltaRange; N from ?dyr=1..5
Mean / Min / Max over yearsone static framelabeled with the year span
Seasonal rangemax − min per cellneeds ≥ 2 samples; viridis over half the layer span

A range picked for one view is meaningless on the next (an anomaly is ±3, the absolute field −2..34), so the scale override resets on an actual view change — but not at boot, where it would throw away a range that arrived in a share link.

3.6 A/B compare#

?cmp=YYYY-MM-DD (or ⚙ → compare) pins a second date on the left of a swipe divider. The implementation reuses the crossfade's second texture slot. Instead of easing between frames, the shader takes one or the other by which side of the divider the pixel is on:

WGSL
var blend = u.p0.z;
if (u.cmp.x > 0.5) {
  blend = select(0.0, 1.0, fp.x > u.cmp.y);   // fp = fragment position in pixels
}

Two dates then share one projection, one color scale and one set of coastlines, which is the only way a difference of a degree or two can be judged by eye. Flipping between two screenshots cannot do it; the eye has no memory for absolute color.


§ 4

The display shader at a glance#

The fullscreen pass is one WGSL module embedded in the sample (SHADER). The vertex shader emits a single oversized triangle. The fragment shader has three parts:

mapColor is called unconditionally from fs so its derivatives are legal everywhere.

Bindings — one bind group, 20 entries. Every optional texture defaults to a 1×1 transparent (or black) placeholder, so the layout never changes:

#Resource#Resource
0uniforms (80 floats)10night lights
1field A (current frame)11analysis arrows
2colormap LUT (256×1)12detail window (Blue Marble / Esri)
3sampler (linear, U repeat, V clamp)13overlay layer field
4basemap (rgb) + land mask (a)14overlay LUT
5currents trails15drawn geometry
6wind trails16ATN tracks
7field B (next frame / compare)17cyclones
8ETOPO (16-bit packed)18wildfires
9radar19borders + cities

Rebinding without churn. The bind group is rebuilt only when a string key changes. The key covers both frame dates, the layer, the overlay field's date, and a generation counter per rasterized overlay (analysisVecGen, detailGen, geomGen, trackGen, stormGen, fireGen, adminGen). A re-rasterization bumps its counter and the next frame swaps in the new view. Lazily loaded textures (night lights, radar, the real ETOPO map) reset bgKey = '' when they arrive.

Uniforms — twenty vec4s, filled from a Float32Array(80) each frame:

vec4Contents
p0resolution, frame blend, imagery flag
p1contour band count, contours on, mode (0 flat / 1 globe / 2 earth), field width
padrelief exaggeration, overLand, sparse, over-land data opacity
bgbackground rgb, sun on
rotglobe yaw, tilt, subsolar lon, lat (rad)
ovcurrents, wind, Niño box, radar toggles
viewflat view center u, v, zoom, projection index
win0detail-window rect u0, v0, 1/du, 1/dv
lin0, lin1deep-zoom linearization: center uv (hi + lo), Jacobian (§6.1)
lay2overlay: iso-line count, hatch threshold, hatch on, strength
cam0..cam3ray-marched earth camera: eye + tan(fov/2), forward, right, up
sigsignificance stipple: on, dot pitch, radius, darkening
ovwannotation window rect
scldisplay-range remap: offset, 1/width, discrete levels
cmpcompare on, divider x (px)
flwparticle-trail window rect
Composite order
Composite order

§ 5

The composite, layer by layer#

5.1 Basemap and wrap-safe mip selection#

The basemap picks its mip level from uv derivatives. But uv.x jumps 0↔1 at the ±180° seam, which the polar and pseudocylindrical projections put in the middle of the screen. The symptom is a one-pixel column blasted to the smallest mip. The fix is to also measure the derivative of the half-rotated coordinate and take the smaller:

WGSL
let ux2 = fract(uv.x + 0.5);
let du = vec2<f32>(min(abs(dpdx(uv.x)), abs(dpdx(ux2))), dpdx(uv.y));
let dv = vec2<f32>(min(abs(dpdy(uv.x)), abs(dpdy(ux2))), dpdy(uv.y));
let lod = max(0.0, 0.5 * log2(max(dot(du, du), dot(dv, dv)) * 2048.0 * 2048.0));

The Niño-box outline uses the same trick for its line width.

Where the streamed detail window covers the view (§7.1), its color replaces the basemap's. Alpha-0 texels are tile-fetch holes and keep the global fallback. The land mask and every data path stay on the global textures, so the window is purely cosmetic sharpening.

5.2 The value path and the halo fix#

For imagery layers the field is the color, and uncovered pixels fall back to basemap × 0.35. For value layers:

WGSL
var val = s.r / max(s.a, 0.25);
var hasData = s.a >= 0.5;

Masked cells store value 0, so a plain bilinear fetch bleeds 0 into valid neighbors and rings every coast and cloud hole with a dark halo. But the sampled value is effectively premultiplied by the sampled mask, so dividing by alpha recovers the coverage-weighted mean of the valid neighbors.

5.3 Coastal flood fill#

The data grid (0.25–1°) is coarser than the land mask, so a fringe of ocean pixels has no data cell where the basemap already says "water". Shown as-is, that ring reads as dark water. The shader searches outward in 4 radii × 8 directions, doubling the v step because equirect texels are 2:1, and floods the nearest valid values to the coast:

WGSL
if (!hasData && landMask < 0.6 && u.pad.z < 0.5) {         // not for sparse feeds
  let tx = 1.0 / max(u.p1.w, 1.0);                          // one field texel
  for (var r = 1; r <= 4; r = r + 1) {
    for (var k = 0; k < 8; k = k + 1) {
      let ang = f32(k) * 0.785398163;
      let sn = textureSampleLevel(fieldTex, samp, uv + vec2(cos(ang) * f32(r) * tx, sin(ang) * 2.0 * f32(r) * tx), 0.0);
      if (sn.a >= 0.5) { sum = sum + sn.r / sn.a; cnt = cnt + 1.0; }
    }
    if (cnt > 0.0) { break; }                               // nearest ring wins
  }
}

Sparse feeds opt out, for the Coral Reef Watch reason in §2.5.

5.4 Scale, colormap, contours, over-land shading#

In order:

  1. Sparse test on the raw encoding: val < 0.02 means "nothing here" and renders as background.
  2. Display-range remap val = (val − scl.x) · scl.y. Values are byte-encoded over the layer's range at fetch time, so narrowing the scale is a shift-and-stretch here, not a refetch. It runs after the sparse test, because remapping first would move the "nothing here" floor and flood empty ocean with color.
  3. Discrete levels (?bands=5|8|10|16) quantize to band centers, so a reader can count steps instead of estimating a gradient.
  4. LUT lookup, clamped to [0.002, 0.998]. The shared sampler wraps in u for longitude, so a value of exactly 1.0 would otherwise blend the LUT's two ends (yellow + purple = tan).
  5. Iso-contours (?iso, 14 bands), anti-aliased by fwidth and faded in the flood-filled ring:
    WGSL
    let ph   = val * u.p1.x;
    let aa   = max(fwidth(ph), 1e-4) * 1.5;
    let line = 1.0 - smoothstep(0.0, aa, min(fract(ph), 1.0 - fract(ph)));
  6. No-coverage tone: ocean with no data is a clean deep blue (0.02, 0.05, 0.10) rather than the basemap's speckly water.
  7. Over-land shading for overLand layers. The data color is modulated by the basemap's luminance over land (terrain shows through, like a tinted shaded-relief map) and blended toward the basemap by the ⚙ opacity slider. The coastline is etched with landMask·(1−landMask)·4, which peaks exactly at the land/sea edge. Sea-only layers simply show the basemap on land.

For an analysis result, "does this cover land?" comes from the display sink, which propagates it from its source layers. Assuming yes painted water-only fields like chlorophyll across the continents, because every coastal cell of the 1° analysis grid overlaps a lot of shoreline.

The color-scale controls live in ⚙:

The fit needs no sort. A 256-bin histogram over the stored bytes is the exact distribution, because the values are bytes. The flip side is a precision floor: narrow the window far enough and only a few of the 256 steps remain, so the UI warns ≈N steps — banding below 24 rather than letting a smooth gradient turn into stripes unexplained. Scale, bands and colormap all travel in share links (vmin, vmax, bands, cmap), because the same colors mean different numbers once the range moves. The colormap tooltip warns off Turbo, since its lightness is not monotonic and it collapses under red-green color-vision deficiency.

5.5 Significance stipple#

An analysis result whose per-cell test failed keeps its value — the estimate exists, the evidence for it is weak — and gets a dot screen instead. Blanking it would be indistinguishable from missing data and would throw the estimate away. Desaturating it would be worse on the diverging colormaps these results use: washing a cell toward the pale midpoint reads as "near zero", which is a claim about the value, not about the confidence in it.

WGSL
let flagged = smoothstep(0.35, 0.65, s.b / max(s.a, 0.25));     // blue channel = "failed"
let cell = fract(fp / max(u.sig.y, 2.0)) - 0.5;                 // 7 px pitch, SCREEN space
let dots = 1.0 - smoothstep(u.sig.z * 0.75, u.sig.z, length(cell));
shaded = mix(shaded, shaded * (1.0 - u.sig.w), dots * flagged);

Dots are phased in screen space so their spacing stays constant while zooming. The legend's note line carries the derivation caveat (which baseline, which significance filter), so it survives into a screenshot.

5.6 Two layers at once: the overlay layer#

A colorbar can only carry one quantity, so a second layer (?over=, or the "+ overlay…" picker) takes the channels that are left:

Which of the two it uses is derived from the layer's own metadata, not from the pair:

Any layer can therefore go over any other with no per-pair table. Thresholds are authored in physical units and normalized with the same curve the field was encoded on (normalizeValue, log-aware).

WGSL
if (u.lay2.x > 0.5) {                                    // uniform test: fwidth is legal here
  let o  = textureSampleLevel(fieldTex2, samp, uv, 0.0);
  let ov = o.r / max(o.a, 0.25);
  let ph = ov * u.lay2.x;
  var line = 1.0 - smoothstep(0.0, max(fwidth(ph), 1e-4) * 1.5, min(fract(ph), 1.0 - fract(ph)));
  line = line * step(0.5, round(ph));                    // no contour at value 0
  let hatch = select(0.0, smoothstep(0.5, 0.95, abs(sin((fp.x + fp.y) * 0.16))) * 0.42,
                     u.lay2.z > 0.5 && ov >= u.lay2.y);
  let solid = select(0.0, 1.0, o.a >= 0.85);             // no spurious rings at coasts
  col = mix(col, tint, clamp(line * 0.95 + hatch, 0.0, 1.0) * u.lay2.w * solid);
}

Correctness details:

The analysis language reaches the same compositor: display takes an optional over input. The result becomes a synthetic BaseLayer (key: 'analysis-over') and goes through exactly the path a picked overlay does — uniforms, legend strip, date pairing — instead of a fork.

5.7 Flow, radar and day/night#

Particle trails are additive: currents are masked to ocean, wind is everywhere (§8). Radar alpha-blends at 85%.

Day/night darkens toward the real terminator. The subsolar point comes from a ~20-line mean-elements solar ephemeris (sun.ts, good to about 0.01°). Per pixel it takes the cosine of the solar zenith, applies a ±7° twilight band, and fades city lights in at night:

WGSL
let cosZ = sin(latF) * sin(u.rot.w) + cos(latF) * cos(u.rot.w) * cos(lonF - u.rot.z);
let day  = smoothstep(-0.12, 0.12, cosZ);
col = col * mix(0.2, 1.0, day) + nightLights * (1.0 - day) * 1.15;

The sun is evaluated at the map's current date but at a fixed UTC hour (the ⚙ slider, ?sunh=). Otherwise time-lapse playback sweeps months per second and the spinning hour angle strobes the terminator. With the hour held, only the seasonal tilt and the ±4°/yr equation-of-time wobble move it. The night map (1×1 black until first use) loads lazily.

5.8 Annotations and the Niño box#

The rasterized overlays are composited last, in a deliberate order:

  1. Analysis arrows.
  2. Borders and cities: the reference frame goes under events, so a fire perimeter is never cut by a state line drawn on top of it.
  3. Animal tracks.
  4. Cyclones and fires: during a live event this is what is being looked at, and an official graphic that anything can hide is worse than useless.
  5. Drawn geometry: last, because it is what the user is actively working with.

All five are straight-alpha equirect rasters sampled through the annotation window (§9.1). With the ENSO panel open, the Niño 3.4 box (5°S–5°N, 170°W–120°W) is outlined with screen-width lines in uv space, so it tracks the globe as it rotates.


§ 6

Four ways to put the Earth on screen#

View modes
View modes

Because the display is factored as mapColor(uv), three of the four modes need only an inverse: screen pixel → uv. The fourth is a whole second renderer.

6.1 Flat projections#

Projection pipeline
Projection pipeline

Pan and zoom live in a normalized map frame ([0,1]² spanning the projection's world bounds), so their math is identical for every projection, and the projection appears in exactly one step. The pipeline:

  1. Letterbox the pixel to the projection's aspect.
  2. Apply view.xy + (fuv − 0.5)/zoom.
  3. Scale to the projection plane.
  4. Call invProject.
  5. Convert lon/lat to uv.

Each projection is a branch in invProject (WGSL), with CPU twins in geo_projections.ts for picking and view clamping. Those twins are round-trip tested against independently written textbook forward projections at seven points each, plus corner extents, out-of-domain rejection and polar orientation.

WGSL
let vuv   = u.view.xy + (fuv - vec2(0.5)) / u.view.z;
let plane = vec2((vuv.x*2.0 - 1.0) * ext.x, (1.0 - vuv.y*2.0) * ext.y);
let ll    = invProject(mode, plane);                                // lon, lat, valid
uv = vec2(fract(ll.x / (2.0*PI) + 0.5), clamp(0.5 - ll.y / PI, 0.0, 1.0));
ModeHalf-extentsInverse
Equirect (default flat)π × π/2the identity: the plane is (lon, lat). Wraps while panning
Mercatorπ × πlat = 2·atan(exp(y)) − π/2, valid to ±85.05°. Wraps. The tooltip warns that polar areas inflate
Mollweide2√2 × √2θ = asin(y/√2), sin φ = (2θ + sin 2θ)/π, λ = πx/(2√2 cos θ). Pixels outside the ellipse are invalid, and the poles are single points on the rim
Equal Earth2.7066 × 1.3180Newton-iterates θ from the 9th-degree y polynomial (4 fixed iterations), then λ through the derivative
Arctic / Antarctic2 × 2polar stereographic to the equator: lat = ±(π/2 − 2·atan(ρ/2)), lon = atan2(x, ∓y). Greenwich points down on the north view and up on the south, with 90°E on the right in both. Made for sea ice
WGSL
var t = p.y / 1.340264;                         // Equal Earth: initial guess y ≈ A1·θ
for (var i = 0; i < 4; i = i + 1) {
  let t2 = t * t;
  let f  = t*(1.340264 + t2*(-0.081106 + t2*t2*(0.000893 + 0.003796*t2))) - p.y;
  let fp = 1.340264 + t2*(-0.243318 + t2*t2*(0.006251 + 0.034164*t2));
  t = t - f / fp;
}

A war story lives in that Horner factoring: nesting t⁶ where t⁴ belongs compiles fine and looks right. The round-trip test caught it, twice.

Cylindrical modes wrap viewCx mod 1 while panning; bounded ones clamp. At zoom 1 the whole world letterboxes, and a click in the side bars is not a place.

Deep zoom. Flat modes zoom to 131,072× — about 400 m across the screen, as deep as the aerial tiles go. Past about 2048× the per-pixel uv step falls below f32's ulp on the absolute transform, and the map would dissolve into blocks. So above LINEAR_ZOOM = 2048 the CPU takes over the conditioning:

Every projection is locally affine at that scale, so each pixel walks a well-conditioned local linear model:

WGSL
let uvRel = u.lin0.zw + u.lin1.xy * duv.x + u.lin1.zw * duv.y;      // lo + J·Δ   (small terms)
uv  = vec2(fract(u.lin0.x + uvRel.x), clamp(u.lin0.y + uvRel.y, 0.0, 1.0));
wuv = (u.lin0.xy - u.win0.xy + uvRel) * u.win0.zw;                   // exact: nearby f32 values

The CPU fills these uniforms from slightly below the threshold (0.999×), so f32 rounding of the zoom uniform can never land the shader in the deep branch while they are still zero. A zero Jacobian — the view center is off the projection's shape — shows background rather than a smeared world origin.

6.2 The globe (the default)#

An orthographic sphere is rotated by yaw and tilt, and the wheel zooms it from 0.3× (a small sphere with space around it) to 2048×. With relief on, the surface is the ETOPO heightfield sphere-traced through a thin shell, so mountain ranges get true limb silhouettes and parallax as the globe turns:

The ETOPO texture's two packed bytes force manual bilinear filtering. Hardware filtering would blend the high and low bytes independently and ripple at every low-byte wrap, so topoMeters() does four textureLoads, decodes each, then mixes:

WGSL
fn decodeTopo(c : vec4<f32>) -> f32 {
  return TOPO_MIN_M + ((c.r * 65280.0 + c.g * 255.0) / 65535.0) * TOPO_SPAN_M;
}

Relief is 25× exaggerated at overview zoom and fades out between zoom 16 and 32. Past that the march terraces up close, and 0.25° ETOPO has no detail to offer anyway, so the streamed detail window carries the look instead. A post-pass adds a subtle slope hillshade on land, lit from the upper left like a physical relief globe under room light. It uses only textureLoad, which is safe in non-uniform flow.

The globe is otherwise flat-shaded: it doubles as a preview for a physical, emissive LED sphere display, which is also why ?nogui and ?spin exist.

Wheel zoom anchors at the cursor. It restores the lon/lat under the pointer by nudging yaw and tilt, which is exact at the sphere center and converges over successive steps at the limb.

6.3 Earth, ray-marched (kept for comparison)#

?proj=earth puts a perspective orbit camera over the same heightfield. The camera has a target lon/lat, tilt off the vertical (0 → 1.45 rad), heading and altitude in radii. That keeps "what am I looking at" separate from "from where", which is what makes dragging feel like flying rather than spinning.

earthCamera() builds the eye and a roll-free basis on the CPU, because the basis is uniform across the frame and in f32 the difference between "computed once" and "computed per pixel" shows up as shimmer. A general ray needs a general intersection, so the shader:

  1. solves ray/sphere analytically for the outer shell and the sea-level sphere;
  2. marches only the span between them — 64 steps plus 6 bisections;
  3. stops at the sea surface when nothing is hit.

It works, and it buys oblique views, a horizon and parallax. But at low altitude 0.25° ETOPO melts into plateaus, a data-resolution limit no camera work fixes. So the mode is hidden from the picker (the option exists, hidden, so ?proj=earth still selects it) and survives as a one-URL comparison against the mode that replaced it.

6.4 Earth, tiled mesh — a second renderer on the same canvas#

"🏔 Earth (tiled mesh)" (?proj=mesh) abandons the one-global-heightfield approach and streams real detail per view. It builds a full Engine — deferred preset with TAA and ACES — driving a GeoScene:

TypeScript
const engine = await Engine.create({
  canvas,
  context: ctx,              // share the explorer's device AND swap chain — no second GPUDevice
  renderPreset: deferredPreset({ transparent: false, taa: true, aces: true }),
});
const frame = GeoFrame.atLonLat(startLon, startLat, startAlt);   // origin AT the camera
const scene = new GeoScene(ctx.device, frame, { maxConcurrent: 24 });
terrain = meshUseIon
  ? scene.addTerrain(await resolveIonAsset(ION_ASSETS.worldTerrain, token), { imagery }).tileset
  : scene.addRasterDemTerrain(AWS_TERRARIUM, { imagery }).tileset;
terrain.maxSSE = 28;
scene.setBudget(384 * 1024 * 1024, 6_000_000);
scene.attach(engine, { cameraGO });   // GeoFeature + floating-origin reanchor (10 km)

One loop at a time. Two rAF loops on one canvas would fight, and acquiring the same canvas texture twice in one frame is the failure mode. In mesh mode the explorer's frame():

Elevation source. AWS Terrarium is the no-account default: Web-Mercator PNG tiles to z15. Cesium ion World Terrain is quantized-mesh on a geographic scheme. It reaches the poles (Mercator stops at ±85.05°, which is the hole at the top of the globe) and carries a per-tile watermask. Switching source or token tears the engine down and rebuilds it, carrying the camera pose across. Swapping the DEM is a statement about data, not location, and dropping back to the start point would make the two impossible to compare.

The drape. Terrain tiles use an ordinary PBR material whose albedo is the imagery drape. The data layer is composited into that imagery before the tile is built, so the terrain shader needs no change. GeoTerrainOptions.imagery accepts a drape source, and the explorer supplies one that loads Esri World Imagery for the tile's Mercator block (loadImageryWithFallback, stepping down two zoom levels up to four times on failure) and runs it through DrapeCompositor. That is a fullscreen pass over a target the size of the tile texture:

The flight panel's slider runs from photo (0) to data alone (1). Cross-fading is how you tell "the sea really is that warm here" from "that is just what the water looks like".

Because the drape is baked into each tile, repainting means re-streaming: clearCache() throws away every resident tile. So refreshMeshDrape() is careful:

Sky. An AtmosphereFeature (sky-view LUT plus the altitude-horizon limb mapping) goes before deferred lighting. Appending it with addFeature() puts it after tonemapping, where it contributes nothing, which is what happened on the first attempt. A CurvedHorizonStarsFeature with the real Yale BSC5 catalog baked at 4096 wide goes before the tonemap. At the default 2048, a texel is about four screen pixels and every star arrives as a soft blob.

CurvedHorizonSky rebases the dome, the aerial-perspective fog and the starfield onto the real ellipsoid. It is fed each frame with:

Stars fade in with altitude and night, and aerial fog fades out between 50 and 150 km.

The camera envelope. The mesh camera is the engine's CameraController in fly mode, and its speed is derived from altitude. That makes the pose a feedback loop, and the per-frame hook bounds it at the state, not at each input:

Place jumps. The flight panel's place list (Santa Barbara, Great Barrier Reef, Galápagos, Florida Keys, Cape Hatteras, Red Sea) reloads the page with mlon/mlat/malt/myaw. A jump across the globe needs a new tangent frame, not a reanchor: GeoFrame.reanchor moves the origin but keeps the basis, so "up" would still point at California from the Coral Sea.

Picking. A click casts the camera ray at the WGS84 ellipsoid (rayEllipsoidIntersect), not the streamed mesh. A mesh raycast would return null wherever tiles have not loaded, and the answer reads a data grid whose cells are tens of kilometers across, so the few hundred meters of relief is far inside one cell. The status line reports resident tiles, bytes, position and altitude straight from cacheStats(), because a terrain that is still filling in looks identical to one that has failed.


§ 7

Detail where you are looking#

7.1 The streamed detail window#

The global basemap is 4096 px wide, about 10 km per texel. Zoomed in, the explorer streams an equirect crop of the visible rect from imaging tiles (TileWindowStreamer). chooseDetailSource sizes the window from the view:

The driver detailTick requests only after the view key has been stable for 250 ms, so a zoom gesture does not spray requests for every intermediate level. The rect comes from visibleUvRect:

The old window stays up until the new one is assembled, so there is never a flash back to the basemap. Attribution for whichever provider is on screen is shown at bottom right.

7.2 The annotation window#

Compositing annotations by uv keeps one rasterization correct in every projection and on the globe. But a world-sized canvas spends its texels uniformly across the planet: zoomed into a bay, a 2048-wide raster leaves about 6 texels per degree, so a 3-px marker arrives as a 60-px blob. So the rasters cover only what is visible, at the same texel budget (2048 wide, with height following the canvas aspect so texels are square on screen):

WGSL
fn overlaySample(t : texture_2d<f32>, uv : vec2<f32>) -> vec4<f32> {
  let q = vec2<f32>(fract(uv.x - u.ovw.x) * u.ovw.z, (uv.y - u.ovw.y) * u.ovw.w);
  if (q.x < 0.0 || q.x > 1.0 || q.y < 0.0 || q.y > 1.0) {
    return vec4<f32>(0.0);          // the shared sampler WRAPS in u — never smear the edge
  }
  return textureSampleLevel(t, samp, q, 0.0);
}

The window maps onto roughly the whole canvas at every zoom, so a feature authored at N raster px stays about N screen px. That is why no marker radius or line width had to change when this stopped drawing the whole world.

overlayTick re-rasterizes after a 200 ms settle, and only when the window moved by more than 2% of its size. Below 1.5× zoom the world raster already has texels to spare, and keeping the whole world means panning never re-rasterizes at all. The fire and border overlays re-query rather than just redraw on a settle, since their data is viewport- and scale-scoped.

Getting lon/lat onto a window that may straddle the antimeridian is the subtle part, so every overlay shares one helper:

TypeScript
function projectPath(pts: LonLat[], TW: number, TH: number) {
  let prevU = (pts[0].lon + 180) / 360;
  const us = pts.map((p) => {
    const uu = (p.lon + 180) / 360;
    prevU = uu - Math.round(uu - prevU);        // continue the previous point's wrap copy
    return prevU;
  });
  const shift = Math.round(us[0] - (overlayWin.u0 + overlayWin.du / 2));   // once per PATH
  return pts.map((p, i) => ({ x: overlayX(us[i] - shift, TW), y: overlayY(p.lat, TH) }));
}

Unwrap once, then shift the whole path onto the window's copy of the world. Re-wrapping each vertex independently tears a line in half wherever it crosses the window edge — a bug that hides until someone pans to the Pacific. Independent points (analysis arrows) instead take the wrap copy nearest the window, since there is no path to tear. strokeCased (a dark casing under the color), drawLabel (white on a dark stroke) and makeLabelPlacer (greedy screen-space collision, fed in priority order) are shared the same way.


§ 8

Currents and wind flow#

Flow overlay
Flow overlay

The moving streamlines are FlowOverlay, one GPU particle system per vector layer:

ParticlesSpeed scaleLifeFadeTint
Currents9,0000.058 s0.02blue
Wind16,0000.0114 s0.03pale

Particles live in equirect uv space, and that one choice does most of the work. The trail texture is sampled by mapColor(uv) like any other layer, so the flow composites identically on the flat map, in every projection and on the rotating globe, with no screen-space bookkeeping. (The mesh mode does not show flow.)

Advection is a compute pass (workgroup 64). Each particle is a vec4: xy = uv, z = age, w = respawn seed. Velocity is sampled from both dated frames bracketing the timeline and mixed, so the flow evolves smoothly through the time-lapse. The step applies the one bit of map geometry that matters: a degree of longitude shrinks by cos(latitude), so east-west motion is divided by it, with a 0.2 floor that caps the stretch near 78°:

WGSL
let s    = mix(textureSampleLevel(fieldA, fsamp, uv, 0.0),
               textureSampleLevel(fieldB, fsamp, uv, 0.0), au.blend);
let vel  = (s.rg - vec2(0.5)) * 2.0 * au.uMax;                 // m/s
let cosl = max(cos(radians(lat)), 0.2);
let step = vec2(vel.x / cosl, -vel.y) * au.speedScale * au.dt;

Respawn. A particle dies when it ages out, drifts onto a masked cell (land), stalls below 0.02 m/s, or leaves the window. It respawns at a hashed random position with a staggered initial age, so respawns never pulse in visible waves. dt is clamped to 50 ms on the CPU.

Windowing. Particles live in a window of uv space, the same idea as the annotation rasters: the whole world zoomed out, the visible rect zoomed in. A fixed world-space density fails at zoom in two ways that share one cause:

Advecting and stamping in window-local uv puts every texel and every particle where the user is looking. Particles convert to world uv only to sample the field, and the whole-world case still wraps in u, or the antimeridian would wear a bald seam.

The speed subtlety. Dividing the world step by the window span is geographically exact and unusable. The same 20 m/s crosses a 4°-wide window about 90× faster than it crosses the globe, and 90× the speed is also 90× the trail length, so the view fills with white. Multiplying back by the span holds the apparent speed constant instead: trail length, ink density and motion look the same at every zoom, and only the detail changes. It is a scalar, so direction is untouched, and trail length still ranks fast against slow within any one view.

flowTick re-windows after a 350 ms settle. That is longer than the other drivers because a window change clears the trail texture — old trails are in the wrong frame of reference — and re-windowing every frame of a drag would leave the flow permanently blank.

Trails accumulate in an rgba8 texture via two draws per frame:

  1. A fullscreen fade whose fragment returns vec4(0, 0, 0, rate) with zero / one-minus-src-alpha blending, so dst ×= (1 − rate). That exponential decay turns earlier positions into a comet tail.
  2. The particles as instanced soft dots with additive blending, brightness driven by the speed channel and hue by the overlay's tint. They are kept dim so trails read as thin streamlines rather than speckle.

The shader samples the trails through flowSample, which returns zero outside the window. Additive light leaking from a wrapped edge would be far more visible than a stray annotation.

Which frames? The same clock that drives the base layer picks each overlay's bracketing frames by date (syncFlowToDate). The stacks start in different years, so pairing by index would drift; outside a stack's coverage it clamps to the nearest frame. Wind streams lazily on first toggle, nearest-now first. GFS is a forecast model, so (last) would be the +7-day edge, not current conditions. The flow starts moving as soon as one frame lands, and the dated history backfills through the pool.


§ 9

Vector overlays in one raster#

Tracks, storms, fires, borders and cities, drawn shapes and analysis arrows share a pattern:

  1. Keep the data (paths, polygons, points) in memory.
  2. On any change — or when the annotation window moves — rasterize into an OffscreenCanvas sized by overlayRasterSize().
  3. Upload with Texture.fromBitmap(transferToImageBitmap()).
  4. Destroy the previous texture and bump that overlay's generation counter.

Nothing projects forward into any map projection. Every overlay is correct in all six flat modes and on both spheres because the only projection anywhere is the inverse that turns a pixel into uv.

The analysis extrema markers (annotate sink) are the one exception. They are HTML pins positioned each frame through the forward projection on the flat map, and hidden on the globe.


§ 10

Reading the numbers#

10.1 Hover and long-press#

A color map answers "where"; a number answers "how much". Hover shows the base value, the overlay value if any, and lat/lon in a box that flips sides near the window edge. "— no data" is a real answer (land, ice, a cloud gap), and saying so beats an empty tooltip that reads as broken. Readouts are coalesced to one sample per animation frame, since pointermove can outpace the display several times over.

The readout has its own listener rather than a branch in the pan handler, because that handler returns early down every drag path. Values come from GriddedField.sample(lon, lat), a nearest-cell lookup on the retained bytes.

10.2 Picks and point series#

pickLonLat routes a click through the active mode's CPU inverse:

ModeInverse
FlatflatProj.inverse after the letterbox and pan transform
GlobeuvAtGlobeScreen, the analytic sphere
EarthearthPick, the analytic sphere
MeshmeshPick, the ellipsoid

The globe and earth picks intersect the smooth sphere rather than re-marching the relief. A pick asks a geographic question, and the parallax difference is far smaller than one data cell. A pick further than 4 px of drag from its press is a pan, not a pick.

The point panel plots the absolute frames at that point, whatever view is displayed, filtered by season. It reports min, mean, max and a least-squares trend per decade. Trends are relative quantities, so °F scales by 9/5 without the offset. A marker (white ring around a dot) is rasterized into the geometry texture so it lands correctly in every mode. The panel's title bar drags it, a corner grip resizes the chart (remembered in localStorage), and CSV downloads exactly the plotted values.

10.3 The by-year view#

A long record drawn end to end buries the question people usually bring to it — "is this year unusual for the time of year?" — under the seasonal cycle. by year folds the series onto Jan→Dec:

A note ranks the newest month against the same month in every year, in the form " (mean of N days so far): — Kth highest of M years". It ranks per-year means, not raw samples, because the live tail is daily while the baked record is one sample per month, and counting samples would let one September's 26 days outvote 45 years. When every sample is a 1st-of-month, the note says so: the baked atlas holds one daily field per month, not monthly means, and a spike on the 1st must not read as a month-long event.

10.4 Drawn lines and areas#

The ╱ and ⬟ draw modes (?draw=line|area) turn clicks into vertices, and a shape measures itself on every vertex. An explicit "finish" gesture is a terrible thing to require, because nothing on screen can tell you it exists. Below the minimum vertex count, the panel says what to do next. The panel's toolbar has ✓ finish, ↶ undo and ✕ clear, since a phone has no Enter, Esc or double-click (on the desktop those still work).

The drawing is rasterized into the geometry texture. Legs are densified along their great circles, with a step that shrinks with the window (max(2, 100·du) km), because a straight line in equirect pixels is not a straight line on the Earth, and at high latitude the two diverge badly.

An area doubles as an analysis region: the panel's region button copies the ring as the points parameter, and ?shape= shares it. A shared shape restores immediately, and its readout fills in when frames arrive.

10.5 CSV provenance#

Every readout exports to CSV, and the file carries a # header:

A CSV outlives the page that produced it, and one that has lost track of which product and which units it holds still looks authoritative, which is worse than having none.


§ 11

Explaining, reproducing, importing#

11.1 The layer-info panel (ⓘ, I, ?info)#

A colorbar says what the colors map to; it does not say whether 8 is alarming or ordinary. Every layer carries an entry in geo_layer_info.ts answering the same questions in the same order:

  1. What the quantity is and how it was measured.
  2. What this scale's range means, including any operational threshold.
  3. What change over time means.
  4. Whether the record is long enough to show a trend.
  5. Which other layers explain the pattern.

The last one renders as clickable chips that switch layers, routed through the same switchLayer as the picker so the <select> follows.

The panel also shows the live state: the legend title and ticks, the source, the coverage floor, the view note, the overlay (how it is drawn and which frame it paired to, or that none did), caveats, and the citation. The content rules keep it honest:

11.2 Get this data (⚙ → data, ?data)#

The reproduce panel rebuilds the exact griddap request behind the frame on screen, using reproduce.ts. Its query builder repeats loadScalar's byte for byte. It offers:

The resolution is read off the grid that came back, not derived from metadata, because strideScale is a stride multiplier and these products' native grids disagree. Baked layers and analysis results say plainly that there is no live query to hand over, rather than produce a URL that would fail from a browser.

11.3 Your data (⚙ → your data, drag-and-drop, ?region=, ?stations=)#

Data deeplinks wait for frames through whenFramesReady, with an 8 s deadline, after which they run anyway and report the real problem. Acting early produces something that looks like a failure (a match-up with no frames) or something plausible but wrong (a request naming (last)).


§ 12

Asking questions: the analysis graph#

The ⚗ Analysis button opens the other half of the sample: a typed dataflow language over the same data. It has 26 ops (layer, enso, region, forecast, mask, filter, derive, isoline, anomaly, lag, selectFrames, areaMean, timeReduce, trend, math, correlate, correlateSeries, regress, display, chart, answer, scatter, histogram, hovmoller, annotate, displayVectors), one interpreter, and three front ends:

The explorer's side of the contract is small:

Programs round-trip as JSON: ?prog= links, saved presets, ?analysis=run|ask|graph. The full story is in the Analysis module guide and the tutorials.


§ 13

The ENSO panel#

⚙ → climate → ENSO (?enso) opens a CPC-style ONI bar chart, 1981 → now:


§ 14

Input, touch and small screens#

The map runs on a phone, and every touch stand-in keys off the event's own pointerType, so the mouse paths are unchanged and a hybrid machine gets both:

GestureMouseTouch
Pan / rotatedragone-finger drag
Zoomwheel (anchored at cursor), +/−/⟲ buttonspinch (anchored at the midpoint)
Aim (earth mode)shift-dragtwo-finger drag of the midpoint
Value readouthoverlong-press (450 ms), pinned until the next touch
Zoom in / close a shapedouble-clickdouble-tap (320 ms, 40 px)
Compare seamfollows the cursordrag it as a handle (44 px grab zone)
Mesh flightdrag look · WASD · E/Q · Alt fasterjoystick (left) · look-drag (right half) · ▲/▼ hold buttons

Details that matter:


§ 15

🔗 Share writes only what differs from the defaults, and every one of these is also accepted on load:

ParamEffect
layer, overbase layer and overlay layer keys
view, dyr, seasonanalysis view, Δ reference years (1–5), month filter (djf, jja, 1..12 …)
date, cmpjump to the nearest frame; pin a compare date
projglobe (default), map, mercator, mollweide, equalearth, arctic, antarctic, mesh, earth (hidden). Legacy ?globe accepted
z, cx, cyflat zoom and view center (map-frame units)
clon, clat, calt, ctilt, chdgray-marched earth camera
mlon, mlat, malt, myaw, mpitch, drape, ion=1mesh-mode start pose, drape opacity, Cesium ion terrain
resfetch stride: 1 native (default), 2 = 0.5°, 4 = 1°
stepcadence in months: 4, 2, 1, or sub-monthly 0.5, 0.25, 0.033 (default, daily; newest 120 frames)
aggGFS daily sampling: max, min, mean (default 12:00 UTC snapshot)
vmin, vmax, bands, cmapcolor-scale override
iso, unit=c, alpha, speedcontours, Celsius, over-land opacity, playback speed
currents, wind, radar, storms, fireoverlays on
borders=0, cities=0reference overlays off (on by default)
sun, sunhday/night terminator and its fixed UTC hour
ensoENSO panel
species, obisgrid, tracks, yearOBIS taxon and grid precision, ATN taxon, GFW year
draw, shapedraw mode; a drawn ring as "lon,lat …"
region, stationsimport a GeoJSON region / station CSV from a URL
play, spin, byyear, menustart playing, spin the globe, fold point charts by year, open ⚙
info, dataopen the layer-info panel / the reproduce panel
analysis, progopen the analysis panel (=run, =ask, =graph); load and run an encoded program
noguidisplay mode: no controls (kiosks, the physical-globe preview)

§ 16

Things that bit, and what they taught#

A few lessons recur across the file, and they generalize: