ghModel = {
const SIGMA = 5.670e-8; // Stefan–Boltzmann constant, W/m²/K⁴
const S0 = 1361; // sunlight reaching the top of the atmosphere, W/m²
const ALBEDO = 0.30; // share of sunlight reflected straight back to space
const ASR = S0 / 4 * (1 - ALBEDO); // sunlight absorbed, averaged over the globe (≈238 W/m²)
const T_PI = 273.15 + 14; // pre-industrial global mean surface temperature (≈14 °C)
const CO2_PI = 280, CH4_PI = 730; // 1750 concentrations: CO₂ in ppm, methane in ppb
const N2O_PI = 270; // nitrous oxide, held at its 1750 level, ppb
const ECS = 3.0; // warming per doubling of CO₂ once settled (IPCC AR6 best estimate)
const S_PI = SIGMA * T_PI ** 4; // infrared given off by a 14 °C surface (≈385 W/m²)
// One-layer atmosphere: it absorbs a share `eps` of the surface's infrared and re-emits it,
// half up and half down. Heat escaping to space is then (1 − eps/2) σT⁴.
const EPS_PI = 2 * (1 - ASR / S_PI); // pre-industrial share, chosen so the planet settles at 14 °C
const olr = (T, eps) => (1 - eps / 2) * SIGMA * T ** 4;
// Extra trapping from CO₂ and methane: simplified formulas from Myhre et al. (1998), in W/m²,
// including the overlap between the methane and nitrous oxide absorption bands
const ovl = (m, n) => 0.47 * Math.log(1 + 2.01e-5 * (m * n) ** 0.75 + 5.31e-15 * m * (m * n) ** 1.52);
const forcing = (co2, ch4) =>
5.35 * Math.log(co2 / CO2_PI) + 0.036 * (Math.sqrt(ch4) - Math.sqrt(CH4_PI))
- (ovl(ch4, N2O_PI) - ovl(CH4_PI, N2O_PI));
const clamp = (x, a, b) => Math.min(b, Math.max(a, x));
// s = {ghg: greenhouse gases present?, co2, ch4, feedback: climate feedbacks on?}
function epsilon(T, s, gamma) {
if (!s.ghg) return 0;
const e = EPS_PI + 2 * forcing(s.co2, s.ch4) / S_PI + (s.feedback ? gamma * (T - T_PI) : 0);
return clamp(e, 0, 0.99);
}
const imbalance = (T, s, gamma) => ASR - olr(T, epsilon(T, s, gamma));
function settle(s, gamma) { // temperature at which energy in = energy out
let lo = 180, hi = 360;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
if (imbalance(mid, s, gamma) > 0) lo = mid; else hi = mid;
}
return (lo + hi) / 2;
}
// Feedbacks (water vapour, ice, clouds) together: extra trapping per degree of warming,
// calibrated so that doubling CO₂ gives ECS degrees of warming once settled.
const doubled = {ghg: true, co2: 2 * CO2_PI, ch4: CH4_PI, feedback: true};
let gLo = 0, gHi = 0.016;
for (let i = 0; i < 60; i++) {
const g = (gLo + gHi) / 2;
if (settle(doubled, g) - T_PI < ECS) gLo = g; else gHi = g;
}
const GAMMA = (gLo + gHi) / 2;
return {
SIGMA, ASR, ALBEDO, T_PI, CO2_PI, CH4_PI, ECS, GAMMA, EPS_PI,
epsilon: (T, s) => epsilon(T, s, GAMMA),
imbalance: (T, s) => imbalance(T, s, GAMMA),
olr: (T, s) => olr(T, epsilon(T, s, GAMMA)),
settle: s => settle(s, GAMMA),
forcing
};
}The greenhouse effect, live
Watch sunlight warm the Earth and greenhouse gases trap the heat. Add CO₂ and methane, switch the climate feedbacks on and off, and see where the temperature settles.
What are greenhouse gases, and why does a tiny amount of CO₂ change the temperature of a whole planet? Follow the guided tour, then take the controls.
ghSim = {
const M = ghModel;
const C2K = 273.15;
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---------- Scenarios (presets) ----------
// A preset sets the gas levels only; the temperature then moves to where it would finally settle.
// 2024 levels: WMO Greenhouse Gas Bulletin No. 21. 2100 CO₂: SSP2-4.5 and SSP5-8.5 (Meinshausen
// et al. 2020); methane is held at today's level in both.
const PRESETS = [
{id: "none", label: "No greenhouse gases", s: {ghg: false}},
{id: "1750", label: "1750 levels", s: {ghg: true, co2: 280, ch4: 730}},
{id: "1960", label: "1960 levels", s: {ghg: true, co2: 317, ch4: 1250}}, // TODO: source for the 1960 methane level (about 1,250 ppb, ice-core records)
{id: "today", label: "Today's levels (2024)", s: {ghg: true, co2: 424, ch4: 1940}},
{id: "ssp245",label: "2100 levels, middle road", s: {ghg: true, co2: 600, ch4: 1940}},
{id: "ssp585",label: "2100 levels, very high emissions", s: {ghg: true, co2: 1135, ch4: 1940}}
];
// ---------- Simulation state ----------
const state = {ghg: true, co2: 280, ch4: 730, feedback: true};
let T = M.T_PI; // surface temperature, K (moves towards the settled value)
let frozen = false; // tour steps 1–5 hold the temperature still
let sayResult = false, saidEq = ""; // announce where the temperature settles, only after the visitor changes something
const HEAT_CAPACITY = 5; // sets how fast the animation warms (real oceans take decades)
const fmt1 = x => (Math.round(x * 10) / 10).toFixed(1).replace("-", "−");
const signed = x => (x >= 0.05 ? "+" : x <= -0.05 ? "−" : "±") + Math.abs(Math.round(x * 10) / 10).toFixed(1);
const toC = K => K - C2K;
// numbers quoted in the tour, computed from the model so text and simulation agree
const tBare = toC(M.settle({ghg: false}));
const dDoubleNoFb = M.settle({ghg: true, co2: 560, ch4: 730, feedback: false}) - M.T_PI;
const dDoubleFb = M.settle({ghg: true, co2: 560, ch4: 730, feedback: true}) - M.T_PI;
const dToday = M.settle({ghg: true, co2: 424, ch4: 1940, feedback: true}) - M.T_PI;
// ---------- DOM ----------
const el = (tag, attrs = {}, ...kids) => {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") n.className = v;
else if (k === "text") n.textContent = v;
else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
else n.setAttribute(k, v);
}
for (const kid of kids) if (kid != null) n.append(kid);
return n;
};
const setText = (n, s) => { if (n.textContent !== s) n.textContent = s; };
const root = el("div", {class: "ghsim"});
root.append(el("h2", {class: "visually-hidden", text: "Greenhouse effect simulation"}));
const tabTour = el("button", {class: "ghs-tab", type: "button", "aria-pressed": "true", text: "Guided tour"});
const tabExplore = el("button", {class: "ghs-tab", type: "button", "aria-pressed": "false", text: "Explore yourself"});
const legend = el("div", {class: "ghs-legend", "aria-hidden": "true"},
el("span", {}, el("i", {class: "sw"}), el("span", {}, "Sunlight", el("span", {class: "ghs-long", text: " (short waves)"}))),
el("span", {}, el("i", {class: "rf"}), el("span", {}, "Reflected", el("span", {class: "ghs-long", text: " sunlight"}))),
el("span", {}, el("i", {class: "ir"}), el("span", {}, "Earth's infrared heat", el("span", {class: "ghs-long", text: " (long waves)"}))),
el("span", {}, el("i", {class: "gh"}), "Greenhouse gas"),
el("span", {}, el("i", {class: "air"}), "N₂ / O₂"));
root.append(el("div", {class: "ghs-top"}, el("div", {class: "ghs-tabs"}, tabTour, tabExplore), legend));
const stageWrap = el("div", {class: "ghs-canvas-wrap"});
const canvas = el("canvas", {class: "ghs-canvas", role: "img", "aria-label":
"Animated diagram of the greenhouse effect. Short yellow waves are sunlight; some bounce off clouds and the ground, the rest warm the surface. The warm surface sends longer red infrared waves upwards. Greenhouse-gas molecules, drawn with a ring, catch some of it and warm the air, which sends infrared out in all directions, partly back down. Current values are in the panels below."});
const tooltip = el("div", {class: "ghs-tip", "aria-hidden": "true"});
stageWrap.append(canvas, tooltip);
// Freeze / animate: reduced-motion visitors start with a still picture
let animPaused = reduceMotion;
const btnAnim = el("button", {class: "ghs-chip ghs-anim", type: "button"});
const syncAnim = () => btnAnim.replaceChildren(el("span", {"aria-hidden": "true", text: animPaused ? "▶︎ " : "❚❚ "}), animPaused ? "Animate" : "Freeze");
btnAnim.addEventListener("click", () => { animPaused = !animPaused; if (animPaused) { auto = false; syncAuto(); preroll(); } syncAnim(); });
stageWrap.append(btnAnim); syncAnim();
root.append(stageWrap);
// Tour card
const stepLabel = el("div", {class: "ghs-step"});
const stepTitle = el("h3", {class: "ghs-step-title"});
const stepText = el("p", {class: "ghs-step-text"});
const stepActions = el("div", {class: "ghs-step-actions"});
const btnPrev = el("button", {class: "ghs-round", type: "button", "aria-label": "Previous step", text: "‹"});
const btnNext = el("button", {class: "ghs-round", type: "button", "aria-label": "Next step", text: "›"});
const btnAuto = el("button", {class: "ghs-auto", type: "button"});
const dots = el("div", {class: "ghs-dots", role: "group", "aria-label": "Tour steps"});
const timer = el("div", {class: "ghs-timer"}, el("span"));
const nav = el("div", {class: "ghs-nav"}, btnPrev, btnNext, dots, btnAuto);
const stepBody = el("div", {class: "ghs-step-body"}, el("div", {}, stepTitle, stepText, stepActions));
const tourCard = el("div", {class: "ghs-card ghs-tour"}, stepLabel, stepBody, nav, timer);
root.append(tourCard);
// phones: the buttons sit above the text, in the order they appear, so Tab follows what you see
const narrow = window.matchMedia("(max-width: 560px)");
const placeNav = () => {
const f = document.activeElement;
if (narrow.matches) { tourCard.prepend(nav, timer); nav.append(btnPrev, btnNext, btnAuto, dots); }
else { tourCard.append(nav, timer); nav.append(btnPrev, btnNext, dots, btnAuto); }
if (f && tourCard.contains(f) && document.activeElement !== f) f.focus();
};
narrow.addEventListener("change", placeNav); placeNav();
// Explore controls
const presetBox = el("div", {class: "ghs-presets", role: "group", "aria-label": "Scenarios"});
const presetBtns = PRESETS.map(p => {
const b = el("button", {class: "ghs-chip", type: "button", text: p.label});
b.addEventListener("click", () => applyPreset(p));
presetBox.append(b);
return b;
});
const co2Range = el("input", {type: "range", min: "0", max: "1000", step: "1", id: "ghs-co2"});
const co2Out = el("span", {class: "ghs-val", "aria-hidden": "true"});
const ch4Range = el("input", {type: "range", min: "0", max: "4000", step: "10", id: "ghs-ch4"});
const ch4Out = el("span", {class: "ghs-val", "aria-hidden": "true"});
const ghgToggle = el("input", {type: "checkbox", id: "ghs-ghg"});
const fbToggle = el("input", {type: "checkbox", id: "ghs-fb"});
const btnSkip = el("button", {class: "ghs-chip", type: "button", text: "Jump to the settled temperature"});
const exploreCard = el("div", {class: "ghs-card ghs-explore", hidden: ""},
el("div", {class: "ghs-ctl-title", text: "Scenarios"}), presetBox,
el("p", {class: "ghs-sub ghs-preset-note", text: "Each scenario sets the gas levels only. The temperature then moves to where Earth would finally settle if those levels stayed for centuries. The real world lags behind, mainly because the oceans warm slowly: about +1.3 °C so far, and IPCC best estimates for 2081–2100 of about +2.7 °C (middle road) and +4.4 °C (very high emissions), compared with the late 1800s. In the 2100 scenarios only CO₂ changes; methane stays at today's level."}),
el("div", {class: "ghs-sliders"},
el("label", {class: "ghs-slider", for: "ghs-co2"},
el("span", {class: "ghs-sl-name"}, "Carbon dioxide (CO₂) ", co2Out), co2Range,
el("span", {class: "ghs-sl-scale", "aria-hidden": "true"}, el("span", {text: "150 ppm"}), el("span", {text: "2,000 ppm"}))),
el("label", {class: "ghs-slider", for: "ghs-ch4"},
el("span", {class: "ghs-sl-name"}, "Methane (CH₄) ", ch4Out), ch4Range,
el("span", {class: "ghs-sl-scale", "aria-hidden": "true"}, el("span", {text: "0 ppb"}), el("span", {text: "4,000 ppb"})))),
el("div", {class: "ghs-toggles"},
el("label", {class: "ghs-switch"}, ghgToggle, el("span", {text: "Greenhouse gases in the air"})),
el("label", {class: "ghs-switch"}, fbToggle, el("span", {text: "Feedbacks (water vapour, ice, clouds)"})),
btnSkip));
root.append(exploreCard);
// Read-outs
const rTemp = el("div", {class: "ghs-big"});
const rTempSub = el("div", {class: "ghs-sub"});
const rSettle = el("div", {class: "ghs-big"});
const rSettleSub = el("div", {class: "ghs-sub"});
const barIn = el("span"), barOut = el("span");
const rIn = el("b"), rOut = el("b"), rNet = el("div", {class: "ghs-sub"});
const rEps = el("div", {class: "ghs-big"});
const rEpsSub = el("div", {class: "ghs-sub"});
root.append(el("div", {class: "ghs-readouts"},
el("div", {class: "ghs-ro"}, el("div", {class: "ghs-ro-h", text: "Surface temperature"}), rTemp, rTempSub),
el("div", {class: "ghs-ro"}, el("div", {class: "ghs-ro-h", text: "Heading towards"}), rSettle, rSettleSub),
el("div", {class: "ghs-ro ghs-ro-energy"}, el("div", {class: "ghs-ro-h", text: "Energy balance (W per m²)"}),
el("div", {class: "ghs-bar in"}, el("span", {class: "ghs-bar-l", text: "In: sunlight absorbed"}), rIn, el("div", {class: "ghs-bar-t"}, barIn)),
el("div", {class: "ghs-bar out"}, el("span", {class: "ghs-bar-l", text: "Out: heat to space"}), rOut, el("div", {class: "ghs-bar-t"}, barOut)),
rNet),
el("div", {class: "ghs-ro"}, el("div", {class: "ghs-ro-h", text: "Infrared caught by the air (model)"}), rEps, rEpsSub)));
const live = el("div", {class: "visually-hidden", "aria-live": "polite"});
root.append(live);
// ---------- Tour ----------
const STAGES = [
{title: "Sunlight arrives",
text: "Energy from the Sun arrives as short waves (visible light and near-infrared) that mostly pass straight through the air.",
show: {sun: true, reflect: false, ir: false, absorb: false}, freeze: true, setup: "pi"},
{title: "About 30% bounces straight back",
text: "Clouds, tiny particles in the air, ice, snow and other bright surfaces reflect about 30% of incoming sunlight straight back to space. It never warms the planet.",
show: {sun: true, reflect: true, ir: false, absorb: false}, freeze: true, setup: "pi"},
{title: "The surface warms up",
text: "The rest is absorbed, mostly by the land and the oceans, which warm up.",
show: {sun: true, reflect: true, ir: false, absorb: false, surfaceFlash: true}, freeze: true, setup: "pi"},
{title: "The warm Earth glows in infrared",
text: "Anything warm gives off invisible infrared: long waves. You do too; it is what an ear or forehead thermometer reads. That is how Earth sheds heat. Picture air with no greenhouse gases: all of this heat would escape straight to space.",
show: {sun: true, reflect: true, ir: true, absorb: false}, freeze: true, setup: "pi"},
{title: "Greenhouse gases catch the infrared",
text: "Nitrogen and oxygen make up 99% of dry air and let infrared pass. A few gases present in small amounts (water vapour, CO₂, methane, nitrous oxide) absorb it and warm the air, and the warm air gives off infrared in every direction, partly back down to the surface. Hover over or tap a molecule (or open 'Which molecule is which?' below the picture) to see which is which.",
show: {sun: true, reflect: true, ir: true, absorb: true, labels: true}, freeze: true, setup: "pi"},
{title: "A natural blanket that keeps us alive",
text: `This natural greenhouse effect keeps Earth's average surface at about 14 °C. In this model, with no greenhouse gases it would settle at about ${fmt1(tBare)} °C, some ${Math.round(14 - tBare)} °C colder, and frozen over. Try it.`,
show: {all: true}, freeze: false, setup: "pi",
actions: [["No greenhouse gases", () => { state.ghg = false; refresh(); }, () => !state.ghg],
["With greenhouse gases", () => { state.ghg = true; refresh(); }, () => state.ghg]]},
{title: "We are thickening the blanket",
text: `Burning coal, oil and gas and clearing forests have raised CO₂ from about 280 parts per million (ppm) in 1750 to about 424 ppm in 2024, and methane has more than doubled. Now less heat goes out than comes in (watch the panel in the top corner), so Earth warms until the two match again: about +${fmt1(dToday)} °C in this model once settled. The real world is at about +1.3 °C so far: the oceans take decades to catch up.`,
show: {all: true}, freeze: false, setup: "today"},
{title: "Feedbacks amplify it",
text: `Start again from 1750 levels, but double CO₂ to 560 ppm. With no feedbacks the model settles ${fmt1(dDoubleNoFb)} °C warmer. But warmer air holds more water vapour (about 7% more per °C), itself a greenhouse gas, and melting ice and changing clouds add more: with these feedbacks it settles ${fmt1(dDoubleFb)} °C warmer. The loop amplifies the warming but does not run away.`,
show: {all: true, labels: true}, freeze: false, setup: "double",
actions: [["Feedbacks off", () => { state.feedback = false; refresh(); }, () => !state.feedback],
["Feedbacks on", () => { state.feedback = true; refresh(); }, () => state.feedback]]},
{title: "Why it matters for health",
text: "A degree or two on the global average means much more frequent heatwaves, shifting ranges for some infectious diseases, and pressure on food and water. Burning the same fossil fuels also pollutes the air we breathe. Now explore the scenarios yourself.",
show: {all: true}, freeze: false, setup: "keep",
actions: [["Explore yourself", () => { setMode("explore"); tabExplore.focus(); }]]}
];
for (const s of STAGES) s.text = s.text.replace(/ (°C|ppm)/g, " $1"); // keep "3.0 °C" on one line
// invisible copies of every step, stacked under the live one: the card is always as tall as the longest step
stepBody.prepend(...STAGES.map(s => el("div", {class: "ghs-sizer", "aria-hidden": "true"},
el("h3", {class: "ghs-step-title", text: s.title}), el("p", {class: "ghs-step-text", text: s.text}),
s.actions ? el("div", {class: "ghs-step-actions"}, ...s.actions.map(([label]) => el("span", {class: "ghs-chip", text: label}))) : null)));
let stage = 0, mode = "tour", stageTimer = null;
let auto = !reduceMotion && window.innerWidth >= 700, stageClock = 0; // phones start paused: the text sits below the picture
const stageSeconds = S => S.seconds || Math.min(28, Math.max(10, 6 + S.text.split(/\s+/).length / 2.5)); // ~150 words a minute plus time to watch
const dotBtns = STAGES.map((s, i) => {
const b = el("button", {class: "ghs-dot", type: "button", "aria-label": `Step ${i + 1}: ${s.title}`});
b.addEventListener("click", () => { auto = false; goStage(i); });
dots.append(b);
return b;
});
let show = {};
function goStage(i) {
clearTimeout(stageTimer);
stage = (i + STAGES.length) % STAGES.length;
const S = STAGES[stage];
stageClock = 0;
show = S.show.all ? {sun: true, reflect: true, ir: true, absorb: true, labels: !!S.show.labels} : S.show;
if (S.setup === "pi") { Object.assign(state, {ghg: true, co2: 280, ch4: 730, feedback: true}); T = M.settle(state); }
if (S.setup === "today") {
Object.assign(state, {ghg: true, co2: 280, ch4: 730, feedback: true}); T = M.settle(state);
const here = stage; // after a pause, raise the gases to today's levels
stageTimer = setTimeout(() => { if (mode === "tour" && stage === here) { Object.assign(state, {co2: 424, ch4: 1940}); refresh(); } }, 1500);
}
if (S.setup === "double") {
Object.assign(state, {ghg: true, co2: 560, ch4: 730, feedback: false}); T = M.T_PI;
const here = stage; // first without feedbacks, then switch them on
stageTimer = setTimeout(() => { if (mode === "tour" && stage === here) { state.feedback = true; refresh(); } }, 6000);
}
if (S.setup === "keep" && (!state.ghg || !state.feedback)) { // leave the tour with gases and feedbacks on
if (!state.ghg) Object.assign(state, {co2: 424, ch4: 1940});
Object.assign(state, {ghg: true, feedback: true}); T = M.settle(state);
}
frozen = !!S.freeze;
stepLabel.textContent = `Step ${stage + 1} of ${STAGES.length}`;
stepTitle.textContent = S.title;
stepText.textContent = S.text;
stepActions.replaceChildren(...(S.actions || []).map(([label, fn, isOn]) => {
const b = el("button", {class: "ghs-chip", type: "button", text: label, onclick: () => { clearTimeout(stageTimer); auto = false; sayResult = true; saidEq = fmt1(toC(Teq)); fn(); syncAuto(); }});
b._isOn = isOn;
return b;
}));
dotBtns.forEach((b, j) => b.setAttribute("aria-current", j === stage ? "step" : "false"));
syncAuto(); // first, so that steps the tour turns by itself are not read out (aria-live off)
live.textContent = `Step ${stage + 1} of ${STAGES.length}: ${S.title}. ${S.text}`;
refresh();
sayResult = false; // the step text already gives the numbers; do not talk over it
}
function syncAuto() {
btnAuto.replaceChildren(el("span", {"aria-hidden": "true", text: auto ? "❚❚ " : "▶︎ "}), auto ? "Pause tour" : "Play tour");
timer.style.visibility = auto ? "visible" : "hidden";
live.setAttribute("aria-live", auto ? "off" : "polite"); // silent while the tour plays itself (WAI-ARIA carousel pattern)
}
btnPrev.addEventListener("click", () => { auto = false; goStage(stage - 1); });
btnNext.addEventListener("click", () => { auto = false; goStage(stage + 1); });
btnAuto.addEventListener("click", () => {
auto = !auto; stageClock = 0;
if (auto && stage === STAGES.length - 1) goStage(0); else syncAuto(); // replay from the start
});
function setMode(m) {
if (m === "explore" && mode === "tour") state.feedback = true; // Explore always opens with feedbacks on, as Things to try assumes
mode = m;
tabTour.setAttribute("aria-pressed", String(m === "tour"));
tabExplore.setAttribute("aria-pressed", String(m === "explore"));
tourCard.hidden = m !== "tour";
exploreCard.hidden = m !== "explore";
if (m === "explore") {
clearTimeout(stageTimer);
frozen = false; show = {sun: true, reflect: true, ir: true, absorb: true, labels: false};
auto = false; syncAuto(); refresh();
sayResult = true; saidEq = fmt1(toC(Teq));
}
else goStage(stage);
}
tabTour.addEventListener("click", () => { if (mode !== "tour") setMode("tour"); });
tabExplore.addEventListener("click", () => setMode("explore"));
// a keyboard user inside the widget sets the pace
root.addEventListener("focusin", e => { if (auto && e.target !== btnAuto) { auto = false; syncAuto(); } });
// ---------- Explore controls ----------
const CO2_MIN = 150, CO2_MAX = 2000;
const co2FromSlider = v => CO2_MIN * (CO2_MAX / CO2_MIN) ** (v / 1000);
const sliderFromCo2 = c => 1000 * Math.log(c / CO2_MIN) / Math.log(CO2_MAX / CO2_MIN);
co2Range.addEventListener("input", () => { state.co2 = Math.round(co2FromSlider(+co2Range.value) / 5) * 5; state.ghg = true; refresh(); });
co2Range.addEventListener("keydown", e => { // keyboard steps in ppm (the log slider's own steps are below the 5 ppm rounding)
const d = {ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5, PageUp: 50, PageDown: -50}[e.key];
if (d == null || co2Range.disabled) return;
e.preventDefault();
state.co2 = Math.min(CO2_MAX, Math.max(CO2_MIN, Math.round(state.co2 / 5) * 5 + d));
state.ghg = true; refresh();
});
ch4Range.addEventListener("input", () => { state.ch4 = +ch4Range.value; state.ghg = true; refresh(); });
ghgToggle.addEventListener("change", () => { state.ghg = ghgToggle.checked; refresh(); });
fbToggle.addEventListener("change", () => { state.feedback = fbToggle.checked; refresh(); });
btnSkip.addEventListener("click", () => { T = M.settle(state); refresh(); });
function applyPreset(p) {
if (p.s.ghg === false) state.ghg = false;
else Object.assign(state, p.s);
refresh();
}
let Teq = M.settle(state);
function refresh() {
Teq = M.settle(state);
co2Range.value = String(sliderFromCo2(state.co2));
co2Out.textContent = `${Math.round(state.co2).toLocaleString("en-GB")} ppm`;
co2Range.setAttribute("aria-valuetext", `${Math.round(state.co2)} parts per million`);
ch4Range.value = String(state.ch4);
ch4Out.textContent = `${Math.round(state.ch4).toLocaleString("en-GB")} ppb`;
ch4Range.setAttribute("aria-valuetext", `${Math.round(state.ch4)} parts per billion`);
ghgToggle.checked = state.ghg;
fbToggle.checked = state.feedback;
co2Range.disabled = ch4Range.disabled = fbToggle.disabled = !state.ghg;
presetBtns.forEach((b, i) => {
const p = PRESETS[i].s;
const on = p.ghg === false ? !state.ghg : state.ghg && p.co2 === state.co2 && p.ch4 === state.ch4;
b.setAttribute("aria-pressed", String(on));
});
for (const b of stepActions.children) if (b._isOn) b.setAttribute("aria-pressed", String(b._isOn()));
reconcileMolecules();
if (animPaused) preroll();
}
// ---------- Canvas scene ----------
const ctx = canvas.getContext("2d");
const rr = (...a) => ctx.roundRect ? ctx.roundRect(...a) : ctx.rect(...a.slice(0, 4)); // Safari 15 has no roundRect
let W = 800, H = 480, dpr = 1;
const geo = {};
function layout() {
const oldW = W, oldH = H;
const w = stageWrap.clientWidth || 800;
W = w;
H = Math.round(w < 560 ? w * 1.05 : Math.min(540, Math.max(360, w * 0.6)));
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
canvas.style.height = H + "px";
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const R = Math.max(W * 1.4, H * 1.6); // radius of the planet's curve
geo.R = R; geo.cx = W / 2; geo.cy = H * 0.86 + R; // surface top at 86% of the height
geo.atmTop = H * 0.30; // where the atmosphere fades out
geo.atmR = geo.cy - geo.atmTop;
geo.cloudY = H * 0.43;
geo.sun = {x: Math.max(48, W * 0.1), y: Math.max(44, H * 0.12), r: Math.max(18, Math.min(W, H) * 0.055)};
geo.atom = Math.max(2.6, Math.min(4.2, W / 230));
const rnd = i => { const v = Math.sin(i * 12.9898 + 78.233) * 43758.5453; return v - Math.floor(v); };
stars = Array.from({length: Math.round(W * H / 7000)}, (_, i) => ({
x: rnd(i) * W, y: rnd(i + 500) * H * 0.42, r: 0.4 + rnd(i + 900) * 1.0, p: i}));
clouds = [0.25, 0.58, 0.86].map((f, i) => ({x: W * f, y: geo.cloudY + (i - 1) * H * 0.02, s: Math.max(0.7, W / 900) * (1 - i * 0.12)}));
// stretch the molecules to the new size (they were placed before the canvas knew its width)
molecules.forEach(m => { m.x = 10 + (m.x - 10) * (W - 20) / Math.max(1, oldW - 20); m.y *= H / oldH; placeInAir(m, true); });
photons = []; flashes = [];
}
let stars = [], clouds = [];
const surfaceY = x => geo.cy - Math.sqrt(Math.max(0, geo.R ** 2 - (x - geo.cx) ** 2));
const atmTopY = x => geo.cy - Math.sqrt(Math.max(0, geo.atmR ** 2 - (x - geo.cx) ** 2));
// ---------- Molecules ----------
const KINDS = {
N2: {ghg: false, name: "Nitrogen (N₂)", info: "78% of dry air: about 1,800 nitrogen molecules for every CO₂ (not to scale here). Lets infrared pass straight through."},
O2: {ghg: false, name: "Oxygen (O₂)", info: "21% of dry air. Lets infrared pass straight through."},
CO2: {ghg: true, name: "Carbon dioxide (CO₂)", info: "About 0.04% of the air (the picture greatly exaggerates it). Absorbs infrared when it bends."},
CH4: {ghg: true, name: "Methane (CH₄)", info: "About 0.0002% of the air, but a powerful absorber of infrared."},
H2O: {ghg: true, name: "Water vapour (H₂O)", info: "The biggest natural greenhouse gas. Warmer air holds more of it."},
N2O: {ghg: true, name: "Nitrous oxide (N₂O)", info: "Comes from soils and oceans, boosted by farm fertilisers and industry. Absorbs infrared. Held at its 1750 level here."}
};
const SYMBOL = {CO2: "CO₂", CH4: "CH₄", H2O: "H₂O", N2O: "N₂O", N2: "N₂", O2: "O₂"};
// the same notes for keyboard and screen-reader users (the tooltip needs a mouse or a tap)
stageWrap.after(el("details", {class: "ghs-mols"}, el("summary", {text: "Which molecule is which?"}),
el("dl", {}, ...Object.values(KINDS).flatMap(k => [el("dt", {text: k.name}), el("dd", {text: k.info})]))));
let molecules = [];
let molId = 0;
function placeInAir(m, keepX) {
if (!keepX) m.x = 12 + Math.random() * (W - 24);
const top = Math.max(atmTopY(m.x) + 18, geo.atmTop + 10), bottom = surfaceY(m.x) - 16;
if (!keepX || m.y < top || m.y > bottom) m.y = top + Math.random() * Math.max(10, bottom - top);
}
function makeMolecule(kind) {
const m = {id: molId++, kind, x: 0, y: 0, vx: (Math.random() - 0.5) * 14, vy: (Math.random() - 0.5) * 8,
a: Math.random() * Math.PI * 2, va: (Math.random() - 0.5) * 0.6, excite: 0, phase: Math.random() * 10, alpha: 0, dying: false};
placeInAir(m, false);
return m;
}
function targetCounts() {
const area = Math.max(0.45, (W * H) / (800 * 480));
const c = {N2: Math.round(14 * area), O2: Math.round(5 * area), CO2: 0, CH4: 0, H2O: 0, N2O: 0};
if (state.ghg) {
c.CO2 = Math.max(1, Math.round(state.co2 / 45 * Math.sqrt(area)));
c.CH4 = Math.round(state.ch4 / 420 * Math.sqrt(area));
c.H2O = Math.max(1, Math.round((7 + (state.feedback ? 1.6 * (T - M.T_PI) : 0)) * Math.sqrt(area)));
c.N2O = 1;
}
return c;
}
function reconcileMolecules() {
const want = targetCounts();
for (const kind of Object.keys(KINDS)) {
const alive = molecules.filter(m => m.kind === kind && !m.dying);
for (let i = alive.length; i < want[kind]; i++) molecules.push(makeMolecule(kind));
for (let i = want[kind]; i < alive.length; i++) alive[i].dying = true;
}
}
// ---------- Photons ----------
// One packet of infrared carries as much energy as one packet of sunlight, so at balance as
// many packets leave for space as sunlight packets are absorbed.
let photons = [];
let sunAcc = 0, irAcc = 0, flashes = [];
const SUN_RATE = 7; // sunlight packets per second
function spawnSun() {
const r = Math.random();
// fate: ~23% reflected by clouds and air, ~7% by the surface, the rest absorbed (Trenberth et al. 2009)
const fate = !show.reflect ? "absorb" : r < 0.23 ? "cloud" : r < 0.30 ? "ground" : "absorb";
let tx, ty;
if (fate === "cloud") {
const c = clouds[Math.floor(Math.random() * clouds.length)];
tx = c.x + (Math.random() - 0.5) * 50 * c.s; ty = c.y + (Math.random() - 0.5) * 16 * c.s;
} else { tx = W * (0.18 + Math.random() * 0.8); ty = surfaceY(tx); }
const sx = geo.sun.x + (Math.random() - 0.5) * geo.sun.r, sy = geo.sun.y + (Math.random() - 0.5) * geo.sun.r;
const d = Math.hypot(tx - sx, ty - sy), speed = H * 0.42;
photons.push({type: "sun", x: sx, y: sy, vx: (tx - sx) / d * speed, vy: (ty - sy) / d * speed,
fate, bounceY: fate === "cloud" ? ty : null, phase: Math.random() * 6});
}
function spawnIR(x, y, dirUp, fromMol) {
const ang = dirUp ? -Math.PI / 2 + (Math.random() - 0.5) * 1.2 : Math.PI / 2 + (Math.random() - 0.5) * 1.2;
const speed = H * 0.30;
let target = null;
const eps = M.epsilon(T, state);
if (!fromMol && show.absorb && Math.random() < eps) {
// pick a greenhouse-gas molecule above this point to absorb the photon, preferably a near one
const above = molecules.filter(m => KINDS[m.kind].ghg && !m.dying && m.y < y - 20);
const near = above.filter(m => Math.abs(m.x - x) < H * 0.5);
const cands = near.length ? near : above;
if (cands.length) target = cands[Math.floor(Math.random() * cands.length)];
}
let vx = Math.cos(ang) * speed, vy = Math.sin(ang) * speed;
if (target) { const d = Math.hypot(target.x - x, target.y - y); vx = (target.x - x) / d * speed; vy = (target.y - y) / d * speed; }
photons.push({type: "ir", x, y, vx, vy, target, phase: Math.random() * 6, age: 0, fromMol: !!fromMol});
}
// ---------- Drawing helpers ----------
const lerp = (a, b, t) => a + (b - a) * t;
const mix = (c1, c2, t) => `rgb(${c1.map((v, i) => Math.round(lerp(v, c2[i], Math.max(0, Math.min(1, t))))).join(",")})`;
function drawBackground(time) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, "#0b1426"); g.addColorStop(0.45, "#15243f"); g.addColorStop(1, "#1d3155");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
for (const s of stars) {
ctx.globalAlpha = 0.35 + 0.35 * Math.sin(time * 0.8 + s.p);
ctx.fillStyle = "#dfe8ff"; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.fill();
}
ctx.globalAlpha = 1;
// atmosphere: a soft glowing shell whose tint deepens with more greenhouse gas
const eps = M.epsilon(T, state);
const ag = ctx.createRadialGradient(geo.cx, geo.cy, geo.R, geo.cx, geo.cy, geo.atmR);
ag.addColorStop(0, `rgba(90,150,230,${0.30 + eps * 0.25})`);
ag.addColorStop(0.75, `rgba(70,120,200,${0.10 + eps * 0.12})`);
ag.addColorStop(1, "rgba(60,100,180,0)");
ctx.fillStyle = ag; ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.atmR, 0, 6.283); ctx.fill();
// warm haze when greenhouse gas rises above 1750 levels
const extra = Math.max(0, Math.min(1, (eps - M.EPS_PI) / 0.12));
if (extra > 0) {
const hg = ctx.createRadialGradient(geo.cx, geo.cy, geo.R, geo.cx, geo.cy, geo.atmR);
hg.addColorStop(0, `rgba(255,120,80,${0.22 * extra})`); hg.addColorStop(1, "rgba(255,120,80,0)");
ctx.fillStyle = hg; ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.atmR, 0, 6.283); ctx.fill();
}
// top of the atmosphere: a faint dashed edge, with the two zones named either side of it
ctx.save();
ctx.strokeStyle = "rgba(160,200,255,.4)"; ctx.lineWidth = 1; ctx.setLineDash([3, 6]);
ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.atmR, Math.PI * 1.02, Math.PI * 1.98); ctx.stroke();
ctx.restore();
ctx.font = `600 ${Math.max(10, Math.min(12, W / 70))}px "Source Sans 3", system-ui, sans-serif`;
ctx.textAlign = "center"; ctx.fillStyle = "rgba(220,232,255,.62)";
if ("letterSpacing" in ctx) ctx.letterSpacing = "2px";
ctx.strokeStyle = "rgba(11,20,38,.7)"; ctx.lineWidth = 3; ctx.lineJoin = "round";
const zx = W < 560 ? W * 0.42 : geo.cx, zy = atmTopY(zx); // phones: clear of the temperature panel
for (const [txt, y] of [["SPACE", zy - 8], ["ATMOSPHERE", zy + 18]]) { ctx.strokeText(txt, zx, y); ctx.fillText(txt, zx, y); }
const zfs = Math.max(10, Math.min(12, W / 70)); // name tags keep clear of these two words
zoneBoxes = [["SPACE", zy - 8], ["ATMOSPHERE", zy + 18]].map(([t, y]) => { const w = ctx.measureText(t).width + 8; return {x: zx - w / 2, y: y - zfs - 2, w, h: zfs + 6}; });
if ("letterSpacing" in ctx) ctx.letterSpacing = "0px";
}
function drawSun(time) {
const {x, y, r} = geo.sun;
const g = ctx.createRadialGradient(x, y, r * 0.2, x, y, r * 2.6);
g.addColorStop(0, "rgba(255,214,90,.9)"); g.addColorStop(0.35, "rgba(255,190,60,.35)"); g.addColorStop(1, "rgba(255,190,60,0)");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r * 2.6, 0, 6.283); ctx.fill();
ctx.fillStyle = "#ffd65a"; ctx.beginPath(); ctx.arc(x, y, r * (1 + 0.03 * Math.sin(time * 2)), 0, 6.283); ctx.fill();
ctx.fillStyle = "#fff1b8"; ctx.beginPath(); ctx.arc(x - r * 0.25, y - r * 0.25, r * 0.45, 0, 6.283); ctx.fill();
}
function drawCloud(c) {
ctx.fillStyle = "rgba(235,242,255,.20)";
for (const [dx, dy, cr] of [[-26, 4, 16], [-8, -6, 20], [14, -2, 18], [30, 6, 13], [4, 8, 16]]) {
ctx.beginPath(); ctx.arc(c.x + dx * c.s, c.y + dy * c.s, cr * c.s, 0, 6.283); ctx.fill();
}
}
function drawSurface(time) {
const a = T - M.T_PI; // anomaly in K
const cold = Math.max(0, Math.min(1, -a / 25)); // icy when far below 1750 levels
const hot = Math.max(0, Math.min(1, a / 6));
const base = cold > 0 ? mix([44, 112, 102], [205, 225, 240], cold) : mix([44, 112, 102], [168, 98, 46], hot);
const g = ctx.createLinearGradient(0, H * 0.84, 0, H);
g.addColorStop(0, base); g.addColorStop(0.45, base); g.addColorStop(1, "#0f1a2c");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.R, 0, 6.283); ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,.22)"; ctx.lineWidth = 1.5; // crisp horizon line
ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.R - 0.5, Math.PI * 1.02, Math.PI * 1.98); ctx.stroke();
// infrared glow along the surface, brighter when the surface is warmer
if (show.ir) {
const glow = Math.max(0.05, Math.min(0.6, 0.25 + a * 0.05));
ctx.strokeStyle = `rgba(255,110,70,${glow})`; ctx.lineWidth = 3 + 2 * Math.sin(time * 3);
ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.R + 1, Math.PI * 1.05, Math.PI * 1.95); ctx.stroke();
}
if (show.surfaceFlash) { // step 3: the ground soaks up sunlight and warms
ctx.strokeStyle = `rgba(255,170,60,${0.55 + 0.25 * Math.sin(time * 2.5)})`; ctx.lineWidth = 4;
ctx.beginPath(); ctx.arc(geo.cx, geo.cy, geo.R + 2, Math.PI * 1.05, Math.PI * 1.95); ctx.stroke();
}
const ice = a < -12, ly = Math.min(H - 8, surfaceY(geo.cx) + 22);
ctx.textAlign = "center"; ctx.font = `${Math.max(11, Math.min(13, W / 60))}px "Source Sans 3", system-ui, sans-serif`;
if (!ice) { ctx.strokeStyle = "rgba(11,20,38,.7)"; ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.strokeText("Earth's surface", geo.cx, ly); } // dark halo on green and brown, as for SPACE / ATMOSPHERE
ctx.fillStyle = ice ? "rgba(20,32,52,.85)" : "#fff"; ctx.fillText("Earth's surface", geo.cx, ly); // dark text on ice
}
function atom(x, y, r, fill) {
ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, r, 0, 6.283); ctx.fill();
}
const COLORS = {C: "#9aa3b5", O: "#ff6b6b", H: "#f4f6fb", N: "#7da2ff"};
function drawMolecule(m, time) {
const r = geo.atom, k = KINDS[m.kind];
const wob = m.excite * Math.sin(time * 26 + m.phase); // vibration when excited
ctx.save(); ctx.translate(m.x, m.y); ctx.rotate(m.a); ctx.globalAlpha = m.alpha * (k.ghg ? 1 : 0.55);
if (k.ghg) { // a ring marks greenhouse gases (as in the legend)
if (m.excite > 0.05) { // just caught an infrared wave: a warm glow that fades as it re-emits
const gr = r * (5 + 4 * m.excite), g = ctx.createRadialGradient(0, 0, r, 0, 0, gr);
g.addColorStop(0, `rgba(255,130,90,${0.7 * m.excite})`); g.addColorStop(1, "rgba(255,130,90,0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(0, 0, gr, 0, 6.283); ctx.fill();
ctx.globalCompositeOperation = "source-over";
}
ctx.fillStyle = "rgba(110,170,255,.10)";
ctx.strokeStyle = m.excite > 0.05 ? `rgba(255,160,120,${0.5 + 0.5 * m.excite})` : "rgba(111,170,255,.6)";
ctx.lineWidth = 1.3;
ctx.beginPath(); ctx.arc(0, 0, r * 4.2, 0, 6.283); ctx.fill(); ctx.stroke();
}
const s = r * 1.9;
if (m.kind === "N2" || m.kind === "O2") {
const c = m.kind === "N2" ? COLORS.N : COLORS.O, st = 1 + 0.08 * Math.sin(time * 9 + m.phase);
atom(-s * 0.5 * st, 0, r, c); atom(s * 0.5 * st, 0, r, c);
} else if (m.kind === "CO2") {
const bend = wob * r * 1.6;
ctx.strokeStyle = "rgba(255,255,255,.35)"; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(-s, 0); ctx.lineTo(0, bend); ctx.lineTo(s, 0); ctx.stroke();
atom(-s, 0, r, COLORS.O); atom(s, 0, r, COLORS.O); atom(0, bend, r * 0.95, COLORS.C);
} else if (m.kind === "N2O") {
const bend = wob * r * 1.4;
atom(-s, 0, r, COLORS.N); atom(0, bend, r, COLORS.N); atom(s, 0, r, COLORS.O);
} else if (m.kind === "H2O") {
const open = 0.95 + wob * 0.35;
atom(0, 0, r * 1.05, COLORS.O);
atom(Math.cos(Math.PI / 2 + open) * s * 0.85, Math.sin(Math.PI / 2 + open) * s * 0.85, r * 0.62, COLORS.H);
atom(Math.cos(Math.PI / 2 - open) * s * 0.85, Math.sin(Math.PI / 2 - open) * s * 0.85, r * 0.62, COLORS.H);
} else if (m.kind === "CH4") {
atom(0, 0, r * 0.95, COLORS.C);
for (let j = 0; j < 4; j++) {
const ang = j * Math.PI / 2 + Math.PI / 4 + wob * 0.3 * (j % 2 ? 1 : -1);
atom(Math.cos(ang) * s * 0.8, Math.sin(ang) * s * 0.8, r * 0.6, COLORS.H);
}
}
ctx.restore();
}
// a wiggly line trailing behind a photon: `cycles` waves over length L
function wavePath(p, L, cycles, amp, speed, time) {
const sp = Math.hypot(p.vx, p.vy) || 1, ux = p.vx / sp, uy = p.vy / sp, n = Math.max(14, cycles * 6);
ctx.beginPath();
for (let k = 0; k <= n; k++) {
const t = k / n, a = amp * Math.sin(t * Math.PI * 2 * cycles + time * speed + p.phase) * (1 - t * 0.5);
const x = p.x - ux * L * t - uy * a, y = p.y - uy * L * t + ux * a;
if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
}
function drawPhotons(time) {
ctx.lineCap = "round"; ctx.lineJoin = "round";
for (const p of photons) {
const sun = p.type === "sun", col = !sun ? "255,105,70" : p.reflected ? "235,242,255" : "255,214,90";
if (sun) wavePath(p, Math.min(18, H * 0.04), 3.5, 1.8, 30, time); // sunlight: short, tight waves
else wavePath(p, Math.min(34, H * 0.07), 1.5, 4, 14, time); // infrared: long, lazy waves
ctx.globalCompositeOperation = "lighter"; // soft glow underneath...
ctx.strokeStyle = `rgba(${col},.22)`; ctx.lineWidth = sun ? 4 : 6; ctx.stroke();
ctx.globalCompositeOperation = "source-over"; // ...and a crisp line on top
ctx.strokeStyle = `rgb(${col})`; ctx.lineWidth = sun ? 1.6 : 2; ctx.stroke();
}
ctx.globalCompositeOperation = "lighter"; // where light lands: a soft glow, not a flat disc
for (const f of flashes) {
const k = Math.max(0, f.life / f.max), fr = (6 + (1 - k) * 12) * (f.max > 0.6 ? 1.8 : 1); // step 3: bigger landing glows
const g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, fr);
g.addColorStop(0, f.color.replace("A", String(0.55 * k))); g.addColorStop(1, f.color.replace("A", "0"));
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(f.x, f.y, fr, 0, 6.283); ctx.fill();
}
ctx.globalCompositeOperation = "source-over";
}
// ---------- Labels and on-canvas read-out ----------
const labelOwner = {};
let hudBox = {x: 0, y: 0, w: 0, h: 0}, zoneBoxes = [];
function drawLabels() { // one name tag per kind of molecule, beside its own molecule and never on another one
const fs = Math.max(10, Math.min(12, W / 70)), h = fs + 6, R = geo.atom * 4.2;
const placed = [hudBox, ...zoneBoxes, {x: btnAnim.offsetLeft, y: btnAnim.offsetTop, w: btnAnim.offsetWidth, h: btnAnim.offsetHeight}];
ctx.font = `600 ${fs}px "Source Sans 3", system-ui, sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
const spots = m => { const w = ctx.measureText(SYMBOL[m.kind]).width + 10, d = geo.atom * 5; // below, above, right, left
return [{x: m.x - w / 2, y: m.y + d, w, h}, {x: m.x - w / 2, y: m.y - d - h, w, h}, {x: m.x + d, y: m.y - h / 2, w, h}, {x: m.x - d - w, y: m.y - h / 2, w, h}]; };
const clash = b => placed.some(q => b.x < q.x + q.w && q.x < b.x + b.w && b.y < q.y + q.h && q.y < b.y + b.h);
const free = (m, b) => b.y > atmTopY(b.x + b.w / 2) + 2 && b.x > 2 && b.x + b.w < W - 2 && b.y + b.h < surfaceY(b.x + b.w / 2) - 2 && !clash(b)
&& !molecules.some(q => q !== m && !q.dying && q.x > b.x - R && q.x < b.x + b.w + R && q.y > b.y - R && q.y < b.y + b.h + R);
const spotOf = m => { // keep the side it already sits on while that side is free
if (m.dying || m.alpha <= 0.9) return null;
const S = spots(m), k = [m.spot || 0, 0, 1, 2, 3].find(i => free(m, S[i]));
return k == null ? null : S[m.spot = k];
};
for (const kind of Object.keys(KINDS)) {
let m = molecules.find(q => q.id === labelOwner[kind]), b = m && spotOf(m);
if (!b) { m = molecules.find(q => q.kind === kind && (b = spotOf(q))); labelOwner[kind] = m ? m.id : null; }
if (!m) continue;
placed.push(b);
ctx.fillStyle = "rgba(11,20,38,.82)"; ctx.beginPath(); rr(b.x, b.y, b.w, b.h, h / 2); ctx.fill();
ctx.fillStyle = "#eddcc6"; ctx.fillText(SYMBOL[kind], b.x + b.w / 2, b.y + h / 2 + 0.5);
}
ctx.textBaseline = "alphabetic";
}
function drawHUD() { // thermometer, temperature, trend and energy in/out, top right
const tc = toC(T), out = M.olr(T, state), net = M.ASR - out, small = W < 560;
const fs = small ? 11 : 12, big = small ? 18 : 22, tubeH = small ? 50 : 62, tw = small ? 7 : 8;
const sIn = `↓${fmt1(M.ASR)} `, sOut = `↑${fmt1(out)} `; // same one decimal as the read-outs below
ctx.font = `${fs}px "Source Sans 3", system-ui, sans-serif`;
const textW = Math.max(ctx.measureText(sIn + sOut + "W/m²").width, big * 4.1);
const pw = Math.ceil(12 + tw + 12 + textW + 12), ph = tubeH + tw + 20, x0 = W - pw - 10, y0 = 10;
hudBox = {x: x0, y: y0, w: pw, h: ph};
ctx.fillStyle = "rgba(11,20,38,.62)"; ctx.strokeStyle = "rgba(237,220,198,.22)"; ctx.lineWidth = 1;
ctx.beginPath(); rr(x0, y0, pw, ph, 10); ctx.fill(); ctx.stroke();
// thermometer from −20 °C (bottom) to +25 °C (top)
const tx = x0 + 12 + tw / 2, top = y0 + 9, bot = top + tubeH, by = bot + tw * 0.6;
const f = Math.max(0, Math.min(1, (tc + 20) / 45));
const col = tc < 0 ? "#8fc3ff" : mix([255, 214, 90], [255, 105, 70], (tc - 12) / 8);
ctx.fillStyle = "rgba(255,255,255,.14)";
ctx.beginPath(); rr(tx - tw / 2, top, tw, tubeH + 2, tw / 2); ctx.fill();
ctx.beginPath(); ctx.arc(tx, by, tw, 0, 6.283); ctx.fill();
ctx.fillStyle = col;
ctx.beginPath(); rr(tx - tw / 2 + 2, bot - f * tubeH, tw - 4, f * tubeH + 2, (tw - 4) / 2); ctx.fill();
ctx.beginPath(); ctx.arc(tx, by, tw - 2, 0, 6.283); ctx.fill();
// numbers
const lx = tx + tw / 2 + 12, l1 = top + big * 0.85, l2 = l1 + fs + 6, l3 = l2 + fs + 5;
ctx.textAlign = "left"; ctx.fillStyle = "#fff"; ctx.font = `600 ${big}px "Source Sans 3", system-ui, sans-serif`;
ctx.fillText(`${fmt1(tc)} °C`, lx, l1);
ctx.font = `600 ${fs}px "Source Sans 3", system-ui, sans-serif`;
const steady = frozen || T === Teq;
ctx.fillStyle = steady ? "rgba(220,232,255,.75)" : net > 0 ? "#ffa88c" : "#8fc3ff";
ctx.fillText(steady ? "● balanced" : net > 0 ? "▲ warming" : "▼ cooling", lx, l2);
ctx.font = `${fs}px "Source Sans 3", system-ui, sans-serif`;
ctx.fillStyle = "#ffd65a"; ctx.fillText(sIn, lx, l3);
const w1 = ctx.measureText(sIn).width;
ctx.fillStyle = "#ff8a6a"; ctx.fillText(sOut, lx + w1, l3);
ctx.fillStyle = "rgba(220,232,255,.7)"; ctx.fillText("W/m²", lx + w1 + ctx.measureText(sOut).width, l3);
}
// ---------- Step ----------
// temperature moves towards balance (much faster than in reality); it keeps moving while the picture is frozen
function approach(dt) {
if (frozen) return;
for (let k = 0; k < 4; k++) T += (dt / 4) * M.imbalance(T, state) / HEAT_CAPACITY;
// last stretch: once the read-outs can't tell T from Teq, finish the approach so all statuses agree
if (Math.abs(Teq - T) < 0.05 || fmt1(toC(T)) === fmt1(toC(Teq))) T = Teq;
}
function step(dt, time) {
approach(dt);
// molecules drift
for (const m of molecules) {
m.x += m.vx * dt; m.y += m.vy * dt; m.a += m.va * dt;
m.vx += (Math.random() - 0.5) * 6 * dt; m.vy += (Math.random() - 0.5) * 6 * dt;
m.vx = Math.max(-16, Math.min(16, m.vx)); m.vy = Math.max(-9, Math.min(9, m.vy));
if (m.x < 10) { m.x = 10; m.vx = Math.abs(m.vx); } if (m.x > W - 10) { m.x = W - 10; m.vx = -Math.abs(m.vx); }
const top = Math.max(atmTopY(m.x) + 18, geo.atmTop + 10), bottom = surfaceY(m.x) - 16;
if (m.y < top) { m.y = top; m.vy = Math.abs(m.vy); } if (m.y > bottom) { m.y = bottom; m.vy = -Math.abs(m.vy); }
m.excite = Math.max(0, m.excite - dt * 1.6);
m.alpha = m.dying ? m.alpha - dt * 1.5 : Math.min(1, m.alpha + dt * 1.5);
if (m.pending && m.pending.length && time >= m.pending[0]) { // re-emit absorbed infrared, one packet at a time
m.pending.shift();
spawnIR(m.x, m.y, Math.random() < 0.5, m);
}
}
molecules = molecules.filter(m => !(m.dying && m.alpha <= 0 && !(m.pending && m.pending.length)));
if (Math.random() < dt * 0.5) reconcileMolecules(); // water vapour follows temperature
// emit photons
if (show.sun) { sunAcc += dt * SUN_RATE * Math.min(1.6, W / 700); while (sunAcc >= 1) { spawnSun(); sunAcc--; } }
if (show.ir) {
// the surface gives off infrared in proportion to σT⁴ (Stefan–Boltzmann law)
const rate = SUN_RATE * (1 - M.ALBEDO) * (M.SIGMA * T ** 4) / M.ASR * Math.min(1.6, W / 700);
irAcc += dt * rate;
while (irAcc >= 1) { const x = W * (0.04 + Math.random() * 0.92); spawnIR(x, surfaceY(x) - 2, true, null); irAcc--; }
}
// move photons
for (const p of photons) {
if (p.type === "sun") {
p.x += p.vx * dt; p.y += p.vy * dt;
if (!p.reflected && p.fate === "cloud" && p.y >= p.bounceY) { p.vy = -Math.abs(p.vy); p.vx += (Math.random() - 0.3) * H * 0.2; p.reflected = true; }
else if (!p.reflected && p.y >= surfaceY(p.x)) {
if (p.fate === "ground") { p.y = surfaceY(p.x) - 1; p.vy = -Math.abs(p.vy); p.reflected = true; }
else { p.dead = true; flashes.push({x: p.x, y: surfaceY(p.x), life: show.surfaceFlash ? 0.9 : 0.45, max: show.surfaceFlash ? 0.9 : 0.45, color: "rgba(255,214,90,A)"}); }
}
if (p.y < -20 || p.x < -20 || p.x > W + 20) p.dead = true;
} else {
p.age += dt;
if (p.target) {
const m = p.target;
if (m.dying || m.alpha <= 0) { p.target = null; }
else {
const dx = m.x - p.x, dy = m.y - p.y, d = Math.hypot(dx, dy), sp = Math.hypot(p.vx, p.vy);
p.vx = dx / d * sp; p.vy = dy / d * sp;
if (d < geo.atom * 3) { p.dead = true; m.excite = 1; (m.pending ||= []).push(time + 0.35 + Math.random() * 0.3); continue; }
}
}
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.vy > 0 && p.y >= surfaceY(p.x)) { p.dead = true; flashes.push({x: p.x, y: surfaceY(p.x), life: 0.5, max: 0.5, color: "rgba(255,105,70,A)"}); }
if (p.y < -30 || p.x < -30 || p.x > W + 30) p.dead = true;
}
}
photons = photons.filter(p => !p.dead);
for (const f of flashes) f.life -= dt;
flashes = flashes.filter(f => f.life > 0);
}
// fill a still picture (animation frozen) with light and heat; the temperature keeps its own pace
function preroll() {
const keepT = T;
photons = []; flashes = []; sunAcc = irAcc = 0; // nothing left over from the previous step
for (const m of molecules) { m.pending = null; m.excite = 0; }
reconcileMolecules();
for (let i = 0; i < 160; i++) { time += 0.03; step(0.03, time); }
T = keepT;
}
// ---------- Read-outs ----------
let lastRO = 0;
function updateReadouts() {
const tc = toC(T), a = T - M.T_PI, eq = toC(Teq), eps = M.epsilon(T, state);
const out = M.olr(T, state), net = M.ASR - out;
const gap = Teq - T, settled = gap === 0;
setText(rTemp, `${fmt1(tc)} °C`);
setText(rTempSub, `${signed(a)} °C since 1750`);
setText(rSettle, `${fmt1(eq)} °C`);
setText(rSettleSub, frozen ? "Held still during these steps" : settled ? "Settled: energy in = energy out" : gap > 0 ? "Still warming…" : "Still cooling…");
setText(rIn, fmt1(M.ASR)); setText(rOut, fmt1(out));
const scale = 300;
barIn.style.width = `${Math.min(100, M.ASR / scale * 100)}%`;
barOut.style.width = `${Math.min(100, out / scale * 100)}%`;
const gapShown = Math.abs(Math.round(M.ASR * 10) - Math.round(out * 10)), netTxt = gapShown ? fmt1(gapShown / 10) : "under 0.1"; // the difference of the two numbers on screen
setText(rNet, settled || frozen ? "Balanced" : net > 0 ? `Earth is gaining ${netTxt} W/m², so it warms` : `Earth is losing ${netTxt} W/m², so it cools`);
rNet.className = "ghs-sub " + (settled || frozen ? "" : net > 0 ? "warm" : "cool");
if (mode === "tour" && !show.absorb) { setText(rEps, "—"); setText(rEpsSub, "Shown from step 5"); }
else {
setText(rEps, `${Math.round(eps * 100)}%`);
setText(rEpsSub, state.ghg ? `CO₂ ${Math.round(state.co2).toLocaleString("en-GB")} ppm · methane ${Math.round(state.ch4).toLocaleString("en-GB")} ppb` : "No greenhouse gases");
}
if (sayResult && !frozen && settled && fmt1(eq) !== saidEq) { live.textContent = `Temperature has settled at ${fmt1(eq)} degrees Celsius.`; saidEq = fmt1(eq); }
}
// ---------- Tooltip (hover with a mouse, tap on a touch screen) ----------
function nearestMolecule(x, y, radius) {
let best = null, bd = radius;
for (const m of molecules) { const d = Math.hypot(m.x - x, m.y - y); if (d < bd) { bd = d; best = m; } }
return best;
}
function showTip(p, radius) {
const r = canvas.getBoundingClientRect();
const m = nearestMolecule(p.clientX - r.left, p.clientY - r.top, radius);
if (!m) { tooltip.classList.remove("on"); return; }
if (tooltip.dataset.kind !== m.kind) {
const k = KINDS[m.kind];
tooltip.replaceChildren(el("b", {text: k.name}), el("span", {text: k.info}));
tooltip.dataset.kind = m.kind;
}
// beside the molecule, flipped left near the right edge; on narrow screens above it (below it near the top)
tooltip.style.left = "0px"; // measure at full width
const tw = tooltip.offsetWidth, th = tooltip.offsetHeight;
let left = m.x + 16 + tw > W - 4 ? m.x - 16 - tw : m.x + 16;
let top = Math.min(H - th - 4, Math.max(4, m.y - th / 2));
if (left < 4) {
left = Math.min(W - tw - 4, Math.max(4, m.x - tw / 2));
top = m.y - 24 - th >= 4 ? m.y - 24 - th : Math.min(H - th - 4, m.y + 24);
}
tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`;
tooltip.classList.add("on");
}
let tipTimer = null, tipPointer = null, tipHeld = false; // tipPointer: where the mouse rests, re-checked as the molecules drift; tipHeld: the mouse itself moved onto the note
const overTip = p => { // the pointer may move onto the note without closing it (WCAG 1.4.13)
if (!tooltip.classList.contains("on")) return false;
const t = tooltip.getBoundingClientRect();
return p.clientX >= t.left && p.clientX <= t.right && p.clientY >= t.top && p.clientY <= t.bottom;
};
canvas.addEventListener("pointermove", e => {
if (e.pointerType !== "mouse") return;
tipPointer = {clientX: e.clientX, clientY: e.clientY};
tipHeld = overTip(tipPointer); // keep the note open only when the mouse moved onto it (WCAG 1.4.13)
if (!tipHeld) showTip(tipPointer, 18);
});
canvas.addEventListener("pointerdown", e => {
if (e.pointerType === "mouse") return;
showTip(e, 32); clearTimeout(tipTimer); tipTimer = setTimeout(() => tooltip.classList.remove("on"), 4000);
});
canvas.addEventListener("pointerleave", e => { if (e.pointerType === "mouse") { tipPointer = null; tipHeld = false; tooltip.classList.remove("on"); } });
const tipEsc = e => { if (e.key === "Escape") { tipPointer = null; tipHeld = false; tooltip.classList.remove("on"); } };
window.addEventListener("keydown", tipEsc);
// ---------- Loop ----------
// The loop keeps running while the page is open (browsers pause it in background tabs);
// it skips the work while the simulation is scrolled out of view.
let raf = null, last = null, time = 0, visible = true, stopped = false;
function frame(now) {
raf = null;
if (stopped) return;
if (!visible) { last = null; raf = requestAnimationFrame(frame); return; }
const dt = last == null ? 0 : Math.min(0.05, (now - last) / 1000);
last = now;
if (mode === "tour" && auto && textInView && !tooltip.classList.contains("on")) { // the tour waits while its text is off screen or a tooltip is open
const dur = stageSeconds(STAGES[stage]);
stageClock += dt;
timer.firstChild.style.width = `${Math.min(100, stageClock / dur * 100)}%`;
if (stageClock >= dur) {
if (stage === STAGES.length - 1) { auto = false; syncAuto(); } // stop on the last step, do not loop
else goStage(stage + 1);
}
}
if (animPaused) approach(dt);
else { time += dt; step(dt, time); }
ctx.clearRect(0, 0, W, H);
drawBackground(time);
clouds.forEach(drawCloud);
drawSurface(time);
for (const m of molecules) drawMolecule(m, time);
drawPhotons(time);
drawSun(time);
drawHUD();
if (show.labels) drawLabels();
if (now - lastRO > 120) { updateReadouts(); if (tipPointer && !tipHeld) showTip(tipPointer, 18); lastRO = now; }
raf = requestAnimationFrame(frame);
}
// only re-lay out when the width really changes (the height follows the width), a frame later so that
// resizing the canvas inside the callback does not trip the browser's "ResizeObserver loop" error
let roFrame = null;
const ro = new ResizeObserver(() => {
cancelAnimationFrame(roFrame);
roFrame = requestAnimationFrame(() => { const w = stageWrap.clientWidth; if (w && w !== W) { layout(); reconcileMolecules(); if (animPaused) preroll(); } });
});
ro.observe(stageWrap);
const io = new IntersectionObserver(entries => { visible = entries[entries.length - 1].isIntersecting; });
io.observe(root);
let textInView = false; // the tour clock runs only while the step text is fully on screen
const ioText = new IntersectionObserver(e => { textInView = e[e.length - 1].intersectionRatio >= 0.99; }, {threshold: [0, 0.99, 1]});
ioText.observe(stepText);
invalidation.then(() => {
stopped = true; if (raf != null) cancelAnimationFrame(raf);
cancelAnimationFrame(roFrame); clearTimeout(stageTimer); clearTimeout(tipTimer);
window.removeEventListener("keydown", tipEsc); narrow.removeEventListener("change", placeNav);
ro.disconnect(); io.disconnect(); ioText.disconnect();
});
layout();
refresh();
goStage(0);
updateReadouts();
raf = requestAnimationFrame(frame);
return root;
}
TipWords used in the simulation
- ppm / ppb: parts per million / billion. 424 ppm of CO₂ means 424 CO₂ molecules in every million molecules of air.
- W/m²: watts per square metre, averaged over the whole Earth, day and night. 1 W/m² over the whole planet is about 25 times all the energy humanity uses.
- Settled: the temperature at which heat out to space equals sunlight in, so it stops changing.
- Feedback: a knock-on effect of warming that changes the warming. Warmer air holds more water vapour, which traps more heat.
Things to try
- In Explore yourself, press 1750 levels, then Today’s levels (2024). Watch the energy balance: for a while less heat goes out than sunlight comes in. Where does the temperature settle?
- Press 1750 levels and switch the feedbacks off. Drag CO₂ to 560 ppm (double 1750). How much warmer than 1750 does it settle? Now switch the feedbacks back on. How many times bigger is the warming?
- Methane is counted in parts per billion: today’s 1,940 ppb is under 2 ppm. Press Today’s levels (2024), then bring methane back to 730 ppb, its 1750 level: that removes just 1.2 ppm of gas. Note Heading towards. Now put methane back to 1,940 and lower CO₂ from 424 to 400 ppm instead, removing 24 ppm. Which cools the planet more? What does that say about methane, molecule for molecule?
- The world today is about 1.3 °C warmer than in the late 1800s (averaged over the last ten years), less than this model’s settled value for today’s gases. The main reason: the oceans take decades to centuries to warm up, so if the gases stayed at today’s levels, more warming would still be on its way. Air pollution (aerosols) also reflects sunlight and hides some warming. Cleaning up the air saves many lives but unmasks some of that hidden warming. How should health policy handle that trade-off?
TipAnswers
- A little over 2 °C warmer than 1750 (about 16.2 °C).
- About 1.1 °C without feedbacks and 3.0 °C with them: almost three times as much.
- Taking methane back to 1750 levels cools the settled temperature by nearly half a degree (to about 15.7 °C); cutting CO₂ by 24 ppm cools it by about a quarter of a degree (to about 15.9 °C), with 20 times more molecules removed. Molecule for molecule, methane traps far more heat.
- There is no single right answer. Cutting fossil fuels removes CO₂ and air pollution together: the cleaner air saves lives straight away, and less CO₂ limits the warming over the long run.
Why CO₂, but not nitrogen?
ghWhy = {
const root = document.createElement("div");
root.className = "ghwhy";
const panels = [
{kind: "N2", title: "Nitrogen (N₂) and oxygen (O₂)",
text: "Two identical atoms: however they vibrate, the charge stays balanced, so infrared passes straight through."},
{kind: "CO2", title: "Carbon dioxide (CO₂)",
text: "When it bends, its electric charge becomes lopsided, first one way, then the other. That lets it absorb infrared. It passes the energy to the air around it as heat, or gives it off again as infrared in any direction."}
];
const canvases = panels.map(p => {
const fig = document.createElement("figure");
const c = document.createElement("canvas");
c.setAttribute("role", "img");
c.setAttribute("aria-label", p.kind === "N2"
? "An infrared wave passes through a nitrogen molecule without being absorbed."
: "An infrared wave is absorbed by a carbon dioxide molecule, which bends and then re-emits the wave in another direction.");
const cap = document.createElement("figcaption");
const b = document.createElement("b"); b.textContent = p.title;
cap.append(b, document.createElement("br"), p.text);
fig.append(c, cap); root.append(fig);
return {c, ctx: c.getContext("2d"), kind: p.kind};
});
const reduceMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// tShown starts on a still frame: the N₂ wave already through, CO₂ glowing and re-emitting
let raf = null, t0 = null, visible = true, stopped = false, paused = false, tShown = 3.2;
function draw(now) {
raf = null;
if (stopped) return;
if (!visible && !reduceMotion && !paused) { raf = requestAnimationFrame(draw); return; }
if (t0 == null) t0 = now;
const t = reduceMotion || paused ? tShown : (tShown = (now - t0) / 1000);
for (const P of canvases) {
const w = P.c.clientWidth || 300, h = Math.round(w * (w > 360 ? 0.42 : 0.55)), dpr = Math.min(2, window.devicePixelRatio || 1);
if (P.c.width !== Math.round(w * dpr)) { P.c.width = Math.round(w * dpr); P.c.height = Math.round(h * dpr); P.c.style.height = h + "px"; }
const ctx = P.ctx; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "#1d2d4b"; ctx.fillRect(0, 0, w, h);
const cx = w * 0.55, cy = h * 0.5, r = Math.max(7, w / 30), cycle = 4.2, ph = (t % cycle) / cycle;
// wave position: travels from the left; for CO₂ it stops at the molecule and is re-emitted
const waveX = -40 + ph * (w + 80);
const absorbed = P.kind === "CO2" && waveX > cx - r * 2;
let excite = 0;
if (P.kind === "CO2") {
const hit = (cx - r * 2 + 40) / (w + 80);
excite = ph < hit ? 0 : Math.max(0, 1 - (ph - hit) / 0.45);
}
// draw wave(s)
const wave = (x0, y0, ang, alpha) => {
ctx.save(); ctx.translate(x0, y0); ctx.rotate(ang);
ctx.strokeStyle = `rgba(255,105,70,${alpha})`; ctx.lineWidth = 2.5; ctx.beginPath();
for (let k = 0; k <= 40; k++) { const x = -60 + k * 1.5, y = 7 * Math.sin(k * 0.6 + t * 12); if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }
ctx.stroke(); ctx.restore();
};
if (!absorbed) wave(waveX, cy, 0, 0.95);
if (P.kind === "CO2" && excite < 0.55 && ph > 0.5) {
const e = (ph - 0.5) / 0.5, ang = -0.9; // re-emitted upwards and to the right
wave(cx + Math.cos(ang) * e * w * 0.6, cy + Math.sin(ang) * e * w * 0.6, ang, 0.9 * (1 - e * 0.5));
}
// molecule
const atom = (x, y, rr, c) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(x, y, rr, 0, 6.283); ctx.fill(); };
if (P.kind === "N2") {
const s = r * 1.1 * (1 + 0.12 * Math.sin(t * 10));
atom(cx - s, cy, r, "#7da2ff"); atom(cx + s, cy, r, "#7da2ff");
ctx.fillStyle = "rgba(255,255,255,.7)"; ctx.font = "12px 'Source Sans 3', sans-serif"; ctx.textAlign = "center";
ctx.fillText("charge stays balanced", cx, cy + r * 3);
} else {
if (excite > 0) { // soft glow, not a flat disc
const gr = r * (4 + 2 * excite), g = ctx.createRadialGradient(cx, cy, r, cx, cy, gr);
g.addColorStop(0, `rgba(255,130,90,${0.6 * excite})`); g.addColorStop(1, "rgba(255,130,90,0)");
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, gr, 0, 6.283); ctx.fill();
}
const bend = (0.25 + excite) * r * 1.4 * Math.sin(t * (excite > 0 ? 24 : 8));
ctx.strokeStyle = "rgba(255,255,255,.4)"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(cx - r * 2.2, cy); ctx.lineTo(cx, cy + bend); ctx.lineTo(cx + r * 2.2, cy); ctx.stroke();
atom(cx - r * 2.2, cy, r, "#ff6b6b"); atom(cx + r * 2.2, cy, r, "#ff6b6b"); atom(cx, cy + bend, r * 0.95, "#9aa3b5");
ctx.fillStyle = "rgba(255,255,255,.7)"; ctx.font = "12px 'Source Sans 3', sans-serif"; ctx.textAlign = "center";
ctx.fillText(excite > 0.05 ? "absorbs the wave and vibrates" : "bending shifts the charge", cx, cy + r * 3.6);
}
}
if (!reduceMotion && !paused) raf = requestAnimationFrame(draw);
}
// a way to stop the motion; still pictures are redrawn when the panels change size
const btnPause = document.createElement("button");
btnPause.type = "button"; btnPause.className = "ghwhy-pause";
const syncPause = () => { btnPause.textContent = paused ? "Play these animations" : "Pause these animations"; };
btnPause.addEventListener("click", () => {
paused = !paused; syncPause();
if (!paused) t0 = performance.now() - tShown * 1000; // carry on from the frozen frame
if (raf == null && !stopped) raf = requestAnimationFrame(draw);
});
if (!reduceMotion) { syncPause(); root.append(btnPause); }
const ro = new ResizeObserver(() => { if (raf == null && !stopped) raf = requestAnimationFrame(draw); });
ro.observe(root);
const io = new IntersectionObserver(e => { visible = e[e.length - 1].isIntersecting; });
io.observe(root);
invalidation.then(() => { stopped = true; if (raf != null) cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); });
raf = requestAnimationFrame(draw);
return root;
}The main greenhouse gases
| Gas | In the air today | Main human sources | How long it stays | Heat trapped per kg over 100 years (CO₂ = 1) |
|---|---|---|---|---|
| Carbon dioxide (CO₂) | 0.04% (424 ppm) | Burning coal, oil and gas; clearing forests | Centuries to millennia (part of it) | 1 |
| Methane (CH₄) | 0.0002% (about 1,940 ppb) | Livestock, rice, landfills, gas and coal leaks | About 12 years | About 27 to 30 |
| Nitrous oxide (N₂O) | 0.00003% (about 338 ppb) | Farm fertilisers, manure, industry | About 110 years | About 270 |
| Water vapour (H₂O) | Up to a few % near the ground | Not directly: it follows the temperature | About 10 days | A feedback, not a driver |
The last column is the “global warming potential” (GWP-100), the factor carbon footprints use to turn emissions of other gases into CO₂-equivalents (CO₂e). It adds up the heat a kilogram traps over 100 years. Methane does most of its trapping in the first decade or two after it is released, so in those early years it warms far more than its GWP suggests, and a century later little of it is left.
Common mix-ups
CO₂ is only 0.04% of the air. How can it matter? Nitrogen, oxygen and argon, 99.9% of dry air, do nothing to infrared, so all the catching is done by clouds and a handful of other gases: water vapour, CO₂, methane and a few more. Small doses can have big effects: a blood alcohol level of 0.08% is enough to impair a driver.
Water vapour is the biggest greenhouse gas. Why worry about CO₂? Water vapour rains out within about ten days, so the amount in the air simply follows the temperature. CO₂ stays for centuries, so it sets the temperature, and water vapour then amplifies the change.
Is this the ozone hole? No. The ozone hole lets more of the Sun’s ultraviolet through, which raises the risk of skin cancer and cataracts. It is a separate problem, slowly healing thanks to the 1987 Montreal Protocol.
Do greenhouse gases form a layer that reflects heat? No. They are mixed all through the air, and they absorb infrared rather than reflect it like a mirror.
Hasn’t CO₂ already caught all the infrared it can? Near the ground, almost. But heat escapes to space from high up, where the air is thin and cold. More CO₂ pushes that escape level higher, into colder air that gives off less heat, so the planet still warms.
Why it matters for health
The greenhouse effect is not the problem in itself: without it the planet would be frozen. The problem is how fast we are strengthening it. A couple of degrees on the global average sounds small, but averages hide the extremes. Extreme heat that used to strike once a decade now strikes almost three times as often, and would strike more than five times as often with 2 °C of global warming (IPCC 2021, Figure SPM.6). For health, that shows up as more frequent and intense heatwaves, changing patterns of infectious disease, and pressure on food and water supplies. And the fossil fuels that add CO₂ also pollute the air, so cutting them brings health benefits straight away. These links are the subject of the Health and Environment module.
NoteAbout this model
This is a teaching model, not a climate model. The planet is one average point with a single layer of air that absorbs a share of the surface’s infrared and sends it back out, half up and half down.
- Sunlight: 1,361 W/m² at the top of the atmosphere, facing the Sun. Spread over the whole globe, day and night, that is about 340 W/m², and 30% of it is reflected, so about 238 W/m² is absorbed.
- The share of infrared absorbed is set so that, with 1750 levels of CO₂ (280 ppm) and methane (730 ppb), the surface settles at about 14 °C. That share (76% in 1750) is a setting of this one-layer model. Real air absorbs nearer 90% of the surface’s infrared, but the surface also loses heat by evaporation and by rising warm air, and the air soaks up some sunlight directly. This model leaves both out, so a smaller share stands in for them.
- Extra CO₂ and methane strengthen the trapping using the simplified formulas of Myhre et al. (1998). Nitrous oxide and the other gases stay at their 1750 levels in every scenario, including today’s.
- The feedbacks switch stands for all the feedbacks together: extra water vapour (the largest), melting ice and changing clouds. It is calibrated so that doubling CO₂ gives 3 °C of warming once settled, the IPCC’s best estimate. Without it, the model gives the no-feedback response of a little over 1 °C.
- Not included: aerosols (air pollution) and the oceans’ slow uptake of heat. Time is sped up enormously: the real climate takes decades to centuries to settle.
- In real air, a molecule that catches infrared almost always passes the energy to its neighbours by bumping into them, warming the air; the warm greenhouse gases then give off infrared in all directions. The animation shortens this to one molecule catching and re-sending a wave.
- The molecules are not to scale. CO₂ is about 0.04% of the air (about 2,300 nitrogen and oxygen molecules for each CO₂), and water-vapour molecules multiply with warming much faster than in reality (real air holds about 7% more per °C) so that the feedback is visible.
Sources
- IPCC (2021). Summary for Policymakers. In: Climate Change 2021: The Physical Science Basis. Best estimate of 3 °C warming per doubling of CO₂; projected warming for 2081–2100 (Table SPM.1); more frequent hot extremes (Figure SPM.6). https://doi.org/10.1017/9781009157896.001
- Forster P, Storelvmo T, et al. (2021). The Earth’s energy budget, climate feedbacks and climate sensitivity. In: Climate Change 2021: The Physical Science Basis, Chapter 7, 923–1054. Table 7.15: lifetimes and global warming potentials (methane 27 from farms and waste, 30 from fossil fuels). https://doi.org/10.1017/9781009157896.009
- WMO (2025). WMO Greenhouse Gas Bulletin No. 21. Greenhouse gas levels in 2024 and their rise since 1750. https://wmo.int/resources/publication-series/greenhouse-gas-bulletin/wmo-greenhouse-gas-bulletin-no-21
- Forster PM, et al. (2026). Indicators of Global Climate Change 2025: annual update of key indicators of the state of the climate system and human influence. Earth System Science Data, 18, 3889–3933. https://doi.org/10.5194/essd-18-3889-2026
- Meinshausen M, et al. (2020). The shared socio-economic pathway (SSP) greenhouse gas concentrations and their extensions to 2500. Geoscientific Model Development, 13, 3571–3605. https://doi.org/10.5194/gmd-13-3571-2020
- Myhre G, Highwood EJ, Shine KP, Stordal F (1998). New estimates of radiative forcing due to well mixed greenhouse gases. Geophysical Research Letters, 25(14), 2715–2718. https://doi.org/10.1029/98GL01908
- Trenberth KE, Fasullo JT, Kiehl J (2009). Earth’s global energy budget. Bulletin of the American Meteorological Society, 90(3), 311–323. https://doi.org/10.1175/2008BAMS2634.1
- NOAA Global Monitoring Laboratory: trends in atmospheric CO₂ and methane. https://gml.noaa.gov/ccgg/trends/
- Romanello M, et al. (2025). The 2025 report of the Lancet Countdown on health and climate change. The Lancet, 406(10521), 2804–2857. https://doi.org/10.1016/S0140-6736(25)01919-1
calloutKeys = {
// Quarto's collapsible callout headers are plain <div>s: make each one a keyboard button named by its title
for (const h of document.querySelectorAll(".callout-header[data-bs-toggle=collapse]")) {
h.setAttribute("role", "button"); h.tabIndex = 0; h.removeAttribute("aria-label");
h.addEventListener("keydown", e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); h.click(); } });
}
return null;
}