🌍 I Built a Climate Time Machine That Makes CO₂ Data Feel Personal
This is a submission for Weekend Challenge: Earth Day Edition
The problem with climate data is that it's abstract. "+1.25°C" doesn't land. "427 ppm CO₂" doesn't hurt. Numbers don't move people — stories do.
So I asked a different question: what if you could see exactly what the planet looked like the year you were born?
That's Earth Time Machine. Enter your birth year. Feel what changed.
What I Built
Earth Time Machine is an interactive climate data experience that makes global warming feel personally relevant by anchoring every metric to your birth year.
The core idea
Instead of showing abstract global averages, it asks: what was CO₂ when you were born? How much Arctic ice existed? How many more people share this planet now? Every number becomes a story about your lifetime.
Features
🌐 Animated 3D globe in the hero — hand-coded in Canvas 2D, no WebGL library. The globe visibly browns and cracks based on the CO₂ rise since your birth year. Born in 1960? Healthy green oceans. 2008? Noticeably warmer.
💨 CO₂ breathing animation — the number counts up from zero to your lifetime rise, then locks and pulses gently like Earth exhaling. No looping. It arrives and stays.
📊 6 planetary vital sign cards — CO₂, temperature, Arctic sea ice, forest cover, population, sea level. Each has a sparkline, animated thermometers, comparison bars, and a "sting" — a one-line gut-punch fact that reveals after you've absorbed the numbers.
🌍 20-country local data — select your country and see local warming vs world average, per-capita CO₂ then vs now, and your country's climate risk tier. India's local warming hits differently than Canada's.
🔮 4 IPCC scenario projections to 2100 — actual path, Paris 1.5°C, moderate action, worst case. Canvas-drawn chart showing where each path leads. Interactive tabs with verdicts.
🌿 Paris Path toggle — overlays what values should be if the Paris Agreement was met. The gap is sobering.
🎮 Generational compare mode — enter two birth years, see the CO₂ absorbed between them, the temperature difference, the ice lost. Perfect for showing a parent vs child what Earth each of them inherited.
🎯 Climate knowledge quiz — 4 randomised questions from a pool of 8. Immediate feedback, score bar, explanations. Keeps people engaged after the data shock.
🔊 Ambient sound — two sine-wave oscillators through the Web Audio API. As you drag the year scrubber, the pitch shifts — deeper in early decades, eerier as CO₂ rises. Subtle but effective.
📥 Download your Earth Identity Card as PNG via html2canvas. Share your personal climate report on social media.
🌐 Live CO₂ clock in the sticky toolbar — ticks upward in real time at the actual rate (2.4 ppm/year). Watch it move.
Demo
🔗 https://earth-time-machine.vercel.app/
Try entering:
- 1960 — see a relatively healthy planet, then watch how much changed
- 1990 — the year of the Earth Summit, Rio. CO₂ was already rising fast
- 2005 — the year Arctic ice hit a then-record low
Drag the year scrubber slowly from 1950 to 2026. Watch the CO₂ pill change. Listen to the drone shift. Find 2007 — the year the Arctic shattered its record.
Code
React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- @vitejs/plugin-react uses Oxc
- @vitejs/plugin-react-swc uses SWC
React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see this documentation.
Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the TS template for information on how to integrate TypeScript and typescript-eslint in your project.
Built with:
- React 19 + Vite 6
-
Tailwind CSS v4 (single
@import "tailwindcss"— no config file) - Canvas 2D for the globe and all charts (no chart library)
- Web Audio API for ambient sound
- html2canvas for PNG card export
Zero external data dependencies. All climate datasets (CO₂, TEMP, ICE, POP, FOREST, SEA) are bundled from NOAA, NASA GISTEMP, NSIDC, FAO, and UN World Population Prospects.
How I Built It
The globe
The hardest part was making the hero globe look like Earth — not a red blob, not random ellipses.
The breakthrough was switching from position: absolute painted ellipses to actual spherical projection math. Each continent is defined as a series of [lat, lon] pairs. A project(lat, lon, t) function maps them onto the 2D canvas using:
function project(lat, lon, t) {
const phi = (lat * Math.PI) / 180;
const lam = (lon * Math.PI) / 180 + t * 0.35; // t = spin time
const cosLat = Math.cos(phi);
const x3d = cosLat * Math.sin(lam);
const y3d = Math.sin(phi);
const z3d = cosLat * Math.cos(lam);
if (z3d < -0.05) return null; // back-face cull
return [cx + x3d * r, cy - y3d * r];
}
Back-face culling (z3d < -0.05) means continents on the far side of the globe simply don't draw. The spin is smooth because t increments 0.008 per frame via requestAnimationFrame.
The heat factor maps CO₂ rise since your birth year onto a 0 → 0.58 value that shifts:
- Ocean colour (vivid blue → murky)
- Land colour (forest green → reddish-brown)
- Ice cap size (large → tiny)
- Atmosphere halo (blue-cyan → dimmer)
let heat = 0;
if (birthYear > 0) {
const rise = interp(CO2, 2026) - interp(CO2, birthYear);
heat = Math.min(rise / 90, 0.58);
}
The CO₂ number
The original version looped through seasonal offsets forever, making the number tick up and down endlessly. That felt anxious and wrong.
The fix: count up once with a cubic ease-out, then lock the value and add a pure CSS pulse — no more JS interval touching the text:
const tick = (now) => {
const p = Math.min((now - start) / 1800, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.textContent = `+${(target * ease).toFixed(1)}`;
if (p < 1) requestAnimationFrame(tick);
else {
el.textContent = `+${target.toFixed(1)}`; // locked
el.classList.add('breathing'); // CSS pulse only
}
};
@keyframes co2Breathe {
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 0px rgba(192,64,48,0)); }
50% { transform: scale(1.028); filter: drop-shadow(0 0 18px rgba(192,64,48,.45)); }
}
Gentle, readable, and the number never changes again.
Sound design
Web Audio API is blocked until a user gesture (browser policy). The fix is audioCtx.resume() which returns a Promise — you must wait for it:
const toggle = () => {
ctx.resume().then(() => {
gain.gain.cancelScheduledValues(ctx.currentTime);
gain.gain.setTargetAtTime(0.6, ctx.currentTime, 0.4);
updatePitch(CO2[2026]);
});
};
Two oscillators detuned by 2.5Hz create a warm beating effect. As the year scrubber moves, setTargetAtTime glides the frequency smoothly rather than jumping:
function updatePitch(co2) {
const t = Math.max(0, Math.min((co2 - 310) / 120, 1));
const freq = 45 + t * 30; // 45Hz clean → 75Hz tense
osc1.frequency.setTargetAtTime(freq, ctx.currentTime, 1.5);
osc2.frequency.setTargetAtTime(freq * 1.045, ctx.currentTime, 1.5);
}
Architecture
The whole app is structured around a single truth: birthYear. Everything derives from it.
App.jsx
├── birthYear (state) ─────────────────────────────┐
├── Hero → onReveal(year) sets birthYear │
├── ShockSection ← birthYear │
├── Toolbar ← country, toggles │
├── CountryPanel ← birthYear + country │
├── Scrubber ← birthYear + sliderYear │
├── ScenarioChart ← birthYear + scenario │
├── MetricCards ← birthYear + country + modes │
├── CompareSection ← birthYear │
├── Quiz ← birthYear (resets questions) │
├── Timeline ← birthYear (filters events) │
└── ShareCard ← birthYear + country │
────────────────────────────────┘
No Redux, no Zustand. Just useState in App.jsx with props passed down. The data layer is pure JS objects — no API calls, no loading states, instant renders.
The data
All datasets are annual means from primary sources, hand-curated and interpolated:
// Linear interpolation across sparse data
export function interp(data, year) {
if (data[year] !== undefined) return data[year];
const keys = Object.keys(data).map(Number).sort((a, b) => a - b);
// find surrounding years and lerp
const t = (year - lo) / (hi - lo);
return data[lo] + t * (data[hi] - data[lo]);
}
This means every year from 1950–2026 returns an accurate value even if we only store data every 5 years.
What I'd do with more time
- SVG world map with country-level temperature choropleth
- Real Keeling Curve seasonal animation (actual monthly NOAA data)
- Share to Twitter/X with OpenGraph preview card
- Offline PWA support — the whole app works without internet anyway
Design Philosophy
The biggest design decision wasn't technical — it was emotional.
Climate data is usually presented as a problem to solve. Numbers, charts, policy. It lands as guilt or numbness.
Earth Time Machine tries a different frame: this happened in your lifetime. You were there. The CO₂ that rose, rose while you were alive. The ice that melted, melted while you watched.
That's not guilt — it's stakes. And stakes make people want to act.
The dark brown/forest green colour palette is intentional. Earth tones. Soil. The feeling of something organic under stress, not cold data on white.
The CO₂ number that arrives and pulses — not loops — is intentional. It's not a process. It's a fact. It stays.
Prize Categories
Not submitting to sponsored prize categories — this project uses no third-party AI APIs, authentication services, or blockchain infrastructure. Just the web platform, open climate data, and a weekend.
Data sources: NOAA Mauna Loa (CO₂), NASA GISTEMP v4 (temperature), NSIDC Sea Ice Index (Arctic ice), FAO FRA 2025 (forest), UN World Population Prospects (population), CSIRO/Church & White 2011 (sea level). All datasets are annual means verified against primary sources.
Built over one weekend for Earth Day 2026. The planet deserved better tooling.
Top comments (0)