An interactive 3D globe that makes the hidden resource burdens behind AI infrastructure visible — the mines, grids, rivers, fuel chains, and communities underneath the cloud.
Every cloud has a mine, a grid, a river, and a community underneath it.
⚠️ PROTOTYPE — ALL DATA IS SYNTHETIC MOCK. Every number shipped in this repo is a deterministic placeholder generated byscripts/generate-mock-data.mjs. Values are illustrative only and must not be cited as measurements. The schema is built so real public datasets drop in without code changes — see How real data replaces this.
npm install # deps
npm run vendor:geo # copy Natural Earth geometry into public/geo (also run by gen:data)
npm run gen:data # (re)generate the synthetic datasets into public/data
npm run dev # http://localhost:5173Build for static hosting:
npm run build # validates data → typechecks → bundles to dist/
npm run preview # serve the production buildnpm run build runs scripts/validate-data.mjs first; the build fails on any
dangling reference, bad dataType, or out-of-range normalized value.
Requirements: Node 20+, npm. No backend, no API keys, no database.
Four composable visualization modes over the same data (switch via the top tabs; fine-tune with the left-rail layer toggles):
| Mode | What you see |
|---|---|
| Heatmap | Flat globe coloured by the selected metric (choropleth). |
| Terrain | Moral topography — the globe surface rises and falls with the metric. The hero feature. |
| Spikes | Facilities (mines / refineries / data centres / power plants) as towers, height ∝ capacity. |
| Flows | Resource & value arcs (extraction → processing → AI infra → value capture, plus fuel routes) with travelling pulses. |
Click any country, facility, or flow to open the inspector: raw value,
normalized value, the exact terrain height it produces, source + real-world
equivalent, year, confidence, and a colour-coded data-type badge
(measured / estimated / modeled / speculative / mock). The
burden × benefit scatter (Analysis) plots the resource-injustice quadrant.
| Concentrated metric (extraction) | Burden × benefit scatter |
|---|---|
![]() |
![]() |
Stack: Vite + React 19 + TypeScript · @react-three/fiber + drei + raw
three · zustand (state) · d3-geo / d3-scale · topojson-client +
world-atlas. No charting framework, no backend, no vendor lock-in.
Why custom R3F layers (not three-globe): three-globe extrudes per-country
polygon blocks; it cannot displace a single continuous surface. The moral-
topography hero needs a watertight displaced sphere, so every layer is a custom,
declarative R3F component. This also keeps the path from static JSON → pixels
fully transparent and extensible.
Data flow:
public/data/*.json ─┐
├─ loadData.ts ─→ joins (star schema) ─→ zustand store ─→ React UI + globe
public/geo/*.json ──┘ │
└─ countryRaster.ts ─→ equirectangular
country-ID image (built once)
The terrain technique (src/globe/useTerrainGeometry.ts + src/lib/countryRaster.ts):
- Draw every country polygon into an offscreen equirectangular canvas, each
filled with a colour encoding its index (
d3-geohandles winding, holes, the antimeridian). This is the country-ID raster —sample(lat,lon) → countryIdin O(1). - Build a subdivided icosphere (
IcosahedronGeometry, ~80–130k tris by device tier). Precompute once: for each vertex cache its unit direction + which country it falls in (one raster sample). - Recompute on metric / exaggeration / mode change: pure typed-array writes —
position = direction · radius,colour = ramp(normalized)— then recompute normals. A few ms for ~160k vertices; not per-frame work. - Picking raycasts (accelerated by
three-mesh-bvhvia drei<Bvh>) → hit point → lat/lon → sample the same raster → country.
The feature array index is the country id everywhere (data + raster), and both the generator and the raster iterate the same TopoJSON in the same order — so the join can't drift.
CS notes. The data model is a star schema (dimensions
countries/sources; factskpis/facilities/flows). The raster lookup is an O(1) spatial hash that decouples geometry resolution from country count. State uses external-store subscription with selector equality (zustand) rather than Context's reference-identity propagation, so a slider drag re-renders only the terrain — not every consumer.
Static files under public/data/ (swappable / mirrorable). Types:
src/types/data.ts.
| File | Role |
|---|---|
countries.json |
Dimension. id = Natural Earth feature index (the join key); naturalEarthId, centroid, region. |
kpis.json |
Fact: one atomic observation per country × kpiType. Carries value, min/max, precomputed normalized, normalization, direction, dataType, sourceId, modeledOnSourceId. |
scores.json |
Derived composites (burden_score, local_benefit_score), kept separate from raw KPIs with an explicit formula + inputKpiTypes + weights so the opinionated parts are auditable. |
facilities.json |
Mines / refineries / data centres / power plants (lat/lon, capacity). |
flows.json |
Extraction → processing → infra → value-capture + fuel routes. |
sources.json |
The synthetic generator plus the real datasets each metric maps onto. |
stories.json |
Guided narratives over the flows (schema present; guided UI deferred to v0.2). |
manifest.json |
Self-describing dataset list (version, date, license) for mirrors. |
KPI categories: water stress · mineral extraction · mineral processing share · grid carbon intensity · AI/data-centre demand · HDI · Gini · (+ derived burden / local-benefit scores).
Documented and applied identically in code and legend:
normalized = (value − min) / (max − min) # precomputed per metric, in the data
peaks-only: heightOffset = normalized · MAX_DISPLACEMENT · exaggeration
peaks+valleys: heightOffset = (normalized · 2 − 1) · MAX_DISPLACEMENT · exaggeration
finalRadius = BASE_RADIUS + heightOffset
MAX_DISPLACEMENT and BASE_RADIUS live in src/lib/constants.ts. v0 normalizes
every metric linearly against its dataset range — for the concentrated metrics
(extraction, processing, compute) this makes a few places tower while the rest stay
flat, which is the concentration story. log and percentile strategies are
implemented (scripts/generate-mock-data.mjs / src/lib/loadData.ts) for future
smooth metrics. Peaks+valleys mode and high exaggeration show an in-UI warning that
exaggerated/signed 3D terrain can mislead — heights are normalized, not to scale.
Because every country is populated (the user chose full-world synthetic over a sparse curated set), honesty is enforced structurally:
- A persistent banner states all data is synthetic — never hidden.
- A
dataTypebadge on every datum (mock = dashed amber) — the most prominent honesty affordance, never buried. - The generator is deterministic and seeded — values are reproducible and auditable, not random per-frame bumps; the globe reads committed values.
- Curated story countries (DRC cobalt, Chile/Australia lithium, China processing,
Ireland/US/Nordic data centres, Gulf fuel, …) are
speculative(shaped to real patterns), not exact figures; everything else ismock. - Flows and stories are framed as attributed illustrations — no naming-and- blaming specific operators on invented numbers.
- Derived scores carry their
formula+weightsso the opinionated composite is inspectable, kept separate from raw observations.
Swap one public/data/*.json for a real export with the same shape, set each
datum's dataType (measured/estimated/…) and sourceId, and re-run
npm run validate:data. No code changes. Each KPI already records the dataset it's
designed to be replaced by (modeledOnSourceId):
| Field | Real-world source |
|---|---|
| water_stress | WRI Aqueduct |
| mineral_production / reserves | USGS Mineral Commodity Summaries, BGS |
| mineral_processing_share / trade flows | UN Comtrade / OEC |
| grid_carbon_intensity / energy mix | Our World in Data |
| datacentre_demand | IEA |
| HDI | UNDP HDR |
| Gini / poverty / GDP / resource rents | World Bank Open Data |
| facilities / power plants | Global Energy Monitor, Open Supply Hub |
| extraction-conflict cases | EJAtlas |
Keep raw (kpis.json), derived (scores.json), and geometry
(public/geo) conceptually separate. Verify any controversial figure against its
primary source before publishing; attribute every claim.
- 100% static —
npm run buildemits adist/you can serve from any static host, a CDN, or apython -m http.server. No backend, no API keys. - Data is plain JSON + public-domain TopoJSON — easy to download, mirror, diff,
and version.
manifest.jsonmakes a mirror self-describing. - IPFS-ready: the build is content-addressable as-is.
import.meta.env.BASE_URLis respected so it works under a subpath / IPFS gateway. (vite build --base ./for fully relative asset paths.) - Open formats throughout (JSON, TopoJSON); the schema also accommodates CSV / GeoJSON / PMTiles / Parquet pipelines feeding the same JSON contract.
public/
geo/countries-110m.json Natural Earth 110m (public domain; vendored)
data/*.json synthetic datasets (generated)
scripts/
generate-mock-data.mjs seeded full-world synthetic generator
validate-data.mjs referential-integrity + enum + range gate (build step)
vendor-geo.mjs copy geometry into public/geo
src/
types/data.ts schema (single source of truth)
state/store.ts zustand store (ui · data · selection)
lib/ geo math · country raster · ramps · loader · device tier
globe/ terrain hero · base/atmosphere/graticule · spikes · arcs · picking
components/ TopBar · Controls · Inspector · Legend · Ranking · Scatter
ui/primitives.tsx hand-rolled Toggle / Slider / Dropdown
| Script | Purpose |
|---|---|
npm run dev |
Vite dev server |
npm run build |
validate data → tsc -b → vite build |
npm run gen:data |
regenerate synthetic datasets (deterministic) |
npm run validate:data |
data integrity gate |
npm run vendor:geo |
copy Natural Earth geometry into public/geo |
npm run lint |
ESLint |
Code: Apache-2.0 (see LICENSE / NOTICE). Country geometry: Natural Earth,
public domain. Shipped data values: synthetic — see public/data/sources.json and
the table above for the real datasets they are designed to be replaced by.


