October 28, 2026 · 7 min read

Building a live 3D earthquake globe with USGS data

A static website that shows every earthquake on Earth as it is reported, replays the last day or month, estimates how strongly each one was felt where you are, and keeps working when the internet doesn't. No backend, no API key.

Live Earthquake Globe on GitHub

Why a globe?

Most earthquake sites show a list or a flat map. A list hides the pattern, and a flat map stretches the places where earthquakes actually cluster, like the edges of the Pacific. On a globe you can spin to any region and immediately see the ring of fire, the Himalayas or the Alborz and Zagros mountains light up.

I also set myself one constraint: it had to run as plain static files on GitHub Pages. That ruled out a server, a database and API keys, and pushed every decision into the browser.

The data: USGS GeoJSON feeds

The U.S. Geological Survey publishes ready-made GeoJSON feeds that are regenerated every minute and can be fetched straight from the browser. The URL pattern is simple: a minimum magnitude and a time window.

https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_day.geojson
# magnitude: significant | 4.5 | 2.5 | 1.0 | all
# period:    hour | day | week | month

Each earthquake is a GeoJSON feature. The fields I use are the magnitude, the place, the time in milliseconds, a tsunami flag and the coordinates:

{
  "id": "...",
  "properties": { "mag": 5.5, "place": "Reykjanes Ridge", "time": 1793178000000, "tsunami": 0 },
  "geometry": { "coordinates": [-35.1, 54.2, 10] }
}

One detail that is easy to get wrong: GeoJSON coordinates are [longitude, latitude, depth], not latitude first. The third value is the depth in kilometers, which turned out to be useful for coloring.

For history I use a second USGS service, the FDSN event API. A single request returns every M7+ earthquake since 1900, which became the "major quakes since 1900" layer.

Outside the United States, USGS is mainly complete for moderate and larger events. Small local earthquakes are better covered by national networks, so the globe is honest about what it shows: the global picture, not every tremor.

Drawing the globe

The 3D part is built on globe.gl, a layer-based wrapper around three.js. Each earthquake becomes a small column whose height and color depend on its magnitude, and earthquakes from the last hour get animated ripples:

const globe = new Globe(document.getElementById('globe'))
  .pointsData(quakes)
  .pointAltitude(q => clamp((q.mag + 1) * 0.012, 0.005, 0.18))
  .pointColor(q => magColor(q.mag))
  .ringsData(quakes.filter(q => Date.now() - q.time < HOUR));

For the "where do earthquakes happen" question, individual points are too noisy. Instead, the last 30 days of M2.5+ events are binned into hexagons, and the height of each hexagon shows how active that area was. Switching the same layer to the 1900+ catalogue shows the long-term picture.

The maps come in four styles. The realistic one adds relief, reflective oceans and a slowly drifting cloud layer. The satellite and dark styles use map tiles, so they load sharper imagery as you zoom in instead of stretching one big texture.

Staying live without flickering

The page polls the feed every 60 seconds. My first version replaced the whole dataset each time, and every point re-animated from the ground up once a minute, which looked broken. The fix was to keep the existing objects and only update them, so the renderer sees the same objects and leaves them alone:

const prev = new Map(state.quakes.map(q => [q.id, q]));
const quakes = parse(geo).map(q => {
  const isNew = !state.firstLoad && !state.seen.has(q.id);
  state.seen.add(q.id);
  return Object.assign(prev.get(q.id) || q, q, { isNew });
});

The same set of seen IDs tells me which events are new since the last refresh. Those trigger a notification, an optional sound and, if you want, the camera flies to the epicenter.

How strong was it where I am?

Magnitude describes the earthquake; intensity describes the shaking at a place. After you share your location (it never leaves the browser), each event gets a rough intensity estimate using the Bakun & Wentworth relation, based on magnitude and the straight-line distance to the hypocenter:

const r = Math.hypot(distanceKm, depthKm);   // distance to the hypocenter
const mmi = 3.67 + 1.17 * magnitude - 3.19 * Math.log10(r);

The result is mapped to the Modified Mercalli scale, from "I · Not felt" to "X · Extreme". It ignores local soil and building effects, so the page always labels it as a rough estimate.

Replay and the activity chart

The replay mode plays the selected time window, a day or a month, in about 30 seconds. Under the hood it only filters the loaded events by time, so it costs no extra requests. A small bar chart shows activity over time, each bar colored by the strongest event in it, and clicking a bar jumps the replay to that moment.

English and Persian

The interface switches between English and Persian, including a full right-to-left layout. Two things made this easier than expected. CSS logical properties like inset-inline-start flip automatically with the text direction. The Intl APIs handle Persian digits and the Persian calendar for free, using the fa-IR locale.

One surprise: the 3D text labels on the globe use a typeface without Persian glyphs, so magnitude labels on the globe itself always use Latin digits, while the rest of the interface is fully localized.

Working offline

A service worker makes the site installable and usable without internet. Different requests get different strategies: the earthquake data is network-first, so you always get fresh data when online and the last saved copy when not. Map tiles are cache-first, because they never change.

if (url.hostname === 'earthquake.usgs.gov') networkFirst(request);
else if (isMapTile(url)) cacheFirst(request);

To avoid presenting old data as live, every feed carries a generated timestamp. If it is more than five minutes old, the status switches from "Live" to "Offline · showing saved data".

A bug worth sharing

After a large update, the live page got stuck on its loading spinner. The browser console showed $(...) is null: the new JavaScript was looking for buttons that only exist in the new HTML, but the old HTML was still being served. Nothing was wrong with the code itself, only with the deployment. Two lessons: when HTML and JavaScript change together, ship and verify them together, and always open the console before guessing.

What's next

The whole project is open source under the MIT license. Try the live globe or read the code on GitHub.

Data: USGS Earthquake Hazards Program. 3D: globe.gl and three.js. Intensity: Bakun & Wentworth (1997).