natural disasters
DISASTER MAP
Live earthquakes, storms, volcanoes, wildfires & floods worldwide. Data from NASA EONET and USGS — updated in real-time, no signup needed.
Developer Reference
Core Algorithm & Standalone Script
Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.
(function () {
/* ── CONFIG ────────────────────────────────────────── */
const EONET_URL = 'https://eonet.gsfc.nasa.gov/api/v3/events?status=all&days=30&limit=200';
const USGS_URL = 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson';
const CAT_META = {
earthquakes: { label: 'Earthquake', color: '#ff0000', icon: '≋', bg: 'rgba(255,0,0,0.15)' },
severeStorms: { label: 'Severe Storm', color: '#f5c518', icon: '⌁', bg: 'rgba(245,197,24,0.15)' },
volcanoes: { label: 'Volcano', color: '#9d00ff', icon: '△', bg: 'rgba(157,0,255,0.15)' },
wildfires: { label: 'Wildfire', color: '#ff8000', icon: '⬡', bg: 'rgba(255,128,0,0.15)' },
floods: { label: 'Flood', color: '#4488ff', icon: '≈', bg: 'rgba(68,136,255,0.15)' },
landslides: { label: 'Landslide', color: '#aa8855', icon: '◿', bg: 'rgba(170,136,85,0.15)' },
seaLakeIce: { label: 'Sea/Lake Ice', color: '#aaddff', icon: '❄', bg: 'rgba(170,221,255,0.15)' },
drought: { label: 'Drought', color: '#cc9944', icon: '○', bg: 'rgba(204,153,68,0.15)' },
dustHaze: { label: 'Dust / Haze', color: '#bbaa88', icon: '≀', bg: 'rgba(187,170,136,0.15)' },
tempExtremes: { label: 'Temp Extreme', color: '#ff8855', icon: '◈', bg: 'rgba(255,136,85,0.15)' },
other: { label: 'Other', color: '#888888', icon: '⬤', bg: 'rgba(136,136,136,0.15)' },
};
function getCatMeta(catId) {
return CAT_META[catId] || CAT_META.other;
}
/* ── MAP INIT ──────────────────────────────────────── */
const map = L.map('map', {
center: [20, 0],
zoom: 2,
zoomControl: true,
attributionControl: true,
scrollWheelZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 18,
}).addTo(map);
let allMarkers = [];
let allEvents = [];
let activeFilter = 'all';
/* ── NORMALIZE EVENTS ──────────────────────────────── */
function geomToLatLng(geom) {
if (!geom || !geom.coordinates) return [null, null];
if (geom.type === 'Point') {
return [geom.coordinates[1], geom.coordinates[0]]; // [lat, lng]
}
if (geom.type === 'Polygon') {
// centroid of exterior ring
const ring = geom.coordinates[0] || [];
if (!ring.length) return [null, null];
const lat = ring.reduce((s, p) => s + p[1], 0) / ring.length;
const lng = ring.reduce((s, p) => s + p[0], 0) / ring.length;
return [lat, lng];
}
if (geom.type === 'MultiPolygon') {
const ring = geom.coordinates?.[0]?.[0] || [];
if (!ring.length) return [null, null];
const lat = ring.reduce((s, p) => s + p[1], 0) / ring.length;
const lng = ring.reduce((s, p) => s + p[0], 0) / ring.length;
return [lat, lng];
}
return [null, null];
}
function normalizeEONET(data) {
const out = [];
for (const ev of data.events || []) {
const catId = ev.categories?.[0]?.id || 'other';
const geoms = ev.geometry || ev.geometries || [];
if (!geoms.length) continue;
// prefer latest Point, fall back to any geometry
const geom = geoms.slice().reverse().find(g => g.type === 'Point')
|| geoms[geoms.length - 1];
const [lat, lng] = geomToLatLng(geom);
// use date of the chosen geometry, fall back to first geometry date
const date = new Date(geom?.date || geoms[0]?.date || Date.now());
out.push({
id: ev.id,
title: ev.title,
catId,
date,
lat,
lng,
mag: geom?.magnitudeValue ?? null,
magUnit: geom?.magnitudeUnit ?? null,
place: null,
url: ev.link || null,
source: 'eonet',
});
}
return out;
}
function normalizeUSGS(data) {
return (data.features || []).map(f => {
const p = f.properties;
const [lng, lat] = f.geometry.coordinates;
return {
id: f.id,
title: p.title || `M${p.mag} Earthquake`,
catId: 'earthquakes',
date: new Date(p.time),
lat,
lng,
mag: p.mag,
place: p.place,
url: p.url || null,
source: 'usgs',
};
});
}
/* ── RENDER MAP MARKERS ────────────────────────────── */
const layerGroup = L.layerGroup().addTo(map);
function renderMarkers(events) {
layerGroup.clearLayers();
allMarkers = [];
for (const ev of events) {
if (!isFinite(ev.lat) || !isFinite(ev.lng)) continue;
const meta = getCatMeta(ev.catId);
// radius scaled by magnitude for earthquakes
let radius = 6;
if (ev.mag !== null) {
radius = Math.max(4, Math.min(20, ev.mag * 2.2));
}
const marker = L.circleMarker([ev.lat, ev.lng], {
radius,
color: meta.color,
fillColor: meta.color,
fillOpacity: 0.55,
weight: 1.5,
opacity: 0.9,
});
const dateStr = ev.date.toUTCString().replace(' GMT', ' UTC');
const magLine = ev.mag !== null
? `<span style="color:${meta.color};font-size:14px;font-family:'Bebas Neue',sans-serif;letter-spacing:1px;">M ${ev.mag.toFixed(1)}</span><br>` : '';
const placeLine = ev.place ? `<span style="color:#555;font-size:11px;">${ev.place}</span><br>` : '';
const srcParam = ev.source === 'usgs' ? 'usgs' : 'eonet';
const linkLine = `<a class="pop-link" href="/disasters/detail.html?id=${encodeURIComponent(ev.id)}&source=${srcParam}">details →</a>`;
marker.bindPopup(`
<div class="pop-type" style="color:${meta.color}">${meta.icon} ${meta.label}</div>
<strong>${ev.title}</strong>
${magLine}${placeLine}
<span class="pop-date">${dateStr}</span>
${linkLine}
`, { maxWidth: 280 });
marker._evData = ev;
marker.addTo(layerGroup);
allMarkers.push(marker);
}
}
/* ── RENDER LIST ───────────────────────────────────── */
function timeAgo(date) {
const diff = Date.now() - date.getTime();
const m = Math.floor(diff / 60000);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
function renderList(events) {
const list = document.getElementById('eventsList');
const countEl = document.getElementById('eventsCount');
countEl.textContent = `${events.length} events`;
if (!events.length) {
list.innerHTML = '<div class="events-empty">No events match the current filter.</div>';
return;
}
const html = events.map(ev => {
const meta = getCatMeta(ev.catId);
const magClass = ev.mag >= 7 ? 'big' : ev.mag >= 5.5 ? 'med' : '';
const magDisplay = ev.mag !== null
? `<div class="event-mag ${magClass}">${ev.catId === 'earthquakes' ? 'M' : ''}${Number(ev.mag).toFixed(1)}${ev.magUnit ? '<span style="font-size:11px;color:var(--muted);margin-left:2px;">' + ev.magUnit + '</span>' : ''}</div>`
: '';
return `
<div class="event-item" data-id="${ev.id}" title="${ev.title}">
<div class="event-icon" style="background:${meta.bg};border:1px solid ${meta.color}22;">
<span style="color:${meta.color}">${meta.icon}</span>
</div>
<div class="event-body">
<div class="event-title">${ev.title}</div>
<div class="event-meta">
<span class="event-type" style="color:${meta.color};border-color:${meta.color}44;background:${meta.bg};">${meta.label}</span>
${ev.place ? `<span class="event-place">${ev.place}</span>` : ''}
</div>
</div>
<div class="event-right">
<div class="event-date">${timeAgo(ev.date)}</div>
${magDisplay}
</div>
</div>
`;
}).join('');
list.innerHTML = html;
// Click to fly to on map
list.querySelectorAll('.event-item').forEach(row => {
row.addEventListener('click', () => {
const id = row.dataset.id;
const ev = allEvents.find(e => e.id === id);
if (!ev) return;
if (isFinite(ev.lat) && isFinite(ev.lng)) {
map.flyTo([ev.lat, ev.lng], Math.max(5, map.getZoom()), { duration: 1.2 });
const marker = allMarkers.find(m => m._evData?.id === id);
if (marker) setTimeout(() => marker.openPopup(), 1300);
}
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
}
/* ── FILTER ────────────────────────────────────────── */
const OTHER_CATS = ['landslides','seaLakeIce','drought','dustHaze','tempExtremes','manmade','waterColor','snow','fog','sand'];
function getFilteredEvents() {
if (activeFilter === 'all') return allEvents;
if (activeFilter === 'other') return allEvents.filter(e => OTHER_CATS.includes(e.catId) || e.catId === 'other');
return allEvents.filter(e => e.catId === activeFilter);
}
function applyFilter() {
const filtered = getFilteredEvents();
renderMarkers(filtered);
renderList(filtered);
}
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.dataset.cat;
applyFilter();
});
});
/* ── STATS ─────────────────────────────────────────── */
function updateStats(events) {
const eqCount = events.filter(e => e.catId === 'earthquakes').length;
document.getElementById('statTotal').textContent = events.length;
document.getElementById('statEq').textContent = eqCount;
document.getElementById('statUpdated').textContent =
'Updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
/* ── FETCH ─────────────────────────────────────────── */
async function loadData() {
const loadingEl = document.getElementById('mapLoading');
try {
const [eonetRes, usgsRes] = await Promise.allSettled([
fetch(EONET_URL).then(r => r.json()),
fetch(USGS_URL).then(r => r.json()),
]);
const eonetEvents = eonetRes.status === 'fulfilled' ? normalizeEONET(eonetRes.value) : [];
const usgsEvents = usgsRes.status === 'fulfilled' ? normalizeUSGS(usgsRes.value) : [];
// Merge: remove EONET earthquake duplicates (USGS is more detailed)
const eonetNonEq = eonetEvents.filter(e => e.catId !== 'earthquakes');
allEvents = [...eonetNonEq, ...usgsEvents]
.sort((a, b) => b.date - a.date);
loadingEl.classList.add('hidden');
updateStats(allEvents);
applyFilter();
} catch (err) {
loadingEl.innerHTML = '<span style="color:var(--error)">Failed to load events. Check your connection.</span>';
console.error(err);
}
}
loadData();
})();