Every GDACS-monitored disaster on Earth · last 90 days · one harmonized API
The Montandon project (IFRC) harmonizes the world’s disaster databases — GDACS, USGS, EM-DAT, GLIDE and more — into one STAC API where every event, hazard footprint and impact estimate shares a common schema and a correlation id. This notebook pulls a 90-day global snapshot of the GDACS collections and turns it into a custom fullscreen mission-control application, defined right here as an anywidget ESM module and driven by the analysis below.
What this page demonstrates:
723 GDACS event registrations, collapsed to the distinct events behind them, across six hazard types (earthquakes, floods, tropical cyclones, wildfires, droughts, volcanoes) from the
gdacs-eventscollection — ongoing situations that GDACS re-registers keep their worst alert + peak impactsGDACS alert levels + hazard footprints — flood, wildfire and drought (Multi)Polygons from
gdacs-hazardsTyped impact estimates (deaths, people affected, displaced, buildings damaged…) from
gdacs-impacts, latest advisory per eventNASA GIBS “Black Marble” nighttime-lights tiles as a population-glow base
A bespoke app shell (
situation_room/) — event log, weekly pulse chart, hazard filters and camera fly-tos — embedding alonboardmap, all fully interactive without a running kernel
Scroll to the card at the bottom and hit ⛶ — or open the app directly.
Requires a (free)
MONTANDON_API_TOKENin.envonly when refreshing the snapshot; this page renders from the cached snapshot pinned below.
# Loaders are cache-first (see monty_situation_data.py); the snapshot window is
# pinned in the cache so re-executions are reproducible.
from monty_situation_data import (
HAZARD_TYPES, IMPACT_LABELS,
load_events, load_footprints, load_impacts, snapshot_meta,
)
events = load_events()
footprints = load_footprints()
impacts = load_impacts()
meta_snap = snapshot_meta()
print(f"window: {meta_snap['window_start'][:10]} -> {meta_snap['window_end'][:10]}")
print(f"{len(events):,} events, {len(footprints):,} hazard footprints, {len(impacts):,} impact records")
events["type"].value_counts()window: 2026-05-12 -> 2026-08-10
723 events, 548 hazard footprints, 544 impact records
type
wf 248
dr 153
fl 147
eq 144
tc 30
vo 1
Name: count, dtype: int64Reading the snapshot¶
Three GDACS collections, tied together by monty:corr_id:
Events are Points with
severitydata(magnitude, wind speed, drought index…) — we keep the latest episode per event.Hazards repeat the events with a GDACS alert level (Green / Orange / Red) and, where GDACS computes one, a hazard footprint polygon.
Impacts are per-advisory records typed as
death,affected_total,relocated,damaged… — we keep the latest advisory per impact type.
Honesty notes baked into the app: potentially affected figures are model forecasts, not observations; and Green events are routine monitoring, not emergencies — the situation room ranks Orange first.
import pandas as pd
# Per-hazard-type palette (one hue per type, tuned for the dark UI).
TYPE_DEFS = [
(code, HAZARD_TYPES[code]["key"], HAZARD_TYPES[code]["label"], HAZARD_TYPES[code]["color"])
for code in ["GH0101", "MH0600", "MH0309", "EN0205", "MH0401", "GH0205"]
]
def hex_rgba(hex_color, alpha):
r, g, b = (int(hex_color[i : i + 2], 16) for i in (1, 3, 5))
return [r, g, b, alpha]
# Latest impact values pivoted to one row per event.
pivot = impacts.pivot_table(index="corr_id", columns="impact_type", values="value", aggfunc="last")
pivot = pivot.reindex(events["corr_id"]).reset_index()
# Headline "people affected": observed totals where reported, else the forecast.
affected_est = pivot["affected_total"].fillna(pivot["potentially_affected"]).fillna(0)
ev = events.reset_index(drop=True).copy()
ev["affected"] = affected_est.to_numpy()
ev["deaths"] = pivot["death"].fillna(0).to_numpy()
# GDACS re-registers ongoing situations under fresh ids — long-running
# droughts/wildfires every few weeks, tropical cyclones and floods per advisory.
# Collapse same-title/same-countries rows of those types into one event per
# TIME CLUSTER (a >30-day gap between registrations means a genuinely separate
# situation, e.g. two different floods in the same country) — keeping the latest
# position and timestamp but the WORST alert level and PEAK impact figures, so
# neither the event log nor the totals double-count. (Earthquakes/volcanoes are
# never collapsed: repeated titles there are genuinely distinct events.)
import geopandas as gpd
ALERT_RANK = {"Red": 0, "Orange": 1, "Green": 2}
episodic = ev["type"].isin(["dr", "wf", "tc", "fl"])
registrations = len(ev)
epi = ev[episodic].sort_values("datetime").copy()
keys = [epi["type"], epi["title"], epi["countries"]]
gap = epi.groupby(["type", "title", "countries"])["datetime"].diff() > pd.Timedelta(days=30)
epi["cluster"] = gap.groupby(keys).cumsum()
grp = epi.groupby(["type", "title", "countries", "cluster"], as_index=False)
collapsed = grp.last()
peaks = grp.agg(
alert=("alert", lambda s: min(s, key=lambda a: ALERT_RANK.get(a, 3))),
affected=("affected", "max"),
deaths=("deaths", "max"),
episodes=("corr_id", "count"),
)
collapsed[["alert", "affected", "deaths", "episodes"]] = peaks[["alert", "affected", "deaths", "episodes"]]
collapsed = gpd.GeoDataFrame(collapsed.drop(columns=["cluster"]), geometry="geometry", crs="EPSG:4326")
ev = pd.concat([ev[~episodic].assign(episodes=1), collapsed], ignore_index=True)
ev = gpd.GeoDataFrame(ev, geometry="geometry", crs="EPSG:4326")
print(f"collapsed {registrations - len(ev)} re-registrations of long-running events -> {len(ev)} distinct events")
pivot_d = pivot[pivot["corr_id"].isin(set(ev["corr_id"]))]
totals = {
"registrations": registrations,
"events": len(ev),
"orange": int((ev["alert"] == "Orange").sum()),
"affected": float(ev["affected"].sum()),
"deaths": float(ev["deaths"].sum()),
"displaced": float(pivot_d["relocated"].fillna(0).sum()),
"buildings": float(pivot_d[["damaged", "destroyed"]].fillna(0).sum().sum()),
"countries": int(pd.Series([c for cs in ev["countries"] for c in cs.split(",") if c and c != "UNK"]).nunique()),
}
print({k: (round(v) if isinstance(v, float) else v) for k, v in totals.items()})
# The ten highest-impact events of the window:
top = ev.sort_values("affected", ascending=False).head(10)
top[["title", "type", "alert", "datetime", "countries", "affected", "deaths"]].assign(
datetime=top["datetime"].dt.strftime("%d %b"), affected=top["affected"].astype(int)
).reset_index(drop=True)collapsed 511 re-registrations of long-running events -> 212 distinct events
{'registrations': 723, 'events': 212, 'orange': 8, 'affected': 1927701, 'deaths': 14, 'displaced': 3698, 'buildings': 438, 'countries': 93}
The map stack¶
Everything on the map is a lonboard layer over a dark Carto basemap:
Night lights — NASA GIBS VIIRS Black Marble as a
BitmapTileLayer(a quiet proxy for where people are).Hazard footprints — one
PolygonLayerper hazard type that has them (droughts, wildfires, floods), so the app can toggle each type’s visibility — the static-export-safe alternative to a category filter extension.Events — one
ScatterplotLayerper hazard type; Orange-alert events get a larger radius.
The camera starts on a whole-world view; the app drives it with fly-to
messages when you click an event.
import numpy as np
from lonboard import Map, PolygonLayer, ScatterplotLayer, BitmapTileLayer
from lonboard.basemap import CartoBasemap, MaplibreBasemap
from lonboard.controls import NavigationControl
GIBS_NIGHT = (
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_Black_Marble/"
"default/2016-01-01/GoogleMapsCompatible_Level8/{z}/{y}/{x}.png"
)
night_layer = BitmapTileLayer(
data=GIBS_NIGHT, tile_size=256, min_zoom=0, max_zoom=8,
extent=[-180, -85.05, 180, 85.05], opacity=0.85,
)
# Footprints: one layer per hazard type that has polygons. Droughts are huge,
# so they draw first (and faintest); floods draw last.
FOOTPRINT_ORDER = ["dr", "wf", "fl"]
FP_ALPHA = {"dr": 38, "wf": 70, "fl": 60}
footprint_layers, footprint_types = [], []
for key in FOOTPRINT_ORDER:
sub = footprints.loc[footprints["type"] == key, ["geometry"]].reset_index(drop=True)
if not len(sub):
continue
color = next(c for _, k, _, c in TYPE_DEFS if k == key)
footprint_layers.append(PolygonLayer.from_geopandas(
sub,
get_fill_color=hex_rgba(color, FP_ALPHA[key]),
get_line_color=hex_rgba(color, 150),
line_width_min_pixels=0.6,
))
footprint_types.append(key)
# Event points: one layer per hazard type (aligned with TYPE_DEFS order),
# radius scaled up for Orange alerts, gentle white rim for contrast.
point_layers = []
for _, key, _, color in TYPE_DEFS:
sub = ev.loc[ev["type"] == key].reset_index(drop=True)
radii = np.where(sub["alert"] == "Orange", 150_000, 55_000).astype("float32")
point_layers.append(ScatterplotLayer.from_geopandas(
sub[["geometry"]],
get_radius=radii,
radius_min_pixels=3.5,
radius_max_pixels=20,
get_fill_color=hex_rgba(color, 200),
stroked=True,
get_line_color=[255, 255, 255, 90],
line_width_min_pixels=1,
))
view0 = dict(longitude=15, latitude=14, zoom=1.5, pitch=0, bearing=0)
world = Map(
[night_layer, *footprint_layers, *point_layers],
basemap=MaplibreBasemap(style=CartoBasemap.DarkMatterNoLabels),
view_state=view0, height="100%", controls=[NavigationControl()],
)
print(f"map ready: {len(world.layers)} layers "
f"({len(footprint_layers)} footprint + {len(point_layers)} point + night lights)")map ready: 10 layers (3 footprint + 6 point + night lights)
A custom app in the fullscreen overlay¶
SituationRoom (in notebooks/situation_room/) is not built from manywidgets
components — it is a bespoke anywidget whose ESM module is a small web application:
its own mission-control chrome, stat tiles, a weekly pulse chart, hazard filters, a
scrollable event log with per-event impact details, and camera controls. The
lonboard map above is mounted inside it via the same reentrant renderChild
mechanism the built-in layout widgets use, and every control writes lonboard traits
or sends fly-to messages client-side — the static export needs no kernel.
The compact card below is the same widget in mode="teaser"; the ⛶ button (or the
?fullscreen=situation-room deep link) opens the full app.
from manywidgets import Fullscreen
from situation_room import SituationRoom
window_start = pd.Timestamp(meta_snap["window_start"])
window_end = pd.Timestamp(meta_snap["window_end"])
# Fly-to zoom: fit the footprint when the event has one, else a per-type default.
TYPE_ZOOM = {"eq": 6.0, "fl": 6.8, "tc": 4.6, "wf": 8.2, "dr": 4.6, "vo": 7.0}
fp_bounds = dict(zip(footprints["corr_id"], footprints.geometry.bounds.itertuples(index=False)))
def event_zoom(row):
b = fp_bounds.get(row["corr_id"])
if b is not None:
span = max((b.maxx - b.minx) * 1.15, (b.maxy - b.miny) * 2.3, 0.02)
return float(np.clip(np.log2(360 / span), 3.0, 9.0))
return TYPE_ZOOM.get(row["type"], 5.0)
IMPACT_ORDER = ["death", "missing", "injured", "affected_total", "potentially_affected",
"relocated", "assisted", "damaged", "destroyed"]
imp_by_event = {cid: g for cid, g in impacts.groupby("corr_id")}
def event_impacts(cid):
g = imp_by_event.get(cid)
if g is None:
return []
g = g.set_index("impact_type")
out = []
for t in IMPACT_ORDER:
if t in g.index:
rec = g.loc[t]
out.append({"label": IMPACT_LABELS.get(t, t), "value": float(rec["value"]),
"forecasted": bool(rec["forecasted"])})
return out
ALERT_RANK = {"Red": 0, "Orange": 1, "Green": 2}
ev_sorted = ev.assign(_rank=ev["alert"].map(ALERT_RANK)).sort_values(
["_rank", "affected", "datetime"], ascending=[True, False, False]
)
events_json = [
{
"id": r["corr_id"],
"title": r["title"],
"type": r["type"],
"alert": r["alert"],
"date": r["datetime"].strftime("%d %b" if r["datetime"].year == window_end.year else "%d %b %y"),
"iso": r["datetime"].isoformat(),
# -1 = long-running event registered before the window (matched on
# end_datetime); the app keeps it in the log but out of the weekly chart
"week": int(min((r["datetime"] - window_start).days // 7, 12)) if r["datetime"] >= window_start else -1,
"countries": r["countries"].replace(",", ", "),
# GDACS emits placeholder "Magnitude 0..." severity for most non-EQ types
"severity_text": (r["severity_text"] or "").strip()
if r["severity_text"] and not str(r["severity_text"]).startswith("Magnitude 0")
else "",
"lon": round(float(r["geometry"].x), 4),
"lat": round(float(r["geometry"].y), 4),
"zoom": round(event_zoom(r), 2),
"affected": float(r["affected"]),
"episodes": int(r["episodes"]),
"impacts": event_impacts(r["corr_id"]),
}
for _, r in ev_sorted.iterrows()
]
types_json = [
{"key": key, "label": label, "color": color,
"count": int((ev["type"] == key).sum()),
"affected": float(ev.loc[ev["type"] == key, "affected"].sum())}
for _, key, label, color in TYPE_DEFS
]
def compact(n):
n = float(n)
if n >= 1e6:
return f"{n / 1e6:.1f}M"
if n >= 1e3:
return f"{n / 1e3:.0f}k"
return f"{n:.0f}"
# Weekly activity pulse from the RAW registrations (the collapsed events would
# empty the middle weeks: chains carry their final registration date).
weekly = [dict() for _ in range(13)]
for _, r in events.iterrows():
if r["datetime"] < window_start:
continue
wi = min(int((r["datetime"] - window_start).days // 7), 12)
weekly[wi][r["type"]] = weekly[wi].get(r["type"], 0) + 1
meta = {
"weekly": weekly,
"weekly_title": "GDACS registrations / week",
"kicker": "Montandon · GDACS · live snapshot",
"title": "Global Disaster Situation Room",
"subtitle": "Every GDACS-monitored event of the last 90 days, harmonized by the IFRC Montandon STAC API",
"window_label": window_start.strftime("%d %b") + " – " + window_end.strftime("%d %b %Y") + " · UTC",
"window_start_label": window_start.strftime("%d %b"),
"window_end_label": window_end.strftime("%d %b"),
"attribution": "GDACS / IFRC Montandon · NASA GIBS Black Marble · © CARTO © OpenStreetMap",
"credits": [
{"label": "Montandon — the Global Crisis Data Bank (IFRC)", "url": "https://montandon.ifrc.org"},
{"label": "GDACS — Global Disaster Alert and Coordination System", "url": "https://www.gdacs.org"},
{"label": "NASA GIBS · VIIRS Black Marble", "url": "https://www.earthdata.nasa.gov/engage/open-data-services-software/earthdata-developer-portal/gibs-api"},
],
"note": "Impact figures are GDACS estimates; “potentially affected” values are model forecasts, not observations.",
}
stats = {"tiles": [
{"label": "distinct events", "value": f"{totals['events']:,}",
"sub": f"from {totals['registrations']} GDACS registrations"},
{"label": "Orange alerts", "value": f"{totals['orange']}", "color": "#ff9f1a"},
{"label": "people affected (est.)", "value": compact(totals["affected"])},
{"label": "deaths reported", "value": f"{totals['deaths']:,.0f}", "color": "#ff4d4d"},
{"label": "people displaced", "value": compact(totals["displaced"])},
{"label": "countries touched", "value": f"{totals['countries']}"},
]}
app = SituationRoom(
mode="app", meta=meta, types=types_json, stats=stats, events=events_json,
map=world, point_layers=point_layers,
footprint_layers=footprint_layers, footprint_types=footprint_types,
night_layer=night_layer,
)
teaser = SituationRoom(
mode="teaser", meta=meta,
stats={"tiles": [
{"label": "events in 90 days", "value": f"{totals['events']:,}"},
{"label": "Orange alerts", "value": f"{totals['orange']}", "color": "#ff9f1a"},
{"label": "people affected (est.)", "value": compact(totals["affected"])},
{"label": "countries", "value": f"{totals['countries']}"},
]},
)
fs = Fullscreen(
teaser, fullscreen=app, widget_id="situation-room",
style={
"--mw-panel-padding-x": "0px",
"--mw-panel-padding-y": "0px",
"--mw-color-overlay": "rgba(4, 7, 11, 0.94)",
"--mw-color-surface": "#0a0e14",
"--mw-control-max-width": "100%",
},
)
fsHow this works (and why it’s reproducible)¶
No kernel needed to view this. The page is a static export: every widget’s state (including the Arrow tables behind the lonboard layers) was captured when the notebook was executed, and all interactivity — filters, the event log, camera fly-tos, layer toggles — is client-side JavaScript talking to the serialized widget models.
One API, many databases. Everything except the basemap and night lights comes from a single authenticated STAC API — the Montandon Global Crisis Data Bank — using plain
pystac-clientsearches over three GDACS collections.Pinned snapshot. The 90-day window is frozen in
notebooks/data/monty_situation/meta.jsonwhen first fetched, so re-executing the notebook reproduces this exact page; delete the cache (and setMONTANDON_API_TOKENin.env) to refresh it to “now”.Re-run it yourself.
just setup && just execute && just preview.