Japan's official seismic data — from the Japan Meteorological Agency — is the most authoritative catalog of domestic earthquakes. It's also un-callable from a browser: no CORS headers, XML payloads, and bulk-download endpoints that gate behind accounts. A real-time map of "what just shook near Japan" needs a different feed. USGS's FDSN event service has it: CORS-enabled, no auth, GeoJSON, 5-10 min publishing latency. This is the 300-line page that wires that feed into a Leaflet map with magnitude-sized, depth-colored epicenter circles, refreshing every 5 minutes.
🌐 Demo: https://sen.ltd/portfolio/earthquake-jp/
📦 GitHub: https://github.com/sen-ltd/earthquake-jp
Why USGS, not JMA
Japan Meteorological Agency (JMA) publishes a much more complete domestic catalog — it goes down to M2 and below, names of villages, JMA shindo intensities. None of which a browser can fetch directly:
-
No CORS on the JMA endpoints. Same-origin policy blocks
fetch()andXMLHttpRequest. - XML, not JSON — and the schemas are pretty maximal (the same envelope for several event types).
- Bulk downloads route through scientific portals (NIED J-SHIS, etc.) that gate access.
To use JMA from a browser you need a server in the middle. P2P Quake (api.p2pquake.net) is a community-maintained relay that does exactly that, and it's a fine answer when JMA-only data is required.
USGS FDSN event service (earthquake.usgs.gov/fdsnws/event/1/) doesn't have any of those problems:
Access-Control-Allow-Origin: *- No auth
- GeoJSON in, no parsing gymnastics
- 5-10 minute latency for new events (good enough for "watch what just happened" use cases)
The trade is "only M2.5+", since USGS is the global catalog, not the JMA domestic-fine catalog. For the visualization-of-everything-recent purpose this page exists for, that's fine.
One URL with everything
The FDSN event service accepts a time window and a geographic bounding box. For Japan I use a generous box (lat 24-46, lon 122-148) that covers the archipelago plus the offshore subduction zones — most "felt in Japan" earthquakes originate on the wider plate boundaries:
export const JP_BBOX = {
minLat: 24, maxLat: 46, minLon: 122, maxLon: 148,
};
export function buildFeedUrl(windowHours, minMag, nowMs = Date.now()) {
const end = new Date(nowMs).toISOString();
const start = new Date(nowMs - windowHours * 3600_000).toISOString();
const params = new URLSearchParams({
format: "geojson",
starttime: start, endtime: end,
minlatitude: String(JP_BBOX.minLat),
maxlatitude: String(JP_BBOX.maxLat),
minlongitude: String(JP_BBOX.minLon),
maxlongitude: String(JP_BBOX.maxLon),
minmagnitude: String(minMag),
orderby: "time",
});
return `https://earthquake.usgs.gov/fdsnws/event/1/query?${params.toString()}`;
}
nowMs is a parameter, not Date.now() baked in, because tests pin it. Same reason most of the helper functions take an injected clock — production calls pass Date.now(), tests pass 1_700_000_000_000.
Linear-in-magnitude circle radii
Richter magnitude is the base-10 logarithm of seismic energy. M7 is ~32× the energy of M6; M5 is ~1000× M3. A naive radius = energy scaling produces a map where a single M9 swallows the visible area while M3 events vanish.
What reads naturally to the eye on a map is linear-in-magnitude radius:
export function magnitudeToRadius(mag) {
if (mag == null || mag < 0) return 4;
const r = 3 + (mag - 1) * 4.4;
if (r < 3) return 3;
if (r > 38) return 38;
return r;
}
| Magnitude | Radius |
|---|---|
| M1 | 3 px |
| M3 | 11.8 px |
| M5 | 20.6 px |
| M7 | 29.4 px |
| M9 | 38 px (clamped) |
The clamp matters: when a great earthquake (M9) lands in the dataset, we don't want it to render at 250 px and bury its neighbours. The unit test pins both ends:
test("magnitudeToRadius clamps the upper end so M9 doesn't blow out", () => {
assert.equal(magnitudeToRadius(9), 38);
assert.equal(magnitudeToRadius(11), 38);
});
test("magnitudeToRadius grows linearly in magnitude", () => {
const m3 = magnitudeToRadius(3);
const m5 = magnitudeToRadius(5);
const m7 = magnitudeToRadius(7);
assert.ok(Math.abs((m5 - m3) - (m7 - m5)) < 0.5); // equal 2-mag gaps
});
Depth-encoded color
Damage from an earthquake depends on magnitude × depth. An M5 at 10 km is a direct-overhead shock; an M5 at 500 km is a soft wide-area tremor. The map encodes that by colour:
const stops = [
[0, [0xef, 0x44, 0x44]], // red — surface
[30, [0xf9, 0x73, 0x16]], // orange
[70, [0xea, 0xb3, 0x08]], // yellow
[150, [0x10, 0xb9, 0x81]], // teal-green
[300, [0x06, 0x6c, 0xb5]], // blue
[700, [0x1e, 0x40, 0xaf]], // deep blue — slab depth
];
A few design choices behind this:
- Stops don't wrap around the colour wheel. Once we leave yellow we keep heading to green and blue, never returning to red. This avoids the cognitive trap where the viewer can't tell whether two same-coloured dots are at the same depth or at very different depths a full hue cycle apart.
- Surface is the warm side. People expect warm colours to mean "danger" and most surface-shock damage clusters at shallow depths.
- Slab depths get cooler. 300-700 km events (the subducting Pacific plate) feel different and are coloured differently.
depthToColor linearly interpolates the RGB channels between adjacent stops, returning a #rrggbb string the Leaflet circleMarker can use directly.
Leaflet + CARTO Dark, no API key
Leaflet 1.9.4 is loaded from unpkg, CARTO Voyager Dark from their tile CDN — both free with attribution, both keyless. The alternative (maplibre-gl with vector tiles) is sharper but requires either an account at MapTiler / Stadia / similar, or a self-hosted tile server. Not worth it here when raster tiles render at 5 zoom levels and look fine:
const map = L.map("map", { zoomSnap: 0.5 }).setView([36.5, 138], 5);
L.tileLayer(
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
{ attribution: '© OSM © CARTO', maxZoom: 9, minZoom: 3 },
).addTo(map);
maxZoom: 9 is a deliberate choice — the whole archipelago fits on screen, prefecture names are readable, and anyone who wants the deep-zoom view can click an epicenter and follow the popup link to the USGS detail page.
5-minute polling, with a visible countdown
USGS doesn't offer SSE or WebSocket for events. Polling is the only option. Every 5 minutes, the page re-issues the same FDSN query and replaces the marker layer. A second 1-second timer drives a countdown so the user can see exactly how stale the on-screen data is:
function scheduleAutoRefresh() {
if (state.refreshTimer) clearInterval(state.refreshTimer);
state.refreshTimer = setInterval(fetchAndRender, REFRESH_INTERVAL_MS);
state.countdownTimer = setInterval(updateCountdown, 1000);
}
function updateCountdown() {
if (!state.lastFetchAt) return;
const remainingMs = state.lastFetchAt + REFRESH_INTERVAL_MS - Date.now();
const m = Math.floor(remainingMs / 60_000);
const s = Math.floor((remainingMs / 1000) % 60);
els.nextRefresh.textContent = `次回更新まで ${m}:${String(s).padStart(2, "0")}`;
}
Background tabs are a known issue — Chrome and Safari throttle setInterval to 1 Hz once a tab is hidden. For a 5-minute interval that's fine; what matters is that the content matches the displayed time, and the countdown always shows the truth.
DOM-free helpers, tested in isolation
quakes.js has no DOM access, no Leaflet, no fetch. Everything testable is in there: URL building, magnitude/depth scales, feature flattening, bbox / time filters, summary stats, relative-time formatting. script.js glues those to the map and the network.
node --test exercises the pure layer against synthetic features and injected nowMs:
$ npm test
✔ buildFeedUrl carries window + bbox + minmagnitude params
✔ buildFeedUrl windows backwards from nowMs
✔ magnitudeToRadius grows linearly in magnitude
✔ magnitudeToRadius clamps the upper end so M9 doesn't blow out
✔ depthToColor returns warm hex for surface events
✔ depthToColor returns cool hex for slab-depth events
✔ depthToColor interpolates between anchor stops
✔ featureToRow flattens USGS GeoJSON to {lat, lon, depth, mag, ...}
✔ featureToRow returns null for malformed input
✔ filterToJp drops points outside the bbox
✔ filterByWindow keeps only rows newer than (now - hours*3600s)
✔ statsFromRows tracks count, max magnitude, average, most-recent time
✔ formatRelativeJa uses 分前 / 時間前 / M/D HH:mm thresholds
…
ℹ tests 17 ℹ pass 17 ℹ fail 0
The USGS API itself isn't tested — that's a contract with a third party, not our code.
Try it
The demo at https://sen.ltd/portfolio/earthquake-jp/ ships with the 7-day / M2.5 window selected by default. Drop to M2.5 / 24h to see the fine grain near the Tohoku coast, or move to 30 days / M5 to see only the bigger events on a longer canvas. Click any epicenter for magnitude, depth, place name (JMA-style), and a link to the USGS detail page.
Source: https://github.com/sen-ltd/earthquake-jp — MIT, ~300 lines of JS, 17 unit tests, no build step, no npm dependencies (Leaflet loads from CDN).
🛠 Built by SEN LLC as part of an ongoing series of small, focused developer tools. Browse the full portfolio for more.

Top comments (0)